diff --git a/.github/workflows/ci-go-functions.yaml b/.github/workflows/ci-go-functions.yaml index 44dc55f567073..c59291036388b 100644 --- a/.github/workflows/ci-go-functions.yaml +++ b/.github/workflows/ci-go-functions.yaml @@ -67,7 +67,7 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - go-version: ['1.24'] + go-version: ['1.25'] steps: - name: Check out code into the Go module directory @@ -85,7 +85,7 @@ jobs: - name: InstallTool run: | cd pulsar-function-go - wget -O - -q https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s v2.0.2 + wget -O - -q https://raw.githubusercontent.com/golangci/golangci-lint/main/install.sh| sh -s v2.12.2 ./bin/golangci-lint --version - name: Build diff --git a/.github/workflows/pulsar-ci.yaml b/.github/workflows/pulsar-ci.yaml index b142b2ea416c7..803ae9993b1a8 100644 --- a/.github/workflows/pulsar-ci.yaml +++ b/.github/workflows/pulsar-ci.yaml @@ -537,7 +537,7 @@ jobs: $GITHUB_WORKSPACE/build/pulsar_ci_tool.sh restore_tar_from_github_actions_artifacts pulsar-maven-repository-binaries - name: Set up QEMU - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 with: platforms: arm64 @@ -944,25 +944,26 @@ jobs: - name: Check binary licenses run: src/check-binary-license.sh ./distribution/server/target/apache-pulsar-*-bin.tar.gz && src/check-binary-license.sh ./distribution/shell/target/apache-pulsar-shell-*-bin.tar.gz -# - name: Run Trivy container scan -# id: trivy_scan -# uses: aquasecurity/trivy-action@v0.35.0 -# if: ${{ github.repository == 'apache/pulsar' && github.event_name != 'pull_request' }} -# continue-on-error: true -# with: -# image-ref: "apachepulsar/pulsar:latest" -# scanners: vuln -# severity: CRITICAL,HIGH,MEDIUM,LOW -# limit-severities-for-sarif: true -# format: 'sarif' -# output: 'trivy-results.sarif' -# -# - name: Upload Trivy scan results to GitHub Security tab -# uses: github/codeql-action/upload-sarif@v3 -# if: ${{ steps.trivy_scan.outcome == 'success' && github.repository == 'apache/pulsar' && github.event_name != 'pull_request' }} -# continue-on-error: true -# with: -# sarif_file: 'trivy-results.sarif' + - name: Run Trivy container scan + id: trivy_scan + uses: lhotari/sandboxed-trivy-action@f01374b6cc3bf7264ab238293e94f6db7ada6dd0 + if: ${{ github.repository == 'apache/pulsar' && github.event_name != 'pull_request' }} + continue-on-error: true + with: + scan-type: 'image' + scan-ref: "apachepulsar/pulsar:latest" + scanners: vuln + severity: CRITICAL,HIGH,MEDIUM,LOW + limit-severities-for-sarif: true + format: 'sarif' + output: 'trivy-results.sarif' + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v4 + if: ${{ steps.trivy_scan.outcome == 'success' && github.repository == 'apache/pulsar' && github.event_name != 'pull_request' }} + continue-on-error: true + with: + sarif_file: 'trivy-results.sarif' - name: Clean up disk space run: | diff --git a/bin/pulsar-admin-common.sh b/bin/pulsar-admin-common.sh index 366d76d7f5b98..ca341b3b5fc45 100755 --- a/bin/pulsar-admin-common.sh +++ b/bin/pulsar-admin-common.sh @@ -127,6 +127,14 @@ if [[ $JAVA_MAJOR_VERSION -ge 11 ]]; then OPTS="$OPTS --add-opens java.base/java.nio=ALL-UNNAMED --add-opens java.base/jdk.internal.misc=ALL-UNNAMED" fi +if [[ $JAVA_MAJOR_VERSION -ge 24 ]]; then + # Netty loads native libraries (epoll, io_uring, tcnative) via java.lang.System::loadLibrary, + # which is a restricted method from Java 24 onwards. Without this the JVM prints a warning to + # stderr on every invocation, and restricted methods will be blocked outright in a future + # release. bin/pulsar already sets this for the server side. + OPTS="$OPTS --enable-native-access=ALL-UNNAMED" +fi + OPTS="-cp $PULSAR_CLASSPATH $OPTS" OPTS="$OPTS $PULSAR_EXTRA_OPTS" diff --git a/bin/pulsar-perf b/bin/pulsar-perf index 2b120cc2f94af..0c61aa9de503a 100755 --- a/bin/pulsar-perf +++ b/bin/pulsar-perf @@ -124,6 +124,14 @@ if [[ $JAVA_MAJOR_VERSION -ge 11 ]]; then OPTS="$OPTS --add-opens java.base/java.nio=ALL-UNNAMED --add-opens java.base/jdk.internal.misc=ALL-UNNAMED" fi +if [[ $JAVA_MAJOR_VERSION -ge 24 ]]; then + # Netty loads native libraries (epoll, io_uring, tcnative) via java.lang.System::loadLibrary, + # which is a restricted method from Java 24 onwards. Without this the JVM prints a warning to + # stderr on every invocation, and restricted methods will be blocked outright in a future + # release. bin/pulsar already sets this for the server side. + OPTS="$OPTS --enable-native-access=ALL-UNNAMED" +fi + OPTS="-cp $PULSAR_CLASSPATH $OPTS" OPTS="$OPTS $PULSAR_EXTRA_OPTS" diff --git a/bouncy-castle/bc/pom.xml b/bouncy-castle/bc/pom.xml index cdbbbe5ddad9a..d75896224ca5e 100644 --- a/bouncy-castle/bc/pom.xml +++ b/bouncy-castle/bc/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar bouncy-castle-parent - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT bouncy-castle-bc diff --git a/bouncy-castle/bcfips-include-test/pom.xml b/bouncy-castle/bcfips-include-test/pom.xml index 0d1229f92074e..82f3336bdced7 100644 --- a/bouncy-castle/bcfips-include-test/pom.xml +++ b/bouncy-castle/bcfips-include-test/pom.xml @@ -24,7 +24,7 @@ com.ascentstream.pulsar bouncy-castle-parent - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT bcfips-include-test diff --git a/bouncy-castle/bcfips/pom.xml b/bouncy-castle/bcfips/pom.xml index 8900500cb92ba..c0b79f96e68f3 100644 --- a/bouncy-castle/bcfips/pom.xml +++ b/bouncy-castle/bcfips/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar bouncy-castle-parent - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT bouncy-castle-bcfips diff --git a/bouncy-castle/pom.xml b/bouncy-castle/pom.xml index 3fa28ee5f8006..f99317dcb9fd3 100644 --- a/bouncy-castle/pom.xml +++ b/bouncy-castle/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT diff --git a/buildtools/pom.xml b/buildtools/pom.xml index f083c563a6288..27bf5434da9c7 100644 --- a/buildtools/pom.xml +++ b/buildtools/pom.xml @@ -31,7 +31,7 @@ com.ascentstream.pulsar buildtools - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT jar Pulsar Build Tools diff --git a/buildtools/src/main/resources/log4j2.xml b/buildtools/src/main/resources/log4j2.xml index b0d01a734c518..62d98f208ebaf 100644 --- a/buildtools/src/main/resources/log4j2.xml +++ b/buildtools/src/main/resources/log4j2.xml @@ -22,7 +22,7 @@ - + diff --git a/conf/broker.conf b/conf/broker.conf index 503a7a0e11f5f..609656b7b986d 100644 --- a/conf/broker.conf +++ b/conf/broker.conf @@ -769,6 +769,13 @@ systemTopicSchemaCompatibilityStrategy=ALWAYS_COMPATIBLE # Please enable the system topic first. topicLevelPoliciesEnabled=true +# Amount of seconds to timeout initializing the topic policies cache of a namespace (reading the namespace's +# __change_events system topic to the end). Topic loading waits for this initialization, so if the system-topic +# reader gets stuck, this bounds the wait: the broker fails the initialization, closes the stuck reader and clears +# the cached state so loading the namespace's topics can be retried with a fresh reader instead of hanging until the +# broker is restarted. Set to 0 or a negative value to disable the timeout (not recommended). +topicPoliciesCacheInitTimeoutSeconds=60 + # If a topic remains fenced for this number of seconds, it will be closed forcefully. # If it is set to 0 or a negative number, the fenced topic will not be closed. topicFencingTimeoutSeconds=0 @@ -1372,15 +1379,9 @@ managedLedgerMaxUnackedRangesToPersist=10000 managedLedgerMaxBatchDeletedIndexToPersist=10000 # When storing acknowledgement state, choose a more compact serialization format that stores -# individual acknowledgements as a bitmap which is serialized to an array of long values. NOTE: This setting requires -# managedLedgerUnackedRangesOpenCacheSetEnabled=true to be effective. +# individual acknowledgements as a bitmap which is serialized to an array of long values. managedLedgerPersistIndividualAckAsLongArray=false -# When set to true, a BitSet will be used to track acknowledged messages that come after the "mark delete position" -# for each subscription. RoaringBitmap is used as a memory efficient BitSet implementation for the acknowledged -# messages tracking. Unacknowledged ranges are the message ranges excluding the acknowledged messages. -managedLedgerUnackedRangesOpenCacheSetEnabled=true - # Max number of "acknowledgment holes" that can be stored in MetadataStore. If number of unack message range is higher # than this limit then broker will persist unacked ranges into bookkeeper to avoid additional data overhead into # MetadataStore. @@ -1805,6 +1806,12 @@ metricsServletTimeoutMs=30000 # Enable or disable broker bundles metrics. The default value is false. exposeBundlesMetricsInPrometheus=false +# Enable computing the age of the oldest unacknowledged message for each subscription and exposing it +# through topic stats and Prometheus. +# When disabled, the broker skips computing per-subscription backlog age and the admin API field +# SubscriptionStats.oldestBacklogMessageAgeSeconds remains -1. Default is false. +exposeSubscriptionBacklogAgeInPrometheus=false + ### --- Functions --- ### # Enable Functions Worker Service in Broker @@ -2078,6 +2085,12 @@ dispatcherPauseOnAckStatePersistentEnabled=false # multiple entries. persistentUnackedRangesWithMultipleEntriesEnabled=false +# Enables per-msgLedger cursor checkpoint persistence. When true, the cursor writes a +# CursorCheckpoint per flush (mark-delete ledger's ack state inline + refs to other ledgers' +# previously-persisted ack states) instead of the legacy single PositionInfo entry. Eliminates +# write amplification and avoids ack truncation. +persistentUnackedRangesWithPerLedgerEntryEnabled=false + # Deprecated - Use managedLedgerCacheEvictionIntervalMs instead managedLedgerCacheEvictionFrequency=0 diff --git a/conf/functions_log4j2.xml b/conf/functions_log4j2.xml index fd4042e82e82f..364fa0c21cfb9 100644 --- a/conf/functions_log4j2.xml +++ b/conf/functions_log4j2.xml @@ -40,7 +40,7 @@ Console SYSTEM_OUT - %d{ISO8601_OFFSET_DATE_TIME_HHMM} [%t] %-5level %logger{36} - %msg%n + %d{ISO8601_OFFSET_DATE_TIME_HHMM} [%t] %-5level %logger{36} - %msg%equals{ %X}{ {}}{}%n @@ -49,7 +49,7 @@ ${sys:pulsar.function.log.dir}/${sys:pulsar.function.log.file}-%d{MM-dd-yyyy}-%i.log.gz true - %d{ISO8601_OFFSET_DATE_TIME_HHMM} [%t] %-5level %logger{36} - %msg%n + %d{ISO8601_OFFSET_DATE_TIME_HHMM} [%t] %-5level %logger{36} - %msg%equals{ %X}{ {}}{}%n @@ -82,7 +82,7 @@ ${sys:pulsar.function.log.dir}/${sys:pulsar.function.log.file}.bk-%d{MM-dd-yyyy}-%i.log.gz true - %d{ISO8601_OFFSET_DATE_TIME_HHMM} [%t] %-5level %logger{36} - %msg%n + %d{ISO8601_OFFSET_DATE_TIME_HHMM} [%t] %-5level %logger{36} - %msg%equals{ %X}{ {}}{}%n diff --git a/conf/functions_worker.yml b/conf/functions_worker.yml index 6f995576ebd64..794cfba1d519b 100644 --- a/conf/functions_worker.yml +++ b/conf/functions_worker.yml @@ -198,6 +198,9 @@ functionRuntimeFactoryConfigs: # # The Kubernetes pod name to run the function instances. It is set to # # `pf----` if this setting is left to be empty # jobName: +# # Optional domain suffix to use when the Function Worker constructs the gRPC address to connect to function instances. +# # If left blank, it defaults to `.svc.cluster.local`. Set this if your Function Worker is outside the cluster and connects via an external Gateway/Ingress. +# kubernetesServiceDomainSuffix: # # the docker image to run function instance. by default it is `apachepulsar/pulsar` # pulsarDockerImageName: # # the docker image to run function instance according to different configurations provided by users. diff --git a/conf/log4j2.yaml b/conf/log4j2.yaml index 2571c2d9ab9e1..6d28eaf5fdcbf 100644 --- a/conf/log4j2.yaml +++ b/conf/log4j2.yaml @@ -58,7 +58,7 @@ Configuration: - name: Console target: SYSTEM_OUT PatternLayout: - Pattern: "%d{ISO8601_OFFSET_DATE_TIME_HHMM} [%t] %-5level %logger{36} - %msg%n" + Pattern: "%d{ISO8601_OFFSET_DATE_TIME_HHMM} [%t] %-5level %logger{36} - %msg%equals{ %X}{ {}}{}%n" - name: ConsoleJson target: SYSTEM_OUT JsonTemplateLayout: @@ -71,7 +71,7 @@ Configuration: filePattern: "${sys:pulsar.log.dir}/${sys:pulsar.log.file}-%d{MM-dd-yyyy}-%i.log.gz" immediateFlush: ${sys:pulsar.log.immediateFlush} PatternLayout: - Pattern: "%d{ISO8601_OFFSET_DATE_TIME_HHMM} [%t] %-5level %logger{36} - %msg%n" + Pattern: "%d{ISO8601_OFFSET_DATE_TIME_HHMM} [%t] %-5level %logger{36} - %msg%equals{ %X}{ {}}{}%n" Policies: TimeBasedTriggeringPolicy: interval: 1 @@ -109,7 +109,7 @@ Configuration: fileName : "${sys:pulsar.log.dir}/functions/${ctx:function}/${ctx:functionname}-${ctx:instance}.log" filePattern : "${sys:pulsar.log.dir}/functions/${sys:pulsar.log.file}-${ctx:instance}-%d{MM-dd-yyyy}-%i.log.gz" PatternLayout: - Pattern: "%d{ABSOLUTE} %level{length=5} [%thread] [instance: %X{instance}] %logger{1} - %msg%n" + Pattern: "%d{ABSOLUTE} %level{length=5} [%thread] [instance:%X{instance}] %logger{1} - %msg%equals{ %X}{ {}}{}%n" Policies: TimeBasedTriggeringPolicy: interval: 1 diff --git a/conf/standalone.conf b/conf/standalone.conf index f9c7ccb658f30..87f7fa904eb90 100644 --- a/conf/standalone.conf +++ b/conf/standalone.conf @@ -579,6 +579,13 @@ systemTopicSchemaCompatibilityStrategy=ALWAYS_COMPATIBLE # Please enable the system topic first. topicLevelPoliciesEnabled=true +# Amount of seconds to timeout initializing the topic policies cache of a namespace (reading the namespace's +# __change_events system topic to the end). Topic loading waits for this initialization, so if the system-topic +# reader gets stuck, this bounds the wait: the broker fails the initialization, closes the stuck reader and clears +# the cached state so loading the namespace's topics can be retried with a fresh reader instead of hanging until the +# broker is restarted. Set to 0 or a negative value to disable the timeout (not recommended). +topicPoliciesCacheInitTimeoutSeconds=60 + # If a topic remains fenced for this number of seconds, it will be closed forcefully. # If it is set to 0 or a negative number, the fenced topic will not be closed. topicFencingTimeoutSeconds=0 @@ -868,15 +875,9 @@ managedLedgerMaxUnackedRangesToPersist=10000 managedLedgerMaxBatchDeletedIndexToPersist=10000 # When storing acknowledgement state, choose a more compact serialization format that stores -# individual acknowledgements as a bitmap which is serialized to an array of long values. NOTE: This setting requires -# managedLedgerUnackedRangesOpenCacheSetEnabled=true to be effective. +# individual acknowledgements as a bitmap which is serialized to an array of long values. managedLedgerPersistIndividualAckAsLongArray=false -# When set to true, a BitSet will be used to track acknowledged messages that come after the "mark delete position" -# for each subscription. RoaringBitmap is used as a memory efficient BitSet implementation for the acknowledged -# messages tracking. Unacknowledged ranges are the message ranges excluding the acknowledged messages. -managedLedgerUnackedRangesOpenCacheSetEnabled=true - # Max number of "acknowledgment holes" that can be stored in MetadataStore. If number of unack message range is higher # than this limit then broker will persist unacked ranges into bookkeeper to avoid additional data overhead into # MetadataStore. @@ -1116,6 +1117,12 @@ exposePublisherStats=true # Default is false. exposePreciseBacklogInPrometheus=false +# Enable computing the age of the oldest unacknowledged message for each subscription and exposing it +# through topic stats and Prometheus. +# When disabled, the broker skips computing per-subscription backlog age and the admin API field +# SubscriptionStats.oldestBacklogMessageAgeSeconds remains -1. Default is false. +exposeSubscriptionBacklogAgeInPrometheus=false + # Enable splitting topic and partition label in Prometheus. # If enabled, a topic name will split into 2 parts, one is topic name without partition index, # another one is partition index, e.g. (topic=xxx, partition=0). diff --git a/deployment/terraform-ansible/templates/broker.conf b/deployment/terraform-ansible/templates/broker.conf index 1ccfbacecb04a..51d5bbae0316c 100644 --- a/deployment/terraform-ansible/templates/broker.conf +++ b/deployment/terraform-ansible/templates/broker.conf @@ -1077,9 +1077,6 @@ managedLedgerOffloadMaxThreads=2 # Maximum prefetch rounds for ledger reading for offloading managedLedgerOffloadPrefetchRounds=1 -# Use Open Range-Set to cache unacked messages -managedLedgerUnackedRangesOpenCacheSetEnabled=true - # For Amazon S3 ledger offload, AWS region s3ManagedLedgerOffloadRegion= diff --git a/distribution/io/pom.xml b/distribution/io/pom.xml index 107e36ee622ef..e4ac7cda2e19b 100644 --- a/distribution/io/pom.xml +++ b/distribution/io/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar distribution - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-io-distribution diff --git a/distribution/offloaders/pom.xml b/distribution/offloaders/pom.xml index 980cbfe7b4bd1..66a7316867f91 100644 --- a/distribution/offloaders/pom.xml +++ b/distribution/offloaders/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar distribution - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-offloader-distribution diff --git a/distribution/pom.xml b/distribution/pom.xml index f44e2db01f654..dae2fb0469c94 100644 --- a/distribution/pom.xml +++ b/distribution/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT distribution diff --git a/distribution/server/pom.xml b/distribution/server/pom.xml index bfa929124e85a..0d88017b1bd59 100644 --- a/distribution/server/pom.xml +++ b/distribution/server/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar distribution - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-server-distribution @@ -93,12 +93,6 @@ ${project.version} - - jline - jline - ${jline.version} - - ${project.groupId} pulsar-package-bookkeeper-storage diff --git a/distribution/server/src/assemble/LICENSE.bin.txt b/distribution/server/src/assemble/LICENSE.bin.txt index 43051dc425da2..bd37b1f162a3e 100644 --- a/distribution/server/src/assemble/LICENSE.bin.txt +++ b/distribution/server/src/assemble/LICENSE.bin.txt @@ -245,23 +245,23 @@ This projects includes binary packages with the following licenses: The Apache Software License, Version 2.0 * JCommander -- com.beust-jcommander-1.82.jar * Picocli - - info.picocli-picocli-4.7.5.jar - - info.picocli-picocli-shell-jline3-4.7.5.jar + - info.picocli-picocli-4.7.7.jar + - info.picocli-picocli-shell-jline3-4.7.7.jar * High Performance Primitive Collections for Java -- com.carrotsearch-hppc-0.9.1.jar * Jackson - - com.fasterxml.jackson.core-jackson-annotations-2.18.6.jar - - com.fasterxml.jackson.core-jackson-core-2.18.6.jar - - com.fasterxml.jackson.core-jackson-databind-2.18.6.jar - - com.fasterxml.jackson.dataformat-jackson-dataformat-yaml-2.18.6.jar - - com.fasterxml.jackson.jaxrs-jackson-jaxrs-base-2.18.6.jar - - com.fasterxml.jackson.jaxrs-jackson-jaxrs-json-provider-2.18.6.jar - - com.fasterxml.jackson.module-jackson-module-jaxb-annotations-2.18.6.jar - - com.fasterxml.jackson.module-jackson-module-jsonSchema-2.18.6.jar - - com.fasterxml.jackson.datatype-jackson-datatype-jdk8-2.18.6.jar - - com.fasterxml.jackson.datatype-jackson-datatype-jsr310-2.18.6.jar - - com.fasterxml.jackson.module-jackson-module-parameter-names-2.18.6.jar + - com.fasterxml.jackson.core-jackson-annotations-2.18.10.jar + - com.fasterxml.jackson.core-jackson-core-2.18.10.jar + - com.fasterxml.jackson.core-jackson-databind-2.18.10.jar + - com.fasterxml.jackson.dataformat-jackson-dataformat-yaml-2.18.10.jar + - com.fasterxml.jackson.jaxrs-jackson-jaxrs-base-2.18.10.jar + - com.fasterxml.jackson.jaxrs-jackson-jaxrs-json-provider-2.18.10.jar + - com.fasterxml.jackson.module-jackson-module-jaxb-annotations-2.18.10.jar + - com.fasterxml.jackson.module-jackson-module-jsonSchema-2.18.10.jar + - com.fasterxml.jackson.datatype-jackson-datatype-jdk8-2.18.10.jar + - com.fasterxml.jackson.datatype-jackson-datatype-jsr310-2.18.10.jar + - com.fasterxml.jackson.module-jackson-module-parameter-names-2.18.10.jar * Caffeine -- com.github.ben-manes.caffeine-caffeine-2.9.1.jar - * Conscrypt -- org.conscrypt-conscrypt-openjdk-uber-2.5.2.jar + * Conscrypt -- org.conscrypt-conscrypt-openjdk-uber-2.6.2.jar * Fastutil -- it.unimi.dsi-fastutil-8.5.16.jar * Proto Google Common Protos -- com.google.api.grpc-proto-google-common-protos-2.59.2.jar * Bitbucket -- org.bitbucket.b_c-jose4j-0.9.6.jar @@ -278,48 +278,49 @@ The Apache Software License, Version 2.0 - io.swagger-swagger-annotations-1.6.2.jar - io.swagger-swagger-core-1.6.2.jar - io.swagger-swagger-models-1.6.2.jar + * slog -- io.github.merlimat.slog-slog-0.10.0.jar * DataSketches - com.yahoo.datasketches-memory-0.8.3.jar - com.yahoo.datasketches-sketches-core-0.8.3.jar * Apache Commons - commons-beanutils-commons-beanutils-1.11.0.jar - commons-cli-commons-cli-1.11.0.jar - - commons-codec-commons-codec-1.20.0.jar - - commons-io-commons-io-2.21.0.jar - - commons-logging-commons-logging-1.3.5.jar + - commons-codec-commons-codec-1.22.0.jar + - commons-io-commons-io-2.22.0.jar + - commons-logging-commons-logging-1.3.6.jar - org.apache.commons-commons-collections4-4.5.0.jar - org.apache.commons-commons-compress-1.28.0.jar - - org.apache.commons-commons-configuration2-2.12.0.jar + - org.apache.commons-commons-configuration2-2.15.1.jar - org.apache.commons-commons-lang3-3.19.0.jar - - org.apache.commons-commons-text-1.14.0.jar + - org.apache.commons-commons-text-1.15.0.jar * Netty - - io.netty-netty-buffer-4.1.133.Final.jar - - io.netty-netty-codec-4.1.133.Final.jar - - io.netty-netty-codec-dns-4.1.133.Final.jar - - io.netty-netty-codec-http-4.1.133.Final.jar - - io.netty-netty-codec-http2-4.1.133.Final.jar - - io.netty-netty-codec-socks-4.1.133.Final.jar - - io.netty-netty-codec-haproxy-4.1.133.Final.jar - - io.netty-netty-common-4.1.133.Final.jar - - io.netty-netty-handler-4.1.133.Final.jar - - io.netty-netty-handler-proxy-4.1.133.Final.jar - - io.netty-netty-resolver-4.1.133.Final.jar - - io.netty-netty-resolver-dns-4.1.133.Final.jar - - io.netty-netty-resolver-dns-classes-macos-4.1.133.Final.jar - - io.netty-netty-resolver-dns-native-macos-4.1.133.Final-osx-aarch_64.jar - - io.netty-netty-resolver-dns-native-macos-4.1.133.Final-osx-x86_64.jar - - io.netty-netty-transport-4.1.133.Final.jar - - io.netty-netty-transport-classes-epoll-4.1.133.Final.jar - - io.netty-netty-transport-native-epoll-4.1.133.Final-linux-aarch_64.jar - - io.netty-netty-transport-native-epoll-4.1.133.Final-linux-x86_64.jar - - io.netty-netty-transport-native-unix-common-4.1.133.Final.jar - - io.netty-netty-tcnative-boringssl-static-2.0.77.Final.jar - - io.netty-netty-tcnative-boringssl-static-2.0.77.Final-linux-aarch_64.jar - - io.netty-netty-tcnative-boringssl-static-2.0.77.Final-linux-x86_64.jar - - io.netty-netty-tcnative-boringssl-static-2.0.77.Final-osx-aarch_64.jar - - io.netty-netty-tcnative-boringssl-static-2.0.77.Final-osx-x86_64.jar - - io.netty-netty-tcnative-boringssl-static-2.0.77.Final-windows-x86_64.jar - - io.netty-netty-tcnative-classes-2.0.77.Final.jar + - io.netty-netty-buffer-4.1.137.Final.jar + - io.netty-netty-codec-4.1.137.Final.jar + - io.netty-netty-codec-dns-4.1.137.Final.jar + - io.netty-netty-codec-http-4.1.137.Final.jar + - io.netty-netty-codec-http2-4.1.137.Final.jar + - io.netty-netty-codec-socks-4.1.137.Final.jar + - io.netty-netty-codec-haproxy-4.1.137.Final.jar + - io.netty-netty-common-4.1.137.Final.jar + - io.netty-netty-handler-4.1.137.Final.jar + - io.netty-netty-handler-proxy-4.1.137.Final.jar + - io.netty-netty-resolver-4.1.137.Final.jar + - io.netty-netty-resolver-dns-4.1.137.Final.jar + - io.netty-netty-resolver-dns-classes-macos-4.1.137.Final.jar + - io.netty-netty-resolver-dns-native-macos-4.1.137.Final-osx-aarch_64.jar + - io.netty-netty-resolver-dns-native-macos-4.1.137.Final-osx-x86_64.jar + - io.netty-netty-transport-4.1.137.Final.jar + - io.netty-netty-transport-classes-epoll-4.1.137.Final.jar + - io.netty-netty-transport-native-epoll-4.1.137.Final-linux-aarch_64.jar + - io.netty-netty-transport-native-epoll-4.1.137.Final-linux-x86_64.jar + - io.netty-netty-transport-native-unix-common-4.1.137.Final.jar + - io.netty-netty-tcnative-boringssl-static-2.0.81.Final.jar + - io.netty-netty-tcnative-boringssl-static-2.0.81.Final-linux-aarch_64.jar + - io.netty-netty-tcnative-boringssl-static-2.0.81.Final-linux-x86_64.jar + - io.netty-netty-tcnative-boringssl-static-2.0.81.Final-osx-aarch_64.jar + - io.netty-netty-tcnative-boringssl-static-2.0.81.Final-osx-x86_64.jar + - io.netty-netty-tcnative-boringssl-static-2.0.81.Final-windows-x86_64.jar + - io.netty-netty-tcnative-classes-2.0.81.Final.jar - io.netty.incubator-netty-incubator-transport-classes-io_uring-0.0.26.Final.jar - io.netty.incubator-netty-incubator-transport-native-io_uring-0.0.26.Final-linux-x86_64.jar - io.netty.incubator-netty-incubator-transport-native-io_uring-0.0.26.Final-linux-aarch_64.jar @@ -337,21 +338,21 @@ The Apache Software License, Version 2.0 - io.prometheus-simpleclient_tracer_otel-0.16.0.jar - io.prometheus-simpleclient_tracer_otel_agent-0.16.0.jar * Prometheus exporter - - io.prometheus-prometheus-metrics-config-1.3.10.jar - - io.prometheus-prometheus-metrics-exporter-common-1.3.10.jar - - io.prometheus-prometheus-metrics-exporter-httpserver-1.3.10.jar - - io.prometheus-prometheus-metrics-exposition-formats-no-protobuf-1.3.10.jar - - io.prometheus-prometheus-metrics-model-1.3.10.jar - - io.prometheus-prometheus-metrics-exposition-textformats-1.3.10.jar + - io.prometheus-prometheus-metrics-config-1.5.1.jar + - io.prometheus-prometheus-metrics-exporter-common-1.5.1.jar + - io.prometheus-prometheus-metrics-exporter-httpserver-1.5.1.jar + - io.prometheus-prometheus-metrics-exposition-formats-no-protobuf-1.5.1.jar + - io.prometheus-prometheus-metrics-exposition-textformats-1.5.1.jar + - io.prometheus-prometheus-metrics-model-1.5.1.jar * Jakarta Bean Validation API - jakarta.validation-jakarta.validation-api-2.0.2.jar - javax.validation-validation-api-1.1.0.Final.jar * Log4J - - org.apache.logging.log4j-log4j-api-2.25.4.jar - - org.apache.logging.log4j-log4j-core-2.25.4.jar - - org.apache.logging.log4j-log4j-slf4j2-impl-2.25.4.jar - - org.apache.logging.log4j-log4j-web-2.25.4.jar - - org.apache.logging.log4j-log4j-layout-template-json-2.25.4.jar + - org.apache.logging.log4j-log4j-api-2.26.1.jar + - org.apache.logging.log4j-log4j-core-2.26.1.jar + - org.apache.logging.log4j-log4j-slf4j2-impl-2.26.1.jar + - org.apache.logging.log4j-log4j-web-2.26.1.jar + - org.apache.logging.log4j-log4j-layout-template-json-2.26.1.jar * Java Native Access JNA - net.java.dev.jna-jna-jpms-5.18.1.jar - net.java.dev.jna-jna-platform-jpms-5.18.1.jar @@ -382,53 +383,53 @@ The Apache Software License, Version 2.0 - com.ascentstream.bookkeeper-bookkeeper-slogger-api-4.17.4.0.jar - com.ascentstream.bookkeeper-bookkeeper-slogger-slf4j-4.17.4.0.jar - com.ascentstream.bookkeeper-native-io-4.17.4.0.jar - - at.yawk.lz4-lz4-java-1.10.3.jar + - at.yawk.lz4-lz4-java-1.11.1.jar * Apache HTTP Client - - org.apache.httpcomponents-httpclient-4.5.13.jar - - org.apache.httpcomponents-httpcore-4.4.15.jar + - org.apache.httpcomponents-httpclient-4.5.14.jar + - org.apache.httpcomponents-httpcore-4.4.16.jar * AirCompressor - io.airlift-aircompressor-2.0.3.jar * AsyncHttpClient - org.asynchttpclient-async-http-client-2.15.0.jar - org.asynchttpclient-async-http-client-netty-utils-2.15.0.jar * Jetty - - org.eclipse.jetty-jetty-alpn-client-12.1.9.jar - - org.eclipse.jetty-jetty-alpn-conscrypt-server-12.1.9.jar - - org.eclipse.jetty-jetty-alpn-server-12.1.9.jar - - org.eclipse.jetty-jetty-annotations-12.1.9.jar - - org.eclipse.jetty-jetty-client-12.1.9.jar - - org.eclipse.jetty-jetty-http-12.1.9.jar - - org.eclipse.jetty-jetty-io-12.1.9.jar - - org.eclipse.jetty-jetty-jndi-12.1.9.jar - - org.eclipse.jetty-jetty-plus-12.1.9.jar - - org.eclipse.jetty-jetty-security-12.1.9.jar - - org.eclipse.jetty-jetty-server-12.1.9.jar - - org.eclipse.jetty-jetty-session-12.1.9.jar - - org.eclipse.jetty-jetty-util-12.1.9.jar - - org.eclipse.jetty-jetty-xml-12.1.9.jar - - org.eclipse.jetty.compression-jetty-compression-common-12.1.9.jar - - org.eclipse.jetty.compression-jetty-compression-gzip-12.1.9.jar - - org.eclipse.jetty.compression-jetty-compression-server-12.1.9.jar - - org.eclipse.jetty.ee-jetty-ee-webapp-12.1.9.jar - - org.eclipse.jetty.ee8-jetty-ee8-annotations-12.1.9.jar - - org.eclipse.jetty.ee8-jetty-ee8-nested-12.1.9.jar - - org.eclipse.jetty.ee8-jetty-ee8-plus-12.1.9.jar - - org.eclipse.jetty.ee8-jetty-ee8-proxy-12.1.9.jar - - org.eclipse.jetty.ee8-jetty-ee8-security-12.1.9.jar - - org.eclipse.jetty.ee8-jetty-ee8-servlet-12.1.9.jar - - org.eclipse.jetty.ee8-jetty-ee8-servlets-12.1.9.jar - - org.eclipse.jetty.ee8-jetty-ee8-webapp-12.1.9.jar - - org.eclipse.jetty.ee8.websocket-jetty-ee8-websocket-jetty-api-12.1.9.jar - - org.eclipse.jetty.ee8.websocket-jetty-ee8-websocket-jetty-common-12.1.9.jar - - org.eclipse.jetty.ee8.websocket-jetty-ee8-websocket-jetty-server-12.1.9.jar - - org.eclipse.jetty.ee8.websocket-jetty-ee8-websocket-servlet-12.1.9.jar + - org.eclipse.jetty-jetty-alpn-client-12.1.12.jar + - org.eclipse.jetty-jetty-alpn-conscrypt-server-12.1.12.jar + - org.eclipse.jetty-jetty-alpn-server-12.1.12.jar + - org.eclipse.jetty-jetty-annotations-12.1.12.jar + - org.eclipse.jetty-jetty-client-12.1.12.jar + - org.eclipse.jetty-jetty-http-12.1.12.jar + - org.eclipse.jetty-jetty-io-12.1.12.jar + - org.eclipse.jetty-jetty-jndi-12.1.12.jar + - org.eclipse.jetty-jetty-plus-12.1.12.jar + - org.eclipse.jetty-jetty-security-12.1.12.jar + - org.eclipse.jetty-jetty-server-12.1.12.jar + - org.eclipse.jetty-jetty-session-12.1.12.jar + - org.eclipse.jetty-jetty-util-12.1.12.jar + - org.eclipse.jetty-jetty-xml-12.1.12.jar + - org.eclipse.jetty.compression-jetty-compression-common-12.1.12.jar + - org.eclipse.jetty.compression-jetty-compression-gzip-12.1.12.jar + - org.eclipse.jetty.compression-jetty-compression-server-12.1.12.jar + - org.eclipse.jetty.ee-jetty-ee-webapp-12.1.12.jar + - org.eclipse.jetty.ee8-jetty-ee8-annotations-12.1.12.jar + - org.eclipse.jetty.ee8-jetty-ee8-nested-12.1.12.jar + - org.eclipse.jetty.ee8-jetty-ee8-plus-12.1.12.jar + - org.eclipse.jetty.ee8-jetty-ee8-proxy-12.1.12.jar + - org.eclipse.jetty.ee8-jetty-ee8-security-12.1.12.jar + - org.eclipse.jetty.ee8-jetty-ee8-servlet-12.1.12.jar + - org.eclipse.jetty.ee8-jetty-ee8-servlets-12.1.12.jar + - org.eclipse.jetty.ee8-jetty-ee8-webapp-12.1.12.jar + - org.eclipse.jetty.ee8.websocket-jetty-ee8-websocket-jetty-api-12.1.12.jar + - org.eclipse.jetty.ee8.websocket-jetty-ee8-websocket-jetty-common-12.1.12.jar + - org.eclipse.jetty.ee8.websocket-jetty-ee8-websocket-jetty-server-12.1.12.jar + - org.eclipse.jetty.ee8.websocket-jetty-ee8-websocket-servlet-12.1.12.jar - org.eclipse.jetty.toolchain-jetty-servlet-api-4.0.9.jar - - org.eclipse.jetty.websocket-jetty-websocket-core-client-12.1.9.jar - - org.eclipse.jetty.websocket-jetty-websocket-core-common-12.1.9.jar - - org.eclipse.jetty.websocket-jetty-websocket-core-server-12.1.9.jar - - org.eclipse.jetty.websocket-jetty-websocket-jetty-api-12.1.9.jar - - org.eclipse.jetty.websocket-jetty-websocket-jetty-client-12.1.9.jar - - org.eclipse.jetty.websocket-jetty-websocket-jetty-common-12.1.9.jar + - org.eclipse.jetty.websocket-jetty-websocket-core-client-12.1.12.jar + - org.eclipse.jetty.websocket-jetty-websocket-core-common-12.1.12.jar + - org.eclipse.jetty.websocket-jetty-websocket-core-server-12.1.12.jar + - org.eclipse.jetty.websocket-jetty-websocket-jetty-api-12.1.12.jar + - org.eclipse.jetty.websocket-jetty-websocket-jetty-client-12.1.12.jar + - org.eclipse.jetty.websocket-jetty-websocket-jetty-common-12.1.12.jar * SnakeYaml -- org.yaml-snakeyaml-2.0.jar * RocksDB - org.rocksdb-rocksdbjni-7.9.2.jar * Google Error Prone Annotations - com.google.errorprone-error_prone_annotations-2.45.0.jar @@ -497,8 +498,8 @@ The Apache Software License, Version 2.0 * Prometheus - io.prometheus-simpleclient_httpserver-0.16.0.jar * Oxia - - io.github.oxia-db-oxia-client-api-0.7.2.jar - - io.github.oxia-db-oxia-client-0.7.2.jar + - io.github.oxia-db-oxia-client-api-0.9.4.jar + - io.github.oxia-db-oxia-client-0.9.4.jar * OpenHFT - net.openhft-zero-allocation-hashing-0.16.jar * Java JSON WebTokens @@ -508,11 +509,11 @@ The Apache Software License, Version 2.0 * JCTools - Java Concurrency Tools for the JVM - org.jctools-jctools-core-2.1.2.jar * Vertx - - io.vertx-vertx-auth-common-4.5.27.jar - - io.vertx-vertx-bridge-common-4.5.27.jar - - io.vertx-vertx-core-4.5.27.jar - - io.vertx-vertx-web-4.5.27.jar - - io.vertx-vertx-web-common-4.5.27.jar + - io.vertx-vertx-auth-common-4.5.28.jar + - io.vertx-vertx-bridge-common-4.5.28.jar + - io.vertx-vertx-core-4.5.28.jar + - io.vertx-vertx-web-4.5.28.jar + - io.vertx-vertx-web-common-4.5.28.jar * Apache ZooKeeper - org.apache.zookeeper-zookeeper-jute-3.9.5.jar * Snappy Java @@ -529,31 +530,30 @@ The Apache Software License, Version 2.0 - io.reactivex.rxjava3-rxjava-3.0.1.jar * RoaringBitmap - org.roaringbitmap-RoaringBitmap-1.6.9.jar - * OpenTelemetry - - io.opentelemetry-opentelemetry-api-1.56.0.jar - - io.opentelemetry-opentelemetry-api-incubator-1.56.0-alpha.jar - - io.opentelemetry-opentelemetry-common-1.56.0.jar - - io.opentelemetry-opentelemetry-context-1.56.0.jar - - io.opentelemetry-opentelemetry-exporter-common-1.56.0.jar - - io.opentelemetry-opentelemetry-exporter-otlp-1.56.0.jar - - io.opentelemetry-opentelemetry-exporter-otlp-common-1.56.0.jar - - io.opentelemetry-opentelemetry-exporter-prometheus-1.56.0-alpha.jar - - io.opentelemetry-opentelemetry-exporter-sender-okhttp-1.56.0.jar - - io.opentelemetry-opentelemetry-sdk-1.56.0.jar - - io.opentelemetry-opentelemetry-sdk-common-1.56.0.jar - - io.opentelemetry-opentelemetry-sdk-extension-autoconfigure-1.56.0.jar - - io.opentelemetry-opentelemetry-sdk-extension-autoconfigure-spi-1.56.0.jar - - io.opentelemetry-opentelemetry-sdk-logs-1.56.0.jar - - io.opentelemetry-opentelemetry-sdk-metrics-1.56.0.jar - - io.opentelemetry-opentelemetry-sdk-trace-1.56.0.jar - - io.opentelemetry.instrumentation-opentelemetry-instrumentation-api-2.21.0.jar - - io.opentelemetry.instrumentation-opentelemetry-instrumentation-api-incubator-2.21.0-alpha.jar - - io.opentelemetry.instrumentation-opentelemetry-resources-2.21.0-alpha.jar - - io.opentelemetry.instrumentation-opentelemetry-runtime-telemetry-java17-2.21.0-alpha.jar - - io.opentelemetry.instrumentation-opentelemetry-runtime-telemetry-java8-2.21.0-alpha.jar - - io.opentelemetry.semconv-opentelemetry-semconv-1.37.0.jar - - com.google.cloud.opentelemetry-detector-resources-support-0.36.0.jar - - io.opentelemetry.contrib-opentelemetry-gcp-resources-1.48.0-alpha.jar +* OpenTelemetry + - io.opentelemetry-opentelemetry-api-1.62.0.jar + - io.opentelemetry-opentelemetry-api-incubator-1.62.0-alpha.jar + - io.opentelemetry-opentelemetry-common-1.62.0.jar + - io.opentelemetry-opentelemetry-context-1.62.0.jar + - io.opentelemetry-opentelemetry-exporter-common-1.62.0.jar + - io.opentelemetry-opentelemetry-exporter-otlp-1.62.0.jar + - io.opentelemetry-opentelemetry-exporter-otlp-common-1.62.0.jar + - io.opentelemetry-opentelemetry-exporter-prometheus-1.62.0-alpha.jar + - io.opentelemetry-opentelemetry-exporter-sender-okhttp-1.62.0.jar + - io.opentelemetry-opentelemetry-sdk-1.62.0.jar + - io.opentelemetry-opentelemetry-sdk-common-1.62.0.jar + - io.opentelemetry-opentelemetry-sdk-extension-autoconfigure-1.62.0.jar + - io.opentelemetry-opentelemetry-sdk-extension-autoconfigure-spi-1.62.0.jar + - io.opentelemetry-opentelemetry-sdk-logs-1.62.0.jar + - io.opentelemetry-opentelemetry-sdk-metrics-1.62.0.jar + - io.opentelemetry-opentelemetry-sdk-trace-1.62.0.jar + - io.opentelemetry.instrumentation-opentelemetry-instrumentation-api-2.28.1.jar + - io.opentelemetry.instrumentation-opentelemetry-instrumentation-api-incubator-2.28.1-alpha.jar + - io.opentelemetry.instrumentation-opentelemetry-resources-2.28.1-alpha.jar + - io.opentelemetry.instrumentation-opentelemetry-runtime-telemetry-2.28.1-alpha.jar + - io.opentelemetry.semconv-opentelemetry-semconv-1.41.1.jar + - com.google.cloud.opentelemetry-detector-resources-support-0.36.0.jar + - io.opentelemetry.contrib-opentelemetry-gcp-resources-1.48.0-alpha.jar * Spotify completable-futures - com.spotify-completable-futures-0.3.6.jar * JSpecify @@ -565,12 +565,11 @@ BSD 3-clause "New" or "Revised" License - com.google.auth-google-auth-library-oauth2-http-1.24.1.jar -- ../licenses/LICENSE-google-auth-library.txt * LevelDB -- (included in org.rocksdb.*.jar) -- ../licenses/LICENSE-LevelDB.txt * JSR305 -- com.google.code.findbugs-jsr305-3.0.2.jar -- ../licenses/LICENSE-JSR305.txt - * JLine -- jline-jline-2.14.6.jar -- ../licenses/LICENSE-JLine.txt - * JLine3 -- org.jline-jline-3.21.0.jar -- ../licenses/LICENSE-JLine.txt + * JLine3 -- org.jline-jline-4.2.1.jar -- ../licenses/LICENSE-JLine.txt * OW2 ASM - - org.ow2.asm-asm-9.9.1.jar -- ../licenses/LICENSE-ASM.txt - - org.ow2.asm-asm-commons-9.9.1.jar -- ../licenses/LICENSE-ASM.txt - - org.ow2.asm-asm-tree-9.9.1.jar -- ../licenses/LICENSE-ASM.txt + - org.ow2.asm-asm-9.10.1.jar -- ../licenses/LICENSE-ASM.txt + - org.ow2.asm-asm-commons-9.10.1.jar -- ../licenses/LICENSE-ASM.txt + - org.ow2.asm-asm-tree-9.10.1.jar -- ../licenses/LICENSE-ASM.txt BSD 2-Clause License * HdrHistogram -- org.hdrhistogram-HdrHistogram-2.1.9.jar -- ../licenses/LICENSE-HdrHistogram.txt @@ -578,8 +577,8 @@ BSD 2-Clause License MIT License * Java SemVer -- com.github.zafarkhaja-java-semver-0.9.0.jar -- ../licenses/LICENSE-SemVer.txt * SLF4J -- ../licenses/LICENSE-SLF4J.txt - - org.slf4j-slf4j-api-2.0.17.jar - - org.slf4j-jcl-over-slf4j-2.0.17.jar + - org.slf4j-slf4j-api-2.0.18.jar + - org.slf4j-jcl-over-slf4j-2.0.18.jar * The Checker Framework - org.checkerframework-checker-qual-3.33.0.jar * oshi @@ -634,9 +633,9 @@ Creative Commons Attribution License Bouncy Castle License * Bouncy Castle -- ../licenses/LICENSE-bouncycastle.txt - - org.bouncycastle-bcpkix-jdk18on-1.84.jar - - org.bouncycastle-bcprov-jdk18on-1.84.jar - - org.bouncycastle-bcutil-jdk18on-1.84.jar + - org.bouncycastle-bcpkix-jdk18on-1.85.jar + - org.bouncycastle-bcprov-jdk18on-1.85.2.jar + - org.bouncycastle-bcutil-jdk18on-1.85.jar ------------------------ diff --git a/distribution/shell/pom.xml b/distribution/shell/pom.xml index c85e18e85e597..a4efb54528ba4 100644 --- a/distribution/shell/pom.xml +++ b/distribution/shell/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar distribution - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-shell-distribution diff --git a/distribution/shell/src/assemble/LICENSE.bin.txt b/distribution/shell/src/assemble/LICENSE.bin.txt index 534cd205b9e7d..5ee203142d095 100644 --- a/distribution/shell/src/assemble/LICENSE.bin.txt +++ b/distribution/shell/src/assemble/LICENSE.bin.txt @@ -310,22 +310,22 @@ This projects includes binary packages with the following licenses: The Apache Software License, Version 2.0 * Picocli - - picocli-4.7.5.jar - - picocli-shell-jline3-4.7.5.jar + - picocli-4.7.7.jar + - picocli-shell-jline3-4.7.7.jar * Jackson - - jackson-annotations-2.18.6.jar - - jackson-core-2.18.6.jar - - jackson-databind-2.18.6.jar - - jackson-dataformat-yaml-2.18.6.jar - - jackson-jaxrs-base-2.18.6.jar - - jackson-jaxrs-json-provider-2.18.6.jar - - jackson-module-jaxb-annotations-2.18.6.jar - - jackson-module-jsonSchema-2.18.6.jar - - jackson-datatype-jdk8-2.18.6.jar - - jackson-datatype-jsr310-2.18.6.jar - - jackson-module-parameter-names-2.18.6.jar + - jackson-annotations-2.18.10.jar + - jackson-core-2.18.10.jar + - jackson-databind-2.18.10.jar + - jackson-dataformat-yaml-2.18.10.jar + - jackson-jaxrs-base-2.18.10.jar + - jackson-jaxrs-json-provider-2.18.10.jar + - jackson-module-jaxb-annotations-2.18.10.jar + - jackson-module-jsonSchema-2.18.10.jar + - jackson-datatype-jdk8-2.18.10.jar + - jackson-datatype-jsr310-2.18.10.jar + - jackson-module-parameter-names-2.18.10.jar * Caffeine -- caffeine-2.9.1.jar - * Conscrypt -- conscrypt-openjdk-uber-2.5.2.jar + * Conscrypt -- conscrypt-openjdk-uber-2.6.2.jar * Gson - gson-2.13.2.jar * Guava @@ -339,41 +339,41 @@ The Apache Software License, Version 2.0 - memory-0.8.3.jar - sketches-core-0.8.3.jar * Apache Commons - - commons-codec-1.20.0.jar - - commons-io-2.21.0.jar + - commons-codec-1.22.0.jar + - commons-io-2.22.0.jar - commons-lang3-3.19.0.jar - - commons-text-1.14.0.jar + - commons-text-1.15.0.jar - commons-compress-1.28.0.jar * Netty - - netty-buffer-4.1.133.Final.jar - - netty-codec-4.1.133.Final.jar - - netty-codec-dns-4.1.133.Final.jar - - netty-codec-http-4.1.133.Final.jar - - netty-codec-socks-4.1.133.Final.jar - - netty-codec-haproxy-4.1.133.Final.jar - - netty-common-4.1.133.Final.jar - - netty-handler-4.1.133.Final.jar - - netty-handler-proxy-4.1.133.Final.jar - - netty-resolver-4.1.133.Final.jar - - netty-resolver-dns-4.1.133.Final.jar - - netty-transport-4.1.133.Final.jar - - netty-transport-classes-epoll-4.1.133.Final.jar - - netty-transport-native-epoll-4.1.133.Final-linux-aarch_64.jar - - netty-transport-native-epoll-4.1.133.Final-linux-x86_64.jar - - netty-transport-native-unix-common-4.1.133.Final.jar - - netty-tcnative-boringssl-static-2.0.77.Final.jar - - netty-tcnative-boringssl-static-2.0.77.Final-linux-aarch_64.jar - - netty-tcnative-boringssl-static-2.0.77.Final-linux-x86_64.jar - - netty-tcnative-boringssl-static-2.0.77.Final-osx-aarch_64.jar - - netty-tcnative-boringssl-static-2.0.77.Final-osx-x86_64.jar - - netty-tcnative-boringssl-static-2.0.77.Final-windows-x86_64.jar - - netty-tcnative-classes-2.0.77.Final.jar + - netty-buffer-4.1.137.Final.jar + - netty-codec-4.1.137.Final.jar + - netty-codec-dns-4.1.137.Final.jar + - netty-codec-http-4.1.137.Final.jar + - netty-codec-socks-4.1.137.Final.jar + - netty-codec-haproxy-4.1.137.Final.jar + - netty-common-4.1.137.Final.jar + - netty-handler-4.1.137.Final.jar + - netty-handler-proxy-4.1.137.Final.jar + - netty-resolver-4.1.137.Final.jar + - netty-resolver-dns-4.1.137.Final.jar + - netty-transport-4.1.137.Final.jar + - netty-transport-classes-epoll-4.1.137.Final.jar + - netty-transport-native-epoll-4.1.137.Final-linux-aarch_64.jar + - netty-transport-native-epoll-4.1.137.Final-linux-x86_64.jar + - netty-transport-native-unix-common-4.1.137.Final.jar + - netty-tcnative-boringssl-static-2.0.81.Final.jar + - netty-tcnative-boringssl-static-2.0.81.Final-linux-aarch_64.jar + - netty-tcnative-boringssl-static-2.0.81.Final-linux-x86_64.jar + - netty-tcnative-boringssl-static-2.0.81.Final-osx-aarch_64.jar + - netty-tcnative-boringssl-static-2.0.81.Final-osx-x86_64.jar + - netty-tcnative-boringssl-static-2.0.81.Final-windows-x86_64.jar + - netty-tcnative-classes-2.0.81.Final.jar - netty-incubator-transport-classes-io_uring-0.0.26.Final.jar - netty-incubator-transport-native-io_uring-0.0.26.Final-linux-aarch_64.jar - netty-incubator-transport-native-io_uring-0.0.26.Final-linux-x86_64.jar - - netty-resolver-dns-classes-macos-4.1.133.Final.jar - - netty-resolver-dns-native-macos-4.1.133.Final-osx-aarch_64.jar - - netty-resolver-dns-native-macos-4.1.133.Final-osx-x86_64.jar + - netty-resolver-dns-classes-macos-4.1.137.Final.jar + - netty-resolver-dns-native-macos-4.1.137.Final-osx-aarch_64.jar + - netty-resolver-dns-native-macos-4.1.137.Final-osx-x86_64.jar * Prometheus client - simpleclient-0.16.0.jar - simpleclient_log4j2-0.16.0.jar @@ -381,15 +381,15 @@ The Apache Software License, Version 2.0 - simpleclient_tracer_otel-0.16.0.jar - simpleclient_tracer_otel_agent-0.16.0.jar * Log4J - - log4j-api-2.25.4.jar - - log4j-core-2.25.4.jar - - log4j-slf4j2-impl-2.25.4.jar - - log4j-web-2.25.4.jar - * OpenTelemetry - - opentelemetry-api-1.56.0.jar - - opentelemetry-api-incubator-1.56.0-alpha.jar - - opentelemetry-common-1.56.0.jar - - opentelemetry-context-1.56.0.jar + - log4j-api-2.26.1.jar + - log4j-core-2.26.1.jar + - log4j-slf4j2-impl-2.26.1.jar + - log4j-web-2.26.1.jar +* OpenTelemetry + - opentelemetry-api-1.62.0.jar + - opentelemetry-api-incubator-1.62.0-alpha.jar + - opentelemetry-common-1.62.0.jar + - opentelemetry-context-1.62.0.jar * BookKeeper - bookkeeper-common-allocator-4.17.4.0.jar @@ -401,18 +401,18 @@ The Apache Software License, Version 2.0 - async-http-client-2.15.0.jar - async-http-client-netty-utils-2.15.0.jar * Jetty - - jetty-alpn-client-12.1.9.jar - - jetty-client-12.1.9.jar - - jetty-compression-common-12.1.9.jar - - jetty-compression-gzip-12.1.9.jar - - jetty-http-12.1.9.jar - - jetty-io-12.1.9.jar - - jetty-util-12.1.9.jar - - jetty-websocket-core-client-12.1.9.jar - - jetty-websocket-core-common-12.1.9.jar - - jetty-websocket-jetty-api-12.1.9.jar - - jetty-websocket-jetty-client-12.1.9.jar - - jetty-websocket-jetty-common-12.1.9.jar + - jetty-alpn-client-12.1.12.jar + - jetty-client-12.1.12.jar + - jetty-compression-common-12.1.12.jar + - jetty-compression-gzip-12.1.12.jar + - jetty-http-12.1.12.jar + - jetty-io-12.1.12.jar + - jetty-util-12.1.12.jar + - jetty-websocket-core-client-12.1.12.jar + - jetty-websocket-core-common-12.1.12.jar + - jetty-websocket-jetty-api-12.1.12.jar + - jetty-websocket-jetty-client-12.1.12.jar + - jetty-websocket-jetty-common-12.1.12.jar * SnakeYaml -- snakeyaml-2.0.jar * Google Error Prone Annotations - error_prone_annotations-2.45.0.jar * Javassist -- javassist-3.25.0-GA.jar @@ -426,12 +426,12 @@ The Apache Software License, Version 2.0 * JSpecify -- jspecify-1.0.0.jar BSD 3-clause "New" or "Revised" License + * JLine3 -- jline-4.2.1.jar -- ../licenses/LICENSE-JLine.txt * JSR305 -- jsr305-3.0.2.jar -- ../licenses/LICENSE-JSR305.txt - * JLine3 -- jline-3.21.0.jar -- ../licenses/LICENSE-JLine.txt MIT License * SLF4J -- ../licenses/LICENSE-SLF4J.txt - - slf4j-api-2.0.17.jar + - slf4j-api-2.0.18.jar * The Checker Framework - checker-qual-3.33.0.jar @@ -473,9 +473,9 @@ Creative Commons Attribution License Bouncy Castle License * Bouncy Castle -- ../licenses/LICENSE-bouncycastle.txt - - bcpkix-jdk18on-1.84.jar - - bcprov-jdk18on-1.84.jar - - bcutil-jdk18on-1.84.jar + - bcpkix-jdk18on-1.85.jar + - bcprov-jdk18on-1.85.2.jar + - bcutil-jdk18on-1.85.jar ------------------------ diff --git a/docker/kinesis-producer-alpine/Dockerfile b/docker/kinesis-producer-alpine/Dockerfile index a33dc82f15866..a33bde59eca56 100644 --- a/docker/kinesis-producer-alpine/Dockerfile +++ b/docker/kinesis-producer-alpine/Dockerfile @@ -17,7 +17,7 @@ # under the License. # -ARG ALPINE_VERSION=3.21 +ARG ALPINE_VERSION=3.24 # Builds an Alpine image with kinesis_producer compiled for Alpine Linux / musl diff --git a/docker/pom.xml b/docker/pom.xml index 5d49f46da16ca..4664b212858bc 100644 --- a/docker/pom.xml +++ b/docker/pom.xml @@ -26,7 +26,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT docker-images Apache Pulsar :: Docker Images diff --git a/docker/pulsar-all/pom.xml b/docker/pulsar-all/pom.xml index 415f3587b1c7d..c6b9e46eb7ed1 100644 --- a/docker/pulsar-all/pom.xml +++ b/docker/pulsar-all/pom.xml @@ -23,7 +23,7 @@ com.ascentstream.pulsar docker-images - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT 4.0.0 pulsar-all-docker-image diff --git a/docker/pulsar/Dockerfile b/docker/pulsar/Dockerfile index fc38c14bbbe1c..8a9d9e18dc841 100644 --- a/docker/pulsar/Dockerfile +++ b/docker/pulsar/Dockerfile @@ -17,7 +17,7 @@ # under the License. # -ARG ALPINE_VERSION=3.21 +ARG ALPINE_VERSION=3.24 ARG IMAGE_JDK_MAJOR_VERSION=21 # First create a stage with just the Pulsar tarball and scripts @@ -73,9 +73,9 @@ RUN echo networkaddress.cache.negative.ttl=1 >> /opt/jvm/conf/security/java.secu FROM alpine:$ALPINE_VERSION AS snappy-java ARG SNAPPY_VERSION -RUN apk add git alpine-sdk util-linux cmake autoconf automake libtool openjdk17 maven curl bash tar -ENV JAVA_HOME=/usr -RUN curl -Ls https://github.com/xerial/snappy-java/archive/refs/tags/v$SNAPPY_VERSION.tar.gz | tar zxf - && cd snappy-java-$SNAPPY_VERSION && make clean-native native +RUN apk add git alpine-sdk util-linux cmake autoconf automake libtool openjdk17 maven curl bash tar findutils +ENV JAVA_HOME=/usr/lib/jvm/default-jvm +RUN curl -Ls https://github.com/xerial/snappy-java/archive/refs/tags/v$SNAPPY_VERSION.tar.gz | tar zxf - && cd snappy-java-$SNAPPY_VERSION && CMAKE_POLICY_VERSION_MINIMUM=3.5 make clean-native native ## Create final stage from Alpine image @@ -112,7 +112,7 @@ ARG PULSAR_CLIENT_PYTHON_VERSION RUN pip3 install --break-system-packages --no-cache-dir \ --only-binary \ grpcio==1.78.0 \ - protobuf==6.33.5 \ + protobuf==6.33.6 \ pulsar-client[all]==${PULSAR_CLIENT_PYTHON_VERSION} \ kazoo diff --git a/docker/pulsar/pom.xml b/docker/pulsar/pom.xml index b8de1321d1384..0e60acc65299d 100644 --- a/docker/pulsar/pom.xml +++ b/docker/pulsar/pom.xml @@ -23,7 +23,7 @@ com.ascentstream.pulsar docker-images - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT 4.0.0 pulsar-docker-image diff --git a/jclouds-shaded/pom.xml b/jclouds-shaded/pom.xml index 21d58c059d4ba..dcebbc7a5f7ad 100644 --- a/jclouds-shaded/pom.xml +++ b/jclouds-shaded/pom.xml @@ -26,7 +26,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT jclouds-shaded diff --git a/jetcd-core-shaded/pom.xml b/jetcd-core-shaded/pom.xml index faff391d4edde..c45644f5d7c25 100644 --- a/jetcd-core-shaded/pom.xml +++ b/jetcd-core-shaded/pom.xml @@ -26,7 +26,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT jetcd-core-shaded diff --git a/jetty-upgrade/bookkeeper-prometheus-metrics-provider/pom.xml b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/pom.xml index b224c7545e2d1..6336d6c27f56a 100644 --- a/jetty-upgrade/bookkeeper-prometheus-metrics-provider/pom.xml +++ b/jetty-upgrade/bookkeeper-prometheus-metrics-provider/pom.xml @@ -24,7 +24,7 @@ com.ascentstream.pulsar jetty-upgrade - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-bookkeeper-prometheus-metrics-provider Apache Pulsar :: BookKeeper Stats Providers :: Prometheus diff --git a/jetty-upgrade/pom.xml b/jetty-upgrade/pom.xml index 7e05cfa623486..c5e190ff76cf9 100644 --- a/jetty-upgrade/pom.xml +++ b/jetty-upgrade/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT jetty-upgrade diff --git a/jetty-upgrade/zookeeper-prometheus-metrics/pom.xml b/jetty-upgrade/zookeeper-prometheus-metrics/pom.xml index 8805d5fd5bef8..15c6a99722448 100755 --- a/jetty-upgrade/zookeeper-prometheus-metrics/pom.xml +++ b/jetty-upgrade/zookeeper-prometheus-metrics/pom.xml @@ -24,7 +24,7 @@ com.ascentstream.pulsar jetty-upgrade - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-zookeeper-prometheus-metrics diff --git a/jetty-upgrade/zookeeper-with-patched-admin/pom.xml b/jetty-upgrade/zookeeper-with-patched-admin/pom.xml index 303675f4db533..81c0f1867674d 100644 --- a/jetty-upgrade/zookeeper-with-patched-admin/pom.xml +++ b/jetty-upgrade/zookeeper-with-patched-admin/pom.xml @@ -26,7 +26,7 @@ com.ascentstream.pulsar jetty-upgrade - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT zookeeper-with-patched-admin diff --git a/lombok.config b/lombok.config index 1be7aa1e6bc18..19d2ae53117a4 100644 --- a/lombok.config +++ b/lombok.config @@ -21,5 +21,5 @@ # see https://projectlombok.org/features/configuration for reference config.stopBubbling = true - +lombok.log.custom.declaration = io.github.merlimat.slog.Logger io.github.merlimat.slog.Logger.get(TYPE) diff --git a/managed-ledger/pom.xml b/managed-ledger/pom.xml index 3efff29513200..7fb47c6d9e220 100644 --- a/managed-ledger/pom.xml +++ b/managed-ledger/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT managed-ledger @@ -48,6 +48,11 @@ protobuf-java + + it.unimi.dsi + fastutil + + ${project.groupId} pulsar-common @@ -102,6 +107,14 @@ org.roaringbitmap RoaringBitmap + + it.unimi.dsi + fastutil + + + io.github.merlimat.slog + slog + io.dropwizard.metrics metrics-core diff --git a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/ManagedCursor.java b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/ManagedCursor.java index 4e5e12365480c..659b162a7658c 100644 --- a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/ManagedCursor.java +++ b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/ManagedCursor.java @@ -18,12 +18,14 @@ */ package org.apache.bookkeeper.mledger; +import static org.apache.pulsar.common.util.Runnables.catchingAndLoggingThrowables; import com.google.common.collect.Range; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; import java.util.function.Predicate; import org.apache.bookkeeper.common.annotation.InterfaceAudience; import org.apache.bookkeeper.common.annotation.InterfaceStability; @@ -369,6 +371,25 @@ default void asyncReadEntriesWithSkipOrWait(int maxEntries, long maxSizeBytes, R */ long getNumberOfEntriesInBacklog(boolean isPrecise); + /** + * Return whether this cursor has non-deleted messages in backlog. + * + * @return true if there is at least one entry in backlog + */ + default boolean hasBacklog() { + return hasBacklog(true); + } + + /** + * Return whether this cursor has non-deleted messages in backlog. + * + * @param isPrecise set to true to get a precise backlog check + * @return true if there is at least one entry in backlog + */ + default boolean hasBacklog(boolean isPrecise) { + return getNumberOfEntriesInBacklog(isPrecise) > 0; + } + /** * This signals that the reader is done with all the entries up to "position" (included). This can potentially * trigger a ledger deletion, if all the other cursors are done too with the underlying ledger. @@ -860,6 +881,20 @@ default void skipNonRecoverableLedger(long ledgerId){} */ ManagedLedger getManagedLedger(); + /** + * Schedule a continuation of a read callback. + * + *

Implementations that deliver read callbacks on a dedicated execution context should override this method + * to run the continuation on that same execution context. + * + * @param callback the callback continuation + * @param delay the delay before executing the continuation + * @param unit the time unit of the delay + */ + default void scheduleReadCallback(Runnable callback, long delay, TimeUnit unit) { + CompletableFuture.delayedExecutor(delay, unit).execute(catchingAndLoggingThrowables(callback)); + } + /** * Get last individual deleted range. * @return range diff --git a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/ManagedCursorMXBean.java b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/ManagedCursorMXBean.java index 7402bd65f793e..dc04fc4923429 100644 --- a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/ManagedCursorMXBean.java +++ b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/ManagedCursorMXBean.java @@ -100,4 +100,66 @@ public interface ManagedCursorMXBean { */ long getReadCursorLedgerSize(); + /** + * Record an acknowledgment operation and its processing latency. + * + * @param latencyMillis acknowledgment processing latency in milliseconds + */ + void recordAck(long latencyMillis); + + /** + * @return the number of acknowledgment operations + */ + long getAckCount(); + + /** + * @return the average acknowledgment processing latency in milliseconds + */ + double getAckLatencyAvgMillis(); + + /** + * Record a cursor persist (checkpoint write to the cursor ledger) operation and its latency. + * + * @param latencyMillis persist latency in milliseconds + */ + void recordPersist(long latencyMillis); + + /** + * @return the number of cursor persist operations + */ + long getPersistCount(); + + /** + * @return the average cursor persist latency in milliseconds + */ + double getPersistLatencyAvgMillis(); + + /** + * Record a cursor recovery operation and its latency. + * + * @param latencyMillis recovery latency in milliseconds + * @param success whether the recovery completed successfully + */ + void recordRecover(long latencyMillis, boolean success); + + /** + * @return the number of cursor recovery operations + */ + long getRecoverCount(); + + /** + * @return the number of successful cursor recovery operations + */ + long getRecoverSucceed(); + + /** + * @return the number of failed cursor recovery operations + */ + long getRecoverErrors(); + + /** + * @return the average cursor recovery latency in milliseconds + */ + double getRecoverLatencyAvgMillis(); + } diff --git a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/ManagedLedgerConfig.java b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/ManagedLedgerConfig.java index aaa127973a5ec..eb3d4844d6d48 100644 --- a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/ManagedLedgerConfig.java +++ b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/ManagedLedgerConfig.java @@ -33,7 +33,6 @@ import org.apache.bookkeeper.mledger.impl.NullLedgerOffloader; import org.apache.bookkeeper.mledger.intercept.ManagedLedgerInterceptor; import org.apache.commons.collections4.MapUtils; -import org.apache.pulsar.common.util.collections.OpenLongPairRangeSet; /** * Configuration class for a ManagedLedger. @@ -46,6 +45,22 @@ public class ManagedLedgerConfig { private int maxUnackedRangesToPersist = 10000; private int maxBatchDeletedIndexToPersist = 10000; private boolean persistentUnackedRangesWithMultipleEntriesEnabled = false; + /** + * Enables per-msgLedger cursor checkpoint persistence. When true, the cursor + * writes one {@code CursorCheckpoint} per flush (with the mark-delete ledger's ack state + * inline and refs to other ledgers' previously-persisted ack states) instead of the + * legacy single-{@code PositionInfo} entry. + * + *

See {@code CursorLogEntry}, {@code CursorCheckpoint}, {@code AckState}, and + * {@code AckStateRef} in {@code MLDataFormats.proto} for the wire format. + */ + private boolean persistentUnackedRangesWithPerLedgerEntryEnabled = false; + /** + * Maximum serialized size for a single {@code CursorLogEntry}. Entries exceeding + * this are transparently chunked. Not exposed as a broker config — defaults to the same + * 5 MB as {@code maxMessageSize} so chunk behavior mirrors the message-size limit. + */ + private int persistentUnackedRangesMaxEntrySize = 5 * 1024 * 1024; private boolean deletionAtBatchIndexLevelEnabled = true; private int maxUnackedRangesToPersistInMetadataStore = 1000; private int maxEntriesPerLedger = 50000; @@ -71,7 +86,6 @@ public class ManagedLedgerConfig { private long addEntryTimeoutSeconds = 120; private DigestType digestType = DigestType.CRC32C; private byte[] password = "".getBytes(StandardCharsets.UTF_8); - private boolean unackedRangesOpenCacheSetEnabled = true; private Class bookKeeperEnsemblePlacementPolicyClassName; private Map bookKeeperEnsemblePlacementPolicyProperties; private LedgerOffloader ledgerOffloader = NullLedgerOffloader.INSTANCE; @@ -292,19 +306,6 @@ public ManagedLedgerConfig setPassword(String password) { return this; } - /** - * should use {@link OpenLongPairRangeSet} to store unacked ranges. - * @return - */ - public boolean isUnackedRangesOpenCacheSetEnabled() { - return unackedRangesOpenCacheSetEnabled; - } - - public ManagedLedgerConfig setUnackedRangesOpenCacheSetEnabled(boolean unackedRangesOpenCacheSetEnabled) { - this.unackedRangesOpenCacheSetEnabled = unackedRangesOpenCacheSetEnabled; - return this; - } - /** * @return the metadataEnsemblesize */ @@ -520,6 +521,22 @@ public void setPersistentUnackedRangesWithMultipleEntriesEnabled(boolean multipl this.persistentUnackedRangesWithMultipleEntriesEnabled = multipleEntriesEnabled; } + public boolean isPersistentUnackedRangesWithPerLedgerEntryEnabled() { + return persistentUnackedRangesWithPerLedgerEntryEnabled; + } + + public void setPersistentUnackedRangesWithPerLedgerEntryEnabled(boolean enabled) { + this.persistentUnackedRangesWithPerLedgerEntryEnabled = enabled; + } + + public int getPersistentUnackedRangesMaxEntrySize() { + return persistentUnackedRangesMaxEntrySize; + } + + public void setPersistentUnackedRangesMaxEntrySize(int maxEntrySize) { + this.persistentUnackedRangesMaxEntrySize = maxEntrySize; + } + /** * @param maxUnackedRangesToPersist * max unacked message ranges that will be persisted and receverd. diff --git a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/AutomaticOffloadTriggerController.java b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/AutomaticOffloadTriggerController.java new file mode 100644 index 0000000000000..35f5a26706371 --- /dev/null +++ b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/AutomaticOffloadTriggerController.java @@ -0,0 +1,86 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.bookkeeper.mledger.impl; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Coalesces repeated automatic offload triggers into at most one active run and one follow-up run. + */ +final class AutomaticOffloadTriggerController { + private static final int IDLE = 0; + private static final int RUNNING = 1; + private static final int RUNNING_WITH_PENDING_TRIGGER = 2; + + private final AtomicInteger state = new AtomicInteger(IDLE); + + /** + * Records an automatic offload trigger. + * + * @return true when the caller must start a new automatic offload run + */ + boolean requestRun() { + while (true) { + int current = state.get(); + switch (current) { + case IDLE: + if (state.compareAndSet(IDLE, RUNNING)) { + return true; + } + break; + case RUNNING: + if (state.compareAndSet(RUNNING, RUNNING_WITH_PENDING_TRIGGER)) { + return false; + } + break; + case RUNNING_WITH_PENDING_TRIGGER: + return false; + default: + throw new IllegalStateException("Unknown automatic offload trigger state: " + current); + } + } + } + + /** + * Records completion of the current automatic offload run. + * + * @return true when the caller must immediately start one coalesced follow-up run + */ + boolean completeRun() { + while (true) { + int current = state.get(); + switch (current) { + case IDLE: + return false; + case RUNNING: + if (state.compareAndSet(RUNNING, IDLE)) { + return false; + } + break; + case RUNNING_WITH_PENDING_TRIGGER: + if (state.compareAndSet(RUNNING_WITH_PENDING_TRIGGER, RUNNING)) { + return true; + } + break; + default: + throw new IllegalStateException("Unknown automatic offload trigger state: " + current); + } + } + } +} diff --git a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/CursorCheckpointLog.java b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/CursorCheckpointLog.java new file mode 100644 index 0000000000000..fdaedfbcf150f --- /dev/null +++ b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/CursorCheckpointLog.java @@ -0,0 +1,416 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.bookkeeper.mledger.impl; + +import com.google.protobuf.ByteString; +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import lombok.extern.slf4j.Slf4j; +import org.apache.bookkeeper.client.BKException; +import org.apache.bookkeeper.client.LedgerHandle; +import org.apache.bookkeeper.mledger.ManagedLedgerException; +import org.apache.bookkeeper.mledger.proto.MLDataFormats.CursorCheckpoint; +import org.apache.bookkeeper.mledger.proto.MLDataFormats.CursorCheckpointChunk; +import org.apache.bookkeeper.mledger.proto.MLDataFormats.CursorLogEntry; +import org.apache.bookkeeper.mledger.proto.MLDataFormats.PositionInfo; +import org.apache.pulsar.common.util.FutureUtil; + +@Slf4j +class CursorCheckpointLog { + + private static final long MAX_SCAN_BACK = 1000L; + + private final int maxEntrySize; + private final int chunkEnvelopeOverhead; + private volatile long zkCheckpointLedgerId = -1; + private volatile long zkCheckpointEntryId = -1; + + CursorCheckpointLog(int maxEntrySize) { + if (maxEntrySize < 1024) { + throw new IllegalArgumentException("maxEntrySize must be at least 1024 bytes"); + } + this.maxEntrySize = maxEntrySize; + CursorLogEntry probe = CursorLogEntry.newBuilder() + .setCheckpointChunk(CursorCheckpointChunk.newBuilder() + .setPartIndex(0).setPartCount(1) + .setCheckpointBytes(ByteString.copyFrom(new byte[0])) + .build()) + .build(); + this.chunkEnvelopeOverhead = probe.toByteArray().length + 8; + } + + void setZkCheckpointHint(long cursorLedgerId, long entryId) { + this.zkCheckpointLedgerId = cursorLedgerId; + this.zkCheckpointEntryId = entryId; + } + + CompletableFuture appendCheckpoint(LedgerHandle lh, CursorCheckpoint checkpoint) { + byte[] checkpointBytes = checkpoint.toByteArray(); + CursorLogEntry envelope; + try { + envelope = CursorLogEntry.newBuilder() + .setCheckpoint(CursorCheckpoint.parseFrom(checkpointBytes)) + .build(); + } catch (Exception e) { + return FutureUtil.failedFuture(new ManagedLedgerException("Failed to parse checkpoint", e)); + } + byte[] data = envelope.toByteArray(); + if (data.length <= maxEntrySize) { + return addEntry(lh, data).thenApply(entryId -> { + log.debug("Appended checkpoint, ledgerId: {}, entryId: {}, size: {}", + lh.getId(), entryId, data.length); + return new AppendResult(data.length, entryId); + }); + } + int maxPayloadSize = maxEntrySize - chunkEnvelopeOverhead; + int partCount = (checkpointBytes.length + maxPayloadSize - 1) / maxPayloadSize; + log.debug("Appending chunked checkpoint, ledgerId: {}, checkpointBytes: {}, partCount: {}", + lh.getId(), checkpointBytes.length, partCount); + List> futures = new ArrayList<>(partCount); + int offset = 0; + for (int i = 0; i < partCount; i++) { + int length = Math.min(maxPayloadSize, checkpointBytes.length - offset); + byte[] payload = new byte[length]; + System.arraycopy(checkpointBytes, offset, payload, 0, length); + offset += length; + CursorLogEntry part = CursorLogEntry.newBuilder() + .setCheckpointChunk(CursorCheckpointChunk.newBuilder() + .setPartIndex(i).setPartCount(partCount) + .setCheckpointBytes(ByteString.copyFrom(payload)) + .build()) + .build(); + futures.add(addEntry(lh, part.toByteArray())); + } + CompletableFuture lastPartFuture = futures.get(partCount - 1); + return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .thenCompose(ignored -> lastPartFuture + .thenApply(lastEntryId -> { + log.debug("Appended chunked checkpoint, ledgerId: {}, lastEntryId: {}, " + + "partCount: {}, totalBytes: {}", + lh.getId(), lastEntryId, partCount, checkpointBytes.length); + return new AppendResult(checkpointBytes.length, lastEntryId); + })); + } + + CompletableFuture readLatest(LedgerHandle lh) { + long last = lh.getLastAddConfirmed(); + if (last < 0) { + return FutureUtil.failedFuture(new ManagedLedgerException( + "Cursor ledger " + lh.getId() + " has no entries")); + } + log.debug("Recovering checkpoint, ledgerId: {}, lastEntryId: {}", lh.getId(), last); + return recoverEntryAt(lh, last, true).thenCompose(decision -> { + if (decision.shouldScanBack) { + log.info("Scanning back for last complete checkpoint, ledgerId: {}, entryId: {}", + lh.getId(), last); + return scanBack(lh, last); + } + return CompletableFuture.completedFuture(decision.state); + }); + } + + /** + * Recovers the checkpoint stored at a specific entry, assembling chunked checkpoints when + * needed. Unlike {@link #readLatest}, this never scans back: the target of an ack state ref + * must be a complete checkpoint at exactly the given entry, so anything else fails fast. + */ + CompletableFuture readAt(LedgerHandle lh, long entryId) { + return recoverEntryAt(lh, entryId, false).thenCompose(decision -> { + if (decision.shouldScanBack) { + return FutureUtil.failedFuture(new ManagedLedgerException( + "No complete checkpoint at entry " + entryId + " in ledger " + lh.getId())); + } + return CompletableFuture.completedFuture(decision.state); + }); + } + + /** + * Reads and decodes the entry at {@code entryId}. When {@code fallbackToScanBack} is set, + * decode failures yield a scan-back decision (used by the tail/scan-back paths); otherwise they + * propagate to the caller. + */ + private CompletableFuture recoverEntryAt(LedgerHandle lh, long entryId, + boolean fallbackToScanBack) { + CompletableFuture recovered = readEntry(lh, entryId) + .thenCompose(bytes -> recoverEntry(lh, entryId, bytes)); + return fallbackToScanBack + ? recovered.exceptionally(error -> { + log.warn("Failed to recover entry, ledgerId: {}, entryId: {}", lh.getId(), entryId, error); + return RecoveryDecision.scanBack(); + }) + : recovered; + } + + private CompletableFuture recoverEntry(LedgerHandle lh, long entryId, byte[] bytes) { + if (bytes == null) { + return FutureUtil.failedFuture(new ManagedLedgerException( + "Entry " + entryId + " in cursor ledger " + lh.getId() + " is empty")); + } + + CursorLogEntry envelope; + try { + envelope = CursorLogEntry.parseFrom(bytes); + } catch (Exception e) { + if (isLegacyPositionInfo(bytes)) { + // Legacy PositionInfo bytes can fail CursorLogEntry parsing. + log.debug("Recovered legacy PositionInfo after CursorLogEntry parse failure, " + + "ledgerId: {}, entryId: {}", lh.getId(), entryId); + return CompletableFuture.completedFuture(RecoveryDecision.recovered(RecoveredState.legacy(bytes))); + } + return FutureUtil.failedFuture(new ManagedLedgerException( + "Invalid cursor log entry at ledger " + lh.getId() + " entry " + entryId + + ": failed to parse CursorLogEntry", e)); + } + if (envelope.hasCheckpoint()) { + CursorCheckpoint cp = envelope.getCheckpoint(); + log.debug("Recovered checkpoint, ledgerId: {}, entryId: {}, mdLedgerId: {}, mdEntryId: {}, " + + "ackStates: {}, ackStateRefs: {}", + lh.getId(), entryId, cp.getMarkDeleteLedgerId(), cp.getMarkDeleteEntryId(), + cp.getAckStatesCount(), cp.getAckStateRefsCount()); + return CompletableFuture.completedFuture( + RecoveryDecision.recovered(new RecoveredState(cp, entryId))); + } + if (envelope.hasCheckpointChunk()) { + CursorCheckpointChunk chunk = envelope.getCheckpointChunk(); + int partCount = chunk.getPartCount(); + int partIndex = chunk.getPartIndex(); + if (partIndex >= 0 && partIndex < partCount) { + return recoverFromChunk(lh, entryId, chunk); + } + } + if (isLegacyPositionInfo(bytes)) { + // Backward compatibility: legacy PositionInfo bytes can parse as an empty CursorLogEntry. + log.debug("Recovered legacy PositionInfo from unknown CursorLogEntry payload, " + + "ledgerId: {}, entryId: {}", lh.getId(), entryId); + return CompletableFuture.completedFuture(RecoveryDecision.recovered(RecoveredState.legacy(bytes))); + } + return FutureUtil.failedFuture(new ManagedLedgerException( + "Invalid cursor log entry at ledger " + lh.getId() + " entry " + entryId + + ": neither checkpoint nor checkpointChunk")); + } + + private CompletableFuture recoverFromChunk( + LedgerHandle lh, long entryId, CursorCheckpointChunk chunk) { + int partCount = chunk.getPartCount(); + int partIndex = chunk.getPartIndex(); + if (partIndex < 0 || partIndex >= partCount) { + log.warn("Invalid chunk metadata, ledgerId: {}, entryId: {}, partIndex: {}, partCount: {}", + lh.getId(), entryId, partIndex, partCount); + return FutureUtil.failedFuture(new ManagedLedgerException( + "Invalid chunk metadata at ledger " + lh.getId() + " entry " + entryId + + ": partIndex=" + partIndex + ", partCount=" + partCount)); + } + if (partIndex != partCount - 1) { + log.debug("Chunk is not last part, scanning back, ledgerId: {}, entryId: {}, partIndex: {}, partCount: {}", + lh.getId(), entryId, partIndex, partCount); + return CompletableFuture.completedFuture(RecoveryDecision.scanBack()); + } + return assemble(lh, entryId, partCount) + .handle((cp, error) -> { + if (error != null) { + log.warn("Chunk assembly failed, scanning back, ledgerId: {}, entryId: {}, partCount: {}", + lh.getId(), entryId, partCount, error); + return RecoveryDecision.scanBack(); + } + return RecoveryDecision.recovered(new RecoveredState(cp, entryId)); + }); + } + + private CompletableFuture assemble(LedgerHandle lh, long lastPartEntryId, int partCount) { + long firstId = lastPartEntryId - partCount + 1; + if (firstId < 0) { + return FutureUtil.failedFuture(new ManagedLedgerException( + "Chunk assembly underflow: first part entry id " + firstId + + " (lastPartEntryId=" + lastPartEntryId + ", partCount=" + partCount + ")")); + } + return readEntryRange(lh, firstId, lastPartEntryId).thenApply(partsBytes -> { + if (partsBytes.size() != partCount) { + throw new RuntimeException(new ManagedLedgerException( + "Chunk assembly mismatch: expected " + partCount + ", got " + partsBytes.size())); + } + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + for (int i = 0; i < partCount; i++) { + CursorLogEntry part; + try { + part = CursorLogEntry.parseFrom(partsBytes.get(i)); + } catch (Exception e) { + throw new RuntimeException(new ManagedLedgerException("Failed to parse chunk part " + i, e)); + } + if (!part.hasCheckpointChunk()) { + throw new RuntimeException(new ManagedLedgerException("Part " + i + " is not a chunk")); + } + CursorCheckpointChunk chunk = part.getCheckpointChunk(); + if (chunk.getPartIndex() != i || chunk.getPartCount() != partCount) { + throw new RuntimeException(new ManagedLedgerException( + "Part " + i + ": partIndex=" + chunk.getPartIndex() + + " partCount=" + chunk.getPartCount() + ", expected " + i + "/" + partCount)); + } + byte[] checkpointBytes = chunk.getCheckpointBytes().toByteArray(); + bos.write(checkpointBytes, 0, checkpointBytes.length); + } + try { + CursorCheckpoint cp = CursorCheckpoint.parseFrom(bos.toByteArray()); + log.debug("Assembled chunked checkpoint, ledgerId: {}, partCount: {}, assembledBytes: {}", + lh.getId(), partCount, bos.size()); + return cp; + } catch (Exception e) { + throw new RuntimeException(new ManagedLedgerException("Failed to parse assembled checkpoint", e)); + } + }); + } + + private CompletableFuture scanBack(LedgerHandle lh, long fromEntryId) { + CompletableFuture hintFuture = null; + if (zkCheckpointLedgerId >= 0 && zkCheckpointEntryId >= 0 + && zkCheckpointLedgerId == lh.getId() && zkCheckpointEntryId < fromEntryId) { + log.debug("Trying ZK checkpoint hint before scan-back, ledgerId: {}, hintEntryId: {}, fromEntryId: {}", + lh.getId(), zkCheckpointEntryId, fromEntryId); + hintFuture = recoverEntryAt(lh, zkCheckpointEntryId, false) + .thenCompose(decision -> decision.shouldScanBack + ? scanBackStep(lh, fromEntryId - 1) + : CompletableFuture.completedFuture(decision.state)); + } + if (hintFuture != null) { + return hintFuture.exceptionally(t -> { + log.debug("ZK checkpoint hint failed, falling back to sequential scan-back, " + + "ledgerId: {}, hintEntryId: {}", + lh.getId(), zkCheckpointEntryId); + return null; + }).thenCompose(state -> + state != null ? CompletableFuture.completedFuture(state) + : scanBackStep(lh, fromEntryId - 1)); + } + return scanBackStep(lh, fromEntryId - 1); + } + + private CompletableFuture scanBackStep(LedgerHandle lh, long entryId) { + long floor = lh.getLastAddConfirmed() - MAX_SCAN_BACK; + if (entryId < 0 || entryId < floor) { + log.warn("Scan-back exhausted without recoverable checkpoint, ledgerId: {}, entryId: {}, floor: {}", + lh.getId(), entryId, floor); + return FutureUtil.failedFuture(new ManagedLedgerException( + "scanBack exhausted without finding a complete checkpoint")); + } + return recoverEntryAt(lh, entryId, true) + .thenCompose(decision -> decision.shouldScanBack + ? scanBackStep(lh, entryId - 1) + : CompletableFuture.completedFuture(decision.state)); + } + + private static CompletableFuture addEntry(LedgerHandle lh, byte[] data) { + CompletableFuture future = new CompletableFuture<>(); + lh.asyncAddEntry(data, (rc, handle, entryId, ctx) -> { + if (rc == BKException.Code.OK) { + future.complete(entryId); + } else { + future.completeExceptionally(BKException.create(rc)); + } + }, null); + return future; + } + + private static CompletableFuture readEntry(LedgerHandle lh, long entryId) { + CompletableFuture future = new CompletableFuture<>(); + lh.asyncReadEntries(entryId, entryId, (rc, lh1, entries, ctx) -> { + if (rc != BKException.Code.OK) { + future.completeExceptionally(BKException.create(rc)); + return; + } + if (!entries.hasMoreElements()) { + future.complete(null); + return; + } + future.complete(entries.nextElement().getEntry()); + }, null); + return future; + } + + private static CompletableFuture> readEntryRange(LedgerHandle lh, long firstId, long lastId) { + CompletableFuture> future = new CompletableFuture<>(); + lh.asyncReadEntries(firstId, lastId, (rc, lh1, entries, ctx) -> { + if (rc != BKException.Code.OK) { + future.completeExceptionally(BKException.create(rc)); + return; + } + List result = new ArrayList<>(); + while (entries.hasMoreElements()) { + result.add(entries.nextElement().getEntry()); + } + future.complete(result); + }, null); + return future; + } + + private static boolean isLegacyPositionInfo(byte[] bytes) { + try { + PositionInfo.parseFrom(bytes); + return true; + } catch (Exception e) { + return false; + } + } + + record AppendResult(int totalBytes, long commitEntryId) {} + + private static final class RecoveryDecision { + private final RecoveredState state; + private final boolean shouldScanBack; + + private RecoveryDecision(RecoveredState state, boolean shouldScanBack) { + this.state = state; + this.shouldScanBack = shouldScanBack; + } + + static RecoveryDecision recovered(RecoveredState state) { + return new RecoveryDecision(state, false); + } + + static RecoveryDecision scanBack() { + return new RecoveryDecision(null, true); + } + } + + static final class RecoveredState { + final CursorCheckpoint checkpoint; + final long commitEntryId; + final byte[] legacyBytes; + + private RecoveredState(CursorCheckpoint checkpoint, long commitEntryId) { + this.checkpoint = checkpoint; + this.commitEntryId = commitEntryId; + this.legacyBytes = null; + } + + private RecoveredState(byte[] legacyBytes) { + this.checkpoint = null; + this.commitEntryId = -1; + this.legacyBytes = legacyBytes; + } + + static RecoveredState legacy(byte[] bytes) { + return new RecoveredState(bytes); + } + + boolean isLegacy() { + return legacyBytes != null; + } + } +} diff --git a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/CursorCheckpointPersistence.java b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/CursorCheckpointPersistence.java new file mode 100644 index 0000000000000..fd677d7dd3de6 --- /dev/null +++ b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/CursorCheckpointPersistence.java @@ -0,0 +1,487 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.bookkeeper.mledger.impl; + +import com.google.common.annotations.VisibleForTesting; +import com.google.protobuf.ByteString; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.locks.ReadWriteLock; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.apache.bookkeeper.client.BKException; +import org.apache.bookkeeper.client.BookKeeper; +import org.apache.bookkeeper.client.LedgerHandle; +import org.apache.bookkeeper.mledger.ManagedLedgerException; +import org.apache.bookkeeper.mledger.Position; +import org.apache.bookkeeper.mledger.PositionFactory; +import org.apache.bookkeeper.mledger.proto.MLDataFormats.AckState; +import org.apache.bookkeeper.mledger.proto.MLDataFormats.AckStateRef; +import org.apache.bookkeeper.mledger.proto.MLDataFormats.BatchedEntryDeletionIndexInfo; +import org.apache.bookkeeper.mledger.proto.MLDataFormats.CursorCheckpoint; +import org.apache.bookkeeper.mledger.proto.MLDataFormats.LongProperty; + +@Slf4j +class CursorCheckpointPersistence { + + private final CursorCheckpointLog writer; + private final ReadWriteLock lock; + private final BookKeeper bookKeeper; + private final BookKeeper.DigestType digestType; + private final byte[] password; + @Getter + private final boolean perLedgerEntryPersistEnabled; + + // Position of each msg ledger's latest checkpoint in the cursor ledger; the AckStateRef + // targets are derived from this index. + private final Long2ObjectOpenHashMap lastCheckpointPos = new Long2ObjectOpenHashMap<>(); + private volatile CompletableFuture lastPersist = + CompletableFuture.completedFuture(null); + + public CursorCheckpointPersistence(CursorCheckpointLog writer, ReadWriteLock lock, + BookKeeper bookKeeper, + BookKeeper.DigestType digestType, byte[] password, + boolean perLedgerEntryPersistEnabled) { + this.writer = writer; + this.lock = lock; + this.bookKeeper = bookKeeper; + this.digestType = digestType; + this.password = password; + this.perLedgerEntryPersistEnabled = perLedgerEntryPersistEnabled; + } + + public void setZkCheckpointHint(long cursorLedgerId, long entryId) { + writer.setZkCheckpointHint(cursorLedgerId, entryId); + } + + public synchronized CompletableFuture persist( + LedgerHandle lh, Position mdPos, Map properties, + ManagedCursorImpl cursor) { + lastPersist = lastPersist + .exceptionally(e -> null) + .thenCompose(ignored -> doPersist(lh, mdPos, properties, cursor)); + return lastPersist; + } + + /** + * Immutable snapshot of cursor state for one flush. Building reads only from this snapshot, + * not live cursor state, so acks arriving during async appends don't wedge the flush. + */ + private static final class PersistContext { + final long mdLedgerId; + // Ledgers whose checkpoint is written by this flush (dirty + self-healed positionless + // active ledgers), excluding the mark-delete ledger. + final Set flushedLedgers; + // Ledgers that may be referenced by this flush's checkpoints. + final Set activeLedgers; + final Map dirtyBitmaps; + final Map> dirtyBatchAcks; + final byte[] mdBitmap; + final List mdBatchAcks; + + PersistContext(long mdLedgerId, Set flushedLedgers, Set activeLedgers, + Map dirtyBitmaps, + Map> dirtyBatchAcks, + byte[] mdBitmap, List mdBatchAcks) { + this.mdLedgerId = mdLedgerId; + this.flushedLedgers = flushedLedgers; + this.activeLedgers = activeLedgers; + this.dirtyBitmaps = dirtyBitmaps; + this.dirtyBatchAcks = dirtyBatchAcks; + this.mdBitmap = mdBitmap; + this.mdBatchAcks = mdBatchAcks; + } + } + + private PersistContext createPersistContext(ManagedCursorImpl cursor, Position mdPos) { + lock.readLock().lock(); + try { + long mdLedgerId = mdPos.getLedgerId(); + Set clearedDirtyLedgers = cursor.individualDeletedMessages.snapshotAndClearDirtyLedgers(); + Set flushedLedgers = new HashSet<>(clearedDirtyLedgers); + flushedLedgers.remove(mdLedgerId); + + Set activeLedgers = new HashSet<>(); + cursor.individualDeletedMessages.forEachActiveLedger(activeLedgers::add); + Map> batchAcksByLedger = groupBatchAcksByLedger(cursor); + batchAcksByLedger.keySet().forEach(activeLedgers::add); + + // Self-heal: active ledgers without a persisted checkpoint are flushed this round, + // otherwise they'd have no position to reference. + for (long id : activeLedgers) { + if (id != mdLedgerId && !lastCheckpointPos.containsKey(id)) { + flushedLedgers.add(id); + } + } + + Map dirtyBitmaps = new HashMap<>(); + Map> dirtyBatchAcks = new HashMap<>(); + for (long id : flushedLedgers) { + dirtyBitmaps.put(id, cursor.individualDeletedMessages.bitmapOf(id)); + dirtyBatchAcks.put(id, batchAcksByLedger.getOrDefault(id, Collections.emptyList())); + } + byte[] mdBitmap = cursor.individualDeletedMessages.bitmapOf(mdLedgerId); + List mdBatchAcks = + batchAcksByLedger.getOrDefault(mdLedgerId, Collections.emptyList()); + return new PersistContext(mdLedgerId, flushedLedgers, activeLedgers, dirtyBitmaps, + dirtyBatchAcks, mdBitmap, mdBatchAcks); + } finally { + lock.readLock().unlock(); + } + } + + private static Map> groupBatchAcksByLedger( + ManagedCursorImpl cursor) { + Map> result = new HashMap<>(); + if (cursor.batchDeletedIndexes == null) { + return result; + } + cursor.batchDeletedIndexes.forEach((position, bitSet) -> { + List list = + result.computeIfAbsent(position.getLedgerId(), k -> new ArrayList<>()); + BatchedEntryDeletionIndexInfo.Builder infoBuilder = BatchedEntryDeletionIndexInfo.newBuilder(); + infoBuilder.getPositionBuilder() + .setLedgerId(position.getLedgerId()) + .setEntryId(position.getEntryId()); + bitSet.stream().forEach(infoBuilder::addDeleteSet); + list.add(infoBuilder.build()); + }); + return result; + } + + private CompletableFuture doPersist( + LedgerHandle lh, Position mdPos, Map properties, + ManagedCursorImpl cursor) { + PersistContext ctx = createPersistContext(cursor, mdPos); + + // Drop positions for ledgers below mark-delete that are no longer active. Active ledgers + // are kept: batch-index entries may still be in memory before the align cleanup runs. + lock.writeLock().lock(); + try { + lastCheckpointPos.keySet().removeIf(id -> id < ctx.mdLedgerId && !ctx.activeLedgers.contains(id)); + } finally { + lock.writeLock().unlock(); + } + + List dirtyOrder = new ArrayList<>(ctx.flushedLedgers); + Collections.sort(dirtyOrder); + // mdLedger is written first so other checkpoints in this flush can reference its position. + List writeOrder = new ArrayList<>(dirtyOrder.size() + 1); + writeOrder.add(ctx.mdLedgerId); + writeOrder.addAll(dirtyOrder); + + final Set appendedLedgers = new HashSet<>(); + CompletableFuture chain = + CompletableFuture.completedFuture(null); + for (long ledgerId : writeOrder) { + chain = chain.thenCompose(ignored -> { + byte[] bitmap = ledgerId == ctx.mdLedgerId ? ctx.mdBitmap : ctx.dirtyBitmaps.get(ledgerId); + List batchAcks = + ledgerId == ctx.mdLedgerId ? ctx.mdBatchAcks : ctx.dirtyBatchAcks.get(ledgerId); + CursorCheckpoint checkpoint = buildCheckpoint(ctx, ledgerId, bitmap, batchAcks, + mdPos, properties); + return writer.appendCheckpoint(lh, checkpoint).thenApply(result -> { + lock.writeLock().lock(); + try { + Position persistedPos = PositionFactory.create(lh.getId(), result.commitEntryId()); + recordCheckpointPos(ledgerId, persistedPos); + appendedLedgers.add(ledgerId); + } finally { + lock.writeLock().unlock(); + } + return result; + }); + }); + } + + return chain.exceptionally(error -> { + restoreDirtyForFailedLedgers(cursor, ctx.flushedLedgers, appendedLedgers); + throw new CompletionException(error); + }); + } + + /** + * Re-marks dirty for ledgers whose checkpoint was not appended, so the next flush retries them. + */ + /** + * Records the position of a msg ledger's latest checkpoint. Persists are serialized + * ({@link #persist} chains behind the previous one), so puts normally arrive in order; + * the monotonic guard is defense-in-depth: a stale completion must never regress the + * index, otherwise a later AckStateRef would point at an older (smaller) ack bitmap + * and the acks recorded in the newer checkpoint would be lost on recovery. + */ + @VisibleForTesting + void recordCheckpointPos(long msgLedgerId, Position pos) { + Position prev = lastCheckpointPos.get(msgLedgerId); + if (prev == null || pos.compareTo(prev) > 0) { + lastCheckpointPos.put(msgLedgerId, pos); + } + } + + @VisibleForTesting + Position checkpointPosOf(long msgLedgerId) { + return lastCheckpointPos.get(msgLedgerId); + } + + private void restoreDirtyForFailedLedgers(ManagedCursorImpl cursor, Set flushedLedgers, + Set appendedLedgers) { + Set failedLedgers = new HashSet<>(flushedLedgers); + failedLedgers.removeAll(appendedLedgers); + if (failedLedgers.isEmpty()) { + return; + } + lock.writeLock().lock(); + try { + cursor.individualDeletedMessages.restoreDirtyLedgers(failedLedgers); + } finally { + lock.writeLock().unlock(); + } + } + + // ============================ recover ============================ + + public CompletableFuture recover(LedgerHandle lh) { + return writer.readLatest(lh).thenCompose(state -> { + if (state.isLegacy()) { + return CompletableFuture.completedFuture(RecoveredCheckpoint.legacy(state.legacyBytes)); + } + CursorCheckpoint cp = state.checkpoint; + return fetchAckStateRefs(cp, lh).thenApply(fetched -> { + validateRecoveredAckData(cp, fetched); + rebuildLastCheckpointPos(lh, state.commitEntryId, cp); + return RecoveredCheckpoint.of(cp, fetched); + }); + }); + } + + /** Validates no duplicate msgLedgerIds and that all refs resolved. Fails fast on corruption. */ + private static void validateRecoveredAckData(CursorCheckpoint cp, Map fetched) { + Set expected = new HashSet<>(cp.getAckStatesCount() + cp.getAckStateRefsCount()); + for (AckState ackState : cp.getAckStatesList()) { + if (!expected.add(ackState.getMsgLedgerId())) { + throw new RuntimeException(new ManagedLedgerException( + "Recovery inconsistency: duplicate msgLedgerId " + ackState.getMsgLedgerId() + + " in inline ack states")); + } + } + for (AckStateRef ref : cp.getAckStateRefsList()) { + if (!expected.add(ref.getMsgLedgerId())) { + throw new RuntimeException(new ManagedLedgerException( + "Recovery inconsistency: msgLedgerId " + ref.getMsgLedgerId() + + " appears both inline and in ack state refs")); + } + } + if (!fetched.keySet().equals(expected)) { + throw new RuntimeException(new ManagedLedgerException( + "Recovery inconsistency: expected ack states for " + expected + + ", got " + fetched.keySet())); + } + } + + /** Rebuilds lastCheckpointPos from the recovered checkpoint so the first persist can emit refs. */ + private void rebuildLastCheckpointPos(LedgerHandle lh, long commitEntryId, CursorCheckpoint cp) { + lock.writeLock().lock(); + try { + lastCheckpointPos.clear(); + Position inlinePos = PositionFactory.create(lh.getId(), commitEntryId); + for (AckState ackState : cp.getAckStatesList()) { + lastCheckpointPos.put(ackState.getMsgLedgerId(), inlinePos); + } + for (AckStateRef ref : cp.getAckStateRefsList()) { + lastCheckpointPos.put(ref.getMsgLedgerId(), + PositionFactory.create(ref.getCursorLedgerId(), ref.getEntryId())); + } + } finally { + lock.writeLock().unlock(); + } + } + + public CompletableFuture recoverWithHint( + LedgerHandle lh, long hintCursorLedgerId, long hintEntryId) { + if (hintCursorLedgerId >= 0 && hintEntryId >= 0) { + setZkCheckpointHint(hintCursorLedgerId, hintEntryId); + } + return recover(lh); + } + + private CompletableFuture> fetchAckStateRefs(CursorCheckpoint cp, LedgerHandle lh) { + Map result = new HashMap<>(); + for (AckState ackState : cp.getAckStatesList()) { + result.put(ackState.getMsgLedgerId(), toAckStateData(ackState)); + } + List refs = cp.getAckStateRefsList(); + if (refs.isEmpty()) { + return CompletableFuture.completedFuture(result); + } + List> fetches = new ArrayList<>(refs.size()); + for (AckStateRef ref : refs) { + fetches.add(fetchAckState(ref, lh)); + } + return CompletableFuture.allOf(fetches.toArray(new CompletableFuture[0])) + .thenApply(ignored -> { + for (int i = 0; i < refs.size(); i++) { + result.put(refs.get(i).getMsgLedgerId(), fetches.get(i).join()); + } + return result; + }); + } + + private CompletableFuture fetchAckState(AckStateRef ref, LedgerHandle lh) { + return readCheckpointFromLedger(lh, ref.getCursorLedgerId(), ref.getEntryId()) + .thenApply(cp -> extractAckState(cp, ref)); + } + + private static AckStateData extractAckState(CursorCheckpoint cp, AckStateRef ref) { + for (AckState ackState : cp.getAckStatesList()) { + if (ackState.getMsgLedgerId() == ref.getMsgLedgerId()) { + return toAckStateData(ackState); + } + } + throw new RuntimeException(new ManagedLedgerException( + "AckStateRef target " + ref + " does not contain msgLedgerId " + + ref.getMsgLedgerId())); + } + + private static AckStateData toAckStateData(AckState ackState) { + return new AckStateData( + ackState.hasAckBitmap() ? ackState.getAckBitmap().toByteArray() : null, + ackState.getBatchAcksList() != null + ? ackState.getBatchAcksList() : Collections.emptyList()); + } + + private CompletableFuture readCheckpointFromLedger( + LedgerHandle lh, long ledgerId, long entryId) { + CompletableFuture future = new CompletableFuture<>(); + if (ledgerId == lh.getId()) { + // Refs into the recovered cursor ledger itself can reuse the already-open handle. + writer.readAt(lh, entryId) + .whenComplete((state, error) -> completeCheckpointRead(future, ledgerId, entryId, state, error)); + return future; + } + bookKeeper.asyncOpenLedgerNoRecovery(ledgerId, digestType, password, (rc, handle, ctx) -> { + if (rc != BKException.Code.OK) { + future.completeExceptionally(BKException.create(rc)); + return; + } + writer.readAt(handle, entryId).whenComplete((state, error) -> { + handle.asyncClose((closeRc, closeHandle, closeCtx) -> {}, null); + completeCheckpointRead(future, ledgerId, entryId, state, error); + }); + }, null); + return future; + } + + private static void completeCheckpointRead(CompletableFuture future, long ledgerId, + long entryId, CursorCheckpointLog.RecoveredState state, + Throwable error) { + if (error != null) { + future.completeExceptionally(error); + } else if (state.isLegacy()) { + future.completeExceptionally(new ManagedLedgerException( + "AckStateRef target entry " + entryId + " in ledger " + ledgerId + + " is not a checkpoint")); + } else { + future.complete(state.checkpoint); + } + } + + // ============================ checkpoint builder ============================ + + /** + * Builds a checkpoint for one msg ledger: inline ack state plus refs to every other active + * ledger's latest position. Refs stay valid because cursor ledgers are only GC'd once + * mark-delete passes the last ledger holding acks. + */ + private CursorCheckpoint buildCheckpoint(PersistContext ctx, long msgLedgerId, byte[] bitmap, + List batchAcks, + Position mdPos, Map properties) { + CursorCheckpoint.Builder cpBuilder = CursorCheckpoint.newBuilder() + .setMarkDeleteLedgerId(mdPos.getLedgerId()) + .setMarkDeleteEntryId(mdPos.getEntryId()); + if (properties != null) { + properties.forEach((name, value) -> { + cpBuilder.addProperties(LongProperty.newBuilder().setName(name).setValue(value).build()); + }); + } + addAckState(cpBuilder, msgLedgerId, bitmap, batchAcks); + lock.readLock().lock(); + try { + for (long id : ctx.activeLedgers) { + if (id != msgLedgerId) { + Position pos = lastCheckpointPos.get(id); + if (pos == null) { + if (ctx.flushedLedgers.contains(id)) { + continue; + } + throw new IllegalStateException( + "Missing lastCheckpointPos for active msgLedgerId " + id); + } + cpBuilder.addAckStateRefs(AckStateRef.newBuilder() + .setMsgLedgerId(id) + .setCursorLedgerId(pos.getLedgerId()) + .setEntryId(pos.getEntryId()) + .build()); + } + } + } finally { + lock.readLock().unlock(); + } + return cpBuilder.build(); + } + + private static void addAckState(CursorCheckpoint.Builder cp, long msgLedgerId, byte[] bitmap, + List batchAcks) { + AckState.Builder ackStateBuilder = AckState.newBuilder().setMsgLedgerId(msgLedgerId); + if (bitmap != null && bitmap.length > 0) { + ackStateBuilder.setAckBitmap(ByteString.copyFrom(bitmap)); + } + if (batchAcks != null && !batchAcks.isEmpty()) { + ackStateBuilder.addAllBatchAcks(batchAcks); + } + cp.addAckStates(ackStateBuilder.build()); + } + + // ============================ DTOs ============================ + + record AckStateData(byte[] ackBitmap, List batchAcks) {} + + record RecoveredCheckpoint(CursorCheckpoint checkpoint, + Map ackData, + byte[] legacyBytes) { + static RecoveredCheckpoint of(CursorCheckpoint cp, Map ackData) { + return new RecoveredCheckpoint(cp, ackData, null); + } + + static RecoveredCheckpoint legacy(byte[] bytes) { + return new RecoveredCheckpoint(null, null, bytes); + } + + boolean isLegacy() { + return legacyBytes != null; + } + } +} diff --git a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/EntryImpl.java b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/EntryImpl.java index e0e2b859794b5..87a037a90f60d 100644 --- a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/EntryImpl.java +++ b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/EntryImpl.java @@ -94,6 +94,10 @@ public static EntryImpl create(Position position, ByteBuf data) { return entry; } + public static EntryImpl create(Position position, ByteBuf data, int expectedReadCount) { + return create(position, data); + } + public static EntryImpl create(EntryImpl other) { EntryImpl entry = RECYCLER.get(); entry.timestamp = System.nanoTime(); diff --git a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/ManagedCursorImpl.java b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/ManagedCursorImpl.java index 790c81cadc9ba..4105f98923874 100644 --- a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/ManagedCursorImpl.java +++ b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/ManagedCursorImpl.java @@ -26,17 +26,18 @@ import static org.apache.bookkeeper.mledger.impl.ManagedLedgerImpl.DEFAULT_LEDGER_DELETE_RETRIES; import static org.apache.bookkeeper.mledger.impl.ManagedLedgerImpl.createManagedLedgerException; import static org.apache.bookkeeper.mledger.util.Errors.isNoSuchLedgerExistsException; +import static org.apache.pulsar.common.util.Runnables.catchingAndLoggingThrowables; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.MoreObjects; import com.google.common.collect.Collections2; import com.google.common.collect.Lists; import com.google.common.collect.Range; import com.google.common.util.concurrent.RateLimiter; -import com.google.protobuf.InvalidProtocolBufferException; import java.time.Clock; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.BitSet; +import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; @@ -47,19 +48,20 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentSkipListMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicIntegerFieldUpdater; -import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLongFieldUpdater; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReferenceFieldUpdater; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.function.Function; +import java.util.function.IntFunction; import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.LongStream; @@ -95,6 +97,9 @@ import org.apache.bookkeeper.mledger.ScanOutcome; import org.apache.bookkeeper.mledger.impl.MetaStore.MetaStoreCallback; import org.apache.bookkeeper.mledger.proto.MLDataFormats; +import org.apache.bookkeeper.mledger.proto.MLDataFormats.AckStateRef; +import org.apache.bookkeeper.mledger.proto.MLDataFormats.BatchedEntryDeletionIndexInfo; +import org.apache.bookkeeper.mledger.proto.MLDataFormats.CursorCheckpoint; import org.apache.bookkeeper.mledger.proto.MLDataFormats.LongListMap; import org.apache.bookkeeper.mledger.proto.MLDataFormats.LongProperty; import org.apache.bookkeeper.mledger.proto.MLDataFormats.ManagedCursorInfo; @@ -104,6 +109,7 @@ import org.apache.bookkeeper.mledger.proto.MLDataFormats.PositionInfo.Builder; import org.apache.bookkeeper.mledger.proto.MLDataFormats.StringProperty; import org.apache.bookkeeper.mledger.util.ManagedLedgerUtils; +import org.apache.commons.lang3.mutable.MutableBoolean; import org.apache.commons.lang3.mutable.MutableInt; import org.apache.commons.lang3.mutable.MutableLong; import org.apache.commons.lang3.tuple.Pair; @@ -112,7 +118,6 @@ import org.apache.pulsar.common.util.FutureUtil; import org.apache.pulsar.common.util.collections.LongPairRangeSet; import org.apache.pulsar.common.util.collections.LongPairRangeSet.LongPairConsumer; -import org.apache.pulsar.common.util.collections.LongPairRangeSet.RangeBoundConsumer; import org.apache.pulsar.metadata.api.Stat; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; @@ -198,11 +203,8 @@ public class ManagedCursorImpl implements ManagedCursor { private static final LongPairConsumer positionRangeConverter = PositionFactory::create; - private static final RangeBoundConsumer positionRangeReverseConverter = - (position) -> new LongPairRangeSet.LongPair(position.getLedgerId(), position.getEntryId()); - private static final LongPairConsumer recyclePositionRangeConverter = PositionRecyclable::get; - protected final RangeSetWrapper individualDeletedMessages; + protected final PositionRangeSet individualDeletedMessages; // Maintain the deletion status for batch messages // (ledgerId, entryId) -> deletion indexes @@ -211,6 +213,11 @@ public class ManagedCursorImpl implements ManagedCursor { @Nullable protected final ConcurrentSkipListMap batchDeletedIndexes; protected final ReadWriteLock lock = new ReentrantReadWriteLock(); + @VisibleForTesting + final CursorCheckpointPersistence ackPersistence; + private volatile long pendingCheckpointHintLedgerId = -1; + private volatile long pendingCheckpointHintEntryId = -1; + private final Set allCursorLedgerIds = ConcurrentHashMap.newKeySet(); private RateLimiter markDeleteLimiter; // The cursor is considered "dirty" when there are mark-delete updates that are only applied in memory, // because of the rate limiting. @@ -223,6 +230,7 @@ public class ManagedCursorImpl implements ManagedCursor { private long entriesReadCount; private long entriesReadSize; private int individualDeletedMessagesSerializedSize; + private final AtomicBoolean lastCursorDataFullyPersistable = new AtomicBoolean(true); private static final String COMPACTION_CURSOR_NAME = "__compaction"; private volatile boolean cacheReadEntry = false; @@ -240,6 +248,13 @@ class MarkDeleteEntry { final Object ctx; final Map properties; final Runnable alignAcknowledgeStatusAfterPersisted; + // True for custom aligns (e.g. resetCursor) that must run even when persist is deferred. + // Entries that carry a custom align runnable (cursor reset) must surface persist + // failures to their caller instead of silently reporting success: the in-memory + // state was NOT applied, so a "successful" reset would be a silent no-op. + final boolean propagatePersistFailure; + // Set once the position is actually durable; gates the default align. + volatile boolean persistedSuccessfully; // If the callbackGroup is set, it means this mark-delete request was done on behalf of a group of request (just // persist the last one in the chain). In this case we need to trigger the callbacks for every request in the @@ -248,11 +263,17 @@ class MarkDeleteEntry { public MarkDeleteEntry(Position newPosition, Map properties, MarkDeleteCallback callback, Object ctx) { - this(newPosition, properties, callback, ctx, null); + this(newPosition, properties, callback, ctx, null, false); } public MarkDeleteEntry(Position newPosition, Map properties, MarkDeleteCallback callback, Object ctx, Runnable alignAcknowledgeStatusAfterPersisted) { + this(newPosition, properties, callback, ctx, alignAcknowledgeStatusAfterPersisted, false); + } + + public MarkDeleteEntry(Position newPosition, Map properties, + MarkDeleteCallback callback, Object ctx, Runnable alignAcknowledgeStatusAfterPersisted, + boolean propagatePersistFailure) { if (alignAcknowledgeStatusAfterPersisted == null) { alignAcknowledgeStatusAfterPersisted = () -> { if (batchDeletedIndexes != null) { @@ -268,6 +289,7 @@ public MarkDeleteEntry(Position newPosition, Map properties, this.callback = callback; this.ctx = ctx; this.alignAcknowledgeStatusAfterPersisted = alignAcknowledgeStatusAfterPersisted; + this.propagatePersistFailure = propagatePersistFailure; } public void triggerComplete() { @@ -365,14 +387,19 @@ protected ManagedCursorImpl(BookKeeper bookkeeper, ManagedLedgerImpl ledger, Str this.cursorProperties = Collections.emptyMap(); this.ledger = ledger; this.name = cursorName; - this.individualDeletedMessages = new RangeSetWrapper<>(positionRangeConverter, - positionRangeReverseConverter, this); + this.individualDeletedMessages = new PositionRangeSet(positionRangeConverter, + getConfig().isPersistentUnackedRangesWithPerLedgerEntryEnabled()); if (getConfig().isDeletionAtBatchIndexLevelEnabled()) { this.batchDeletedIndexes = new ConcurrentSkipListMap<>(); } else { this.batchDeletedIndexes = null; } this.digestType = BookKeeper.DigestType.fromApiDigestType(getConfig().getDigestType()); + CursorCheckpointLog writer = new CursorCheckpointLog(getConfig().getPersistentUnackedRangesMaxEntrySize()); + this.ackPersistence = new CursorCheckpointPersistence( + writer, lock, bookkeeper, + this.digestType, getConfig().getPassword(), + getConfig().isPersistentUnackedRangesWithPerLedgerEntryEnabled()); PENDING_MARK_DELETED_SUBMITTED_COUNT_UPDATER.set(this, 0); PENDING_READ_OPS_UPDATER.set(this, 0); RESET_CURSOR_IN_PROGRESS_UPDATER.set(this, FALSE); @@ -401,6 +428,35 @@ public Map getProperties() { return lastMarkDeleteEntry != null ? lastMarkDeleteEntry.properties : Collections.emptyMap(); } + private void recordAckStats(long latencyMillis) { + mbean.recordAck(latencyMillis); + var stats = otelCursorStatsOrNull(); + if (stats != null) { + stats.recordAckLatency(this, latencyMillis / 1000.0); + } + } + + private void recordPersistStats(long latencyMillis) { + mbean.recordPersist(latencyMillis); + var stats = otelCursorStatsOrNull(); + if (stats != null) { + stats.recordPersistLatency(this, latencyMillis / 1000.0); + } + } + + private void recordRecoverStats(long latencyMillis, boolean success) { + mbean.recordRecover(latencyMillis, success); + var stats = otelCursorStatsOrNull(); + if (stats != null) { + stats.recordRecoverLatency(this, latencyMillis / 1000.0); + } + } + + // Null when the ledger (and thus its factory) is mocked in unit tests. + private OpenTelemetryManagedCursorStats otelCursorStatsOrNull() { + return ledger.getFactory() == null ? null : ledger.getFactory().getOpenTelemetryManagedCursorStats(); + } + @Override public boolean isCursorDataFullyPersistable() { lock.readLock().lock(); @@ -546,6 +602,22 @@ public boolean removeProperty(String key) { void recover(final VoidCallback callback) { // Read the meta-data ledgerId from the store log.info("[{}] Recovering from bookkeeper ledger cursor: {}", ledger.getName(), name); + final long recoverStartTimeNanos = System.nanoTime(); + final VoidCallback recoverCallback = new VoidCallback() { + @Override + public void operationComplete() { + recordRecoverStats( + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - recoverStartTimeNanos), true); + callback.operationComplete(); + } + + @Override + public void operationFailed(ManagedLedgerException exception) { + recordRecoverStats( + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - recoverStartTimeNanos), false); + callback.operationFailed(exception); + } + }; ledger.getStore().asyncGetCursorInfo(ledger.getName(), name, new MetaStoreCallback() { @Override public void operationComplete(ManagedCursorInfo info, Stat stat) { @@ -586,18 +658,18 @@ public void operationComplete(ManagedCursorInfo info, Stat stat) { } recoveredCursor(recoveredPosition, recoveredProperties, recoveredCursorProperties, null); - callback.operationComplete(); + recoverCallback.operationComplete(); } else { // Need to proceed and read the last entry in the specified ledger to find out the last position log.info("[{}] Cursor {} meta-data recover from ledger {}", ledger.getName(), name, info.getCursorsLedgerId()); - recoverFromLedger(info, callback); + recoverFromLedger(info, recoverCallback); } } @Override public void operationFailed(MetaStoreException e) { - callback.operationFailed(e); + recoverCallback.operationFailed(e); } }); } @@ -655,32 +727,7 @@ protected void recoverFromLedger(final ManagedCursorInfo info, final VoidCallbac LedgerEntry entry = seq.nextElement(); mbean.addReadCursorLedgerSize(entry.getLength()); - PositionInfo positionInfo; - try { - positionInfo = PositionInfo.parseFrom(entry.getEntry()); - } catch (InvalidProtocolBufferException e) { - callback.operationFailed(new ManagedLedgerException(e)); - return; - } - - Map recoveredProperties = Collections.emptyMap(); - if (positionInfo.getPropertiesCount() > 0) { - // Recover properties map - recoveredProperties = new HashMap<>(); - for (int i = 0; i < positionInfo.getPropertiesCount(); i++) { - LongProperty property = positionInfo.getProperties(i); - recoveredProperties.put(property.getName(), property.getValue()); - } - } - - Position position = PositionFactory.create(positionInfo.getLedgerId(), positionInfo.getEntryId()); - recoverIndividualDeletedMessages(positionInfo); - if (getConfig().isDeletionAtBatchIndexLevelEnabled() - && positionInfo.getBatchedEntryDeletionIndexInfoCount() > 0) { - recoverBatchDeletedIndexes(positionInfo.getBatchedEntryDeletionIndexInfoList()); - } - recoveredCursor(position, recoveredProperties, cursorProperties, lh); - callback.operationComplete(); + recoverWithCheckpointFormat(info, lh, ledgerId, entry.getEntry(), callback); }, null); }; try { @@ -693,6 +740,27 @@ protected void recoverFromLedger(final ManagedCursorInfo info, final VoidCallbac } } + private void recoverWithCheckpointFormat(ManagedCursorInfo info, LedgerHandle lh, long cursorLedgerId, + byte[] lastEntryBytes, VoidCallback callback) { + long hintLedgerId = info.hasLastCmCursorLedgerId() ? info.getLastCmCursorLedgerId() : -1; + long hintEntryId = info.hasLastCmCursorLedgerId() ? info.getLastCmEntryId() : -1; + ackPersistence.recoverWithHint(lh, hintLedgerId, hintEntryId).whenComplete((recovered, recoverError) -> { + if (recoverError != null) { + Throwable cause = FutureUtil.unwrapCompletionException(recoverError); + log.warn("Per-msgLedger checkpoint recovery failed, rewinding to ZK snapshot, ledgerId: {}", + cursorLedgerId, cause); + initialize(getRollbackPosition(info), Collections.emptyMap(), cursorProperties, callback); + return; + } + if (recovered != null && !recovered.isLegacy()) { + applyRecoveredCheckpoint(recovered, info, lh, cursorProperties, callback); + return; + } + byte[] legacyBytes = recovered != null ? recovered.legacyBytes() : lastEntryBytes; + applyLegacyPositionInfo(legacyBytes, info, lh, cursorProperties, callback); + }); + } + public void recoverIndividualDeletedMessages(PositionInfo positionInfo) { if (positionInfo.getIndividualDeletedMessagesCount() > 0) { recoverIndividualDeletedMessages(positionInfo.getIndividualDeletedMessagesList()); @@ -702,22 +770,7 @@ public void recoverIndividualDeletedMessages(PositionInfo positionInfo) { try { Map rangeMap = rangeList.stream().collect(Collectors.toMap(LongListMap::getKey, list -> list.getValuesList().stream().mapToLong(i -> i).toArray())); - // Guarantee compatability for the config "unackedRangesOpenCacheSetEnabled". - if (getConfig().isUnackedRangesOpenCacheSetEnabled()) { - individualDeletedMessages.build(rangeMap); - } else { - RangeSetWrapper rangeSetWrapperV2 = new RangeSetWrapper<>(positionRangeConverter, - positionRangeReverseConverter, true, - getConfig().isPersistentUnackedRangesWithMultipleEntriesEnabled()); - rangeSetWrapperV2.build(rangeMap); - rangeSetWrapperV2.forEach(range -> { - individualDeletedMessages.addOpenClosed(range.lowerEndpoint().getLedgerId(), - range.lowerEndpoint().getEntryId(), range.upperEndpoint().getLedgerId(), - range.upperEndpoint().getEntryId()); - return true; - }); - rangeSetWrapperV2.clear(); - } + individualDeletedMessages.build(rangeMap); } catch (Exception e) { log.warn("[{}]-{} Failed to recover individualDeletedMessages from serialized data", ledger.getName(), name, e); @@ -751,10 +804,16 @@ private List buildLongPropertiesMap(Map properties) { } private void recoverIndividualDeletedMessages(List individualDeletedMessagesList) { + recoverIndividualDeletedMessages(individualDeletedMessagesList.size(), individualDeletedMessagesList::get); + } + + @VisibleForTesting + void recoverIndividualDeletedMessages(int count, IntFunction accessor) { lock.writeLock().lock(); try { individualDeletedMessages.clear(); - individualDeletedMessagesList.forEach(messageRange -> { + for (int i = 0; i < count; i++) { + MessageRange messageRange = accessor.apply(i); MLDataFormats.NestedPositionInfo lowerEndpoint = messageRange.getLowerEndpoint(); MLDataFormats.NestedPositionInfo upperEndpoint = messageRange.getUpperEndpoint(); @@ -781,7 +840,7 @@ private void recoverIndividualDeletedMessages(List i individualDeletedMessages.addOpenClosed(upperEndpoint.getLedgerId(), -1, upperEndpoint.getLedgerId(), upperEndpoint.getEntryId()); } - }); + } } finally { lock.writeLock().unlock(); } @@ -809,6 +868,100 @@ private void recoverBatchDeletedIndexes ( } } + /** + * Applies a recovered {@link CursorCheckpointPersistence.RecoveredCheckpoint} to + * the cursor's in-memory state, then delegates to {@link #recoveredCursor} for finalization. + */ + private void applyRecoveredCheckpoint(CursorCheckpointPersistence.RecoveredCheckpoint recovered, + ManagedCursorInfo info, LedgerHandle lh, + Map cursorProperties, + VoidCallback callback) { + CursorCheckpoint cp = recovered.checkpoint(); + Map recoveredProperties = Collections.emptyMap(); + if (cp.getPropertiesCount() > 0) { + recoveredProperties = new HashMap<>(); + for (int i = 0; i < cp.getPropertiesCount(); i++) { + LongProperty prop = cp.getProperties(i); + recoveredProperties.put(prop.getName(), prop.getValue()); + } + } + Position position = PositionFactory.create(cp.getMarkDeleteLedgerId(), cp.getMarkDeleteEntryId()); + + // Track the recovered cursor ledger and every referenced old cursor ledger so GC can + // reclaim them across restarts once mark-delete passes the last ledger holding acks. + allCursorLedgerIds.add(lh.getId()); + for (AckStateRef ref : cp.getAckStateRefsList()) { + allCursorLedgerIds.add(ref.getCursorLedgerId()); + } + + // Apply per-ledger ack bitmaps via bulk build (RoaringBitmap bytes → LongBitmap). + Map bitmaps = new HashMap<>(); + for (Map.Entry e : recovered.ackData().entrySet()) { + byte[] bm = e.getValue().ackBitmap(); + if (bm != null && bm.length > 0) { + bitmaps.put(e.getKey(), bm); + } + } + lock.writeLock().lock(); + try { + individualDeletedMessages.buildFromBitmaps(bitmaps); + // Apply batch acks (across all ledgers) + if (getConfig().isDeletionAtBatchIndexLevelEnabled() && batchDeletedIndexes != null) { + for (CursorCheckpointPersistence.AckStateData data : recovered.ackData().values()) { + if (data.batchAcks() != null) { + for (BatchedEntryDeletionIndexInfo batchInfo : data.batchAcks()) { + Position batchPos = PositionFactory.create( + batchInfo.getPosition().getLedgerId(), batchInfo.getPosition().getEntryId()); + BitSet bitSet = new BitSet(); + for (int i = 0; i < batchInfo.getDeleteSetCount(); i++) { + bitSet.set((int) batchInfo.getDeleteSet(i)); + } + batchDeletedIndexes.put(batchPos, bitSet); + } + } + } + } + // Dirty state is fresh after recovery — nothing to persist until next ack. + individualDeletedMessages.resetDirtyKeys(); + } finally { + lock.writeLock().unlock(); + } + + recoveredCursor(position, recoveredProperties, cursorProperties, lh); + callback.operationComplete(); + } + + /** + * Legacy fallback: parses bytes as {@link PositionInfo} and runs the existing + * recovery path. + */ + private void applyLegacyPositionInfo(byte[] bytes, ManagedCursorInfo info, LedgerHandle lh, + Map cursorProperties, VoidCallback callback) { + PositionInfo positionInfo; + try { + positionInfo = PositionInfo.parseFrom(bytes); + } catch (Exception e) { + callback.operationFailed(new ManagedLedgerException(e)); + return; + } + Map recoveredProperties = Collections.emptyMap(); + if (positionInfo.getPropertiesCount() > 0) { + recoveredProperties = new HashMap<>(); + for (int i = 0; i < positionInfo.getPropertiesCount(); i++) { + LongProperty property = positionInfo.getProperties(i); + recoveredProperties.put(property.getName(), property.getValue()); + } + } + Position position = PositionFactory.create(positionInfo.getLedgerId(), positionInfo.getEntryId()); + recoverIndividualDeletedMessages(positionInfo); + if (getConfig().isDeletionAtBatchIndexLevelEnabled() + && positionInfo.getBatchedEntryDeletionIndexInfoCount() > 0) { + recoverBatchDeletedIndexes(positionInfo.getBatchedEntryDeletionIndexInfoList()); + } + recoveredCursor(position, recoveredProperties, cursorProperties, lh); + callback.operationComplete(); + } + private void recoveredCursor(Position position, Map properties, Map cursorProperties, LedgerHandle recoveredFromCursorLedger) { @@ -1289,8 +1442,41 @@ public int getNonContiguousDeletedMessagesRangeSerializedSize() { return this.individualDeletedMessagesSerializedSize; } + long getNumberOfIndividualDeletedMessages() { + return individualDeletedMessages.totalCardinality(); + } + + String getFirstIndividualDeletedMessage() { + lock.readLock().lock(); + try { + Range first = individualDeletedMessages.firstRange(); + if (first == null) { + return null; + } + return PositionFactory.create(first.lowerEndpoint().getLedgerId(), + first.lowerEndpoint().getEntryId() + 1).toString(); + } finally { + lock.readLock().unlock(); + } + } + @Override public long getEstimatedSizeSinceMarkDeletePosition() { + Position markDeletePosition = this.markDeletePosition; + Position lastPosition = ledger.getLastPosition(); + if (markDeletePosition == null || markDeletePosition.compareTo(lastPosition) == 0) { + return 0; + } + if (markDeletePosition.compareTo(lastPosition) > 0) { + if (!ledger.ledgerExists(lastPosition.getLedgerId()) + || isMarkDeletePositionOnEmptyCurrentLedger(markDeletePosition)) { + return 0; + } + throw new IllegalArgumentException(String.format( + "Cursor %s mark-delete position %s is ahead of the last position %s for managed ledger %s", + name, markDeletePosition, lastPosition, ledger.getName())); + } + long totalSize = ledger.estimateBacklogFromPosition(markDeletePosition); // Need to subtract size of individual deleted messages @@ -1303,24 +1489,10 @@ public long getEstimatedSizeSinceMarkDeletePosition() { long deletedCount = 0; lock.readLock().lock(); try { - Range backlogRange = Range.openClosed(markDeletePosition, ledger.getLastPosition()); - - if (getConfig().isUnackedRangesOpenCacheSetEnabled()) { - deletedCount = individualDeletedMessages.cardinality( - backlogRange.lowerEndpoint().getLedgerId(), backlogRange.lowerEndpoint().getEntryId(), - backlogRange.upperEndpoint().getLedgerId(), backlogRange.upperEndpoint().getEntryId()); - } else { - AtomicLong deletedCounter = new AtomicLong(0); - individualDeletedMessages.forEach((r) -> { - if (r.isConnected(backlogRange)) { - Range intersection = r.intersection(backlogRange); - long countInRange = ledger.getNumberOfEntries(intersection); - deletedCounter.addAndGet(countInRange); - } - return true; - }, recyclePositionRangeConverter); - deletedCount = deletedCounter.get(); - } + Range backlogRange = Range.openClosed(markDeletePosition, lastPosition); + deletedCount = individualDeletedMessages.cardinality( + backlogRange.lowerEndpoint().getLedgerId(), backlogRange.lowerEndpoint().getEntryId(), + backlogRange.upperEndpoint().getLedgerId(), backlogRange.upperEndpoint().getEntryId()); } finally { lock.readLock().unlock(); } @@ -1330,7 +1502,7 @@ public long getEstimatedSizeSinceMarkDeletePosition() { } // Estimate size by using average entry size from the backlog range - Range backlogRange = Range.openClosed(markDeletePosition, ledger.getLastPosition()); + Range backlogRange = Range.openClosed(markDeletePosition, lastPosition); long totalEntriesInBacklog = ledger.getNumberOfEntries(backlogRange); if (totalEntriesInBacklog <= deletedCount || totalEntriesInBacklog == 0) { @@ -1356,6 +1528,12 @@ public long getEstimatedSizeSinceMarkDeletePosition() { return adjustedSize; } + private boolean isMarkDeletePositionOnEmptyCurrentLedger(Position markDeletePosition) { + return ledger.currentLedger != null + && markDeletePosition.getLedgerId() == ledger.currentLedger.getId() + && ledger.currentLedgerEntries == 0; + } + private long getNumberOfEntriesInBacklog() { if (markDeletePosition.compareTo(ledger.getLastPosition()) >= 0) { return 0; @@ -1383,6 +1561,50 @@ public long getNumberOfEntriesInBacklog(boolean isPrecise) { return backlog; } + @Override + public boolean hasBacklog() { + Position markDeletePosition = this.markDeletePosition; + Position lastPosition = ledger.getLastPosition(); + if (markDeletePosition == null || markDeletePosition.compareTo(lastPosition) >= 0) { + return false; + } + + Position nextPosition = ledger.getNextValidPosition(markDeletePosition); + if (nextPosition.compareTo(lastPosition) > 0) { + return false; + } + + lock.readLock().lock(); + try { + while (nextPosition.compareTo(lastPosition) <= 0) { + Range deletedRange = individualDeletedMessages.rangeContaining( + nextPosition.getLedgerId(), nextPosition.getEntryId()); + if (deletedRange == null) { + return true; + } + + Position upperEndpoint = deletedRange.upperEndpoint(); + if (upperEndpoint.compareTo(lastPosition) >= 0) { + return false; + } + nextPosition = ledger.getNextValidPosition(upperEndpoint); + } + return false; + } finally { + lock.readLock().unlock(); + } + } + + @Override + public boolean hasBacklog(boolean isPrecise) { + if (isPrecise) { + return hasBacklog(); + } + + long backlog = ManagedLedgerImpl.ENTRIES_ADDED_COUNTER_UPDATER.get(ledger) - messagesConsumedCounter; + return backlog >= 0 ? backlog > 0 : hasBacklog(); + } + public long getNumberOfEntriesInStorage() { return ledger.getNumberOfEntries(Range.openClosed(markDeletePosition, ledger.getLastPosition())); } @@ -1607,6 +1829,8 @@ protected void internalResetCursor(Position proposedReadPosition, MSG_CONSUMED_COUNTER_UPDATER.addAndGet(ManagedCursorImpl.this, -ackedEntriesAfterMdPosition.get().longValue()); markDeletePosition = newMarkDeletePosition; + // Clear: a racing mark-delete may have advanced this past the reset position. + persistentMarkDeletePosition = null; lastMarkDeleteEntry = new MarkDeleteEntry(newMarkDeletePosition, isCompactionCursor() ? getProperties() : Collections.emptyMap(), null, null); individualDeletedMessages.clear(); @@ -1616,6 +1840,7 @@ protected void internalResetCursor(Position proposedReadPosition, long[] resetWords = ackSetState.getAckSet(); if (resetWords != null) { batchDeletedIndexes.put(newReadPosition, BitSet.valueOf(resetWords)); + individualDeletedMessages.markDirtyLedger(newReadPosition.getLedgerId()); } }); } @@ -1669,7 +1894,7 @@ public void operationFailed(ManagedLedgerException exception) { persistentMarkDeletePosition = null; inProgressMarkDeletePersistPosition = null; - internalAsyncMarkDelete(newMarkDeletePosition, isCompactionCursor() ? getProperties() : Collections.emptyMap(), + internalAsyncMarkDelete(newMarkDeletePosition, isCompactionCursor() ? null : Collections.emptyMap(), new MarkDeleteCallback() { @Override public void markDeleteComplete(Object ctx) { @@ -1872,45 +2097,22 @@ protected long getNumberOfEntries(Range range) { log.debug("[{}] getNumberOfEntries. {} allEntries: {}", ledger.getName(), range, allEntries); } - AtomicLong deletedEntries = new AtomicLong(0); + long deletedEntriesCount = 0; lock.readLock().lock(); try { - if (getConfig().isUnackedRangesOpenCacheSetEnabled()) { - int cardinality = individualDeletedMessages.cardinality( - range.lowerEndpoint().getLedgerId(), range.lowerEndpoint().getEntryId(), - range.upperEndpoint().getLedgerId(), range.upperEndpoint().getEntryId()); - deletedEntries.addAndGet(cardinality); - } else { - individualDeletedMessages.forEach((r) -> { - try { - if (r.isConnected(range)) { - Range commonEntries = r.intersection(range); - long commonCount = ledger.getNumberOfEntries(commonEntries); - if (log.isDebugEnabled()) { - log.debug("[{}] [{}] Discounting {} entries for already deleted range {}", - ledger.getName(), name, commonCount, commonEntries); - } - deletedEntries.addAndGet(commonCount); - } - return true; - } finally { - if (r.lowerEndpoint() instanceof PositionRecyclable) { - ((PositionRecyclable) r.lowerEndpoint()).recycle(); - ((PositionRecyclable) r.upperEndpoint()).recycle(); - } - } - }, recyclePositionRangeConverter); - } + deletedEntriesCount = individualDeletedMessages.cardinality( + range.lowerEndpoint().getLedgerId(), range.lowerEndpoint().getEntryId(), + range.upperEndpoint().getLedgerId(), range.upperEndpoint().getEntryId()); } finally { lock.readLock().unlock(); } if (log.isDebugEnabled()) { - log.debug("[{}] Found {} entries - deleted: {}", ledger.getName(), allEntries - deletedEntries.get(), - deletedEntries); + log.debug("[{}] Found {} entries - deleted: {}", ledger.getName(), allEntries - deletedEntriesCount, + deletedEntriesCount); } - return allEntries - deletedEntries.get(); + return allEntries - deletedEntriesCount; } @@ -2229,9 +2431,23 @@ public MarkDeletingMarkedPosition(String s) { public void asyncMarkDelete(final Position position, Map properties, final MarkDeleteCallback callback, final Object ctx) { requireNonNull(position); + final long ackStartTimeNanos = System.nanoTime(); + final MarkDeleteCallback ackCallback = new MarkDeleteCallback() { + @Override + public void markDeleteComplete(Object ctx) { + recordAckStats(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - ackStartTimeNanos)); + callback.markDeleteComplete(ctx); + } + + @Override + public void markDeleteFailed(ManagedLedgerException exception, Object ctx) { + recordAckStats(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - ackStartTimeNanos)); + callback.markDeleteFailed(exception, ctx); + } + }; if (isClosed()) { - callback.markDeleteFailed(new ManagedLedgerException + ackCallback.markDeleteFailed(new ManagedLedgerException .CursorAlreadyClosedException("Cursor was already closed"), ctx); return; } @@ -2241,7 +2457,7 @@ public void asyncMarkDelete(final Position position, Map propertie log.debug("[{}] cursor reset in progress - ignoring mark delete on position [{}] for cursor [{}]", ledger.getName(), position, name); } - callback.markDeleteFailed( + ackCallback.markDeleteFailed( new ManagedLedgerException("Reset cursor in progress - unable to mark delete position " + position.toString()), ctx); @@ -2275,7 +2491,7 @@ public void asyncMarkDelete(final Position position, Map propertie log.debug("[{}] Failed mark delete due to invalid markDelete {} is ahead of last-confirmed-entry {}" + " for cursor [{}]", ledger.getName(), position, lastConfirmedEntry, name); } - callback.markDeleteFailed(new ManagedLedgerException("Invalid mark deleted position"), ctx); + ackCallback.markDeleteFailed(new ManagedLedgerException("Invalid mark deleted position"), ctx); return; } } @@ -2284,7 +2500,7 @@ public void asyncMarkDelete(final Position position, Map propertie try { newPosition = setAcknowledgedPosition(newPosition); } catch (IllegalArgumentException e) { - callback.markDeleteFailed(getManagedLedgerException(e), ctx); + ackCallback.markDeleteFailed(getManagedLedgerException(e), ctx); return; } finally { lock.writeLock().unlock(); @@ -2294,10 +2510,10 @@ public void asyncMarkDelete(final Position position, Map propertie if (markDeleteLimiter != null && !markDeleteLimiter.tryAcquire()) { isDirty = true; updateLastMarkDeleteEntryToLatest(newPosition, properties); - callback.markDeleteComplete(ctx); + ackCallback.markDeleteComplete(ctx); return; } - internalAsyncMarkDelete(newPosition, properties, callback, ctx, null); + internalAsyncMarkDelete(newPosition, properties, ackCallback, ctx, null); } private Position ackBatchPosition(Position position) { @@ -2336,7 +2552,7 @@ protected void internalAsyncMarkDelete(final Position newPosition, Map propertiesToUse = properties != null ? properties : (last != null ? last.properties : getProperties()); MarkDeleteEntry mdEntry = new MarkDeleteEntry(newPosition, propertiesToUse, callback, ctx, - alignAcknowledgeStatusAfterPersisted); + alignAcknowledgeStatusAfterPersisted, alignAcknowledgeStatusAfterPersisted != null); // The state might have changed while we were waiting on the queue mutex switch (state) { @@ -2433,7 +2649,9 @@ public void operationComplete() { // point. lock.writeLock().lock(); try { - mdEntry.alignAcknowledgeStatus(); + if (mdEntry.persistedSuccessfully) { + mdEntry.alignAcknowledgeStatus(); + } } finally { lock.writeLock().unlock(); } @@ -2537,8 +2755,38 @@ public void deleteFailed(ManagedLedgerException exception, Object ctx) { @Override public void asyncDelete(Iterable positions, AsyncCallbacks.DeleteCallback callback, Object ctx) { + Iterable ackPositions = positions; + if (!(ackPositions instanceof Collection)) { + List materialized = new ArrayList<>(); + for (Position position : ackPositions) { + materialized.add(position); + } + ackPositions = materialized; + } + final int ackPositionCount = ((Collection) ackPositions).size(); + final long ackStartTimeNanos = System.nanoTime(); + final AsyncCallbacks.DeleteCallback ackCallback = new AsyncCallbacks.DeleteCallback() { + @Override + public void deleteComplete(Object ctx) { + long latencyMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - ackStartTimeNanos); + for (int i = 0; i < ackPositionCount; i++) { + recordAckStats(latencyMillis); + } + callback.deleteComplete(ctx); + } + + @Override + public void deleteFailed(ManagedLedgerException exception, Object ctx) { + long latencyMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - ackStartTimeNanos); + for (int i = 0; i < ackPositionCount; i++) { + recordAckStats(latencyMillis); + } + callback.deleteFailed(exception, ctx); + } + }; + if (isClosed()) { - callback.deleteFailed(new ManagedLedgerException + ackCallback.deleteFailed(new ManagedLedgerException .CursorAlreadyClosedException("Cursor was already closed"), ctx); return; } @@ -2553,7 +2801,7 @@ public void asyncDelete(Iterable positions, AsyncCallbacks.DeleteCallb ledger.getName(), name, positions, individualDeletedMessages, markDeletePosition); } - for (Position pos : positions) { + for (Position pos : ackPositions) { Position position = requireNonNull(pos); if (ledger.getLastConfirmedEntry().compareTo(position) < 0) { if (log.isDebugEnabled()) { @@ -2561,7 +2809,7 @@ public void asyncDelete(Iterable positions, AsyncCallbacks.DeleteCallb "[{}] Failed mark delete due to invalid markDelete {} is ahead of last-confirmed-entry {} " + "for cursor [{}]", ledger.getName(), position, ledger.getLastConfirmedEntry(), name); } - callback.deleteFailed(new ManagedLedgerException("Invalid mark deleted position"), ctx); + ackCallback.deleteFailed(new ManagedLedgerException("Invalid mark deleted position"), ctx); return; } @@ -2610,6 +2858,10 @@ public void asyncDelete(Iterable positions, AsyncCallbacks.DeleteCallb position.getLedgerId(), position.getEntryId()); MSG_CONSUMED_COUNTER_UPDATER.incrementAndGet(this); batchDeletedIndexes.remove(position); + } else { + // Batch-index deletions are not reflected in the individual range bitmap, so + // mark the ledger dirty explicitly to ensure they are persisted. + individualDeletedMessages.markDirtyLedger(position.getLedgerId()); } } } @@ -2658,12 +2910,12 @@ public void asyncDelete(Iterable positions, AsyncCallbacks.DeleteCallb } catch (Exception e) { log.warn("[{}] [{}] Error while updating individualDeletedMessages [{}]", ledger.getName(), name, e.getMessage(), e); - callback.deleteFailed(getManagedLedgerException(e), ctx); + ackCallback.deleteFailed(getManagedLedgerException(e), ctx); return; } finally { lock.writeLock().unlock(); if (skipMarkDeleteBecauseAckedNothing) { - callback.deleteComplete(ctx); + ackCallback.deleteComplete(ctx); } } @@ -2671,7 +2923,7 @@ public void asyncDelete(Iterable positions, AsyncCallbacks.DeleteCallb if (markDeleteLimiter != null && !markDeleteLimiter.tryAcquire()) { isDirty = true; updateLastMarkDeleteEntryToLatest(newMarkDeletePosition, null); - callback.deleteComplete(ctx); + ackCallback.deleteComplete(ctx); return; } @@ -2679,12 +2931,12 @@ public void asyncDelete(Iterable positions, AsyncCallbacks.DeleteCallb internalAsyncMarkDelete(newMarkDeletePosition, null, new MarkDeleteCallback() { @Override public void markDeleteComplete(Object ctx) { - callback.deleteComplete(ctx); + ackCallback.deleteComplete(ctx); } @Override public void markDeleteFailed(ManagedLedgerException exception, Object ctx) { - callback.deleteFailed(exception, ctx); + ackCallback.deleteFailed(exception, ctx); } }, ctx, null); @@ -2695,7 +2947,7 @@ public void markDeleteFailed(ManagedLedgerException exception, Object ctx) { log.debug("[{}] Consumer {} cursor asyncDelete error, counters: consumed {} mdPos {} rdPos {}", ledger.getName(), name, messagesConsumedCounter, markDeletePosition, readPosition); } - callback.deleteFailed(new ManagedLedgerException(e), ctx); + ackCallback.deleteFailed(new ManagedLedgerException(e), ctx); } } @@ -2941,6 +3193,11 @@ public void operationFailed(MetaStoreException e) { private boolean shouldPersistUnackRangesToLedger() { lock.readLock().lock(); try { + // With the per-msgLedger checkpoint log enabled, closing always persists to BK: + // holes must never be collapsed into the ZK metadata (the legacy small-state path). + if (getConfig().isPersistentUnackedRangesWithPerLedgerEntryEnabled()) { + return cursorLedger != null && !isCursorLedgerReadOnly; + } return cursorLedger != null && !isCursorLedgerReadOnly && getConfig().getMaxUnackedRangesToPersist() > 0 @@ -2968,6 +3225,11 @@ private void persistPositionMetaStore(long cursorsLedgerId, Position position, M .setMarkDeleteEntryId(position.getEntryId()) // .setLastActive(lastActive); // + if (pendingCheckpointHintLedgerId >= 0) { + info.setLastCmCursorLedgerId(pendingCheckpointHintLedgerId); + info.setLastCmEntryId(pendingCheckpointHintEntryId); + } + info.addAllProperties(buildPropertiesMap(properties)); info.addAllCursorProperties(buildStringPropertiesMap(cursorProperties)); if (persistIndividualDeletedMessageRanges) { @@ -3373,8 +3635,15 @@ private List buildIndividualDeletedMessageRanges() { AtomicInteger acksSerializedSize = new AtomicInteger(0); List rangeList = new ArrayList<>(); + final int maxRanges = getConfig().getMaxUnackedRangesToPersist(); + final MutableBoolean truncated = new MutableBoolean(false); individualDeletedMessages.forEachRawRange((lowerKey, lowerValue, upperKey, upperValue) -> { + if (rangeList.size() >= maxRanges) { + truncated.setTrue(); + return false; + } + MLDataFormats.NestedPositionInfo lowerPosition = nestedPositionBuilder .setLedgerId(lowerKey) .setEntryId(lowerValue) @@ -3393,11 +3662,29 @@ private List buildIndividualDeletedMessageRanges() { acksSerializedSize.addAndGet(messageRange.getSerializedSize()); rangeList.add(messageRange); - return rangeList.size() <= getConfig().getMaxUnackedRangesToPersist(); + return true; }); this.individualDeletedMessagesSerializedSize = acksSerializedSize.get(); individualDeletedMessages.resetDirtyKeys(); + + if (truncated.booleanValue()) { + ledger.getFactory().getOpenTelemetryManagedCursorStats() + .incrementPersistUnackedRangesTruncated(this); + if (lastCursorDataFullyPersistable.compareAndSet(true, false)) { + int totalRanges = individualDeletedMessages.size(); + log.warn("[{}]-{} Individually deleted message ranges exceed" + + " managedLedgerMaxUnackedRangesToPersist (totalRanges: {}, maxRanges: {}," + + " truncated: {}). Acknowledged messages beyond this limit are not persisted" + + " and will be replayed on broker restart. Consider raising" + + " managedLedgerMaxUnackedRangesToPersist or enabling" + + " managedLedgerPersistIndividualAckAsLongArray to reduce the persisted size.", + ledger.getName(), name, totalRanges, maxRanges, totalRanges - rangeList.size()); + } + } else { + lastCursorDataFullyPersistable.compareAndSet(false, true); + } + return rangeList; } finally { lock.writeLock().unlock(); @@ -3416,7 +3703,8 @@ private List buildBatchEntryDeletio .BatchedEntryDeletionIndexInfo.newBuilder(); List result = new ArrayList<>(); final var iterator = batchDeletedIndexes.entrySet().iterator(); - while (iterator.hasNext() && result.size() < getConfig().getMaxBatchDeletedIndexToPersist()) { + final int maxIndexes = getConfig().getMaxBatchDeletedIndexToPersist(); + while (iterator.hasNext() && result.size() < maxIndexes) { final var entry = iterator.next(); nestedPositionBuilder.setLedgerId(entry.getKey().getLedgerId()); nestedPositionBuilder.setEntryId(entry.getKey().getEntryId()); @@ -3430,6 +3718,12 @@ private List buildBatchEntryDeletio batchDeletedIndexInfoBuilder.addAllDeleteSet(deleteSet); result.add(batchDeletedIndexInfoBuilder.build()); } + + if (iterator.hasNext()) { + ledger.getFactory().getOpenTelemetryManagedCursorStats() + .incrementPersistBatchDeletedIndexesTruncated(this); + } + return result; } finally { lock.readLock().unlock(); @@ -3438,6 +3732,72 @@ private List buildBatchEntryDeletio void persistPositionToLedger(final LedgerHandle lh, MarkDeleteEntry mdEntry, final VoidCallback callback, boolean ignoreClosedStateAfterFailure) { + final long persistStartTimeNanos = System.nanoTime(); + final VoidCallback persistCallback = new VoidCallback() { + @Override + public void operationComplete() { + recordPersistStats(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - persistStartTimeNanos)); + callback.operationComplete(); + } + + @Override + public void operationFailed(ManagedLedgerException exception) { + recordPersistStats(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - persistStartTimeNanos)); + callback.operationFailed(exception); + } + }; + if (ackPersistence.isPerLedgerEntryPersistEnabled()) { + ackPersistence.persist(lh, mdEntry.newPosition, mdEntry.properties, this) + .thenAccept(result -> { + mdEntry.persistedSuccessfully = true; + pendingCheckpointHintLedgerId = lh.getId(); + pendingCheckpointHintEntryId = result.commitEntryId(); + mbean.persistToLedger(true); + mbean.addWriteCursorLedgerSize(result.totalBytes()); + rolloverLedgerIfNeeded(lh); + persistCallback.operationComplete(); + }) + .exceptionally(error -> { + Throwable cause = FutureUtil.unwrapCompletionException(error); + log.warn("Per-msgLedger checkpoint persist failed, keeping state in memory;" + + " retried on the next mark-delete, ledgerId: {}, errorMessage: {}", + lh.getId(), cause.getMessage(), cause); + mbean.persistToLedger(false); + if (cause instanceof BKException) { + // BKException fences the handle; next mark-delete creates a fresh ledger. + STATE_UPDATER.compareAndSet(ManagedCursorImpl.this, State.Open, State.NoLedger); + } + // Regular mark-deletes stay tolerated and retried (at-least-once): + // ZK must never carry the large ack bitmaps. Cursor resets are different: + // their target state has no holes, so an md-only tombstone in ZK is a + // complete representation. Fall back to ZK; only if that fails too does + // the reset surface as an error (memory untouched, consistent). + if (mdEntry.propagatePersistFailure) { + persistPositionMetaStore(-1, mdEntry.newPosition, mdEntry.properties, + new MetaStoreCallback() { + @Override + public void operationComplete(Void result, Stat stat) { + // The tombstone is durable (in ZK): mark the entry so + // the align gate applies the in-memory reset. + mdEntry.persistedSuccessfully = true; + persistCallback.operationComplete(); + } + + @Override + public void operationFailed(MetaStoreException metaStoreException) { + metaStoreException.addSuppressed(cause); + persistCallback.operationFailed(createManagedLedgerException( + metaStoreException)); + } + }, false); + } else { + persistCallback.operationComplete(); + } + return null; + }); + return; + } + Position position = mdEntry.newPosition; Builder piBuilder = PositionInfo.newBuilder().setLedgerId(position.getLedgerId()) .setEntryId(position.getEntryId()) @@ -3454,10 +3814,27 @@ void persistPositionToLedger(final LedgerHandle lh, MarkDeleteEntry mdEntry, fin * Do not enable the feature that https://github.com/apache/pulsar/pull/9292 introduced, to avoid serialization * and deserialization error. */ - if (getConfig().isUnackedRangesOpenCacheSetEnabled() && getConfig().isPersistIndividualAckAsLongArray()) { + + if (getConfig().isPersistIndividualAckAsLongArray()) { lock.readLock().lock(); try { - internalRanges = individualDeletedMessages.toRanges(getConfig().getMaxUnackedRangesToPersist()); + MutableBoolean truncatedLedger = new MutableBoolean(false); + internalRanges = individualDeletedMessages.toRanges(getConfig().getMaxUnackedRangesToPersist(), + skippedLedgers -> { + truncatedLedger.setTrue(); + ledger.getFactory().getOpenTelemetryManagedCursorStats() + .incrementPersistUnackedRangesTruncated(this); + if (lastCursorDataFullyPersistable.compareAndSet(true, false)) { + log.warn("[{}]-{} Truncated individual acks of {} ledgers at persistence:" + + " cumulative cardinality exceeds maxUnackedRangesToPersist={}." + + " Ack state in skipped ledgers is lost on broker restart.", + ledger.getName(), name, skippedLedgers, + getConfig().getMaxUnackedRangesToPersist()); + } + }); + if (!truncatedLedger.get()) { + lastCursorDataFullyPersistable.compareAndSet(false, true); + } } catch (Exception e) { log.warn("[{}]-{} Failed to serialize individualDeletedMessages", ledger.getName(), name, e); } finally { @@ -3485,16 +3862,17 @@ void persistPositionToLedger(final LedgerHandle lh, MarkDeleteEntry mdEntry, fin lh1.getId()); } + mdEntry.persistedSuccessfully = true; rolloverLedgerIfNeeded(lh1); mbean.persistToLedger(true); mbean.addWriteCursorLedgerSize(data.length); - callback.operationComplete(); + persistCallback.operationComplete(); } else { if (!ignoreClosedStateAfterFailure && state.isClosed()) { // After closed the cursor, the in-progress persistence task will get a // BKException.Code.LedgerClosedException. - callback.operationFailed(new CursorAlreadyClosedException(String.format("%s %s skipped this" + persistCallback.operationFailed(new CursorAlreadyClosedException(String.format("%s %s skipped this" + " persistence, because the cursor already closed", ledger.getName(), name))); return; } @@ -3505,7 +3883,7 @@ void persistPositionToLedger(final LedgerHandle lh, MarkDeleteEntry mdEntry, fin STATE_UPDATER.compareAndSet(ManagedCursorImpl.this, State.Open, State.NoLedger); // Before giving up, try to persist the position in the metadata store. - persistPositionToMetaStore(mdEntry, callback); + persistPositionToMetaStore(mdEntry, persistCallback); } }, null); } @@ -3551,6 +3929,7 @@ public void operationComplete(Void result, Stat stat) { "[{}][{}] Updated cursor in meta store after previous failure in ledger at position" + " {}", ledger.getName(), name, newPosition); } + mdEntry.persistedSuccessfully = true; mbean.persistToZookeeper(true); callback.operationComplete(); } @@ -3580,6 +3959,61 @@ boolean shouldCloseLedger(LedgerHandle lh) { } } + /** + * GC for old cursor ledgers. Uses the simplified {@code maxHold} model — when + * mark-delete has advanced past the highest msgLedgerId that still holds individual or batch + * acks, + * all referenced AckStateRefs are stale and every old cursor ledger can be deleted. + * + *

This is an all-or-nothing GC: it either deletes all tracked old ledgers or none. + * Unreferenced ledgers that were never tracked (e.g. from before a restart) leak — + * acceptable because they hold no live data. + */ + @VisibleForTesting + void gcOldCursorLedgers() { + if (!ackPersistence.isPerLedgerEntryPersistEnabled() || allCursorLedgerIds.isEmpty()) { + return; + } + long mdLedgerId = markDeletePosition.getLedgerId(); + // Find the highest msgLedgerId that currently has individual acks above mark-delete. + // individualDeletedMessages.rangeBitmapMap is not thread-safe, so the iteration must + // hold the cursor readLock to prevent concurrent mark-delete / delete modifications. + long[] maxHold = {-1}; + lock.readLock().lock(); + try { + individualDeletedMessages.forEachActiveLedger(id -> { + if (id > maxHold[0]) { + maxHold[0] = id; + } + }); + if (batchDeletedIndexes != null) { + batchDeletedIndexes.keySet().forEach(pos -> { + long id = pos.getLedgerId(); + if (id > maxHold[0]) { + maxHold[0] = id; + } + }); + } + } finally { + lock.readLock().unlock(); + } + if (maxHold[0] >= 0 && mdLedgerId < maxHold[0]) { + return; + } + for (long id : new ArrayList<>(allCursorLedgerIds)) { + log.debug("GC old cursor ledger - maxHold cleared, ledgerId: {}", id); + bookkeeper.asyncDeleteLedger(id, (rc, ctx) -> { + if (rc == BKException.Code.OK || rc == BKException.Code.NoSuchLedgerExistsException) { + allCursorLedgerIds.remove(id); + } else { + log.warn("Failed to GC old cursor ledger, will retry on next rollover, ledgerId: {}, " + + "errorMessage: {}", + id, BKException.getMessage(rc)); + } + }, null); + } + } + void switchToNewLedger(final LedgerHandle lh, final VoidCallback callback) { if (log.isDebugEnabled()) { log.debug("[{}] Switching cursor {} to ledger {}", ledger.getName(), name, lh.getId()); @@ -3597,7 +4031,12 @@ public void operationComplete(Void result, Stat stat) { // At this point the position had already been safely markdeleted callback.operationComplete(); - asyncDeleteLedger(oldLedger); + if (ackPersistence.isPerLedgerEntryPersistEnabled() && oldLedger != null) { + allCursorLedgerIds.add(oldLedger.getId()); + gcOldCursorLedgers(); + } else { + asyncDeleteLedger(oldLedger); + } } @Override @@ -3982,6 +4421,12 @@ public ManagedLedger getManagedLedger() { return this.ledger; } + @Override + public void scheduleReadCallback(Runnable callback, long delay, TimeUnit unit) { + ledger.getScheduledExecutor().schedule( + catchingAndLoggingThrowables(() -> ledger.getExecutor().execute(callback)), delay, unit); + } + @Override public Range getLastIndividualDeletedRange() { lock.readLock().lock(); @@ -4146,7 +4591,8 @@ public ManagedLedgerInternalStats.CursorStats getCursorStats() { cs.messagesConsumedCounter = getMessagesConsumedCounter(); cs.cursorLedger = getCursorLedger(); cs.cursorLedgerLastEntry = getCursorLedgerLastEntry(); - cs.individuallyDeletedMessages = getIndividuallyDeletedMessages(); + cs.individualDeletedMessagesCount = getNumberOfIndividualDeletedMessages(); + cs.firstIndividualDeletedMessage = getFirstIndividualDeletedMessage(); cs.lastLedgerSwitchTimestamp = DateFormatter.format(getLastLedgerSwitchTimestamp()); cs.state = getState(); cs.active = isActive(); diff --git a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/ManagedCursorMXBeanImpl.java b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/ManagedCursorMXBeanImpl.java index a183c0d61ce16..69b374870aeeb 100644 --- a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/ManagedCursorMXBeanImpl.java +++ b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/ManagedCursorMXBeanImpl.java @@ -34,6 +34,17 @@ public class ManagedCursorMXBeanImpl implements ManagedCursorMXBean { private final LongAdder writeCursorLedgerLogicalSize = new LongAdder(); private final LongAdder readCursorLedgerSize = new LongAdder(); + private final LongAdder ackCount = new LongAdder(); + private final LongAdder ackLatencyTotalMillis = new LongAdder(); + + private final LongAdder persistCount = new LongAdder(); + private final LongAdder persistLatencyTotalMillis = new LongAdder(); + + private final LongAdder recoverCount = new LongAdder(); + private final LongAdder recoverSucceed = new LongAdder(); + private final LongAdder recoverErrors = new LongAdder(); + private final LongAdder recoverLatencyTotalMillis = new LongAdder(); + private final ManagedCursor managedCursor; public ManagedCursorMXBeanImpl(ManagedCursor managedCursor) { @@ -114,4 +125,70 @@ public long getWriteCursorLedgerLogicalSize() { public long getReadCursorLedgerSize() { return readCursorLedgerSize.longValue(); } + + @Override + public void recordAck(long latencyMillis) { + ackCount.increment(); + ackLatencyTotalMillis.add(latencyMillis); + } + + @Override + public long getAckCount() { + return ackCount.longValue(); + } + + @Override + public double getAckLatencyAvgMillis() { + long count = ackCount.longValue(); + return count == 0 ? 0 : ackLatencyTotalMillis.longValue() / (double) count; + } + + @Override + public void recordPersist(long latencyMillis) { + persistCount.increment(); + persistLatencyTotalMillis.add(latencyMillis); + } + + @Override + public long getPersistCount() { + return persistCount.longValue(); + } + + @Override + public double getPersistLatencyAvgMillis() { + long count = persistCount.longValue(); + return count == 0 ? 0 : persistLatencyTotalMillis.longValue() / (double) count; + } + + @Override + public void recordRecover(long latencyMillis, boolean success) { + recoverCount.increment(); + recoverLatencyTotalMillis.add(latencyMillis); + if (success) { + recoverSucceed.increment(); + } else { + recoverErrors.increment(); + } + } + + @Override + public long getRecoverCount() { + return recoverCount.longValue(); + } + + @Override + public long getRecoverSucceed() { + return recoverSucceed.longValue(); + } + + @Override + public long getRecoverErrors() { + return recoverErrors.longValue(); + } + + @Override + public double getRecoverLatencyAvgMillis() { + long count = recoverCount.longValue(); + return count == 0 ? 0 : recoverLatencyTotalMillis.longValue() / (double) count; + } } diff --git a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/ManagedLedgerFactoryImpl.java b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/ManagedLedgerFactoryImpl.java index bcca3fb9a5286..8690653ec2477 100644 --- a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/ManagedLedgerFactoryImpl.java +++ b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/ManagedLedgerFactoryImpl.java @@ -19,7 +19,7 @@ package org.apache.bookkeeper.mledger.impl; import static org.apache.bookkeeper.mledger.ManagedLedgerException.getManagedLedgerException; -import static org.apache.bookkeeper.mledger.impl.ManagedLedgerImpl.NULL_OFFLOAD_PROMISE; +import static org.apache.bookkeeper.mledger.impl.ManagedLedgerImpl.AUTOMATIC_OFFLOAD_TRIGGER; import static org.apache.pulsar.common.util.Runnables.catchingAndLoggingThrowables; import com.google.common.base.Predicates; import com.google.common.collect.BoundType; @@ -250,6 +250,10 @@ private ManagedLedgerFactoryImpl(MetadataStoreExtended metadataStore, openTelemetryManagedCursorStats = new OpenTelemetryManagedCursorStats(openTelemetry, this); } + public OpenTelemetryManagedCursorStats getOpenTelemetryManagedCursorStats() { + return openTelemetryManagedCursorStats; + } + static class DefaultBkFactory implements BookkeeperFactoryForCustomEnsemblePlacementPolicy, AutoCloseable { private final BookKeeper bkClient; @@ -430,7 +434,7 @@ public void initializeComplete() { future.complete(newledger); // May need to trigger offloading if (config.isTriggerOffloadOnTopicLoad()) { - newledger.maybeOffloadInBackground(NULL_OFFLOAD_PROMISE); + newledger.maybeOffloadInBackground(AUTOMATIC_OFFLOAD_TRIGGER); } }); } diff --git a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/ManagedLedgerImpl.java b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/ManagedLedgerImpl.java index 7c86f4396e39f..8697a7ba8ab7e 100644 --- a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/ManagedLedgerImpl.java +++ b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/ManagedLedgerImpl.java @@ -228,8 +228,20 @@ public class ManagedLedgerImpl implements ManagedLedger, CreateCallback { protected final CallbackMutex trimmerMutex = new CallbackMutex(); protected final CallbackMutex offloadMutex = new CallbackMutex(); - public static final CompletableFuture NULL_OFFLOAD_PROMISE = CompletableFuture + private final AutomaticOffloadTriggerController automaticOffloadTriggerController = + new AutomaticOffloadTriggerController(); + // Identity sentinel for automatic offload requests. The completed Position value is not used. + public static final CompletableFuture AUTOMATIC_OFFLOAD_TRIGGER = CompletableFuture .completedFuture(PositionFactory.LATEST); + + private enum OffloadRequestSource { + AUTOMATIC, + EXPLICIT + } + + private record OffloadThresholds(long thresholdInBytes, long thresholdInSeconds) { + } + @VisibleForTesting @Getter protected volatile LedgerHandle currentLedger; @@ -1955,7 +1967,7 @@ synchronized void ledgerClosed(final LedgerHandle lh, Long lastAddConfirmed, Led trimConsumedLedgersInBackground(); - maybeOffloadInBackground(NULL_OFFLOAD_PROMISE); + maybeOffloadInBackground(AUTOMATIC_OFFLOAD_TRIGGER); createLedgerAfterClosed(ledgerRollReason); } @@ -2443,10 +2455,13 @@ private void internalReadFromLedger(ReadHandle ledger, OpReadEntry opReadEntry) } } - // If all messages in [firstEntry...lastEntry] are filter out, - // then manual call internalReadEntriesComplete to advance read position. + // If all positions in [firstEntry...lastEntry] are filtered out, advance the read position + // without issuing a ledger read. if (firstValidEntry == -1L) { - final var nextReadPosition = PositionFactory.create(ledger.getId(), lastEntry).getNext(); + final Position lastScannedPosition = PositionFactory.create(ledger.getId(), lastEntry); + // The whole scan window was skipped. If the last scanned position is individually deleted, + // hop over its deleted range instead of advancing one window at a time. + final Position nextReadPosition = opReadEntry.cursor.getNextAvailablePosition(lastScannedPosition); opReadEntry.updateReadPosition(nextReadPosition); opReadEntry.checkReadCompletion(); return; @@ -2846,22 +2861,73 @@ private void scheduleDeferredTrimming(boolean isTruncate, CompletableFuture p } public void maybeOffloadInBackground(CompletableFuture promise) { - if (getOffloadPoliciesIfAppendable().isEmpty()) { + if (promise == AUTOMATIC_OFFLOAD_TRIGGER) { + if (automaticOffloadTriggerController.requestRun()) { + startAutomaticOffload(); + } + return; + } + + maybeOffloadInBackground(promise, OffloadRequestSource.EXPLICIT); + } + + private void startAutomaticOffload() { + CompletableFuture automaticOffloadCompletion = new CompletableFuture<>(); + automaticOffloadCompletion.whenComplete((res, ex) -> finishAutomaticOffload(ex)); + try { + maybeOffloadInBackground(automaticOffloadCompletion, OffloadRequestSource.AUTOMATIC); + } catch (RuntimeException e) { + automaticOffloadCompletion.completeExceptionally(e); + } + } + + private void maybeOffloadInBackground(CompletableFuture promise, OffloadRequestSource source) { + Optional offloadThresholds = getOffloadThresholds(); + if (offloadThresholds.isEmpty()) { + if (source == OffloadRequestSource.AUTOMATIC) { + promise.complete(PositionFactory.LATEST); + } return; } - final OffloadPolicies policies = config.getLedgerOffloader().getOffloadPolicies(); + OffloadThresholds thresholds = offloadThresholds.get(); + try { + executor.execute(() -> maybeOffload(thresholds.thresholdInBytes(), thresholds.thresholdInSeconds(), + promise, source)); + } catch (RuntimeException e) { + promise.completeExceptionally(e); + } + } + + private Optional getOffloadThresholds() { + Optional optionalOffloadPolicies = getOffloadPoliciesIfAppendable(); + if (optionalOffloadPolicies.isEmpty()) { + return Optional.empty(); + } + + final OffloadPolicies policies = optionalOffloadPolicies.get(); final long offloadThresholdInBytes = Optional.ofNullable(policies.getManagedLedgerOffloadThresholdInBytes()).orElse(-1L); final long offloadThresholdInSeconds = Optional.ofNullable(policies.getManagedLedgerOffloadThresholdInSeconds()).orElse(-1L); if (offloadThresholdInBytes >= 0 || offloadThresholdInSeconds >= 0) { - executor.execute(() -> maybeOffload(offloadThresholdInBytes, offloadThresholdInSeconds, promise)); + return Optional.of(new OffloadThresholds(offloadThresholdInBytes, offloadThresholdInSeconds)); + } + + return Optional.empty(); + } + + private void finishAutomaticOffload(Throwable exception) { + if (exception != null && log.isDebugEnabled()) { + log.debug("Failed to automatically offload ledgers", exception); + } + if (automaticOffloadTriggerController.completeRun()) { + startAutomaticOffload(); } } private void maybeOffload(long offloadThresholdInBytes, long offloadThresholdInSeconds, - CompletableFuture finalPromise) { + CompletableFuture finalPromise, OffloadRequestSource source) { if (getOffloadPoliciesIfAppendable().isEmpty()) { String msg = String.format("[%s] Nothing to offload due to offloader or offloadPolicies is NULL", name); finalPromise.completeExceptionally(new IllegalArgumentException(msg)); @@ -2876,8 +2942,12 @@ private void maybeOffload(long offloadThresholdInBytes, long offloadThresholdInS } if (!offloadMutex.tryLock()) { - scheduledExecutor.schedule(() -> maybeOffloadInBackground(finalPromise), - 100, TimeUnit.MILLISECONDS); + try { + scheduledExecutor.schedule(() -> maybeOffloadInBackground(finalPromise, source), + 100, TimeUnit.MILLISECONDS); + } catch (RuntimeException e) { + finalPromise.completeExceptionally(e); + } return; } @@ -2967,12 +3037,11 @@ void internalTrimConsumedLedgers(CompletableFuture promise) { private Optional getOffloadPoliciesIfAppendable() { LedgerOffloader ledgerOffloader = config.getLedgerOffloader(); - if (ledgerOffloader == null - || !ledgerOffloader.isAppendable() - || ledgerOffloader.getOffloadPolicies() == null) { + if (ledgerOffloader == null || !ledgerOffloader.isAppendable()) { return Optional.empty(); } - return Optional.ofNullable(ledgerOffloader.getOffloadPolicies()); + OffloadPolicies offloadPolicies = ledgerOffloader.getOffloadPolicies(); + return Optional.ofNullable(offloadPolicies); } @VisibleForTesting @@ -4941,7 +5010,9 @@ public CompletableFuture getManagedLedgerInternalSta stats.lastConfirmedEntry = this.getLastConfirmedEntry().toString(); stats.state = this.getState().toString(); - stats.cursors = new HashMap(); + stats.properties = new HashMap<>(propertiesMap); + + stats.cursors = new HashMap<>(); this.getCursors().forEach(c -> { ManagedCursorImpl cursor = (ManagedCursorImpl) c; PersistentTopicInternalStats.CursorStats cs = new PersistentTopicInternalStats.CursorStats(); @@ -4952,7 +5023,8 @@ public CompletableFuture getManagedLedgerInternalSta cs.messagesConsumedCounter = cursor.getMessagesConsumedCounter(); cs.cursorLedger = cursor.getCursorLedger(); cs.cursorLedgerLastEntry = cursor.getCursorLedgerLastEntry(); - cs.individuallyDeletedMessages = cursor.getIndividuallyDeletedMessages(); + cs.individualDeletedMessagesCount = cursor.getNumberOfIndividualDeletedMessages(); + cs.firstIndividualDeletedMessage = cursor.getFirstIndividualDeletedMessage(); cs.lastLedgerSwitchTimestamp = DateFormatter.format(cursor.getLastLedgerSwitchTimestamp()); cs.state = cursor.getState(); cs.active = cursor.isActive(); diff --git a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/MetaStoreImpl.java b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/MetaStoreImpl.java index 611d9d60202cd..7b122691fe46f 100644 --- a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/MetaStoreImpl.java +++ b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/MetaStoreImpl.java @@ -126,6 +126,17 @@ public void getManagedLedgerInfo(String ledgerName, boolean createIfMissing, Map } callback.operationComplete(ledgerBuilder.build(), stat); }).exceptionally(ex -> { + if (FutureUtil.unwrapCompletionException(ex) + instanceof MetadataStoreException.BadVersionException) { + // The z-node was created concurrently after the read above returned + // "not found". This happens for example when the broker creates the + // partitions of a topic while the same topic is being loaded. Read the + // z-node back instead of failing the managed ledger initialization. + log.info("Managed ledger path '{}' was concurrently created, reading it " + + "back", path); + getManagedLedgerInfo(ledgerName, false, null, callback); + return null; + } callback.operationFailed(getException(ex)); return null; }); diff --git a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/OpenTelemetryManagedCursorStats.java b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/OpenTelemetryManagedCursorStats.java index ec73c9d5e5eb2..ffaed66ca20dd 100644 --- a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/OpenTelemetryManagedCursorStats.java +++ b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/OpenTelemetryManagedCursorStats.java @@ -20,7 +20,10 @@ import com.google.common.collect.Streams; import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.common.Attributes; import io.opentelemetry.api.metrics.BatchCallback; +import io.opentelemetry.api.metrics.DoubleHistogram; +import io.opentelemetry.api.metrics.LongCounter; import io.opentelemetry.api.metrics.ObservableLongMeasurement; import org.apache.bookkeeper.mledger.ManagedCursor; import org.apache.bookkeeper.mledger.ManagedLedger; @@ -55,6 +58,40 @@ public class OpenTelemetryManagedCursorStats implements AutoCloseable { public static final String INCOMING_BYTE_COUNTER = "pulsar.broker.managed_ledger.cursor.incoming.size"; private final ObservableLongMeasurement incomingByteCounter; + // Broker-level counters incremented when cursor persistence silently truncates ack state. + // See managedLedgerMaxUnackedRangesToPersist and managedLedgerMaxBatchDeletedIndexToPersist. + public static final String PERSIST_UNACKED_RANGES_TRUNCATED = + "pulsar.broker.managed_ledger.cursor.persist.unacked_ranges.truncated"; + private final LongCounter persistUnackedRangesTruncated; + + public static final String PERSIST_BATCH_DELETED_INDEXES_TRUNCATED = + "pulsar.broker.managed_ledger.cursor.persist.batch_deleted_indexes.truncated"; + private final LongCounter persistBatchDeletedIndexesTruncated; + + // Replaces ['brk_ml_cursor_ackCount'] + public static final String ACK_OPERATION_COUNTER = + "pulsar.broker.managed_ledger.cursor.ack.operation.count"; + private final ObservableLongMeasurement ackOperationCounter; + + // Replaces ['brk_ml_cursor_ackLatencyAvgMs'] + public static final String ACK_LATENCY = "pulsar.broker.managed_ledger.cursor.ack.latency"; + private final DoubleHistogram ackLatency; + + // Replaces ['brk_ml_cursor_persistLatencyAvgMs']; the count is already covered by + // PERSIST_OPERATION_COUNTER. + public static final String PERSIST_LATENCY = "pulsar.broker.managed_ledger.cursor.persist.latency"; + private final DoubleHistogram persistLatency; + + // Replaces ['brk_ml_cursor_recoverCount', 'brk_ml_cursor_recoverSucceed', + // 'brk_ml_cursor_recoverErrors']. + public static final String RECOVER_OPERATION_COUNTER = + "pulsar.broker.managed_ledger.cursor.recover.operation.count"; + private final ObservableLongMeasurement recoverOperationCounter; + + // Replaces ['brk_ml_cursor_recoverLatencyAvgMs'] + public static final String RECOVER_LATENCY = "pulsar.broker.managed_ledger.cursor.recover.latency"; + private final DoubleHistogram recoverLatency; + private final BatchCallback batchCallback; public OpenTelemetryManagedCursorStats(OpenTelemetry openTelemetry, ManagedLedgerFactoryImpl factory) { @@ -96,6 +133,49 @@ public OpenTelemetryManagedCursorStats(OpenTelemetry openTelemetry, ManagedLedge .setDescription("The total amount of data read from the ledger.") .buildObserver(); + persistUnackedRangesTruncated = meter + .counterBuilder(PERSIST_UNACKED_RANGES_TRUNCATED) + .setUnit("{truncation}") + .setDescription("The number of times a cursor exceeded" + + " managedLedgerMaxUnackedRangesToPersist, causing ack state to be truncated" + + " at persistence. Ack state beyond the limit is lost on broker restart.") + .build(); + + persistBatchDeletedIndexesTruncated = meter + .counterBuilder(PERSIST_BATCH_DELETED_INDEXES_TRUNCATED) + .setUnit("{truncation}") + .setDescription("The number of times a cursor exceeded" + + " managedLedgerMaxBatchDeletedIndexToPersist, causing batch deleted index state" + + " to be truncated at persistence. State beyond the limit is lost on broker restart.") + .build(); + + ackOperationCounter = meter + .counterBuilder(ACK_OPERATION_COUNTER) + .setUnit("{operation}") + .setDescription("The number of cursor acknowledgment operations.") + .buildObserver(); + + ackLatency = meter.histogramBuilder(ACK_LATENCY) + .setUnit("s") + .setDescription("Cursor acknowledgment operation latency.") + .build(); + + persistLatency = meter.histogramBuilder(PERSIST_LATENCY) + .setUnit("s") + .setDescription("Cursor persist latency: the checkpoint write to the cursor ledger.") + .build(); + + recoverOperationCounter = meter + .counterBuilder(RECOVER_OPERATION_COUNTER) + .setUnit("{operation}") + .setDescription("The number of cursor recovery operations.") + .buildObserver(); + + recoverLatency = meter.histogramBuilder(RECOVER_LATENCY) + .setUnit("s") + .setDescription("Cursor recovery latency.") + .build(); + batchCallback = meter.batchCallback(() -> factory.getManagedLedgers() .values() .stream() @@ -107,7 +187,9 @@ public OpenTelemetryManagedCursorStats(OpenTelemetry openTelemetry, ManagedLedge nonContiguousMessageRangeCounter, outgoingByteCounter, outgoingByteLogicalCounter, - incomingByteCounter); + incomingByteCounter, + ackOperationCounter, + recoverOperationCounter); } @Override @@ -115,6 +197,14 @@ public void close() { batchCallback.close(); } + public void incrementPersistUnackedRangesTruncated(ManagedCursor cursor) { + persistUnackedRangesTruncated.add(1, cursor.getManagedCursorAttributes().getAttributes()); + } + + public void incrementPersistBatchDeletedIndexesTruncated(ManagedCursor cursor) { + persistBatchDeletedIndexesTruncated.add(1, cursor.getManagedCursorAttributes().getAttributes()); + } + private void recordMetrics(ManagedCursor cursor) { var stats = cursor.getStats(); var cursorAttributesSet = cursor.getManagedCursorAttributes(); @@ -133,5 +223,40 @@ private void recordMetrics(ManagedCursor cursor) { outgoingByteCounter.record(stats.getWriteCursorLedgerSize(), attributes); outgoingByteLogicalCounter.record(stats.getWriteCursorLedgerLogicalSize(), attributes); incomingByteCounter.record(stats.getReadCursorLedgerSize(), attributes); + + ackOperationCounter.record(stats.getAckCount(), attributes); + recoverOperationCounter.record(stats.getRecoverSucceed(), attributesSucceed); + recoverOperationCounter.record(stats.getRecoverErrors(), attributesFailed); + } + + public void recordAckLatency(ManagedCursor cursor, double seconds) { + var attributes = attributesOrNull(cursor); + if (attributes != null) { + ackLatency.record(seconds, attributes); + } + } + + public void recordPersistLatency(ManagedCursor cursor, double seconds) { + var attributes = attributesOrNull(cursor); + if (attributes != null) { + persistLatency.record(seconds, attributes); + } + } + + public void recordRecoverLatency(ManagedCursor cursor, double seconds) { + var attributes = attributesOrNull(cursor); + if (attributes != null) { + recoverLatency.record(seconds, attributes); + } + } + + private static Attributes attributesOrNull(ManagedCursor cursor) { + try { + return cursor.getManagedCursorAttributes().getAttributes(); + } catch (Exception e) { + // Attribute resolution throws for non-topic-named managed ledgers (e.g. system + // ledgers); recording telemetry must never break the caller's callback chain. + return null; + } } } diff --git a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/PositionRangeSet.java b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/PositionRangeSet.java new file mode 100644 index 0000000000000..82fcbf14264a4 --- /dev/null +++ b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/PositionRangeSet.java @@ -0,0 +1,599 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.bookkeeper.mledger.impl; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.BoundType; +import com.google.common.collect.Range; +import io.netty.buffer.Unpooled; +import it.unimi.dsi.fastutil.longs.Long2ObjectRBTreeMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectSortedMap; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.LongConsumer; +import lombok.extern.slf4j.Slf4j; +import org.apache.bookkeeper.mledger.Position; +import org.apache.bookkeeper.mledger.PositionFactory; +import org.apache.commons.lang3.mutable.MutableInt; +import org.apache.pulsar.common.util.collections.LongBitmap; +import org.apache.pulsar.common.util.collections.LongBitmaps; +import org.apache.pulsar.common.util.collections.LongPairRangeSet; + +/** + * Tracks deleted-message positions as ranges of {@link Position}s. + * + *

The implementation stores positions in a two-level structure: + * the ledger id is used as the map key, and the corresponding entry ids are stored in a + * {@link LongBitmap}. Bit {@code n} in the bitmap of ledger {@code L} represents the position + * {@code (L, n)}. + * + *

Thread Safety

+ * + *

This class is not thread-safe. All methods require the caller to provide external + * synchronization. In normal usage, callers must hold the owning {@link ManagedCursorImpl}'s + * cursor lock before accessing this class. + * + *

The class intentionally does not maintain internal locking. The cursor lock is the single + * synchronization boundary for both this structure and related cursor state. + * + *

Persistence Compatibility

+ * + *

The persisted representation remains compatible with the existing format using + * {@link LongBitmap#serializeToLongArray()} and {@link LongBitmap#deserializeFromLongArray(long[])}, + * which are compatible with the BitSet long[] format. + * + *

Entry ids are stored as bitmap indexes and therefore use {@code int} values. This is safe + * because {@code managedLedgerMaxEntriesPerLedger} is an {@code int}, so valid entry ids are within + * {@code [0, Integer.MAX_VALUE]}. + */ +@Slf4j +class PositionRangeSet implements LongPairRangeSet { + + private static final long EARLIEST_LEDGER_ID = -1L; + private static final long EARLIEST_ENTRY_ID = -1L; + private static final long LATEST_LEDGER_ID = Long.MAX_VALUE; + private static final long LATEST_ENTRY_ID = Long.MAX_VALUE; + + /** + * Maps ledger ID to a bitmap of deleted entry IDs within that ledger. + * Bit {@code n} in the bitmap represents entry {@code n} in the ledger. + */ + private final Long2ObjectSortedMap rangeBitmapMap = new Long2ObjectRBTreeMap<>(); + private final LongPairConsumer consumer; + private final boolean enableMultiEntry; + + private final LongBitmap dirtyLedgers = LongBitmaps.create(); + + private int cachedSize = 0; + private String cachedToString = "[]"; + private boolean updatedAfterCachedForSize = true; + private boolean updatedAfterCachedForToString = true; + private long totalCardinality = 0; + + PositionRangeSet(LongPairConsumer consumer, boolean enableMultiEntry) { + this.consumer = consumer; + this.enableMultiEntry = enableMultiEntry; + } + + private static long lastPresentValue(LongBitmap bitmap) { + return bitmap.lastPresentValue(); + } + + @Override + public void addOpenClosed(long lowerLedgerId, long lowerEntryIdOpen, long upperLedgerId, long upperEntryId) { + if (enableMultiEntry) { + markDirty(lowerLedgerId, upperLedgerId); + } + long lowerEntryId = lowerEntryIdOpen + 1; + if (lowerLedgerId != upperLedgerId) { + // Extend lower ledger's bitmap only if it already exists and has bits at/after lowerEntryId; + // otherwise we'd invent acknowledgements that never happened (e.g. (2:10..4:10] must not + // touch 2:10 if ledger 2 was empty). + if (isValid(lowerLedgerId, lowerEntryId)) { + LongBitmap rangeBitmap = rangeBitmapMap.get(lowerLedgerId); + if (rangeBitmap != null) { + long lastEntryId = rangeBitmap.lastPresentValue(); + if (lastEntryId > lowerEntryIdOpen) { + addRange(rangeBitmap, lowerEntryId, Math.max(lastEntryId, lowerEntryId) + 1); + } + } + } + if (isValid(upperLedgerId, upperEntryId)) { + LongBitmap rangeBitmap = rangeBitmapMap.computeIfAbsent(upperLedgerId, k -> LongBitmaps.create()); + addRange(rangeBitmap, 0, upperEntryId + 1); + } + } else { + LongBitmap rangeBitmap = rangeBitmapMap.computeIfAbsent(lowerLedgerId, k -> LongBitmaps.create()); + addRange(rangeBitmap, lowerEntryId, upperEntryId + 1); + } + invalidateCaches(); + } + + private void addRange(LongBitmap bitmap, long from, long to) { + long before = bitmap.cardinality(); + bitmap.add(from, to); + totalCardinality += bitmap.cardinality() - before; + } + + private void removeRange(LongBitmap bitmap, long from, long to) { + long before = bitmap.cardinality(); + bitmap.remove(from, to); + totalCardinality -= before - bitmap.cardinality(); + } + + private void clearSubMapCardinality(Long2ObjectSortedMap subMap) { + for (LongBitmap bitmap : subMap.values()) { + totalCardinality -= bitmap.cardinality(); + } + subMap.clear(); + } + + @Override + public boolean contains(long ledgerId, long entryId) { + LongBitmap rangeBitmap = rangeBitmapMap.get(ledgerId); + if (rangeBitmap != null) { + return rangeBitmap.contains(getSafeEntry(entryId)); + } + return false; + } + + @Override + public Range rangeContaining(long ledgerId, long entryId) { + LongBitmap rangeBitmap = rangeBitmapMap.get(ledgerId); + if (rangeBitmap == null || !rangeBitmap.contains(getSafeEntry(entryId))) { + return null; + } + long safeEntryId = getSafeEntry(entryId); + long lowerEntryId = rangeBitmap.previousAbsentValue(safeEntryId) + 1; + Position lower = consumer.apply(ledgerId, lowerEntryId); + long nextAbsentEntryId = rangeBitmap.nextAbsentValue(safeEntryId); + Position upper = consumer.apply(ledgerId, Math.max(nextAbsentEntryId - 1, lowerEntryId)); + return Range.closed(lower, upper); + } + + @Override + public void removeAtMost(long ledgerId, long entryId) { + if (enableMultiEntry && ledgerId >= 0) { + long end = Math.min(ledgerId + 1L, (long) Integer.MAX_VALUE + 1); + dirtyLedgers.remove(0, end); + } + remove(Range.atMost(PositionFactory.create(ledgerId, entryId))); + } + + @Override + public boolean isEmpty() { + if (rangeBitmapMap.isEmpty()) { + return true; + } + for (LongBitmap bitmap : rangeBitmapMap.values()) { + if (!bitmap.isEmpty()) { + return false; + } + } + return true; + } + + @Override + public void clear() { + rangeBitmapMap.clear(); + totalCardinality = 0; + resetDirtyKeys(); + invalidateCaches(); + } + + @Override + public Range span() { + if (rangeBitmapMap.isEmpty()) { + return null; + } + long firstLedgerId = rangeBitmapMap.firstLongKey(); + long lastLedgerId = rangeBitmapMap.lastLongKey(); + LongBitmap firstBitmap = rangeBitmapMap.get(firstLedgerId); + LongBitmap lastBitmap = rangeBitmapMap.get(lastLedgerId); + long firstEntryId = firstBitmap.nextPresentValue(0); + long lastEntryId = lastBitmap.lastPresentValue(); + return Range.openClosed(consumer.apply(firstLedgerId, firstEntryId - 1), + consumer.apply(lastLedgerId, lastEntryId)); + } + + @Override + public List> asRanges() { + List> ranges = new ArrayList<>(); + forEach(range -> { + ranges.add(range); + return true; + }); + return ranges; + } + + @Override + public void forEach(RangeProcessor action) { + forEach(action, consumer); + } + + @Override + public void forEach(RangeProcessor action, LongPairConsumer consumerParam) { + forEachRawRange((lowerLedgerId, lowerEntryId, upperLedgerId, upperEntryId) -> { + Range range = Range.openClosed( + consumerParam.apply(lowerLedgerId, lowerEntryId), + consumerParam.apply(upperLedgerId, upperEntryId)); + return action.process(range); + }); + } + + @Override + public void forEachRawRange(RawRangeProcessor processor) { + AtomicBoolean completed = new AtomicBoolean(false); + rangeBitmapMap.forEach((ledgerId, bitmap) -> { + if (completed.get() || bitmap.isEmpty()) { + return; + } + long firstEntryId = bitmap.nextPresentValue(0); + long lastEntryId = bitmap.lastPresentValue(); + long currentEntryId = firstEntryId; + while (currentEntryId != -1 && currentEntryId <= lastEntryId) { + long nextAbsentEntryId = bitmap.nextAbsentValue(currentEntryId); + if (!processor.processRawRange(ledgerId, currentEntryId - 1, ledgerId, nextAbsentEntryId - 1)) { + completed.set(true); + break; + } + if (nextAbsentEntryId > Integer.MAX_VALUE) { + break; + } + currentEntryId = bitmap.nextPresentValue(nextAbsentEntryId); + } + }); + } + + @Override + public Range firstRange() { + if (rangeBitmapMap.isEmpty()) { + return null; + } + long firstLedgerId = rangeBitmapMap.firstLongKey(); + LongBitmap firstBitmap = rangeBitmapMap.get(firstLedgerId); + long lowerEntryId = firstBitmap.nextPresentValue(0); + long upperEntryId = Math.max(lowerEntryId, firstBitmap.nextAbsentValue(lowerEntryId) - 1); + return Range.openClosed(consumer.apply(firstLedgerId, lowerEntryId - 1), + consumer.apply(firstLedgerId, upperEntryId)); + } + + @Override + public Range lastRange() { + if (rangeBitmapMap.isEmpty()) { + return null; + } + long lastLedgerId = rangeBitmapMap.lastLongKey(); + LongBitmap lastBitmap = rangeBitmapMap.get(lastLedgerId); + long upperEntryId = lastBitmap.lastPresentValue(); + long lowerEntryId = Math.min(lastBitmap.previousAbsentValue(upperEntryId), upperEntryId); + return Range.openClosed(consumer.apply(lastLedgerId, lowerEntryId), + consumer.apply(lastLedgerId, upperEntryId)); + } + + @Override + public Map toRanges(int maxRanges) { + return toRanges(maxRanges, null); + } + + /** + * Serializes the individual-deleted positions as per-ledger bitmaps, including ledgers only while + * the cumulative cardinality stays within {@code maxRanges}. Ledgers beyond the limit are skipped + * silently (their ack state will be lost on broker restart); when {@code truncationNotifier} is + * provided it is invoked once with the number of skipped ledgers so callers can record the loss. + */ + public Map toRanges(int maxRanges, java.util.function.IntConsumer truncationNotifier) { + Map internalBitSetMap = new HashMap<>(); + MutableInt rangeCount = new MutableInt(); + MutableInt skippedLedgers = new MutableInt(); + rangeBitmapMap.forEach((ledgerId, bitmap) -> { + if (rangeCount.addAndGet((int) bitmap.cardinality()) > maxRanges) { + skippedLedgers.increment(); + return; + } + internalBitSetMap.put(ledgerId, bitmap.serializeToLongArray()); + }); + if (truncationNotifier != null && skippedLedgers.intValue() > 0) { + truncationNotifier.accept(skippedLedgers.intValue()); + } + return internalBitSetMap; + } + + @Override + public void build(Map internalRange) { + rangeBitmapMap.clear(); + resetDirtyKeys(); + + internalRange.forEach((ledgerId, ranges) -> { + rangeBitmapMap.put(ledgerId.longValue(), LongBitmaps.deserializeFromLongArray(ranges)); + }); + recomputeTotalCardinality(); + invalidateCaches(); + } + + void buildFromBitmaps(Map bitmaps) { + rangeBitmapMap.clear(); + resetDirtyKeys(); + bitmaps.forEach((ledgerId, bytes) -> { + if (bytes != null && bytes.length > 0) { + rangeBitmapMap.put(ledgerId.longValue(), + LongBitmaps.deserialize(Unpooled.wrappedBuffer(bytes))); + } + }); + recomputeTotalCardinality(); + invalidateCaches(); + } + + @Override + public int cardinality(long lowerLedgerId, long lowerEntryId, long upperLedgerId, long upperEntryId) { + Long2ObjectSortedMap subMap = rangeBitmapMap.subMap(lowerLedgerId, upperLedgerId + 1); + MutableInt v = new MutableInt(0); + subMap.forEach((ledgerId, bitmap) -> { + if (ledgerId == lowerLedgerId && ledgerId == upperLedgerId) { + long count = bitmap.rank(upperEntryId + 1) - bitmap.rank(lowerEntryId); + v.add(Math.toIntExact(count)); + } else if (ledgerId == lowerLedgerId) { + long count = bitmap.cardinality() - bitmap.rank(lowerEntryId); + v.add(Math.toIntExact(count)); + } else if (ledgerId == upperLedgerId) { + long count = bitmap.rank(upperEntryId + 1); + v.add(Math.toIntExact(count)); + } else { + v.add(Math.toIntExact(bitmap.cardinality())); + } + }); + return v.intValue(); + } + + @Override + public int size() { + if (updatedAfterCachedForSize) { + MutableInt size = new MutableInt(0); + forEachRawRange((lowerLedgerId, lowerEntryId, upperLedgerId, upperEntryId) -> { + size.increment(); + return true; + }); + cachedSize = size.intValue(); + updatedAfterCachedForSize = false; + } + return cachedSize; + } + + @Override + public int hashCode() { + return Objects.hashCode(rangeBitmapMap); + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof PositionRangeSet other)) { + return false; + } + if (this == obj) { + return true; + } + return this.rangeBitmapMap.equals(other.rangeBitmapMap); + } + + @Override + public String toString() { + if (updatedAfterCachedForToString) { + StringBuilder sb = new StringBuilder(); + sb.append("["); + AtomicBoolean first = new AtomicBoolean(true); + forEach(range -> { + if (!first.get()) { + sb.append(","); + } + sb.append(range); + first.set(false); + return true; + }); + sb.append("]"); + cachedToString = sb.toString(); + updatedAfterCachedForToString = false; + } + return cachedToString; + } + + @VisibleForTesting + void add(Range range) { + Position lowerEndpoint = range.hasLowerBound() ? range.lowerEndpoint() + : PositionFactory.create(EARLIEST_LEDGER_ID, EARLIEST_ENTRY_ID); + Position upperEndpoint = range.hasUpperBound() ? range.upperEndpoint() + : PositionFactory.create(LATEST_LEDGER_ID, LATEST_ENTRY_ID); + + long lowerEntryIdOpen = (range.hasLowerBound() && range.lowerBoundType().equals(BoundType.CLOSED)) + ? getSafeEntry(lowerEndpoint) - 1 + : getSafeEntry(lowerEndpoint); + long upperEntryIdClosed = (range.hasUpperBound() && range.upperBoundType().equals(BoundType.CLOSED)) + ? getSafeEntry(upperEndpoint) + : getSafeEntry(upperEndpoint) + 1; + + LongBitmap bitmap = rangeBitmapMap.computeIfAbsent(lowerEndpoint.getLedgerId(), + k -> LongBitmaps.create()); + if (bitmap.checkedAdd(lowerEntryIdOpen + 1)) { + totalCardinality++; + } + addOpenClosed(lowerEndpoint.getLedgerId(), lowerEntryIdOpen, + upperEndpoint.getLedgerId(), upperEntryIdClosed); + } + + @VisibleForTesting + void remove(Range range) { + Position lowerEndpoint = range.hasLowerBound() ? range.lowerEndpoint() + : PositionFactory.create(EARLIEST_LEDGER_ID, EARLIEST_ENTRY_ID); + Position upperEndpoint = range.hasUpperBound() ? range.upperEndpoint() + : PositionFactory.create(LATEST_LEDGER_ID, LATEST_ENTRY_ID); + + long lowerEntryId = (range.hasLowerBound() && range.lowerBoundType().equals(BoundType.CLOSED)) + ? getSafeEntry(lowerEndpoint) + : getSafeEntry(lowerEndpoint) + 1; + long upperEntryId = (range.hasUpperBound() && range.upperBoundType().equals(BoundType.CLOSED)) + ? getSafeEntry(upperEndpoint) + : getSafeEntry(upperEndpoint) - 1; + + long lowerLedgerId = lowerEndpoint.getLedgerId(); + long upperLedgerId = upperEndpoint.getLedgerId(); + boolean lowerIsEarliest = lowerLedgerId == EARLIEST_LEDGER_ID + && lowerEndpoint.getEntryId() == EARLIEST_ENTRY_ID; + boolean upperIsLatest = upperLedgerId == LATEST_LEDGER_ID + && upperEndpoint.getEntryId() == LATEST_ENTRY_ID; + boolean sameLedger = lowerLedgerId == upperLedgerId; + + if (lowerIsEarliest) { + clearSubMapCardinality(rangeBitmapMap.headMap(upperLedgerId)); + } + if (upperIsLatest) { + clearSubMapCardinality(rangeBitmapMap.tailMap(lowerLedgerId + 1)); + } + if (!sameLedger && !lowerIsEarliest && !upperIsLatest) { + clearSubMapCardinality(rangeBitmapMap.subMap(lowerLedgerId + 1, upperLedgerId)); + } + + LongBitmap lowerSet = lowerIsEarliest ? null : rangeBitmapMap.get(lowerLedgerId); + LongBitmap upperSet = upperIsLatest ? null + : (sameLedger ? lowerSet : rangeBitmapMap.get(upperLedgerId)); + + if (sameLedger && lowerSet != null) { + removeRange(lowerSet, lowerEntryId, upperEntryId + 1); + } else { + if (lowerSet != null) { + removeRange(lowerSet, lowerEntryId, lastPresentValue(lowerSet)); + } + if (upperSet != null) { + removeRange(upperSet, 0, upperEntryId + 1); + } + } + + if (lowerSet != null && lowerSet.isEmpty()) { + rangeBitmapMap.remove(lowerLedgerId); + } + if (!sameLedger && upperSet != null && upperSet.isEmpty()) { + rangeBitmapMap.remove(upperLedgerId); + } + + invalidateCaches(); + } + + void resetDirtyKeys() { + dirtyLedgers.clear(); + } + + boolean isDirtyLedgers(long ledgerId) { + return ledgerId >= 0 && ledgerId <= Integer.MAX_VALUE && dirtyLedgers.contains(ledgerId); + } + + /** + * Atomically snapshots the dirty ledger IDs and clears the dirty set. + * + *

The returned set is consumed by the persistence layer to decide which ledgers + * need fresh AckState entries written this flush. Keys above {@code Integer.MAX_VALUE} + * are skipped at mark-time, so callers see raw ledger IDs in {@code [0, 2^31-1]}. + */ + Set snapshotAndClearDirtyLedgers() { + Set snapshot = new HashSet<>(); + dirtyLedgers.forEachLong(bit -> snapshot.add(bit - 1)); + dirtyLedgers.clear(); + return snapshot; + } + + void restoreDirtyLedgers(Set ledgers) { + for (long id : ledgers) { + if (id >= 0 && id < Integer.MAX_VALUE) { + dirtyLedgers.add(id + 1); + } + } + } + + /** + * Returns the serialized RoaringBitmap bytes for a specific ledger's individual acks, + * or {@code null} when the ledger has no entries in this set. + */ + byte[] bitmapOf(long ledgerId) { + LongBitmap bitmap = rangeBitmapMap.get(ledgerId); + return bitmap == null ? null : bitmap.serialize(); + } + + /** + * Invokes the given consumer for each ledger ID that currently has at least one + * individual-deleted entry in this set. Used by the persistence layer to enumerate + * the active ledgers that need an AckState or AckStateRef in the checkpoint. + */ + void forEachActiveLedger(LongConsumer action) { + rangeBitmapMap.keySet().forEach(action); + } + + /** + * Marks a single ledger as dirty. Used for batch-index deletions, which are tracked in + * {@code batchDeletedIndexes} rather than in the individual-deleted range bitmap. + */ + void markDirtyLedger(long ledgerId) { + markDirty(ledgerId, ledgerId); + } + + private void markDirty(long lowerLedgerId, long upperLedgerId) { + if (upperLedgerId < lowerLedgerId || lowerLedgerId < 0) { + return; + } + if (lowerLedgerId >= Integer.MAX_VALUE || upperLedgerId > Integer.MAX_VALUE) { + log.warn("Skipping dirty tracking for ledger ID at/exceeding Integer.MAX_VALUE, " + + "lowerLedgerId: {}, upperLedgerId: {}", + lowerLedgerId, upperLedgerId); + return; + } + dirtyLedgers.add(lowerLedgerId + 1, upperLedgerId + 2); + } + + private boolean isValid(long ledgerId, long entryId) { + return ledgerId != EARLIEST_LEDGER_ID && entryId != EARLIEST_ENTRY_ID + && ledgerId != LATEST_LEDGER_ID && entryId != LATEST_ENTRY_ID; + } + + private int getSafeEntry(Position position) { + return getSafeEntry(position.getEntryId()); + } + + private int getSafeEntry(long value) { + return (int) Math.max(value, -1); + } + + private void invalidateCaches() { + updatedAfterCachedForSize = true; + updatedAfterCachedForToString = true; + } + + long totalCardinality() { + return totalCardinality; + } + + private void recomputeTotalCardinality() { + long total = 0; + for (LongBitmap bitmap : rangeBitmapMap.values()) { + total += bitmap.cardinality(); + } + totalCardinality = total; + } +} diff --git a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/RangeSetWrapper.java b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/RangeSetWrapper.java deleted file mode 100644 index 76ac3e1be726c..0000000000000 --- a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/impl/RangeSetWrapper.java +++ /dev/null @@ -1,211 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.bookkeeper.mledger.impl; - -import com.google.common.annotations.VisibleForTesting; -import com.google.common.collect.Range; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Map; -import org.apache.pulsar.common.util.collections.LongPairRangeSet; -import org.apache.pulsar.common.util.collections.OpenLongPairRangeSet; -import org.roaringbitmap.RoaringBitSet; - -/** - * Wraps other Range classes, and adds LRU, marking dirty data and other features on this basis. - * This range set is not thread safety. - * - * @param - */ -public class RangeSetWrapper> implements LongPairRangeSet { - - private final LongPairRangeSet rangeSet; - private final LongPairConsumer rangeConverter; - private final boolean enableMultiEntry; - - /** - * Record which Ledger is dirty. - */ - private final DefaultRangeSet dirtyLedgers = new LongPairRangeSet.DefaultRangeSet<>( - (LongPairConsumer) (key, value) -> key, - (RangeBoundConsumer) key -> new LongPair(key, 0)); - - public RangeSetWrapper(LongPairConsumer rangeConverter, - RangeBoundConsumer rangeBoundConsumer, - ManagedCursorImpl managedCursor) { - this(rangeConverter, rangeBoundConsumer, managedCursor.getConfig().isUnackedRangesOpenCacheSetEnabled(), - managedCursor.getConfig().isPersistentUnackedRangesWithMultipleEntriesEnabled()); - } - - public RangeSetWrapper(LongPairConsumer rangeConverter, - RangeBoundConsumer rangeBoundConsumer, - boolean unackedRangesOpenCacheSetEnabled, - boolean persistentUnackedRangesWithMultipleEntriesEnabled) { - this.rangeConverter = rangeConverter; - this.rangeSet = unackedRangesOpenCacheSetEnabled - ? new OpenLongPairRangeSet<>(rangeConverter, RoaringBitSet::new) - : new LongPairRangeSet.DefaultRangeSet<>(rangeConverter, rangeBoundConsumer); - this.enableMultiEntry = persistentUnackedRangesWithMultipleEntriesEnabled; - } - - @Override - public void addOpenClosed(long lowerKey, long lowerValue, long upperKey, long upperValue) { - if (enableMultiEntry) { - dirtyLedgers.addOpenClosed(lowerKey, 0, upperKey, 0); - } - rangeSet.addOpenClosed(lowerKey, lowerValue, upperKey, upperValue); - } - - @Override - public boolean contains(long key, long value) { - return rangeSet.contains(key, value); - } - - @Override - public Range rangeContaining(long key, long value) { - return rangeSet.rangeContaining(key, value); - } - - @Override - public void removeAtMost(long key, long value) { - if (enableMultiEntry) { - dirtyLedgers.removeAtMost(key, 0); - } - rangeSet.removeAtMost(key, value); - } - - @Override - public boolean isEmpty() { - return rangeSet.isEmpty(); - } - - @Override - public void clear() { - rangeSet.clear(); - dirtyLedgers.clear(); - } - - @Override - public Range span() { - return rangeSet.span(); - } - - @Override - public Collection> asRanges() { - Collection> collection = rangeSet.asRanges(); - if (collection instanceof List) { - return collection; - } - return new ArrayList<>(collection); - } - - @Override - public void forEach(RangeProcessor action) { - rangeSet.forEach(action); - } - - @Override - public void forEach(RangeProcessor action, LongPairConsumer consumer) { - rangeSet.forEach(action, consumer); - } - - @Override - public void forEachRawRange(RawRangeProcessor action) { - rangeSet.forEachRawRange(action); - } - - @Override - public int size() { - return rangeSet.size(); - } - - @Override - public Range firstRange() { - return rangeSet.firstRange(); - } - - @Override - public Range lastRange() { - return rangeSet.lastRange(); - } - - @Override - public Map toRanges(int maxRanges) { - return rangeSet.toRanges(maxRanges); - } - - @Override - public void build(Map internalRange) { - rangeSet.build(internalRange); - } - - @Override - public int cardinality(long lowerKey, long lowerValue, long upperKey, long upperValue) { - return rangeSet.cardinality(lowerKey, lowerValue, upperKey, upperValue); - } - - @VisibleForTesting - void add(Range range) { - if (!(rangeSet instanceof OpenLongPairRangeSet)) { - throw new UnsupportedOperationException("Only ConcurrentOpenLongPairRangeSet support this method"); - } - ((OpenLongPairRangeSet) rangeSet).add(range); - } - - @VisibleForTesting - void remove(Range range) { - if (rangeSet instanceof OpenLongPairRangeSet) { - ((OpenLongPairRangeSet) rangeSet).remove((Range) range); - } else { - ((DefaultRangeSet) rangeSet).remove(range); - } - } - - public void resetDirtyKeys() { - dirtyLedgers.clear(); - } - - public boolean isDirtyLedgers(long ledgerId) { - return dirtyLedgers.contains(ledgerId); - } - - @Override - public String toString() { - return rangeSet.toString(); - } - - @Override - public int hashCode() { - return rangeSet.hashCode(); - } - - @Override - public boolean equals(Object obj) { - if (!(obj instanceof RangeSetWrapper)) { - return false; - } - if (this == obj) { - return true; - } - @SuppressWarnings("rawtypes") - RangeSetWrapper set = (RangeSetWrapper) obj; - return this.rangeSet.equals(set.rangeSet); - } -} diff --git a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/offload/OffloadersCache.java b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/offload/OffloadersCache.java index 9f2f5860d4ebd..03c87713566c4 100644 --- a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/offload/OffloadersCache.java +++ b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/offload/OffloadersCache.java @@ -19,6 +19,7 @@ package org.apache.bookkeeper.mledger.offload; import java.io.IOException; +import java.nio.file.Paths; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import lombok.extern.slf4j.Slf4j; @@ -41,7 +42,8 @@ public class OffloadersCache implements AutoCloseable { * @throws IOException when fail to retrieve the pulsar offloader class */ public Offloaders getOrLoadOffloaders(String offloadersPath, String narExtractionDirectory) { - return loadedOffloaders.computeIfAbsent(offloadersPath, + String normalizedOffloadersPath = Paths.get(offloadersPath).toAbsolutePath().normalize().toString(); + return loadedOffloaders.computeIfAbsent(normalizedOffloadersPath, (directory) -> { try { return OffloaderUtils.searchForOffloaders(directory, narExtractionDirectory); diff --git a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/util/StatsBuckets.java b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/util/StatsBuckets.java index 60c0a7f6c9d22..ec3a7e00fd390 100644 --- a/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/util/StatsBuckets.java +++ b/managed-ledger/src/main/java/org/apache/bookkeeper/mledger/util/StatsBuckets.java @@ -60,6 +60,16 @@ public void addValue(long value) { sumCounter.add(value); } + /** + * Snapshots the current values into {@link #getBuckets()}, {@link #getCount()}, and + * {@link #getSum()}, then resets the internal counters to zero. + * + *

Use this for periodic aggregation where the consumer reads values on a fixed + * schedule (e.g. {@code ManagedLedgerMBeanImpl} refreshes every + * {@code managedLedgerStatsPeriodSeconds}). Do not use this for metrics that + * are scraped on demand (e.g. Prometheus {@code /metrics}), because data recorded + * between scrapes is lost after each call — use {@link #snapshot()} instead. + */ public void refresh() { long count = 0; sum = sumCounter.sumThenReset(); @@ -73,6 +83,24 @@ public void refresh() { this.count = count; } + /** + * Snapshots the current cumulative values without resetting the internal counters. + * Use this instead of {@link #refresh()} when the data should survive across reads + * (e.g. Prometheus metrics that are scraped multiple times). + */ + public void snapshot() { + long count = 0; + sum = sumCounter.sum(); + + for (int i = 0; i < buckets.length; i++) { + long value = buckets[i].sum(); + count += value; + values[i] = value; + } + + this.count = count; + } + public void reset() { sum = 0; sumCounter.reset(); diff --git a/managed-ledger/src/main/proto/MLDataFormats.proto b/managed-ledger/src/main/proto/MLDataFormats.proto index 825da1e414cea..4109fcf36130a 100644 --- a/managed-ledger/src/main/proto/MLDataFormats.proto +++ b/managed-ledger/src/main/proto/MLDataFormats.proto @@ -140,6 +140,45 @@ message ManagedCursorInfo { // Additional custom properties associated with // the cursor repeated StringProperty cursorProperties = 8; + + optional int64 lastCmCursorLedgerId = 9; + optional int64 lastCmEntryId = 10; +} + +message CursorLogEntry { + oneof entry { + CursorCheckpoint checkpoint = 2; + CursorCheckpointChunk checkpointChunk = 3; + } +} + +// A consistent snapshot of cursor ack state for one persisted ledger update. +// Each checkpoint entry contains exactly one inline ackState; other active +// ledgers are represented by refs to their latest persisted checkpoints. +message CursorCheckpoint { + required int64 markDeleteLedgerId = 1; + required int64 markDeleteEntryId = 2; + repeated LongProperty properties = 3; + repeated AckState ackStates = 4; + repeated AckStateRef ackStateRefs = 5; +} + +message AckState { + required int64 msgLedgerId = 1; + optional bytes ackBitmap = 2; + repeated BatchedEntryDeletionIndexInfo batchAcks = 3; +} + +message AckStateRef { + required int64 msgLedgerId = 1; + required int64 cursorLedgerId = 2; + required int64 entryId = 3; +} + +message CursorCheckpointChunk { + optional int32 partIndex = 1; + optional int32 partCount = 2; + optional bytes checkpointBytes = 3; } enum CompressionType { diff --git a/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/AutomaticOffloadTriggerControllerTest.java b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/AutomaticOffloadTriggerControllerTest.java new file mode 100644 index 0000000000000..9aefc7e3e1e1f --- /dev/null +++ b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/AutomaticOffloadTriggerControllerTest.java @@ -0,0 +1,85 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.bookkeeper.mledger.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.testng.annotations.Test; + +public class AutomaticOffloadTriggerControllerTest { + + @Test + public void triggersCoalesceWhileRunIsActive() { + AutomaticOffloadTriggerController controller = new AutomaticOffloadTriggerController(); + + assertThat(controller.requestRun()).isTrue(); + assertThat(controller.requestRun()).isFalse(); + assertThat(controller.requestRun()).isFalse(); + } + + @Test + public void pendingTriggerSchedulesOneFollowUpRun() { + AutomaticOffloadTriggerController controller = new AutomaticOffloadTriggerController(); + + assertThat(controller.requestRun()).isTrue(); + assertThat(controller.requestRun()).isFalse(); + + assertThat(controller.completeRun()).isTrue(); + assertThat(controller.completeRun()).isFalse(); + assertThat(controller.requestRun()).isTrue(); + } + + @Test(timeOut = 30000) + public void concurrentTriggerAndCompletionAlwaysReserveOneFollowUpRun() throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + for (int i = 0; i < 1000; i++) { + AutomaticOffloadTriggerController controller = new AutomaticOffloadTriggerController(); + assertThat(controller.requestRun()).isTrue(); + + // Completion and a new trigger can race; exactly one side must reserve the follow-up run. + CyclicBarrier barrier = new CyclicBarrier(3); + Future completeResult = executor.submit(() -> { + barrier.await(5, TimeUnit.SECONDS); + return controller.completeRun(); + }); + Future triggerResult = executor.submit(() -> { + barrier.await(5, TimeUnit.SECONDS); + return controller.requestRun(); + }); + + barrier.await(5, TimeUnit.SECONDS); + boolean followUpReservedByComplete = completeResult.get(5, TimeUnit.SECONDS); + boolean followUpReservedByTrigger = triggerResult.get(5, TimeUnit.SECONDS); + + assertThat(followUpReservedByComplete) + .as("iteration %s must reserve exactly one follow-up run", i) + .isNotEqualTo(followUpReservedByTrigger); + assertThat(controller.completeRun()).isFalse(); + } + } finally { + executor.shutdownNow(); + executor.awaitTermination(5, TimeUnit.SECONDS); + } + } +} diff --git a/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/CursorCheckpointLogRecoveryTest.java b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/CursorCheckpointLogRecoveryTest.java new file mode 100644 index 0000000000000..e73f41fc930a3 --- /dev/null +++ b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/CursorCheckpointLogRecoveryTest.java @@ -0,0 +1,355 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.bookkeeper.mledger.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import com.google.protobuf.ByteString; +import java.util.concurrent.TimeUnit; +import org.apache.bookkeeper.client.BookKeeper; +import org.apache.bookkeeper.client.LedgerHandle; +import org.apache.bookkeeper.mledger.ManagedLedgerException; +import org.apache.bookkeeper.mledger.proto.MLDataFormats.AckState; +import org.apache.bookkeeper.mledger.proto.MLDataFormats.CursorCheckpoint; +import org.apache.bookkeeper.mledger.proto.MLDataFormats.CursorCheckpointChunk; +import org.apache.bookkeeper.mledger.proto.MLDataFormats.CursorLogEntry; +import org.apache.bookkeeper.mledger.proto.MLDataFormats.PositionInfo; +import org.apache.bookkeeper.test.MockedBookKeeperTestCase; +import org.testng.annotations.Test; + +public class CursorCheckpointLogRecoveryTest extends MockedBookKeeperTestCase { + + private LedgerHandle createLedger() throws Exception { + return bkc.createLedger(BookKeeper.DigestType.MAC, new byte[0]); + } + + private long appendRaw(LedgerHandle lh, byte[] data) throws Exception { + return lh.append(data); + } + + private byte[] wrapCheckpoint(CursorCheckpoint cp) throws Exception { + return CursorLogEntry.newBuilder() + .setCheckpoint(CursorCheckpoint.parseFrom(cp.toByteArray())) + .build() + .toByteArray(); + } + + private byte[] wrapChunkPart(int partIndex, int partCount, byte[] payload) { + return CursorLogEntry.newBuilder() + .setCheckpointChunk(CursorCheckpointChunk.newBuilder() + .setPartIndex(partIndex).setPartCount(partCount) + .setCheckpointBytes(ByteString.copyFrom(payload)) + .build()) + .build() + .toByteArray(); + } + + private byte[] wrapLegacyPositionInfo(long ledgerId, long entryId) { + return PositionInfo.newBuilder() + .setLedgerId(ledgerId).setEntryId(entryId) + .build() + .toByteArray(); + } + + private CursorCheckpoint makeCheckpoint(long mdLedgerId, long mdEntryId) { + return CursorCheckpoint.newBuilder() + .setMarkDeleteLedgerId(mdLedgerId) + .setMarkDeleteEntryId(mdEntryId) + .addAckStates(AckState.newBuilder().setMsgLedgerId(mdLedgerId).build()) + .build(); + } + + /** + * Scenario: checkpoint → legacy → chunk(complete) → chunk(incomplete) → chunk(incomplete) + * + * Recovery reads last (incomplete) → scanBack → finds chunk(complete) → assembles. + */ + @Test(timeOut = 30000) + public void testScanBackFindsCompleteChunk() throws Exception { + LedgerHandle lh = createLedger(); + CursorCheckpointLog writer = new CursorCheckpointLog(5 * 1024 * 1024); + + appendRaw(lh, wrapCheckpoint(makeCheckpoint(10, 0))); + appendRaw(lh, wrapLegacyPositionInfo(10, 1)); + // Write a complete 2-part chunk + byte[] cpBytes = makeCheckpoint(30, 5).toByteArray(); + int half = cpBytes.length / 2; + byte[] part0 = new byte[half]; + byte[] part1 = new byte[cpBytes.length - half]; + System.arraycopy(cpBytes, 0, part0, 0, half); + System.arraycopy(cpBytes, half, part1, 0, part1.length); + appendRaw(lh, wrapChunkPart(0, 2, part0)); + appendRaw(lh, wrapChunkPart(1, 2, part1)); + // Two incomplete chunks after + appendRaw(lh, wrapChunkPart(0, 3, new byte[100])); + appendRaw(lh, wrapChunkPart(1, 3, new byte[100])); + + CursorCheckpointLog.RecoveredState state = writer.readLatest(lh).get(5, TimeUnit.SECONDS); + assertThat(state.isLegacy()).isFalse(); + assertThat(state.checkpoint.getMarkDeleteLedgerId()).isEqualTo(30L); + } + + /** + * Scenario: checkpoint → legacy → chunk(incomplete) + * + * Recovery reads last (incomplete) → scanBack → finds legacy → returns legacy. + */ + @Test(timeOut = 30000) + public void testScanBackFallsBackToLegacy() throws Exception { + LedgerHandle lh = createLedger(); + CursorCheckpointLog writer = new CursorCheckpointLog(5 * 1024 * 1024); + + appendRaw(lh, wrapCheckpoint(makeCheckpoint(10, 0))); + appendRaw(lh, wrapLegacyPositionInfo(20, 5)); + // Incomplete chunk + appendRaw(lh, wrapChunkPart(0, 3, new byte[100])); + + CursorCheckpointLog.RecoveredState state = writer.readLatest(lh).get(5, TimeUnit.SECONDS); + assertThat(state.isLegacy()).isTrue(); + PositionInfo pi = PositionInfo.parseFrom(state.legacyBytes); + assertThat(pi.getLedgerId()).isEqualTo(20L); + assertThat(pi.getEntryId()).isEqualTo(5L); + } + + /** + * Scenario: only incomplete chunks, scanBack exhausts, error. + */ + @Test(timeOut = 30000) + public void testScanBackExhausts() throws Exception { + LedgerHandle lh = createLedger(); + CursorCheckpointLog writer = new CursorCheckpointLog(5 * 1024 * 1024); + + appendRaw(lh, wrapChunkPart(0, 3, new byte[100])); + appendRaw(lh, wrapChunkPart(1, 3, new byte[100])); + + try { + writer.readLatest(lh).get(5, TimeUnit.SECONDS); + assertThat(false).as("Should have failed").isTrue(); + } catch (Exception e) { + assertThat(e).hasCauseInstanceOf(ManagedLedgerException.class); + } + } + + /** + * Scenario: last entry is legacy → returns directly (no scanBack). + */ + @Test(timeOut = 30000) + public void testLegacyAsLastEntry() throws Exception { + LedgerHandle lh = createLedger(); + CursorCheckpointLog writer = new CursorCheckpointLog(5 * 1024 * 1024); + + appendRaw(lh, wrapCheckpoint(makeCheckpoint(10, 0))); + appendRaw(lh, wrapLegacyPositionInfo(20, 5)); + + CursorCheckpointLog.RecoveredState state = writer.readLatest(lh).get(5, TimeUnit.SECONDS); + assertThat(state.isLegacy()).isTrue(); + } + + /** + * Scenario: last entry is a valid checkpoint → returns directly. + */ + @Test(timeOut = 30000) + public void testCheckpointAsLastEntry() throws Exception { + LedgerHandle lh = createLedger(); + CursorCheckpointLog writer = new CursorCheckpointLog(5 * 1024 * 1024); + + appendRaw(lh, wrapLegacyPositionInfo(10, 0)); + appendRaw(lh, wrapCheckpoint(makeCheckpoint(20, 5))); + + CursorCheckpointLog.RecoveredState state = writer.readLatest(lh).get(5, TimeUnit.SECONDS); + assertThat(state.isLegacy()).isFalse(); + assertThat(state.checkpoint.getMarkDeleteLedgerId()).isEqualTo(20L); + } + + /** + * Scenario: old, chunk(complete as last entry). + */ + @Test(timeOut = 30000) + public void testCompleteChunkAsLastEntry() throws Exception { + LedgerHandle lh = createLedger(); + CursorCheckpointLog writer = new CursorCheckpointLog(5 * 1024 * 1024); + + appendRaw(lh, wrapLegacyPositionInfo(10, 0)); + byte[] cpBytes = makeCheckpoint(40, 7).toByteArray(); + int half = cpBytes.length / 2; + byte[] part0 = new byte[half]; + byte[] part1 = new byte[cpBytes.length - half]; + System.arraycopy(cpBytes, 0, part0, 0, half); + System.arraycopy(cpBytes, half, part1, 0, part1.length); + appendRaw(lh, wrapChunkPart(0, 2, part0)); + appendRaw(lh, wrapChunkPart(1, 2, part1)); + + CursorCheckpointLog.RecoveredState state = writer.readLatest(lh).get(5, TimeUnit.SECONDS); + assertThat(state.isLegacy()).isFalse(); + assertThat(state.checkpoint.getMarkDeleteLedgerId()).isEqualTo(40L); + } + + /** + * Scenario: chunk(complete), chunk(incomplete). + */ + @Test(timeOut = 30000) + public void testCompleteThenIncompleteChunkFallsBackToPreviousCompleteChunk() throws Exception { + LedgerHandle lh = createLedger(); + CursorCheckpointLog writer = new CursorCheckpointLog(5 * 1024 * 1024); + + byte[] cpBytes = makeCheckpoint(50, 9).toByteArray(); + int half = cpBytes.length / 2; + byte[] part0 = new byte[half]; + byte[] part1 = new byte[cpBytes.length - half]; + System.arraycopy(cpBytes, 0, part0, 0, half); + System.arraycopy(cpBytes, half, part1, 0, part1.length); + appendRaw(lh, wrapChunkPart(0, 2, part0)); + appendRaw(lh, wrapChunkPart(1, 2, part1)); + appendRaw(lh, wrapChunkPart(0, 3, new byte[64])); + + CursorCheckpointLog.RecoveredState state = writer.readLatest(lh).get(5, TimeUnit.SECONDS); + assertThat(state.isLegacy()).isFalse(); + assertThat(state.checkpoint.getMarkDeleteLedgerId()).isEqualTo(50L); + } + + /** + * Scenario: new, chunk(incomplete). + */ + @Test(timeOut = 30000) + public void testNewThenIncompleteChunkFallsBackToNewCheckpoint() throws Exception { + LedgerHandle lh = createLedger(); + CursorCheckpointLog writer = new CursorCheckpointLog(5 * 1024 * 1024); + + appendRaw(lh, wrapCheckpoint(makeCheckpoint(60, 11))); + appendRaw(lh, wrapChunkPart(0, 3, new byte[64])); + + CursorCheckpointLog.RecoveredState state = writer.readLatest(lh).get(5, TimeUnit.SECONDS); + assertThat(state.isLegacy()).isFalse(); + assertThat(state.checkpoint.getMarkDeleteLedgerId()).isEqualTo(60L); + } + + /** + * Boundary: recovery keeps scanning back, but only within MAX_SCAN_BACK (1000). + */ + @Test(timeOut = 30000) + public void testScanBackLimitBoundary() throws Exception { + LedgerHandle lh = createLedger(); + CursorCheckpointLog writer = new CursorCheckpointLog(5 * 1024 * 1024); + + appendRaw(lh, wrapCheckpoint(makeCheckpoint(70, 13))); + for (int i = 0; i < 1000; i++) { + appendRaw(lh, wrapChunkPart(0, 3, new byte[16])); + } + CursorCheckpointLog.RecoveredState withinLimit = writer.readLatest(lh).get(5, TimeUnit.SECONDS); + assertThat(withinLimit.isLegacy()).isFalse(); + assertThat(withinLimit.checkpoint.getMarkDeleteLedgerId()).isEqualTo(70L); + + appendRaw(lh, wrapChunkPart(0, 3, new byte[16])); + try { + writer.readLatest(lh).get(5, TimeUnit.SECONDS); + assertThat(false).as("Should fail after exceeding scan-back limit").isTrue(); + } catch (Exception e) { + assertThat(e).hasCauseInstanceOf(ManagedLedgerException.class); + } + } + + /** + * Scenario: entry parses as CursorLogEntry but contains no known payload. + * + * Recovery should fail fast because this is a data error. + */ + @Test(timeOut = 30000) + public void testInvalidEnvelopeFailsRecovery() throws Exception { + LedgerHandle lh = createLedger(); + CursorCheckpointLog writer = new CursorCheckpointLog(5 * 1024 * 1024); + + appendRaw(lh, CursorLogEntry.getDefaultInstance().toByteArray()); + + try { + writer.readLatest(lh).get(5, TimeUnit.SECONDS); + assertThat(false).as("Should have failed").isTrue(); + } catch (Exception e) { + assertThat(e).hasCauseInstanceOf(ManagedLedgerException.class); + } + } + + /** + * Scenario: bytes cannot be parsed as CursorLogEntry or PositionInfo. + * + * Recovery should fail rather than treating corrupted data as legacy. + */ + @Test(timeOut = 30000) + public void testCorruptedBytesFailRecovery() throws Exception { + LedgerHandle lh = createLedger(); + CursorCheckpointLog writer = new CursorCheckpointLog(5 * 1024 * 1024); + + appendRaw(lh, new byte[]{(byte) 0x80}); + + try { + writer.readLatest(lh).get(5, TimeUnit.SECONDS); + assertThat(false).as("Should have failed").isTrue(); + } catch (Exception e) { + assertThat(e).hasCauseInstanceOf(ManagedLedgerException.class); + } + } + + /** + * Scenario: latest chunk looks complete, but assembly fails. + * + * Recovery should continue scan-back and return the previous valid checkpoint. + */ + @Test(timeOut = 30000) + public void testBrokenCompleteChunkFallsBackToPreviousCheckpoint() throws Exception { + LedgerHandle lh = createLedger(); + CursorCheckpointLog writer = new CursorCheckpointLog(5 * 1024 * 1024); + + appendRaw(lh, wrapCheckpoint(makeCheckpoint(80, 15))); + appendRaw(lh, new byte[]{0x01, 0x02, 0x03}); // not a valid chunk part + appendRaw(lh, wrapChunkPart(1, 2, new byte[]{0x11, 0x22})); + + CursorCheckpointLog.RecoveredState state = writer.readLatest(lh).get(5, TimeUnit.SECONDS); + assertThat(state.isLegacy()).isFalse(); + assertThat(state.checkpoint.getMarkDeleteLedgerId()).isEqualTo(80L); + } + + /** + * Scenario: readAt on the last part of a chunked checkpoint returns the assembled checkpoint, + * while readAt on a non-terminal part fails fast (ref targets must not scan back). + */ + @Test(timeOut = 30000) + public void testReadAtAssemblesChunkAndFailsOnNonTerminalPart() throws Exception { + LedgerHandle lh = createLedger(); + CursorCheckpointLog writer = new CursorCheckpointLog(5 * 1024 * 1024); + + byte[] cpBytes = makeCheckpoint(90, 21).toByteArray(); + int half = cpBytes.length / 2; + byte[] part0 = new byte[half]; + byte[] part1 = new byte[cpBytes.length - half]; + System.arraycopy(cpBytes, 0, part0, 0, half); + System.arraycopy(cpBytes, half, part1, 0, part1.length); + long firstEntry = appendRaw(lh, wrapChunkPart(0, 2, part0)); + long lastEntry = appendRaw(lh, wrapChunkPart(1, 2, part1)); + + CursorCheckpointLog.RecoveredState state = writer.readAt(lh, lastEntry).get(5, TimeUnit.SECONDS); + assertThat(state.isLegacy()).isFalse(); + assertThat(state.checkpoint.getMarkDeleteLedgerId()).isEqualTo(90L); + assertThat(state.commitEntryId).isEqualTo(lastEntry); + + try { + writer.readAt(lh, firstEntry).get(5, TimeUnit.SECONDS); + assertThat(false).as("Should have failed on a non-terminal chunk part").isTrue(); + } catch (Exception e) { + assertThat(e).hasCauseInstanceOf(ManagedLedgerException.class); + } + } +} diff --git a/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/CursorCheckpointPersistenceOrderingTest.java b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/CursorCheckpointPersistenceOrderingTest.java new file mode 100644 index 0000000000000..241a5dbab64c2 --- /dev/null +++ b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/CursorCheckpointPersistenceOrderingTest.java @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.bookkeeper.mledger.impl; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNull; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import org.apache.bookkeeper.mledger.Position; +import org.apache.bookkeeper.mledger.PositionFactory; +import org.testng.annotations.Test; + +/** + * Ordering guarantees of {@link CursorCheckpointPersistence}: the per-msg-ledger checkpoint + * position index must never regress, otherwise a later AckStateRef would point at an older + * (smaller) ack bitmap and acks recorded in the newer checkpoint would be lost on recovery. + */ +public class CursorCheckpointPersistenceOrderingTest { + + private CursorCheckpointPersistence newInstance() { + return new CursorCheckpointPersistence(null, new ReentrantReadWriteLock(), + null, null, new byte[0], true); + } + + @Test + public void testRecordCheckpointPosMonotonic() { + CursorCheckpointPersistence persistence = newInstance(); + assertNull(persistence.checkpointPosOf(5)); + + // First record wins by absence. + persistence.recordCheckpointPos(5, pos(1, 10)); + assertEquals(persistence.checkpointPosOf(5), pos(1, 10)); + + // Newer position advances the index. + persistence.recordCheckpointPos(5, pos(1, 20)); + assertEquals(persistence.checkpointPosOf(5), pos(1, 20)); + + // A stale completion (out-of-order append callback, same cursor ledger) must not regress. + persistence.recordCheckpointPos(5, pos(1, 15)); + assertEquals(persistence.checkpointPosOf(5), pos(1, 20)); + + // A position from an older cursor ledger (after rollover replay) must not regress either. + persistence.recordCheckpointPos(5, pos(0, 999)); + assertEquals(persistence.checkpointPosOf(5), pos(1, 20)); + + // Ledger ids are independent entries. + persistence.recordCheckpointPos(7, pos(1, 3)); + assertEquals(persistence.checkpointPosOf(7), pos(1, 3)); + assertEquals(persistence.checkpointPosOf(5), pos(1, 20)); + } + + private static Position pos(long ledgerId, long entryId) { + return PositionFactory.create(ledgerId, entryId); + } +} diff --git a/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/ManagedCursorConcurrencyTest.java b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/ManagedCursorConcurrencyTest.java index f7b6e755bce83..1b8c999eef37a 100644 --- a/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/ManagedCursorConcurrencyTest.java +++ b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/ManagedCursorConcurrencyTest.java @@ -70,7 +70,7 @@ public void deleteFailed(ManagedLedgerException exception, Object ctx) { @Test(dataProvider = "useOpenRangeSet") public void testMarkDeleteAndRead(boolean useOpenRangeSet) throws Exception { ManagedLedgerConfig config = new ManagedLedgerConfig().setMaxEntriesPerLedger(2) - .setUnackedRangesOpenCacheSetEnabled(useOpenRangeSet); + ; ManagedLedger ledger = factory.open("my_test_ledger", config); final ManagedCursor cursor = ledger.openCursor("c1"); diff --git a/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/ManagedCursorIndividualDeletedMessagesTest.java b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/ManagedCursorIndividualDeletedMessagesTest.java index b6c4cc5895db5..86e3bcbd22307 100644 --- a/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/ManagedCursorIndividualDeletedMessagesTest.java +++ b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/ManagedCursorIndividualDeletedMessagesTest.java @@ -44,7 +44,6 @@ void testRecoverIndividualDeletedMessages() throws Exception { BookKeeper bookkeeper = mock(BookKeeper.class); ManagedLedgerConfig config = new ManagedLedgerConfig(); - config.setUnackedRangesOpenCacheSetEnabled(true); NavigableMap ledgersInfo = new ConcurrentSkipListMap<>(); ledgersInfo.put(1L, createLedgerInfo(1, 100, 1024)); diff --git a/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/ManagedCursorSkipDeletedEntriesTest.java b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/ManagedCursorSkipDeletedEntriesTest.java new file mode 100644 index 0000000000000..8c7dbd93dbf2d --- /dev/null +++ b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/ManagedCursorSkipDeletedEntriesTest.java @@ -0,0 +1,142 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.bookkeeper.mledger.impl; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Predicate; +import lombok.Cleanup; +import org.apache.bookkeeper.mledger.AsyncCallbacks.ReadEntriesCallback; +import org.apache.bookkeeper.mledger.Entry; +import org.apache.bookkeeper.mledger.ManagedLedgerConfig; +import org.apache.bookkeeper.mledger.ManagedLedgerException; +import org.apache.bookkeeper.mledger.Position; +import org.apache.bookkeeper.mledger.PositionFactory; +import org.apache.bookkeeper.test.MockedBookKeeperTestCase; +import org.testng.annotations.Test; + +/** + * Regression test for reading over a cursor that has a very large contiguous range of individually + * deleted (acknowledged) entries while its mark-delete position is pinned by an older unacknowledged entry. + * + *

This is the shape a Key_Shared subscription ends up in when a single unacknowledged message blocks the + * mark-delete position while consumers keep acknowledging everything after it. Reads then have to get + * past tens of millions of already-acknowledged entries before they can fill a batch. + * + *

The skip pre-filter in {@link ManagedLedgerImpl#internalReadFromLedger} used to advance the read + * position by the size of the scan window when every entry in that window was already acknowledged, instead of + * hopping over the whole deleted range via + * {@link ManagedCursorImpl#getNextAvailablePosition(Position)}. That turned a single range hop into + * O(entries / batchSize) read-loop iterations. Each iteration re-ran + * {@code isLedgerFullyAcked} -> {@code ManagedCursorImpl#getNumberOfEntries} -> + * {@code RangeSetWrapper#cardinality}, which clones the cursor's per-ledger Roaring bitmap. + */ +public class ManagedCursorSkipDeletedEntriesTest extends MockedBookKeeperTestCase { + + private static final int TOTAL_ENTRIES = 20_000; + private static final int READ_BATCH_SIZE = 100; + /** Entries {@code [1, LAST_DELETED_INDEX]} are individually deleted, forming one contiguous range. */ + private static final int LAST_DELETED_INDEX = TOTAL_ENTRIES - 4; + + /** + * A read that has to get past one large contiguous deleted range must hop over it rather than walk + * it one batch at a time. + */ + @Test(timeOut = 300_000) + public void testReadHopsOverLargeDeletedRangeInsteadOfWalkingIt() throws Exception { + ManagedLedgerConfig config = new ManagedLedgerConfig() + // Keep every entry below in a single ledger, then force a rollover so that the reads + // under test target a closed ledger. That makes every read-loop iteration go through + // the isLedgerFullyAcked check, as it does in production. + .setMaxEntriesPerLedger(TOTAL_ENTRIES) + .setRetentionTime(1, TimeUnit.HOURS) + .setRetentionSizeInMB(-1); + + @Cleanup + ManagedLedgerImpl ledger = (ManagedLedgerImpl) factory.open("skip-deleted-entries", config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("sub"); + + List positions = new ArrayList<>(TOTAL_ENTRIES); + for (int i = 0; i < TOTAL_ENTRIES; i++) { + positions.add(ledger.addEntry(new byte[]{1})); + } + // Roll the ledger holding the entries above, so it is closed by the time it is read. + Position afterRollover = ledger.addEntry(new byte[]{1}); + assertTrue(ledger.getLedgersInfoAsList().size() >= 2, + "expected the entries under test to live in a closed ledger, ledgers=" + + ledger.getLedgersInfoAsList().size()); + + // Individually acknowledge [1, LAST_DELETED_INDEX]. Entry 0 stays unacknowledged and pins the + // mark-delete position, so none of these acknowledgments can be collapsed into it. + cursor.delete(positions.subList(1, LAST_DELETED_INDEX + 1)); + + assertEquals(cursor.getMarkDeletedPosition(), PositionFactory.create(positions.get(0).getLedgerId(), -1), + "mark-delete position must stay pinned behind the unacknowledged entry 0"); + assertEquals(cursor.getTotalNonContiguousDeletedMessagesRange(), 1, + "the acknowledgments must collapse into a single contiguous range"); + + // The deliverable entries are entry 0, the tail holes, and the entry that caused the rollover. + List expected = new ArrayList<>(); + expected.add(positions.get(0)); + for (int i = LAST_DELETED_INDEX + 1; i < TOTAL_ENTRIES; i++) { + expected.add(positions.get(i)); + } + expected.add(afterRollover); + + // Count every position the skip pre-filter examines. Predicate#or evaluates this one first, so + // it sees exactly the positions ManagedLedgerImpl walks before deciding what to read. + AtomicInteger examinedPositions = new AtomicInteger(); + Predicate countingCondition = position -> { + examinedPositions.incrementAndGet(); + return false; + }; + + CompletableFuture> readFuture = new CompletableFuture<>(); + cursor.asyncReadEntriesWithSkip(READ_BATCH_SIZE, -1L, new ReadEntriesCallback() { + @Override + public void readEntriesComplete(List entries, Object ctx) { + readFuture.complete(entries); + } + + @Override + public void readEntriesFailed(ManagedLedgerException exception, Object ctx) { + readFuture.completeExceptionally(exception); + } + }, null, PositionFactory.LATEST, countingCondition); + + List read = readFuture.get(120, TimeUnit.SECONDS); + List readPositions = new ArrayList<>(); + for (Entry entry : read) { + readPositions.add(entry.getPosition()); + entry.release(); + } + assertEquals(readPositions, expected, "the read must return exactly the unacknowledged entries"); + + // Hopping the range examines one scan window before jumping it and a small number of windows for + // the entries actually returned. Walking it examines every position in the deleted block. + assertTrue(examinedPositions.get() <= 4 * READ_BATCH_SIZE, + "read examined " + examinedPositions.get() + " positions to return " + read.size() + + " entries over a deleted range of " + LAST_DELETED_INDEX + " entries"); + } +} diff --git a/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/ManagedCursorTest.java b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/ManagedCursorTest.java index 72bd81c08b025..cdee6454d5df0 100644 --- a/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/ManagedCursorTest.java +++ b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/ManagedCursorTest.java @@ -23,6 +23,7 @@ import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.Mockito.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.eq; @@ -89,11 +90,13 @@ import org.apache.bookkeeper.client.BookKeeper; import org.apache.bookkeeper.client.BookKeeper.DigestType; import org.apache.bookkeeper.client.LedgerEntry; +import org.apache.bookkeeper.client.LedgerHandle; import org.apache.bookkeeper.client.PulsarMockBookKeeper; import org.apache.bookkeeper.client.PulsarMockReadHandleInterceptor; import org.apache.bookkeeper.client.api.LedgerEntries; import org.apache.bookkeeper.client.api.ReadHandle; import org.apache.bookkeeper.common.util.OrderedExecutor; +import org.apache.bookkeeper.common.util.OrderedScheduler; import org.apache.bookkeeper.mledger.AsyncCallbacks; import org.apache.bookkeeper.mledger.AsyncCallbacks.AddEntryCallback; import org.apache.bookkeeper.mledger.AsyncCallbacks.DeleteCallback; @@ -124,6 +127,7 @@ import org.apache.commons.lang3.mutable.MutableBoolean; import org.apache.pulsar.common.api.proto.CommandSubscribe; import org.apache.pulsar.common.api.proto.IntRange; +import org.apache.pulsar.common.policies.data.ManagedLedgerInternalStats; import org.apache.pulsar.common.util.FutureUtil; import org.apache.pulsar.common.util.collections.BitSetRecyclable; import org.apache.pulsar.common.util.collections.LongPairRangeSet; @@ -132,6 +136,7 @@ import org.apache.pulsar.metadata.api.extended.SessionEvent; import org.apache.pulsar.metadata.impl.FaultInjectionMetadataStore; import org.awaitility.Awaitility; +import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; import org.mockito.Mockito; import org.mockito.invocation.InvocationOnMock; @@ -271,7 +276,7 @@ public void testConcurrentPropertyOperationsThreadSafety() throws Exception { } executor.shutdown(); - // Wait for each task to complete with timeout + // Wait for each task to complete with timeou for (Future future : allFutures) { try { future.get(30, TimeUnit.SECONDS); @@ -293,7 +298,7 @@ public void testConcurrentPropertyOperationsThreadSafety() throws Exception { // 2. No inconsistent states detected assertFalse(inconsistencyDetected.get(), "No inconsistent states (key with null value) should be detected"); - // 3: Final cursor state should be internally consistent + // 3: Final cursor state should be internally consisten Map finalProperties = cursor.getProperties(); try { for (Map.Entry entry : finalProperties.entrySet()) { @@ -981,7 +986,7 @@ void testResetCursor1() throws Exception { final AtomicBoolean moveStatus = new AtomicBoolean(false); - // reset to earliest + // reset to earlies Position earliest = PositionFactory.EARLIEST; try { cursor.resetCursor(earliest); @@ -1551,7 +1556,7 @@ public void asyncMarkDeleteBlockingWithOneShot() throws Exception { // just for log debug purpose Deque positions = new ConcurrentLinkedDeque<>(); - // In previous flaky test, we set num=100, PR https://github.com/apache/pulsar/pull/25087 will make the test + // In previous flaky test, we set num=100, PR https://github.com/apache/pulsar/pull/25087 will make the tes // more flaky. Flaky case: // 1. cursor recovered with markDeletePosition 12:9, persistentMarkDeletePosition 12:9. // 2. cursor recovered with mark markDeletePosition 13:-1, persistentMarkDeletePosition 13:-1. @@ -2075,7 +2080,7 @@ void testCountingWithDeletedEntries() throws Exception { @Test(timeOut = 20000, dataProvider = "useOpenRangeSet") void testMarkDeleteTwice(boolean useOpenRangeSet) throws Exception { ManagedLedger ledger = factory.open("my_test_ledger", new ManagedLedgerConfig() - .setUnackedRangesOpenCacheSetEnabled(useOpenRangeSet).setMaxEntriesPerLedger(2)); + .setMaxEntriesPerLedger(2)); ManagedCursor cursor = ledger.openCursor("c1"); Position p1 = ledger.addEntry("entry1".getBytes()); @@ -2088,7 +2093,7 @@ void testMarkDeleteTwice(boolean useOpenRangeSet) throws Exception { @Test(timeOut = 20000, dataProvider = "useOpenRangeSet") void testSkipEntries(boolean useOpenRangeSet) throws Exception { ManagedLedgerImpl ledger = (ManagedLedgerImpl) factory.open("my_test_ledger", new ManagedLedgerConfig() - .setUnackedRangesOpenCacheSetEnabled(useOpenRangeSet).setMaxEntriesPerLedger(2)); + .setMaxEntriesPerLedger(2)); Position pos; ManagedCursor c1 = ledger.openCursor("c1"); @@ -2138,7 +2143,7 @@ void testSkipEntries(boolean useOpenRangeSet) throws Exception { @Test(timeOut = 20000, dataProvider = "useOpenRangeSet") void testSkipEntriesWithIndividualDeletedMessages(boolean useOpenRangeSet) throws Exception { ManagedLedger ledger = factory.open("testSkipEntriesWithIndividualDeletedMessages", new ManagedLedgerConfig() - .setUnackedRangesOpenCacheSetEnabled(useOpenRangeSet).setMaxEntriesPerLedger(5)); + .setMaxEntriesPerLedger(5)); ManagedCursor c1 = ledger.openCursor("c1"); Position pos1 = ledger.addEntry("dummy-entry-1".getBytes(Encoding)); @@ -2186,7 +2191,7 @@ void testSkipEntriesWithIndividualDeletedMessages(boolean useOpenRangeSet) throw @Test(timeOut = 20000, dataProvider = "useOpenRangeSet") void testClearBacklog(boolean useOpenRangeSet) throws Exception { ManagedLedger ledger = factory.open("my_test_ledger", new ManagedLedgerConfig() - .setUnackedRangesOpenCacheSetEnabled(useOpenRangeSet).setMaxEntriesPerLedger(1)); + .setMaxEntriesPerLedger(1)); ManagedCursor c1 = ledger.openCursor("c1"); ledger.addEntry("dummy-entry-1".getBytes(Encoding)); @@ -2238,7 +2243,7 @@ void testClearBacklog(boolean useOpenRangeSet) throws Exception { @Test(timeOut = 20000, dataProvider = "useOpenRangeSet") void testRateLimitMarkDelete(boolean useOpenRangeSet) throws Exception { ManagedLedgerConfig config = new ManagedLedgerConfig(); - config.setThrottleMarkDelete(1).setUnackedRangesOpenCacheSetEnabled(useOpenRangeSet); // Throttle to 1/s + config.setThrottleMarkDelete(1); // Throttle to 1/s ManagedLedger ledger = factory.open("my_test_ledger", config); ManagedCursor c1 = ledger.openCursor("c1"); @@ -2267,7 +2272,7 @@ void testRateLimitMarkDelete(boolean useOpenRangeSet) throws Exception { @Test(timeOut = 20000, dataProvider = "useOpenRangeSet") void deleteSingleMessageTwice(boolean useOpenRangeSet) throws Exception { ManagedLedger ledger = factory.open("my_test_ledger", - new ManagedLedgerConfig().setUnackedRangesOpenCacheSetEnabled(useOpenRangeSet)); + new ManagedLedgerConfig()); ManagedCursor c1 = ledger.openCursor("c1"); @@ -2331,11 +2336,10 @@ void deleteSingleMessageTwice(boolean useOpenRangeSet) throws Exception { assertEquals(c1.getMarkDeletedPosition(), p4); assertEquals(c1.getReadPosition(), p4.getNext()); } - @Test(timeOut = 10000, dataProvider = "useOpenRangeSet") void testReadEntriesOrWait(boolean useOpenRangeSet) throws Exception { ManagedLedger ledger = factory.open("my_test_ledger", - new ManagedLedgerConfig().setUnackedRangesOpenCacheSetEnabled(useOpenRangeSet)); + new ManagedLedgerConfig()); final int consumers = 10; final CountDownLatch counter = new CountDownLatch(consumers); @@ -2481,7 +2485,7 @@ void testScan(int numEntries, int batchSize) throws Exception { return true; }), batchSize, 1, Long.MAX_VALUE).get()); - // timeout + // timeou // please note that the timeout is verified // between the reads // so with a big batchSize this test would take too much @@ -2750,7 +2754,7 @@ void testFindNewestMatchingEdgeCase5() throws Exception { @Test(timeOut = 20000, dataProvider = "useOpenRangeSet") void testFindNewestMatchingEdgeCase6(boolean useOpenRangeSet) throws Exception { ManagedLedger ledger = factory.open("testFindNewestMatchingEdgeCase6", new ManagedLedgerConfig() - .setUnackedRangesOpenCacheSetEnabled(useOpenRangeSet).setMaxEntriesPerLedger(3)); + .setMaxEntriesPerLedger(3)); ManagedCursorImpl c1 = (ManagedCursorImpl) ledger.openCursor("c1"); ledger.addEntry("expired".getBytes(Encoding)); @@ -2769,7 +2773,7 @@ void testFindNewestMatchingEdgeCase6(boolean useOpenRangeSet) throws Exception { @Test(timeOut = 20000, dataProvider = "useOpenRangeSet") void testFindNewestMatchingEdgeCase7(boolean useOpenRangeSet) throws Exception { ManagedLedger ledger = factory.open("testFindNewestMatchingEdgeCase7", - new ManagedLedgerConfig().setUnackedRangesOpenCacheSetEnabled(useOpenRangeSet)); + new ManagedLedgerConfig()); ManagedCursorImpl c1 = (ManagedCursorImpl) ledger.openCursor("c1"); ledger.addEntry("expired".getBytes(Encoding)); @@ -2864,7 +2868,7 @@ void testFindNewestMatchingEdgeCase10() throws Exception { @Test(timeOut = 20000, dataProvider = "useOpenRangeSet") void testIndividuallyDeletedMessages(boolean useOpenRangeSet) throws Exception { ManagedLedger ledger = factory.open("testIndividuallyDeletedMessages", - new ManagedLedgerConfig().setUnackedRangesOpenCacheSetEnabled(useOpenRangeSet)); + new ManagedLedgerConfig()); ManagedCursorImpl c1 = (ManagedCursorImpl) ledger.openCursor("c1"); ledger.addEntry("entry-0".getBytes(Encoding)); @@ -2904,7 +2908,7 @@ void testIndividuallyDeletedMessages1() throws Exception { @Test(timeOut = 20000, dataProvider = "useOpenRangeSet") void testIndividuallyDeletedMessages2(boolean useOpenRangeSet) throws Exception { ManagedLedger ledger = factory.open("testIndividuallyDeletedMessages2", - new ManagedLedgerConfig().setUnackedRangesOpenCacheSetEnabled(useOpenRangeSet)); + new ManagedLedgerConfig()); ManagedCursorImpl c1 = (ManagedCursorImpl) ledger.openCursor("c1"); ledger.addEntry("entry-0".getBytes(Encoding)); @@ -2925,7 +2929,7 @@ void testIndividuallyDeletedMessages2(boolean useOpenRangeSet) throws Exception @Test(timeOut = 20000, dataProvider = "useOpenRangeSet") void testIndividuallyDeletedMessages3(boolean useOpenRangeSet) throws Exception { ManagedLedger ledger = factory.open("testIndividuallyDeletedMessages3", - new ManagedLedgerConfig().setUnackedRangesOpenCacheSetEnabled(useOpenRangeSet)); + new ManagedLedgerConfig()); ManagedCursorImpl c1 = (ManagedCursorImpl) ledger.openCursor("c1"); ledger.addEntry("entry-0".getBytes(Encoding)); @@ -2969,7 +2973,7 @@ void testFindNewestMatchingAfterLedgerRollover() throws Exception { // then we are done // there was a bug (https://github.com/apache/pulsar/issues/9082) // in which if the last message was in a different ledger - // the jump from the first message to the last message went + // the jump from the first message to the last message wen // to an invalid position and so the search stopped at the first message // we want to assert here that the algorithm returns the position of the @@ -3112,7 +3116,7 @@ void testReplayEntries() throws Exception { ledger.addEntry("entry4".getBytes(Encoding)); // 1. Replay empty position set should return empty entry set - Set positions = new HashSet(); + Set positions = new HashSet<>(); assertTrue(c1.replayEntries(positions).isEmpty()); positions.add(p1); @@ -3535,7 +3539,7 @@ public void operationFailed(ManagedLedgerException exception) { String path = "/managed-ledgers/my_test_ledger/c1"; metadataStore.put(path, "".getBytes(), Optional.empty()).join(); - // try to create ledger again which will fail because managedCursorInfo znode is already updated with different + // try to create ledger again which will fail because managedCursorInfo znode is already updated with differen // version so, this call will fail with BadVersionException CountDownLatch latch2 = new CountDownLatch(1); // create ledger will create ledgerId = 6 @@ -3797,6 +3801,115 @@ public void testEstimatedUnackedSize() throws Exception { assertEquals(cursor.getEstimatedSizeSinceMarkDeletePosition(), 10 * entryData.length); } + @Test + public void testEstimatedUnackedSizeWhenLatestLedgerIsEmpty() throws Exception { + ManagedLedger ledger = factory.open("test_estimated_unacked_size_empty_latest_ledger"); + ManagedCursor cursor = ledger.openCursor("c1"); + + assertEquals(cursor.getEstimatedSizeSinceMarkDeletePosition(), 0); + } + + @Test + public void testEstimatedUnackedSizeWhenCursorCaughtUpWithLastPosition() throws Exception { + ManagedLedger ledger = factory.open("test_estimated_unacked_size_cursor_caught_up"); + ManagedCursor cursor = ledger.openCursor("c1"); + + Position lastPosition = ledger.addEntry("entry".getBytes(Encoding)); + cursor.markDelete(lastPosition); + + assertEquals(cursor.getEstimatedSizeSinceMarkDeletePosition(), 0); + } + + @Test + public void testScheduleReadCallbackUsesManagedLedgerExecutionContext() { + ManagedLedgerImpl ledger = mock(ManagedLedgerImpl.class); + when(ledger.getConfig()).thenReturn(new ManagedLedgerConfig()); + OrderedScheduler scheduledExecutor = mock(OrderedScheduler.class); + ExecutorService executor = mock(ExecutorService.class); + when(ledger.getScheduledExecutor()).thenReturn(scheduledExecutor); + when(ledger.getExecutor()).thenReturn(executor); + ManagedCursorImpl cursor = new ManagedCursorImpl(mock(BookKeeper.class), ledger, "c1"); + Runnable callback = mock(Runnable.class); + ArgumentCaptor scheduledTask = ArgumentCaptor.forClass(Runnable.class); + + cursor.scheduleReadCallback(callback, 100, TimeUnit.MILLISECONDS); + + verify(scheduledExecutor).schedule(scheduledTask.capture(), eq(100L), eq(TimeUnit.MILLISECONDS)); + scheduledTask.getValue().run(); + verify(executor).execute(callback); + } + + @Test + public void testDefaultScheduleReadCallback() throws InterruptedException { + ManagedCursor cursor = mock(ManagedCursor.class, Mockito.CALLS_REAL_METHODS); + CountDownLatch callbackExecuted = new CountDownLatch(1); + + cursor.scheduleReadCallback(callbackExecuted::countDown, 0, TimeUnit.MILLISECONDS); + + assertTrue(callbackExecuted.await(5, TimeUnit.SECONDS)); + } + + @Test + public void testEstimatedUnackedSizeWhenCursorAdvancedToEmptyCurrentLedger() { + ManagedLedgerImpl ledger = mock(ManagedLedgerImpl.class); + when(ledger.getName()).thenReturn("test_estimated_unacked_size_empty_current_ledger"); + when(ledger.getConfig()).thenReturn(new ManagedLedgerConfig()); + + long currentLedgerId = 4; + Position lastPosition = PositionFactory.create(3, 9); + Position markDeletePosition = PositionFactory.create(currentLedgerId, -1); + when(ledger.getLastPosition()).thenReturn(lastPosition); + when(ledger.ledgerExists(lastPosition.getLedgerId())).thenReturn(true); + + LedgerHandle currentLedger = mock(LedgerHandle.class); + when(currentLedger.getId()).thenReturn(currentLedgerId); + ledger.currentLedger = currentLedger; + ledger.currentLedgerEntries = 0; + + ManagedCursorImpl cursor = new ManagedCursorImpl(mock(BookKeeper.class), ledger, "c1"); + cursor.markDeletePosition = markDeletePosition; + + assertEquals(cursor.getEstimatedSizeSinceMarkDeletePosition(), 0); + verify(ledger, never()).estimateBacklogFromPosition(any()); + } + + @Test + public void testEstimatedUnackedSizeWhenLastPositionLedgerIsNoLongerInLedgerList() { + ManagedLedgerImpl ledger = mock(ManagedLedgerImpl.class); + when(ledger.getName()).thenReturn("test_estimated_unacked_size_last_position_ledger_removed"); + when(ledger.getConfig()).thenReturn(new ManagedLedgerConfig()); + + Position lastPosition = PositionFactory.create(3, 0); + Position markDeletePosition = PositionFactory.create(4, -1); + when(ledger.getLastPosition()).thenReturn(lastPosition); + when(ledger.ledgerExists(lastPosition.getLedgerId())).thenReturn(false); + + ManagedCursorImpl cursor = new ManagedCursorImpl(mock(BookKeeper.class), ledger, "c1"); + cursor.markDeletePosition = markDeletePosition; + + assertEquals(cursor.getEstimatedSizeSinceMarkDeletePosition(), 0); + } + + @Test + public void testEstimatedUnackedSizeFailsWhenCursorIsUnexpectedlyAheadOfLastPosition() { + ManagedLedgerImpl ledger = mock(ManagedLedgerImpl.class); + when(ledger.getName()).thenReturn("test_estimated_unacked_size_unexpected_position"); + when(ledger.getConfig()).thenReturn(new ManagedLedgerConfig()); + + Position lastPosition = PositionFactory.create(1, 10); + Position markDeletePosition = PositionFactory.create(2, -1); + when(ledger.getLastPosition()).thenReturn(lastPosition); + when(ledger.ledgerExists(lastPosition.getLedgerId())).thenReturn(true); + when(ledger.ledgerExists(markDeletePosition.getLedgerId())).thenReturn(true); + + ManagedCursorImpl cursor = new ManagedCursorImpl(mock(BookKeeper.class), ledger, "c1"); + cursor.markDeletePosition = markDeletePosition; + + IllegalArgumentException exception = Assert.expectThrows(IllegalArgumentException.class, + cursor::getEstimatedSizeSinceMarkDeletePosition); + assertTrue(exception.getMessage().contains("is ahead of the last position")); + } + /** * Test that cursor.getEstimatedSizeSinceMarkDeletePosition() correctly accounts for individual * message deletions (asyncDelete/individual ack). @@ -3866,7 +3979,7 @@ public void deleteFailed(ManagedLedgerException exception, Object ctx) { assertEquals(actualBacklogSize, expectedBacklogSize, "Backlog size should account for individual deletions"); - // Verify both count and size are correct + // Verify both count and size are correc assertEquals(cursor.getNumberOfEntriesInBacklog(true), 2, "Backlog count should be 2"); ledger.close(); @@ -4704,7 +4817,7 @@ public void testCursorNoRolloverIfNoMetadataSession() throws Exception { assertEquals(cursor.getCursorLedger(), initialLedgerId); - // After the session gets reestablished, the rollover should restart + // After the session gets reestablished, the rollover should restar metadataStore.triggerSessionEvent(SessionEvent.SessionReestablished); for (int i = 0; i < 10; i++) { @@ -5341,7 +5454,7 @@ public void findEntryFailed(ManagedLedgerException exception, assertNotNull(positionRef.get()); assertEquals(positionRef.get(), position1); - // find the newest entry with start + // find the newest entry with star AtomicBoolean failed1 = new AtomicBoolean(false); CountDownLatch latch1 = new CountDownLatch(1); AtomicReference positionRef1 = new AtomicReference<>(); @@ -5498,7 +5611,7 @@ public void findEntryFailed(ManagedLedgerException exception, assertNotNull(positionRef.get()); assertEquals(positionRef.get(), position3); - // find the newest entry with start + // find the newest entry with star AtomicBoolean failed1 = new AtomicBoolean(false); CountDownLatch latch1 = new CountDownLatch(1); AtomicReference positionRef1 = new AtomicReference<>(); @@ -6017,6 +6130,89 @@ public void deleteFailed(ManagedLedgerException exception, Object ctx) { assertEquals(properties.get(propertyKey), lastIndex - 1); } + @Test + @SuppressWarnings("unchecked") + public void testCompactionCursorResetNeverLoseMarkDeleteProperties() throws Exception { + @Cleanup + ManagedLedgerImpl ledger = (ManagedLedgerImpl) factory.open( + "testCompactionCursorResetNeverLoseMarkDeleteProperties", + new ManagedLedgerConfig().setMaxEntriesPerLedger(10)); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("__compaction"); + ManagedCursorImpl spyCursor = spy(cursor); + ledger.getCursors().removeCursor(cursor.getName()); + ledger.getCursors().add(spyCursor, null); + + ledger.addEntry("entry-1".getBytes(Encoding)); + Position markDeletePosition = ledger.addEntry("entry-2".getBytes(Encoding)); + + String compactedLedgerProperty = "CompactedTopicLedger"; + Map properties = Map.of(compactedLedgerProperty, 123456L); + + CountDownLatch markDeleteEntered = new CountDownLatch(1); + CountDownLatch resetEntered = new CountDownLatch(1); + CountDownLatch markDeleteReturned = new CountDownLatch(1); + CountDownLatch markDeleteCompleted = new CountDownLatch(1); + CountDownLatch resetCompleted = new CountDownLatch(1); + + doAnswer(invocation -> { + Map invocationProperties = invocation.getArgument(1); + if (invocationProperties != null && invocationProperties.containsKey(compactedLedgerProperty)) { + // Hold the compaction mark-delete after it enters internalAsyncMarkDelete, but before its + // properties can update lastMarkDeleteEntry. + markDeleteEntered.countDown(); + assertTrue(resetEntered.await(5, TimeUnit.SECONDS)); + try { + return invocation.callRealMethod(); + } finally { + markDeleteReturned.countDown(); + } + } + + if (invocationProperties == null || invocationProperties.isEmpty()) { + // Let reset capture its properties argument first, then persist it only after the compaction + // mark-delete has completed the real internalAsyncMarkDelete call. + resetEntered.countDown(); + assertTrue(markDeleteReturned.await(5, TimeUnit.SECONDS)); + return invocation.callRealMethod(); + } + + return invocation.callRealMethod(); + }).when(spyCursor).internalAsyncMarkDelete(any(Position.class), nullable(Map.class), + any(MarkDeleteCallback.class), nullable(Object.class), nullable(Runnable.class)); + + // Start compaction mark-delete from another thread because the spy intentionally blocks it. + CompletableFuture.runAsync(() -> spyCursor.asyncMarkDelete( + markDeletePosition, properties, new MarkDeleteCallback() { + @Override + public void markDeleteComplete(Object ctx) { + markDeleteCompleted.countDown(); + } + + @Override + public void markDeleteFailed(ManagedLedgerException exception, Object ctx) { + } + }, null)); + + assertTrue(markDeleteEntered.await(5, TimeUnit.SECONDS)); + // Reset the compaction cursor while the previous mark-delete with properties is still in progress. + spyCursor.asyncResetCursor(markDeletePosition, false, new AsyncCallbacks.ResetCursorCallback() { + @Override + public void resetComplete(Object ctx) { + resetCompleted.countDown(); + } + + @Override + public void resetFailed(ManagedLedgerException exception, Object ctx) { + } + }); + + assertTrue(markDeleteCompleted.await(5, TimeUnit.SECONDS)); + assertTrue(resetCompleted.await(5, TimeUnit.SECONDS)); + + assertEquals(spyCursor.getMarkDeletedPosition(), markDeletePosition); + assertEquals(spyCursor.getProperties(), properties); + } + class TestPulsarMockBookKeeper extends PulsarMockBookKeeper { Map ledgerErrors = new HashMap<>(); @@ -6048,4 +6244,1265 @@ public void asyncOpenLedger(final long lId, final DigestType digestType, final b } private static final Logger log = LoggerFactory.getLogger(ManagedCursorTest.class); + @Test(timeOut = 30000) + public void testCheckpointIndividualAckPersistAndRecover() throws Exception { + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setPersistentUnackedRangesWithPerLedgerEntryEnabled(true); + config.setMaxUnackedRangesToPersistInMetadataStore(-1); + config.setThrottleMarkDelete(0); + + String ledgerName = "test_checkpoint_individual_ack"; + ManagedLedger ledger = factory.open(ledgerName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + List positions = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + positions.add(ledger.addEntry(("msg-" + i).getBytes(Encoding))); + } + + cursor.markDelete(positions.get(0)); + cursor.delete(positions.get(2)); + cursor.delete(positions.get(5)); + + assertThat(cursor.isMessageDeleted(positions.get(2))).isTrue(); + assertThat(cursor.isMessageDeleted(positions.get(5))).isTrue(); + assertThat(cursor.isMessageDeleted(positions.get(3))).isFalse(); + + ManagedCursorImpl cursorBeforeClose = cursor; + Awaitility.await().untilAsserted( + () -> assertThat(cursorBeforeClose.getCursorLedger()).isNotNegative()); + ledger.close(); + + ledger = factory.open(ledgerName, config); + cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + assertThat(cursor.getMarkDeletedPosition()).isEqualTo(positions.get(0)); + assertThat(cursor.isMessageDeleted(positions.get(2))).isTrue(); + assertThat(cursor.isMessageDeleted(positions.get(5))).isTrue(); + assertThat(cursor.isMessageDeleted(positions.get(3))).isFalse(); + ledger.close(); + } + + @Test(timeOut = 30000) + public void testCheckpointMultiMsgLedgerRecovery() throws Exception { + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setPersistentUnackedRangesWithPerLedgerEntryEnabled(true); + config.setMaxEntriesPerLedger(5); // small msg-ledger → multiple msgLedgers + config.setMaxUnackedRangesToPersistInMetadataStore(-1); + config.setThrottleMarkDelete(0); + + String ledgerName = "test_checkpoint_multi_ledger"; + ManagedLedger ledger = factory.open(ledgerName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + List positions = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + positions.add(ledger.addEntry(("msg-" + i).getBytes(Encoding))); + } + + cursor.markDelete(positions.get(0)); + cursor.delete(positions.get(3)); + cursor.delete(positions.get(7)); + cursor.delete(positions.get(12)); + cursor.delete(positions.get(18)); + + ManagedCursorImpl cursorBeforeClose2 = cursor; + Awaitility.await().untilAsserted( + () -> assertThat(cursorBeforeClose2.getCursorLedger()).isNotNegative()); + ledger.close(); + + ledger = factory.open(ledgerName, config); + cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + assertThat(cursor.getMarkDeletedPosition()).isEqualTo(positions.get(0)); + assertThat(cursor.isMessageDeleted(positions.get(3))).isTrue(); + assertThat(cursor.isMessageDeleted(positions.get(7))).isTrue(); + assertThat(cursor.isMessageDeleted(positions.get(12))).isTrue(); + assertThat(cursor.isMessageDeleted(positions.get(18))).isTrue(); + assertThat(cursor.isMessageDeleted(positions.get(5))).isFalse(); + ledger.close(); + } + + @Test(timeOut = 30000) + public void testCheckpointCursorLedgerRollover() throws Exception { + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setPersistentUnackedRangesWithPerLedgerEntryEnabled(true); + config.setMaxEntriesPerLedger(5); + config.setMetadataMaxEntriesPerLedger(5); // force frequent cursor-ledger rollover + config.setMaxUnackedRangesToPersistInMetadataStore(-1); + config.setThrottleMarkDelete(0); + + String ledgerName = "test_checkpoint_rollover"; + ManagedLedger ledger = factory.open(ledgerName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + + List positions = new ArrayList<>(); + for (int i = 0; i < 30; i++) { + positions.add(ledger.addEntry(("msg-" + i).getBytes(Encoding))); + cursor.markDelete(positions.get(i)); + } + cursor.delete(positions.get(5)); + cursor.delete(positions.get(15)); + cursor.delete(positions.get(25)); + + ManagedCursorImpl cursorBeforeClose3 = cursor; + Awaitility.await().untilAsserted( + () -> assertThat(cursorBeforeClose3.getCursorLedger()).isNotNegative()); + ledger.close(); + + ledger = factory.open(ledgerName, config); + cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + assertThat(cursor.isMessageDeleted(positions.get(5))).isTrue(); + assertThat(cursor.isMessageDeleted(positions.get(15))).isTrue(); + assertThat(cursor.isMessageDeleted(positions.get(25))).isTrue(); + ledger.close(); + } + + @Test(timeOut = 60000) + public void testCheckpointImmediateCloseFlushesAllAppliedIndividualAcks() throws Exception { + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setPersistentUnackedRangesWithPerLedgerEntryEnabled(true); + config.setMaxUnackedRangesToPersistInMetadataStore(-1); + config.setThrottleMarkDelete(0); + + String ledgerName = "test_checkpoint_immediate_close"; + ManagedLedger ledger = factory.open(ledgerName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + + List positions = new ArrayList<>(); + for (int i = 0; i < 1000; i++) { + positions.add(ledger.addEntry(("msg-" + i).getBytes(Encoding))); + } + cursor.markDelete(positions.get(0)); + // Individual-delete even positions without waiting for any periodic flush. + for (int i = 2; i < 1000; i += 2) { + cursor.delete(positions.get(i)); + } + + // Close immediately: no Awaitility wait for the periodic flush. All acks already + // applied to individualDeletedMessages must be captured by the closing persist. + ledger.close(); + + ledger = factory.open(ledgerName, config); + cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + assertThat(cursor.getMarkDeletedPosition()).isEqualTo(positions.get(0)); + int lost = 0; + for (int i = 2; i < 1000; i += 2) { + if (!cursor.isMessageDeleted(positions.get(i))) { + lost++; + } + } + assertThat(lost).isZero(); + for (int i = 1; i < 1000; i += 2) { + assertThat(cursor.isMessageDeleted(positions.get(i))).isFalse(); + } + ledger.close(); + } + + @Test(timeOut = 60000) + public void testCheckpointChunkedPersistAndRecoverLargeAckSet() throws Exception { + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setPersistentUnackedRangesWithPerLedgerEntryEnabled(true); + // Force the checkpoint log to chunk entries: a single per-ledger bitmap of + // 5k alternating positions (~10KB Roaring) far exceeds this cap. + config.setPersistentUnackedRangesMaxEntrySize(2048); + config.setMaxUnackedRangesToPersistInMetadataStore(-1); + config.setThrottleMarkDelete(0); + + String ledgerName = "test_checkpoint_chunked_large_ack_set"; + ManagedLedger ledger = factory.open(ledgerName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + + final int total = 5_000; + List positions = new ArrayList<>(total); + for (int i = 0; i < total; i++) { + positions.add(ledger.addEntry(("msg-" + i).getBytes(Encoding))); + } + cursor.markDelete(positions.get(0)); + for (int i = 2; i < total; i += 2) { + cursor.delete(positions.get(i)); + } + + ledger.close(); + + ledger = factory.open(ledgerName, config); + cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + assertThat(cursor.getMarkDeletedPosition()).isEqualTo(positions.get(0)); + int lost = 0; + for (int i = 2; i < total; i += 2) { + if (!cursor.isMessageDeleted(positions.get(i))) { + lost++; + } + } + assertThat(lost).isZero(); + assertThat(cursor.isMessageDeleted(positions.get(1))).isFalse(); + ledger.close(); + } + + @Test(timeOut = 60000) + public void testCheckpointGcAfterMarkDeleteAbsorbsAllAcks() throws Exception { + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setPersistentUnackedRangesWithPerLedgerEntryEnabled(true); + config.setMaxEntriesPerLedger(2); + config.setMetadataMaxEntriesPerLedger(2); // frequent rollovers + config.setMaxUnackedRangesToPersistInMetadataStore(-1); + config.setThrottleMarkDelete(0); + + String ledgerName = "test_checkpoint_gc"; + ManagedLedger ledger = factory.open(ledgerName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + + List positions = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + positions.add(ledger.addEntry(("m-" + i).getBytes(Encoding))); + } + cursor.markDelete(positions.get(0)); + cursor.delete(positions.get(1)); + cursor.markDelete(positions.get(2)); + cursor.delete(positions.get(3)); + + Set seenCursorLedgers = new HashSet<>(); + seenCursorLedgers.add(cursor.getCursorLedger()); + for (int i = 0; i < 20; i++) { + Position p = ledger.addEntry(("extra-" + i).getBytes(Encoding)); + cursor.markDelete(p); + seenCursorLedgers.add(cursor.getCursorLedger()); + } + assertThat(seenCursorLedgers.size()).isGreaterThan(1); + + // At least one old cursor ledger should be GC'd (maxHold absorbed). Invoke the GC + // explicitly instead of relying on the incidental rollover timing: this keeps the + // test deterministic regardless of executor scheduling in shared-JVM test runs. + cursor.gcOldCursorLedgers(); + Set seenSnapshot = Collections.unmodifiableSet(new HashSet<>(seenCursorLedgers)); + ManagedCursorImpl cursorRef = cursor; + Awaitility.await().untilAsserted(() -> { + Set live = bkc.getLedgers(); + long currentId = cursorRef.getCursorLedger(); + boolean anyReclaimed = seenSnapshot.stream() + .anyMatch(id -> id != currentId && !live.contains(id)); + assertThat(anyReclaimed).isTrue(); + }); + ledger.close(); + } + + @Test(timeOut = 60000) + public void testCheckpointGcRetriesAfterLedgerDeleteFailure() throws Exception { + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setPersistentUnackedRangesWithPerLedgerEntryEnabled(true); + config.setMaxEntriesPerLedger(2); + config.setMetadataMaxEntriesPerLedger(2); // frequent rollovers -> frequent GC attempts + config.setMaxUnackedRangesToPersistInMetadataStore(-1); + config.setThrottleMarkDelete(0); + + String ledgerName = "test_checkpoint_gc_retry_on_delete_failure"; + ManagedLedger ledger = factory.open(ledgerName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + + List positions = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + positions.add(ledger.addEntry(("m-" + i).getBytes(Encoding))); + } + cursor.markDelete(positions.get(0)); + cursor.markDelete(positions.get(3)); // everything absorbed; all refs stale + + // Fail BK ledger deletes until the flag is cleared, via the mock's dedicated hook + // (ledger deletes do not go through the metadata store in this mock). + AtomicBoolean failLedgerDeletes = new AtomicBoolean(true); + ((org.apache.bookkeeper.client.PulsarMockBookKeeper) bkc).setDeleteLedgerFailure(failLedgerDeletes::get); + + Set seenBeforeFailure = new HashSet<>(); + for (int i = 0; i < 6; i++) { + Position p = ledger.addEntry(("failing-" + i).getBytes(Encoding)); + cursor.markDelete(p); + seenBeforeFailure.add(cursor.getCursorLedger()); + } + Set failingSnapshot = Collections.unmodifiableSet(new HashSet<>(seenBeforeFailure)); + ManagedCursorImpl cursorDuringFailure = cursor; + + // While deletes fail, old cursor ledgers must survive and GC must not lose its + // bookkeeping (they stay tracked for retry on the next rollover). + Awaitility.await().untilAsserted(() -> { + Set live = bkc.getLedgers(); + long currentId = cursorDuringFailure.getCursorLedger(); + boolean anyStillLive = failingSnapshot.stream() + .anyMatch(id -> id != currentId && live.contains(id)); + assertThat(anyStillLive).isTrue(); + }); + + // Lift the fault; the next rollovers retry the deletes and reclaim the ledgers. + failLedgerDeletes.set(false); + Set seenAfterRecovery = new HashSet<>(); + for (int i = 0; i < 8; i++) { + Position p = ledger.addEntry(("recovered-" + i).getBytes(Encoding)); + cursor.markDelete(p); + seenAfterRecovery.add(cursor.getCursorLedger()); + } + Set allSeen = new HashSet<>(failingSnapshot); + allSeen.addAll(seenAfterRecovery); + ManagedCursorImpl cursorAfterRecovery = cursor; + Awaitility.await().untilAsserted(() -> { + Set live = bkc.getLedgers(); + long currentId = cursorAfterRecovery.getCursorLedger(); + boolean anyReclaimed = allSeen.stream() + .anyMatch(id -> id != currentId && !live.contains(id)); + assertThat(anyReclaimed).isTrue(); + }); + ledger.close(); + } + + @Test(timeOut = 30000) + public void testCheckpointBatchAckPersistAndRecover() throws Exception { + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setPersistentUnackedRangesWithPerLedgerEntryEnabled(true); + config.setMaxUnackedRangesToPersistInMetadataStore(-1); + config.setMaxBatchDeletedIndexToPersist(1000); + config.setDeletionAtBatchIndexLevelEnabled(true); + config.setThrottleMarkDelete(0); + + String ledgerName = "test_checkpoint_batch_ack"; + ManagedLedger ledger = factory.open(ledgerName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + List positions = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + positions.add(ledger.addEntry(("entry-" + i).getBytes(Encoding))); + } + + cursor.markDelete(positions.get(0)); + // Simulate batch ack: add to batchDeletedIndexes directly + Position batchPos = positions.get(3); + BitSet bitSet = new BitSet(32); + bitSet.set(2, 5); // ack batch indices 2,3,4 + cursor.batchDeletedIndexes.put(batchPos, bitSet); + + assertThat(cursor.getDeletedBatchIndexesAsLongArray(batchPos)).isNotEmpty(); + + cursor.markDelete(positions.get(1)); + + ManagedCursorImpl cursorBeforeClose4 = cursor; + Awaitility.await().untilAsserted( + () -> assertThat(cursorBeforeClose4.getCursorLedger()).isNotNegative()); + ledger.close(); + + ledger = factory.open(ledgerName, config); + cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + assertThat(cursor.getDeletedBatchIndexesAsLongArray(positions.get(3))).isNotEmpty(); + ledger.close(); + } + + @Test(timeOut = 30000) + public void testCheckpointLegacyFallback() throws Exception { + // When feature is off, legacy PositionInfo path is used. When the feature is then + // turned on and we reopen, the cursor should detect the legacy format and recover + // from PositionInfo. + String ledgerName = "test_checkpoint_legacy_fallback"; + + ManagedLedgerConfig legacyConfig = new ManagedLedgerConfig(); + legacyConfig.setMaxUnackedRangesToPersistInMetadataStore(-1); + legacyConfig.setThrottleMarkDelete(0); + ManagedLedger ledger = factory.open(ledgerName, legacyConfig); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + List positions = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + positions.add(ledger.addEntry(("msg-" + i).getBytes(Encoding))); + } + cursor.putCursorProperty("cp-key", "cp-value").get(); + cursor.markDelete(positions.get(0), Collections.singletonMap("md-key", 1L)); + cursor.delete(positions.get(2)); + ledger.close(); + + ManagedLedgerConfig newConfig = new ManagedLedgerConfig(); + newConfig.setPersistentUnackedRangesWithPerLedgerEntryEnabled(true); + newConfig.setMaxUnackedRangesToPersistInMetadataStore(-1); + newConfig.setThrottleMarkDelete(0); + ledger = factory.open(ledgerName, newConfig); + cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + assertThat(cursor.getMarkDeletedPosition()).isEqualTo(positions.get(0)); + assertThat(cursor.isMessageDeleted(positions.get(2))).isTrue(); + assertThat(cursor.isMessageDeleted(positions.get(1))).isFalse(); + assertThat(cursor.getProperties()).containsEntry("md-key", 1L); + assertThat(cursor.getCursorProperties()).containsEntry("cp-key", "cp-value"); + ledger.close(); + } + + @Test(timeOut = 30000) + public void testCheckpointForwardCompatibilityNewToLegacyConfig() throws Exception { + // Start with per-msgLedger persistence enabled, persist checkpoint/ref state, then reopen with legacy config. + // Recovery should still read new-format cursor entries and rebuild cursor state. + String ledgerName = "test_checkpoint_new_to_legacy_config"; + + ManagedLedgerConfig newConfig = new ManagedLedgerConfig(); + newConfig.setPersistentUnackedRangesWithPerLedgerEntryEnabled(true); + newConfig.setMaxEntriesPerLedger(3); + newConfig.setMetadataMaxEntriesPerLedger(3); + newConfig.setMaxUnackedRangesToPersistInMetadataStore(-1); + newConfig.setThrottleMarkDelete(0); + ManagedLedger ledger = factory.open(ledgerName, newConfig); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + + List positions = new ArrayList<>(); + for (int i = 0; i < 12; i++) { + positions.add(ledger.addEntry(("msg-" + i).getBytes(Encoding))); + } + cursor.putCursorProperty("cp-new", "cp-new-value").get(); + cursor.markDelete(positions.get(0)); + cursor.delete(positions.get(2)); + cursor.delete(positions.get(5)); + cursor.delete(positions.get(8)); + cursor.markDelete(positions.get(6), Collections.singletonMap("md-new", 6L)); + Thread.sleep(500); + ledger.close(); + + ManagedLedgerConfig legacyConfig = new ManagedLedgerConfig(); + legacyConfig.setMaxUnackedRangesToPersistInMetadataStore(-1); + legacyConfig.setThrottleMarkDelete(0); + ledger = factory.open(ledgerName, legacyConfig); + cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + assertThat(cursor.getMarkDeletedPosition()).isEqualTo(positions.get(6)); + assertThat(cursor.isMessageDeleted(positions.get(8))).isTrue(); + assertThat(cursor.isMessageDeleted(positions.get(7))).isFalse(); + assertThat(cursor.getProperties()).containsEntry("md-new", 6L); + assertThat(cursor.getCursorProperties()).containsEntry("cp-new", "cp-new-value"); + ledger.close(); + } + + @Test(timeOut = 30000) + public void testCheckpointCrossLedgerAckRef() throws Exception { + // Verifies that an AckStateRef pointing to an OLD cursor ledger (before rollover) + // is correctly fetched and applied during recovery. + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setPersistentUnackedRangesWithPerLedgerEntryEnabled(true); + config.setMaxEntriesPerLedger(3); // small msg-ledger → multiple msgLedgers + config.setMetadataMaxEntriesPerLedger(3); // force cursor-ledger rollover + config.setMaxUnackedRangesToPersistInMetadataStore(-1); + config.setThrottleMarkDelete(0); + + String ledgerName = "test_cross_ledger_ref"; + ManagedLedger ledger = factory.open(ledgerName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + + List positions = new ArrayList<>(); + for (int i = 0; i < 15; i++) { + positions.add(ledger.addEntry(("m-" + i).getBytes(Encoding))); + } + cursor.markDelete(positions.get(0)); + for (int i : new int[]{2, 5, 8, 11, 14}) { + cursor.delete(positions.get(i)); + } + // Advance mark-delete past the first msg-ledger to trigger rollovers + cursor.markDelete(positions.get(7)); + cursor.markDelete(positions.get(12)); + + Thread.sleep(500); // wait for async persists + rollovers + ledger.close(); + + // Reopen — recovery must fetch cross-ledger refs + ledger = factory.open(ledgerName, config); + cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + // All individual acks should be recovered, even those persisted in old cursor ledgers + for (int i : new int[]{2, 5, 8, 11, 14}) { + assertThat(cursor.isMessageDeleted(positions.get(i))).isTrue(); + } + ledger.close(); + } + + @Test(timeOut = 30000) + public void testCheckpointChunkedRecovery() throws Exception { + // Forces a single checkpoint to exceed maxEntrySize → chunked write + assembly. + // We can't easily produce a >5MB checkpoint with MockedBookKeeper, so we lower + // maxEntrySize to force chunking. + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setPersistentUnackedRangesWithPerLedgerEntryEnabled(true); + config.setPersistentUnackedRangesMaxEntrySize(1024); // very small → forces chunking + config.setMaxUnackedRangesToPersistInMetadataStore(-1); + config.setThrottleMarkDelete(0); + + String ledgerName = "test_chunked_recovery"; + ManagedLedger ledger = factory.open(ledgerName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + + List positions = new ArrayList<>(); + for (int i = 0; i < 200; i++) { + positions.add(ledger.addEntry(("m-" + i).getBytes(Encoding))); + } + cursor.markDelete(positions.get(0)); + // Many individual acks → large primary bitmap → checkpoint > 512 bytes → chunked + for (int i = 1; i < 200; i += 3) { + cursor.delete(positions.get(i)); + } + Thread.sleep(500); + ledger.close(); + + // Recovery must assemble chunks → parse checkpoint → apply state. + // Mark-delete position may have advanced beyond positions[0] due to individual + // ack side-effects, so we only assert on the recovered ack state. + ledger = factory.open(ledgerName, config); + cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + for (int i = 1; i < 200; i += 3) { + assertThat(cursor.isMessageDeleted(positions.get(i))).isTrue(); + } + ledger.close(); + } + + /** + * Regression: verifies that same-ledger individual acks (the most common case where + * the ack and mark-delete are in the same msgLedger) are correctly persisted and + * recovered. Before the markDirty fix, same-ledger acks were silently skipped by + * dirty tracking, resulting in empty checkpoints and data loss on recovery. + */ + @Test(timeOut = 30000) + public void testCheckpointSameLedgerAckSurvivesRecovery() throws Exception { + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setPersistentUnackedRangesWithPerLedgerEntryEnabled(true); + config.setMaxUnackedRangesToPersistInMetadataStore(-1); + config.setThrottleMarkDelete(0); + + String ledgerName = "test_same_ledger_ack_recovery"; + ManagedLedger ledger = factory.open(ledgerName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + + // All entries in one msgLedger. md at entry 0, individual acks at entries 3, 7, 12. + List positions = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + positions.add(ledger.addEntry(("m-" + i).getBytes(Encoding))); + } + cursor.markDelete(positions.get(0)); + cursor.delete(positions.get(3)); + cursor.delete(positions.get(7)); + cursor.delete(positions.get(12)); + + Thread.sleep(500); + ledger.close(); + + ledger = factory.open(ledgerName, config); + cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + assertThat(cursor.isMessageDeleted(positions.get(3))).isTrue(); + assertThat(cursor.isMessageDeleted(positions.get(7))).isTrue(); + assertThat(cursor.isMessageDeleted(positions.get(12))).isTrue(); + assertThat(cursor.isMessageDeleted(positions.get(1))).isFalse(); + ledger.close(); + } + + /** + * Verifies that recovery fails fast (not silent fallback) when a checkpoint + * references a ledger that can't be read. The cursor should rewind to ZK + * snapshot rather than silently dropping ack state. + */ + @Test(timeOut = 30000) + public void testCheckpointRefFetchFailureFailsRecovery() throws Exception { + TestPulsarMockBookKeeper bk = new TestPulsarMockBookKeeper(executor); + factory.shutdown(); + factory = new ManagedLedgerFactoryImpl(metadataStore, bk); + + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setPersistentUnackedRangesWithPerLedgerEntryEnabled(true); + config.setMaxEntriesPerLedger(3); + config.setMetadataMaxEntriesPerLedger(1); + config.setMaxUnackedRangesToPersistInMetadataStore(-1); + config.setThrottleMarkDelete(0); + + String ledgerName = "test_ref_fetch_fail"; + ManagedLedger ledger = factory.open(ledgerName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + + List positions = new ArrayList<>(); + for (int i = 0; i < 9; i++) { + positions.add(ledger.addEntry(("m-" + i).getBytes(Encoding))); + } + + cursor.markDelete(positions.get(0)); + cursor.delete(positions.get(2)); + cursor.delete(positions.get(5)); + Thread.sleep(500); + long refLedgerToBreak = cursor.getCursorLedger(); + cursor.markDelete(positions.get(3)); + Thread.sleep(500); + ledger.close(); + + bk.setErrorCodeMap(refLedgerToBreak, BKException.Code.BookieHandleNotAvailableException); + + ManagedLedgerFactoryImpl recoveryFactory = new ManagedLedgerFactoryImpl(metadataStore, bk); + ledger = recoveryFactory.open(ledgerName, config); + cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + assertThat(cursor.getMarkDeletedPosition()).isEqualTo(positions.get(3)); + assertThat(cursor.isMessageDeleted(positions.get(2))).isTrue(); + ledger.close(); + recoveryFactory.shutdown(); + } + + /** + * Verifies that if a checkpoint flush fails, the cursor keeps the previous + * successfully persisted state and the failed update is not recovered. + */ + @Test(timeOut = 30000) + public void testCheckpointFlushFailurePreservesPreviousState() throws Exception { + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setPersistentUnackedRangesWithPerLedgerEntryEnabled(true); + config.setMaxEntriesPerLedger(3); + config.setMaxUnackedRangesToPersistInMetadataStore(-1); + config.setThrottleMarkDelete(0); + + String ledgerName = "test_checkpoint_flush_fail"; + ManagedLedger ledger = factory.open(ledgerName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + + List positions = new ArrayList<>(); + for (int i = 0; i < 12; i++) { + positions.add(ledger.addEntry(("f-" + i).getBytes(Encoding))); + } + + cursor.markDelete(positions.get(0)); + cursor.delete(positions.get(2)); + cursor.delete(positions.get(5)); + final ManagedCursorImpl persistedCursor = cursor; + Awaitility.await().untilAsserted( + () -> assertThat(persistedCursor.getStats().getPersistLedgerSucceed()).isGreaterThan(0)); + + bkc.addEntryFailAfter(0, BKException.Code.NoBookieAvailableException); + bkc.addEntryFailAfter(1, BKException.Code.NoBookieAvailableException); + cursor.delete(positions.get(8)); + final ManagedCursorImpl failedCursor = cursor; + Awaitility.await().untilAsserted( + () -> assertThat(failedCursor.getStats().getPersistLedgerErrors()).isGreaterThan(0)); + + ledger.close(); + + ManagedLedgerFactoryImpl recoveryFactory = new ManagedLedgerFactoryImpl(metadataStore, bkc); + ManagedLedger recoveredLedger = recoveryFactory.open(ledgerName, config); + ManagedCursorImpl recoveredCursor = (ManagedCursorImpl) recoveredLedger.openCursor("c1"); + assertThat(recoveredCursor.isMessageDeleted(positions.get(2))).isTrue(); + assertThat(recoveredCursor.isMessageDeleted(positions.get(5))).isTrue(); + recoveredLedger.close(); + recoveryFactory.shutdown(); + } + + /** + * Regression: after recovery the per-ledger {@code lastCheckpointPos} bookkeeping must be rebuilt + * from the recovered checkpoint. Without it, the first persist after a restart with multiple + * active msg ledgers throws "Missing lastCheckpointPos" and silently falls back to the metadata + * store, defeating per-msgLedger persistence. + */ + @Test(timeOut = 30000) + public void testCheckpointRecoveryRebuildsLastAppendedPos() throws Exception { + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setPersistentUnackedRangesWithPerLedgerEntryEnabled(true); + config.setMaxEntriesPerLedger(5); + config.setMaxUnackedRangesToPersistInMetadataStore(-1); + config.setThrottleMarkDelete(0); + + String ledgerName = "test_checkpoint_rebuild_last_appended_pos"; + ManagedLedger ledger = factory.open(ledgerName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + List positions = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + positions.add(ledger.addEntry(("msg-" + i).getBytes(Encoding))); + } + cursor.markDelete(positions.get(0)); + cursor.delete(positions.get(3)); + cursor.delete(positions.get(7)); + cursor.delete(positions.get(12)); + cursor.delete(positions.get(18)); + Thread.sleep(500); + ledger.close(); + + // Reopen, then keep using the cursor — the first post-recovery persist must succeed via the + // cursor ledger instead of silently degrading to the metadata store. + ledger = factory.open(ledgerName, config); + cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + assertThat(cursor.isMessageDeleted(positions.get(3))).isTrue(); + assertThat(cursor.isMessageDeleted(positions.get(12))).isTrue(); + + cursor.delete(positions.get(5)); + cursor.markDelete(positions.get(10)); + ManagedCursorImpl cursorRef = cursor; + Awaitility.await().untilAsserted( + () -> assertThat(cursorRef.getStats().getPersistLedgerSucceed()).isGreaterThan(0)); + ledger.close(); + } + + /** + * Regression: an AckStateRef points at the commit entry of a checkpoint; for a chunked + * checkpoint that is the last chunk part. Ref recovery must assemble the chunk before + * extracting the ack state, otherwise a cursor with many active msg ledgers and a small + * maxEntrySize fails recovery and rewinds to the ZK snapshot, losing individual acks. + */ + @Test(timeOut = 60000) + public void testCheckpointChunkedRefRecovery() throws Exception { + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setPersistentUnackedRangesWithPerLedgerEntryEnabled(true); + config.setPersistentUnackedRangesMaxEntrySize(1024); // small → per-ledger checkpoints chunk + config.setMaxEntriesPerLedger(3); // many msg ledgers → many refs per checkpoint + config.setMaxUnackedRangesToPersistInMetadataStore(-1); + config.setThrottleMarkDelete(0); + + String ledgerName = "test_checkpoint_chunked_ref_recovery"; + ManagedLedger ledger = factory.open(ledgerName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + List positions = new ArrayList<>(); + for (int i = 0; i < 150; i++) { + positions.add(ledger.addEntry(("m-" + i).getBytes(Encoding))); + } + cursor.markDelete(positions.get(0)); + // One ack in nearly every msg ledger → each per-ledger checkpoint carries ~50 refs, + // pushing it over maxEntrySize and forcing chunked writes. + for (int i = 1; i < 150; i += 3) { + cursor.delete(positions.get(i)); + } + Thread.sleep(1000); + ledger.close(); + + ledger = factory.open(ledgerName, config); + cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + for (int i = 1; i < 150; i += 3) { + assertThat(cursor.isMessageDeleted(positions.get(i))) + .as("chunked ref recovery should preserve ack at " + i) + .isTrue(); + } + ledger.close(); + } + + /** + * Regression: when a flush fails, only the ledgers whose checkpoints were not appended are + * re-marked dirty; the next flush must retry them — including a ledger that has never been + * persisted before. Without this, a first-time dirty ledger whose append failed would have no + * lastCheckpointPos and no dirty flag, wedging every subsequent persist into "Missing + * lastCheckpointPos" and permanently degrading to the metadata store. + */ + @Test(timeOut = 30000) + public void testCheckpointFailedFlushRetriesMissingLedgers() throws Exception { + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setPersistentUnackedRangesWithPerLedgerEntryEnabled(true); + config.setMaxEntriesPerLedger(3); + config.setMaxUnackedRangesToPersistInMetadataStore(-1); + config.setThrottleMarkDelete(0); + + String ledgerName = "test_checkpoint_retry_failed_ledgers"; + ManagedLedger ledger = factory.open(ledgerName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + List positions = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + positions.add(ledger.addEntry(("m-" + i).getBytes(Encoding))); + } + + // Establish persisted positions for the first two msg ledgers (2 successful flushes). + cursor.markDelete(positions.get(0)); + cursor.delete(positions.get(5)); // gap from mark-delete → no implicit flush + cursor.markDelete(positions.get(1)); + ManagedCursorImpl persistedCursor = cursor; + Awaitility.await().untilAsserted( + () -> assertThat(persistedCursor.getStats().getPersistLedgerSucceed()).isGreaterThanOrEqualTo(2)); + + // Fail the next flush entirely: a first-time dirty ledger (5) plus the mark-delete ledger. + bkc.addEntryFailAfter(0, BKException.Code.NoBookieAvailableException); + bkc.addEntryFailAfter(1, BKException.Code.NoBookieAvailableException); + cursor.delete(positions.get(16)); // msg ledger 5, never persisted before + cursor.markDelete(positions.get(3)); + ManagedCursorImpl failedCursor = cursor; + Awaitility.await().untilAsserted( + () -> assertThat(failedCursor.getStats().getPersistLedgerErrors()).isGreaterThan(0)); + + // The next flush (mark-delete in a different ledger) must retry msg ledger 5. + cursor.markDelete(positions.get(9)); + ManagedCursorImpl retriedCursor = cursor; + Awaitility.await().untilAsserted( + () -> assertThat(retriedCursor.getStats().getPersistLedgerSucceed()).isGreaterThan(2)); + ledger.close(); + + // The retried ack must survive recovery. + ledger = factory.open(ledgerName, config); + cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + assertThat(cursor.isMessageDeleted(positions.get(16))).isTrue(); + assertThat(cursor.isMessageDeleted(positions.get(14))).isFalse(); + ledger.close(); + } + + /** + * Regression: the first flush must succeed even when the mark-delete ledger itself holds + * individual acks that have never been persisted (e.g. the first acks arrived non-contiguously + * and did not trigger an implicit mark-delete). The mark-delete ledger's checkpoint is written + * first so other checkpoints in the same flush can reference it. + */ + @Test(timeOut = 30000) + public void testCheckpointFirstFlushWithMdLedgerAcks() throws Exception { + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setPersistentUnackedRangesWithPerLedgerEntryEnabled(true); + config.setMaxEntriesPerLedger(3); + config.setMaxUnackedRangesToPersistInMetadataStore(-1); + config.setThrottleMarkDelete(0); + + String ledgerName = "test_checkpoint_first_flush_md_ledger_acks"; + ManagedLedger ledger = factory.open(ledgerName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + List positions = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + positions.add(ledger.addEntry(("m-" + i).getBytes(Encoding))); + } + + // Non-contiguous acks: one in the mark-delete ledger (0), one in a later ledger (1). + // Neither triggers an implicit mark-delete, so this is the first ledger persist. + cursor.delete(positions.get(2)); + cursor.delete(positions.get(5)); + cursor.markDelete(positions.get(1)); + + ManagedCursorImpl flushedCursor = cursor; + Awaitility.await().untilAsserted( + () -> assertThat(flushedCursor.getStats().getPersistLedgerSucceed()).isGreaterThan(0)); + ledger.close(); + + ledger = factory.open(ledgerName, config); + cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + assertThat(cursor.isMessageDeleted(positions.get(2))).isTrue(); + assertThat(cursor.isMessageDeleted(positions.get(5))).isTrue(); + assertThat(cursor.isMessageDeleted(positions.get(3))).isFalse(); + ledger.close(); + } + + /** + * Regression: a batch-index ack in a msg ledger that has no individual acks must still be + * persisted and survive recovery. Batch acks are tracked in {@code batchDeletedIndexes} only; + * without dirty-marking and ref enumeration for those ledgers, the batch ack is written once + * and then becomes unreachable from later checkpoints, so it is lost across restart. + */ + @Test(timeOut = 30000) + public void testCheckpointBatchAckInNonMdLedgerSurvivesRecovery() throws Exception { + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setPersistentUnackedRangesWithPerLedgerEntryEnabled(true); + config.setMaxEntriesPerLedger(3); + config.setMaxUnackedRangesToPersistInMetadataStore(-1); + config.setMaxBatchDeletedIndexToPersist(1000); + config.setDeletionAtBatchIndexLevelEnabled(true); + config.setThrottleMarkDelete(0); + + String ledgerName = "test_checkpoint_batch_ack_non_md_ledger"; + ManagedLedger ledger = factory.open(ledgerName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + List positions = new ArrayList<>(); + for (int i = 0; i < 9; i++) { + positions.add(ledger.addEntry(("entry-" + i).getBytes(Encoding))); + } + cursor.markDelete(positions.get(1)); // md ledger 0 + + // Batch ack in msg ledger 2 (entry 7): no individual ack range, so dirty-marking must + // come from the batch-index path. + Position batchPos = positions.get(7); + BitSet bitSet = new BitSet(32); + bitSet.set(2, 5); + cursor.batchDeletedIndexes.put(batchPos, bitSet); + + cursor.markDelete(positions.get(4)); // flush A: writes ledger 2 (dirty) + md ledger 1 + cursor.markDelete(positions.get(5)); // flush B: md ledger 1 must ref ledger 2 + ManagedCursorImpl flushedCursor = cursor; + Awaitility.await().untilAsserted( + () -> assertThat(flushedCursor.getStats().getPersistLedgerSucceed()).isGreaterThanOrEqualTo(2)); + ledger.close(); + + ledger = factory.open(ledgerName, config); + cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + assertThat(cursor.getDeletedBatchIndexesAsLongArray(positions.get(7))).isNotEmpty(); + ledger.close(); + } + + /** + * Regression: old cursor ledgers that are only referenced via AckStateRefs (cross-ledger refs + * from before a restart) must be tracked after recovery and reclaimed by GC once mark-delete + * passes the last msg ledger holding acks. Without recovery-time tracking, those ledgers leak + * forever across restarts. + */ + @Test(timeOut = 60000) + public void testCheckpointGcReclaimsRefLedgersAcrossRestart() throws Exception { + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setPersistentUnackedRangesWithPerLedgerEntryEnabled(true); + config.setMaxEntriesPerLedger(3); + config.setMetadataMaxEntriesPerLedger(6); // exactly one rollover in phase 1 + config.setMaxUnackedRangesToPersistInMetadataStore(-1); + config.setThrottleMarkDelete(0); + + String ledgerName = "test_checkpoint_gc_across_restart"; + ManagedLedger ledger = factory.open(ledgerName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + List positions = new ArrayList<>(); + for (int i = 0; i < 15; i++) { + positions.add(ledger.addEntry(("m-" + i).getBytes(Encoding))); + } + cursor.markDelete(positions.get(0)); + long firstCursorLedger = cursor.getCursorLedger(); + for (int i : new int[]{5, 8, 11, 14}) { // gaps → no implicit mark-delete + cursor.delete(positions.get(i)); + } + cursor.markDelete(positions.get(1)); // flush: 5 checkpoints in the first cursor ledger + // The next mark-delete pushes LAC to 6 and rolls the cursor ledger over; it writes + // the first checkpoint into the new ledger, with refs pointing at the old one. + cursor.markDelete(positions.get(7)); + long secondCursorLedger = cursor.getCursorLedger(); + assertThat(secondCursorLedger).isNotEqualTo(firstCursorLedger); + ManagedCursorImpl cursorBeforeClose = cursor; + Awaitility.await().untilAsserted( + () -> assertThat(cursorBeforeClose.getStats().getPersistLedgerSucceed()).isGreaterThanOrEqualTo(3)); + ledger.close(); + + // Restart: refs into the pre-restart cursor ledger must be tracked for GC. + ledger = factory.open(ledgerName, config); + cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + assertThat(cursor.isMessageDeleted(positions.get(11))).isTrue(); + assertThat(cursor.isMessageDeleted(positions.get(14))).isTrue(); + + // Absorb all remaining acks, then force rollovers so GC runs. + for (int i = 15; i < 35; i++) { + positions.add(ledger.addEntry(("m-" + i).getBytes(Encoding))); + } + cursor.markDelete(positions.get(19)); // first persist creates a fresh cursor ledger + cursor.markDelete(positions.get(23)); // absorbs remaining acks + cursor.markDelete(positions.get(25)); + cursor.markDelete(positions.get(27)); + cursor.markDelete(positions.get(29)); + cursor.markDelete(positions.get(31)); // forces the next rollover → GC + + Set expectedReclaimed = new HashSet<>(); + expectedReclaimed.add(firstCursorLedger); + expectedReclaimed.add(secondCursorLedger); + Awaitility.await().untilAsserted(() -> { + Set live = bkc.getLedgers(); + assertThat(live).doesNotContainAnyElementsOf(expectedReclaimed); + }); + ledger.close(); + } + + /** + * Unlike the legacy single-PositionInfo path (which truncates batch indexes at + * {@code maxBatchDeletedIndexToPersist}), the per-ledger path does NOT truncate: each + * checkpoint is bounded by chunking, so all batch indexes survive recovery. + */ + @Test(timeOut = 30000) + public void testCheckpointBatchAckNoTruncationInPerLedgerMode() throws Exception { + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setPersistentUnackedRangesWithPerLedgerEntryEnabled(true); + config.setMaxEntriesPerLedger(5); + config.setMaxBatchDeletedIndexToPersist(3); + config.setDeletionAtBatchIndexLevelEnabled(true); + config.setMaxUnackedRangesToPersistInMetadataStore(-1); + config.setThrottleMarkDelete(0); + + // Topic-format name: the legacy close-time meta-store snapshot still applies batch-index + // truncation, whose OTEL metric builds attributes via TopicName parsing. + String ledgerName = "my-tenant/my-ns/persistent/test_checkpoint_batch_ack_no_truncation"; + ManagedLedger ledger = factory.open(ledgerName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + List positions = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + positions.add(ledger.addEntry(("entry-" + i).getBytes(Encoding))); + } + cursor.markDelete(positions.get(0)); + // Five batch acks in the same msg ledger (entries 5-9, ledger 4). Per-ledger checkpoints are + // bounded by chunking, so batch indexes are NOT truncated here (unlike the legacy single + // PositionInfo path, which applies maxBatchDeletedIndexToPersist). + for (int i = 5; i <= 9; i++) { + BitSet bitSet = new BitSet(32); + bitSet.set(2, 4); + cursor.batchDeletedIndexes.put(positions.get(i), bitSet); + } + cursor.markDelete(positions.get(2)); // flush + ManagedCursorImpl flushedCursor = cursor; + Awaitility.await().untilAsserted( + () -> assertThat(flushedCursor.getStats().getPersistLedgerSucceed()).isGreaterThan(0)); + ledger.close(); + + ledger = factory.open(ledgerName, config); + cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + for (int i = 5; i <= 9; i++) { + assertThat(cursor.getDeletedBatchIndexesAsLongArray(positions.get(i))) + .as("batch ack at position " + i).isNotEmpty(); + } + ledger.close(); + } + + /** + * A per-msgLedger checkpoint write failure must not fall back to the metadata store: the unpersisted update stays + * in memory, is retried on the next mark-delete, and ZK is only touched by the infrequent + * rollover bookkeeping. This keeps the failure path off ZK (which is the point of per-ledger + * persistence) and relies on at-least-once redelivery as the safety net. + */ + @Test(timeOut = 30000) + public void testCheckpointPersistFailureSkipsMetaStoreFallback() throws Exception { + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setPersistentUnackedRangesWithPerLedgerEntryEnabled(true); + config.setMaxEntriesPerLedger(3); + config.setMaxUnackedRangesToPersistInMetadataStore(-1); + config.setThrottleMarkDelete(0); + + String ledgerName = "test_checkpoint_no_zk_fallback"; + ManagedLedger ledger = factory.open(ledgerName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + List positions = new ArrayList<>(); + for (int i = 0; i < 12; i++) { + positions.add(ledger.addEntry(("m-" + i).getBytes(Encoding))); + } + cursor.markDelete(positions.get(0)); + cursor.delete(positions.get(5)); // gaps → no implicit mark-delete + cursor.delete(positions.get(8)); + cursor.markDelete(positions.get(1)); // successful flush with acks 5, 8 + ManagedCursorImpl persistedCursor = cursor; + Awaitility.await().untilAsserted( + () -> assertThat(persistedCursor.getStats().getPersistLedgerSucceed()).isGreaterThanOrEqualTo(2)); + long zkBefore = cursor.getStats().getPersistZookeeperSucceed(); + + // Fail the next flush entirely (ack 11 in msg ledger 3 + mark-delete ledger). + bkc.addEntryFailAfter(0, BKException.Code.NoBookieAvailableException); + bkc.addEntryFailAfter(1, BKException.Code.NoBookieAvailableException); + cursor.delete(positions.get(11)); + cursor.markDelete(positions.get(4)); + ManagedCursorImpl failedCursor = cursor; + Awaitility.await().untilAsserted( + () -> assertThat(failedCursor.getStats().getPersistLedgerErrors()).isGreaterThan(0)); + // The failure must not have written the metadata store. + assertThat(cursor.getStats().getPersistZookeeperSucceed()).isEqualTo(zkBefore); + + // The next mark-delete retries on a fresh cursor ledger and persists the failed ack. + cursor.markDelete(positions.get(7)); + ManagedCursorImpl retriedCursor = cursor; + Awaitility.await().untilAsserted( + () -> assertThat(retriedCursor.getStats().getPersistLedgerSucceed()).isGreaterThan(2)); + ledger.close(); + + ledger = factory.open(ledgerName, config); + cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + assertThat(cursor.isMessageDeleted(positions.get(11))).isTrue(); + assertThat(cursor.isMessageDeleted(positions.get(10))).isFalse(); + ledger.close(); + } + + /** + * A resetCursor whose own mark-delete persist fails must still apply its in-memory state change + * (deferred-persist semantics): the reset completes, the ack state is cleared in memory, and the + * next flush persists the cleared state. This also guards the reset-vs-failed-ack race: a failed + * normal mark-delete must never run the default align (which would advance + * persistentMarkDeletePosition as if the write had succeeded). + */ + @Test(timeOut = 30000) + public void testCheckpointResetCursorSurvivesPersistFailure() throws Exception { + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setPersistentUnackedRangesWithPerLedgerEntryEnabled(true); + config.setMaxEntriesPerLedger(3); + config.setMaxUnackedRangesToPersistInMetadataStore(-1); + config.setThrottleMarkDelete(0); + + String ledgerName = "test_checkpoint_reset_persist_failure"; + ManagedLedger ledger = factory.open(ledgerName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + List positions = new ArrayList<>(); + for (int i = 0; i < 9; i++) { + positions.add(ledger.addEntry(("m-" + i).getBytes(Encoding))); + } + cursor.markDelete(positions.get(0)); + cursor.delete(positions.get(5)); // gaps → no implicit mark-delete + cursor.delete(positions.get(8)); + cursor.markDelete(positions.get(1)); // persists acks 5, 8 + ManagedCursorImpl persistedCursor = cursor; + Awaitility.await().untilAsserted( + () -> assertThat(persistedCursor.getStats().getPersistLedgerSucceed()).isGreaterThanOrEqualTo(2)); + + // Fail the reset's own ledger persist (both checkpoints in the flush). + // BK writes fail; the reset falls back to the md-only tombstone in ZK (the post-reset + // state has no holes, so the ZK form is complete). + bkc.addEntryFailAfter(0, BKException.Code.NoBookieAvailableException); + bkc.addEntryFailAfter(1, BKException.Code.NoBookieAvailableException); + cursor.resetCursor(positions.get(0)); + // The reset succeeded durably (via ZK) and only then applied in memory. + assertThat(cursor.isMessageDeleted(positions.get(5))).isFalse(); + assertThat(cursor.isMessageDeleted(positions.get(8))).isFalse(); + ledger.close(); + + // Recovery reads the ZK tombstone: pre-reset acks must not resurrect. + ledger = factory.open(ledgerName, config); + cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + assertThat(cursor.getMarkDeletedPosition()).isEqualTo(ledger.getPreviousPosition(positions.get(0))); + assertThat(cursor.isMessageDeleted(positions.get(5))).isFalse(); + assertThat(cursor.isMessageDeleted(positions.get(8))).isFalse(); + ledger.close(); + } + + @Test(timeOut = 30000) + public void testCheckpointResetCursorCleanPersistRecovery() throws Exception { + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setPersistentUnackedRangesWithPerLedgerEntryEnabled(true); + config.setMaxEntriesPerLedger(3); + config.setMaxUnackedRangesToPersistInMetadataStore(-1); + config.setThrottleMarkDelete(0); + + String ledgerName = "test_checkpoint_reset_clean_recovery"; + ManagedLedger ledger = factory.open(ledgerName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + List positions = new ArrayList<>(); + for (int i = 0; i < 9; i++) { + positions.add(ledger.addEntry(("m-" + i).getBytes(Encoding))); + } + cursor.markDelete(positions.get(0)); + cursor.delete(positions.get(5)); + cursor.delete(positions.get(8)); + cursor.markDelete(positions.get(1)); // persists acks 5, 8 + ManagedCursorImpl persistedCursor = cursor; + Awaitility.await().untilAsserted( + () -> assertThat(persistedCursor.getStats().getPersistLedgerSucceed()).isGreaterThanOrEqualTo(2)); + + // Clean-path reset: the tombstone checkpoint (empty ack state, new mark-delete) is + // persisted without failures. + cursor.resetCursor(positions.get(0)); + assertThat(cursor.isMessageDeleted(positions.get(5))).isFalse(); + ManagedCursorImpl resetCursor = cursor; + Awaitility.await().untilAsserted( + () -> assertThat(resetCursor.getStats().getPersistLedgerSucceed()).isGreaterThan(2)); + ledger.close(); + + // Recovery takes the tombstone: pre-reset acks must not resurrect. + ledger = factory.open(ledgerName, config); + cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + assertThat(cursor.getMarkDeletedPosition()).isEqualTo(ledger.getPreviousPosition(positions.get(0))); + assertThat(cursor.isMessageDeleted(positions.get(5))).isFalse(); + assertThat(cursor.isMessageDeleted(positions.get(8))).isFalse(); + ledger.close(); + } + + @Test(timeOut = 30000) + public void testCheckpointResetConsistentWhenAllPersistsFail() throws Exception { + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setPersistentUnackedRangesWithPerLedgerEntryEnabled(true); + config.setMaxEntriesPerLedger(3); + config.setMaxUnackedRangesToPersistInMetadataStore(-1); + config.setThrottleMarkDelete(0); + + String ledgerName = "test_checkpoint_reset_lost_before_tombstone"; + ManagedLedger ledger = factory.open(ledgerName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + List positions = new ArrayList<>(); + for (int i = 0; i < 9; i++) { + positions.add(ledger.addEntry(("m-" + i).getBytes(Encoding))); + } + cursor.markDelete(positions.get(0)); + cursor.delete(positions.get(5)); + cursor.delete(positions.get(8)); + cursor.markDelete(positions.get(1)); // persists acks 5, 8 + ManagedCursorImpl persistedCursor = cursor; + Awaitility.await().untilAsserted( + () -> assertThat(persistedCursor.getStats().getPersistLedgerSucceed()).isGreaterThanOrEqualTo(2)); + + // Make every subsequent BK append fail AND the ZK cursor-info write fail: neither + // tombstone destination is available, so the reset must fail visibly. + for (int i = 0; i < 100; i++) { + bkc.addEntryFailAfter(i, BKException.Code.NoBookieAvailableException); + } + metadataStore.failConditional(new MetadataStoreException("stop zk writes"), + (operationType, path) -> operationType == FaultInjectionMetadataStore.OperationType.PUT + && path.equals("/managed-ledgers/" + ledgerName + "/c1")); + try { + cursor.resetCursor(positions.get(0)); + fail("reset should fail when both BK and ZK writes fail"); + } catch (Exception e) { + // resetFailed is expected: no durable tombstone could be written. + } + // Consistency-first: the in-memory state is NOT reset before the tombstone is durable. + assertThat(cursor.isMessageDeleted(positions.get(5))).isTrue(); + assertThat(cursor.isMessageDeleted(positions.get(8))).isTrue(); + + // Simulate a broker crash + restart at this point: a second factory recovers purely + // from the persisted log, and sees exactly what the first instance still holds in + // memory. No silent reset loss window. + ManagedLedgerFactoryImpl factory2 = new ManagedLedgerFactoryImpl(metadataStore, bkc); + ManagedLedger ledger2 = factory2.open(ledgerName, config); + ManagedCursorImpl recovered = (ManagedCursorImpl) ledger2.openCursor("c1"); + assertThat(recovered.isMessageDeleted(positions.get(5))).isTrue(); + assertThat(recovered.isMessageDeleted(positions.get(8))).isTrue(); + factory2.shutdownAsync().get(10, TimeUnit.SECONDS); + } + + @Test(timeOut = 30000) + public void testCheckpointClearBacklogRecovery() throws Exception { + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setPersistentUnackedRangesWithPerLedgerEntryEnabled(true); + config.setMaxEntriesPerLedger(3); + config.setMaxUnackedRangesToPersistInMetadataStore(-1); + config.setThrottleMarkDelete(0); + + String ledgerName = "test_checkpoint_clear_backlog_recovery"; + ManagedLedger ledger = factory.open(ledgerName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + List positions = new ArrayList<>(); + for (int i = 0; i < 9; i++) { + positions.add(ledger.addEntry(("m-" + i).getBytes(Encoding))); + } + cursor.markDelete(positions.get(0)); + cursor.delete(positions.get(5)); // hole survives: no contiguous mark-delete + cursor.markDelete(positions.get(1)); + ManagedCursorImpl persistedCursor = cursor; + Awaitility.await().untilAsserted( + () -> assertThat(persistedCursor.getStats().getPersistLedgerSucceed()).isGreaterThanOrEqualTo(2)); + assertThat(cursor.isMessageDeleted(positions.get(5))).isTrue(); + + // clearBacklog absorbs every hole via mark-delete-to-tail and invalidates refs. + CountDownLatch cleared = new CountDownLatch(1); + cursor.asyncClearBacklog(new AsyncCallbacks.ClearBacklogCallback() { + @Override + public void clearBacklogComplete(Object ctx) { + cleared.countDown(); + } + + @Override + public void clearBacklogFailed(ManagedLedgerException exception, Object ctx) { + // no-op + } + }, null); + cleared.await(10, TimeUnit.SECONDS); + assertThat(cursor.isMessageDeleted(positions.get(5))).isTrue(); // absorbed, not a hole + ledger.close(); + + ledger = factory.open(ledgerName, config); + cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + // After recovery the backlog is drained: mark-delete at tail, no individual holes. + assertThat(cursor.getMarkDeletedPosition()).isEqualTo(positions.get(8)); + assertThat(cursor.isMessageDeleted(positions.get(5))).isTrue(); + ledger.close(); + } + + @Test(timeOut = 30000) + public void testCursorStatsIndividualDeletedMessages() throws Exception { + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setMaxEntriesPerLedger(5); + config.setThrottleMarkDelete(0); + + String ledgerName = "test_cursor_stats_individual_deleted"; + ManagedLedger ledger = factory.open(ledgerName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor("c1"); + List positions = new ArrayList<>(); + for (int i = 0; i < 15; i++) { + positions.add(ledger.addEntry(("m-" + i).getBytes(Encoding))); + } + + cursor.markDelete(positions.get(0)); + + // No holes yet. + ManagedLedgerInternalStats.CursorStats stats = cursor.getCursorStats(); + assertThat(stats.individualDeletedMessagesCount).isEqualTo(0); + assertThat(stats.firstIndividualDeletedMessage).isNull(); + + // Contiguous holes 2,3,4 (merge into one range) plus isolated holes at 7 and 12. + cursor.delete(positions.get(2)); + cursor.delete(positions.get(3)); + cursor.delete(positions.get(4)); + cursor.delete(positions.get(7)); + cursor.delete(positions.get(12)); + + stats = cursor.getCursorStats(); + assertThat(stats.individualDeletedMessagesCount).isEqualTo(5); + // First hole is positions.get(2) — the start of the contiguous run, not its end (4). + assertThat(stats.firstIndividualDeletedMessage).isEqualTo(positions.get(2).toString()); + + // Mark-delete past the contiguous run: the first hole becomes 7. + cursor.markDelete(positions.get(4)); + stats = cursor.getCursorStats(); + assertThat(stats.individualDeletedMessagesCount).isEqualTo(2); + assertThat(stats.firstIndividualDeletedMessage).isEqualTo(positions.get(7).toString()); + + // Mark-delete past all holes: count drops to 0. + cursor.markDelete(positions.get(13)); + stats = cursor.getCursorStats(); + assertThat(stats.individualDeletedMessagesCount).isEqualTo(0); + assertThat(stats.firstIndividualDeletedMessage).isNull(); + + ledger.close(); + } + } diff --git a/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/ManagedLedgerBkTest.java b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/ManagedLedgerBkTest.java index 1ba3c0f1ed332..71c38bbe64ca3 100644 --- a/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/ManagedLedgerBkTest.java +++ b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/ManagedLedgerBkTest.java @@ -704,62 +704,6 @@ public void testPeriodicRollover() throws Exception { Awaitility.await().until(() -> cursorImpl.getCursorLedger() != currentLedgerId); } - @DataProvider(name = "unackedRangesOpenCacheSetEnabledPair") - public Object[][] unackedRangesOpenCacheSetEnabledPair() { - return new Object[][]{ - {false, true}, - {true, false}, - {true, true}, - {false, false} - }; - } - - /** - * This test validates that cursor serializes and deserializes individual-ack list from the bk-ledger. - * @throws Exception - */ - @Test(dataProvider = "unackedRangesOpenCacheSetEnabledPair") - public void testUnackmessagesAndRecoveryCompatibility(boolean enabled1, boolean enabled2) throws Exception { - final String mlName = "ml" + UUID.randomUUID().toString().replaceAll("-", ""); - final String cursorName = "c1"; - ManagedLedgerFactoryConfig factoryConf = new ManagedLedgerFactoryConfig(); - ManagedLedgerFactory factory = new ManagedLedgerFactoryImpl(metadataStore, bkc, factoryConf); - final ManagedLedgerConfig config1 = new ManagedLedgerConfig().setEnsembleSize(1).setWriteQuorumSize(1) - .setAckQuorumSize(1).setMetadataEnsembleSize(1).setMetadataWriteQuorumSize(1) - .setMaxUnackedRangesToPersistInMetadataStore(1).setMaxEntriesPerLedger(5).setMetadataAckQuorumSize(1) - .setUnackedRangesOpenCacheSetEnabled(enabled1); - final ManagedLedgerConfig config2 = new ManagedLedgerConfig().setEnsembleSize(1).setWriteQuorumSize(1) - .setAckQuorumSize(1).setMetadataEnsembleSize(1).setMetadataWriteQuorumSize(1) - .setMaxUnackedRangesToPersistInMetadataStore(1).setMaxEntriesPerLedger(5).setMetadataAckQuorumSize(1) - .setUnackedRangesOpenCacheSetEnabled(enabled2); - - ManagedLedger ledger1 = factory.open(mlName, config1); - ManagedCursorImpl cursor1 = (ManagedCursorImpl) ledger1.openCursor(cursorName); - - int totalEntries = 100; - for (int i = 0; i < totalEntries; i++) { - Position p = ledger1.addEntry("entry".getBytes()); - if (i % 2 == 0) { - cursor1.delete(p); - } - } - log.info("ack ranges: {}", cursor1.getIndividuallyDeletedMessagesSet().size()); - - // reopen and recover cursor - ledger1.close(); - ManagedLedger ledger2 = factory.open(mlName, config2); - ManagedCursorImpl cursor2 = (ManagedCursorImpl) ledger2.openCursor(cursorName); - - log.info("before: {}", cursor1.getIndividuallyDeletedMessagesSet().asRanges()); - log.info("after : {}", cursor2.getIndividuallyDeletedMessagesSet().asRanges()); - assertEquals(cursor1.getIndividuallyDeletedMessagesSet().asRanges(), - cursor2.getIndividuallyDeletedMessagesSet().asRanges()); - assertEquals(cursor1.markDeletePosition, cursor2.markDeletePosition); - - ledger2.close(); - factory.shutdown(); - } - @DataProvider(name = "booleans") public Object[][] booleans() { return new Object[][] { @@ -778,7 +722,7 @@ public void testConfigPersistIndividualAckAsLongArray(boolean enable) throws Exc .setEnsembleSize(1).setWriteQuorumSize(1).setAckQuorumSize(1) .setMetadataEnsembleSize(1).setMetadataWriteQuorumSize(1).setMetadataAckQuorumSize(1) .setMaxUnackedRangesToPersistInMetadataStore(1) - .setUnackedRangesOpenCacheSetEnabled(true).setPersistIndividualAckAsLongArray(enable); + .setPersistIndividualAckAsLongArray(enable); ManagedLedger ledger1 = factory.open(mlName, config); ManagedCursorImpl cursor1 = (ManagedCursorImpl) ledger1.openCursor(cursorName); diff --git a/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/ManagedLedgerTest.java b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/ManagedLedgerTest.java index 4f302af34d54f..1da9980585cf5 100644 --- a/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/ManagedLedgerTest.java +++ b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/ManagedLedgerTest.java @@ -78,6 +78,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.CyclicBarrier; import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.FutureTask; @@ -190,19 +191,21 @@ public static void makeAddEntryTimeout(ManagedLedgerImpl ml, AtomicBoolean addEn ml.currentLedger = spyLedgerHandle; } - public static void makeReadEntryProbFail(ManagedLedgerImpl ml, Supplier errorOrNot) - throws Exception { + public static void makeReadEntryProbFail(ManagedLedgerImpl ml, Supplier errorOrNot, + Executor errorSupplierExecutor) throws Exception { ml.entryCache.clear(); LedgerHandle currentLedger = ml.currentLedger; final LedgerHandle spyLedgerHandle = spy(currentLedger); doAnswer(invocation -> { - long ledgerId = (long) invocation.getArguments()[0]; - long entryId = (long) invocation.getArguments()[1]; - ManagedLedgerException mightError = errorOrNot.get(); - if (mightError != null) { - return CompletableFuture.failedFuture(mightError); - } - return currentLedger.readUnconfirmedAsync(ledgerId, entryId); + long ledgerId = invocation.getArgument(0); + long entryId = invocation.getArgument(1); + // Evaluate errorOrNot on errorSupplierExecutor. Pass a single-threaded executor when errorOrNot may + // block (e.g. it waits on a CountDownLatch) so it doesn't block the calling read thread; pass + // MoreExecutors.directExecutor() to evaluate it inline on the calling thread. + return CompletableFuture.supplyAsync(errorOrNot, errorSupplierExecutor) + .thenCompose(mightError -> mightError != null + ? CompletableFuture.failedFuture(mightError) + : currentLedger.readUnconfirmedAsync(ledgerId, entryId)); }).when(spyLedgerHandle).readUnconfirmedAsync(anyLong(), anyLong()); ml.currentLedger = spyLedgerHandle; } diff --git a/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/MetaStoreImplTest.java b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/MetaStoreImplTest.java index 00ad494a5ca59..3f0ac3aab2cd1 100644 --- a/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/MetaStoreImplTest.java +++ b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/MetaStoreImplTest.java @@ -18,6 +18,10 @@ */ package org.apache.bookkeeper.mledger.impl; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.spy; +import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; import static org.testng.Assert.assertNotNull; import static org.testng.Assert.assertTrue; @@ -25,6 +29,7 @@ import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import lombok.AllArgsConstructor; import lombok.Data; @@ -38,6 +43,7 @@ import org.apache.pulsar.metadata.api.MetadataCache; import org.apache.pulsar.metadata.api.MetadataStoreException; import org.apache.pulsar.metadata.api.Stat; +import org.apache.pulsar.metadata.api.extended.MetadataStoreExtended; import org.apache.pulsar.metadata.impl.FaultInjectionMetadataStore; import org.testng.annotations.Test; @@ -159,6 +165,48 @@ public void operationComplete(ManagedLedgerInfo result, Stat version) { promise.get(); } + /** + * The z-node backing a managed ledger can be created by another party (for example + * {@code AdminResource#tryCreatePartitionAsync}) in between the read and the write that + * {@code getManagedLedgerInfo(createIfMissing = true)} performs. The metadata store reports that as a + * {@link MetadataStoreException.BadVersionException}, which must not fail the managed ledger initialization. + */ + @Test(timeOut = 20000) + void createMLNodeConcurrently() throws Exception { + String ledgerName = "my_test"; + String path = "/managed-ledgers/" + ledgerName; + + AtomicBoolean raceInjected = new AtomicBoolean(); + MetadataStoreExtended racyStore = spy(metadataStore); + doAnswer(invocation -> { + if (path.equals(invocation.getArgument(0)) && raceInjected.compareAndSet(false, true)) { + // Create the z-node behind the caller's back and report it as still missing, so that the + // subsequent create hits an already existing z-node. + metadataStore.put(path, new byte[0], Optional.of(-1L)).get(); + return CompletableFuture.completedFuture(Optional.empty()); + } + return invocation.callRealMethod(); + }).when(racyStore).get(anyString()); + + MetaStore store = new MetaStoreImpl(racyStore, executor); + + final CompletableFuture promise = new CompletableFuture<>(); + store.getManagedLedgerInfo(ledgerName, true, new MetaStoreCallback() { + public void operationFailed(MetaStoreException e) { + promise.completeExceptionally(e); + } + + public void operationComplete(ManagedLedgerInfo result, Stat version) { + promise.complete(result); + } + }); + + ManagedLedgerInfo info = promise.get(); + assertNotNull(info); + assertEquals(info.getLedgerInfoCount(), 0); + assertTrue(raceInjected.get()); + } + @Test(timeOut = 20000) void updatingCursorNode() throws Exception { MetaStore store = new MetaStoreImpl(metadataStore, executor); diff --git a/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/OffloadPrefixTest.java b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/OffloadPrefixTest.java index 2a50330a0da7c..434980f000762 100644 --- a/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/OffloadPrefixTest.java +++ b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/OffloadPrefixTest.java @@ -1184,6 +1184,130 @@ public CompletableFuture offload(ReadHandle ledger, } } + @Test + public void automaticOffloadTriggersAreCoalescedWhileOffloadInProgress() throws Exception { + CompletableFuture slowOffload = new CompletableFuture<>(); + CountDownLatch offloadRunning = new CountDownLatch(1); + AtomicInteger offloadPolicyCalls = new AtomicInteger(); + MockLedgerOffloader offloader = new MockLedgerOffloader() { + @Override + public CompletableFuture offload(ReadHandle ledger, + UUID uuid, + Map extraMetadata) { + offloadRunning.countDown(); + return slowOffload.thenCompose((res) -> super.offload(ledger, uuid, extraMetadata)); + } + + @Override + public OffloadPoliciesImpl getOffloadPolicies() { + offloadPolicyCalls.incrementAndGet(); + return super.getOffloadPolicies(); + } + }; + + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setMaxEntriesPerLedger(10); + config.setRetentionTime(10, TimeUnit.MINUTES); + config.setRetentionSizeInMB(10); + offloader.getOffloadPolicies().setManagedLedgerOffloadThresholdInBytes(0L); + offloader.getOffloadPolicies().setManagedLedgerOffloadThresholdInSeconds(null); + config.setLedgerOffloader(offloader); + + ManagedLedgerImpl ledger = + (ManagedLedgerImpl) factory.open("my_test_ledger" + UUID.randomUUID(), config); + + for (int i = 0; i < 25; i++) { + ledger.addEntry(buildEntry(10, "entry-" + i)); + } + assertTrue(offloadRunning.await(5, TimeUnit.SECONDS)); + + // Repeated automatic triggers should stop at the controller and avoid another policy lookup. + int callsBeforeRepeatedTriggers = offloadPolicyCalls.get(); + for (int i = 0; i < 20; i++) { + ledger.maybeOffloadInBackground(ManagedLedgerImpl.AUTOMATIC_OFFLOAD_TRIGGER); + } + + assertEquals(offloadPolicyCalls.get(), callsBeforeRepeatedTriggers); + + slowOffload.complete(null); + + assertEventuallyTrue(() -> offloader.offloadedLedgers().size() == 2); + List allLedgerIds = ledger.getLedgersInfoAsList().stream().map(LedgerInfo::getLedgerId).toList(); + assertEquals(offloader.offloadedLedgers(), Set.of(allLedgerIds.get(0), allLedgerIds.get(1))); + } + + @Test + public void automaticOffloadRunsAgainForCoalescedTrigger() throws Exception { + CompletableFuture slowOffload = new CompletableFuture<>(); + CountDownLatch offloadRunning = new CountDownLatch(1); + MockLedgerOffloader offloader = new MockLedgerOffloader() { + @Override + public CompletableFuture offload(ReadHandle ledger, + UUID uuid, + Map extraMetadata) { + offloadRunning.countDown(); + return slowOffload.thenCompose((res) -> super.offload(ledger, uuid, extraMetadata)); + } + }; + + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setMaxEntriesPerLedger(10); + config.setRetentionTime(10, TimeUnit.MINUTES); + config.setRetentionSizeInMB(10); + offloader.getOffloadPolicies().setManagedLedgerOffloadThresholdInBytes(0L); + offloader.getOffloadPolicies().setManagedLedgerOffloadThresholdInSeconds(null); + config.setLedgerOffloader(offloader); + + ManagedLedgerImpl ledger = + (ManagedLedgerImpl) factory.open("my_test_ledger" + UUID.randomUUID(), config); + + for (int i = 0; i < 11; i++) { + ledger.addEntry(buildEntry(10, "entry-" + i)); + } + assertTrue(offloadRunning.await(5, TimeUnit.SECONDS)); + + // The next ledger closes after the first automatic scan, so it depends on the coalesced rerun. + for (int i = 11; i < 21; i++) { + ledger.addEntry(buildEntry(10, "entry-" + i)); + } + assertEquals(offloader.offloadedLedgers().size(), 0); + + slowOffload.complete(null); + + assertEventuallyTrue(() -> offloader.offloadedLedgers().size() == 2); + List allLedgerIds = ledger.getLedgersInfoAsList().stream().map(LedgerInfo::getLedgerId).toList(); + assertEquals(offloader.offloadedLedgers(), Set.of(allLedgerIds.get(0), allLedgerIds.get(1))); + } + + @Test + public void automaticOffloadWithoutThresholdDoesNotBlockLaterTriggers() throws Exception { + MockLedgerOffloader offloader = new MockLedgerOffloader(); + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setMaxEntriesPerLedger(10); + config.setRetentionTime(10, TimeUnit.MINUTES); + config.setRetentionSizeInMB(10); + offloader.getOffloadPolicies().setManagedLedgerOffloadThresholdInBytes(-1L); + offloader.getOffloadPolicies().setManagedLedgerOffloadThresholdInSeconds(null); + config.setLedgerOffloader(offloader); + + ManagedLedgerImpl ledger = + (ManagedLedgerImpl) factory.open("my_test_ledger" + UUID.randomUUID(), config); + + for (int i = 0; i < 25; i++) { + ledger.addEntry(buildEntry(10, "entry-" + i)); + } + ledger.maybeOffloadInBackground(ManagedLedgerImpl.AUTOMATIC_OFFLOAD_TRIGGER); + assertEquals(offloader.offloadedLedgers().size(), 0); + + // A disabled automatic trigger must complete internally so a later valid trigger can run. + offloader.getOffloadPolicies().setManagedLedgerOffloadThresholdInBytes(0L); + ledger.maybeOffloadInBackground(ManagedLedgerImpl.AUTOMATIC_OFFLOAD_TRIGGER); + + assertEventuallyTrue(() -> offloader.offloadedLedgers().size() == 2); + List allLedgerIds = ledger.getLedgersInfoAsList().stream().map(LedgerInfo::getLedgerId).toList(); + assertEquals(offloader.offloadedLedgers(), Set.of(allLedgerIds.get(0), allLedgerIds.get(1))); + } + @DataProvider(name = "offloadAsSoonAsClosed") public Object[][] offloadAsSoonAsClosedProvider() { return new Object[][]{ diff --git a/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/PositionRangeSetCompatibilityTest.java b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/PositionRangeSetCompatibilityTest.java new file mode 100644 index 0000000000000..75e01555a94ba --- /dev/null +++ b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/PositionRangeSetCompatibilityTest.java @@ -0,0 +1,198 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.bookkeeper.mledger.impl; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.IntFunction; +import lombok.Cleanup; +import org.apache.bookkeeper.mledger.ManagedLedger; +import org.apache.bookkeeper.mledger.ManagedLedgerConfig; +import org.apache.bookkeeper.mledger.ManagedLedgerFactory; +import org.apache.bookkeeper.mledger.ManagedLedgerFactoryConfig; +import org.apache.bookkeeper.mledger.Position; +import org.apache.bookkeeper.mledger.PositionFactory; +import org.apache.bookkeeper.mledger.proto.MLDataFormats.MessageRange; +import org.apache.bookkeeper.mledger.proto.MLDataFormats.NestedPositionInfo; +import org.apache.bookkeeper.test.BookKeeperClusterTestCase; +import org.apache.pulsar.common.util.collections.LongPairRangeSet; +import org.apache.pulsar.common.util.collections.OpenLongPairRangeSet; +import org.roaringbitmap.RoaringBitSet; +import org.testng.annotations.Test; + +/** + * Comprehensive compatibility tests for PositionRangeSet. + * Covers 4.x to 5.0 upgrade, 5.0 to 4.x downgrade, and cross-ledger MessageRange recovery. + */ +public class PositionRangeSetCompatibilityTest extends BookKeeperClusterTestCase { + + public PositionRangeSetCompatibilityTest() { + super(2); + } + + /** + * Verifies 4.x to 5.0 upgrade with bitmap format. + * Tests that 5.0 PositionRangeSet can deserialize data written by 4.x OpenLongPairRangeSet + * (when unackedRangesOpenCacheSetEnabled=true in 4.x). + */ + @Test + public void testUpgrade_BitmapFormat() { + OpenLongPairRangeSet legacy = new OpenLongPairRangeSet<>(PositionFactory::create); + legacy.addOpenClosed(0, -1, 0, 99); + legacy.addOpenClosed(1, 49, 1, 149); + legacy.addOpenClosed(5, -1, 5, 999); + + Map serialized = legacy.toRanges(Integer.MAX_VALUE); + + PositionRangeSet recovered = new PositionRangeSet(PositionFactory::create, false); + recovered.build(serialized); + + assertEquals(recovered.asRanges(), legacy.asRanges()); + assertEquals(recovered.size(), legacy.size()); + } + + /** + * Verifies 4.x to 5.0 upgrade with MessageRange format including cross-ledger expansion. + * Tests that 5.0 PositionRangeSet can recover data written by 4.x DefaultRangeSet + * (when unackedRangesOpenCacheSetEnabled=false in 4.x) and correctly expands + * MessageRanges that span multiple ledgers using ledger metadata. + */ + @Test + public void testUpgrade_CrossLedgerMessageRange() throws Exception { + String mlName = "test-ml-" + UUID.randomUUID(); + String cursorName = "test-cursor"; + + ManagedLedgerFactoryConfig factoryConf = new ManagedLedgerFactoryConfig(); + @Cleanup("shutdown") + ManagedLedgerFactory factory = new ManagedLedgerFactoryImpl(metadataStore, bkc, factoryConf); + + ManagedLedgerConfig config = new ManagedLedgerConfig() + .setEnsembleSize(1).setWriteQuorumSize(1).setAckQuorumSize(1) + .setMetadataEnsembleSize(1).setMetadataWriteQuorumSize(1).setMetadataAckQuorumSize(1) + .setMaxEntriesPerLedger(5); + + ManagedLedger ledger = factory.open(mlName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor(cursorName); + + for (int i = 0; i < 100; i++) { + ledger.addEntry(("entry-" + i).getBytes()); + } + + List messageRanges = new ArrayList<>(); + messageRanges.add(createMessageRange(0, 2, 2, 1)); + messageRanges.add(createMessageRange(5, 0, 7, 3)); + + cursor.recoverIndividualDeletedMessages(messageRanges.size(), (IntFunction) messageRanges::get); + + PositionRangeSet recovered = (PositionRangeSet) cursor.getIndividuallyDeletedMessagesSet(); + + assertTrue(recovered.contains(0, 3)); + assertTrue(recovered.contains(1, 2)); + assertTrue(recovered.contains(2, 1)); + + ledger.close(); + } + + /** + * Verifies 5.0 to 4.x downgrade with bitmap format. + * Tests that 4.x OpenLongPairRangeSet can deserialize data written by 5.0 PositionRangeSet + * (when persistIndividualAckAsLongArray=true in 5.0). + */ + @Test + public void testDowngrade_BitmapFormat() { + PositionRangeSet source = new PositionRangeSet(PositionFactory::create, false); + source.addOpenClosed(0, -1, 0, 99); + source.addOpenClosed(1, 49, 1, 149); + + Map serialized = source.toRanges(Integer.MAX_VALUE); + + OpenLongPairRangeSet legacy = new OpenLongPairRangeSet<>(PositionFactory::create); + legacy.build(serialized); + + assertEquals(legacy.asRanges(), source.asRanges()); + assertEquals(legacy.size(), source.size()); + } + + /** + * Verifies 5.0 to 4.x downgrade with MessageRange format. + * Tests that 4.x DefaultRangeSet can recover data written by 5.0 PositionRangeSet + * (when persistIndividualAckAsLongArray=false in 5.0, which is the default). + */ + @Test + public void testDowngrade_MessageRangeFormat() { + PositionRangeSet source = new PositionRangeSet(PositionFactory::create, false); + source.addOpenClosed(0, -1, 0, 99); + source.addOpenClosed(5, -1, 5, 999); + + List messageRanges = buildMessageRanges(source); + + LongPairRangeSet legacy = + new LongPairRangeSet.DefaultRangeSet<>( + PositionFactory::create, + pos -> new LongPairRangeSet.LongPair(pos.getLedgerId(), pos.getEntryId())); + for (MessageRange mr : messageRanges) { + legacy.addOpenClosed( + mr.getLowerEndpoint().getLedgerId(), mr.getLowerEndpoint().getEntryId(), + mr.getUpperEndpoint().getLedgerId(), mr.getUpperEndpoint().getEntryId()); + } + + assertEquals(legacy.asRanges(), source.asRanges()); + } + + /** + * Verifies bitmap wire format compatibility at binary level. + * Ensures BitSet.toLongArray()/valueOf() produces identical representations + * between 4.x OpenLongPairRangeSet and 5.0 PositionRangeSet. + */ + @Test + public void testBitmapBinaryFormat() { + OpenLongPairRangeSet legacy = new OpenLongPairRangeSet<>( + PositionFactory::create, RoaringBitSet::new); + legacy.addOpenClosed(0, -1, 0, 9); + + Map serialized = legacy.toRanges(Integer.MAX_VALUE); + long[] bitSetArray = serialized.get(0L); + + assertEquals(Arrays.stream(bitSetArray).filter(l -> l != 0).count(), 1); + } + + private MessageRange createMessageRange(long lowerLedger, long lowerEntry, + long upperLedger, long upperEntry) { + return MessageRange.newBuilder() + .setLowerEndpoint(NestedPositionInfo.newBuilder() + .setLedgerId(lowerLedger).setEntryId(lowerEntry)) + .setUpperEndpoint(NestedPositionInfo.newBuilder() + .setLedgerId(upperLedger).setEntryId(upperEntry)) + .build(); + } + + private List buildMessageRanges(PositionRangeSet rangeSet) { + List result = new ArrayList<>(); + rangeSet.forEachRawRange((lowerKey, lowerValue, upperKey, upperValue) -> { + result.add(createMessageRange(lowerKey, lowerValue, upperKey, upperValue)); + return true; + }); + return result; + } +} diff --git a/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/PositionRangeSetDirtyTrackingTest.java b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/PositionRangeSetDirtyTrackingTest.java new file mode 100644 index 0000000000000..b3fc46b61f424 --- /dev/null +++ b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/PositionRangeSetDirtyTrackingTest.java @@ -0,0 +1,193 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.bookkeeper.mledger.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import java.util.Set; +import org.apache.bookkeeper.mledger.Position; +import org.apache.bookkeeper.mledger.PositionFactory; +import org.apache.pulsar.common.util.collections.LongPairRangeSet.LongPairConsumer; +import org.testng.annotations.Test; + +/** + * Regression tests for {@link PositionRangeSet} dirty tracking. + * + *

These tests cover the three markDirty fixes: + *

    + *
  1. Same-ledger acks (upperLedgerId == lowerLedgerId) now mark dirty — original code + * had {@code upperLedgerId <= lowerLedgerId} which silently skipped them.
  2. + *
  3. Range end is inclusive — original {@code add(L+1, U+1)} missed the upper ledger; + * fixed to {@code add(L+1, U+2)}.
  4. + *
  5. snapshotAndClearDirtyLedgers correctly maps dirty bits back to raw ledger IDs.
  6. + *
+ */ +public class PositionRangeSetDirtyTrackingTest { + + private static final LongPairConsumer CONVERTER = + (ledgerId, entryId) -> PositionFactory.create(ledgerId, entryId); + + @Test + public void testSameLedgerAckMarksDirty() { + // Regression: same-ledger ack (the most common case) was silently skipped by + // the original markDirty when upperLedgerId == lowerLedgerId. + PositionRangeSet set = new PositionRangeSet(CONVERTER, true); + set.addOpenClosed(100, 0, 100, 5); + Set dirty = set.snapshotAndClearDirtyLedgers(); + assertThat(dirty).containsExactly(100L); + } + + @Test + public void testCrossLedgerRangeMarksAllInclusive() { + // Regression: markDirty(L1, L2) should mark L1 through L2 INCLUSIVE. + // Original add(L1+1, L2+1) [half-open] missed L2. + PositionRangeSet set = new PositionRangeSet(CONVERTER, true); + set.addOpenClosed(100, 0, 103, 0); + Set dirty = set.snapshotAndClearDirtyLedgers(); + assertThat(dirty).containsExactlyInAnyOrder(100L, 101L, 102L, 103L); + } + + @Test + public void testDirtyClearedAfterSnapshot() { + PositionRangeSet set = new PositionRangeSet(CONVERTER, true); + set.addOpenClosed(100, 0, 100, 5); + set.snapshotAndClearDirtyLedgers(); + // After snapshot, dirty should be empty + Set dirty2 = set.snapshotAndClearDirtyLedgers(); + assertThat(dirty2).isEmpty(); + } + + @Test + public void testRestoreDirtyLedgers() { + PositionRangeSet set = new PositionRangeSet(CONVERTER, true); + set.addOpenClosed(100, 0, 100, 5); + Set dirty = set.snapshotAndClearDirtyLedgers(); + // Restore dirty ledgers (simulates failed persist) + set.restoreDirtyLedgers(dirty); + // Next snapshot should have them again + Set dirty2 = set.snapshotAndClearDirtyLedgers(); + assertThat(dirty2).containsExactly(100L); + } + + @Test + public void testMultipleLedgersIndividuallyDirty() { + PositionRangeSet set = new PositionRangeSet(CONVERTER, true); + set.addOpenClosed(100, 0, 100, 3); + set.addOpenClosed(200, 0, 200, 7); + set.addOpenClosed(300, 0, 300, 1); + Set dirty = set.snapshotAndClearDirtyLedgers(); + assertThat(dirty).containsExactlyInAnyOrder(100L, 200L, 300L); + } + + @Test + public void testBitmapOfReturnsNullForEmptyLedger() { + PositionRangeSet set = new PositionRangeSet(CONVERTER, true); + assertThat(set.bitmapOf(999)).isNull(); + } + + @Test + public void testBitmapOfReturnsBytesForActiveLedger() { + PositionRangeSet set = new PositionRangeSet(CONVERTER, true); + set.addOpenClosed(100, 0, 100, 5); + byte[] bytes = set.bitmapOf(100); + assertThat(bytes).isNotNull(); + assertThat(bytes.length).isGreaterThan(0); + } + + @Test + public void testForEachActiveLedger() { + PositionRangeSet set = new PositionRangeSet(CONVERTER, true); + set.addOpenClosed(100, 0, 100, 1); + set.addOpenClosed(200, 0, 200, 1); + Set active = new java.util.HashSet<>(); + set.forEachActiveLedger(active::add); + assertThat(active).containsExactlyInAnyOrder(100L, 200L); + } + + @Test + public void testDirtyDisabledWhenMultiEntryOff() { + // When enableMultiEntry is false, dirty tracking is a no-op. + PositionRangeSet set = new PositionRangeSet(CONVERTER, false); + set.addOpenClosed(100, 0, 100, 5); + Set dirty = set.snapshotAndClearDirtyLedgers(); + assertThat(dirty).isEmpty(); + } + + @Test + public void testSameLedgerMarkDirty() { + PositionRangeSet set = new PositionRangeSet(CONVERTER, true); + set.addOpenClosed(0, -1, 0, 0); + Set dirty = set.snapshotAndClearDirtyLedgers(); + assertThat(dirty).containsExactly(0L); + } + + @Test + public void testCrossLedgerMarkDirtyUpperBound() { + PositionRangeSet set = new PositionRangeSet(CONVERTER, true); + set.addOpenClosed(100, 0, 103, 0); + Set dirty = set.snapshotAndClearDirtyLedgers(); + assertThat(dirty).containsExactlyInAnyOrder(100L, 101L, 102L, 103L); + } + + @Test + public void testRestoreAfterSnapshot() { + PositionRangeSet set = new PositionRangeSet(CONVERTER, true); + set.addOpenClosed(50, 0, 50, 1); + set.addOpenClosed(60, 0, 60, 1); + Set dirty = set.snapshotAndClearDirtyLedgers(); + assertThat(dirty).containsExactlyInAnyOrder(50L, 60L); + assertThat(set.snapshotAndClearDirtyLedgers()).isEmpty(); + set.restoreDirtyLedgers(dirty); + assertThat(set.snapshotAndClearDirtyLedgers()).containsExactlyInAnyOrder(50L, 60L); + } + + @Test + public void testRemoveAtMostClearsDirty() { + PositionRangeSet set = new PositionRangeSet(CONVERTER, true); + set.addOpenClosed(10, 0, 20, 0); + set.removeAtMost(15, 0); + Set dirty = set.snapshotAndClearDirtyLedgers(); + for (long id : dirty) { + assertThat(id).isGreaterThan(14); + } + } + + @Test + public void testEmptySnapshot() { + PositionRangeSet set = new PositionRangeSet(CONVERTER, true); + assertThat(set.snapshotAndClearDirtyLedgers()).isEmpty(); + } + + @Test + public void testMarkDirtyAtMaxValueBoundary() { + PositionRangeSet set = new PositionRangeSet(CONVERTER, true); + long near = Integer.MAX_VALUE - 1; + set.addOpenClosed(near, 0, near, 1); + Set dirty = set.snapshotAndClearDirtyLedgers(); + assertThat(dirty).containsExactly(near); + } + + @Test + public void testMarkDirtyUpperAtMaxValue() { + PositionRangeSet set = new PositionRangeSet(CONVERTER, true); + long near = Integer.MAX_VALUE - 1; + set.addOpenClosed(near - 1, 0, near, 0); + Set dirty = set.snapshotAndClearDirtyLedgers(); + assertThat(dirty).containsExactlyInAnyOrder(near - 1, near); + } +} diff --git a/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/PositionRangeSetTest.java b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/PositionRangeSetTest.java new file mode 100644 index 0000000000000..788ed78156b7d --- /dev/null +++ b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/PositionRangeSetTest.java @@ -0,0 +1,641 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.bookkeeper.mledger.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; +import com.google.common.collect.BoundType; +import com.google.common.collect.Range; +import com.google.common.collect.RangeSet; +import com.google.common.collect.TreeRangeSet; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.apache.bookkeeper.mledger.Position; +import org.apache.bookkeeper.mledger.PositionFactory; +import org.apache.commons.lang3.mutable.MutableInt; +import org.apache.pulsar.common.util.collections.LongPairRangeSet.LongPairConsumer; +import org.apache.pulsar.common.util.collections.OpenLongPairRangeSet; +import org.roaringbitmap.RoaringBitSet; +import org.testng.annotations.Test; + +/** + * Tests for {@link PositionRangeSet}, the Position-specific bitmap-backed range set that replaces the + * previous generic {@code RangeSetWrapper} + {@code OpenLongPairRangeSet} stack. + * + *

Originally {@code RangeSetWrapperTest} (parameterized over {@code unackedRangesOpenCacheSetEnabled}). + * The {@code DefaultRangeSet} code path that the {@code false} axis exercised no longer exists, so the + * {@code testAddForDifferentKey2} test and the {@code false} half of {@code testAddForSameKey} / + * {@code testDeleteWithAtMost2} were removed; the remaining assertions reproduce the original + * bitmap-mode ({@code openCacheSet=true}) behavior verbatim. + */ +public class PositionRangeSetTest { + + static final LongPairConsumer CONSUMER = PositionFactory::create; + + private static Position pos(long ledgerId, long entryId) { + return PositionFactory.create(ledgerId, entryId); + } + + // Standard fixture: multi-entry (dirty tracking) enabled — matches ManagedCursorImpl's production wiring. + private static PositionRangeSet newSet() { + return new PositionRangeSet(CONSUMER, true); + } + + @Test + public void testTotalCardinalityMaintainedIncrementally() { + PositionRangeSet set = newSet(); + assertEquals(set.totalCardinality(), 0); + + set.addOpenClosed(1, -1, 1, 4); // entries 0..4 in ledger 1 + assertEquals(set.totalCardinality(), 5); + + set.addOpenClosed(1, 4, 1, 9); // entries 5..9 in ledger 1 + assertEquals(set.totalCardinality(), 10); + + set.addOpenClosed(3, -1, 5, 2); // cross-ledger: entries 0..2 in ledger 5 + assertEquals(set.totalCardinality(), 13); + + // Overlapping add contributes nothing new. + set.addOpenClosed(1, -1, 1, 9); + assertEquals(set.totalCardinality(), 13); + + // Cross-check against the range-based cardinality computation. + assertEquals(set.cardinality(0, -1, 1_000_000, 2_000_000_000), set.totalCardinality()); + + set.removeAtMost(1, 4); // drop ledger-1 entries 0..4 + assertEquals(set.totalCardinality(), 8); + assertEquals(set.cardinality(0, -1, 1_000_000, 2_000_000_000), set.totalCardinality()); + + set.remove(Range.atMost(pos(1, 9))); // drop the rest of ledger 1 + assertEquals(set.totalCardinality(), 3); + assertEquals(set.cardinality(0, -1, 1_000_000, 2_000_000_000), set.totalCardinality()); + + set.clear(); + assertEquals(set.totalCardinality(), 0); + } + + @Test + public void testToRangesReportsTruncation() { + PositionRangeSet set = newSet(); + // ledger 1: 10 positions, ledger 2: 10 positions, ledger 3: 10 positions. + set.addOpenClosed(1, -1, 1, 9); + set.addOpenClosed(2, -1, 2, 9); + set.addOpenClosed(3, -1, 3, 9); + assertEquals(set.totalCardinality(), 30); + + // Cap that fits everything: no truncation reported. + int[] skipped = {0}; + Map all = set.toRanges(30, count -> skipped[0] = count); + assertEquals(all.size(), 3); + assertEquals(skipped[0], 0); + + // Cap that fits only two ledgers (cumulative cardinality 20): third ledger skipped + // and the notifier reports it exactly once. + skipped[0] = 0; + Map truncated = set.toRanges(20, count -> skipped[0] = count); + assertThat(truncated).containsOnlyKeys(1L, 2L); + assertEquals(skipped[0], 1); + + // Legacy overload behaves the same without a notifier. + Map legacy = set.toRanges(20); + assertThat(legacy).containsOnlyKeys(1L, 2L); + } + + @Test + public void testDirtyLedger() { + PositionRangeSet rangeSet = newSet(); + rangeSet.addOpenClosed(10, 0, 20, 0); + assertEquals(rangeSet.size(), 1); + // addOpenClosed(10,0,20,0) marks ledgers in (10, 20] dirty per the original + // dirtyLedgers.addOpenClosed(k,0,k',0) LongPair-ordering semantics — ledger 10 is open-lower + // and therefore not dirty. + assertFalse(rangeSet.isDirtyLedgers(10L)); + for (long i = 11; i <= 20; i++) { + assertTrue(rangeSet.isDirtyLedgers(i)); + } + + rangeSet.removeAtMost(11, 0); + assertEquals(rangeSet.size(), 1); + assertFalse(rangeSet.isDirtyLedgers(11L)); + for (long i = 12; i <= 20; i++) { + assertTrue(rangeSet.isDirtyLedgers(i)); + } + } + + @Test + public void testDirtyLedgerDisabledWhenMultiEntryOff() { + PositionRangeSet rangeSet = new PositionRangeSet(CONSUMER, false); + rangeSet.addOpenClosed(10, 0, 20, 0); + for (long i = 0; i <= 20; i++) { + assertFalse(rangeSet.isDirtyLedgers(i)); + } + } + + @Test + public void testAddForSameKey() { + PositionRangeSet set = newSet(); + set.addOpenClosed(0, 0, 0, 5); + set.addOpenClosed(0, 8, 0, 8); + set.addOpenClosed(0, 9, 0, 9); + set.addOpenClosed(0, 10, 0, 10); + set.addOpenClosed(0, 98, 0, 99); + set.addOpenClosed(0, 102, 0, 106); + + List> ranges = new ArrayList<>(set.asRanges()); + int count = 0; + assertEquals(ranges.get(count++), Range.openClosed(pos(0, 0), pos(0, 5))); + assertEquals(ranges.get(count++), Range.openClosed(pos(0, 98), pos(0, 99))); + assertEquals(ranges.get(count), Range.openClosed(pos(0, 102), pos(0, 106))); + } + + @Test + public void testAddForDifferentKey() { + PositionRangeSet set = newSet(); + set.addOpenClosed(0, 98, 0, 99); + set.addOpenClosed(0, 100, 1, 5); + set.addOpenClosed(1, 10, 1, 15); + set.addOpenClosed(1, 20, 2, 10); + + // bitmap-mode normalization: cross-ledger addOpenClosed into a ledger that did not previously + // exist reports the lower endpoint as (upper, -1) because the bitmap starts at index 0 + // (= entry 0) with the open lower bound at -1. + List> ranges = new ArrayList<>(set.asRanges()); + int count = 0; + assertEquals(ranges.get(count++), Range.openClosed(pos(0, 98), pos(0, 99))); + assertEquals(ranges.get(count++), Range.openClosed(pos(1, -1), pos(1, 5))); + assertEquals(ranges.get(count++), Range.openClosed(pos(1, 10), pos(1, 15))); + assertEquals(ranges.get(count), Range.openClosed(pos(2, -1), pos(2, 10))); + } + + @Test + public void testAddCompareCompareWithGuava() { + PositionRangeSet set = newSet(); + RangeSet gSet = TreeRangeSet.create(); + + int totalInsert = 10_000; + for (int i = 0; i < totalInsert; i++) { + if (i % 3 == 0 || i % 6 == 0 || i % 8 == 0) { + Position lower = pos(0, i - 1); + Position upper = pos(0, i); + set.addOpenClosed(lower.getLedgerId(), lower.getEntryId(), upper.getLedgerId(), upper.getEntryId()); + gSet.add(Range.openClosed(lower, upper)); + } + } + for (int i = totalInsert; i < (totalInsert * 2); i++) { + if (i % 5 == 0) { + Position lower = pos(0, i - 3 - 1); + Position upper = pos(0, i + 3); + set.addOpenClosed(lower.getLedgerId(), lower.getEntryId(), upper.getLedgerId(), upper.getEntryId()); + gSet.add(Range.openClosed(lower, upper)); + } + } + List> ranges = new ArrayList<>(set.asRanges()); + Set> gRanges = gSet.asRanges(); + + List> gRangeConnected = getConnectedRange(gRanges); + assertEquals(gRangeConnected.size(), ranges.size()); + int i = 0; + for (Range range : gRangeConnected) { + assertEquals(range, ranges.get(i)); + i++; + } + } + + @Test + public void testDeleteCompareWithGuava() throws Exception { + PositionRangeSet set = newSet(); + RangeSet gSet = TreeRangeSet.create(); + + int totalInsert = 10_000; + List> removedRanges = new ArrayList<>(); + for (int i = 0; i < totalInsert; i++) { + if (i % 3 == 0 || i % 7 == 0 || i % 11 == 0) { + continue; + } + Position lower = pos(0, i - 1); + Position upper = pos(0, i); + Range range = Range.openClosed(lower, upper); + set.addOpenClosed(lower.getLedgerId(), lower.getEntryId(), upper.getLedgerId(), upper.getEntryId()); + gSet.add(range); + if (i % 4 == 0) { + removedRanges.add(range); + } + } + for (int i = totalInsert; i < (totalInsert * 2); i++) { + Position lower = pos(0, i - 3 - 1); + Position upper = pos(0, i + 3); + Range range = Range.openClosed(lower, upper); + if (i % 5 != 0) { + set.addOpenClosed(lower.getLedgerId(), lower.getEntryId(), upper.getLedgerId(), upper.getEntryId()); + gSet.add(range); + } + if (i % 4 == 0) { + removedRanges.add(range); + } + } + // remove records + for (Range range : removedRanges) { + set.remove(range); + gSet.remove(range); + } + + List> ranges = new ArrayList<>(set.asRanges()); + Set> gRanges = gSet.asRanges(); + List> gRangeConnected = getConnectedRange(gRanges); + assertEquals(gRangeConnected.size(), ranges.size()); + int i = 0; + for (Range range : gRangeConnected) { + assertEquals(range, ranges.get(i)); + i++; + } + } + + @Test + public void testSpanWithGuava() { + PositionRangeSet set = newSet(); + RangeSet gSet = TreeRangeSet.create(); + set.addOpenClosed(0, 97, 0, 99); + gSet.add(Range.openClosed(pos(0, 97), pos(0, 99))); + set.addOpenClosed(0, 99, 1, 5); + gSet.add(Range.openClosed(pos(0, 99), pos(1, 5))); + assertEquals(set.span(), gSet.span()); + assertEquals(set.span(), Range.openClosed(pos(0, 97), pos(1, 5))); + + set.addOpenClosed(1, 9, 1, 15); + set.addOpenClosed(1, 19, 2, 10); + set.addOpenClosed(2, 24, 2, 28); + set.addOpenClosed(3, 11, 3, 20); + set.addOpenClosed(4, 11, 4, 20); + gSet.add(Range.openClosed(pos(1, 9), pos(1, 15))); + gSet.add(Range.openClosed(pos(1, 19), pos(2, 10))); + gSet.add(Range.openClosed(pos(2, 24), pos(2, 28))); + gSet.add(Range.openClosed(pos(3, 11), pos(3, 20))); + gSet.add(Range.openClosed(pos(4, 11), pos(4, 20))); + assertEquals(set.span(), gSet.span()); + assertEquals(set.span(), Range.openClosed(pos(0, 97), pos(4, 20))); + } + + @Test + public void testFirstRange() { + PositionRangeSet set = newSet(); + assertNull(set.firstRange()); + set.addOpenClosed(0, 97, 0, 99); + assertEquals(set.firstRange(), Range.openClosed(pos(0, 97), pos(0, 99))); + assertEquals(set.size(), 1); + set.addOpenClosed(0, 98, 0, 105); + assertEquals(set.firstRange(), Range.openClosed(pos(0, 97), pos(0, 105))); + assertEquals(set.size(), 1); + set.addOpenClosed(0, 5, 0, 75); + assertEquals(set.firstRange(), Range.openClosed(pos(0, 5), pos(0, 75))); + assertEquals(set.size(), 2); + } + + @Test + public void testLastRange() { + PositionRangeSet set = newSet(); + assertNull(set.lastRange()); + Range range = Range.openClosed(pos(0, 97), pos(0, 99)); + set.addOpenClosed(0, 97, 0, 99); + assertEquals(set.lastRange(), range); + assertEquals(set.size(), 1); + set.addOpenClosed(0, 98, 0, 105); + assertEquals(set.lastRange(), Range.openClosed(pos(0, 97), pos(0, 105))); + assertEquals(set.size(), 1); + range = Range.openClosed(pos(1, 5), pos(1, 75)); + set.addOpenClosed(1, 5, 1, 75); + assertEquals(set.lastRange(), range); + assertEquals(set.size(), 2); + range = Range.openClosed(pos(1, 80), pos(1, 120)); + set.addOpenClosed(1, 80, 1, 120); + assertEquals(set.lastRange(), range); + assertEquals(set.size(), 3); + } + + @Test + public void testToString() { + PositionRangeSet set = newSet(); + set.addOpenClosed(0, 97, 0, 99); + assertEquals(set.toString(), "[(0:97..0:99]]"); + set.addOpenClosed(0, 98, 0, 105); + assertEquals(set.toString(), "[(0:97..0:105]]"); + set.addOpenClosed(0, 5, 0, 75); + assertEquals(set.toString(), "[(0:5..0:75],(0:97..0:105]]"); + } + + @Test + public void testDeleteForDifferentKey() { + PositionRangeSet set = newSet(); + set.addOpenClosed(0, 97, 0, 99); + set.addOpenClosed(0, 99, 1, 5); + set.addOpenClosed(1, 9, 1, 15); + set.addOpenClosed(1, 19, 2, 10); + set.addOpenClosed(2, 24, 2, 28); + set.addOpenClosed(3, 11, 3, 20); + set.addOpenClosed(4, 11, 4, 20); + + // delete only (0,100) + set.remove(Range.open(pos(0, 99), pos(0, 105))); + + /** + * delete all keys from [2,27]->[4,15] : remaining [2,25..26,28], [4,16..20] + */ + set.remove(Range.closed(pos(2, 27), pos(4, 15))); + + List> ranges = new ArrayList<>(set.asRanges()); + int count = 0; + assertEquals(ranges.get(count++), Range.openClosed(pos(0, 97), pos(0, 99))); + assertEquals(ranges.get(count++), Range.openClosed(pos(1, -1), pos(1, 5))); + assertEquals(ranges.get(count++), Range.openClosed(pos(1, 9), pos(1, 15))); + assertEquals(ranges.get(count++), Range.openClosed(pos(2, -1), pos(2, 10))); + + assertEquals(ranges.get(count++), Range.openClosed(pos(2, 24), pos(2, 26))); + assertEquals(ranges.get(count++), Range.openClosed(pos(2, 27), pos(2, 28))); + assertEquals(ranges.get(count++), Range.openClosed(pos(4, 15), pos(4, 20))); + } + + @Test + public void testDeleteWithAtMost() { + PositionRangeSet set = newSet(); + set.addOpenClosed(0, 98, 0, 99); + set.addOpenClosed(0, 100, 1, 5); + set.addOpenClosed(1, 10, 1, 15); + set.addOpenClosed(1, 20, 2, 10); + set.addOpenClosed(2, 25, 2, 28); + set.addOpenClosed(3, 12, 3, 20); + set.addOpenClosed(4, 12, 4, 20); + + // delete only (0,100) + set.remove(Range.open(pos(0, 99), pos(0, 105))); + + /** + * delete all keys from [2,27]->[4,15] : remaining [2,25..26,28], [4,16..20] + */ + set.remove(Range.atMost(pos(2, 27))); + + List> ranges = new ArrayList<>(set.asRanges()); + int count = 0; + assertEquals(ranges.get(count++), Range.openClosed(pos(2, 27), pos(2, 28))); + assertEquals(ranges.get(count++), Range.openClosed(pos(3, 12), pos(3, 20))); + assertEquals(ranges.get(count++), Range.openClosed(pos(4, 12), pos(4, 20))); + } + + @Test + public void testDeleteWithAtMost2() { + // Originally this test ran twice — once with openCacheSet=true and once with =false. + // The =false (DefaultRangeSet) variant is dropped because PositionRangeSet has a single + // bitmap-backed implementation. The remaining assertions reproduce the bitmap-mode behavior. + PositionRangeSet set = newSet(); + set.addOpenClosed(0, 98, 0, 99); + set.addOpenClosed(0, 100, 1, 5); + set.addOpenClosed(1, 10, 1, 15); + set.addOpenClosed(1, 20, 2, 10); + set.addOpenClosed(2, 25, 2, 28); + set.addOpenClosed(3, 12, 3, 20); + set.addOpenClosed(4, 12, 4, 20); + + // delete entire ledger 0 (closed-closed within the same ledger id) + set.remove(Range.closed(pos(0, 0), pos(0, Integer.MAX_VALUE - 1))); + + List> ranges = new ArrayList<>(set.asRanges()); + int count = 0; + assertEquals(ranges.get(count++), Range.openClosed(pos(1, -1), pos(1, 5))); + assertEquals(ranges.get(count++), Range.openClosed(pos(1, 10), pos(1, 15))); + assertEquals(ranges.get(count++), Range.openClosed(pos(2, -1), pos(2, 10))); + assertEquals(ranges.get(count), Range.openClosed(pos(2, 25), pos(2, 28))); + } + + @Test + public void testDeleteWithLeastMost() { + PositionRangeSet set = newSet(); + set.addOpenClosed(0, 98, 0, 99); + set.addOpenClosed(0, 100, 1, 5); + set.addOpenClosed(1, 10, 1, 15); + set.addOpenClosed(1, 20, 2, 10); + set.addOpenClosed(2, 25, 2, 28); + set.addOpenClosed(2, 12, 3, 20); + set.addOpenClosed(4, 12, 4, 20); + + // delete only (0,100) + set.remove(Range.open(pos(0, 99), pos(0, 105))); + + /** + * delete all keys from [2,27]->[4,15] : remaining [2,25..26,28], [4,16..20] + */ + set.remove(Range.atLeast(pos(2, 27))); + + List> ranges = new ArrayList<>(set.asRanges()); + int count = 0; + assertEquals(ranges.get(count++), Range.openClosed(pos(0, 98), pos(0, 99))); + assertEquals(ranges.get(count++), Range.openClosed(pos(1, -1), pos(1, 5))); + assertEquals(ranges.get(count++), Range.openClosed(pos(1, 10), pos(1, 15))); + assertEquals(ranges.get(count++), Range.openClosed(pos(2, -1), pos(2, 10))); + assertEquals(ranges.get(count), Range.openClosed(pos(2, 12), pos(2, 26))); + } + + @Test + public void testRangeContaining() { + PositionRangeSet set = newSet(); + set.add(Range.closed(pos(0, 98), pos(0, 99))); + set.add(Range.closed(pos(0, 100), pos(1, 5))); + RangeSet gSet = TreeRangeSet.create(); + gSet.add(Range.closed(pos(0, 98), pos(0, 100))); + gSet.add(Range.closed(pos(0, 101), pos(1, 5))); + set.add(Range.closed(pos(1, 10), pos(1, 15))); + set.add(Range.closed(pos(1, 20), pos(2, 10))); + set.add(Range.closed(pos(2, 25), pos(2, 28))); + set.add(Range.closed(pos(3, 12), pos(3, 20))); + set.add(Range.closed(pos(4, 12), pos(4, 20))); + gSet.add(Range.closed(pos(1, 10), pos(1, 15))); + gSet.add(Range.closed(pos(1, 20), pos(2, 10))); + gSet.add(Range.closed(pos(2, 25), pos(2, 28))); + gSet.add(Range.closed(pos(3, 12), pos(3, 20))); + gSet.add(Range.closed(pos(4, 12), pos(4, 20))); + + Position position = pos(0, 99); + assertEquals(set.rangeContaining(position.getLedgerId(), position.getEntryId()), + Range.closed(pos(0, 98), pos(0, 100))); + assertEquals(set.rangeContaining(position.getLedgerId(), position.getEntryId()), + gSet.rangeContaining(position)); + + position = pos(2, 30); + assertNull(set.rangeContaining(position.getLedgerId(), position.getEntryId())); + assertEquals(set.rangeContaining(position.getLedgerId(), position.getEntryId()), + gSet.rangeContaining(position)); + + position = pos(3, 13); + assertEquals(set.rangeContaining(position.getLedgerId(), position.getEntryId()), + Range.closed(pos(3, 12), pos(3, 20))); + assertEquals(set.rangeContaining(position.getLedgerId(), position.getEntryId()), + gSet.rangeContaining(position)); + + position = pos(3, 22); + assertNull(set.rangeContaining(position.getLedgerId(), position.getEntryId())); + assertEquals(set.rangeContaining(position.getLedgerId(), position.getEntryId()), + gSet.rangeContaining(position)); + } + + @Test + public void testWireFormatRoundTrip() { + // Verify the dense-long[] contract used by ManagedCursorImpl persistence: serialize via + // toRanges, deserialize via build, and confirm the resulting range set is identical. + PositionRangeSet original = newSet(); + original.addOpenClosed(0, 0, 0, 5); + original.addOpenClosed(0, 10, 0, 100); + original.addOpenClosed(1, 0, 1, 50); + original.addOpenClosed(3, 200, 3, 300); + + Map serialized = original.toRanges(Integer.MAX_VALUE); + PositionRangeSet restored = newSet(); + restored.build(serialized); + + assertEquals(restored.asRanges(), original.asRanges()); + assertEquals(restored.cardinality(0, 0, 3, 300), original.cardinality(0, 0, 3, 300)); + for (Range r : original.asRanges()) { + assertTrue(restored.contains(r.lowerEndpoint().getLedgerId(), r.lowerEndpoint().getEntryId() + 1)); + } + } + + @Test + public void testCompatibilityWithLegacyOpenLongPairRangeSet() { + // Simulate 4.x cursor persistence: serialize individual deleted messages using the legacy + // OpenLongPairRangeSet (still present in pulsar-common) backed by RoaringBitSet, then + // deserialize via PositionRangeSet. This proves on-disk byte compatibility — a cursor + // persisted by 4.x can be recovered by 5.0 without conversion. + OpenLongPairRangeSet legacy = new OpenLongPairRangeSet<>( + PositionFactory::create, RoaringBitSet::new); + legacy.addOpenClosed(0, -1, 0, 9); + legacy.addOpenClosed(0, 50, 0, 99); + legacy.addOpenClosed(1, 10, 1, 20); + legacy.addOpenClosed(3, 0, 4, 5); + + Map legacySerialized = legacy.toRanges(Integer.MAX_VALUE); + PositionRangeSet restored = newSet(); + restored.build(legacySerialized); + + assertEquals(restored.asRanges(), legacy.asRanges()); + assertEquals(restored.size(), legacy.size()); + assertEquals(restored.cardinality(0, 0, 4, 5), legacy.cardinality(0, 0, 4, 5)); + } + + @Test + public void testCardinality() { + PositionRangeSet set = newSet(); + // half-open lower: addOpenClosed(k, -1, k, N) sets entries 0..N inclusive. + set.addOpenClosed(0, -1, 0, 9); // 10 entries: 0..9 + set.addOpenClosed(1, -1, 1, 19); // 20 entries: 0..19 + set.addOpenClosed(2, -1, 2, 4); // 5 entries: 0..4 + + // full span — cardinality bounds are inclusive on both ends + assertEquals(set.cardinality(0, 0, 2, 4), 35); + // partial ledger 0 + assertEquals(set.cardinality(0, 3, 0, 7), 5); + // partial across ledgers 0 and 1 + assertEquals(set.cardinality(0, 5, 1, 5), 5 + 6); + // single ledger interior + assertEquals(set.cardinality(1, 5, 1, 14), 10); + } + + @Test + public void testClear() { + PositionRangeSet set = newSet(); + // cross-ledger adds mark dirty (single-ledger adds do not, matching the original + // RangeSetWrapper/DefaultRangeSet semantics where addOpenClosed(k,0,k,0) is an empty range). + set.addOpenClosed(0, 5, 1, 5); + set.addOpenClosed(2, 5, 3, 5); + assertFalse(set.isEmpty()); + assertEquals(set.size(), 2); + assertTrue(set.isDirtyLedgers(1)); + assertTrue(set.isDirtyLedgers(3)); + set.clear(); + assertTrue(set.isEmpty()); + assertEquals(set.size(), 0); + // clear also resets dirty tracker + assertFalse(set.isDirtyLedgers(1)); + assertFalse(set.isDirtyLedgers(3)); + } + + @Test + public void testRemoveAtMostClearsEmptyLedgers() { + // Cross-ledger removeAtMost must drop now-empty ledgers from the underlying TreeMap so + // the structure does not accumulate stale empty bitmaps over the cursor's lifetime. + PositionRangeSet set = newSet(); + set.addOpenClosed(0, -1, 0, 9); // ledger 0: entries 0..9 + set.addOpenClosed(1, -1, 1, 9); // ledger 1: entries 0..9 + set.addOpenClosed(2, -1, 2, 9); // ledger 2: entries 0..9 + + // removeAtMost(2, -1) deletes ledgers 0 and 1 wholesale (positions <= (2, -1)) and leaves + // ledger 2 untouched (no entry <= -1 in ledger 2). + set.removeAtMost(2, -1); + + // Only ledger 2 should remain in the underlying map. + List> ranges = new ArrayList<>(set.asRanges()); + assertEquals(ranges.size(), 1); + assertEquals(ranges.get(0), Range.openClosed(pos(2, -1), pos(2, 9))); + // The two cleared ledgers must not linger as empty bitmaps — verify via forEachRawRange, + // which would otherwise skip them silently and hide the leak. + MutableInt ledgerCount = new MutableInt(0); + set.forEachRawRange((lowerKey, lowerValue, upperKey, upperValue) -> { + ledgerCount.increment(); + return true; + }); + assertEquals(ledgerCount.intValue(), 1); + } + + + private List> getConnectedRange(Set> gRanges) { + List> gRangeConnected = new ArrayList<>(); + Range lastRange = null; + for (Range range : gRanges) { + if (lastRange == null) { + lastRange = range; + continue; + } + Position previousUpper = lastRange.upperEndpoint(); + Position currentLower = range.lowerEndpoint(); + int previousUpperValue = (int) (lastRange.upperBoundType().equals(BoundType.CLOSED) + ? previousUpper.getEntryId() + : previousUpper.getEntryId() - 1); + int currentLowerValue = (int) (range.lowerBoundType().equals(BoundType.CLOSED) + ? currentLower.getEntryId() + : currentLower.getEntryId() + 1); + boolean connected = previousUpper.getLedgerId() == currentLower.getLedgerId() + && (previousUpperValue >= currentLowerValue); + if (connected) { + lastRange = Range.closed(lastRange.lowerEndpoint(), range.upperEndpoint()); + } else { + gRangeConnected.add(lastRange); + lastRange = range; + } + } + if (lastRange != null) { + int lowerOpenValue = (int) (lastRange.lowerBoundType().equals(BoundType.CLOSED) + ? (lastRange.lowerEndpoint().getEntryId() - 1) + : lastRange.lowerEndpoint().getEntryId()); + lastRange = Range.openClosed(pos(lastRange.lowerEndpoint().getLedgerId(), lowerOpenValue), + lastRange.upperEndpoint()); + gRangeConnected.add(lastRange); + } + return gRangeConnected; + } +} diff --git a/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/RangeSetWrapperTest.java b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/RangeSetWrapperTest.java deleted file mode 100644 index 9012945ddf7c1..0000000000000 --- a/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/impl/RangeSetWrapperTest.java +++ /dev/null @@ -1,516 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.bookkeeper.mledger.impl; - -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; -import static org.testng.Assert.assertEquals; -import static org.testng.Assert.assertFalse; -import static org.testng.Assert.assertNull; -import static org.testng.Assert.assertTrue; -import com.google.common.collect.BoundType; -import com.google.common.collect.Range; -import com.google.common.collect.TreeRangeSet; -import java.util.ArrayList; -import java.util.List; -import java.util.Set; -import org.apache.bookkeeper.mledger.ManagedLedgerConfig; -import org.apache.pulsar.common.util.collections.LongPairRangeSet.LongPair; -import org.apache.pulsar.common.util.collections.LongPairRangeSet.LongPairConsumer; -import org.apache.pulsar.common.util.collections.LongPairRangeSet.RangeBoundConsumer; -import org.testng.annotations.AfterMethod; -import org.testng.annotations.BeforeMethod; -import org.testng.annotations.Test; - -public class RangeSetWrapperTest { - - static final LongPairConsumer CONSUMER = (key, value) -> new LongPair(key, value); - static final RangeBoundConsumer REVERSE_CONVERT = (pair) -> pair; - - ManagedLedgerImpl managedLedger; - RangeSetWrapper set; - ManagedLedgerConfig managedLedgerConfig; - ManagedCursorImpl managedCursor; - - @BeforeMethod - public void setUp() { - initManagedLedgerConfig(); - managedLedger = mock(ManagedLedgerImpl.class); - managedCursor = mock(ManagedCursorImpl.class); - doReturn(managedLedgerConfig).when(managedLedger).getConfig(); - doReturn(managedLedgerConfig).when(managedCursor).getConfig(); - doReturn(managedLedger).when(managedCursor).getManagedLedger(); - } - - private void initManagedLedgerConfig() { - managedLedgerConfig = new ManagedLedgerConfig(); - managedLedgerConfig.setUnackedRangesOpenCacheSetEnabled(true); - managedLedgerConfig.setPersistentUnackedRangesWithMultipleEntriesEnabled(true); - } - - @AfterMethod - public void clean() throws Exception { - } - - @Test - public void testDirtyLedger() { - RangeSetWrapper rangeSetWrapper = new RangeSetWrapper<>(CONSUMER, - REVERSE_CONVERT, - managedCursor); - // Test add range - rangeSetWrapper.addOpenClosed(10, 0, 20, 0); - assertEquals(rangeSetWrapper.size(), 1); - assertFalse(rangeSetWrapper.isDirtyLedgers(10L)); - for (long i = 11; i < 20; i++) { - assertTrue(rangeSetWrapper.isDirtyLedgers(i)); - } - - // Test remove range - rangeSetWrapper.removeAtMost(11, 0); - assertEquals(rangeSetWrapper.size(), 1); - assertFalse(rangeSetWrapper.isDirtyLedgers(11L)); - for (long i = 12; i < 20; i++) { - assertTrue(rangeSetWrapper.isDirtyLedgers(i)); - } - } - - @Test - public void testAddForSameKey() { - doTestAddForSameKey(); - managedLedgerConfig.setUnackedRangesOpenCacheSetEnabled(false); - doTestAddForSameKey(); - } - - private void doTestAddForSameKey() { - set = new RangeSetWrapper(CONSUMER, REVERSE_CONVERT, managedCursor); - // add 0 to 5 - set.addOpenClosed(0, 0, 0, 5); - // add 8,9,10 - set.addOpenClosed(0, 8, 0, 8); - set.addOpenClosed(0, 9, 0, 9); - set.addOpenClosed(0, 10, 0, 10); - // add 98 to 99 and 102,105 - set.addOpenClosed(0, 98, 0, 99); - set.addOpenClosed(0, 102, 0, 106); - - List> ranges = new ArrayList<>(set.asRanges()); - int count = 0; - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(0, 0), new LongPair(0, 5)))); - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(0, 98), new LongPair(0, 99)))); - assertEquals(ranges.get(count), (Range.openClosed(new LongPair(0, 102), new LongPair(0, 106)))); - } - - @Test - public void testAddForDifferentKey() { - set = new RangeSetWrapper<>(CONSUMER, REVERSE_CONVERT, managedCursor); - // [98,100],[(1,5),(1,5)],[(1,10,1,15)],[(1,20),(1,20)],[(2,0),(2,10)] - set.addOpenClosed(0, 98, 0, 99); - set.addOpenClosed(0, 100, 1, 5); - set.addOpenClosed(1, 10, 1, 15); - set.addOpenClosed(1, 20, 2, 10); - - List> ranges = new ArrayList<>(set.asRanges()); - int count = 0; - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(0, 98), new LongPair(0, 99)))); - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(1, -1), new LongPair(1, 5)))); - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(1, 10), new LongPair(1, 15)))); - assertEquals(ranges.get(count), (Range.openClosed(new LongPair(2, -1), new LongPair(2, 10)))); - } - - @Test - public void testAddForDifferentKey2() { - managedLedgerConfig.setUnackedRangesOpenCacheSetEnabled(false); - set = new RangeSetWrapper<>(CONSUMER, REVERSE_CONVERT, managedCursor); - // [98,100],[(1,5),(1,5)],[(1,10,1,15)],[(1,20),(1,20)],[(2,0),(2,10)] - set.addOpenClosed(0, 98, 0, 99); - set.addOpenClosed(0, 100, 1, 5); - set.addOpenClosed(1, 10, 1, 15); - set.addOpenClosed(1, 20, 2, 10); - - List> ranges = new ArrayList<>(set.asRanges()); - int count = 0; - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(0, 98), new LongPair(0, 99)))); - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(0, 100), new LongPair(1, 5)))); - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(1, 10), new LongPair(1, 15)))); - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(1, 20), new LongPair(2, 10)))); - } - - @Test - public void testAddCompareCompareWithGuava() { - set = new RangeSetWrapper<>(CONSUMER, REVERSE_CONVERT, managedCursor); - com.google.common.collect.RangeSet gSet = TreeRangeSet.create(); - - // add 10K values for key 0 - int totalInsert = 10_000; - // add single values - for (int i = 0; i < totalInsert; i++) { - if (i % 3 == 0 || i % 6 == 0 || i % 8 == 0) { - LongPair lower = new LongPair(0, i - 1); - LongPair upper = new LongPair(0, i); - // set.add(Range.openClosed(lower, upper)); - set.addOpenClosed(lower.getKey(), lower.getValue(), upper.getKey(), upper.getValue()); - gSet.add(Range.openClosed(lower, upper)); - } - } - // add batches - for (int i = totalInsert; i < (totalInsert * 2); i++) { - if (i % 5 == 0) { - LongPair lower = new LongPair(0, i - 3 - 1); - LongPair upper = new LongPair(0, i + 3); - // set.add(Range.openClosed(lower, upper)); - set.addOpenClosed(lower.getKey(), lower.getValue(), upper.getKey(), upper.getValue()); - gSet.add(Range.openClosed(lower, upper)); - } - } - List> ranges = new ArrayList<>(set.asRanges()); - Set> gRanges = gSet.asRanges(); - - List> gRangeConnected = getConnectedRange(gRanges); - assertEquals(gRangeConnected.size(), ranges.size()); - int i = 0; - for (Range range : gRangeConnected) { - assertEquals(range, ranges.get(i)); - i++; - } - } - - @Test - public void testDeleteCompareWithGuava() throws Exception { - RangeSetWrapper set = new RangeSetWrapper<>(CONSUMER, REVERSE_CONVERT, managedCursor); - com.google.common.collect.RangeSet gSet = TreeRangeSet.create(); - - // add 10K values for key 0 - int totalInsert = 10_000; - // add single values - List> removedRanges = new ArrayList(); - for (int i = 0; i < totalInsert; i++) { - if (i % 3 == 0 || i % 7 == 0 || i % 11 == 0) { - continue; - } - LongPair lower = new LongPair(0, i - 1); - LongPair upper = new LongPair(0, i); - Range range = Range.openClosed(lower, upper); - // set.add(range); - set.addOpenClosed(lower.getKey(), lower.getValue(), upper.getKey(), upper.getValue()); - gSet.add(range); - if (i % 4 == 0) { - removedRanges.add(range); - } - } - // add batches - for (int i = totalInsert; i < (totalInsert * 2); i++) { - LongPair lower = new LongPair(0, i - 3 - 1); - LongPair upper = new LongPair(0, i + 3); - Range range = Range.openClosed(lower, upper); - if (i % 5 != 0) { - // set.add(range); - set.addOpenClosed(lower.getKey(), lower.getValue(), upper.getKey(), upper.getValue()); - gSet.add(range); - } - if (i % 4 == 0) { - removedRanges.add(range); - } - } - // remove records - for (Range range : removedRanges) { - set.remove(range); - gSet.remove(range); - } - - List> ranges = new ArrayList<>(set.asRanges()); - Set> gRanges = gSet.asRanges(); - List> gRangeConnected = getConnectedRange(gRanges); - assertEquals(gRangeConnected.size(), ranges.size()); - int i = 0; - for (Range range : gRangeConnected) { - assertEquals(range, ranges.get(i)); - i++; - } - } - - @Test - public void testSpanWithGuava() { - set = new RangeSetWrapper<>(CONSUMER, REVERSE_CONVERT, managedCursor); - com.google.common.collect.RangeSet gSet = TreeRangeSet.create(); - set.addOpenClosed(0, 97, 0, 99); - gSet.add(Range.openClosed(new LongPair(0, 97), new LongPair(0, 99))); - set.addOpenClosed(0, 99, 1, 5); - gSet.add(Range.openClosed(new LongPair(0, 99), new LongPair(1, 5))); - assertEquals(set.span(), gSet.span()); - assertEquals(set.span(), Range.openClosed(new LongPair(0, 97), new LongPair(1, 5))); - - set.addOpenClosed(1, 9, 1, 15); - set.addOpenClosed(1, 19, 2, 10); - set.addOpenClosed(2, 24, 2, 28); - set.addOpenClosed(3, 11, 3, 20); - set.addOpenClosed(4, 11, 4, 20); - gSet.add(Range.openClosed(new LongPair(1, 9), new LongPair(1, 15))); - gSet.add(Range.openClosed(new LongPair(1, 19), new LongPair(2, 10))); - gSet.add(Range.openClosed(new LongPair(2, 24), new LongPair(2, 28))); - gSet.add(Range.openClosed(new LongPair(3, 11), new LongPair(3, 20))); - gSet.add(Range.openClosed(new LongPair(4, 11), new LongPair(4, 20))); - assertEquals(set.span(), gSet.span()); - assertEquals(set.span(), Range.openClosed(new LongPair(0, 97), new LongPair(4, 20))); - } - - @Test - public void testFirstRange() { - set = new RangeSetWrapper<>(CONSUMER, REVERSE_CONVERT, managedCursor); - assertNull(set.firstRange()); - set.addOpenClosed(0, 97, 0, 99); - assertEquals(set.firstRange(), Range.openClosed(new LongPair(0, 97), new LongPair(0, 99))); - assertEquals(set.size(), 1); - set.addOpenClosed(0, 98, 0, 105); - assertEquals(set.firstRange(), Range.openClosed(new LongPair(0, 97), new LongPair(0, 105))); - assertEquals(set.size(), 1); - set.addOpenClosed(0, 5, 0, 75); - assertEquals(set.firstRange(), Range.openClosed(new LongPair(0, 5), new LongPair(0, 75))); - assertEquals(set.size(), 2); - } - - @Test - public void testLastRange() { - set = new RangeSetWrapper<>(CONSUMER, REVERSE_CONVERT, managedCursor); - assertNull(set.lastRange()); - Range range = Range.openClosed(new LongPair(0, 97), new LongPair(0, 99)); - set.addOpenClosed(0, 97, 0, 99); - assertEquals(set.lastRange(), range); - assertEquals(set.size(), 1); - set.addOpenClosed(0, 98, 0, 105); - assertEquals(set.lastRange(), Range.openClosed(new LongPair(0, 97), new LongPair(0, 105))); - assertEquals(set.size(), 1); - range = Range.openClosed(new LongPair(1, 5), new LongPair(1, 75)); - set.addOpenClosed(1, 5, 1, 75); - assertEquals(set.lastRange(), range); - assertEquals(set.size(), 2); - range = Range.openClosed(new LongPair(1, 80), new LongPair(1, 120)); - set.addOpenClosed(1, 80, 1, 120); - assertEquals(set.lastRange(), range); - assertEquals(set.size(), 3); - } - - @Test - public void testToString() { - set = new RangeSetWrapper<>(CONSUMER, REVERSE_CONVERT, managedCursor); - set.addOpenClosed(0, 97, 0, 99); - assertEquals(set.toString(), "[(0:97..0:99]]"); - set.addOpenClosed(0, 98, 0, 105); - assertEquals(set.toString(), "[(0:97..0:105]]"); - set.addOpenClosed(0, 5, 0, 75); - assertEquals(set.toString(), "[(0:5..0:75],(0:97..0:105]]"); - } - - @Test - public void testDeleteForDifferentKey() { - set = new RangeSetWrapper<>(CONSUMER, REVERSE_CONVERT, managedCursor); - set.addOpenClosed(0, 97, 0, 99); - set.addOpenClosed(0, 99, 1, 5); - set.addOpenClosed(1, 9, 1, 15); - set.addOpenClosed(1, 19, 2, 10); - set.addOpenClosed(2, 24, 2, 28); - set.addOpenClosed(3, 11, 3, 20); - set.addOpenClosed(4, 11, 4, 20); - - // delete only (0,100) - set.remove(Range.open(new LongPair(0, 99), new LongPair(0, 105))); - - /** - * delete all keys from [2,27]->[4,15] : remaining [2,25..26,28], [4,16..20] - */ - set.remove(Range.closed(new LongPair(2, 27), new LongPair(4, 15))); - - List> ranges = new ArrayList<>(set.asRanges()); - int count = 0; - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(0, 97), new LongPair(0, 99)))); - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(1, -1), new LongPair(1, 5)))); - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(1, 9), new LongPair(1, 15)))); - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(2, -1), new LongPair(2, 10)))); - - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(2, 24), new LongPair(2, 26)))); - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(2, 27), new LongPair(2, 28)))); - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(4, 15), new LongPair(4, 20)))); - } - - @Test - public void testDeleteWithAtMost() { - set = new RangeSetWrapper<>(CONSUMER, REVERSE_CONVERT, managedCursor); - set.addOpenClosed(0, 98, 0, 99); - set.addOpenClosed(0, 100, 1, 5); - set.addOpenClosed(1, 10, 1, 15); - set.addOpenClosed(1, 20, 2, 10); - set.addOpenClosed(2, 25, 2, 28); - set.addOpenClosed(3, 12, 3, 20); - set.addOpenClosed(4, 12, 4, 20); - - // delete only (0,100) - set.remove(Range.open(new LongPair(0, 99), new LongPair(0, 105))); - - /** - * delete all keys from [2,27]->[4,15] : remaining [2,25..26,28], [4,16..20] - */ - set.remove(Range.atMost(new LongPair(2, 27))); - - List> ranges = new ArrayList<>(set.asRanges()); - int count = 0; - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(2, 27), new LongPair(2, 28)))); - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(3, 12), new LongPair(3, 20)))); - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(4, 12), new LongPair(4, 20)))); - } - - @Test - public void testDeleteWithAtMost2() { - set = new RangeSetWrapper<>(CONSUMER, REVERSE_CONVERT, managedCursor); - set.addOpenClosed(0, 98, 0, 99); - set.addOpenClosed(0, 100, 1, 5); - set.addOpenClosed(1, 10, 1, 15); - set.addOpenClosed(1, 20, 2, 10); - set.addOpenClosed(2, 25, 2, 28); - set.addOpenClosed(3, 12, 3, 20); - set.addOpenClosed(4, 12, 4, 20); - - // delete only (0,100) - set.remove(Range.closed(new LongPair(0, 0), new LongPair(0, Integer.MAX_VALUE - 1))); - - List> ranges = new ArrayList<>(set.asRanges()); - int count = 0; - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(1, -1), new LongPair(1, 5)))); - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(1, 10), new LongPair(1, 15)))); - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(2, -1), new LongPair(2, 10)))); - assertEquals(ranges.get(count), (Range.openClosed(new LongPair(2, 25), new LongPair(2, 28)))); - - managedLedgerConfig.setUnackedRangesOpenCacheSetEnabled(false); - set = new RangeSetWrapper<>(CONSUMER, REVERSE_CONVERT, managedCursor); - set.addOpenClosed(0, 98, 0, 99); - set.addOpenClosed(0, 100, 1, 5); - set.addOpenClosed(1, 10, 1, 15); - set.addOpenClosed(1, 20, 2, 10); - set.addOpenClosed(2, 25, 2, 28); - set.addOpenClosed(3, 12, 3, 20); - set.addOpenClosed(4, 12, 4, 20); - - set.remove(Range.openClosed(new LongPair(0, 0), new LongPair(0, Integer.MAX_VALUE - 1))); - ranges = new ArrayList<>(set.asRanges()); - count = 0; - assertEquals(ranges.get(count++), - (Range.openClosed(new LongPair(0, Integer.MAX_VALUE - 1), new LongPair(1, 5)))); - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(1, 10), new LongPair(1, 15)))); - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(1, 20), new LongPair(2, 10)))); - assertEquals(ranges.get(count), (Range.openClosed(new LongPair(2, 25), new LongPair(2, 28)))); - } - - @Test - public void testDeleteWithLeastMost() { - set = new RangeSetWrapper<>(CONSUMER, REVERSE_CONVERT, managedCursor); - set.addOpenClosed(0, 98, 0, 99); - set.addOpenClosed(0, 100, 1, 5); - set.addOpenClosed(1, 10, 1, 15); - set.addOpenClosed(1, 20, 2, 10); - set.addOpenClosed(2, 25, 2, 28); - set.addOpenClosed(2, 12, 3, 20); - set.addOpenClosed(4, 12, 4, 20); - - // delete only (0,100) - set.remove(Range.open(new LongPair(0, 99), new LongPair(0, 105))); - - /** - * delete all keys from [2,27]->[4,15] : remaining [2,25..26,28], [4,16..20] - */ - set.remove(Range.atLeast(new LongPair(2, 27))); - - List> ranges = new ArrayList<>(set.asRanges()); - int count = 0; - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(0, 98), new LongPair(0, 99)))); - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(1, -1), new LongPair(1, 5)))); - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(1, 10), new LongPair(1, 15)))); - assertEquals(ranges.get(count++), (Range.openClosed(new LongPair(2, -1), new LongPair(2, 10)))); - assertEquals(ranges.get(count), (Range.openClosed(new LongPair(2, 12), new LongPair(2, 26)))); - } - - @Test - public void testRangeContaining() { - set = new RangeSetWrapper<>(CONSUMER, REVERSE_CONVERT, managedCursor); - set.add(Range.closed(new LongPair(0, 98), new LongPair(0, 99))); - set.add(Range.closed(new LongPair(0, 100), new LongPair(1, 5))); - com.google.common.collect.RangeSet gSet = TreeRangeSet.create(); - gSet.add(Range.closed(new LongPair(0, 98), new LongPair(0, 100))); - gSet.add(Range.closed(new LongPair(0, 101), new LongPair(1, 5))); - set.add(Range.closed(new LongPair(1, 10), new LongPair(1, 15))); - set.add(Range.closed(new LongPair(1, 20), new LongPair(2, 10))); - set.add(Range.closed(new LongPair(2, 25), new LongPair(2, 28))); - set.add(Range.closed(new LongPair(3, 12), new LongPair(3, 20))); - set.add(Range.closed(new LongPair(4, 12), new LongPair(4, 20))); - gSet.add(Range.closed(new LongPair(1, 10), new LongPair(1, 15))); - gSet.add(Range.closed(new LongPair(1, 20), new LongPair(2, 10))); - gSet.add(Range.closed(new LongPair(2, 25), new LongPair(2, 28))); - gSet.add(Range.closed(new LongPair(3, 12), new LongPair(3, 20))); - gSet.add(Range.closed(new LongPair(4, 12), new LongPair(4, 20))); - - LongPair position = new LongPair(0, 99); - assertEquals(set.rangeContaining(position.getKey(), position.getValue()), - Range.closed(new LongPair(0, 98), new LongPair(0, 100))); - assertEquals(set.rangeContaining(position.getKey(), position.getValue()), gSet.rangeContaining(position)); - - position = new LongPair(2, 30); - assertNull(set.rangeContaining(position.getKey(), position.getValue())); - assertEquals(set.rangeContaining(position.getKey(), position.getValue()), gSet.rangeContaining(position)); - - position = new LongPair(3, 13); - assertEquals(set.rangeContaining(position.getKey(), position.getValue()), - Range.closed(new LongPair(3, 12), new LongPair(3, 20))); - assertEquals(set.rangeContaining(position.getKey(), position.getValue()), gSet.rangeContaining(position)); - - position = new LongPair(3, 22); - assertNull(set.rangeContaining(position.getKey(), position.getValue())); - assertEquals(set.rangeContaining(position.getKey(), position.getValue()), gSet.rangeContaining(position)); - } - - - private List> getConnectedRange(Set> gRanges) { - List> gRangeConnected = new ArrayList(); - Range lastRange = null; - for (Range range : gRanges) { - if (lastRange == null) { - lastRange = range; - continue; - } - LongPair previousUpper = lastRange.upperEndpoint(); - LongPair currentLower = range.lowerEndpoint(); - int previousUpperValue = (int) (lastRange.upperBoundType().equals(BoundType.CLOSED) - ? previousUpper.getValue() - : previousUpper.getValue() - 1); - int currentLowerValue = (int) (range.lowerBoundType().equals(BoundType.CLOSED) ? currentLower.getValue() - : currentLower.getValue() + 1); - boolean connected = - previousUpper.getKey() == currentLower.getKey() && (previousUpperValue >= currentLowerValue); - if (connected) { - lastRange = Range.closed(lastRange.lowerEndpoint(), range.upperEndpoint()); - } else { - gRangeConnected.add(lastRange); - lastRange = range; - } - } - int lowerOpenValue = (int) (lastRange.lowerBoundType().equals(BoundType.CLOSED) - ? (lastRange.lowerEndpoint().getValue() - 1) - : lastRange.lowerEndpoint().getValue()); - lastRange = Range.openClosed(new LongPair(lastRange.lowerEndpoint().getKey(), lowerOpenValue), - lastRange.upperEndpoint()); - gRangeConnected.add(lastRange); - return gRangeConnected; - } -} \ No newline at end of file diff --git a/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/offload/OffloadersCacheTest.java b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/offload/OffloadersCacheTest.java index f20b9b9cbbf2e..9879019471565 100644 --- a/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/offload/OffloadersCacheTest.java +++ b/managed-ledger/src/test/java/org/apache/bookkeeper/mledger/offload/OffloadersCacheTest.java @@ -19,7 +19,10 @@ package org.apache.bookkeeper.mledger.offload; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.testng.Assert.assertSame; +import java.nio.file.Paths; import org.mockito.MockedStatic; import org.mockito.Mockito; import org.testng.annotations.Test; @@ -29,9 +32,10 @@ public class OffloadersCacheTest { @Test public void testLoadsOnlyOnce() throws Exception { Offloaders expectedOffloaders = new Offloaders(); + String normalizedPath = Paths.get("./offloaders").toAbsolutePath().normalize().toString(); try (MockedStatic offloaderUtils = Mockito.mockStatic(OffloaderUtils.class)) { - offloaderUtils.when(() -> OffloaderUtils.searchForOffloaders(eq("./offloaders"), eq("/tmp"))) + offloaderUtils.when(() -> OffloaderUtils.searchForOffloaders(eq(normalizedPath), eq("/tmp"))) .thenReturn(expectedOffloaders); OffloadersCache cache = new OffloadersCache(); @@ -47,4 +51,30 @@ public void testLoadsOnlyOnce() throws Exception { assertSame(offloaders2, expectedOffloaders, "The offloaders should be the mocked one."); } } + + @Test + public void testEquivalentPathsLoadOnlyOnce() throws Exception { + String relativePath = "./offloaders"; + String normalizedPath = Paths.get(relativePath).toAbsolutePath().normalize().toString(); + Offloaders expectedOffloaders = new Offloaders(); + + try (MockedStatic offloaderUtils = Mockito.mockStatic(OffloaderUtils.class)) { + offloaderUtils.when(() -> OffloaderUtils.searchForOffloaders(eq(relativePath), eq("/tmp"))) + .thenReturn(expectedOffloaders); + offloaderUtils.when(() -> OffloaderUtils.searchForOffloaders(eq(normalizedPath), eq("/tmp"))) + .thenReturn(expectedOffloaders); + + OffloadersCache cache = new OffloadersCache(); + + Offloaders offloaders1 = cache.getOrLoadOffloaders(relativePath, "/tmp"); + Offloaders offloaders2 = cache.getOrLoadOffloaders(normalizedPath, "/tmp"); + + assertSame(offloaders1, expectedOffloaders, "The relative path should load the mocked offloaders."); + assertSame(offloaders2, expectedOffloaders, "The absolute path should reuse the cached offloaders."); + offloaderUtils.verify( + () -> OffloaderUtils.searchForOffloaders(eq(normalizedPath), eq("/tmp")), times(1)); + offloaderUtils.verify( + () -> OffloaderUtils.searchForOffloaders(eq(relativePath), eq("/tmp")), never()); + } + } } diff --git a/microbench/pom.xml b/microbench/pom.xml index d0b8fd129102a..8eb8c08131a41 100644 --- a/microbench/pom.xml +++ b/microbench/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT microbench diff --git a/microbench/src/main/java/org/apache/bookkeeper/mledger/impl/PositionRangeSetBenchmark.java b/microbench/src/main/java/org/apache/bookkeeper/mledger/impl/PositionRangeSetBenchmark.java new file mode 100644 index 0000000000000..1e920ef62a4d6 --- /dev/null +++ b/microbench/src/main/java/org/apache/bookkeeper/mledger/impl/PositionRangeSetBenchmark.java @@ -0,0 +1,136 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.bookkeeper.mledger.impl; + +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import org.apache.bookkeeper.mledger.PositionFactory; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * Baseline throughput numbers for {@link PositionRangeSet} on realistic cursor workloads. Hot paths + * exercised: {@code addOpenClosed} (per individual ack), {@code contains} (per message read), + * {@code forEachRawRange} (per persistence flush), {@code removeAtMost} (per mark-delete), and + * {@code toRanges} (serialization for the cursor ledger). + * + *

Run with: + *

{@code
+ * ./gradlew :microbench:shadowJar
+ * java -jar microbench/build/libs/microbench-*-benchmarks.jar PositionRangeSetBenchmark
+ * }
+ */ +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +@State(Scope.Benchmark) +@Warmup(time = 2, iterations = 1, timeUnit = TimeUnit.SECONDS) +@Measurement(time = 3, iterations = 1, timeUnit = TimeUnit.SECONDS) +@Fork(1) +public class PositionRangeSetBenchmark { + + @Param({"10", "100", "1000"}) + public int ledgerCount; + + @Param({"100", "10000"}) + public int entriesPerLedger; + + private PositionRangeSet set; + private final AtomicLong cursor = new AtomicLong(); + private long totalEntries; + + @Setup(Level.Trial) + public void setup() { + set = new PositionRangeSet(PositionFactory::create, false); + // Pre-populate: every other entry in every ledger is "individually deleted" — models the + // typical Shared/Key_Shared ack pattern that produces fragmented deleted-message ranges. + for (int l = 0; l < ledgerCount; l++) { + for (int e = 1; e < entriesPerLedger; e += 2) { + set.addOpenClosed(l, e - 1, l, e); + } + } + totalEntries = (long) ledgerCount * entriesPerLedger; + cursor.set(0); + } + + @Benchmark + @Threads(1) + public boolean addOpenClosedSameLedger() { + // Simulate a new individual ack arriving on an existing ledger. + long l = (cursor.getAndIncrement() % ledgerCount); + long e = entriesPerLedger + (cursor.getAndIncrement() & 0xFF); + set.addOpenClosed(l, e, l, e + 1); + return true; + } + + @Benchmark + @Threads(1) + public boolean containsHit() { + // Read-path check: is this (already-deleted) entry in the set? + long l = (cursor.getAndIncrement() & Long.MAX_VALUE) % ledgerCount; + long e = 1 + 2 * ((cursor.getAndIncrement() & Long.MAX_VALUE) % (entriesPerLedger / 2)); + return set.contains(l, e); + } + + @Benchmark + @Threads(1) + public boolean containsMiss() { + // Read-path check: entry that was NOT individually deleted (still in the backlog). + long l = (cursor.getAndIncrement() & Long.MAX_VALUE) % ledgerCount; + long e = 2 * ((cursor.getAndIncrement() & Long.MAX_VALUE) % (entriesPerLedger / 2)); + return set.contains(l, e); + } + + @Benchmark + @Threads(1) + public int forEachRawRange(Blackhole bh) { + // Persistence path: walk every deleted range once per mark-delete flush. + int[] count = {0}; + set.forEachRawRange((lowerKey, lowerValue, upperKey, upperValue) -> { + count[0]++; + return true; + }); + return count[0]; + } + + @Benchmark + @Threads(1) + public Map toRanges() { + // Serialization: builds the dense long[] map written to the cursor ledger. + return set.toRanges(Integer.MAX_VALUE); + } + + @Benchmark + @Threads(1) + public int size() { + return set.size(); + } +} diff --git a/microbench/src/main/java/org/apache/pulsar/broker/delayed/bucket/package-info.java b/microbench/src/main/java/org/apache/bookkeeper/mledger/impl/package-info.java similarity index 69% rename from microbench/src/main/java/org/apache/pulsar/broker/delayed/bucket/package-info.java rename to microbench/src/main/java/org/apache/bookkeeper/mledger/impl/package-info.java index 188ce0be224cd..ba3f3713fe202 100644 --- a/microbench/src/main/java/org/apache/pulsar/broker/delayed/bucket/package-info.java +++ b/microbench/src/main/java/org/apache/bookkeeper/mledger/impl/package-info.java @@ -17,11 +17,5 @@ * under the License. */ -/** - * Microbenchmarks for delayed message delivery bucket implementation. - * - *

This package contains JMH benchmarks for testing the performance - * characteristics of the BucketDelayedDeliveryTracker, particularly - * focusing on thread safety improvements with StampedLock optimistic reads. - */ -package org.apache.pulsar.broker.delayed.bucket; \ No newline at end of file +/** Benchmarks for managed-ledger data structures. */ +package org.apache.bookkeeper.mledger.impl; diff --git a/microbench/src/main/java/org/apache/bookkeeper/mledger/package-info.java b/microbench/src/main/java/org/apache/bookkeeper/mledger/package-info.java new file mode 100644 index 0000000000000..85820da956b59 --- /dev/null +++ b/microbench/src/main/java/org/apache/bookkeeper/mledger/package-info.java @@ -0,0 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** Benchmarks for managed-ledger data structures. */ +package org.apache.bookkeeper.mledger; diff --git a/microbench/src/main/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedDeliveryTrackerSimpleBenchmark.java b/microbench/src/main/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedDeliveryTrackerSimpleBenchmark.java deleted file mode 100644 index 985e714d54d1d..0000000000000 --- a/microbench/src/main/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedDeliveryTrackerSimpleBenchmark.java +++ /dev/null @@ -1,408 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.pulsar.broker.delayed.bucket; - -import java.util.concurrent.ThreadLocalRandom; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.StampedLock; -import org.openjdk.jmh.annotations.Benchmark; -import org.openjdk.jmh.annotations.BenchmarkMode; -import org.openjdk.jmh.annotations.Fork; -import org.openjdk.jmh.annotations.Level; -import org.openjdk.jmh.annotations.Measurement; -import org.openjdk.jmh.annotations.Mode; -import org.openjdk.jmh.annotations.OutputTimeUnit; -import org.openjdk.jmh.annotations.Param; -import org.openjdk.jmh.annotations.Scope; -import org.openjdk.jmh.annotations.Setup; -import org.openjdk.jmh.annotations.State; -import org.openjdk.jmh.annotations.TearDown; -import org.openjdk.jmh.annotations.Threads; -import org.openjdk.jmh.annotations.Warmup; - -/** - * Simplified JMH Benchmarks for BucketDelayedDeliveryTracker thread safety improvements. - * This benchmark focuses on the core StampedLock optimistic read performance without - * complex dependencies on the full BucketDelayedDeliveryTracker implementation. - * Run with: mvn exec:java -Dexec.mainClass="org.openjdk.jmh.Main" - * -Dexec.args="BucketDelayedDeliveryTrackerSimpleBenchmark" - */ -@BenchmarkMode(Mode.Throughput) -@OutputTimeUnit(TimeUnit.SECONDS) -@State(Scope.Benchmark) -@Warmup(iterations = 5, time = 1) -@Measurement(iterations = 5, time = 1) -@Fork(1) -public class BucketDelayedDeliveryTrackerSimpleBenchmark { - - @Param({"1", "2", "4", "8", "16"}) - public int threadCount; - - private StampedLock stampedLock; - private boolean testData = true; - private volatile long counter = 0; - - @Setup(Level.Trial) - public void setup() throws Exception { - stampedLock = new StampedLock(); - } - - @TearDown(Level.Trial) - public void tearDown() throws Exception { - // Cleanup if needed - } - - // ============================================================================= - // STAMPED LOCK OPTIMISTIC READ BENCHMARKS - // ============================================================================= - - @Benchmark - @Threads(1) - public boolean benchmarkOptimisticReadSingleThreaded() { - // Simulate optimistic read like in containsMessage() - long stamp = stampedLock.tryOptimisticRead(); - boolean result = testData; // Simulate reading shared data - - if (!stampedLock.validate(stamp)) { - stamp = stampedLock.readLock(); - try { - result = testData; - } finally { - stampedLock.unlockRead(stamp); - } - } - return result; - } - - @Benchmark - @Threads(2) - public boolean benchmarkOptimisticReadMultiThreaded() { - long stamp = stampedLock.tryOptimisticRead(); - boolean result = testData; - - if (!stampedLock.validate(stamp)) { - stamp = stampedLock.readLock(); - try { - result = testData; - } finally { - stampedLock.unlockRead(stamp); - } - } - return result; - } - - @Benchmark - @Threads(8) - public boolean benchmarkOptimisticReadHighConcurrency() { - long stamp = stampedLock.tryOptimisticRead(); - boolean result = testData; - - if (!stampedLock.validate(stamp)) { - stamp = stampedLock.readLock(); - try { - result = testData; - } finally { - stampedLock.unlockRead(stamp); - } - } - return result; - } - - @Benchmark - @Threads(16) - public boolean benchmarkOptimisticReadExtremeConcurrency() { - long stamp = stampedLock.tryOptimisticRead(); - boolean result = testData; - - if (!stampedLock.validate(stamp)) { - stamp = stampedLock.readLock(); - try { - result = testData; - } finally { - stampedLock.unlockRead(stamp); - } - } - return result; - } - - // ============================================================================= - // READ:WRITE RATIO BENCHMARKS (as requested) - // ============================================================================= - - @Benchmark - @Threads(4) - public boolean benchmarkReadWrite10_90() { - // 10:90 read:write ratio simulation - if (ThreadLocalRandom.current().nextInt(100) < 10) { - // Read operation - long stamp = stampedLock.tryOptimisticRead(); - boolean result = testData; - - if (!stampedLock.validate(stamp)) { - stamp = stampedLock.readLock(); - try { - result = testData; - } finally { - stampedLock.unlockRead(stamp); - } - } - return result; - } else { - // Write operation - long stamp = stampedLock.writeLock(); - try { - testData = !testData; - counter++; - return testData; - } finally { - stampedLock.unlockWrite(stamp); - } - } - } - - @Benchmark - @Threads(4) - public boolean benchmarkReadWrite20_80() { - // 20:80 read:write ratio - if (ThreadLocalRandom.current().nextInt(100) < 20) { - long stamp = stampedLock.tryOptimisticRead(); - boolean result = testData; - - if (!stampedLock.validate(stamp)) { - stamp = stampedLock.readLock(); - try { - result = testData; - } finally { - stampedLock.unlockRead(stamp); - } - } - return result; - } else { - long stamp = stampedLock.writeLock(); - try { - testData = !testData; - counter++; - return testData; - } finally { - stampedLock.unlockWrite(stamp); - } - } - } - - @Benchmark - @Threads(4) - public boolean benchmarkReadWrite40_60() { - // 40:60 read:write ratio - if (ThreadLocalRandom.current().nextInt(100) < 40) { - long stamp = stampedLock.tryOptimisticRead(); - boolean result = testData; - - if (!stampedLock.validate(stamp)) { - stamp = stampedLock.readLock(); - try { - result = testData; - } finally { - stampedLock.unlockRead(stamp); - } - } - return result; - } else { - long stamp = stampedLock.writeLock(); - try { - testData = !testData; - counter++; - return testData; - } finally { - stampedLock.unlockWrite(stamp); - } - } - } - - @Benchmark - @Threads(4) - public boolean benchmarkReadWrite50_50() { - // 50:50 read:write ratio - if (ThreadLocalRandom.current().nextInt(100) < 50) { - long stamp = stampedLock.tryOptimisticRead(); - boolean result = testData; - - if (!stampedLock.validate(stamp)) { - stamp = stampedLock.readLock(); - try { - result = testData; - } finally { - stampedLock.unlockRead(stamp); - } - } - return result; - } else { - long stamp = stampedLock.writeLock(); - try { - testData = !testData; - counter++; - return testData; - } finally { - stampedLock.unlockWrite(stamp); - } - } - } - - @Benchmark - @Threads(4) - public boolean benchmarkReadWrite60_40() { - // 60:40 read:write ratio - if (ThreadLocalRandom.current().nextInt(100) < 60) { - long stamp = stampedLock.tryOptimisticRead(); - boolean result = testData; - - if (!stampedLock.validate(stamp)) { - stamp = stampedLock.readLock(); - try { - result = testData; - } finally { - stampedLock.unlockRead(stamp); - } - } - return result; - } else { - long stamp = stampedLock.writeLock(); - try { - testData = !testData; - counter++; - return testData; - } finally { - stampedLock.unlockWrite(stamp); - } - } - } - - @Benchmark - @Threads(4) - public boolean benchmarkReadWrite80_20() { - // 80:20 read:write ratio - if (ThreadLocalRandom.current().nextInt(100) < 80) { - long stamp = stampedLock.tryOptimisticRead(); - boolean result = testData; - - if (!stampedLock.validate(stamp)) { - stamp = stampedLock.readLock(); - try { - result = testData; - } finally { - stampedLock.unlockRead(stamp); - } - } - return result; - } else { - long stamp = stampedLock.writeLock(); - try { - testData = !testData; - counter++; - return testData; - } finally { - stampedLock.unlockWrite(stamp); - } - } - } - - @Benchmark - @Threads(4) - public boolean benchmarkReadWrite90_10() { - // 90:10 read:write ratio - most realistic for production - if (ThreadLocalRandom.current().nextInt(100) < 90) { - long stamp = stampedLock.tryOptimisticRead(); - boolean result = testData; - - if (!stampedLock.validate(stamp)) { - stamp = stampedLock.readLock(); - try { - result = testData; - } finally { - stampedLock.unlockRead(stamp); - } - } - return result; - } else { - long stamp = stampedLock.writeLock(); - try { - testData = !testData; - counter++; - return testData; - } finally { - stampedLock.unlockWrite(stamp); - } - } - } - - // ============================================================================= - // HIGH CONCURRENCY SCENARIOS - // ============================================================================= - - @Benchmark - @Threads(8) - public boolean benchmarkReadWrite90_10_HighConcurrency() { - // 90:10 read:write ratio with high concurrency - if (ThreadLocalRandom.current().nextInt(100) < 90) { - long stamp = stampedLock.tryOptimisticRead(); - boolean result = testData; - - if (!stampedLock.validate(stamp)) { - stamp = stampedLock.readLock(); - try { - result = testData; - } finally { - stampedLock.unlockRead(stamp); - } - } - return result; - } else { - long stamp = stampedLock.writeLock(); - try { - testData = !testData; - counter++; - return testData; - } finally { - stampedLock.unlockWrite(stamp); - } - } - } - - @Benchmark - @Threads(16) - public boolean benchmarkOptimisticReadContention() { - // High contention scenario to test optimistic read fallback behavior - long stamp = stampedLock.tryOptimisticRead(); - boolean result = testData; - - // Simulate some computation - if (ThreadLocalRandom.current().nextInt(1000) == 0) { - Thread.yield(); // Occasionally yield to increase contention - } - - if (!stampedLock.validate(stamp)) { - stamp = stampedLock.readLock(); - try { - result = testData; - } finally { - stampedLock.unlockRead(stamp); - } - } - return result; - } -} \ No newline at end of file diff --git a/microbench/src/main/java/org/apache/pulsar/common/util/collections/LongBitmapBenchmark.java b/microbench/src/main/java/org/apache/pulsar/common/util/collections/LongBitmapBenchmark.java new file mode 100644 index 0000000000000..9fa3c464d7324 --- /dev/null +++ b/microbench/src/main/java/org/apache/pulsar/common/util/collections/LongBitmapBenchmark.java @@ -0,0 +1,141 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.common.util.collections; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; +import org.roaringbitmap.RoaringBitmap; +import org.roaringbitmap.longlong.Roaring64Bitmap; + +/** + * JMH benchmark for {@link LongBitmap} ({@link ConcurrentRoaringBitmap}), compared against + * the pre-PR bitmap implementations it replaces: + *

    + *
  • {@link Roaring64Bitmap} — previously used in {@code InMemoryDelayedDeliveryTracker}.
  • + *
  • {@link RoaringBitmap} — previously used in {@code ConsumerNameIndexTracker}, + * {@code DrainingHashesTracker}, and the bucket delayed-delivery family.
  • + *
+ * + *

Run with: + *

{@code
+ * ./gradlew :microbench:shadowJar
+ * java -jar microbench/build/libs/microbench-*-benchmarks.jar LongBitmapBenchmark
+ * }
+ */ +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +@State(Scope.Benchmark) +@Warmup(time = 2, iterations = 1, timeUnit = TimeUnit.SECONDS) +@Measurement(time = 3, iterations = 1, timeUnit = TimeUnit.SECONDS) +@Fork(1) +public class LongBitmapBenchmark { + + @Param({"1000", "100000"}) + public int bitmapSize; + + private LongBitmap longBitmap; + private RoaringBitmap roaringBitmap; + private Roaring64Bitmap roaring64Bitmap; + + private final AtomicLong nextValue = new AtomicLong(); + + @Setup(Level.Trial) + public void setup() { + longBitmap = LongBitmaps.create(); + roaringBitmap = new RoaringBitmap(); + roaring64Bitmap = new Roaring64Bitmap(); + for (int i = 0; i < bitmapSize; i++) { + longBitmap.add(i); + roaringBitmap.add(i); + roaring64Bitmap.addLong(i); + } + nextValue.set(bitmapSize); + } + + @Benchmark + @Threads(1) + public boolean longBitmapAddSingleThread() { + long v = nextValue.getAndIncrement(); + return longBitmap.checkedAdd(v); + } + + @Benchmark + @Threads(1) + public boolean roaringBitmapAddSingleThread() { + long v = nextValue.getAndIncrement(); + return roaringBitmap.checkedAdd((int) v); + } + + @Benchmark + @Threads(1) + public boolean roaring64BitmapAddSingleThread() { + long v = nextValue.getAndIncrement(); + boolean existed = roaring64Bitmap.contains(v); + roaring64Bitmap.addLong(v); + return !existed; + } + + @Benchmark + @Threads(1) + public boolean longBitmapContainsSingleThread() { + return longBitmap.contains(nextValue.getAndIncrement() % bitmapSize); + } + + @Benchmark + @Threads(1) + public boolean roaringBitmapContainsSingleThread() { + return roaringBitmap.contains((int) (nextValue.getAndIncrement() % bitmapSize)); + } + + @Benchmark + @Threads(1) + public boolean roaring64BitmapContainsSingleThread() { + return roaring64Bitmap.contains(nextValue.getAndIncrement() % bitmapSize); + } + + // Bare RoaringBitmap variants are not thread-safe and are omitted below. + + @Benchmark + @Threads(4) + public boolean longBitmapAdd4Threads() { + long v = nextValue.getAndIncrement(); + return longBitmap.checkedAdd(v); + } + + @Benchmark + @Threads(4) + public void longBitmapContains4Threads(Blackhole bh) { + int v = (int) (Thread.currentThread().getId() * 31 + System.nanoTime()); + bh.consume(longBitmap.contains(Math.abs(v) % bitmapSize)); + } +} diff --git a/microbench/src/main/java/org/apache/pulsar/common/util/collections/TripleLongPriorityQueueBenchmark.java b/microbench/src/main/java/org/apache/pulsar/common/util/collections/TripleLongPriorityQueueBenchmark.java new file mode 100644 index 0000000000000..1e50ab600bfae --- /dev/null +++ b/microbench/src/main/java/org/apache/pulsar/common/util/collections/TripleLongPriorityQueueBenchmark.java @@ -0,0 +1,168 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.pulsar.common.util.collections; + +import java.util.Random; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * JMH benchmarks for {@link TripleLongPriorityQueue} simulating Pulsar delayed delivery workloads. + * + *

Three scenarios matching real usage: + *

    + *
  • {@link #recoveryBulkAddThenPop} — snapshot recovery: bulk add all entries, then pop all. + * Cold cache, large heap. This is the worst case for the hole-based optimization + * because cache misses dominate over reduced readLong calls.
  • + *
  • {@link #interleavedAddPop} — steady-state delayed delivery: batch add (messages arriving + * between timer ticks), then batch pop (getScheduledMessages). Heap stays warm in cache. + * This is the primary hot path.
  • + *
  • {@link #steadyState} — constant-depth steady state: pre-fill queue, then alternating + * small add/pop batches. Simulates sustained throughput with ~10K queue depth.
  • + *
+ * + *

Build and run: + *

+ * ./gradlew :microbench:shadowJar
+ * java -jar microbench/build/libs/microbench-*-benchmarks.jar ".*TripleLongPriorityQueue.*"
+ * 
+ */ +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +@Fork(2) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +@State(Scope.Thread) +public class TripleLongPriorityQueueBenchmark { + + @Param({"50000", "500000", "2000000"}) + int size; + + /** + * Recovery scenario: bulk add all then pop all. + * Simulates snapshot recovery from BookKeeper — cold cache, large heap. + */ + @Benchmark + public void recoveryBulkAddThenPop(Blackhole bh) { + try (TripleLongPriorityQueue pq = new TripleLongPriorityQueue()) { + long baseTs = System.currentTimeMillis(); + Random rng = new Random(42); + for (int i = 0; i < size; i++) { + long n1 = baseTs + rng.nextLong(3_600_000); + long n2 = i / 1000; + long n3 = i; + pq.add(n1, n2, n3); + } + while (!pq.isEmpty()) { + bh.consume(pq.peekN1()); + pq.pop(); + } + } + } + + /** + * Interleaved scenario: batch add then batch pop, repeated. + * Simulates steady-state delayed delivery — messages arrive continuously, + * getScheduledMessages pops in batches of ~500 when consumers are ready. + * Heap is warm in L2/L3 cache between operations. + */ + @Benchmark + public void interleavedAddPop(Blackhole bh) { + try (TripleLongPriorityQueue pq = new TripleLongPriorityQueue()) { + Random rng = new Random(42); + long baseTs = System.currentTimeMillis(); + int batchSize = 500; + int totalAdded = 0; + + while (totalAdded < size) { + // Batch add: messages arriving between timer ticks + int addCount = Math.min(batchSize + rng.nextInt(500), size - totalAdded); + for (int i = 0; i < addCount; i++) { + long n1 = baseTs + rng.nextLong(3_600_000); + long n2 = (totalAdded + i) / 1000; + long n3 = totalAdded + i; + pq.add(n1, n2, n3); + } + totalAdded += addCount; + + // Batch pop: getScheduledMessages delivering to consumers + int popCount = (int) Math.min(batchSize, pq.size()); + for (int i = 0; i < popCount; i++) { + bh.consume(pq.peekN1()); + pq.pop(); + } + } + // Drain remaining + while (!pq.isEmpty()) { + bh.consume(pq.peekN1()); + pq.pop(); + } + } + } + + /** + * Steady-state scenario: pre-fill queue, then alternating small add/pop. + * Simulates sustained throughput with constant ~10K queue depth. + * Heap stays hot in cache — this is where the readLong reduction matters most. + */ + @Benchmark + public void steadyState(Blackhole bh) { + int steadyDepth = 10_000; + try (TripleLongPriorityQueue pq = new TripleLongPriorityQueue()) { + Random rng = new Random(42); + long baseTs = System.currentTimeMillis(); + long seq = 0; + + // Pre-fill + for (int i = 0; i < steadyDepth; i++) { + pq.add(baseTs + rng.nextLong(3_600_000), seq / 1000, seq); + seq++; + } + + // Alternating add/pop to maintain steady depth + int ops = 0; + while (ops < size) { + // Small batch add + int addCount = 100 + rng.nextInt(100); + for (int i = 0; i < addCount && ops < size; i++) { + pq.add(baseTs + rng.nextLong(3_600_000), seq / 1000, seq); + seq++; + ops++; + } + // Small batch pop + int popCount = addCount - rng.nextInt(20); + for (int i = 0; i < popCount && !pq.isEmpty(); i++) { + bh.consume(pq.peekN1()); + pq.pop(); + } + } + } + } +} diff --git a/microbench/src/main/resources/log4j2.xml b/microbench/src/main/resources/log4j2.xml index 7ec5ed8169a66..0898d4d66e489 100644 --- a/microbench/src/main/resources/log4j2.xml +++ b/microbench/src/main/resources/log4j2.xml @@ -22,7 +22,7 @@ - + diff --git a/pip/pip-469.md b/pip/pip-469.md new file mode 100644 index 0000000000000..4734adc127d4c --- /dev/null +++ b/pip/pip-469.md @@ -0,0 +1,299 @@ +# PIP-469: Legacy-aware topic policies backend routing and metadata-store topic policies + +# Background knowledge + +Apache Pulsar introduced topic-level policies in [PIP-39](pip-39.md). A broker reads and writes these policies through +`TopicPoliciesService`. The default implementation, +`SystemTopicBasedTopicPoliciesService`, persists topic policy changes in the namespace `__change_events` system topic and +keeps an in-memory cache on brokers that own bundles for that namespace. + +[PIP-92](pip-92.md) extended topic policies with the distinction between local and global policies. Any +`TopicPoliciesService` implementation therefore needs to handle two independent values for the same topic: the +cluster-local policy state and the globally visible policy state. + +[PIP-376](pip-376.md) made `TopicPoliciesService` pluggable through the broker configuration +`topicPoliciesServiceClassName`. That change removed the hard coupling between topic policies and system topics, but the +backend choice is still broker-wide. During upgrade from the default system-topic backend, brokers still need a way to +recognize namespaces that already have topic-policies state in `__change_events`, so those namespaces do not silently +move to another backend. + +# Motivation + +The system-topic-based topic policies implementation works by appending topic policy changes to a `__change_events` +topic in each namespace. It works well when this topic has already been loaded by a broker, then all topic policies +operations just access the in-memory cache. However, in cold start scenarios, for example when the owner broker is down +during a restart, the new owner broker has to create a reader on the `__change_events` topic and wait for it to catch +up before it can read any topic policies, which is required in the path of loading a topic in the same namespace. This +adds significant latency to the topic load path, especially before the topic is compacted. + +Things become worse when many `__change_events` topics move to a restarting broker. The new owner broker has to create +many readers and replay all messages on these topics. This leads to high pressure on BookKeeper and can cause +`Too many requests on the same bookie` errors in `GetLastMessageId` RPCs. + +A metadata-store-backed topic policies backend is attractive because it removes the extra lifecycle and operational +dependency of a dedicated `__change_events` topic. A metadata-cache-based implementation can still provide caching and +change notifications, while avoiding the cold-start latency of waiting for a system-topic reader to initialize and +catch up. + +There is a second operational requirement: operators need a safe gradual rollout path. Existing namespaces that already +have topic-policies state in `__change_events` must stay on the system-topic backend, while newly created namespaces +should be able to use the broker-configured backend. This does not require a new namespace policy. For the upgrade case +from the default configuration, the existence of `__change_events` is already a conservative legacy marker. + +# Goals + +## In Scope + +- Add a metadata-store-backed `TopicPoliciesService` implementation that does not depend on system topics. +- Add routing logic that forces the system-topic backend for namespaces that already have `__change_events`. +- Keep using the broker-level `topicPoliciesServiceClassName` for namespaces that do not have `__change_events`, + including newly created namespaces. + +## Out of Scope + +- Adding a migration framework that moves topic policies data between backends automatically. + +# High Level Design + +When topic-level policies are enabled, the broker instantiates a `LegacyAwareTopicPoliciesService` instead of using the +configured implementation directly. + +The wrapper always has access to two backends: + +- `SystemTopicBasedTopicPoliciesService` +- The broker-configured `topicPoliciesServiceClassName` + +For each namespace, the wrapper checks whether the topic-policies system topic `persistent://{tenant}/{namespace}/__change_events` +already exists: + +- If it exists, the namespace is treated as a legacy system-topic namespace and all topic-policies operations are + routed to `SystemTopicBasedTopicPoliciesService`. +- If it does not exist, the namespace uses the broker-configured `topicPoliciesServiceClassName`. + +This rule is intentionally conservative. If `__change_events` exists, the broker assumes that namespace may already +contain topic-policies state in the system-topic backend and therefore must not be moved implicitly. + +This proposal also introduces `MetadataStoreTopicPoliciesService`, a concrete `TopicPoliciesService` implementation +that stores topic policies in dedicated metadata-store paths: + +- Global topic policies are stored in the configuration metadata store. +- Local topic policies are stored in the local metadata store. + +This keeps the storage scope aligned with the semantics introduced by PIP-92 and avoids writing topic policies through +managed-ledger metadata side effects. + +# Detailed Design + +## Design & Implementation Details + +### Startup and validation + +`PulsarService#initTopicPoliciesService()` continues to respect `topicLevelPoliciesEnabled`. When topic-level policies +are disabled, behavior is unchanged and `TopicPoliciesService.DISABLED` is used. + +When topic-level policies are enabled, the broker constructs: + +```java +new LegacyAwareTopicPoliciesService( + this, + new SystemTopicBasedTopicPoliciesService(this), + configuredTopicPoliciesService) +``` + +Broker startup validates both backends: + +- `SystemTopicBasedTopicPoliciesService` must be instantiable. +- The configured `topicPoliciesServiceClassName` must be instantiable. + +`LegacyAwareTopicPoliciesService#start` starts only the configured backend. It intentionally does not call +`SystemTopicBasedTopicPoliciesService#start`, because that start path registers a namespace-bundle ownership listener +whose only purpose is to eagerly create a reader on `/__change_events` when a namespace bundle is loaded. +Under legacy-aware routing, that eager optimization would be counterproductive because it can create readers for +namespaces that do not have topic policies in `__change_events`. For legacy namespaces, the system-topic reader and +policy cache are initialized lazily by the routed system-topic backend operations. + +If either backend cannot be instantiated, or if the configured backend cannot be started, broker startup fails. There is +no per-request fallback from one backend to another. + +### Namespace-scoped service routing + +`LegacyAwareTopicPoliciesService` is responsible for: + +- Checking whether `__change_events` exists for the namespace by using + `NamespaceEventsSystemTopicFactory.checkSystemTopicExists(namespace, EventType.TOPIC_POLICY, pulsarService)`. +- Routing `getTopicPoliciesAsync`, `updateTopicPoliciesAsync`, `deleteTopicPoliciesAsync`, and listener operations to + the system-topic backend when the system topic exists. +- Routing the same operations to the configured backend when the system topic does not exist. + +Listener registration is routed through `TopicPoliciesService#registerListenerAsync`. This lets the wrapper resolve the +namespace backend before registering the listener, and the listener is registered only on the selected backend instead +of being registered on both backends. + +The system-topic existence check can be cached per namespace in memory, but the routing rule is defined by actual topic +existence rather than by new namespace metadata. + +This means: + +- Existing namespaces that already materialized `__change_events` continue to use the system-topic backend. +- Namespaces that never created `__change_events` use the broker-configured backend. +- Newly created namespaces use the broker-configured backend because `__change_events` does not exist yet. + +If `__change_events` is later deleted, the namespace falls back to the broker-configured backend on subsequent +resolution. This matches current system-topic behavior, which already treats a missing `__change_events` topic as +meaning the system-topic-backed topic-policies state is gone. + +### Metadata-backed topic policies service + +`MetadataStoreTopicPoliciesService` implements `TopicPoliciesService` with the following storage model: + +- Topic names are normalized to the partitioned topic name, so all partitions share the same topic-policies record. +- Global policies are stored in the configuration metadata store path: + `/admin/topic-policies/global/{tenant}/{namespace}/{domain}/{encodedTopic}`. +- Local policies are stored in the local metadata store path: + `/admin/topic-policies/local/{tenant}/{namespace}/{domain}/{encodedTopic}`. + +To avoid possible conflicts like the listener registered on the `/admin/local-policies` path from +`BrokerService#handleMetadataChanges`, these two paths share the same root path `/admin/topic-policies`, which is not +used by any other component. + +Each node stores a serialized `TopicPolicies` document. The backend writes and reads the two scopes independently: + +- Reads with `GetType.GLOBAL_ONLY` only touch the global path and return a `TopicPolicies` object whose `isGlobal` + flag is `true`. +- Reads with `GetType.LOCAL_ONLY` only touch the local path and return a `TopicPolicies` object whose `isGlobal` flag + is `false`. +- Updates with `isGlobalPolicy=true` only modify the global path. +- Updates with `isGlobalPolicy=false` only modify the local path. + +Deletes remove the local record and, unless `keepGlobalPoliciesAfterDeleting` is set, also remove the global record. +This matches the existing `TopicPoliciesService` deletion contract. + +This design intentionally uses dedicated metadata nodes instead of piggybacking on `PartitionedTopicMetadata` or +`ManagedLedgerInfo`. That keeps local/global visibility correct and avoids losing topic policies during normal +managed-ledger metadata updates. + +### Listener behavior + +`TopicPoliciesService` adds `registerListenerAsync(TopicName, TopicPolicyListener)` for listener registration. The +existing synchronous `registerListener(TopicName, TopicPolicyListener)` method is retained as a deprecated compatibility +hook for existing custom implementations, and the default async method delegates to it. Implementations that need async +routing or initialization, such as `LegacyAwareTopicPoliciesService`, override `registerListenerAsync` directly. + +The backend registers watchers on both metadata stores: + +- A change on the local path re-reads the local node and notifies listeners with the latest local `TopicPolicies` or + `null` if the local node was removed. +- A change on the global path re-reads the global node and notifies listeners with the latest global `TopicPolicies` + or `null` if the global node was removed. + +This preserves runtime updates for already loaded topics, including global topic policies. The backend does not add an +append-only replay log; it relies on metadata-store notifications and read-after-notify refresh. + +## Public-facing Changes + +### Public API + +The `TopicPoliciesService` extension point gains a default +`CompletableFuture registerListenerAsync(TopicName, TopicPolicyListener)` method. Existing implementations +remain compatible because `registerListener(TopicName, TopicPolicyListener)` is retained and used by the default async +implementation. + +No new namespace policy field is introduced. + +No new namespace admin REST endpoint or Java admin client method is introduced. + +Changing the topic-policies backend for a namespace is not a public operation in this proposal. The routing rule is +derived from `__change_events` existence plus the broker-level configuration. + +### Binary protocol + +No binary protocol changes. + +### Configuration + +- `topicPoliciesServiceClassName` + - Continues to define the broker-configured `TopicPoliciesService` implementation. + - Namespaces that do not have `__change_events` use this backend. + - Namespaces that already have `__change_events` keep using `SystemTopicBasedTopicPoliciesService` regardless of + this value. + +### CLI + +No CLI change in this proposal. + +### Metrics + +No new metric is required. + +# Backward & Forward Compatibility + +## Upgrade + +The intended upgrade flow is: + +1. Upgrade brokers to a version that understands legacy-aware backend routing. +2. Change `topicPoliciesServiceClassName` to the alternate backend if newly created namespaces should use it. +3. Existing namespaces that already have `__change_events` continue to use `SystemTopicBasedTopicPoliciesService`. +4. Namespaces that do not have `__change_events`, including newly created namespaces, use the configured backend. + +No namespace metadata backfill is required. + +This upgrade rule is intentionally conservative: + +- If `__change_events` exists, the namespace stays on the system-topic backend. +- If `__change_events` does not exist, the namespace uses the configured backend. + +This means some namespaces with an empty but already-created `__change_events` topic may continue using the +system-topic backend. That is acceptable because it avoids missing legacy state. + +Existing custom `TopicPoliciesService` implementations that only implement the synchronous `registerListener` method +continue to work through the default `registerListenerAsync` bridge. Implementations can override +`registerListenerAsync` when registration itself needs asynchronous backend resolution or initialization. + +## Downgrade / Rollback + +Rolling back to a broker version that does not understand legacy-aware routing returns topic-policies backend +selection to pure broker-wide behavior. + +- The older broker will no longer special-case namespaces that have `__change_events`. +- Operators will need to choose one broker-wide backend for the rollback cluster, or migrate data before rollback if + both legacy system-topic namespaces and metadata-store namespaces must coexist. + +## Pulsar Geo-Replication Upgrade & Downgrade/Rollback Considerations + +This proposal does not introduce a new geo-replication protocol for topic policies. + +- Global topic policies stay in the configuration metadata store and therefore keep global visibility semantics. +- Local topic policies stay in the local metadata store and therefore keep cluster-local visibility semantics. +- Legacy namespaces are recognized by the existence of `__change_events`, which is already shared broker-visible topic + metadata. + +# Alternatives + +## Keep a single broker-wide topic policies backend + +This keeps the implementation simpler, but it does not solve the operational requirement to keep existing namespaces on +their current backend while directing newly created namespaces to a different one. + +## Persist an explicit namespace backend marker + +This would also solve the upgrade problem, but it introduces new namespace-scoped metadata changes that are not +necessary for the default-system-topic upgrade path. The proposal prefers to reuse the already existing +`__change_events` artifact as the legacy marker. + +## Add a user-managed namespace override API + +This provides more flexibility than needed, but it also reintroduces runtime switching, rollback ambiguity, and the +risk of one namespace being served by different backends if brokers do not resolve the override identically. The +proposal intentionally avoids this surface. + +# General Notes + +This proposal is a follow-up to [PIP-376](pip-376.md). It keeps backend selection pluggable, but handles upgrade from +the legacy system-topic backend by reusing `__change_events` as the compatibility marker instead of introducing a new +namespace-level policy or namespace-level metadata field. + +# Links + +* Mailing List discussion thread: https://lists.apache.org/thread/sn2pyyl9p1vm5vr8j8qssxbbksm2bzfr +* Mailing List voting thread: https://lists.apache.org/thread/b5mfqrmxcwwzjbkhzv6t6t12gtvjz1so diff --git a/pom.xml b/pom.xml index 39d14df0835de..42a405e6c6e7e 100644 --- a/pom.xml +++ b/pom.xml @@ -32,7 +32,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT Pulsar Pulsar is a distributed pub-sub messaging platform with a very @@ -82,7 +82,7 @@ flexible messaging model and an intuitive client API. ${maven.compiler.target} 8 - 3.10.0 + 3.13.0 21 @@ -183,33 +183,33 @@ flexible messaging model and an intuitive client API. 4.17.4.0 3.9.5 1.11.0 - 1.14.0 + 1.15.0 1.1.10.8 4.1.12.1 5.7.1 - 4.1.133.Final + 4.1.137.Final 0.0.26.Final - 12.1.9 + 12.1.12 9.4.58.v20250814 - 2.5.2 + 2.6.2 2.42 1.10.62 0.16.0 - 4.5.27 + 4.5.28 7.9.2 - 2.0.17 + 2.0.18 4.5.0 - 2.25.4 + 2.26.1 - 1.84 - ${bouncycastle.version} + 1.85 + 1.85.2 ${bouncycastle.version} ${bouncycastle.version} - 2.0.11 - 2.0.6 - 2.0.1 - 2.18.6 + 2.0.12 + 2.0.7 + 2.0.2 + 2.18.10 8.5.16 0.10.2 1.6.2 @@ -256,7 +256,7 @@ flexible messaging model and an intuitive client API. 1.15.16.Final 0.11.1 0.28.0 - 3.4.2 + 3.5.0 3.6.2 ${hadoop3.version} 2.6.3-hadoop3 @@ -267,16 +267,15 @@ flexible messaging model and an intuitive client API. 2.0.3 2.15.0 3.19.0 - 2.21.0 - 1.20.0 - 1.3.5 + 2.22.0 + 1.22.0 + 1.3.6 2.1.6 2.1.9 3.1.0 2.9.1 0.9.0 - 2.14.6 - 3.21.0 + 4.2.1 0.9.1 2.1.0 3.27.7 @@ -294,11 +293,13 @@ flexible messaging model and an intuitive client API. 3.17.0 1.0 9.1.6 - 6.2.12 - 4.5.13 - 4.4.15 + 7.0.8 + 4.5.14 + 4.4.16 0.7.7 - 0.7.2 + 0.9.4 + 0.7.4 + 0.10.0 2.0 1.10.12 5.5.0 @@ -309,12 +310,12 @@ flexible messaging model and an intuitive client API. the core logic is switched to java implementation of zstd in org.apache.commons:commons-compress --> 1.5.7-3 2.0.6 - 1.56.0 + 1.62.0 ${opentelemetry.version}-alpha - 2.21.0 + 2.28.1 ${opentelemetry.instrumentation.version}-alpha - 1.37.0 - 4.7.5 + 1.41.1 + 4.7.7 1.8 0.3.6 3.3.2 @@ -374,9 +375,9 @@ flexible messaging model and an intuitive client API. 3.33.0 9.37.4 1.11.0 - 2.12.0 + 2.15.1 2.1.10 - 1.10.3 + 1.11.1 @@ -1314,7 +1315,12 @@ flexible messaging model and an intuitive client API. io.github.oxia-db oxia-testcontainers - ${oxia.version} + ${oxia-testcontainers.version} + + + io.github.merlimat.slog + slog + ${slog.version} diff --git a/pulsar-bom/pom.xml b/pulsar-bom/pom.xml index 8236b2f87e035..dc6a87264387f 100644 --- a/pulsar-bom/pom.xml +++ b/pulsar-bom/pom.xml @@ -33,7 +33,7 @@ com.ascentstream.pulsar pulsar-bom - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT Pulsar BOM Pulsar (Bill of Materials) diff --git a/pulsar-broker-auth-athenz/pom.xml b/pulsar-broker-auth-athenz/pom.xml index 401543e8f129e..5740b9912da5b 100644 --- a/pulsar-broker-auth-athenz/pom.xml +++ b/pulsar-broker-auth-athenz/pom.xml @@ -26,7 +26,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-broker-auth-athenz diff --git a/pulsar-broker-auth-oidc/pom.xml b/pulsar-broker-auth-oidc/pom.xml index 9beb4143c7acb..4136d46cf88cb 100644 --- a/pulsar-broker-auth-oidc/pom.xml +++ b/pulsar-broker-auth-oidc/pom.xml @@ -26,7 +26,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-broker-auth-oidc diff --git a/pulsar-broker-auth-sasl/pom.xml b/pulsar-broker-auth-sasl/pom.xml index a09cae8bb6d45..49a95e66e91ca 100644 --- a/pulsar-broker-auth-sasl/pom.xml +++ b/pulsar-broker-auth-sasl/pom.xml @@ -26,7 +26,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-broker-auth-sasl diff --git a/pulsar-broker-common/pom.xml b/pulsar-broker-common/pom.xml index 30c3714bef556..b0a27b1269089 100644 --- a/pulsar-broker-common/pom.xml +++ b/pulsar-broker-common/pom.xml @@ -26,7 +26,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-broker-common diff --git a/pulsar-broker-common/src/main/java/org/apache/pulsar/bookie/rackawareness/IsolatedBookieEnsemblePlacementPolicy.java b/pulsar-broker-common/src/main/java/org/apache/pulsar/bookie/rackawareness/IsolatedBookieEnsemblePlacementPolicy.java index 4ef1c594be444..f2c1b5e736191 100644 --- a/pulsar-broker-common/src/main/java/org/apache/pulsar/bookie/rackawareness/IsolatedBookieEnsemblePlacementPolicy.java +++ b/pulsar-broker-common/src/main/java/org/apache/pulsar/bookie/rackawareness/IsolatedBookieEnsemblePlacementPolicy.java @@ -18,6 +18,7 @@ */ package org.apache.pulsar.bookie.rackawareness; +import static java.util.Collections.emptySet; import static org.apache.pulsar.bookie.rackawareness.BookieRackAffinityMapping.METADATA_STORE_INSTANCE; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.Sets; @@ -164,11 +165,13 @@ private static Optional getEnsemblePlacementPolic return Optional.empty(); } - private static Pair, Set> getIsolationGroup( + @VisibleForTesting + Pair, Set> getIsolationGroup( EnsemblePlacementPolicyConfig ensemblePlacementPolicyConfig) { - MutablePair, Set> pair = new MutablePair<>(); - String className = IsolatedBookieEnsemblePlacementPolicy.class.getName(); - if (ensemblePlacementPolicyConfig.getPolicyClass().getName().equals(className)) { + // Retain compatibility with ZkIsolatedBookieEnsemblePlacementPolicy + Class policyClass = ensemblePlacementPolicyConfig.getPolicyClass(); + if (IsolatedBookieEnsemblePlacementPolicy.class.isAssignableFrom(policyClass)) { + MutablePair, Set> pair = new MutablePair<>(emptySet(), emptySet()); Map properties = ensemblePlacementPolicyConfig.getProperties(); String primaryIsolationGroupString = ConfigurationStringUtil .castToString(properties.getOrDefault(ISOLATION_BOOKIE_GROUPS, "")); @@ -176,21 +179,22 @@ private static Pair, Set> getIsolationGroup( .castToString(properties.getOrDefault(SECONDARY_ISOLATION_BOOKIE_GROUPS, "")); if (!primaryIsolationGroupString.isEmpty()) { pair.setLeft(Sets.newHashSet(primaryIsolationGroupString.split(","))); - } else { - pair.setLeft(Collections.emptySet()); } if (!secondaryIsolationGroupString.isEmpty()) { pair.setRight(Sets.newHashSet(secondaryIsolationGroupString.split(","))); - } else { - pair.setRight(Collections.emptySet()); } + return pair; + } else { + log.info("The ensemble placement policy class [{}] is not compatible with " + + "IsolatedBookieEnsemblePlacementPolicy, fallback to use defaultIsolationGroups", + ensemblePlacementPolicyConfig.getPolicyClass().getName()); + return defaultIsolationGroups; } - return pair; } @VisibleForTesting Set getExcludedBookiesWithIsolationGroups(int ensembleSize, - Pair, Set> isolationGroups) { + Pair, Set> isolationGroups) { Set excludedBookies = new HashSet<>(); if (isolationGroups != null && isolationGroups.getLeft().contains(PULSAR_SYSTEM_TOPIC_ISOLATION_GROUP)) { return excludedBookies; @@ -213,8 +217,8 @@ Set getExcludedBookiesWithIsolationGroups(int ensembleSize, return excludedBookies; } int totalAvailableBookiesInPrimaryGroup = 0; - Set primaryIsolationGroup = Collections.emptySet(); - Set secondaryIsolationGroup = Collections.emptySet(); + Set primaryIsolationGroup = emptySet(); + Set secondaryIsolationGroup = emptySet(); Set primaryGroupBookies = new HashSet<>(); if (isolationGroups != null) { primaryIsolationGroup = isolationGroups.getLeft(); diff --git a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/ServiceConfiguration.java b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/ServiceConfiguration.java index 9314117bed5f5..9f703f598d98f 100644 --- a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/ServiceConfiguration.java +++ b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/ServiceConfiguration.java @@ -552,6 +552,19 @@ The max allowed delay for delayed delivery (in milliseconds). If the broker rece ) private long topicLoadTimeoutSeconds = 60; + @FieldContext( + category = CATEGORY_SERVER, + dynamic = true, + doc = "Amount of seconds to timeout initializing the topic policies cache of a namespace (reading the " + + "namespace's __change_events system topic to the end). Topic loading waits for this " + + "initialization, so if the system-topic reader gets stuck (for example after __change_events is " + + "unloaded and the reconnected reader stops making progress), this bounds the wait: the broker " + + "fails the initialization, closes the stuck reader and clears the cached state so that loading " + + "the namespace's topics can be retried with a fresh reader instead of hanging until the broker " + + "is restarted. Set to 0 or a negative value to disable the timeout (not recommended)." + ) + private long topicPoliciesCacheInitTimeoutSeconds = 60; + @FieldContext( category = CATEGORY_SERVER, doc = "Whether we should enable metadata operations batching" @@ -745,6 +758,20 @@ The max allowed delay for delayed delivery (in milliseconds). If the broker rece ) private Integer brokerDeleteInactiveTopicsMaxInactiveDurationSeconds = null; + @FieldContext( + category = CATEGORY_POLICIES, + dynamic = true, + doc = "Time in seconds that a persistent geo-replication replicator may stay idle before the broker" + + " disconnects its replication producer. A replicator is eligible only when it has no backlog and" + + " has not read entries for replication processing for longer than this threshold. Disconnecting" + + " only releases the idle producer; the replicator and its cursor remain available, and the" + + " producer is recreated automatically when new messages need to be replicated. Set this value to" + + " 0 or a negative value to disable idle-replicator disconnection. The check runs with the" + + " inactive-topic monitor, whose interval is brokerDeleteInactiveTopicsFrequencySeconds, and only" + + " when brokerDeleteInactiveTopicsEnabled is true. The default is 86400 seconds (24 hours)." + ) + private int brokerReplicationInactiveThresholdSeconds = 24 * 3600; + @FieldContext( category = CATEGORY_POLICIES, dynamic = true, @@ -1731,8 +1758,25 @@ The max allowed delay for delayed delivery (in milliseconds). If the broker rece @FieldContext( category = CATEGORY_SERVER, - doc = "The class name of the topic policies service. The default config only takes affect when the " - + "systemTopicEnable config is true" + doc = "When enabled, all registered topic-policy listeners in a namespace are re-notified with the current" + + " topic policies after the namespace's topic-policy cache finishes its initial load. Topics load" + + " and apply their own policies when they are loaded, so this broadcast is normally redundant; it" + + " is only needed for custom plugins that register TopicPolicyListeners and depend on it for" + + " backwards compatibility. Disabled by default.") + private boolean topicPolicyListenerReplayEnabled = false; + + @FieldContext( + category = CATEGORY_SERVER, + doc = """ + The class name of the topic policies service. There are 2 built-in implementations: + 1. "org.apache.pulsar.broker.service.SystemTopicBasedTopicPoliciesService" (default) + It stores a topic's policies in the `__change_events` topic. If `systemTopicEnabled` is false, + the topic policies will just be disabled + 2. "org.apache.pulsar.broker.service.MetadataStoreTopicPoliciesService" + It stores a topic's policies in the metadata store. If `systemTopicEnabled` is true and the + topic's namespace has a `__change_events` topic, the policies will still be stored in the + `__change_events` topic for backward compatibility. + """ ) private String topicPoliciesServiceClassName = "org.apache.pulsar.broker.service.SystemTopicBasedTopicPoliciesService"; @@ -2437,8 +2481,7 @@ The max allowed delay for delayed delivery (in milliseconds). If the broker rece @FieldContext(category = CATEGORY_STORAGE_ML, doc = "When storing acknowledgement state, choose a more compact serialization format that stores" - + " individual acknowledgements as a bitmap which is serialized to an array of long values.\n\n" - + "NOTE: This setting requires managedLedgerUnackedRangesOpenCacheSetEnabled=true to be effective.") + + " individual acknowledgements as a bitmap which is serialized to an array of long values.") private boolean managedLedgerPersistIndividualAckAsLongArray = false; @FieldContext( @@ -2446,6 +2489,13 @@ The max allowed delay for delayed delivery (in milliseconds). If the broker rece doc = "If enabled, the maximum \"acknowledgment holes\" will not be limited and \"acknowledgment holes\" " + "are stored in multiple entries.") private boolean persistentUnackedRangesWithMultipleEntriesEnabled = false; + @FieldContext( + category = CATEGORY_STORAGE_ML, + doc = "Enables per-msgLedger cursor checkpoint persistence. When true, the cursor " + + "writes a CursorCheckpoint per flush (mark-delete ledger's ack state inline + refs " + + "to other ledgers' previously-persisted ack states) instead of the legacy single " + + "PositionInfo entry. Eliminates write amplification and avoids ack truncation.") + private boolean persistentUnackedRangesWithPerLedgerEntryEnabled = false; @Deprecated @FieldContext( category = CATEGORY_STORAGE_ML, @@ -2461,13 +2511,6 @@ The max allowed delay for delayed delivery (in milliseconds). If the broker rece + "If number of unack message range is higher than this limit then broker will persist" + " unacked ranges into bookkeeper to avoid additional data overhead into MetadataStore.") private int managedLedgerMaxUnackedRangesToPersistInMetadataStore = 1000; - @FieldContext( - category = CATEGORY_STORAGE_OFFLOADING, - doc = "When set to true, a BitSet will be used to track acknowledged messages that come after the \"mark " - + "delete position\" for each subscription.\n\nRoaringBitmap is used as a memory efficient BitSet " - + "implementation for the acknowledged messages tracking. Unacknowledged ranges are the message " - + "ranges excluding the acknowledged messages.") - private boolean managedLedgerUnackedRangesOpenCacheSetEnabled = true; @FieldContext( dynamic = true, category = CATEGORY_STORAGE_ML, @@ -3128,6 +3171,13 @@ public double getLoadBalancerBandwidthOutResourceWeight() { ) private boolean loadBalancerSheddingBundlesWithPoliciesEnabled = false; + @FieldContext( + dynamic = true, + category = CATEGORY_LOAD_BALANCER, + doc = "The namespaces to be excluded from load shedding" + ) + private Set loadBalancerSheddingExcludedNamespaces = new HashSet<>(); + @FieldContext( category = CATEGORY_LOAD_BALANCER, doc = "Time to wait before fixing any stuck in-flight service unit states. " @@ -3469,6 +3519,15 @@ public double getLoadBalancerBandwidthOutResourceWeight() { ) private boolean exposeSubscriptionBacklogSizeInPrometheus = false; + @FieldContext( + category = CATEGORY_METRICS, + doc = "Enable computing the age of the oldest unacknowledged message for each subscription and exposing " + + "it through topic stats and Prometheus.\n" + + " When disabled, the broker skips computing per-subscription backlog age and " + + "SubscriptionStats.oldestBacklogMessageAgeSeconds remains -1. Default is false." + ) + private boolean exposeSubscriptionBacklogAgeInPrometheus = false; + @FieldContext( category = CATEGORY_METRICS, doc = "Enable splitting topic and partition label in Prometheus.\n" diff --git a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/authorization/MultiRolesTokenAuthorizationProvider.java b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/authorization/MultiRolesTokenAuthorizationProvider.java index 6b46289af4811..272f6ee868dcf 100644 --- a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/authorization/MultiRolesTokenAuthorizationProvider.java +++ b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/authorization/MultiRolesTokenAuthorizationProvider.java @@ -28,6 +28,7 @@ import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.function.Function; @@ -120,6 +121,18 @@ public CompletableFuture validateTenantAdminAccess(String tenantName, S return pulsarResources.getTenantResources() .getTenantAsync(tenantName) + // Failing to read the tenant is a broker-side fault, so it is handled here, on the + // stage that can actually fail. Keeping this handler off the stage below prevents the + // expected "tenant does not exist" rejection from being reported as an error. + .exceptionally(ex -> { + Throwable cause = FutureUtil.unwrapCompletionException(ex); + if (cause instanceof MetadataStoreException.NotFoundException) { + log.warn("Failed to get tenant info data for non existing tenant {}", tenantName); + return Optional.empty(); + } + log.error("Failed to get tenant {}", tenantName, cause); + throw new RestException(cause); + }) .thenCompose(op -> { if (op.isPresent()) { TenantInfo tenantInfo = op.get(); @@ -129,17 +142,11 @@ public CompletableFuture validateTenantAdminAccess(String tenantName, S return CompletableFuture.completedFuture(roles.stream() .anyMatch(n -> tenantInfo.getAdminRoles().contains(n))); - } else { - throw new RestException(Response.Status.NOT_FOUND, "Tenant does not exist"); } - }).exceptionally(ex -> { - Throwable cause = ex.getCause(); - if (cause instanceof MetadataStoreException.NotFoundException) { - log.warn("Failed to get tenant info data for non existing tenant {}", tenantName); - throw new RestException(Response.Status.NOT_FOUND, "Tenant does not exist"); - } - log.error("Failed to get tenant {}", tenantName, cause); - throw new RestException(cause); + // A client naming a tenant that does not exist is a client error, not a broker + // fault: reject it without logging. Any client can trigger this at will, and the + // caller (e.g. ServerCnx) already logs the rejection at its own level. + throw new RestException(Response.Status.NOT_FOUND, "Tenant does not exist"); }); }); } diff --git a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/authorization/PulsarAuthorizationProvider.java b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/authorization/PulsarAuthorizationProvider.java index e4f2ac7e8cd16..8837b340f9b79 100644 --- a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/authorization/PulsarAuthorizationProvider.java +++ b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/authorization/PulsarAuthorizationProvider.java @@ -26,6 +26,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.stream.Stream; @@ -716,20 +717,26 @@ public CompletableFuture validateTenantAdminAccess(String tenantName, S } return pulsarResources.getTenantResources() .getTenantAsync(tenantName) - .thenCompose(op -> { - if (op.isPresent()) { - return isTenantAdmin(tenantName, role, op.get(), authData); - } else { - throw new RestException(Response.Status.NOT_FOUND, "Tenant does not exist"); - } - }).exceptionally(ex -> { - Throwable cause = ex.getCause(); + // Failing to read the tenant is a broker-side fault, so it is handled here, on the + // stage that can actually fail. Keeping this handler off the stage below prevents the + // expected "tenant does not exist" rejection from being reported as an error. + .exceptionally(ex -> { + Throwable cause = FutureUtil.unwrapCompletionException(ex); if (cause instanceof NotFoundException) { log.warn("Failed to get tenant info data for non existing tenant {}", tenantName); - throw new RestException(Response.Status.NOT_FOUND, "Tenant does not exist"); + return Optional.empty(); } log.error("Failed to get tenant {}", tenantName, cause); throw new RestException(cause); + }) + .thenCompose(op -> { + if (op.isPresent()) { + return isTenantAdmin(tenantName, role, op.get(), authData); + } + // A client naming a tenant that does not exist is a client error, not a broker + // fault: reject it without logging. Any client can trigger this at will, and the + // caller (e.g. ServerCnx) already logs the rejection at its own level. + throw new RestException(Response.Status.NOT_FOUND, "Tenant does not exist"); }); }); } diff --git a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/resources/MetadataStoreCacheLoader.java b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/resources/MetadataStoreCacheLoader.java index 29451148da447..7a0a41282824c 100644 --- a/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/resources/MetadataStoreCacheLoader.java +++ b/pulsar-broker-common/src/main/java/org/apache/pulsar/broker/resources/MetadataStoreCacheLoader.java @@ -25,7 +25,7 @@ import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; -import java.util.function.Supplier; +import java.util.concurrent.atomic.AtomicBoolean; import org.apache.bookkeeper.common.util.OrderedScheduler; import org.apache.commons.collections4.CollectionUtils; import org.apache.pulsar.common.util.FutureUtil; @@ -46,6 +46,7 @@ public class MetadataStoreCacheLoader implements Closeable { private volatile List availableBrokers; private final FutureUtil.Sequencer sequencer; + private final AtomicBoolean refreshInProgress = new AtomicBoolean(false); private final OrderedScheduler orderedExecutor = OrderedScheduler.newSchedulerBuilder().numThreads(8) .name("pulsar-metadata-cache-loader-ordered-cache").build(); @@ -65,40 +66,38 @@ public MetadataStoreCacheLoader(PulsarResources pulsarResources, int operationTi * @throws Exception */ public void init() throws Exception { - Supplier> tryUpdate = () -> { - return loadReportResources.getChildrenAsync(LOADBALANCE_BROKERS_ROOT) - .thenComposeAsync(brokerNodes -> { - return updateBrokerList(brokerNodes).thenRun(() -> { - log.info("Successfully updated broker info {}", brokerNodes); - }); - }) - .exceptionally(ex -> { - log.warn("Error updating broker info after broker list changed", ex); - return null; - }); - }; loadReportResources.getStore().registerListener((n) -> { if (LOADBALANCE_BROKERS_ROOT.equals(n.getPath()) && NotificationType.ChildrenChanged.equals(n.getType())) { - sequencer.sequential(tryUpdate); + sequencer.sequential(this::reloadBrokers); } }); if (loadReportResources.getStore() instanceof MetadataStoreExtended) { ((MetadataStoreExtended) loadReportResources.getStore()).registerSessionListener(sessionEvent -> - sequencer.sequential(tryUpdate)); + sequencer.sequential(this::reloadBrokers)); } // Do initial fetch of brokers list - tryUpdate.get().get(operationTimeoutMs, TimeUnit.MILLISECONDS); + reloadBrokers().get(operationTimeoutMs, TimeUnit.MILLISECONDS); + } + + private CompletableFuture reloadBrokers() { + return loadReportResources.getChildrenAsync(LOADBALANCE_BROKERS_ROOT) + .thenComposeAsync(brokerNodes -> updateBrokerList(brokerNodes).thenRun(() -> + log.info("Successfully updated broker info {}", brokerNodes))) + .exceptionally(ex -> { + log.warn("Error updating broker info after broker list changed", ex); + return null; + }); } public List getAvailableBrokers() { - if (CollectionUtils.isEmpty(availableBrokers)) { - try { - updateBrokerList(loadReportResources.getChildren(LOADBALANCE_BROKERS_ROOT)); - } catch (Exception e) { - log.warn("Error updating broker from zookeeper.", e); - } + List brokers = availableBrokers; + if (CollectionUtils.isEmpty(brokers) && refreshInProgress.compareAndSet(false, true)) { + // Avoid blocking the caller (which may be a Netty IO thread): refresh the cache in the + // background and return the current snapshot. The cache is otherwise kept up to date by the + // metadata-store listener, so an empty snapshot means there are no active brokers. + sequencer.sequential(this::reloadBrokers).whenComplete((__, ex) -> refreshInProgress.set(false)); } - return availableBrokers; + return brokers == null ? new ArrayList<>() : brokers; } @Override diff --git a/pulsar-broker-common/src/test/java/org/apache/pulsar/bookie/rackawareness/IsolatedBookieEnsemblePlacementPolicyTest.java b/pulsar-broker-common/src/test/java/org/apache/pulsar/bookie/rackawareness/IsolatedBookieEnsemblePlacementPolicyTest.java index 0dc996c7d7def..d4785d6ca83d5 100644 --- a/pulsar-broker-common/src/test/java/org/apache/pulsar/bookie/rackawareness/IsolatedBookieEnsemblePlacementPolicyTest.java +++ b/pulsar-broker-common/src/test/java/org/apache/pulsar/bookie/rackawareness/IsolatedBookieEnsemblePlacementPolicyTest.java @@ -42,12 +42,14 @@ import java.util.Set; import java.util.concurrent.CompletableFuture; import org.apache.bookkeeper.client.BKException.BKNotEnoughBookiesException; +import org.apache.bookkeeper.client.RackawareEnsemblePlacementPolicy; import org.apache.bookkeeper.conf.ClientConfiguration; import org.apache.bookkeeper.feature.SettableFeatureProvider; import org.apache.bookkeeper.net.BookieId; import org.apache.bookkeeper.net.BookieSocketAddress; import org.apache.bookkeeper.stats.NullStatsLogger; import org.apache.commons.lang3.tuple.MutablePair; +import org.apache.commons.lang3.tuple.Pair; import org.apache.pulsar.common.policies.data.BookieInfo; import org.apache.pulsar.common.policies.data.BookiesRackConfiguration; import org.apache.pulsar.common.policies.data.EnsemblePlacementPolicyConfig; @@ -57,6 +59,7 @@ import org.apache.pulsar.metadata.api.MetadataStoreFactory; import org.apache.pulsar.metadata.api.extended.MetadataStoreExtended; import org.apache.pulsar.metadata.cache.impl.MetadataCacheImpl; +import org.apache.pulsar.zookeeper.ZkIsolatedBookieEnsemblePlacementPolicy; import org.awaitility.Awaitility; import org.testng.Assert; import org.testng.annotations.AfterMethod; @@ -592,6 +595,12 @@ public void testSecondaryIsolationGroupsBookiesNegative() throws Exception { NullStatsLogger.INSTANCE, BookieSocketAddress.LEGACY_BOOKIEID_RESOLVER); isolationPolicy.onClusterChanged(writableBookies, readOnlyBookies); + // Wait for the async cache load triggered by initialize() to complete; otherwise the + // first newEnsemble call can race with an empty cachedRackConfiguration and skip isolation. + Awaitility.await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertNotNull(isolationPolicy.getBookieMappingCache() + .getIfCached(BookieRackAffinityMapping.BOOKIE_INFO_ROOT_PATH))); + try { isolationPolicy .newEnsemble(3, 3, 2, Collections.emptyMap(), new HashSet<>()).getResult(); @@ -844,6 +853,139 @@ public void testGetExcludedBookiesWithIsolationGroups() throws Exception { assertTrue(blacklist.isEmpty()); } + /** + * Regression test for the NPE reported in the stack trace below. When custom metadata carries an + * {@link EnsemblePlacementPolicyConfig} whose policy class does NOT match + * {@link IsolatedBookieEnsemblePlacementPolicy}, the old {@code getIsolationGroup()} returned a + * {@code MutablePair} with {@code null} left/right, which caused a {@link NullPointerException} in + * {@code getExcludedBookiesWithIsolationGroups} when {@code getLeft().contains(...)} was called. + * + *
+     * java.lang.NullPointerException: Cannot invoke "java.util.Set.contains(Object)"
+     *     because the return value of "org.apache.commons.lang3.tuple.Pair.getLeft()" is null
+     *     at IsolatedBookieEnsemblePlacementPolicy.getExcludedBookiesWithIsolationGroups(...)
+     *     at IsolatedBookieEnsemblePlacementPolicy.getExcludedBookies(...)
+     *     at IsolatedBookieEnsemblePlacementPolicy.replaceBookie(...)
+     * 
+ */ + @Test + public void testReplaceBookieWithNonMatchingPolicyClassShouldNotThrowNPE() throws Exception { + Map> bookieMapping = new HashMap<>(); + Map group1 = new HashMap<>(); + group1.put(BOOKIE1, BookieInfo.builder().rack("rack0").build()); + group1.put(BOOKIE2, BookieInfo.builder().rack("rack1").build()); + group1.put(BOOKIE3, BookieInfo.builder().rack("rack0").build()); + group1.put(BOOKIE4, BookieInfo.builder().rack("rack1").build()); + bookieMapping.put("group1", group1); + + store.put(BookieRackAffinityMapping.BOOKIE_INFO_ROOT_PATH, jsonMapper.writeValueAsBytes(bookieMapping), + Optional.empty()).join(); + + IsolatedBookieEnsemblePlacementPolicy isolationPolicy = new IsolatedBookieEnsemblePlacementPolicy(); + ClientConfiguration bkClientConf = new ClientConfiguration(); + bkClientConf.setProperty(BookieRackAffinityMapping.METADATA_STORE_INSTANCE, store); + bkClientConf.setProperty(IsolatedBookieEnsemblePlacementPolicy.ISOLATION_BOOKIE_GROUPS, "group1"); + isolationPolicy.initialize(bkClientConf, Optional.empty(), timer, SettableFeatureProvider.DISABLE_ALL, + NullStatsLogger.INSTANCE, BookieSocketAddress.LEGACY_BOOKIEID_RESOLVER); + isolationPolicy.onClusterChanged(writableBookies, readOnlyBookies); + + // Use a policy class that does NOT match IsolatedBookieEnsemblePlacementPolicy. + // In the old code this caused getIsolationGroup() to return a MutablePair with null left/right, + // triggering NPE at the getLeft().contains() call in getExcludedBookiesWithIsolationGroups. + EnsemblePlacementPolicyConfig policyConfig = new EnsemblePlacementPolicyConfig( + RackawareEnsemblePlacementPolicy.class, Collections.emptyMap()); + Map customMetadata = new HashMap<>(); + customMetadata.put(EnsemblePlacementPolicyConfig.ENSEMBLE_PLACEMENT_POLICY_CONFIG, policyConfig.encode()); + + BookieId bookie1Id = new BookieSocketAddress(BOOKIE1).toBookieId(); + BookieId bookie2Id = new BookieSocketAddress(BOOKIE2).toBookieId(); + + // Must not throw NullPointerException; BKNotEnoughBookiesException is acceptable. + isolationPolicy.replaceBookie(2, 2, 2, customMetadata, + Arrays.asList(bookie1Id, bookie2Id), bookie2Id, null); + } + + /** + * Verifies that {@link IsolatedBookieEnsemblePlacementPolicy#getIsolationGroup} treats + * {@link ZkIsolatedBookieEnsemblePlacementPolicy} (a subclass) exactly like + * {@link IsolatedBookieEnsemblePlacementPolicy} itself when reading isolation groups from + * {@link EnsemblePlacementPolicyConfig} properties. + * + *

Legacy Pulsar clusters may have persisted {@code EnsemblePlacementPolicyConfig} entries whose + * {@code policyClass} field is set to {@code ZkIsolatedBookieEnsemblePlacementPolicy}. The + * {@code isAssignableFrom} check in {@code getIsolationGroup} must recognise this subclass so that + * the isolation groups are read from the stored properties rather than falling back to the + * policy-level defaults. + */ + @Test + public void testGetIsolationGroupWithZkCompatiblePolicyClass() throws Exception { + // Group1 → default isolation group configured on the policy. + // Group2 → isolation group carried inside the custom metadata (ZkIsolated class). + final String defaultGroup = "Group1"; + final String customGroup = "Group2"; + + Map> bookieMapping = new HashMap<>(); + Map group1 = new HashMap<>(); + group1.put(BOOKIE1, BookieInfo.builder().rack("rack0").build()); + group1.put(BOOKIE2, BookieInfo.builder().rack("rack0").build()); + Map group2 = new HashMap<>(); + group2.put(BOOKIE3, BookieInfo.builder().rack("rack1").build()); + group2.put(BOOKIE4, BookieInfo.builder().rack("rack1").build()); + bookieMapping.put(defaultGroup, group1); + bookieMapping.put(customGroup, group2); + + store.put(BookieRackAffinityMapping.BOOKIE_INFO_ROOT_PATH, jsonMapper.writeValueAsBytes(bookieMapping), + Optional.empty()).join(); + + IsolatedBookieEnsemblePlacementPolicy isolationPolicy = new IsolatedBookieEnsemblePlacementPolicy(); + ClientConfiguration bkClientConf = new ClientConfiguration(); + bkClientConf.setProperty(BookieRackAffinityMapping.METADATA_STORE_INSTANCE, store); + bkClientConf.setProperty(IsolatedBookieEnsemblePlacementPolicy.ISOLATION_BOOKIE_GROUPS, defaultGroup); + isolationPolicy.initialize(bkClientConf, Optional.empty(), timer, SettableFeatureProvider.DISABLE_ALL, + NullStatsLogger.INSTANCE, BookieSocketAddress.LEGACY_BOOKIEID_RESOLVER); + isolationPolicy.onClusterChanged(writableBookies, readOnlyBookies); + + // --- unit-level: getIsolationGroup should parse properties, not fall back to defaults --- + Map props = new HashMap<>(); + props.put(IsolatedBookieEnsemblePlacementPolicy.ISOLATION_BOOKIE_GROUPS, customGroup); + props.put(IsolatedBookieEnsemblePlacementPolicy.SECONDARY_ISOLATION_BOOKIE_GROUPS, "secondaryGroup"); + EnsemblePlacementPolicyConfig zkConfig = new EnsemblePlacementPolicyConfig( + ZkIsolatedBookieEnsemblePlacementPolicy.class, props); + + Pair, Set> groups = isolationPolicy.getIsolationGroup(zkConfig); + assertEquals(groups.getLeft(), Sets.newHashSet(customGroup), + "primary group must be read from ZkIsolated config properties"); + assertEquals(groups.getRight(), Sets.newHashSet("secondaryGroup"), + "secondary group must be read from ZkIsolated config properties"); + + // --- integration-level: newEnsemble must select bookies from the ZkIsolated config group --- + Map placementPolicyProperties = new HashMap<>(); + placementPolicyProperties.put(IsolatedBookieEnsemblePlacementPolicy.ISOLATION_BOOKIE_GROUPS, customGroup); + placementPolicyProperties.put(IsolatedBookieEnsemblePlacementPolicy.SECONDARY_ISOLATION_BOOKIE_GROUPS, ""); + EnsemblePlacementPolicyConfig policyConfig = new EnsemblePlacementPolicyConfig( + ZkIsolatedBookieEnsemblePlacementPolicy.class, placementPolicyProperties); + Map customMetadata = new HashMap<>(); + customMetadata.put(EnsemblePlacementPolicyConfig.ENSEMBLE_PLACEMENT_POLICY_CONFIG, policyConfig.encode()); + + Set bookieIdGroup2 = new HashSet<>(); + bookieIdGroup2.add(new BookieSocketAddress(BOOKIE3).toBookieId()); + bookieIdGroup2.add(new BookieSocketAddress(BOOKIE4).toBookieId()); + + List ensemble = isolationPolicy + .newEnsemble(2, 2, 2, customMetadata, new HashSet<>()).getResult(); + assertTrue(bookieIdGroup2.containsAll(ensemble), + "ensemble should come from " + customGroup + " (ZkIsolated config), got " + ensemble); + + // Sanity-check: without custom metadata the default group1 bookies are chosen. + Set bookieIdGroup1 = new HashSet<>(); + bookieIdGroup1.add(new BookieSocketAddress(BOOKIE1).toBookieId()); + bookieIdGroup1.add(new BookieSocketAddress(BOOKIE2).toBookieId()); + List defaultEnsemble = isolationPolicy + .newEnsemble(2, 2, 2, Collections.emptyMap(), new HashSet<>()).getResult(); + assertTrue(bookieIdGroup1.containsAll(defaultEnsemble), + "default ensemble should come from " + defaultGroup + ", got " + defaultEnsemble); + } + // The policy gets the bookie info asynchronously before each query or update, when putting the bookie info into // the metadata store, the cache needs some time to receive the notification and update accordingly. private void updateBookieInfo(IsolatedBookieEnsemblePlacementPolicy isolationPolicy, byte[] bookieInfo) { diff --git a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/authorization/LogCapture.java b/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/authorization/LogCapture.java new file mode 100644 index 0000000000000..fb715d268f46b --- /dev/null +++ b/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/authorization/LogCapture.java @@ -0,0 +1,94 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.broker.authorization; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.LoggerContext; +import org.apache.logging.log4j.core.appender.AbstractAppender; +import org.apache.logging.log4j.core.config.LoggerConfig; +import org.apache.logging.log4j.core.layout.PatternLayout; + +/** + * Captures the Log4j2 events emitted by a single logger, so that tests can assert on the level a + * given condition is reported at. slog resolves to its Log4j2 backend whenever log4j-core is on the + * classpath, so this captures slog output too. + */ +final class LogCapture extends AbstractAppender implements AutoCloseable { + + private static final AtomicInteger ID = new AtomicInteger(); + + private final List events = Collections.synchronizedList(new ArrayList<>()); + private final String loggerName; + private final LoggerConfig loggerConfig; + private final LoggerContext context; + + /** + * Starts capturing the events logged by {@code loggerOwner}'s logger. The capture is released on + * {@link #close()}. + */ + static LogCapture attach(Class loggerOwner) { + LoggerContext context = (LoggerContext) LogManager.getContext(false); + LoggerConfig loggerConfig = context.getConfiguration().getLoggerConfig(loggerOwner.getName()); + LogCapture capture = new LogCapture(loggerOwner.getName(), loggerConfig, context); + capture.start(); + // The resolved LoggerConfig is often an ancestor (usually root), so this appender may also see + // events from unrelated loggers. append() filters them out by name. + loggerConfig.addAppender(capture, Level.ALL, null); + context.updateLoggers(); + return capture; + } + + private LogCapture(String loggerName, LoggerConfig loggerConfig, LoggerContext context) { + super("LogCapture-" + ID.incrementAndGet(), null, PatternLayout.createDefaultLayout(), false, null); + this.loggerName = loggerName; + this.loggerConfig = loggerConfig; + this.context = context; + } + + @Override + public void append(LogEvent event) { + if (loggerName.equals(event.getLoggerName())) { + events.add(event.toImmutable()); + } + } + + /** Returns the formatted messages captured at {@code level}, in the order they were logged. */ + List messagesAt(Level level) { + synchronized (events) { + return events.stream() + .filter(event -> event.getLevel() == level) + .map(event -> event.getMessage().getFormattedMessage()) + .collect(Collectors.toList()); + } + } + + @Override + public void close() { + stop(); + loggerConfig.removeAppender(getName()); + context.updateLoggers(); + } +} diff --git a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/authorization/MultiRolesTokenAuthorizationProviderTest.java b/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/authorization/MultiRolesTokenAuthorizationProviderTest.java index 094152a5c44a2..9dbd75a6b026c 100644 --- a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/authorization/MultiRolesTokenAuthorizationProviderTest.java +++ b/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/authorization/MultiRolesTokenAuthorizationProviderTest.java @@ -19,24 +19,32 @@ package org.apache.pulsar.broker.authorization; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; import static org.testng.Assert.assertTrue; import static org.testng.Assert.expectThrows; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; +import java.util.List; +import java.util.Optional; import java.util.Properties; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.function.Function; import javax.crypto.SecretKey; +import javax.ws.rs.core.Response; import lombok.Cleanup; +import org.apache.logging.log4j.Level; import org.apache.pulsar.broker.PulsarServerException; import org.apache.pulsar.broker.ServiceConfiguration; import org.apache.pulsar.broker.authentication.AuthenticationDataSource; import org.apache.pulsar.broker.authentication.AuthenticationDataSubscription; import org.apache.pulsar.broker.authentication.utils.AuthTokenUtils; import org.apache.pulsar.broker.resources.PulsarResources; +import org.apache.pulsar.broker.resources.TenantResources; +import org.apache.pulsar.common.util.RestException; import org.testng.annotations.Test; public class MultiRolesTokenAuthorizationProviderTest { @@ -411,4 +419,50 @@ public String getHttpHeader(String name) { assertTrue(ex.getCause().getMessage().contains( "The subscription name needs to be prefixed by the authentication role")); } + + /** + * A client asking about a tenant that does not exist is a client-side 404, not a broker fault, so + * it must not be reported at ERROR. Any client can trigger it at will simply by mistyping a tenant. + */ + @Test + public void testMissingTenantIsNotLoggedAtError() throws Exception { + String tenant = "non-existent-tenant"; + TenantResources tenantResources = mock(TenantResources.class); + when(tenantResources.getTenantAsync(tenant)) + .thenReturn(CompletableFuture.completedFuture(Optional.empty())); + PulsarResources pulsarResources = mock(PulsarResources.class); + when(pulsarResources.getTenantResources()).thenReturn(tenantResources); + + SecretKey secretKey = AuthTokenUtils.createSecretKey(SignatureAlgorithm.HS256); + String token = Jwts.builder().claim("sub", new String[]{"user-a"}).signWith(secretKey).compact(); + + MultiRolesTokenAuthorizationProvider provider = new MultiRolesTokenAuthorizationProvider(); + provider.initialize(new ServiceConfiguration(), pulsarResources); + + AuthenticationDataSource ads = new AuthenticationDataSource() { + @Override + public boolean hasDataFromHttp() { + return true; + } + + @Override + public String getHttpHeader(String name) { + if (name.equals("Authorization")) { + return "Bearer " + token; + } else { + throw new IllegalArgumentException("Wrong HTTP header"); + } + } + }; + + try (LogCapture logs = LogCapture.attach(MultiRolesTokenAuthorizationProvider.class)) { + CompletableFuture future = provider.validateTenantAdminAccess(tenant, "user-a", ads); + + ExecutionException ee = expectThrows(ExecutionException.class, future::get); + assertEquals(((RestException) ee.getCause()).getResponse().getStatus(), + Response.Status.NOT_FOUND.getStatusCode()); + assertEquals(logs.messagesAt(Level.ERROR), List.of(), + "A tenant that does not exist must not be logged at ERROR"); + } + } } diff --git a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/authorization/PulsarAuthorizationProviderTest.java b/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/authorization/PulsarAuthorizationProviderTest.java new file mode 100644 index 0000000000000..540c1ca252626 --- /dev/null +++ b/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/authorization/PulsarAuthorizationProviderTest.java @@ -0,0 +1,125 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.broker.authorization; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.expectThrows; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import javax.ws.rs.core.Response; +import org.apache.logging.log4j.Level; +import org.apache.pulsar.broker.ServiceConfiguration; +import org.apache.pulsar.broker.resources.PulsarResources; +import org.apache.pulsar.broker.resources.TenantResources; +import org.apache.pulsar.common.naming.TopicName; +import org.apache.pulsar.common.policies.data.TenantInfo; +import org.apache.pulsar.common.policies.data.TopicOperation; +import org.apache.pulsar.common.util.RestException; +import org.apache.pulsar.metadata.api.MetadataStoreException; +import org.testng.annotations.Test; + +public class PulsarAuthorizationProviderTest { + + private static final String MISSING_TENANT = "non-existent-tenant"; + private static final String ROLE = "some-role"; + + private static PulsarAuthorizationProvider providerReturning( + CompletableFuture> tenantLookup) throws Exception { + TenantResources tenantResources = mock(TenantResources.class); + when(tenantResources.getTenantAsync(MISSING_TENANT)).thenReturn(tenantLookup); + PulsarResources pulsarResources = mock(PulsarResources.class); + when(pulsarResources.getTenantResources()).thenReturn(tenantResources); + + PulsarAuthorizationProvider provider = new PulsarAuthorizationProvider(); + provider.initialize(new ServiceConfiguration(), pulsarResources); + return provider; + } + + /** A tenant that was never created: {@code getTenantAsync} succeeds with an empty Optional. */ + private static PulsarAuthorizationProvider providerWithMissingTenant() throws Exception { + return providerReturning(CompletableFuture.completedFuture(Optional.empty())); + } + + private static void assertNotFound(ExecutionException ee) { + RestException restException = (RestException) ee.getCause(); + assertEquals(restException.getResponse().getStatus(), Response.Status.NOT_FOUND.getStatusCode()); + } + + /** + * A client asking about a tenant that does not exist is a client-side 404, not a broker fault, so + * it must not be reported at ERROR. Any client can trigger it at will simply by mistyping a tenant. + */ + @Test + public void testMissingTenantIsNotLoggedAtErrorByValidateTenantAdminAccess() throws Exception { + PulsarAuthorizationProvider provider = providerWithMissingTenant(); + + try (LogCapture logs = LogCapture.attach(PulsarAuthorizationProvider.class)) { + CompletableFuture future = + provider.validateTenantAdminAccess(MISSING_TENANT, ROLE, null); + + assertNotFound(expectThrows(ExecutionException.class, future::get)); + assertEquals(logs.messagesAt(Level.ERROR), List.of(), + "A tenant that does not exist must not be logged at ERROR"); + } + } + + /** + * The path an actual LOOKUP takes: ServerCnx -> AuthorizationService -> allowTopicOperationAsync. + * One ERROR line per lookup here is what floods a broker when a misconfigured client hammers a + * cluster that does not host its tenant. + */ + @Test + public void testMissingTenantIsNotLoggedAtErrorByLookupAuthorization() throws Exception { + PulsarAuthorizationProvider provider = providerWithMissingTenant(); + TopicName topicName = TopicName.get("persistent://" + MISSING_TENANT + "/ns/topic"); + + try (LogCapture logs = LogCapture.attach(PulsarAuthorizationProvider.class)) { + CompletableFuture future = + provider.allowTopicOperationAsync(topicName, ROLE, TopicOperation.LOOKUP, null); + + assertNotFound(expectThrows(ExecutionException.class, future::get)); + assertEquals(logs.messagesAt(Level.ERROR), List.of(), + "A tenant that does not exist must not be logged at ERROR"); + } + } + + /** + * Guards the fix from over-reaching: a genuine metadata store failure is a broker-side fault and + * must still be reported at ERROR. + */ + @Test + public void testMetadataStoreFailureIsStillLoggedAtError() throws Exception { + PulsarAuthorizationProvider provider = providerReturning(CompletableFuture.failedFuture( + new MetadataStoreException("simulated metadata store failure"))); + + try (LogCapture logs = LogCapture.attach(PulsarAuthorizationProvider.class)) { + CompletableFuture future = + provider.validateTenantAdminAccess(MISSING_TENANT, ROLE, null); + + expectThrows(ExecutionException.class, future::get); + assertFalse(logs.messagesAt(Level.ERROR).isEmpty(), + "A metadata store failure must still be logged at ERROR"); + } + } +} diff --git a/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/resources/MetadataStoreCacheLoaderTest.java b/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/resources/MetadataStoreCacheLoaderTest.java new file mode 100644 index 0000000000000..e8d4d28153c94 --- /dev/null +++ b/pulsar-broker-common/src/test/java/org/apache/pulsar/broker/resources/MetadataStoreCacheLoaderTest.java @@ -0,0 +1,76 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.broker.resources; + +import static org.apache.pulsar.broker.resources.MetadataStoreCacheLoader.LOADBALANCE_BROKERS_ROOT; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import lombok.Cleanup; +import org.apache.pulsar.metadata.api.MetadataStore; +import org.apache.pulsar.policies.data.loadbalancer.LoadManagerReport; +import org.testng.annotations.Test; + +public class MetadataStoreCacheLoaderTest { + + private MetadataStoreCacheLoader newCacheLoader(LoadManagerReportResources loadReportResources) throws Exception { + MetadataStore store = mock(MetadataStore.class); + PulsarResources pulsarResources = mock(PulsarResources.class); + when(pulsarResources.getLoadReportResources()).thenReturn(loadReportResources); + when(loadReportResources.getStore()).thenReturn(store); + return new MetadataStoreCacheLoader(pulsarResources, 5000); + } + + @Test + public void testGetAvailableBrokersServesCacheWithoutBlocking() throws Exception { + LoadManagerReportResources loadReportResources = mock(LoadManagerReportResources.class); + LoadManagerReport report = mock(LoadManagerReport.class); + when(loadReportResources.getChildrenAsync(LOADBALANCE_BROKERS_ROOT)) + .thenReturn(CompletableFuture.completedFuture(List.of("broker-1"))); + when(loadReportResources.getAsync(LOADBALANCE_BROKERS_ROOT + "/broker-1")) + .thenReturn(CompletableFuture.completedFuture(Optional.of(report))); + + @Cleanup + MetadataStoreCacheLoader loader = newCacheLoader(loadReportResources); + + assertEquals(loader.getAvailableBrokers(), List.of(report)); + // The blocking, synchronous getChildren(...) must never be used: it could stall a Netty IO thread. + verify(loadReportResources, never()).getChildren(anyString()); + } + + @Test + public void testGetAvailableBrokersDoesNotBlockOnEmptyCache() throws Exception { + LoadManagerReportResources loadReportResources = mock(LoadManagerReportResources.class); + when(loadReportResources.getChildrenAsync(LOADBALANCE_BROKERS_ROOT)) + .thenReturn(CompletableFuture.completedFuture(List.of())); + + @Cleanup + MetadataStoreCacheLoader loader = newCacheLoader(loadReportResources); + + assertTrue(loader.getAvailableBrokers().isEmpty()); + verify(loadReportResources, never()).getChildren(anyString()); + } +} diff --git a/pulsar-broker/pom.xml b/pulsar-broker/pom.xml index 201114f5e2028..a68768a86c9ca 100644 --- a/pulsar-broker/pom.xml +++ b/pulsar-broker/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-broker diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/PulsarService.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/PulsarService.java index a77e09d79cad3..e74bc51a5329c 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/PulsarService.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/PulsarService.java @@ -104,6 +104,7 @@ import org.apache.pulsar.broker.rest.Topics; import org.apache.pulsar.broker.service.BrokerService; import org.apache.pulsar.broker.service.HealthChecker; +import org.apache.pulsar.broker.service.LegacyAwareTopicPoliciesService; import org.apache.pulsar.broker.service.PulsarMetadataEventSynchronizer; import org.apache.pulsar.broker.service.SystemTopicBasedTopicPoliciesService; import org.apache.pulsar.broker.service.Topic; @@ -2232,8 +2233,15 @@ private TopicPoliciesService initTopicPoliciesService() throws Exception { return TopicPoliciesService.DISABLED; } } - return (TopicPoliciesService) Reflections.createInstance(className, + final var configuredService = (TopicPoliciesService) Reflections.createInstance(className, Thread.currentThread().getContextClassLoader()); + if (!config.isSystemTopicEnabled()) { + LOG.info("[{}] System topic is disabled, using configured topic policies service without legacy routing", + className); + return configuredService; + } + return new LegacyAwareTopicPoliciesService(this, new SystemTopicBasedTopicPoliciesService(this), + configuredService); } /** diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/admin/impl/BrokersBase.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/admin/impl/BrokersBase.java index 3ee2a1285d3b8..642acc7ac372b 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/admin/impl/BrokersBase.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/admin/impl/BrokersBase.java @@ -129,8 +129,11 @@ public void getActiveBrokers(@Suspended final AsyncResponse asyncResponse) throw public void getLeaderBroker(@Suspended final AsyncResponse asyncResponse) { validateBothSuperuserAndBrokerOperation(pulsar().getConfig().getClusterName(), pulsar().getBrokerId(), BrokerOperation.GET_LEADER_BROKER) - .thenAccept(__ -> { - LeaderBroker leaderBroker = pulsar().getLeaderElectionService().getCurrentLeader() + // The authoritative read: waits for an in-progress leader election to settle + // instead of returning 404 while a re-election is still in flight. + .thenCompose(__ -> pulsar().getLeaderElectionService().readCurrentLeader()) + .thenAccept(leader -> { + LeaderBroker leaderBroker = leader .orElseThrow(() -> new RestException(Status.NOT_FOUND, "Couldn't find leader broker")); BrokerInfo brokerInfo = BrokerInfo.builder() .serviceUrl(leaderBroker.getServiceUrl()) diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/admin/impl/NamespacesBase.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/admin/impl/NamespacesBase.java index 49784fc6b3eba..4235bc661c9d4 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/admin/impl/NamespacesBase.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/admin/impl/NamespacesBase.java @@ -57,7 +57,6 @@ import org.apache.commons.lang3.mutable.MutableObject; import org.apache.pulsar.broker.PulsarServerException; import org.apache.pulsar.broker.admin.AdminResource; -import org.apache.pulsar.broker.loadbalance.LeaderBroker; import org.apache.pulsar.broker.loadbalance.extensions.ExtensibleLoadManagerImpl; import org.apache.pulsar.broker.service.BrokerServiceException; import org.apache.pulsar.broker.service.BrokerServiceException.SubscriptionBusyException; @@ -1053,42 +1052,44 @@ private CompletableFuture validateLeaderBrokerAsync() { if (this.isLeaderBroker()) { return CompletableFuture.completedFuture(null); } - Optional currentLeaderOpt = pulsar().getLeaderElectionService().getCurrentLeader(); - if (currentLeaderOpt.isEmpty()) { - String errorStr = "The current leader is empty."; - log.error(errorStr); - return FutureUtil.failedFuture(new RestException(Response.Status.PRECONDITION_FAILED, errorStr)); - } - LeaderBroker leaderBroker = pulsar().getLeaderElectionService().getCurrentLeader().get(); - String leaderBrokerId = leaderBroker.getBrokerId(); - return pulsar().getNamespaceService() - .createLookupResult(leaderBrokerId, false, null) - .thenCompose(lookupResult -> { - String redirectUrl = isRequestHttps() ? lookupResult.getLookupData().getHttpUrlTls() - : lookupResult.getLookupData().getHttpUrl(); - if (redirectUrl == null) { - log.error("Redirected broker's service url is not configured"); - return FutureUtil.failedFuture(new RestException(Response.Status.PRECONDITION_FAILED, - "Redirected broker's service url is not configured.")); - } + // The authoritative read: waits for an in-progress leader election to settle instead of + // failing the request while a re-election is still in flight. + return pulsar().getLeaderElectionService().readCurrentLeader().thenCompose(currentLeaderOpt -> { + if (currentLeaderOpt.isEmpty()) { + String errorStr = "The current leader is empty."; + log.error(errorStr); + return FutureUtil.failedFuture(new RestException(Response.Status.PRECONDITION_FAILED, errorStr)); + } + String leaderBrokerId = currentLeaderOpt.get().getBrokerId(); + return pulsar().getNamespaceService() + .createLookupResult(leaderBrokerId, false, null) + .thenCompose(lookupResult -> { + String redirectUrl = isRequestHttps() ? lookupResult.getLookupData().getHttpUrlTls() + : lookupResult.getLookupData().getHttpUrl(); + if (redirectUrl == null) { + log.error("Redirected broker's service url is not configured"); + return FutureUtil.failedFuture(new RestException(Response.Status.PRECONDITION_FAILED, + "Redirected broker's service url is not configured.")); + } - try { - URL url = new URL(redirectUrl); - URI redirect = UriBuilder.fromUri(uri.getRequestUri()).host(url.getHost()) - .port(url.getPort()) - .replaceQueryParam("authoritative", - false).build(); - // Redirect - if (log.isDebugEnabled()) { - log.debug("Redirecting the request call to leader - {}", redirect); + try { + URL url = new URL(redirectUrl); + URI redirect = UriBuilder.fromUri(uri.getRequestUri()).host(url.getHost()) + .port(url.getPort()) + .replaceQueryParam("authoritative", + false).build(); + // Redirect + if (log.isDebugEnabled()) { + log.debug("Redirecting the request call to leader - {}", redirect); + } + return FutureUtil.failedFuture(( + new WebApplicationException(Response.temporaryRedirect(redirect).build()))); + } catch (MalformedURLException exception) { + log.error("The redirect url is malformed - {}", redirectUrl); + return FutureUtil.failedFuture(new RestException(exception)); } - return FutureUtil.failedFuture(( - new WebApplicationException(Response.temporaryRedirect(redirect).build()))); - } catch (MalformedURLException exception) { - log.error("The redirect url is malformed - {}", redirectUrl); - return FutureUtil.failedFuture(new RestException(exception)); - } - }); + }); + }); } public CompletableFuture setNamespaceBundleAffinityAsync(String bundleRange, String destinationBroker) { diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/admin/impl/PersistentTopicsBase.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/admin/impl/PersistentTopicsBase.java index fac2c6e249ff1..5a59fea316670 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/admin/impl/PersistentTopicsBase.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/admin/impl/PersistentTopicsBase.java @@ -18,6 +18,7 @@ */ package org.apache.pulsar.broker.admin.impl; +import static org.apache.bookkeeper.mledger.ManagedCursor.CURSOR_INTERNAL_PROPERTY_PREFIX; import static org.apache.pulsar.common.api.proto.CompressionType.NONE; import static org.apache.pulsar.common.naming.SystemTopicNames.isSystemTopic; import static org.apache.pulsar.common.naming.SystemTopicNames.isTransactionCoordinatorAssign; @@ -464,6 +465,9 @@ protected CompletableFuture internalCreateNonPartitionedTopicAsync(boolean // We must not re-create non-durable subscriptions on the new partitions .stream().filter(entry -> entry.getValue().isDurable()) .map(entry -> { + Map subscriptionProperties = + filterSubscriptionPropertiesForPartitionExpansion( + entry.getValue().getSubscriptionProperties()); final List> innerFutures = new ArrayList<>(expectPartitions); for (int i = 0; i < expectPartitions; i++) { @@ -471,7 +475,7 @@ protected CompletableFuture internalCreateNonPartitionedTopicAsync(boolean topicName.getPartition(i).toString(), entry.getKey(), MessageId.earliest, entry.getValue().isReplicated(), - entry.getValue().getSubscriptionProperties()) + subscriptionProperties) .exceptionally(ex -> { Throwable rc = FutureUtil.unwrapCompletionException(ex); @@ -533,6 +537,23 @@ protected CompletableFuture internalCreateNonPartitionedTopicAsync(boolean }); } + /** + * Returns the properties that can be applied to a subscription on a newly created partition. + * + *

Cursor internal properties belong to the source partition and must not be copied. Keep all filtering + * rules here so partition expansion has a single property-copy boundary.

+ */ + private static Map filterSubscriptionPropertiesForPartitionExpansion( + Map subscriptionProperties) { + if (subscriptionProperties == null) { + return null; + } + + Map filteredProperties = new HashMap<>(subscriptionProperties); + filteredProperties.keySet().removeIf(key -> key.startsWith(CURSOR_INTERNAL_PROPERTY_PREFIX)); + return filteredProperties; + } + private CompletableFuture> getReplicationClusters() { return namespaceResources().getPoliciesAsync(namespaceName).thenCompose(optionalPolicies -> { if (optionalPolicies.isEmpty()) { @@ -3013,9 +3034,8 @@ protected CompletableFuture internalPeekNthMessageAsync(String subName PersistentReplicator repl = getReplicatorReference(subName, (PersistentTopic) topic); entry = repl.peekNthMessage(messagePosition); } else { - PersistentSubscription sub = - (PersistentSubscription) getSubscriptionReference(subName, (PersistentTopic) topic); - entry = sub.peekNthMessage(messagePosition); + entry = findOrCreateSubscriptionAsync(subName, (PersistentTopic) topic) + .thenCompose(sub -> sub.peekNthMessage(messagePosition)); } } return entry.thenApply(e -> Pair.of(e, (PersistentTopic) topic)); @@ -3226,6 +3246,16 @@ private Response generateResponseWithEntry(Entry entry, PersistentTopic persiste if (metadata.hasTxnidMostBits()) { responseBuilder.header("X-Pulsar-txnid-most-bits", metadata.getTxnidMostBits()); } + if (metadata.hasTxnidMostBits() && metadata.hasTxnidLeastBits()) { + TxnID txnID = new TxnID(metadata.getTxnidMostBits(), metadata.getTxnidLeastBits()); + boolean isTxnAborted = persistentTopic.isTxnAborted(txnID, entry.getPosition()); + responseBuilder.header("X-Pulsar-txn-aborted", isTxnAborted); + boolean isTxnUncommitted = persistentTopic.isTxnOngoing(txnID); + responseBuilder.header("X-Pulsar-txn-uncommitted", isTxnUncommitted); + } + boolean isTxnConsumable = entry.getPosition() + .compareTo(persistentTopic.getMaxReadPosition()) <= 0; + responseBuilder.header("X-Pulsar-txn-consumable", isTxnConsumable); if (metadata.hasHighestSequenceId()) { responseBuilder.header("X-Pulsar-highest-sequence-id", metadata.getHighestSequenceId()); } @@ -3244,14 +3274,6 @@ private Response generateResponseWithEntry(Entry entry, PersistentTopic persiste if (metadata.hasNullPartitionKey()) { responseBuilder.header("X-Pulsar-null-partition-key", metadata.isNullPartitionKey()); } - if (metadata.hasTxnidMostBits() && metadata.hasTxnidLeastBits()) { - TxnID txnID = new TxnID(metadata.getTxnidMostBits(), metadata.getTxnidLeastBits()); - boolean isTxnAborted = persistentTopic.isTxnAborted(txnID, entry.getPosition()); - responseBuilder.header("X-Pulsar-txn-aborted", isTxnAborted); - } - boolean isTxnUncommitted = (entry.getPosition()) - .compareTo(persistentTopic.getMaxReadPosition()) > 0; - responseBuilder.header("X-Pulsar-txn-uncommitted", isTxnUncommitted); // Decode if needed CompressionCodec codec = CompressionCodecProvider @@ -4582,20 +4604,35 @@ private CompletableFuture topicNotFoundReasonAsync(TopicName topicName) { } /** - * Get the Subscription object reference from the Topic reference. + * Find the subscription or create it when automatic subscription creation is allowed. */ - private Subscription getSubscriptionReference(String subName, PersistentTopic topic) { - try { - Subscription sub = topic.getSubscription(subName); - if (sub == null) { - sub = topic.createSubscription(subName, - InitialPosition.Earliest, false, null).get(); - } - - return checkNotNull(sub); - } catch (Exception e) { - throw new RestException(Status.NOT_FOUND, getSubNotFoundErrorMessage(topicName.toString(), subName)); + private CompletableFuture findOrCreateSubscriptionAsync(String subName, PersistentTopic topic) { + Subscription subscription = topic.getSubscription(subName); + if (subscription != null) { + return CompletableFuture.completedFuture(subscription); } + + return pulsar().getBrokerService().isAllowAutoSubscriptionCreationAsync(topicName) + .thenCompose(isAllowed -> { + Subscription existingSubscription = topic.getSubscription(subName); + if (existingSubscription != null) { + return CompletableFuture.completedFuture(existingSubscription); + } + if (!isAllowed) { + return CompletableFuture.failedFuture(new RestException(Status.PRECONDITION_FAILED, + String.format("Subscription %s does not exist for topic %s and automatic " + + "subscription creation is disabled", + subName, topicName))); + } + return topic.createSubscription(subName, InitialPosition.Earliest, false, null) + .handle((createdSubscription, ex) -> { + if (ex != null || createdSubscription == null) { + throw new RestException(Status.NOT_FOUND, + getSubNotFoundErrorMessage(topicName.toString(), subName)); + } + return createdSubscription; + }); + }); } /** diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/admin/v2/PersistentTopics.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/admin/v2/PersistentTopics.java index 3706194d93199..e06d76918b20c 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/admin/v2/PersistentTopics.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/admin/v2/PersistentTopics.java @@ -2007,7 +2007,9 @@ public void resetCursorOnPosition( @ApiResponse(code = 404, message = "Namespace or topic, subscription or the message position does not" + " exist"), @ApiResponse(code = 405, message = "Skipping messages on a non-persistent topic is not allowed"), - @ApiResponse(code = 412, message = "Topic name is not valid"), + @ApiResponse(code = 412, + message = "Topic name is not valid, or the subscription does not exist and automatic " + + "subscription creation is disabled"), @ApiResponse(code = 500, message = "Internal server error"), @ApiResponse(code = 503, message = "Failed to validate global cluster configuration")}) public void peekNthMessage( diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/AbstractDelayedDeliveryTracker.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/AbstractDelayedDeliveryTracker.java index bec5134c4f79a..47753335db8cc 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/AbstractDelayedDeliveryTracker.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/AbstractDelayedDeliveryTracker.java @@ -29,7 +29,7 @@ @Slf4j public abstract class AbstractDelayedDeliveryTracker implements DelayedDeliveryTracker, TimerTask { - protected final AbstractPersistentDispatcherMultipleConsumers dispatcher; + protected final DelayedDeliveryContext context; // Reference to the shared (per-broker) timer for delayed delivery protected final Timer timer; @@ -48,24 +48,39 @@ public abstract class AbstractDelayedDeliveryTracker implements DelayedDeliveryT protected final Clock clock; private final boolean isDelayedDeliveryDeliverAtTimeStrict; + private final Object triggerLock; public AbstractDelayedDeliveryTracker(AbstractPersistentDispatcherMultipleConsumers dispatcher, Timer timer, long tickTimeMillis, boolean isDelayedDeliveryDeliverAtTimeStrict) { - this(dispatcher, timer, tickTimeMillis, Clock.systemUTC(), isDelayedDeliveryDeliverAtTimeStrict); + this(new DispatcherDelayedDeliveryContext(dispatcher), timer, tickTimeMillis, + Clock.systemUTC(), isDelayedDeliveryDeliverAtTimeStrict); } public AbstractDelayedDeliveryTracker(AbstractPersistentDispatcherMultipleConsumers dispatcher, Timer timer, long tickTimeMillis, Clock clock, boolean isDelayedDeliveryDeliverAtTimeStrict) { - this.dispatcher = dispatcher; + this(new DispatcherDelayedDeliveryContext(dispatcher), timer, tickTimeMillis, + clock, isDelayedDeliveryDeliverAtTimeStrict); + } + + public AbstractDelayedDeliveryTracker(DelayedDeliveryContext context, Timer timer, + long tickTimeMillis, + boolean isDelayedDeliveryDeliverAtTimeStrict) { + this(context, timer, tickTimeMillis, Clock.systemUTC(), isDelayedDeliveryDeliverAtTimeStrict); + } + + public AbstractDelayedDeliveryTracker(DelayedDeliveryContext context, Timer timer, + long tickTimeMillis, Clock clock, + boolean isDelayedDeliveryDeliverAtTimeStrict) { + this.context = context; + this.triggerLock = context.getTriggerLock(); this.timer = timer; this.tickTimeMillis = tickTimeMillis; this.clock = clock; this.isDelayedDeliveryDeliverAtTimeStrict = isDelayedDeliveryDeliverAtTimeStrict; } - /** * When {@link #isDelayedDeliveryDeliverAtTimeStrict} is false, we allow for early delivery by as much as the * {@link #tickTimeMillis} because it is a slight optimization to let messages skip going back into the delay @@ -124,7 +139,7 @@ protected void updateTimer() { long calculatedDelayMillis = Math.max(delayMillis, remainingTickDelayMillis); if (log.isDebugEnabled()) { - log.debug("[{}] Start timer in {} millis", dispatcher.getName(), calculatedDelayMillis); + log.debug("[{}] Start timer in {} millis", context.getName(), calculatedDelayMillis); } // Even though we may delay longer than this timestamp because of the tick delay, we still track the @@ -136,17 +151,17 @@ protected void updateTimer() { @Override public void run(Timeout timeout) throws Exception { if (log.isDebugEnabled()) { - log.debug("[{}] Timer triggered", dispatcher.getName()); + log.debug("[{}] Timer triggered", context.getName()); } if (timeout == null || timeout.isCancelled()) { return; } - synchronized (dispatcher) { + synchronized (triggerLock) { lastTickRun = clock.millis(); currentTimeoutTarget = -1; this.timeout = null; - dispatcher.readMoreEntriesAsync(); + context.triggerReadMoreEntries(); } } diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/DelayedDeliveryContext.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/DelayedDeliveryContext.java new file mode 100644 index 0000000000000..a94d5258f1945 --- /dev/null +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/DelayedDeliveryContext.java @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.broker.delayed; + +import org.apache.bookkeeper.mledger.ManagedCursor; + +public interface DelayedDeliveryContext { + + String getName(); + + ManagedCursor getCursor(); + + Object getTriggerLock(); + + void triggerReadMoreEntries(); +} diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/DelayedDeliveryTracker.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/DelayedDeliveryTracker.java index 7c954879fe845..96e7151e7ea45 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/DelayedDeliveryTracker.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/DelayedDeliveryTracker.java @@ -86,6 +86,16 @@ public interface DelayedDeliveryTracker extends AutoCloseable { */ void close(); + /** + * Close the subscription tracker and release all resources, completing the returned future once the + * tracker has finished closing. Prefer this over {@link #close()} on asynchronous paths (e.g. inside a + * {@link CompletableFuture} continuation) so the caller is not blocked. + */ + default CompletableFuture closeAsync() { + close(); + return CompletableFuture.completedFuture(null); + } + DelayedDeliveryTracker DISABLE = new DelayedDeliveryTracker() { @Override public boolean addMessage(long ledgerId, long entryId, long deliveryAt) { diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/DelayedMessageIndex.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/DelayedMessageIndex.java new file mode 100644 index 0000000000000..3eb05bae5c3fc --- /dev/null +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/DelayedMessageIndex.java @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.broker.delayed; + +import it.unimi.dsi.fastutil.longs.Long2ObjectMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import javax.annotation.concurrent.ThreadSafe; +import org.apache.pulsar.common.util.collections.LongBitmap; +import org.apache.pulsar.common.util.collections.LongBitmaps; + +/** + * Runtime truth for delayed messages that have been accepted but not yet delivered. + * Co-locates the bitmap and its cardinality so the counter is an invariant of the bitmap + * rather than a discipline callers must maintain. Used by both the in-memory and the bucket + * delayed delivery trackers; the bucket trackers' frozen snapshot bitmaps for BookKeeper + * writes/merge are intentionally not consulted here. + */ +@ThreadSafe +public final class DelayedMessageIndex { + + private final Long2ObjectMap inflightIndex = new Long2ObjectOpenHashMap<>(); + private final AtomicLong size = new AtomicLong(0); + + /** + * Idempotent: re-tracking a position already in the index is a no-op. + * + * @return true if the position was newly tracked, false if it was already present + */ + public boolean track(long ledgerId, long entryId) { + if (inflightIndex.computeIfAbsent(ledgerId, k -> LongBitmaps.create()).checkedAdd(entryId)) { + size.incrementAndGet(); + return true; + } + return false; + } + + /** @return true if the bit was present and removed; false if it was already absent. */ + public boolean untrack(long ledgerId, long entryId) { + LongBitmap bitSet = inflightIndex.get(ledgerId); + if (bitSet == null || !bitSet.contains(entryId)) { + return false; + } + bitSet.remove(entryId); + if (bitSet.isEmpty()) { + inflightIndex.remove(ledgerId); + } + size.decrementAndGet(); + return true; + } + + public boolean contains(long ledgerId, long entryId) { + LongBitmap bitSet = inflightIndex.get(ledgerId); + return bitSet != null && bitSet.contains(entryId); + } + + public long size() { + return size.get(); + } + + public void clear() { + inflightIndex.clear(); + size.set(0); + } + + /** + * Bulk-load after recovery. Built on {@link #track} so overlapping bits merge, not double-count. + */ + public void restore(Map snapshot) { + snapshot.forEach((ledgerId, bitmap) -> + bitmap.forEachLong(entryId -> track(ledgerId, entryId))); + } +} diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/DispatcherDelayedDeliveryContext.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/DispatcherDelayedDeliveryContext.java new file mode 100644 index 0000000000000..2ea388d504c24 --- /dev/null +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/DispatcherDelayedDeliveryContext.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.broker.delayed; + +import org.apache.bookkeeper.mledger.ManagedCursor; +import org.apache.pulsar.broker.service.persistent.AbstractPersistentDispatcherMultipleConsumers; + +public class DispatcherDelayedDeliveryContext implements DelayedDeliveryContext { + + private final AbstractPersistentDispatcherMultipleConsumers dispatcher; + + public DispatcherDelayedDeliveryContext(AbstractPersistentDispatcherMultipleConsumers dispatcher) { + this.dispatcher = dispatcher; + } + + @Override + public String getName() { + return dispatcher.getName(); + } + + @Override + public ManagedCursor getCursor() { + return dispatcher.getCursor(); + } + + @Override + public Object getTriggerLock() { + return dispatcher; + } + + @Override + public void triggerReadMoreEntries() { + dispatcher.readMoreEntriesAsync(); + } +} diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/InMemoryDelayedDeliveryTracker.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/InMemoryDelayedDeliveryTracker.java index bdc6e4c814e33..b5e6277064cda 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/InMemoryDelayedDeliveryTracker.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/InMemoryDelayedDeliveryTracker.java @@ -52,19 +52,34 @@ public class InMemoryDelayedDeliveryTracker extends AbstractDelayedDeliveryTrack // Track whether we have seen all messages with fixed delay so far. private boolean messagesHaveFixedDelay = true; + // Dedup index of in-flight delayed messages; the counter is an invariant of the bitmap + // so stats reads stay lock-free and duplicate positions are tracked once (#26251). + @Getter + @VisibleForTesting + private final DelayedMessageIndex index = new DelayedMessageIndex(); + InMemoryDelayedDeliveryTracker(AbstractPersistentDispatcherMultipleConsumers dispatcher, Timer timer, long tickTimeMillis, boolean isDelayedDeliveryDeliverAtTimeStrict, long fixedDelayDetectionLookahead) { - this(dispatcher, timer, tickTimeMillis, Clock.systemUTC(), isDelayedDeliveryDeliverAtTimeStrict, - fixedDelayDetectionLookahead); + this(new DispatcherDelayedDeliveryContext(dispatcher), timer, tickTimeMillis, Clock.systemUTC(), + isDelayedDeliveryDeliverAtTimeStrict, fixedDelayDetectionLookahead); } + @VisibleForTesting public InMemoryDelayedDeliveryTracker(AbstractPersistentDispatcherMultipleConsumers dispatcher, Timer timer, long tickTimeMillis, Clock clock, boolean isDelayedDeliveryDeliverAtTimeStrict, long fixedDelayDetectionLookahead) { - super(dispatcher, timer, tickTimeMillis, clock, isDelayedDeliveryDeliverAtTimeStrict); + this(new DispatcherDelayedDeliveryContext(dispatcher), timer, tickTimeMillis, clock, + isDelayedDeliveryDeliverAtTimeStrict, fixedDelayDetectionLookahead); + } + + private InMemoryDelayedDeliveryTracker(DelayedDeliveryContext context, Timer timer, + long tickTimeMillis, Clock clock, + boolean isDelayedDeliveryDeliverAtTimeStrict, + long fixedDelayDetectionLookahead) { + super(context, timer, tickTimeMillis, clock, isDelayedDeliveryDeliverAtTimeStrict); this.fixedDelayDetectionLookahead = fixedDelayDetectionLookahead; } @@ -76,11 +91,13 @@ public boolean addMessage(long ledgerId, long entryId, long deliverAt) { } if (log.isDebugEnabled()) { - log.debug("[{}] Add message {}:{} -- Delivery in {} ms ", dispatcher.getName(), ledgerId, entryId, + log.debug("[{}] Add message {}:{} -- Delivery in {} ms ", context.getName(), ledgerId, entryId, deliverAt - clock.millis()); } - priorityQueue.add(deliverAt, ledgerId, entryId); + if (index.track(ledgerId, entryId)) { + priorityQueue.add(deliverAt, ledgerId, entryId); + } updateTimer(); checkAndUpdateHighest(deliverAt); @@ -132,11 +149,12 @@ public NavigableSet getScheduledMessages(int maxMessages) { positions.add(PositionFactory.create(ledgerId, entryId)); priorityQueue.pop(); + index.untrack(ledgerId, entryId); --n; } if (log.isDebugEnabled()) { - log.debug("[{}] Get scheduled messages - found {}", dispatcher.getName(), positions.size()); + log.debug("[{}] Get scheduled messages - found {}", context.getName(), positions.size()); } if (priorityQueue.isEmpty()) { @@ -152,12 +170,13 @@ public NavigableSet getScheduledMessages(int maxMessages) { @Override public CompletableFuture clear() { this.priorityQueue.clear(); + this.index.clear(); return CompletableFuture.completedFuture(null); } @Override public long getNumberOfDelayedMessages() { - return priorityQueue.size(); + return index.size(); } @Override diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/NoopDelayedDeliveryContext.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/NoopDelayedDeliveryContext.java new file mode 100644 index 0000000000000..826e127df005a --- /dev/null +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/NoopDelayedDeliveryContext.java @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.broker.delayed; + +import org.apache.bookkeeper.mledger.ManagedCursor; + +public class NoopDelayedDeliveryContext implements DelayedDeliveryContext { + + private final String name; + private final ManagedCursor cursor; + private final Object triggerLock = new Object(); + + public NoopDelayedDeliveryContext(String name, ManagedCursor cursor) { + this.name = name; + this.cursor = cursor; + } + + @Override + public String getName() { + return name; + } + + @Override + public ManagedCursor getCursor() { + return cursor; + } + + @Override + public Object getTriggerLock() { + return triggerLock; + } + + @Override + public void triggerReadMoreEntries() { + // no-op; for tests/JMH + } +} diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/Bucket.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/Bucket.java deleted file mode 100644 index 776f99b120c3d..0000000000000 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/Bucket.java +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package org.apache.pulsar.broker.delayed.bucket; - -import static org.apache.bookkeeper.mledger.util.Futures.executeWithRetry; -import static org.apache.pulsar.broker.delayed.bucket.BucketDelayedDeliveryTracker.DELAYED_BUCKET_KEY_PREFIX; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.extern.slf4j.Slf4j; -import org.apache.bookkeeper.mledger.ManagedCursor; -import org.apache.bookkeeper.mledger.ManagedLedgerException; -import org.apache.pulsar.broker.delayed.proto.SnapshotMetadata; -import org.apache.pulsar.broker.delayed.proto.SnapshotSegment; -import org.apache.pulsar.common.util.Codec; -import org.apache.pulsar.common.util.FutureUtil; -import org.roaringbitmap.RoaringBitmap; - -@Slf4j -@Data -@AllArgsConstructor -abstract class Bucket { - - static final String DELIMITER = "_"; - static final int MaxRetryTimes = 3; - - protected final String dispatcherName; - - protected final ManagedCursor cursor; - - protected final FutureUtil.Sequencer sequencer; - - protected final BucketSnapshotStorage bucketSnapshotStorage; - - long startLedgerId; - long endLedgerId; - - Map delayedIndexBitMap; - - long numberBucketDelayedMessages; - - int lastSegmentEntryId; - - volatile int currentSegmentEntryId; - - volatile long snapshotLength; - - private volatile Long bucketId; - - private volatile CompletableFuture snapshotCreateFuture; - - - Bucket(String dispatcherName, ManagedCursor cursor, FutureUtil.Sequencer sequencer, - BucketSnapshotStorage storage, long startLedgerId, long endLedgerId) { - this(dispatcherName, cursor, sequencer, storage, startLedgerId, endLedgerId, new HashMap<>(), -1, -1, 0, 0, - null, null); - } - - boolean containsMessage(long ledgerId, long entryId) { - RoaringBitmap bitSet = delayedIndexBitMap.get(ledgerId); - if (bitSet == null) { - return false; - } - return bitSet.contains(entryId, entryId + 1); - } - - void putIndexBit(long ledgerId, long entryId) { - delayedIndexBitMap.computeIfAbsent(ledgerId, k -> new RoaringBitmap()).add(entryId, entryId + 1); - } - - boolean removeIndexBit(long ledgerId, long entryId) { - boolean contained = false; - RoaringBitmap bitSet = delayedIndexBitMap.get(ledgerId); - if (bitSet != null && bitSet.contains(entryId, entryId + 1)) { - contained = true; - bitSet.remove(entryId, entryId + 1); - - if (bitSet.isEmpty()) { - delayedIndexBitMap.remove(ledgerId); - } - - if (numberBucketDelayedMessages > 0) { - numberBucketDelayedMessages--; - } - } - return contained; - } - - String bucketKey() { - return String.join(DELIMITER, DELAYED_BUCKET_KEY_PREFIX, String.valueOf(startLedgerId), - String.valueOf(endLedgerId)); - } - - Optional> getSnapshotCreateFuture() { - return Optional.ofNullable(snapshotCreateFuture); - } - - Optional getBucketId() { - return Optional.ofNullable(bucketId); - } - - long getAndUpdateBucketId() { - Optional bucketIdOptional = getBucketId(); - if (bucketIdOptional.isPresent()) { - return bucketIdOptional.get(); - } - - String bucketIdStr = cursor.getCursorProperties().get(bucketKey()); - long bucketId = Long.parseLong(bucketIdStr); - setBucketId(bucketId); - return bucketId; - } - - CompletableFuture asyncSaveBucketSnapshot( - ImmutableBucket bucket, SnapshotMetadata snapshotMetadata, - List bucketSnapshotSegments) { - final String bucketKey = bucket.bucketKey(); - final String cursorName = Codec.decode(cursor.getName()); - final String topicName = dispatcherName.substring(0, dispatcherName.lastIndexOf(" / " + cursorName)); - return executeWithRetry( - () -> bucketSnapshotStorage.createBucketSnapshot(snapshotMetadata, bucketSnapshotSegments, bucketKey, - topicName, cursorName) - .whenComplete((__, ex) -> { - if (ex != null) { - log.warn("[{}] Failed to create bucket snapshot, bucketKey: {}", - dispatcherName, bucketKey, ex); - } - }), BucketSnapshotPersistenceException.class, MaxRetryTimes).thenCompose(newBucketId -> { - bucket.setBucketId(newBucketId); - - return putBucketKeyId(bucketKey, newBucketId).exceptionally(ex -> { - log.warn("[{}] Failed to record bucketId to cursor property, bucketKey: {}, bucketId: {}", - dispatcherName, bucketKey, newBucketId, ex); - return null; - }).thenApply(__ -> newBucketId); - }); - } - - private CompletableFuture putBucketKeyId(String bucketKey, Long bucketId) { - if (bucketId == null) { - return FutureUtil.failedFuture(new NullPointerException("Expected bucketId should not be null")); - } - return sequencer.sequential(() -> { - return executeWithRetry(() -> cursor.putCursorProperty(bucketKey, String.valueOf(bucketId)), - ManagedLedgerException.BadVersionException.class, MaxRetryTimes); - }); - } - - protected CompletableFuture removeBucketCursorProperty(String bucketKey) { - return sequencer.sequential(() -> { - return executeWithRetry(() -> cursor.removeCursorProperty(bucketKey), - ManagedLedgerException.BadVersionException.class, MaxRetryTimes); - }); - } -} diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/BucketContext.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/BucketContext.java new file mode 100644 index 0000000000000..cf1d7bfe981cb --- /dev/null +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/BucketContext.java @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.broker.delayed.bucket; + +import org.apache.bookkeeper.mledger.ManagedCursor; +import org.apache.pulsar.common.util.FutureUtil; + +record BucketContext( + String dispatcherName, + ManagedCursor cursor, + FutureUtil.Sequencer sequencer, + BucketSnapshotStorage bucketSnapshotStorage) { +} diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedDeliveryTracker.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedDeliveryTracker.java index 9ed304c73312d..471cbd7272a26 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedDeliveryTracker.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedDeliveryTracker.java @@ -20,24 +20,25 @@ import static com.google.common.base.Preconditions.checkArgument; import static org.apache.bookkeeper.mledger.ManagedCursor.CURSOR_INTERNAL_PROPERTY_PREFIX; -import static org.apache.pulsar.broker.delayed.bucket.Bucket.DELIMITER; +import static org.apache.pulsar.broker.delayed.bucket.ImmutableBucket.DELIMITER; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.Range; import com.google.common.collect.RangeMap; import com.google.common.collect.TreeRangeMap; import io.netty.util.Timeout; import io.netty.util.Timer; +import it.unimi.dsi.fastutil.longs.Long2ObjectMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import java.time.Clock; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; -import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.NavigableSet; -import java.util.Optional; import java.util.TreeSet; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @@ -48,20 +49,23 @@ import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.apache.bookkeeper.mledger.ManagedCursor; +import org.apache.bookkeeper.mledger.ManagedLedger; import org.apache.bookkeeper.mledger.Position; import org.apache.bookkeeper.mledger.PositionFactory; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.collections4.MapUtils; -import org.apache.commons.lang3.mutable.MutableLong; import org.apache.commons.lang3.tuple.Pair; import org.apache.pulsar.broker.delayed.AbstractDelayedDeliveryTracker; +import org.apache.pulsar.broker.delayed.DelayedDeliveryContext; +import org.apache.pulsar.broker.delayed.DelayedMessageIndex; +import org.apache.pulsar.broker.delayed.DispatcherDelayedDeliveryContext; import org.apache.pulsar.broker.delayed.proto.DelayedIndex; import org.apache.pulsar.broker.delayed.proto.SnapshotSegment; import org.apache.pulsar.broker.service.persistent.AbstractPersistentDispatcherMultipleConsumers; import org.apache.pulsar.common.policies.data.stats.TopicMetricBean; import org.apache.pulsar.common.util.FutureUtil; +import org.apache.pulsar.common.util.collections.LongBitmap; import org.apache.pulsar.common.util.collections.TripleLongPriorityQueue; -import org.roaringbitmap.RoaringBitmap; @Slf4j @ThreadSafe @@ -91,7 +95,9 @@ public static record SnapshotKey(long ledgerId, long entryId) {} private final int maxNumBuckets; - private final AtomicLong numberDelayedMessages = new AtomicLong(0); + @Getter + @VisibleForTesting + private final BucketContext ctx; @Getter @@ -106,12 +112,26 @@ public static record SnapshotKey(long ledgerId, long entryId) {} @VisibleForTesting private final RangeMap immutableBuckets; + @Getter + @VisibleForTesting + private final DelayedMessageIndex index = new DelayedMessageIndex(); + + @Getter + @VisibleForTesting + private final AtomicLong bucketsCount = new AtomicLong(0); + + @Getter + @VisibleForTesting + private final AtomicLong totalSnapshotLengthBytes = new AtomicLong(0); + private final ConcurrentHashMap snapshotSegmentLastIndexMap; private final BucketDelayedMessageIndexStats stats; private CompletableFuture pendingLoad = null; + private volatile CompletableFuture trimFuture; + public BucketDelayedDeliveryTracker(AbstractPersistentDispatcherMultipleConsumers dispatcher, Timer timer, long tickTimeMillis, boolean isDelayedDeliveryDeliverAtTimeStrict, @@ -119,9 +139,9 @@ public BucketDelayedDeliveryTracker(AbstractPersistentDispatcherMultipleConsumer long minIndexCountPerBucket, long timeStepPerBucketSnapshotSegmentInMillis, int maxIndexesPerBucketSnapshotSegment, int maxNumBuckets) throws RecoverDelayedDeliveryTrackerException { - this(dispatcher, timer, tickTimeMillis, Clock.systemUTC(), isDelayedDeliveryDeliverAtTimeStrict, - bucketSnapshotStorage, minIndexCountPerBucket, timeStepPerBucketSnapshotSegmentInMillis, - maxIndexesPerBucketSnapshotSegment, maxNumBuckets); + this(new DispatcherDelayedDeliveryContext(dispatcher), timer, tickTimeMillis, Clock.systemUTC(), + isDelayedDeliveryDeliverAtTimeStrict, bucketSnapshotStorage, minIndexCountPerBucket, + timeStepPerBucketSnapshotSegmentInMillis, maxIndexesPerBucketSnapshotSegment, maxNumBuckets); } public BucketDelayedDeliveryTracker(AbstractPersistentDispatcherMultipleConsumers dispatcher, @@ -131,7 +151,20 @@ public BucketDelayedDeliveryTracker(AbstractPersistentDispatcherMultipleConsumer long minIndexCountPerBucket, long timeStepPerBucketSnapshotSegmentInMillis, int maxIndexesPerBucketSnapshotSegment, int maxNumBuckets) throws RecoverDelayedDeliveryTrackerException { - super(dispatcher, timer, tickTimeMillis, clock, isDelayedDeliveryDeliverAtTimeStrict); + this(new DispatcherDelayedDeliveryContext(dispatcher), timer, tickTimeMillis, clock, + isDelayedDeliveryDeliverAtTimeStrict, bucketSnapshotStorage, minIndexCountPerBucket, + timeStepPerBucketSnapshotSegmentInMillis, maxIndexesPerBucketSnapshotSegment, maxNumBuckets); + } + + @VisibleForTesting + public BucketDelayedDeliveryTracker(DelayedDeliveryContext context, + Timer timer, long tickTimeMillis, Clock clock, + boolean isDelayedDeliveryDeliverAtTimeStrict, + BucketSnapshotStorage bucketSnapshotStorage, + long minIndexCountPerBucket, long timeStepPerBucketSnapshotSegmentInMillis, + int maxIndexesPerBucketSnapshotSegment, int maxNumBuckets) + throws RecoverDelayedDeliveryTrackerException { + super(context, timer, tickTimeMillis, clock, isDelayedDeliveryDeliverAtTimeStrict); this.minIndexCountPerBucket = minIndexCountPerBucket; this.timeStepPerBucketSnapshotSegmentInMillis = timeStepPerBucketSnapshotSegmentInMillis; this.maxIndexesPerBucketSnapshotSegment = maxIndexesPerBucketSnapshotSegment; @@ -139,15 +172,14 @@ public BucketDelayedDeliveryTracker(AbstractPersistentDispatcherMultipleConsumer this.sharedBucketPriorityQueue = new TripleLongPriorityQueue(); this.immutableBuckets = TreeRangeMap.create(); this.snapshotSegmentLastIndexMap = new ConcurrentHashMap<>(); - this.lastMutableBucket = - new MutableBucket(dispatcher.getName(), dispatcher.getCursor(), FutureUtil.Sequencer.create(), - bucketSnapshotStorage); + this.ctx = new BucketContext(context.getName(), context.getCursor(), FutureUtil.Sequencer.create(), + bucketSnapshotStorage); + this.lastMutableBucket = new MutableBucket(ctx); this.stats = new BucketDelayedMessageIndexStats(); // Close the tracker if failed to recover. try { - long recoveredMessages = recoverBucketSnapshot(); - this.numberDelayedMessages.set(recoveredMessages); + recoverBucketSnapshot(); } catch (RecoverDelayedDeliveryTrackerException e) { close(); throw e; @@ -155,32 +187,29 @@ public BucketDelayedDeliveryTracker(AbstractPersistentDispatcherMultipleConsumer } private synchronized long recoverBucketSnapshot() throws RecoverDelayedDeliveryTrackerException { - ManagedCursor cursor = this.lastMutableBucket.getCursor(); + ManagedCursor cursor = ctx.cursor(); Map cursorProperties = cursor.getCursorProperties(); if (MapUtils.isEmpty(cursorProperties)) { log.info("[{}] Recover delayed message index bucket snapshot finish, don't find bucket snapshot", - dispatcher.getName()); + context.getName()); return 0; } - FutureUtil.Sequencer sequencer = this.lastMutableBucket.getSequencer(); Map, ImmutableBucket> toBeDeletedBucketMap = new HashMap<>(); cursorProperties.keySet().forEach(key -> { if (key.startsWith(DELAYED_BUCKET_KEY_PREFIX)) { String[] keys = key.split(DELIMITER); checkArgument(keys.length == 3); ImmutableBucket immutableBucket = - new ImmutableBucket(dispatcher.getName(), cursor, sequencer, - this.lastMutableBucket.bucketSnapshotStorage, - Long.parseLong(keys[1]), Long.parseLong(keys[2])); - putAndCleanOverlapRange(Range.closed(immutableBucket.startLedgerId, immutableBucket.endLedgerId), - immutableBucket, toBeDeletedBucketMap); + new ImmutableBucket(ctx, Long.parseLong(keys[1]), Long.parseLong(keys[2])); + putAndCleanOverlapRange(Range.closed(immutableBucket.getStartLedgerId(), + immutableBucket.getEndLedgerId()), immutableBucket, toBeDeletedBucketMap); } }); Map, ImmutableBucket> immutableBucketMap = immutableBuckets.asMapOfRanges(); if (immutableBucketMap.isEmpty()) { log.info("[{}] Recover delayed message index bucket snapshot finish, don't find bucket snapshot", - dispatcher.getName()); + context.getName()); return 0; } @@ -194,7 +223,7 @@ private synchronized long recoverBucketSnapshot() throws RecoverDelayedDeliveryT try { FutureUtil.waitForAll(futures.values()).get(AsyncOperationTimeoutSeconds * 5, TimeUnit.SECONDS); } catch (InterruptedException | ExecutionException | TimeoutException e) { - log.error("[{}] Failed to recover delayed message index bucket snapshot.", dispatcher.getName(), e); + log.error("[{}] Failed to recover delayed message index bucket snapshot.", context.getName(), e); if (e instanceof InterruptedException) { Thread.currentThread().interrupt(); } @@ -224,20 +253,23 @@ private synchronized long recoverBucketSnapshot() throws RecoverDelayedDeliveryT for (Map.Entry, ImmutableBucket> mapEntry : toBeDeletedBucketMap.entrySet()) { Range key = mapEntry.getKey(); ImmutableBucket immutableBucket = mapEntry.getValue(); - immutableBucketMap.remove(key); + removeBucket(key); // delete asynchronously without waiting for completion immutableBucket.asyncDeleteBucketSnapshot(stats); } - MutableLong numberDelayedMessages = new MutableLong(0); - immutableBucketMap.values().forEach(bucket -> { - numberDelayedMessages.add(bucket.numberBucketDelayedMessages); - }); + long totalLength = 0; + for (ImmutableBucket bucket : immutableBucketMap.values()) { + index.restore(bucket.getDelayedIndexBitMap()); + totalLength += bucket.getSnapshotLength(); + } + totalSnapshotLengthBytes.set(totalLength); + bucketsCount.set(immutableBuckets.asMapOfRanges().size()); log.info("[{}] Recover delayed message index bucket snapshot finish, buckets: {}, numberDelayedMessages: {}", - dispatcher.getName(), immutableBucketMap.size(), numberDelayedMessages.getValue()); + context.getName(), immutableBucketMap.size(), index.size()); - return numberDelayedMessages.getValue(); + return index.size(); } /** @@ -269,12 +301,16 @@ private CompletableFuture> handleRecoverBucketSnapshotEntry(I private synchronized void putAndCleanOverlapRange(Range range, ImmutableBucket immutableBucket, Map, ImmutableBucket> toBeDeletedBucketMap) { - RangeMap subRangeMap = immutableBuckets.subRangeMap(range); + Map, ImmutableBucket> subRangeMap = immutableBuckets.subRangeMap(range).asMapOfRanges(); boolean canPut = false; - if (!subRangeMap.asMapOfRanges().isEmpty()) { - for (Map.Entry, ImmutableBucket> rangeEntry : subRangeMap.asMapOfRanges().entrySet()) { - if (range.encloses(rangeEntry.getKey())) { - toBeDeletedBucketMap.put(rangeEntry.getKey(), rangeEntry.getValue()); + if (!subRangeMap.isEmpty()) { + for (Map.Entry, ImmutableBucket> rangeEntry : subRangeMap.entrySet()) { + // Use original key instead of truncated key for encloses check + ImmutableBucket bucket = rangeEntry.getValue(); + Range originalKey = Range.closed(bucket.getStartLedgerId(), bucket.getEndLedgerId()); + + if (range.encloses(originalKey)) { + toBeDeletedBucketMap.put(originalKey, bucket); canPut = true; } } @@ -283,7 +319,7 @@ private synchronized void putAndCleanOverlapRange(Range range, ImmutableBu } if (canPut) { - immutableBuckets.put(range, immutableBucket); + putBucket(range, immutableBucket); } } @@ -298,19 +334,15 @@ public void run(Timeout timeout) throws Exception { super.run(timeout); } - private Optional findImmutableBucket(long ledgerId) { - if (immutableBuckets.asMapOfRanges().isEmpty()) { - return Optional.empty(); - } - - return Optional.ofNullable(immutableBuckets.get(ledgerId)); + private ImmutableBucket findImmutableBucket(long ledgerId) { + return immutableBuckets.get(ledgerId); } private void afterCreateImmutableBucket(Pair immutableBucketDelayedIndexPair, long startTime) { if (immutableBucketDelayedIndexPair != null) { ImmutableBucket immutableBucket = immutableBucketDelayedIndexPair.getLeft(); - immutableBuckets.put(Range.closed(immutableBucket.startLedgerId, immutableBucket.endLedgerId), + putBucket(Range.closed(immutableBucket.getStartLedgerId(), immutableBucket.getEndLedgerId()), immutableBucket); DelayedIndex lastDelayedIndex = immutableBucketDelayedIndexPair.getRight(); @@ -322,8 +354,13 @@ private void afterCreateImmutableBucket(Pair immu CompletableFuture future = createFuture.handle((bucketId, ex) -> { if (ex == null) { immutableBucket.setSnapshotSegments(null); - immutableBucket.asyncUpdateSnapshotLength(); - log.info("[{}] Create bucket snapshot finish, bucketKey: {}", dispatcher.getName(), + immutableBucket.asyncUpdateSnapshotLength() + .thenAccept(newLength -> { + synchronized (BucketDelayedDeliveryTracker.this) { + updateBucketSnapshotLength(immutableBucket, newLength); + } + }); + log.info("[{}] Create bucket snapshot finish, bucketKey: {}", context.getName(), immutableBucket.bucketKey()); stats.recordSuccessEvent(BucketDelayedMessageIndexStats.Type.create, @@ -332,7 +369,7 @@ private void afterCreateImmutableBucket(Pair immu return bucketId; } - log.error("[{}] Failed to create bucket snapshot, bucketKey: {}", dispatcher.getName(), + log.error("[{}] Failed to create bucket snapshot, bucketKey: {}", context.getName(), immutableBucket.bucketKey(), ex); stats.recordFailEvent(BucketDelayedMessageIndexStats.Type.create); @@ -348,9 +385,9 @@ private void afterCreateImmutableBucket(Pair immu immutableBucket.setSnapshotSegments(null); }); - immutableBucket.setCurrentSegmentEntryId(immutableBucket.lastSegmentEntryId); - immutableBuckets.asMapOfRanges().remove( - Range.closed(immutableBucket.startLedgerId, immutableBucket.endLedgerId)); + immutableBucket.setCurrentSegmentEntryId(immutableBucket.getLastSegmentEntryId()); + removeBucket( + Range.closed(immutableBucket.getStartLedgerId(), immutableBucket.getEndLedgerId())); snapshotSegmentLastIndexMap.remove( new SnapshotKey(lastDelayedIndex.getLedgerId(), lastDelayedIndex.getEntryId())); } @@ -363,15 +400,16 @@ private void afterCreateImmutableBucket(Pair immu @Override public synchronized boolean addMessage(long ledgerId, long entryId, long deliverAt) { - if (containsMessage(ledgerId, entryId)) { - return true; - } - if (deliverAt < 0 || deliverAt <= getCutoffTime()) { + removeIndexBit(ledgerId, entryId); return false; } - boolean existBucket = findImmutableBucket(ledgerId).isPresent(); + if (containsMessage(ledgerId, entryId)) { + return true; + } + + boolean existBucket = findImmutableBucket(ledgerId) != null; // Create bucket snapshot if (!existBucket && ledgerId > lastMutableBucket.endLedgerId @@ -387,8 +425,15 @@ public synchronized boolean addMessage(long ledgerId, long entryId, long deliver afterCreateImmutableBucket(immutableBucketDelayedIndexPair, createStartTime); lastMutableBucket.resetLastMutableBucketRange(); - if (maxNumBuckets > 0 && immutableBuckets.asMapOfRanges().size() > maxNumBuckets) { - asyncMergeBucketSnapshot(); + if (maxNumBuckets > 0 && bucketsCount.get() > maxNumBuckets + && (trimFuture == null || trimFuture.isDone())) { + trimFuture = asyncTrimImmutableBuckets() + .thenCompose(ignore -> asyncMergeBucketSnapshot()) + .whenComplete((ignore, t) -> { + if (t != null) { + log.warn("Failed to trim or merge bucket snapshots", t); + } + }); } } @@ -398,13 +443,11 @@ public synchronized boolean addMessage(long ledgerId, long entryId, long deliver // Message index belongs to previous bucket range or the current mutable bucket range, // enter sharedBucketPriorityQueue directly sharedBucketPriorityQueue.add(deliverAt, ledgerId, entryId); - lastMutableBucket.putIndexBit(ledgerId, entryId); } - - numberDelayedMessages.incrementAndGet(); + index.track(ledgerId, entryId); if (log.isDebugEnabled()) { - log.debug("[{}] Add message {}:{} -- Delivery in {} ms ", dispatcher.getName(), ledgerId, entryId, + log.debug("[{}] Add message {}:{} -- Delivery in {} ms ", context.getName(), ledgerId, entryId, deliverAt - clock.millis()); } @@ -413,25 +456,33 @@ public synchronized boolean addMessage(long ledgerId, long entryId, long deliver return true; } - private synchronized List selectMergedBuckets(final List values, int mergeNum) { - checkArgument(mergeNum < values.size()); + @VisibleForTesting + synchronized List selectMergedBuckets(final List values, int mergeNum) { + if (values.size() < 2 || mergeNum < 2) { + return Collections.emptyList(); + } + int actualMergeNum = Math.min(mergeNum, values.size()); long minNumberMessages = Long.MAX_VALUE; long minScheduleTimestamp = Long.MAX_VALUE; int minIndex = -1; - for (int i = 0; i + (mergeNum - 1) < values.size(); i++) { - List immutableBuckets = values.subList(i, i + mergeNum); + for (int i = 0; i + (actualMergeNum - 1) < values.size(); i++) { + List immutableBuckets = values.subList(i, i + actualMergeNum); if (immutableBuckets.stream().allMatch(bucket -> { // We should skip the bucket which last segment already been load to memory, // avoid record replicated index. - return bucket.lastSegmentEntryId > bucket.currentSegmentEntryId && !bucket.merging; + return bucket.getLastSegmentEntryId() > bucket.getCurrentSegmentEntryId() && !bucket.merging; })) { long numberMessages = immutableBuckets.stream() - .mapToLong(bucket -> bucket.numberBucketDelayedMessages) + .mapToLong(bucket -> bucket.getNumberBucketDelayedMessages()) .sum(); if (numberMessages <= minNumberMessages) { minNumberMessages = numberMessages; + // Snapshot segment IDs start at 1 while the timestamp list is zero-based. + // The next unloaded segment is currentSegmentEntryId + 1, at list index + // currentSegmentEntryId. long scheduleTimestamp = immutableBuckets.stream() - .mapToLong(bucket -> bucket.firstScheduleTimestamps.get(bucket.currentSegmentEntryId + 1)) + .mapToLong(bucket -> bucket.getFirstScheduleTimestamps() + .get(bucket.getCurrentSegmentEntryId())) .min().getAsLong(); if (scheduleTimestamp < minScheduleTimestamp) { minScheduleTimestamp = scheduleTimestamp; @@ -442,27 +493,30 @@ private synchronized List selectMergedBuckets(final List= 0) { - return values.subList(minIndex, minIndex + mergeNum); - } else if (mergeNum > 2){ - return selectMergedBuckets(values, mergeNum - 1); + return values.subList(minIndex, minIndex + actualMergeNum); + } else if (actualMergeNum > 2) { + return selectMergedBuckets(values, actualMergeNum - 1); } else { return Collections.emptyList(); } } private synchronized CompletableFuture asyncMergeBucketSnapshot() { + if (maxNumBuckets <= 0 || bucketsCount.get() <= maxNumBuckets) { + return CompletableFuture.completedFuture(null); + } List immutableBucketList = immutableBuckets.asMapOfRanges().values().stream().toList(); List toBeMergeImmutableBuckets = selectMergedBuckets(immutableBucketList, MAX_MERGE_NUM); if (toBeMergeImmutableBuckets.isEmpty()) { - log.warn("[{}] Can't find able merged buckets", dispatcher.getName()); + log.warn("[{}] Can't find able merged buckets", context.getName()); return CompletableFuture.completedFuture(null); } - final String bucketsStr = toBeMergeImmutableBuckets.stream().map(Bucket::bucketKey).collect( + final String bucketsStr = toBeMergeImmutableBuckets.stream().map(ImmutableBucket::bucketKey).collect( Collectors.joining(",")).replaceAll(DELAYED_BUCKET_KEY_PREFIX + "_", ""); if (log.isDebugEnabled()) { - log.info("[{}] Merging bucket snapshot, bucketKeys: {}", dispatcher.getName(), bucketsStr); + log.info("[{}] Merging bucket snapshot, bucketKeys: {}", context.getName(), bucketsStr); } for (ImmutableBucket immutableBucket : toBeMergeImmutableBuckets) { @@ -479,12 +533,12 @@ private synchronized CompletableFuture asyncMergeBucketSnapshot() { } if (ex != null) { log.error("[{}] Failed to merge bucket snapshot, bucketKeys: {}", - dispatcher.getName(), bucketsStr, ex); + context.getName(), bucketsStr, ex); stats.recordFailEvent(BucketDelayedMessageIndexStats.Type.merge); } else { log.info("[{}] Merge bucket snapshot finish, bucketKeys: {}, bucketNum: {}", - dispatcher.getName(), bucketsStr, immutableBuckets.asMapOfRanges().size()); + context.getName(), bucketsStr, bucketsCount.get()); stats.recordSuccessEvent(BucketDelayedMessageIndexStats.Type.merge, System.currentTimeMillis() - mergeStartTime); @@ -502,13 +556,13 @@ private synchronized CompletableFuture asyncMergeBucketSnapshot(List>> getRemainFutures = - buckets.stream().map(ImmutableBucket::getRemainSnapshotSegment).toList(); + List>> getAllSnapshotFutures = + buckets.stream().map(ImmutableBucket::getAllSnapshotSegments).toList(); - return FutureUtil.waitForAll(getRemainFutures) + return FutureUtil.waitForAll(getAllSnapshotFutures) .thenApply(__ -> { return CombinedSegmentDelayedIndexQueue.wrap( - getRemainFutures.stream().map(CompletableFuture::join).toList()); + getAllSnapshotFutures.stream().map(CompletableFuture::join).toList()); }) .thenAccept(combinedDelayedIndexQueue -> { synchronized (BucketDelayedDeliveryTracker.this) { @@ -519,14 +573,14 @@ private synchronized CompletableFuture asyncMergeBucketSnapshot(List delayedIndexBitMap = - new HashMap<>(buckets.get(0).getDelayedIndexBitMap()); + Long2ObjectMap delayedIndexBitMap = + new Long2ObjectOpenHashMap<>(buckets.get(0).getDelayedIndexBitMap()); for (int i = 1; i < buckets.size(); i++) { - buckets.get(i).delayedIndexBitMap.forEach((ledgerId, bitMapB) -> { + buckets.get(i).getDelayedIndexBitMap().forEach((ledgerId, bitMapB) -> { delayedIndexBitMap.compute(ledgerId, (k, bitMap) -> { if (bitMap == null) { return bitMapB; @@ -538,8 +592,6 @@ private synchronized CompletableFuture asyncMergeBucketSnapshot(List asyncMergeBucketSnapshot(List 0 while both queues are empty (e.g. remaining + // messages live in not-yet-loaded snapshot segments). Returning Long.MAX_VALUE + // signals "no imminent delivery" without throwing on the empty queues. + return Long.MAX_VALUE; } long timestamp = lastMutableBucket.nextDeliveryTime(); long bucketTimestamp = sharedBucketPriorityQueue.peekN1(); @@ -586,7 +642,7 @@ protected synchronized long nextDeliveryTime() { @Override public long getNumberOfDelayedMessages() { - return numberDelayedMessages.get(); + return index.size(); } @Override @@ -599,12 +655,13 @@ public synchronized NavigableSet getScheduledMessages(int maxMessages) if (!checkPendingLoadDone()) { if (log.isDebugEnabled()) { log.debug("[{}] Skip getScheduledMessages to wait for bucket snapshot load finish.", - dispatcher.getName()); + context.getName()); } return Collections.emptyNavigableSet(); } long cutoffTime = getCutoffTime(); + Long firstLiveLedgerId = firstActiveLedgerId(); lastMutableBucket.moveScheduledMessageToSharedQueue(cutoffTime, sharedBucketPriorityQueue); @@ -613,13 +670,17 @@ public synchronized NavigableSet getScheduledMessages(int maxMessages) while (n > 0 && !sharedBucketPriorityQueue.isEmpty()) { long timestamp = sharedBucketPriorityQueue.peekN1(); + long ledgerId = sharedBucketPriorityQueue.peekN2(); + long entryId = sharedBucketPriorityQueue.peekN3(); + if (firstLiveLedgerId != null && ledgerId < firstLiveLedgerId) { + sharedBucketPriorityQueue.pop(); + removeIndexBit(ledgerId, entryId); + continue; + } if (timestamp > cutoffTime) { break; } - long ledgerId = sharedBucketPriorityQueue.peekN2(); - long entryId = sharedBucketPriorityQueue.peekN3(); - SnapshotKey snapshotKey = new SnapshotKey(ledgerId, entryId); ImmutableBucket bucket = snapshotSegmentLastIndexMap.get(snapshotKey); @@ -627,19 +688,19 @@ public synchronized NavigableSet getScheduledMessages(int maxMessages) // All message of current snapshot segment are scheduled, try load next snapshot segment if (bucket.merging) { log.info("[{}] Skip load to wait for bucket snapshot merge finish, bucketKey:{}", - dispatcher.getName(), bucket.bucketKey()); + context.getName(), bucket.bucketKey()); break; } - final int preSegmentEntryId = bucket.currentSegmentEntryId; + final int preSegmentEntryId = bucket.getCurrentSegmentEntryId(); if (log.isDebugEnabled()) { log.debug("[{}] Loading next bucket snapshot segment, bucketKey: {}, nextSegmentEntryId: {}", - dispatcher.getName(), bucket.bucketKey(), preSegmentEntryId + 1); + context.getName(), bucket.bucketKey(), preSegmentEntryId + 1); } boolean createFutureDone = bucket.getSnapshotCreateFuture().orElse(NULL_LONG_PROMISE).isDone(); if (!createFutureDone) { log.info("[{}] Skip load to wait for bucket snapshot create finish, bucketKey:{}", - dispatcher.getName(), bucket.bucketKey()); + context.getName(), bucket.bucketKey()); break; } @@ -650,8 +711,7 @@ public synchronized NavigableSet getScheduledMessages(int maxMessages) synchronized (BucketDelayedDeliveryTracker.this) { this.snapshotSegmentLastIndexMap.remove(snapshotKey); if (CollectionUtils.isEmpty(indexList)) { - immutableBuckets.asMapOfRanges() - .remove(Range.closed(bucket.startLedgerId, bucket.endLedgerId)); + removeBucket(Range.closed(bucket.getStartLedgerId(), bucket.getEndLedgerId())); bucket.asyncDeleteBucketSnapshot(stats); return; } @@ -671,13 +731,13 @@ public synchronized NavigableSet getScheduledMessages(int maxMessages) bucket.setCurrentSegmentEntryId(preSegmentEntryId); log.error("[{}] Failed to load bucket snapshot segment, bucketKey: {}, segmentEntryId: {}", - dispatcher.getName(), bucket.bucketKey(), preSegmentEntryId + 1, ex); + context.getName(), bucket.bucketKey(), preSegmentEntryId + 1, ex); stats.recordFailEvent(BucketDelayedMessageIndexStats.Type.load); } else { log.info("[{}] Load next bucket snapshot segment finish, bucketKey: {}, segmentEntryId: {}", - dispatcher.getName(), bucket.bucketKey(), - (preSegmentEntryId == bucket.lastSegmentEntryId) ? "-1" : preSegmentEntryId + 1); + context.getName(), bucket.bucketKey(), + (preSegmentEntryId == bucket.getLastSegmentEntryId()) ? "-1" : preSegmentEntryId + 1); stats.recordSuccessEvent(BucketDelayedMessageIndexStats.Type.load, System.currentTimeMillis() - loadStartTime); @@ -695,13 +755,13 @@ public synchronized NavigableSet getScheduledMessages(int maxMessages) } } - positions.add(PositionFactory.create(ledgerId, entryId)); - sharedBucketPriorityQueue.pop(); - removeIndexBit(ledgerId, entryId); - - --n; - numberDelayedMessages.decrementAndGet(); + // Dedup: queue may carry the same position twice (initial seal + merge); only the + // first delivery of each position decrements the counter via removeIndexBit. + if (removeIndexBit(ledgerId, entryId)) { + positions.add(PositionFactory.create(ledgerId, entryId)); + --n; + } } updateTimer(); @@ -724,67 +784,167 @@ public boolean shouldPauseAllDeliveries() { @Override public synchronized CompletableFuture clear() { - CompletableFuture future = cleanImmutableBuckets(); - sharedBucketPriorityQueue.clear(); - lastMutableBucket.clear(); - snapshotSegmentLastIndexMap.clear(); - numberDelayedMessages.set(0); - return future; + // Wait for any in-flight trim+merge to settle, then clear. + // Reuse trimFuture to block new triggers until the clear chain completes. + CompletableFuture before = trimFuture != null && !trimFuture.isDone() + ? trimFuture : CompletableFuture.completedFuture(null); + trimFuture = before + .exceptionally(t -> { + log.warn("Trim/merge buckets failed, but still clear", t); + return null; + }) + .thenCompose(__ -> { + synchronized (BucketDelayedDeliveryTracker.this) { + CompletableFuture future = cleanImmutableBuckets(); + sharedBucketPriorityQueue.clear(); + index.clear(); + lastMutableBucket.clear(); + snapshotSegmentLastIndexMap.clear(); + return future; + } + }); + return trimFuture; } @Override - public synchronized void close() { - super.close(); - lastMutableBucket.close(); - sharedBucketPriorityQueue.close(); - try { - List> completableFutures = immutableBuckets.asMapOfRanges().values().stream() + public void close() { + // Block for AutoCloseable / synchronous callers; asynchronous callers should use closeAsync(). + closeAsync().join(); + } + + @Override + public CompletableFuture closeAsync() { + List> completableFutures; + synchronized (this) { + super.close(); + lastMutableBucket.close(); + sharedBucketPriorityQueue.close(); + completableFutures = immutableBuckets.asMapOfRanges().values().stream() .map(bucket -> bucket.getSnapshotCreateFuture().orElse(NULL_LONG_PROMISE)).toList(); - FutureUtil.waitForAll(completableFutures).get(AsyncOperationTimeoutSeconds, TimeUnit.SECONDS); - } catch (Exception e) { - log.warn("[{}] Failed wait to snapshot generate", dispatcher.getName(), e); } + return FutureUtil.waitForAll(completableFutures) + .exceptionally(e -> { + log.warn("[{}] Failed wait to snapshot generate", context.getName(), e); + return null; + }); } private CompletableFuture cleanImmutableBuckets() { + Map, ImmutableBucket> bucketsToDelete = + new HashMap<>(immutableBuckets.asMapOfRanges()); + List> futures = new ArrayList<>(); - Iterator iterator = immutableBuckets.asMapOfRanges().values().iterator(); - while (iterator.hasNext()) { - ImmutableBucket bucket = iterator.next(); + bucketsToDelete.forEach((range, bucket) -> { + removeBucket(range); futures.add(bucket.clear(stats)); - numberDelayedMessages.addAndGet(-bucket.getNumberBucketDelayedMessages()); - iterator.remove(); - } + }); + return FutureUtil.waitForAll(futures); } private boolean removeIndexBit(long ledgerId, long entryId) { - if (lastMutableBucket.removeIndexBit(ledgerId, entryId)) { - return true; - } - - return findImmutableBucket(ledgerId).map(bucket -> bucket.removeIndexBit(ledgerId, entryId)) - .orElse(false); + return index.untrack(ledgerId, entryId); } public synchronized boolean containsMessage(long ledgerId, long entryId) { - if (lastMutableBucket.containsMessage(ledgerId, entryId)) { - return true; - } - - return findImmutableBucket(ledgerId).map(bucket -> bucket.containsMessage(ledgerId, entryId)) - .orElse(false); + return index.contains(ledgerId, entryId); } public Map genTopicMetricMap() { - stats.recordNumOfBuckets(immutableBuckets.asMapOfRanges().size() + 1); + stats.recordNumOfBuckets((int) (bucketsCount.get() + 1)); stats.recordDelayedMessageIndexLoaded(this.sharedBucketPriorityQueue.size() + this.lastMutableBucket.size()); - MutableLong totalSnapshotLength = new MutableLong(); - immutableBuckets.asMapOfRanges().values().forEach(immutableBucket -> { - totalSnapshotLength.add(immutableBucket.getSnapshotLength()); - }); - stats.recordBucketSnapshotSizeBytes(totalSnapshotLength.longValue()); + stats.recordBucketSnapshotSizeBytes(totalSnapshotLengthBytes.get()); return stats.genTopicMetricMap(); } + + /** + * Delete orphaned bucket snapshots whose ledger range lies entirely before the earliest + * surviving ledger. Buckets are deleted sequentially; the chain stops on first failure + * to avoid wasted work when storage is unavailable. + */ + private synchronized CompletableFuture asyncTrimImmutableBuckets() { + Long firstLedgerId = firstActiveLedgerId(); + if (null == firstLedgerId) { + return CompletableFuture.completedFuture(null); + } + ManagedLedger ledger = context.getCursor().getManagedLedger(); + + Map, ImmutableBucket> toBeDeletedBuckets = new HashMap<>(); + // subRangeMap returns clipped intersection ranges. Snapshot deletion must use the original + // bucket range, so only select buckets whose complete range precedes the first live ledger. + immutableBuckets.asMapOfRanges().forEach((range, bucket) -> { + if (range.upperEndpoint() < firstLedgerId) { + toBeDeletedBuckets.put(range, bucket); + } + }); + + if (toBeDeletedBuckets.isEmpty()) { + return CompletableFuture.completedFuture(null); + } + + String ledgerName = ledger.getName(); + CompletableFuture chain = CompletableFuture.completedFuture(null); + for (Map.Entry, ImmutableBucket> entry : toBeDeletedBuckets.entrySet()) { + chain = chain.thenCompose(__ -> + deleteBucketSnapshot(ledgerName, entry.getKey(), entry.getValue())); + } + return chain; + } + + private CompletableFuture deleteBucketSnapshot(String ledgerName, + Range range, ImmutableBucket bucket) { + return bucket.asyncDeleteBucketSnapshot(stats) + .handle((__, t) -> { + if (t != null) { + log.warn("Failed to delete bucket snapshot, LedgerName: {}, BucketKey: {}", + ledgerName, bucket.bucketKey()); + throw new CompletionException(t); + } + synchronized (this) { + snapshotSegmentLastIndexMap.entrySet().removeIf(entry -> entry.getValue() == bucket); + removeBucket(range); + bucket.getDelayedIndexBitMap().forEach((ledgerId, bitmap) -> + bitmap.forEachLong(entryId -> index.untrack(ledgerId, entryId))); + } + return null; + }); + } + + private Long firstActiveLedgerId() { + ManagedCursor cursor = context.getCursor(); + Position mdp = cursor.getMarkDeletedPosition(); + return mdp == null ? null : mdp.getLedgerId(); + } + + private void putBucket(Range range, ImmutableBucket bucket) { + long removedLength = immutableBuckets.subRangeMap(range).asMapOfRanges().values().stream() + .mapToLong(ImmutableBucket::getSnapshotLength) + .sum(); + + immutableBuckets.put(range, bucket); + bucketsCount.set(immutableBuckets.asMapOfRanges().size()); + totalSnapshotLengthBytes.addAndGet(bucket.getSnapshotLength() - removedLength); + } + + private void removeBucket(Range range) { + // Use exact key matching - all callers should provide exact keys + ImmutableBucket bucket = immutableBuckets.asMapOfRanges().get(range); + + if (bucket != null) { + // Remove even if snapshot length is 0 (for newly created buckets) + immutableBuckets.asMapOfRanges().remove(range); + bucketsCount.set(immutableBuckets.asMapOfRanges().size()); + totalSnapshotLengthBytes.addAndGet(-bucket.getSnapshotLength()); + } + } + + private void updateBucketSnapshotLength(ImmutableBucket bucket, long newLength) { + if (!immutableBuckets.asMapOfRanges().containsValue(bucket)) { + return; + } + long oldLength = bucket.getSnapshotLength(); + bucket.setSnapshotLength(newLength); + totalSnapshotLengthBytes.addAndGet(newLength - oldLength); + } } diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedMessageIndexStats.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedMessageIndexStats.java index 68788c359d560..502ca9f3d4edd 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedMessageIndexStats.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedMessageIndexStats.java @@ -47,8 +47,8 @@ enum Type { private static final String BUCKET_TOTAL_NAME = "pulsar_delayed_message_index_bucket_total"; private static final String INDEX_LOADED_NAME = "pulsar_delayed_message_index_loaded"; private static final String SNAPSHOT_SIZE_BYTES_NAME = "pulsar_delayed_message_index_bucket_snapshot_size_bytes"; - private static final String OP_COUNT_NAME = "pulsar_delayed_message_index_bucket_op_count"; - private static final String OP_LATENCY_NAME = "pulsar_delayed_message_index_bucket_op_latency_ms"; + static final String OP_COUNT_NAME = "pulsar_delayed_message_index_bucket_op_count"; + static final String OP_LATENCY_NAME = "pulsar_delayed_message_index_bucket_op_latency_ms"; private final AtomicInteger delayedMessageIndexBucketTotal = new AtomicInteger(); private final AtomicLong delayedMessageIndexLoaded = new AtomicLong(); @@ -59,7 +59,7 @@ enum Type { public BucketDelayedMessageIndexStats() { } - public Map genTopicMetricMap() { + public synchronized Map genTopicMetricMap() { Map metrics = new HashMap<>(); metrics.put(BUCKET_TOTAL_NAME, @@ -75,11 +75,11 @@ public Map genTopicMetricMap() { String[] labels = splitKey(k); String[] labelsAndValues = new String[] {"state", labels[0], "type", labels[1]}; String key = OP_COUNT_NAME + joinKey(labelsAndValues); - metrics.put(key, new TopicMetricBean(OP_COUNT_NAME, count.sumThenReset(), labelsAndValues)); + metrics.put(key, new TopicMetricBean(OP_COUNT_NAME, count.sum(), labelsAndValues)); }); delayedMessageIndexBucketOpLatencyMs.forEach((typeName, statsBuckets) -> { - statsBuckets.refresh(); + statsBuckets.snapshot(); long[] buckets = statsBuckets.getBuckets(); for (int i = 0; i < buckets.length; i++) { long count = buckets[i]; diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/ImmutableBucket.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/ImmutableBucket.java index a1944a21ea794..e8cf822024740 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/ImmutableBucket.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/ImmutableBucket.java @@ -19,28 +19,49 @@ package org.apache.pulsar.broker.delayed.bucket; import static org.apache.bookkeeper.mledger.util.Futures.executeWithRetry; +import static org.apache.pulsar.broker.delayed.bucket.BucketDelayedDeliveryTracker.DELAYED_BUCKET_KEY_PREFIX; import static org.apache.pulsar.broker.delayed.bucket.BucketDelayedDeliveryTracker.NULL_LONG_PROMISE; -import java.io.IOException; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.function.Supplier; +import lombok.Getter; import lombok.Setter; import lombok.extern.slf4j.Slf4j; -import org.apache.bookkeeper.mledger.ManagedCursor; +import org.apache.bookkeeper.mledger.ManagedLedgerException; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.mutable.MutableLong; import org.apache.pulsar.broker.delayed.proto.DelayedIndex; +import org.apache.pulsar.broker.delayed.proto.SnapshotMetadata; import org.apache.pulsar.broker.delayed.proto.SnapshotSegment; -import org.apache.pulsar.broker.delayed.proto.SnapshotSegmentMetadata; +import org.apache.pulsar.common.util.Codec; import org.apache.pulsar.common.util.FutureUtil; -import org.roaringbitmap.InvalidRoaringFormat; -import org.roaringbitmap.RoaringBitmap; +import org.apache.pulsar.common.util.collections.LongBitmap; +import org.apache.pulsar.common.util.collections.LongBitmaps; @Slf4j -class ImmutableBucket extends Bucket { +class ImmutableBucket { + + static final String DELIMITER = "_"; + static final int MaxRetryTimes = 3; + + private final BucketContext ctx; + + @Getter + private final long startLedgerId; + + @Getter + private final long endLedgerId; + + @Getter + @Setter + private Map delayedIndexBitMap = new Long2ObjectOpenHashMap<>(); @Setter private List snapshotSegments; @@ -48,11 +69,102 @@ class ImmutableBucket extends Bucket { boolean merging = false; @Setter + @Getter List firstScheduleTimestamps = new ArrayList<>(); - ImmutableBucket(String dispatcherName, ManagedCursor cursor, FutureUtil.Sequencer sequencer, - BucketSnapshotStorage storage, long startLedgerId, long endLedgerId) { - super(dispatcherName, cursor, sequencer, storage, startLedgerId, endLedgerId); + @Getter + @Setter + private long numberBucketDelayedMessages; + + @Getter + @Setter + private int lastSegmentEntryId; + + @Getter + @Setter + private volatile int currentSegmentEntryId; + + @Getter + @Setter + private volatile long snapshotLength; + + @Getter + @Setter + private volatile Long bucketId; + + @Getter + @Setter + private volatile CompletableFuture snapshotCreateFuture; + + ImmutableBucket(BucketContext ctx, long startLedgerId, long endLedgerId) { + this.ctx = ctx; + this.startLedgerId = startLedgerId; + this.endLedgerId = endLedgerId; + } + + String bucketKey() { + return String.join(DELIMITER, DELAYED_BUCKET_KEY_PREFIX, String.valueOf(startLedgerId), + String.valueOf(endLedgerId)); + } + + Optional> getSnapshotCreateFuture() { + return Optional.ofNullable(snapshotCreateFuture); + } + + Optional getBucketId() { + return Optional.ofNullable(bucketId); + } + + long getAndUpdateBucketId() { + Optional bucketIdOptional = getBucketId(); + if (bucketIdOptional.isPresent()) { + return bucketIdOptional.get(); + } + + String bucketIdStr = ctx.cursor().getCursorProperties().get(bucketKey()); + long bucketId = Long.parseLong(bucketIdStr); + setBucketId(bucketId); + return bucketId; + } + + CompletableFuture asyncSaveBucketSnapshot( + SnapshotMetadata snapshotMetadata, List bucketSnapshotSegments) { + final String bucketKey = bucketKey(); + final String cursorName = Codec.decode(ctx.cursor().getName()); + final String dispatcher = ctx.dispatcherName(); + final String topicName = dispatcher.substring(0, dispatcher.lastIndexOf(" / " + cursorName)); + return executeWithRetry( + () -> ctx.bucketSnapshotStorage().createBucketSnapshot(snapshotMetadata, bucketSnapshotSegments, + bucketKey, topicName, cursorName) + .whenComplete((__, ex) -> { + if (ex != null) { + log.warn("[{}] Failed to create bucket snapshot, bucketKey: {}", + dispatcher, bucketKey, ex); + } + }), BucketSnapshotPersistenceException.class, MaxRetryTimes).thenCompose(newBucketId -> { + setBucketId(newBucketId); + + return putBucketKeyId(bucketKey, newBucketId).exceptionally(ex -> { + log.warn("[{}] Failed to record bucketId {} to cursor property, bucketKey: {}", + dispatcher, newBucketId, bucketKey, ex); + return null; + }).thenApply(__ -> newBucketId); + }); + } + + private CompletableFuture putBucketKeyId(String bucketKey, Long bucketId) { + if (bucketId == null) { + return FutureUtil.failedFuture(new NullPointerException("Expected bucketId should not be null")); + } + return ctx.sequencer().sequential(() -> + executeWithRetry(() -> ctx.cursor().putCursorProperty(bucketKey, String.valueOf(bucketId)), + ManagedLedgerException.BadVersionException.class, MaxRetryTimes)); + } + + CompletableFuture removeBucketCursorProperty(String bucketKey) { + return ctx.sequencer().sequential(() -> + executeWithRetry(() -> ctx.cursor().removeCursorProperty(bucketKey), + ManagedLedgerException.BadVersionException.class, MaxRetryTimes)); } public Optional> getSnapshotSegments() { @@ -75,29 +187,31 @@ private CompletableFuture> asyncLoadNextBucketSnapshotEntry(b final long cutoffTime = cutoffTimeSupplier.get(); // Load Metadata of bucket snapshot final String bucketKey = bucketKey(); - loadMetaDataFuture = executeWithRetry(() -> bucketSnapshotStorage.getBucketSnapshotMetadata(bucketId) + loadMetaDataFuture = executeWithRetry(() -> ctx.bucketSnapshotStorage().getBucketSnapshotMetadata(bucketId) .whenComplete((___, ex) -> { if (ex != null) { - log.warn("[{}] Failed to get bucket snapshot metadata," - + " bucketKey: {}, bucketId: {}", - dispatcherName, bucketKey, bucketId, ex); + log.warn("[{}] Failed to get bucket snapshot metadata, bucketKey: {}, bucketId: {}", + ctx.dispatcherName(), bucketKey, bucketId, ex); } }), BucketSnapshotPersistenceException.class, MaxRetryTimes) .thenApply(snapshotMetadata -> { - List metadataList = - snapshotMetadata.getMetadataListList(); + int metadataListSize = snapshotMetadata.getMetadataListCount(); // Skip all already reach schedule time snapshot segments int nextSnapshotEntryIndex = 0; - while (nextSnapshotEntryIndex < metadataList.size() - && metadataList.get(nextSnapshotEntryIndex).getMaxScheduleTimestamp() <= cutoffTime) { + while (nextSnapshotEntryIndex < metadataListSize + && snapshotMetadata.getMetadataList(nextSnapshotEntryIndex) + .getMaxScheduleTimestamp() <= cutoffTime) { nextSnapshotEntryIndex++; } - this.setLastSegmentEntryId(metadataList.size()); - this.recoverDelayedIndexBitMapAndNumber(nextSnapshotEntryIndex, metadataList); - List firstScheduleTimestamps = metadataList.stream().map( - SnapshotSegmentMetadata::getMinScheduleTimestamp).toList(); + this.setLastSegmentEntryId(metadataListSize); + this.recoverDelayedIndexBitMapAndNumber(nextSnapshotEntryIndex, snapshotMetadata); + List firstScheduleTimestamps = new ArrayList<>(); + for (int i = 0; i < metadataListSize; i++) { + firstScheduleTimestamps.add( + snapshotMetadata.getMetadataList(i).getMinScheduleTimestamp()); + } this.setFirstScheduleTimestamps(firstScheduleTimestamps); return nextSnapshotEntryIndex + 1; @@ -112,17 +226,17 @@ private CompletableFuture> asyncLoadNextBucketSnapshotEntry(b } return executeWithRetry( - () -> bucketSnapshotStorage.getBucketSnapshotSegment(bucketId, nextSegmentEntryId, + () -> ctx.bucketSnapshotStorage().getBucketSnapshotSegment(bucketId, nextSegmentEntryId, nextSegmentEntryId).whenComplete((___, ex) -> { if (ex != null) { - log.warn("[{}] Failed to get bucket snapshot segment. bucketKey: {}," - + " bucketId: {}, segmentEntryId: {}", dispatcherName, bucketKey(), - bucketId, nextSegmentEntryId, ex); + log.warn("[{}] Failed to get bucket snapshot segment, bucketKey: {}, bucketId: {}," + + " segmentEntryId: {}", ctx.dispatcherName(), + bucketKey(), bucketId, nextSegmentEntryId, ex); } }), BucketSnapshotPersistenceException.class, MaxRetryTimes) - .thenApply(bucketSnapshotSegments -> { + .thenCompose(bucketSnapshotSegments -> { if (CollectionUtils.isEmpty(bucketSnapshotSegments)) { - return Collections.emptyList(); + return CompletableFuture.completedFuture(Collections.emptyList()); } SnapshotSegment snapshotSegment = @@ -130,59 +244,54 @@ private CompletableFuture> asyncLoadNextBucketSnapshotEntry(b List indexList = snapshotSegment.getIndexesList(); this.setCurrentSegmentEntryId(nextSegmentEntryId); if (isRecover) { - this.asyncUpdateSnapshotLength(); + return this.asyncUpdateSnapshotLength() + .thenAccept(this::setSnapshotLength) + .thenApply(__ -> indexList); } - return indexList; + return CompletableFuture.completedFuture(indexList); }); }); } /** * Recover delayed index bit map and message numbers. - * @throws InvalidRoaringFormat invalid bitmap serialization format */ private void recoverDelayedIndexBitMapAndNumber(int startSnapshotIndex, - List segmentMetaList) { + SnapshotMetadata snapshotMetadata) { delayedIndexBitMap.clear(); // cleanup dirty bm final var numberMessages = new MutableLong(0); - for (int i = startSnapshotIndex; i < segmentMetaList.size(); i++) { - for (final var entry : segmentMetaList.get(i).getDelayedIndexBitMapMap().entrySet()) { - final var ledgerId = entry.getKey(); - final var bs = entry.getValue(); - final var sbm = new RoaringBitmap(); + for (int i = startSnapshotIndex; i < snapshotMetadata.getMetadataListCount(); i++) { + snapshotMetadata.getMetadataList(i).getDelayedIndexBitMap().forEach((ledgerId, bs) -> { + final ByteBuf buf = Unpooled.wrappedBuffer(bs.asReadOnlyByteBuffer()); try { - sbm.deserialize(bs.asReadOnlyByteBuffer()); - } catch (IOException e) { - throw new InvalidRoaringFormat(e.getMessage()); + final LongBitmap sbm = LongBitmaps.deserialize(buf); + numberMessages.add(sbm.cardinality()); + delayedIndexBitMap.compute(ledgerId, (lId, bm) -> { + if (bm == null) { + return sbm; + } + bm.or(sbm); + return bm; + }); + } finally { + buf.release(); } - numberMessages.add(sbm.getCardinality()); - delayedIndexBitMap.compute(ledgerId, (lId, bm) -> { - if (bm == null) { - return sbm; - } - bm.or(sbm); - return bm; - }); - } + }); } - // optimize bm - delayedIndexBitMap.values().forEach(RoaringBitmap::runOptimize); - setNumberBucketDelayedMessages(numberMessages.getValue()); + setNumberBucketDelayedMessages(numberMessages.longValue()); } - CompletableFuture> getRemainSnapshotSegment() { - int nextSegmentEntryId = currentSegmentEntryId + 1; - if (nextSegmentEntryId > lastSegmentEntryId) { + CompletableFuture> getAllSnapshotSegments() { + if (lastSegmentEntryId < 1) { return CompletableFuture.completedFuture(Collections.emptyList()); } return executeWithRetry(() -> { - return bucketSnapshotStorage.getBucketSnapshotSegment(getAndUpdateBucketId(), nextSegmentEntryId, + return ctx.bucketSnapshotStorage().getBucketSnapshotSegment(getAndUpdateBucketId(), 1, lastSegmentEntryId).whenComplete((__, ex) -> { if (ex != null) { - log.warn( - "[{}] Failed to get remain bucket snapshot segment, bucketKey: {}," - + " nextSegmentEntryId: {}, lastSegmentEntryId: {}", - dispatcherName, bucketKey(), nextSegmentEntryId, lastSegmentEntryId, ex); + log.warn("[{}] Failed to get all bucket snapshot segments for merge, bucketKey: {}," + + " lastSegmentEntryId: {}", ctx.dispatcherName(), bucketKey(), + lastSegmentEntryId, ex); } }); }, BucketSnapshotPersistenceException.class, MaxRetryTimes); @@ -194,17 +303,17 @@ CompletableFuture asyncDeleteBucketSnapshot(BucketDelayedMessageIndexStats String bucketKey = bucketKey(); long bucketId = getAndUpdateBucketId(); - return executeWithRetry(() -> bucketSnapshotStorage.deleteBucketSnapshot(bucketId), + return executeWithRetry(() -> ctx.bucketSnapshotStorage().deleteBucketSnapshot(bucketId), BucketSnapshotPersistenceException.class, MaxRetryTimes) .whenComplete((__, ex) -> { if (ex != null) { log.error("[{}] Failed to delete bucket snapshot, bucketId: {}, bucketKey: {}", - dispatcherName, bucketId, bucketKey, ex); + ctx.dispatcherName(), bucketId, bucketKey, ex); stats.recordFailEvent(BucketDelayedMessageIndexStats.Type.delete); } else { log.info("[{}] Delete bucket snapshot finish, bucketId: {}, bucketKey: {}", - dispatcherName, bucketId, bucketKey); + ctx.dispatcherName(), bucketId, bucketKey); stats.recordSuccessEvent(BucketDelayedMessageIndexStats.Type.delete, System.currentTimeMillis() - deleteStartTime); @@ -221,12 +330,10 @@ CompletableFuture clear(BucketDelayedMessageIndexStats stats) { protected CompletableFuture asyncUpdateSnapshotLength() { long bucketId = getAndUpdateBucketId(); - return bucketSnapshotStorage.getBucketSnapshotLength(bucketId).whenComplete((length, ex) -> { + return ctx.bucketSnapshotStorage().getBucketSnapshotLength(bucketId).whenComplete((length, ex) -> { if (ex != null) { log.error("[{}] Failed to get snapshot length, bucketId: {}, bucketKey: {}", - dispatcherName, bucketId, bucketKey(), ex); - } else { - setSnapshotLength(length); + ctx.dispatcherName(), bucketId, bucketKey(), ex); } }); } diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/MutableBucket.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/MutableBucket.java index 1173a401a8903..d0a9e8aef1a1b 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/MutableBucket.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/delayed/bucket/MutableBucket.java @@ -20,7 +20,6 @@ import static com.google.common.base.Preconditions.checkArgument; import com.google.protobuf.UnsafeByteOperations; -import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.HashMap; import java.util.Iterator; @@ -28,24 +27,27 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; import lombok.extern.slf4j.Slf4j; -import org.apache.bookkeeper.mledger.ManagedCursor; import org.apache.commons.lang3.tuple.Pair; import org.apache.pulsar.broker.delayed.proto.DelayedIndex; import org.apache.pulsar.broker.delayed.proto.SnapshotMetadata; import org.apache.pulsar.broker.delayed.proto.SnapshotSegment; import org.apache.pulsar.broker.delayed.proto.SnapshotSegmentMetadata; -import org.apache.pulsar.common.util.FutureUtil; +import org.apache.pulsar.common.util.collections.LongBitmap; +import org.apache.pulsar.common.util.collections.LongBitmaps; import org.apache.pulsar.common.util.collections.TripleLongPriorityQueue; -import org.roaringbitmap.RoaringBitmap; @Slf4j -class MutableBucket extends Bucket implements AutoCloseable { +class MutableBucket implements AutoCloseable { + + private final BucketContext ctx; private final TripleLongPriorityQueue priorityQueue; - MutableBucket(String dispatcherName, ManagedCursor cursor, FutureUtil.Sequencer sequencer, - BucketSnapshotStorage bucketSnapshotStorage) { - super(dispatcherName, cursor, sequencer, bucketSnapshotStorage, -1L, -1L); + long startLedgerId = -1L; + long endLedgerId = -1L; + + MutableBucket(BucketContext ctx) { + this.ctx = ctx; this.priorityQueue = new TripleLongPriorityQueue(); } @@ -63,10 +65,9 @@ Pair createImmutableBucketAndAsyncPersistent( TripleLongPriorityQueue sharedQueue, DelayedIndexQueue delayedIndexQueue, final long startLedgerId, final long endLedgerId) { if (log.isDebugEnabled()) { - log.debug("[{}] Creating bucket snapshot, startLedgerId: {}, endLedgerId: {}", dispatcherName, + log.debug("[{}] Creating bucket snapshot, startLedgerId: {}, endLedgerId: {}", ctx.dispatcherName(), startLedgerId, endLedgerId); } - if (delayedIndexQueue.isEmpty()) { return null; } @@ -74,9 +75,9 @@ Pair createImmutableBucketAndAsyncPersistent( List bucketSnapshotSegments = new ArrayList<>(); List segmentMetadataList = new ArrayList<>(); - Map immutableBucketBitMap = new HashMap<>(); + Map immutableBucketBitMap = new HashMap<>(); - Map bitMap = new HashMap<>(); + Map bitMap = new HashMap<>(); SnapshotSegment snapshotSegment = new SnapshotSegment(); SnapshotSegmentMetadata.Builder segmentMetadataBuilder = SnapshotSegmentMetadata.newBuilder(); @@ -97,8 +98,6 @@ Pair createImmutableBucketAndAsyncPersistent( final long ledgerId = delayedIndex.getLedgerId(); final long entryId = delayedIndex.getEntryId(); - removeIndexBit(ledgerId, entryId); - checkArgument(ledgerId >= startLedgerId && ledgerId <= endLedgerId); // Move first segment of bucket snapshot to sharedBucketPriorityQueue @@ -106,7 +105,7 @@ Pair createImmutableBucketAndAsyncPersistent( sharedQueue.add(timestamp, ledgerId, entryId); } - bitMap.computeIfAbsent(ledgerId, k -> new RoaringBitmap()).add(entryId, entryId + 1); + bitMap.computeIfAbsent(ledgerId, k -> LongBitmaps.create()).add(entryId); numMessages++; @@ -117,16 +116,12 @@ Pair createImmutableBucketAndAsyncPersistent( segmentMetadataBuilder.setMinScheduleTimestamp(currentFirstTimestamp); currentTimestampUpperLimit = 0; - Iterator> iterator = bitMap.entrySet().iterator(); + Iterator> iterator = bitMap.entrySet().iterator(); while (iterator.hasNext()) { final var entry = iterator.next(); final var lId = entry.getKey(); final var bm = entry.getValue(); - bm.runOptimize(); - ByteBuffer byteBuffer = ByteBuffer.allocate(bm.serializedSizeInBytes()); - bm.serialize(byteBuffer); - byteBuffer.flip(); - segmentMetadataBuilder.putDelayedIndexBitMap(lId, UnsafeByteOperations.unsafeWrap(byteBuffer)); + segmentMetadataBuilder.putDelayedIndexBitMap(lId, UnsafeByteOperations.unsafeWrap(bm.serialize())); immutableBucketBitMap.compute(lId, (__, bm0) -> { if (bm0 == null) { return bm; @@ -138,25 +133,20 @@ Pair createImmutableBucketAndAsyncPersistent( } segmentMetadataList.add(segmentMetadataBuilder.build()); - segmentMetadataBuilder.clear(); + segmentMetadataBuilder = SnapshotSegmentMetadata.newBuilder(); bucketSnapshotSegments.add(snapshotSegment); snapshotSegment = new SnapshotSegment(); } } - // optimize bm - immutableBucketBitMap.values().forEach(RoaringBitmap::runOptimize); - this.delayedIndexBitMap.values().forEach(RoaringBitmap::runOptimize); - SnapshotMetadata bucketSnapshotMetadata = SnapshotMetadata.newBuilder() .addAllMetadataList(segmentMetadataList) .build(); final int lastSegmentEntryId = segmentMetadataList.size(); - ImmutableBucket bucket = new ImmutableBucket(dispatcherName, cursor, sequencer, bucketSnapshotStorage, - startLedgerId, endLedgerId); + ImmutableBucket bucket = new ImmutableBucket(ctx, startLedgerId, endLedgerId); bucket.setCurrentSegmentEntryId(1); bucket.setNumberBucketDelayedMessages(numMessages); bucket.setLastSegmentEntryId(lastSegmentEntryId); @@ -173,7 +163,7 @@ Pair createImmutableBucketAndAsyncPersistent( DelayedIndex lastDelayedIndex = firstSnapshotSegment.getIndexeAt(firstSnapshotSegment.getIndexesCount() - 1); Pair result = Pair.of(bucket, lastDelayedIndex); - CompletableFuture future = asyncSaveBucketSnapshot(bucket, + CompletableFuture future = bucket.asyncSaveBucketSnapshot( bucketSnapshotMetadata, bucketSnapshotSegments); bucket.setSnapshotCreateFuture(future); @@ -202,7 +192,6 @@ void resetLastMutableBucketRange() { void clear() { this.resetLastMutableBucketRange(); - this.delayedIndexBitMap.clear(); this.priorityQueue.clear(); } @@ -232,6 +221,5 @@ void addMessage(long ledgerId, long entryId, long deliverAt) { this.startLedgerId = ledgerId; } this.endLedgerId = ledgerId; - putIndexBit(ledgerId, entryId); } } diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/LeaderElectionService.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/LeaderElectionService.java index 2e53b54e98f61..21f67bd6b3613 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/LeaderElectionService.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/LeaderElectionService.java @@ -56,10 +56,20 @@ public void close() throws Exception { leaderElection.close(); } + /** + * Authoritative read of the current leader: if a leader election is in progress, the returned + * future completes once it settles (bounded by the default metadata operation timeout). Use + * this whenever a decision is made based on who the leader is. + */ public CompletableFuture> readCurrentLeader() { return leaderElection.getLeaderValue(); } + /** + * Non-blocking snapshot of the current leader; empty while a re-election is settling even + * though a leader may technically exist. Only suitable for best-effort uses such as logging — + * decision-making callers must use {@link #readCurrentLeader()}. + */ public Optional getCurrentLeader() { return leaderElection.getLeaderValueIfPresent(); } diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/LoadManager.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/LoadManager.java index 9399fcb3aad7a..c9aa673d6b53d 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/LoadManager.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/LoadManager.java @@ -151,35 +151,31 @@ default void writeLoadReportOnZookeeper(boolean force) throws Exception { void initialize(PulsarService pulsar); static LoadManager create(final PulsarService pulsar) { - try { - final ServiceConfiguration conf = pulsar.getConfiguration(); - - String loadManagerClassName = conf.getLoadManagerClassName(); - if (StringUtils.isBlank(loadManagerClassName)) { - loadManagerClassName = SimpleLoadManagerImpl.class.getName(); - } - - // Assume there is a constructor with one argument of PulsarService. - final Object loadManagerInstance = Reflections.createInstance(loadManagerClassName, - Thread.currentThread().getContextClassLoader()); - if (loadManagerInstance instanceof LoadManager casted) { - casted.initialize(pulsar); - return casted; - } else if (loadManagerInstance instanceof ModularLoadManager modularLoadManager) { - final LoadManager casted = new ModularLoadManagerWrapper(modularLoadManager); - casted.initialize(pulsar); - return casted; - } else if (loadManagerInstance instanceof ExtensibleLoadManager) { - final LoadManager casted = - new ExtensibleLoadManagerWrapper((ExtensibleLoadManagerImpl) loadManagerInstance); - casted.initialize(pulsar); - return casted; - } - } catch (Exception e) { - LOG.warn("Error when trying to create load manager: ", e); + final ServiceConfiguration conf = pulsar.getConfiguration(); + + String loadManagerClassName = conf.getLoadManagerClassName(); + if (StringUtils.isBlank(loadManagerClassName)) { + loadManagerClassName = SimpleLoadManagerImpl.class.getName(); + } + + // Assume there is a constructor with one argument of PulsarService. + final Object loadManagerInstance = Reflections.createInstance(loadManagerClassName, + Thread.currentThread().getContextClassLoader()); + if (loadManagerInstance instanceof LoadManager casted) { + casted.initialize(pulsar); + return casted; + } else if (loadManagerInstance instanceof ModularLoadManager modularLoadManager) { + final LoadManager casted = new ModularLoadManagerWrapper(modularLoadManager); + casted.initialize(pulsar); + return casted; + } else if (loadManagerInstance instanceof ExtensibleLoadManager) { + final LoadManager casted = + new ExtensibleLoadManagerWrapper((ExtensibleLoadManagerImpl) loadManagerInstance); + casted.initialize(pulsar); + return casted; + } else { + throw new IllegalArgumentException(loadManagerClassName + " is not a supported LoadManager"); } - // If we failed to create a load manager, default to SimpleLoadManagerImpl. - return new SimpleLoadManagerImpl(pulsar); } } diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/ModularLoadManager.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/ModularLoadManager.java index d608bd6784f29..93059d722e734 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/ModularLoadManager.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/ModularLoadManager.java @@ -138,8 +138,22 @@ default void writeBrokerDataOnZooKeeper(boolean force) { * * @param bundle * @return bundle data + * @deprecated use {@link #getBundleDataOrDefaultAsync(String)} instead; this method blocks on + * metadata-store reads and must not be called from async or event-loop threads. */ + @Deprecated BundleData getBundleDataOrDefault(String bundle); + /** + * Asynchronously fetch bundle's load report data. + * + * @param bundle + * @return future of the bundle data + */ + @SuppressWarnings("deprecation") + default CompletableFuture getBundleDataOrDefaultAsync(String bundle) { + return CompletableFuture.completedFuture(getBundleDataOrDefault(bundle)); + } + String setNamespaceBundleAffinity(String bundle, String broker); } diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/extensions/ExtensibleLoadManagerImpl.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/extensions/ExtensibleLoadManagerImpl.java index ef29d7d9a74f3..af95df60174db 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/extensions/ExtensibleLoadManagerImpl.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/extensions/ExtensibleLoadManagerImpl.java @@ -84,6 +84,7 @@ import org.apache.pulsar.broker.loadbalance.extensions.strategy.BrokerSelectionStrategy; import org.apache.pulsar.broker.loadbalance.extensions.strategy.BrokerSelectionStrategyFactory; import org.apache.pulsar.broker.loadbalance.extensions.strategy.LeastResourceUsageWithWeight; +import org.apache.pulsar.broker.loadbalance.extensions.strategy.RoundRobinBrokerSelectionStrategy; import org.apache.pulsar.broker.loadbalance.impl.LoadManagerShared; import org.apache.pulsar.broker.loadbalance.impl.SimpleResourceAllocationPolicies; import org.apache.pulsar.broker.namespace.LookupOptions; @@ -161,6 +162,8 @@ public class ExtensibleLoadManagerImpl implements ExtensibleLoadManager, BrokerS @Getter private final BrokerSelectionStrategy brokerSelectionStrategy; + private final BrokerSelectionStrategy sheddingExcludedNamespaceSelectionStrategy; + @Getter private final List brokerFilterPipeline; @@ -254,6 +257,7 @@ public ExtensibleLoadManagerImpl() { this.brokerFilterPipeline.add(new BrokerMaxTopicCountFilter()); this.brokerFilterPipeline.add(new BrokerVersionFilter()); this.brokerSelectionStrategy = createBrokerSelectionStrategy(); + this.sheddingExcludedNamespaceSelectionStrategy = new RoundRobinBrokerSelectionStrategy(); } public static boolean isLoadManagerExtensionEnabled(PulsarService pulsar) { @@ -636,11 +640,33 @@ public CompletableFuture> selectAsync(ServiceUnitId bundle, return Optional.empty(); } Set candidateBrokers = availableBrokerCandidates.keySet(); - return getBrokerSelectionStrategy().select(candidateBrokers, bundle, context); + return getBrokerSelectionStrategy(bundle).select(candidateBrokers, bundle, context); }); }); } + /** + * For shedding excluded namespaces, use RoundRobinBrokerSelector to assign the ownership, + * it can make the assignment more average because these will not automatically rebalance to + * another broker unless manually unloaded it. + * + * @param bundle the bundle to assign + * @return the broker selection strategy + */ + private BrokerSelectionStrategy getBrokerSelectionStrategy(ServiceUnitId bundle) { + + Set sheddingExcludedNamespaces = conf.getLoadBalancerSheddingExcludedNamespaces(); + + var namespace = NamespaceBundle.getBundleNamespace(bundle.toString()); + if (sheddingExcludedNamespaces.contains(namespace)) { + if (debug(conf, log)) { + log.info("Use round robin broker selector for {}", bundle); + } + return sheddingExcludedNamespaceSelectionStrategy; + } + return brokerSelectionStrategy; + } + @Override public CompletableFuture checkOwnershipAsync(Optional topic, ServiceUnitId bundleUnit) { return getOwnershipAsync(topic, bundleUnit) diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/extensions/channel/ServiceUnitStateChannelImpl.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/extensions/channel/ServiceUnitStateChannelImpl.java index a0e9f1f73e251..731a2196d2e07 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/extensions/channel/ServiceUnitStateChannelImpl.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/extensions/channel/ServiceUnitStateChannelImpl.java @@ -232,6 +232,16 @@ public ServiceUnitStateChannelImpl(PulsarService pulsar) { this.channelState = Constructed; } + @VisibleForTesting + Map> getOwnerRequests() { + return getOwnerRequests; + } + + @VisibleForTesting + Map> getCleanupJobs() { + return cleanupJobs; + } + @Override public void scheduleOwnershipMonitor() { if (monitorTask == null) { @@ -846,13 +856,15 @@ brokerId, getLogEventTag(data), serviceUnit, } } - private void handleSkippedEvent(String serviceUnit) { + @VisibleForTesting + void handleSkippedEvent(String serviceUnit) { var getOwnerRequest = getOwnerRequests.get(serviceUnit); if (getOwnerRequest != null) { var data = tableview.get(serviceUnit); if (data != null && data.state() == Owned) { getOwnerRequest.complete(data.dstBroker()); - getOwnerRequests.remove(serviceUnit); + // Completing the request can run callbacks that install a newer request for the same service unit. + getOwnerRequests.remove(serviceUnit, getOwnerRequest); stateChangeListeners.notify(serviceUnit, data, null); } } @@ -1015,7 +1027,8 @@ private CompletableFuture deferGetOwner(String serviceUnit) { return future; } - private CompletableFuture dedupeGetOwnerRequest(String serviceUnit) { + @VisibleForTesting + CompletableFuture dedupeGetOwnerRequest(String serviceUnit) { var requested = new MutableObject>(); try { @@ -1048,7 +1061,8 @@ private CompletableFuture dedupeGetOwnerRequest(String serviceUnit) { var future = requested.getValue(); if (future != null) { future.whenComplete((__, e) -> { - getOwnerRequests.remove(serviceUnit); + // The request may have been replaced by a later lookup before this callback runs. + getOwnerRequests.remove(serviceUnit, future); if (e != null) { log.warn("{} failed to getOwner for serviceUnit:{}", brokerId, serviceUnit, e); } @@ -1325,12 +1339,13 @@ private MetadataState getMetadataState() { private void handleBrokerCreationEvent(String broker) { - if (!cleanupJobs.isEmpty() && cleanupJobs.containsKey(broker)) { + CompletableFuture cleanupJob = cleanupJobs.get(broker); + if (cleanupJob != null) { healthCheckBrokerAsync(broker) .thenAccept(__ -> { - CompletableFuture future = cleanupJobs.remove(broker); - if (future != null) { - future.cancel(false); + // The health check is async; only cancel the cleanup job observed before the check. + if (cleanupJobs.remove(broker, cleanupJob)) { + cleanupJob.cancel(false); totalInactiveBrokerCleanupCancelledCnt++; log.info("Successfully cancelled the ownership cleanup for broker:{}." + " Active cleanup job count:{}", @@ -1391,7 +1406,8 @@ private boolean channelDisabled() { return false; } - private void scheduleCleanup(String broker, long delayInSecs) { + @VisibleForTesting + void scheduleCleanup(String broker, long delayInSecs) { var scheduled = new MutableObject>(); try { if (channelDisabled()) { @@ -1419,7 +1435,8 @@ private void scheduleCleanup(String broker, long delayInSecs) { var future = scheduled.getValue(); if (future != null) { future.whenComplete((v, ex) -> { - cleanupJobs.remove(broker); + // The job may have been replaced by a later cleanup schedule before this callback runs. + cleanupJobs.remove(broker, future); }); } } diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/extensions/channel/ServiceUnitStateTableViewSyncer.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/extensions/channel/ServiceUnitStateTableViewSyncer.java index 45ff0dcb2674e..999c7bea93ad2 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/extensions/channel/ServiceUnitStateTableViewSyncer.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/extensions/channel/ServiceUnitStateTableViewSyncer.java @@ -23,20 +23,26 @@ import static org.apache.pulsar.broker.loadbalance.extensions.ExtensibleLoadManagerImpl.COMPACTION_THRESHOLD; import static org.apache.pulsar.broker.loadbalance.extensions.ExtensibleLoadManagerImpl.configureSystemTopics; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.google.common.annotations.VisibleForTesting; import java.io.Closeable; import java.io.IOException; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.function.BiConsumer; import lombok.Cleanup; import lombok.extern.slf4j.Slf4j; import org.apache.pulsar.broker.PulsarService; +import org.apache.pulsar.client.api.PulsarClientException; import org.apache.pulsar.common.util.FutureUtil; import org.apache.pulsar.common.util.ObjectMapperFactory; +import org.jspecify.annotations.NonNull; /** * Defines ServiceUnitTableViewSyncer. @@ -47,10 +53,15 @@ public class ServiceUnitStateTableViewSyncer implements Closeable { private static final int MAX_CONCURRENT_SYNC_COUNT = 100; private static final int SYNC_WAIT_TIME_IN_SECS = 300; + private static final long RECONCILE_INTERVAL_IN_MILLIS = 5_000; + private static final BiConsumer NOOP_CONSUMER = (__, ___) -> { + }; + private volatile int syncWaitTimeInSecs = SYNC_WAIT_TIME_IN_SECS; private PulsarService pulsar; private volatile ServiceUnitStateTableView systemTopicTableView; private volatile ServiceUnitStateTableView metadataStoreTableView; private volatile boolean isActive = false; + private final ObjectWriter jsonWriter = ObjectMapperFactory.getMapper().writer(); public void start(PulsarService pulsar) @@ -82,53 +93,77 @@ public void start(PulsarService pulsar) } private CompletableFuture syncToSystemTopic(String key, ServiceUnitStateData data) { - return systemTopicTableView.put(key, data); + return logIfFailed(sync(systemTopicTableView, key, data), key, data, "systemTopic"); } private CompletableFuture syncToMetadataStore(String key, ServiceUnitStateData data) { - return metadataStoreTableView.put(key, data); + return logIfFailed(sync(metadataStoreTableView, key, data), key, data, "metadataStore"); } - private void dummy(String key, ServiceUnitStateData data) { + private CompletableFuture sync(ServiceUnitStateTableView dst, String key, ServiceUnitStateData data) { + // A null tail item is a tombstone: the source view removed the key. Route it to + // delete() rather than put(): the metadata-store view's put() rejects null + // (@NonNull) and the system-topic view's delete() is itself a null-valued put(), + // so a uniform delete keeps both sync directions symmetric and prevents a missed + // deletion from leaving the two views with different sizes (which would make + // waitUntilSynced spin until the timeout budget). + return data == null ? dst.delete(key) : dst.put(key, data); + } + + private CompletableFuture logIfFailed(CompletableFuture future, String key, + ServiceUnitStateData data, String dst) { + return future.whenComplete((__, e) -> { + if (e != null && !(e instanceof PulsarClientException.AlreadyClosedException)) { + log.warn("Failed to sync tableview item; sizes may diverge until the next update;" + + " dst={} serviceUnit={} data={}", dst, key, data, e); + } + }); } private void syncExistingItems() throws IOException, ExecutionException, InterruptedException, TimeoutException { long started = System.currentTimeMillis(); + @Cleanup ServiceUnitStateTableView metadataStoreTableView = new ServiceUnitStateMetadataStoreTableViewImpl(); metadataStoreTableView.start( pulsar, - this::dummy, - this::dummy, - this::dummy + NOOP_CONSUMER, + NOOP_CONSUMER, + NOOP_CONSUMER ); @Cleanup ServiceUnitStateTableView systemTopicTableView = new ServiceUnitStateTableViewImpl(); systemTopicTableView.start( pulsar, - this::dummy, - this::dummy, - this::dummy + NOOP_CONSUMER, + NOOP_CONSUMER, + NOOP_CONSUMER ); var syncer = pulsar.getConfiguration().getLoadBalancerServiceUnitTableViewSyncer(); + ServiceUnitStateTableView src; + ServiceUnitStateTableView dst; if (syncer == SystemTopicToMetadataStoreSyncer) { clean(metadataStoreTableView); syncExistingItemsToMetadataStore(systemTopicTableView); + src = systemTopicTableView; + dst = metadataStoreTableView; } else { clean(systemTopicTableView); syncExistingItemsToSystemTopic(metadataStoreTableView, systemTopicTableView); + src = metadataStoreTableView; + dst = systemTopicTableView; } - if (!waitUntilSynced(metadataStoreTableView, systemTopicTableView, started)) { + if (!waitUntilSynced(src, dst, started)) { throw new TimeoutException( syncer + " failed to sync existing items in tableviews. MetadataStoreTableView.size: " + metadataStoreTableView.entrySet().size() + ", SystemTopicTableView.size: " + systemTopicTableView.entrySet().size() + " in " - + SYNC_WAIT_TIME_IN_SECS + " secs"); + + syncWaitTimeInSecs + " secs"); } log.info("Synced existing items MetadataStoreTableView.size:{} , " @@ -154,8 +189,8 @@ private void syncTailItems() throws InterruptedException, IOException, TimeoutEx this.metadataStoreTableView.start( pulsar, this::syncToSystemTopic, - this::dummy, - this::dummy + NOOP_CONSUMER, + NOOP_CONSUMER ); log.info("Started MetadataStoreTableView"); @@ -163,18 +198,20 @@ private void syncTailItems() throws InterruptedException, IOException, TimeoutEx this.systemTopicTableView.start( pulsar, this::syncToMetadataStore, - this::dummy, - this::dummy + NOOP_CONSUMER, + NOOP_CONSUMER ); log.info("Started SystemTopicTableView"); var syncer = pulsar.getConfiguration().getLoadBalancerServiceUnitTableViewSyncer(); - if (!waitUntilSynced(metadataStoreTableView, systemTopicTableView, started)) { + var src = syncer == SystemTopicToMetadataStoreSyncer ? systemTopicTableView : metadataStoreTableView; + var dst = syncer == SystemTopicToMetadataStoreSyncer ? metadataStoreTableView : systemTopicTableView; + if (!waitUntilSynced(src, dst, started)) { throw new TimeoutException( syncer + " failed to sync tableviews. MetadataStoreTableView.size: " + metadataStoreTableView.entrySet().size() + ", SystemTopicTableView.size: " + systemTopicTableView.entrySet().size() + " in " - + SYNC_WAIT_TIME_IN_SECS + " secs"); + + syncWaitTimeInSecs + " secs"); } @@ -187,62 +224,134 @@ private void syncTailItems() throws InterruptedException, IOException, TimeoutEx private void syncExistingItemsToMetadataStore(ServiceUnitStateTableView src) throws JsonProcessingException, ExecutionException, InterruptedException, TimeoutException { // Directly use store to sync existing items to metadataStoreTableView(otherwise, they are conflicted out) - var store = pulsar.getLocalMetadataStore(); - var writer = ObjectMapperFactory.getMapper().writer(); - var opTimeout = pulsar.getConfiguration().getMetadataStoreOperationTimeoutSeconds(); List> futures = new ArrayList<>(); var srcIter = src.entrySet().iterator(); while (srcIter.hasNext()) { var e = srcIter.next(); - futures.add(store.put(ServiceUnitStateMetadataStoreTableViewImpl.PATH_PREFIX + "/" + e.getKey(), - writer.writeValueAsBytes(e.getValue()), Optional.empty()).thenApply(__ -> null)); - if (futures.size() == MAX_CONCURRENT_SYNC_COUNT || !srcIter.hasNext()) { - FutureUtil.waitForAll(futures).get(opTimeout, TimeUnit.SECONDS); - } + futures.add(writeToMetadataStore(e.getKey(), e.getValue())); + maybeWaitCompletion(futures, !srcIter.hasNext()); + } + } + + private void maybeWaitCompletion(List> futures, boolean forceWait) + throws InterruptedException, ExecutionException, TimeoutException { + if (!futures.isEmpty() && (futures.size() == MAX_CONCURRENT_SYNC_COUNT || forceWait)) { + FutureUtil.waitForAll(futures) + .get(pulsar.getConfiguration().getMetadataStoreOperationTimeoutSeconds(), TimeUnit.SECONDS); + futures.clear(); } } + private @NonNull CompletableFuture writeToMetadataStore(String key, ServiceUnitStateData value) + throws JsonProcessingException { + return pulsar.getLocalMetadataStore().put(ServiceUnitStateMetadataStoreTableViewImpl.PATH_PREFIX + "/" + key, + jsonWriter.writeValueAsBytes(value), Optional.empty()).thenApply(__ -> null); + } + private void syncExistingItemsToSystemTopic(ServiceUnitStateTableView src, ServiceUnitStateTableView dst) throws ExecutionException, InterruptedException, TimeoutException { - var opTimeout = pulsar.getConfiguration().getMetadataStoreOperationTimeoutSeconds(); List> futures = new ArrayList<>(); var srcIter = src.entrySet().iterator(); while (srcIter.hasNext()) { var e = srcIter.next(); futures.add(dst.put(e.getKey(), e.getValue())); - if (futures.size() == MAX_CONCURRENT_SYNC_COUNT || !srcIter.hasNext()) { - FutureUtil.waitForAll(futures).get(opTimeout, TimeUnit.SECONDS); - } + maybeWaitCompletion(futures, !srcIter.hasNext()); } } private void clean(ServiceUnitStateTableView dst) throws ExecutionException, InterruptedException, TimeoutException { - var opTimeout = pulsar.getConfiguration().getMetadataStoreOperationTimeoutSeconds(); var dstIter = dst.entrySet().iterator(); List> futures = new ArrayList<>(); while (dstIter.hasNext()) { var e = dstIter.next(); futures.add(dst.delete(e.getKey())); - if (futures.size() == MAX_CONCURRENT_SYNC_COUNT || !dstIter.hasNext()) { - FutureUtil.waitForAll(futures).get(opTimeout, TimeUnit.SECONDS); - } + maybeWaitCompletion(futures, !dstIter.hasNext()); } } - private boolean waitUntilSynced(ServiceUnitStateTableView srt, ServiceUnitStateTableView dst, long started) + private boolean waitUntilSynced(ServiceUnitStateTableView src, ServiceUnitStateTableView dst, long started) throws InterruptedException { - while (srt.entrySet().size() != dst.entrySet().size()) { - if (TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis() - started) - > SYNC_WAIT_TIME_IN_SECS) { + long lastReconciled = started; + while (src.entrySet().size() != dst.entrySet().size()) { + long now = System.currentTimeMillis(); + if (TimeUnit.MILLISECONDS.toSeconds(now - started) > syncWaitTimeInSecs) { return false; } + // Give in-flight syncs a grace period to settle on their own, then reconcile + // periodically: updates that raced with the table views' (re)start were replayed + // to the fresh views as existing items — which are deliberately not wired to + // sync — so without reconciliation the views would never converge. + if (now - lastReconciled >= RECONCILE_INTERVAL_IN_MILLIS) { + if (log.isDebugEnabled()) { + log.debug("Tableviews not synced yet; reconciling; srcSize={} dstSize={} elapsedSecs={}", + src.entrySet().size(), dst.entrySet().size(), + TimeUnit.MILLISECONDS.toSeconds(now - started)); + } + reconcile(src, dst, started); + lastReconciled = now; + } Thread.sleep(100); } return true; } + /** + * Copies items the destination table view is missing and removes stale items that no longer + * exist in the source. Channel updates that land between the existing-items copy and the + * registration of the tail listeners are only visible as existing items of the freshly + * started views, so the tail listeners never see them. Writes flow to the migration source + * while the syncer starts, making the source view authoritative; destination-only items are + * removed only when they predate this sync phase and are still absent from the source, so a + * concurrent fresh write to the destination is never discarded. Failures are logged and left + * for the next reconcile pass. Runs on the caller's (load manager) thread with each batch + * bounded by the metadata store operation timeout. + */ + private void reconcile(ServiceUnitStateTableView src, ServiceUnitStateTableView dst, long started) + throws InterruptedException { + // Snapshot the destination entries before iterating the source so that a key arriving + // in the destination through a concurrent tail sync cannot be misclassified as stale. + var staleDstItems = new HashMap(); + for (var e : dst.entrySet()) { + staleDstItems.put(e.getKey(), e.getValue()); + } + try { + List> futures = new ArrayList<>(); + for (var e : src.entrySet()) { + if (staleDstItems.remove(e.getKey()) == null) { + log.info("Reconciling item missing from the destination tableview; serviceUnit={}", + e.getKey()); + if (dst.isMetadataStoreBased()) { + // Write directly to the store like syncExistingItemsToMetadataStore + // does; the view's put() would conflict the item out. + futures.add(writeToMetadataStore(e.getKey(), e.getValue())); + } else { + futures.add(dst.put(e.getKey(), e.getValue())); + } + maybeWaitCompletion(futures, false); + } + } + for (var e : staleDstItems.entrySet()) { + // Only remove items written before this sync phase began and re-confirmed absent + // from the source: a fresh destination write (e.g. from a broker already switched + // to the destination implementation) is propagated to the source by the tail + // listener instead of being deleted here. + if (e.getValue().timestamp() < started && src.get(e.getKey()) == null) { + log.info("Reconciling stale item in the destination tableview; serviceUnit={}", + e.getKey()); + futures.add(dst.delete(e.getKey())); + maybeWaitCompletion(futures, false); + } + } + maybeWaitCompletion(futures, true); + } catch (IOException | ExecutionException | TimeoutException e) { + // Transient write failures leave a size divergence behind; the next reconcile pass + // (or the sync-wait timeout) handles it. + log.warn("Failed to reconcile tableview items", e); + } + } + @Override public void close() throws IOException { if (!isActive) { @@ -282,4 +391,9 @@ public void close() throws IOException { public boolean isActive() { return isActive; } + + @VisibleForTesting + public void setSyncWaitTimeInSecs(int syncWaitTimeInSecs) { + this.syncWaitTimeInSecs = syncWaitTimeInSecs; + } } diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/extensions/models/TopKBundles.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/extensions/models/TopKBundles.java index 9c6e963417813..481e907d04439 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/extensions/models/TopKBundles.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/extensions/models/TopKBundles.java @@ -22,6 +22,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.ToString; @@ -68,8 +69,10 @@ public TopKBundles(PulsarService pulsar) { public void update(Map bundleStats, int topk) { arr.clear(); try { + var conf = pulsar.getConfiguration(); var isLoadBalancerSheddingBundlesWithPoliciesEnabled = - pulsar.getConfiguration().isLoadBalancerSheddingBundlesWithPoliciesEnabled(); + conf.isLoadBalancerSheddingBundlesWithPoliciesEnabled(); + Set sheddingExcludedNamespaces = conf.getLoadBalancerSheddingExcludedNamespaces(); for (var etr : bundleStats.entrySet()) { String bundle = etr.getKey(); var stat = etr.getValue(); @@ -79,12 +82,16 @@ public void update(Map bundleStats, int topk) { continue; } // TODO: do not filter system topic while shedding - if (NamespaceService.isSystemServiceNamespace(NamespaceBundle.getBundleNamespace(bundle))) { + String namespace = NamespaceBundle.getBundleNamespace(bundle); + if (NamespaceService.isSystemServiceNamespace(namespace)) { continue; } if (!isLoadBalancerSheddingBundlesWithPoliciesEnabled && hasPolicies(bundle)) { continue; } + if (sheddingExcludedNamespaces.contains(namespace)) { + continue; + } arr.add(etr); } var topKBundlesLoadData = loadData.getTopBundlesLoadData(); diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/extensions/scheduler/TransferShedder.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/extensions/scheduler/TransferShedder.java index b5255f2713a6a..18555bc18fac9 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/extensions/scheduler/TransferShedder.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/extensions/scheduler/TransferShedder.java @@ -493,6 +493,7 @@ public Set findBundlesForUnloading(LoadManagerContext context, } int remainingTopBundles = maxBrokerTopBundlesLoadData.size(); + Set sheddingExcludedNamespaces = conf.getLoadBalancerSheddingExcludedNamespaces(); for (var e : maxBrokerTopBundlesLoadData) { String bundle = e.bundleName(); if (channel != null && !channel.isOwner(bundle, maxBroker)) { @@ -502,6 +503,14 @@ public Set findBundlesForUnloading(LoadManagerContext context, } continue; } + final String namespaceName = NamespaceBundle.getBundleNamespace(bundle); + if (sheddingExcludedNamespaces.contains(namespaceName)) { + if (debugMode) { + log.info(String.format(CANNOT_UNLOAD_BUNDLE_MSG + + " Bundle namespace has been found in sheddingExcludedNamespaces", bundle)); + } + continue; + } if (recentlyUnloadedBundles.containsKey(bundle)) { if (debugMode) { log.info(String.format(CANNOT_UNLOAD_BUNDLE_MSG diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/extensions/store/TableViewLoadDataStoreImpl.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/extensions/store/TableViewLoadDataStoreImpl.java index 3ce44a1e65a73..21b687337c9a7 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/extensions/store/TableViewLoadDataStoreImpl.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/extensions/store/TableViewLoadDataStoreImpl.java @@ -18,6 +18,7 @@ */ package org.apache.pulsar.broker.loadbalance.extensions.store; +import com.google.common.annotations.VisibleForTesting; import java.io.IOException; import java.util.Map; import java.util.Optional; @@ -145,7 +146,14 @@ public synchronized void init() throws IOException { public synchronized void closeTableView() throws IOException { validateState(); if (tableView != null) { - tableView.close(); + // Close asynchronously without waiting: the close future may only be able to complete on the + // client's internal executor thread, and that thread can be blocked on this store's monitor + // (e.g. a ServiceUnitStateChannel StateChangeListener calling pushAsync/removeAsync), so a + // blocking close while holding the monitor could deadlock. + tableView.closeAsync().exceptionally(e -> { + log.warn("Failed to close table view on {}", topic, e); + return null; + }); tableView = null; } } @@ -160,7 +168,11 @@ public synchronized void start() throws LoadDataStoreException { private synchronized void closeProducer() throws IOException { validateState(); if (producer != null) { - producer.close(); + // Close asynchronously without waiting; see closeTableView() for the deadlock rationale. + producer.closeAsync().exceptionally(e -> { + log.warn("Failed to close producer on {}", topic, e); + return null; + }); producer = null; } } @@ -210,6 +222,11 @@ public synchronized void shutdown() throws IOException { isShutdown = true; } + @VisibleForTesting + synchronized void setTableView(TableView tableView) { + this.tableView = tableView; + } + private String validateProducer() { if (isShutdown) { return SHUTDOWN_ERR_MSG; diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/extensions/strategy/RoundRobinBrokerSelectionStrategy.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/extensions/strategy/RoundRobinBrokerSelectionStrategy.java new file mode 100644 index 0000000000000..2f356ec1f5ec1 --- /dev/null +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/extensions/strategy/RoundRobinBrokerSelectionStrategy.java @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.broker.loadbalance.extensions.strategy; + +import java.util.Optional; +import java.util.Set; +import org.apache.pulsar.broker.loadbalance.extensions.LoadManagerContext; +import org.apache.pulsar.broker.loadbalance.impl.RoundRobinBrokerSelector; +import org.apache.pulsar.common.naming.ServiceUnitId; + +/** + * Simple Round Robin Broker Selection Strategy. + */ +public class RoundRobinBrokerSelectionStrategy implements BrokerSelectionStrategy { + private final RoundRobinBrokerSelector selector = new RoundRobinBrokerSelector(); + + @Override + public Optional select(Set brokers, ServiceUnitId bundle, LoadManagerContext context) { + return selector.selectBroker(brokers, null, null, context.brokerConfiguration()); + } +} diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/impl/ModularLoadManagerImpl.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/impl/ModularLoadManagerImpl.java index 1f549cbb66ec7..214d12e7ad2c8 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/impl/ModularLoadManagerImpl.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/loadbalance/impl/ModularLoadManagerImpl.java @@ -152,6 +152,8 @@ public class ModularLoadManagerImpl implements ModularLoadManager { // Strategy used to determine where new topics should be placed. private ModularLoadManagerStrategy placementStrategy; + private ModularLoadManagerStrategy sheddingExcludedNamespaceSelectionStrategy; + // Policies used to determine which brokers are available for particular namespaces. private SimpleResourceAllocationPolicies policies; @@ -252,6 +254,7 @@ public void initialize(final PulsarService pulsar) { defaultStats.msgRateOut = DEFAULT_MESSAGE_RATE; placementStrategy = ModularLoadManagerStrategy.create(conf); + sheddingExcludedNamespaceSelectionStrategy = new RoundRobinBrokerSelector(); policies = new SimpleResourceAllocationPolicies(pulsar); filterPipeline.add(new BrokerLoadManagerClassFilter()); filterPipeline.add(new BrokerVersionFilter()); @@ -378,46 +381,55 @@ private boolean checkBundleDataExistInNamespaceBundles(NamespaceBundles namespac // Attempt to local the data for the given bundle in metadata store // If it cannot be found, return the default bundle data. + /** + * @deprecated use {@link #getBundleDataOrDefaultAsync(String)} instead. + */ + @Deprecated @Override public BundleData getBundleDataOrDefault(final String bundle) { - BundleData bundleData = null; - try { - Optional optBundleData = - pulsarResources.getLoadBalanceResources().getBundleDataResources().getBundleData(bundle).join(); - if (optBundleData.isPresent()) { - return optBundleData.get(); - } + return getBundleDataOrDefaultAsync(bundle).join(); + } - Optional optQuota = pulsarResources.getLoadBalanceResources().getQuotaResources() - .getQuota(bundle).join(); - if (optQuota.isPresent()) { - ResourceQuota quota = optQuota.get(); - bundleData = new BundleData(NUM_SHORT_SAMPLES, NUM_LONG_SAMPLES); - // Initialize from existing resource quotas if new API ZNodes do not exist. - final TimeAverageMessageData shortTermData = bundleData.getShortTermData(); - final TimeAverageMessageData longTermData = bundleData.getLongTermData(); - - shortTermData.setMsgRateIn(quota.getMsgRateIn()); - shortTermData.setMsgRateOut(quota.getMsgRateOut()); - shortTermData.setMsgThroughputIn(quota.getBandwidthIn()); - shortTermData.setMsgThroughputOut(quota.getBandwidthOut()); - - longTermData.setMsgRateIn(quota.getMsgRateIn()); - longTermData.setMsgRateOut(quota.getMsgRateOut()); - longTermData.setMsgThroughputIn(quota.getBandwidthIn()); - longTermData.setMsgThroughputOut(quota.getBandwidthOut()); - - // Assume ample history. - shortTermData.setNumSamples(NUM_SHORT_SAMPLES); - longTermData.setNumSamples(NUM_LONG_SAMPLES); - } - } catch (Exception e) { - log.warn("Error when trying to find bundle {} on metadata store: {}", bundle, e); - } - if (bundleData == null) { - bundleData = new BundleData(NUM_SHORT_SAMPLES, NUM_LONG_SAMPLES, defaultStats); - } - return bundleData; + @Override + public CompletableFuture getBundleDataOrDefaultAsync(final String bundle) { + return pulsarResources.getLoadBalanceResources().getBundleDataResources().getBundleData(bundle) + .thenCompose(optBundleData -> { + if (optBundleData.isPresent()) { + return CompletableFuture.completedFuture(optBundleData.get()); + } + return pulsarResources.getLoadBalanceResources().getQuotaResources().getQuota(bundle) + .thenApply(optQuota -> { + if (optQuota.isEmpty()) { + return null; + } + ResourceQuota quota = optQuota.get(); + BundleData bundleData = new BundleData(NUM_SHORT_SAMPLES, NUM_LONG_SAMPLES); + // Initialize from existing resource quotas if new API ZNodes do not exist. + final TimeAverageMessageData shortTermData = bundleData.getShortTermData(); + final TimeAverageMessageData longTermData = bundleData.getLongTermData(); + + shortTermData.setMsgRateIn(quota.getMsgRateIn()); + shortTermData.setMsgRateOut(quota.getMsgRateOut()); + shortTermData.setMsgThroughputIn(quota.getBandwidthIn()); + shortTermData.setMsgThroughputOut(quota.getBandwidthOut()); + + longTermData.setMsgRateIn(quota.getMsgRateIn()); + longTermData.setMsgRateOut(quota.getMsgRateOut()); + longTermData.setMsgThroughputIn(quota.getBandwidthIn()); + longTermData.setMsgThroughputOut(quota.getBandwidthOut()); + + // Assume ample history. + shortTermData.setNumSamples(NUM_SHORT_SAMPLES); + longTermData.setNumSamples(NUM_LONG_SAMPLES); + return bundleData; + }); + }) + .exceptionally(e -> { + log.warn("Error when trying to find bundle {} on metadata store: {}", bundle, e); + return null; + }) + .thenApply(bundleData -> bundleData != null ? bundleData + : new BundleData(NUM_SHORT_SAMPLES, NUM_LONG_SAMPLES, defaultStats)); } // Use the Pulsar client to acquire the namespace bundle stats. @@ -558,7 +570,7 @@ private void updateBundleData() { } else { // Otherwise, attempt to find the bundle data on metadata store. // If it cannot be found, use the latest stats as the first sample. - BundleData currentBundleData = getBundleDataOrDefault(bundle); + BundleData currentBundleData = getBundleDataOrDefaultAsync(bundle).join(); currentBundleData.update(stats); bundleData.put(bundle, currentBundleData); } @@ -641,6 +653,7 @@ public synchronized void doLoadShedding() { final Map recentlyUnloadedBundles = loadData.getRecentlyUnloadedBundles(); recentlyUnloadedBundles.keySet().removeIf(e -> recentlyUnloadedBundles.get(e) < timeout); + Set sheddingExcludedNamespaces = conf.getLoadBalancerSheddingExcludedNamespaces(); final Multimap bundlesToUnload = loadSheddingStrategy.findBundlesForUnloading(loadData, conf); bundlesToUnload.asMap().forEach((broker, bundles) -> { @@ -648,6 +661,13 @@ public synchronized void doLoadShedding() { bundles.forEach(bundle -> { final String namespaceName = LoadManagerShared.getNamespaceNameFromBundleName(bundle); final String bundleRange = LoadManagerShared.getBundleRangeFromBundleName(bundle); + if (sheddingExcludedNamespaces.contains(namespaceName)) { + if (log.isDebugEnabled()) { + log.debug("[{}] Skipping load shedding for namespace {}", + loadSheddingStrategy.getClass().getSimpleName(), namespaceName); + } + return; + } if (!shouldNamespacePoliciesUnload(namespaceName, bundleRange, broker)) { return; } @@ -868,7 +888,7 @@ public Optional selectBrokerForAssignment(final ServiceUnitId serviceUni private void preallocateBundle(String bundle, String broker) { final BundleData data = loadData.getBundleData().computeIfAbsent(bundle, - key -> getBundleDataOrDefault(bundle)); + key -> getBundleDataOrDefaultAsync(bundle).join()); loadData.getBrokerData().get(broker).getPreallocatedBundleData().put(bundle, data); preallocatedBundleToBroker.put(bundle, broker); @@ -882,7 +902,7 @@ Optional selectBroker(final ServiceUnitId serviceUnit) { synchronized (brokerCandidateCache) { final String bundle = serviceUnit.toString(); final BundleData data = loadData.getBundleData().computeIfAbsent(bundle, - key -> getBundleDataOrDefault(bundle)); + key -> getBundleDataOrDefaultAsync(bundle).join()); brokerCandidateCache.clear(); LoadManagerShared.applyNamespacePolicies(serviceUnit, policies, brokerCandidateCache, getAvailableBrokers(), @@ -931,8 +951,22 @@ Optional selectBroker(final ServiceUnitId serviceUnit) { brokerTopicLoadingPredicate); } - // Choose a broker among the potentially smaller filtered list, when possible - Optional broker = placementStrategy.selectBroker(brokerCandidateCache, data, loadData, conf); + Optional broker; + // For shedding excluded namespaces, use RoundRobinBrokerSelector to assign the ownership, + // it can make the assignment more average because these will not automatically rebalance to + // another broker unless manually unloaded it. + Set sheddingExcludedNamespaces = conf.getLoadBalancerSheddingExcludedNamespaces(); + String namespaceNameFromBundleName = LoadManagerShared.getNamespaceNameFromBundleName(bundle); + if (sheddingExcludedNamespaces.contains(namespaceNameFromBundleName)) { + if (log.isDebugEnabled()) { + log.debug("Use round robin broker selector for {}", bundle); + } + broker = sheddingExcludedNamespaceSelectionStrategy + .selectBroker(brokerCandidateCache, data, loadData, conf); + } else { + // Choose a broker among the potentially smaller filtered list, when possible + broker = placementStrategy.selectBroker(brokerCandidateCache, data, loadData, conf); + } if (log.isDebugEnabled()) { log.debug("Selected broker {} from candidate brokers {}", broker, brokerCandidateCache); } @@ -1139,7 +1173,15 @@ public void writeBrokerDataOnZooKeeper(boolean force) { */ private int selectTopKBundle() { bundleArr.clear(); - bundleArr.addAll(loadData.getBundleData().entrySet()); + Set sheddingExcludedNamespaces = conf.getLoadBalancerSheddingExcludedNamespaces(); + for (Map.Entry entry : loadData.getBundleData().entrySet()) { + String bundle = entry.getKey(); + String namespace = NamespaceBundle.getBundleNamespace(bundle); + if (sheddingExcludedNamespaces.contains(namespace)) { + continue; + } + bundleArr.add(entry); + } int maxNumberOfBundlesInBundleLoadReport = pulsar.getConfiguration() .getLoadBalancerMaxNumberOfBundlesInBundleLoadReport(); diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/namespace/NamespaceService.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/namespace/NamespaceService.java index eeabb996c518c..f37f945d42566 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/namespace/NamespaceService.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/namespace/NamespaceService.java @@ -59,7 +59,6 @@ import org.apache.pulsar.broker.PulsarServerException; import org.apache.pulsar.broker.PulsarService; import org.apache.pulsar.broker.ServiceConfiguration; -import org.apache.pulsar.broker.loadbalance.LeaderBroker; import org.apache.pulsar.broker.loadbalance.LeaderElectionService; import org.apache.pulsar.broker.loadbalance.LoadManager; import org.apache.pulsar.broker.loadbalance.ResourceUnit; @@ -456,11 +455,27 @@ public boolean registerNamespace(NamespaceName nsname, boolean ensureOwned) thro } } - private final Map>> + // The two maps keep authoritative and non-authoritative lookups separate. The key contains every + // remaining option that affects the lookup result or its side effects. + private final Map>> findingBundlesAuthoritative = new ConcurrentHashMap<>(); - private final Map>> + private final Map>> findingBundlesNotAuthoritative = new ConcurrentHashMap<>(); + /** + * Key for coalescing lookup requests handled by this lookup path. + * + *

Lookup properties and the requestHttps flag are intentionally excluded because this lookup path + * does not consume them. + */ + private record LookupRequestKey(NamespaceBundle bundle, boolean readOnly, boolean loadTopicsInBundle, + String advertisedListenerName) { + private static LookupRequestKey from(NamespaceBundle bundle, LookupOptions options) { + return new LookupRequestKey(bundle, options.isReadOnly(), options.isLoadTopicsInBundle(), + options.hasAdvertisedListenerName() ? options.getAdvertisedListenerName() : null); + } + } + /** * Main internal method to lookup and setup ownership of service unit to a broker. * @@ -474,18 +489,19 @@ private CompletableFuture> findBrokerServiceUrl( LOG.debug("findBrokerServiceUrl: {} - options: {}", bundle, options); } - Map>> targetMap; + Map>> targetMap; if (options.isAuthoritative()) { targetMap = findingBundlesAuthoritative; } else { targetMap = findingBundlesNotAuthoritative; } + LookupRequestKey lookupRequestKey = LookupRequestKey.from(bundle, options); - return targetMap.computeIfAbsent(bundle, (k) -> { + return targetMap.computeIfAbsent(lookupRequestKey, (k) -> { CompletableFuture> future = new CompletableFuture<>(); // First check if we or someone else already owns the bundle - ownershipCache.getOwnerAsync(bundle).thenAccept(nsData -> { + getOwnershipCache().getOwnerAsync(bundle).thenAccept(nsData -> { if (nsData.isEmpty()) { // No one owns this bundle @@ -529,7 +545,7 @@ private CompletableFuture> findBrokerServiceUrl( }); future.whenComplete((r, t) -> pulsar.getExecutor().execute( - () -> targetMap.remove(bundle) + () -> targetMap.remove(lookupRequestKey, future) )); return future; @@ -571,7 +587,6 @@ public CompletableFuture getHeartbeatOrSLAMonitorBrokerId( private void searchForCandidateBroker(NamespaceBundle bundle, CompletableFuture> lookupFuture, LookupOptions options) { - String candidateBroker; LeaderElectionService les = pulsar.getLeaderElectionService(); if (les == null) { LOG.warn("The leader election has not yet been completed! NamespaceBundle[{}]", bundle); @@ -580,67 +595,98 @@ private void searchForCandidateBroker(NamespaceBundle bundle, return; } - boolean authoritativeRedirect = les.isLeader(); + selectCandidateBroker(bundle, options, les) + .thenAcceptAsync(selection -> { + if (selection.isEmpty()) { + LOG.warn("Load manager didn't return any available broker. " + + "Returning empty result to lookup. NamespaceBundle[{}]", + bundle); + lookupFuture.complete(Optional.empty()); + return; + } + acquireOwnershipOrRedirect(bundle, options, selection.get(), lookupFuture); + }, pulsar.getExecutor()) + .exceptionally(e -> { + LOG.warn("Error when searching for candidate broker to acquire {}: {}", bundle, e.getMessage(), e); + lookupFuture.completeExceptionally(FutureUtil.unwrapCompletionException(e)); + return null; + }); + } - try { - // check if this is Heartbeat or SLAMonitor namespace - candidateBroker = getHeartbeatOrSLAMonitorBrokerId(bundle, cb -> - CompletableFuture.completedFuture(isBrokerActive(cb))) - .get(config.getMetadataStoreOperationTimeoutSeconds(), SECONDS); + /** The broker selected for a bundle assignment, and whether the redirect to it is authoritative. */ + private record CandidateBrokerSelection(String candidateBroker, boolean authoritativeRedirect) { } - if (candidateBroker == null) { - Optional currentLeader = pulsar.getLeaderElectionService().getCurrentLeader(); + private CompletableFuture> selectCandidateBroker( + NamespaceBundle bundle, LookupOptions options, LeaderElectionService les) { + boolean authoritativeRedirect = les.isLeader(); - if (options.isAuthoritative()) { - // leader broker already assigned the current broker as owner - candidateBroker = pulsar.getBrokerId(); - } else { + // check if this is Heartbeat or SLAMonitor namespace + return getHeartbeatOrSLAMonitorBrokerId(bundle, cb -> + CompletableFuture.completedFuture(isBrokerActive(cb))) + .thenComposeAsync(heartbeatOrSlaBroker -> { + if (heartbeatOrSlaBroker != null) { + return completedSelection(heartbeatOrSlaBroker, authoritativeRedirect); + } + if (options.isAuthoritative()) { + // leader broker already assigned the current broker as owner + return completedSelection(pulsar.getBrokerId(), authoritativeRedirect); + } LoadManager loadManager = this.loadManager.get(); - boolean makeLoadManagerDecisionOnThisBroker = !loadManager.isCentralized() || les.isLeader(); - if (!makeLoadManagerDecisionOnThisBroker) { - // If leader is not active, fallback to pick the least loaded from current broker loadmanager + if (!loadManager.isCentralized() || les.isLeader()) { + return selectLeastLoadedBroker(bundle); + } + // The load manager decision belongs to the leader: read the leader + // authoritatively (waits for an in-progress election to settle) instead of + // acting on a possibly-empty snapshot during a leadership handoff. + return les.readCurrentLeader().thenComposeAsync(currentLeader -> { boolean leaderBrokerActive = currentLeader.isPresent() && isBrokerActive(currentLeader.get().getBrokerId()); - if (!leaderBrokerActive) { - makeLoadManagerDecisionOnThisBroker = true; - if (currentLeader.isEmpty()) { - LOG.warn( - "The information about the current leader broker wasn't available. " - + "Handling load manager decisions in a decentralized way. " - + "NamespaceBundle[{}]", - bundle); - } else { - LOG.warn( - "The current leader broker {} isn't active. " - + "Handling load manager decisions in a decentralized way. " - + "NamespaceBundle[{}]", - currentLeader.get(), bundle); - } + if (leaderBrokerActive) { + // forward to leader broker to make assignment + return completedSelection(currentLeader.get().getBrokerId(), authoritativeRedirect); } - } - if (makeLoadManagerDecisionOnThisBroker) { - Optional availableBroker = getLeastLoadedFromLoadManager(bundle); - if (availableBroker.isEmpty()) { - LOG.warn("Load manager didn't return any available broker. " - + "Returning empty result to lookup. NamespaceBundle[{}]", + // If leader is not active, fallback to pick the least loaded from current broker loadmanager + if (currentLeader.isEmpty()) { + LOG.warn( + "The information about the current leader broker wasn't available. " + + "Handling load manager decisions in a decentralized way. " + + "NamespaceBundle[{}]", bundle); - lookupFuture.complete(Optional.empty()); - return; + } else { + LOG.warn( + "The current leader broker {} isn't active. " + + "Handling load manager decisions in a decentralized way. " + + "NamespaceBundle[{}]", + currentLeader.get(), bundle); } - candidateBroker = availableBroker.get(); - authoritativeRedirect = true; - } else { - // forward to leader broker to make assignment - candidateBroker = currentLeader.get().getBrokerId(); - } - } - } + return selectLeastLoadedBroker(bundle); + }, pulsar.getExecutor()); + }, pulsar.getExecutor()); + } + + private static CompletableFuture> completedSelection( + String candidateBroker, boolean authoritativeRedirect) { + return CompletableFuture.completedFuture( + Optional.of(new CandidateBrokerSelection(candidateBroker, authoritativeRedirect))); + } + + // The decentralized decision is authoritative: this broker picked the owner itself. + private CompletableFuture> selectLeastLoadedBroker(NamespaceBundle bundle) { + Optional availableBroker; + try { + availableBroker = getLeastLoadedFromLoadManager(bundle); } catch (Exception e) { - LOG.warn("Error when searching for candidate broker to acquire {}: {}", bundle, e.getMessage(), e); - lookupFuture.completeExceptionally(e); - return; + return CompletableFuture.failedFuture(e); } + return CompletableFuture.completedFuture( + availableBroker.map(broker -> new CandidateBrokerSelection(broker, true))); + } + private void acquireOwnershipOrRedirect(NamespaceBundle bundle, LookupOptions options, + CandidateBrokerSelection selection, + CompletableFuture> lookupFuture) { + final String candidateBroker = selection.candidateBroker(); + final boolean authoritativeRedirect = selection.authoritativeRedirect(); try { Objects.requireNonNull(candidateBroker); diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/AbstractBaseDispatcher.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/AbstractBaseDispatcher.java index 1a1ab2ddca8c8..f95f2a11e05e5 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/AbstractBaseDispatcher.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/AbstractBaseDispatcher.java @@ -496,7 +496,8 @@ protected void checkAndApplyReachedEndOfTopicOrTopicMigration(List con public static void checkAndApplyReachedEndOfTopicOrTopicMigration(PersistentTopic topic, List consumers) { if (topic.isMigrated()) { - consumers.forEach(c -> c.topicMigrated(topic.getMigratedClusterUrl())); + topic.getMigratedClusterUrlAsync() + .thenAccept(clusterUrl -> consumers.forEach(c -> c.topicMigrated(clusterUrl))); } else { consumers.forEach(Consumer::reachedEndOfTopic); } diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/AbstractReplicator.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/AbstractReplicator.java index e86c84feb2da3..9c7dc090243d2 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/AbstractReplicator.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/AbstractReplicator.java @@ -85,6 +85,11 @@ public abstract class AbstractReplicator implements Replicator { private static final AtomicReferenceFieldUpdater ATTRIBUTES_UPDATER = AtomicReferenceFieldUpdater.newUpdater(AbstractReplicator.class, Attributes.class, "attributes"); + protected volatile long latestPublishTime = System.currentTimeMillis(); + + // The estimated time when the producer connection is successful, "0" means it will be connected immediately. + protected volatile long estimatedTimeStampProducerConnected = 0; + public enum State { /** * This enum has two mean meanings: @@ -166,7 +171,7 @@ protected CompletableFuture prepareCreateProducer() { return CompletableFuture.completedFuture(null); } - public void startProducer() { + protected void startProducer() { // Guarantee only one task call "producerBuilder.createAsync()". Pair setStartingRes = compareSetAndGetState(State.Disconnected, State.Starting); if (!setStartingRes.getLeft()) { @@ -200,12 +205,14 @@ public void startProducer() { builderImpl.getConf().setNonPartitionedTopicExpected(true); builderImpl.getConf().setReplProducer(true); return producerBuilder.createAsync().thenAccept(producer -> { + estimatedTimeStampProducerConnected = 0; setProducerAndTriggerReadEntries(producer); }); }).exceptionally(ex -> { Pair setDisconnectedRes = compareSetAndGetState(State.Starting, State.Disconnected); if (setDisconnectedRes.getLeft()) { long waitTimeMs = backOff.next(); + estimatedTimeStampProducerConnected = System.currentTimeMillis() + waitTimeMs; log.warn("[{}] Failed to create remote producer ({}), retrying in {} s", replicatorId, ex.getMessage(), waitTimeMs / 1000.0); // BackOff before retrying @@ -308,8 +315,7 @@ protected CompletableFuture isLocalTopicActive() { /** * This method only be used by {@link PersistentTopic#checkGC} now. */ - @Override - public CompletableFuture disconnect() { + protected CompletableFuture disconnect() { long backlog = getNumberOfEntriesInBacklog(); if (backlog > 0) { CompletableFuture disconnectFuture = new CompletableFuture<>(); @@ -387,8 +393,6 @@ protected CompletableFuture closeProducerAsync(boolean closeTheStartingPro Pair setDisconnectedRes = compareSetAndGetState(State.Disconnecting, State.Disconnected); if (setDisconnectedRes.getLeft()) { this.producer = null; - // deactivate further read - disableReplicatorRead(); return; } if (setDisconnectedRes.getRight() == State.Terminating diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/AbstractTopic.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/AbstractTopic.java index 648d97992aacc..80991077cbbc1 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/AbstractTopic.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/AbstractTopic.java @@ -43,11 +43,13 @@ import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicIntegerFieldUpdater; import java.util.concurrent.atomic.AtomicLongFieldUpdater; import java.util.concurrent.atomic.LongAdder; import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.Function; import java.util.function.ToLongFunction; import lombok.Getter; import lombok.Setter; @@ -58,6 +60,7 @@ import org.apache.commons.lang3.tuple.Pair; import org.apache.pulsar.broker.PulsarService; import org.apache.pulsar.broker.ServiceConfiguration; +import org.apache.pulsar.broker.loadbalance.extensions.ExtensibleLoadManagerImpl; import org.apache.pulsar.broker.resourcegroup.ResourceGroup; import org.apache.pulsar.broker.resourcegroup.ResourceGroupDispatchLimiter; import org.apache.pulsar.broker.resourcegroup.ResourceGroupPublishLimiter; @@ -68,6 +71,7 @@ import org.apache.pulsar.broker.service.BrokerServiceException.TopicMigratedException; import org.apache.pulsar.broker.service.BrokerServiceException.TopicTerminatedException; import org.apache.pulsar.broker.service.persistent.DispatchRateLimiter; +import org.apache.pulsar.broker.service.persistent.PersistentReplicator; import org.apache.pulsar.broker.service.plugin.EntryFilter; import org.apache.pulsar.broker.service.schema.SchemaRegistryService; import org.apache.pulsar.broker.service.schema.exceptions.IncompatibleSchemaException; @@ -117,6 +121,10 @@ public abstract class AbstractTopic implements Topic, TopicPolicyListener { protected final BrokerService brokerService; + // Wraps this topic as a TopicPolicyListener so topic-policy updates received while the initial policy is still + // loading are buffered and applied in order once initTopicPolicy() completes initialization. + protected final TopicPolicyListenerWrapper topicPolicyListener = new TopicPolicyListenerWrapper(this); + // Prefix for replication cursors protected final String replicatorPrefix; @@ -181,7 +189,17 @@ public abstract class AbstractTopic implements Topic, TopicPolicyListener { AtomicLongFieldUpdater.newUpdater(AbstractTopic.class, "usageCount"); private volatile long usageCount = 0; - private Map subscriptionPolicies = Collections.emptyMap(); + // Effective per-subscription policies, merged from the local and global topic policies with local precedence. + // Unlike the PolicyHierarchyValue-backed fields, subscriptionPolicies is a plain map. It is kept as the merge of + // the two scopes below (rather than assigned directly) because the local-before-global initialization order + // (see TopicPolicyListenerWrapper) would otherwise let the global map -- empty by default in TopicPolicies -- + // overwrite and clear the local per-subscription policies that were applied just before it. + // subscriptionPolicies is volatile because it is read on dispatch threads (getSubscriptionDispatchRate) while it + // is updated on the policy-update thread. localSubscriptionPolicies/globalSubscriptionPolicies are only ever read + // and written on the (single) policy-update thread, so they don't need to be volatile. + private volatile Map subscriptionPolicies = Collections.emptyMap(); + private Map localSubscriptionPolicies = Collections.emptyMap(); + private Map globalSubscriptionPolicies = Collections.emptyMap(); protected final LongAdder msgOutFromRemovedSubscriptions = new LongAdder(); protected final LongAdder bytesOutFromRemovedSubscriptions = new LongAdder(); @@ -192,10 +210,16 @@ public abstract class AbstractTopic implements Topic, TopicPolicyListener { protected final Clock clock; protected Set additionalSystemCursorNames = new TreeSet<>(); + private final ExecutorService topicPoliciesNotifyThread; public AbstractTopic(String topic, BrokerService brokerService) { this.topic = topic; this.namespace = TopicName.get(topic).getNamespaceObject(); + // Pin the per-topic policies-notify thread once. BrokerService#getTopicPoliciesNotifyThread centralizes + // the topic-to-thread mapping so it stays consistent with SystemTopicBasedTopicPoliciesService. In unit + // tests that construct topics with a mock BrokerService this returns null (the thread is unused there). + this.topicPoliciesNotifyThread = + brokerService.getTopicPoliciesNotifyThread(TopicName.getPartitionedTopicName(topic)); this.clock = brokerService.getClock(); this.brokerService = brokerService; this.producers = new ConcurrentHashMap<>(); @@ -342,12 +366,37 @@ protected void updateTopicPolicy(TopicPolicies data) { topicPolicies.getEntryFilters().updateTopicValue(data.getEntryFilters(), isGlobalPolicies); topicPolicies.getDispatcherPauseOnAckStatePersistentEnabled() .updateTopicValue(data.getDispatcherPauseOnAckStatePersistentEnabled(), isGlobalPolicies); - this.subscriptionPolicies = data.getSubscriptionPolicies(); topicPolicies.getResourceGroupName().updateTopicValue(data.getResourceGroupName()); + // Merge instead of assigning directly: keep the local and global per-subscription policies separately and + // recompute the effective map with local precedence, so applying the (default-empty) global map does not + // clear the local per-subscription policies during the local-before-global initialization. + if (isGlobalPolicies) { + globalSubscriptionPolicies = data.getSubscriptionPolicies(); + } else { + localSubscriptionPolicies = data.getSubscriptionPolicies(); + } + subscriptionPolicies = mergeSubscriptionPolicies(globalSubscriptionPolicies, localSubscriptionPolicies); + updateEntryFilters(); } + // Merges the global and local per-subscription policies with local precedence: a subscription present in the + // local policies keeps its local value; otherwise the global value (if any) is used. + private static Map mergeSubscriptionPolicies( + Map globalSubscriptionPolicies, + Map localSubscriptionPolicies) { + if (globalSubscriptionPolicies.isEmpty()) { + return localSubscriptionPolicies; + } + if (localSubscriptionPolicies.isEmpty()) { + return globalSubscriptionPolicies; + } + Map merged = new HashMap<>(globalSubscriptionPolicies); + merged.putAll(localSubscriptionPolicies); + return merged; + } + private void updateTopicLevelReplicatorDispatchRate(Map policy, DispatchRateImpl defaultDispatchRate) { Map dispatchRateMap = new HashMap<>(); @@ -629,14 +678,67 @@ protected boolean isProducersExceeded(boolean isRemote) { && maxProducers <= USER_CREATED_PRODUCER_COUNTER_UPDATER.get(this); } - protected void registerTopicPolicyListener() { - brokerService.getPulsar().getTopicPoliciesService() - .registerListener(TopicName.getPartitionedTopicName(topic), this); + protected TopicPolicyListener getTopicPolicyListener() { + return topicPolicyListener; } protected void unregisterTopicPolicyListener() { brokerService.getPulsar().getTopicPoliciesService() - .unregisterListener(TopicName.getPartitionedTopicName(topic), this); + .unregisterListener(TopicName.getPartitionedTopicName(topic), getTopicPolicyListener()); + } + + /** + * Registers the topic-policy listener and applies the topic's initial policies (global and local) to this topic. + * Shared by {@link org.apache.pulsar.broker.service.persistent.PersistentTopic} and + * {@link org.apache.pulsar.broker.service.nonpersistent.NonPersistentTopic} so both load their own policies on + * topic load, which removes the need to broadcast every topic's policy when a namespace's policy cache finishes + * loading (see {@code topicPolicyListenerReplayEnabled}). + * + *

Each call re-initializes the listener wrapper and, whatever the outcome, always completes its initialization + * afterwards, so the wrapper never stays in the buffering phase (dropping updates) even if policy loading fails. + * This makes the method safe to run again (e.g. a future retry); runs are expected to be serialized. + */ + protected CompletableFuture initTopicPolicy() { + final var topicPoliciesService = brokerService.getPulsar().getTopicPoliciesService(); + final var partitionedTopicName = TopicName.getPartitionedTopicName(topic); + + // Begin a fresh initialization phase: updates are buffered until initialization completes below. This resets + // any previous phase so the method can be run again. + topicPolicyListener.startInitialization(); + CompletableFuture initTopicPolicyFuture = + topicPoliciesService.registerListenerAsync(partitionedTopicName, topicPolicyListener) + .thenCompose(registered -> { + if (!registered) { + return CompletableFuture.completedFuture(null); + } + if (ExtensibleLoadManagerImpl.isInternalTopic(topic)) { + // Internal topics don't load topic-level policies + return CompletableFuture.completedFuture(null); + } + // future for fetching global topic policies + CompletableFuture> globalPoliciesFuture = + topicPoliciesService.getTopicPoliciesAsync(partitionedTopicName, + TopicPoliciesService.GetType.GLOBAL_ONLY); + // future for fetching local topic policies + CompletableFuture> localPoliciesFuture = + topicPoliciesService.getTopicPoliciesAsync(partitionedTopicName, + TopicPoliciesService.GetType.LOCAL_ONLY); + return globalPoliciesFuture.thenCombine(localPoliciesFuture, (global, local) -> { + // finally update the topic policies with the latest value or loaded value + return CompletableFuture.runAsync(() -> + topicPolicyListener.completeInitialization(global.orElse(null), + local.orElse(null)), + getPoliciesNotifyThread()); + }).thenCompose(Function.identity()); + }); + // Whatever the outcome -- success, failure, or the listener not being registered -- make sure the wrapper + // leaves the initialization (buffering) phase, so it forwards any buffered value plus all future live updates + // instead of dropping them. This is a no-op when the loaded policies were already applied above. Return the + // whenComplete stage (not initTopicPolicyFuture) so the returned future completes only after this has run, and + // whenComplete's pass-through semantics carry the original success or failure to the caller's initialize(). + return initTopicPolicyFuture.whenCompleteAsync((v, ex) -> { + topicPolicyListener.completeInitializationUnlessAlreadyCompleted(); + }, getPoliciesNotifyThread()); } protected boolean isSameAddressProducersExceeded(Producer producer) { @@ -729,6 +831,19 @@ protected Consumer getActiveConsumer(Subscription subscription) { return null; } + protected boolean hasProducersActive() { + return !producers.isEmpty(); + } + + protected boolean hasActiveReplicators() { + for (Replicator replicator : getReplicators().values()) { + if (replicator.isConnected()) { + return true; + } + } + return false; + } + protected boolean hasLocalProducers() { if (producers.isEmpty()) { return false; @@ -741,6 +856,19 @@ protected boolean hasLocalProducers() { return false; } + public void disconnectReplicatorsIfNoTrafficAndBacklog() { + for (Replicator replicator : getReplicators().values()) { + if (replicator instanceof PersistentReplicator persistentReplicator) { + persistentReplicator.disconnectIfNoTrafficAndBacklog(); + } + } + for (Replicator replicator : getShadowReplicators().values()) { + if (replicator instanceof PersistentReplicator persistentReplicator) { + persistentReplicator.disconnectIfNoTrafficAndBacklog(); + } + } + } + @Override public String toString() { return MoreObjects.toStringHelper(this).add("topic", topic).toString(); @@ -1444,8 +1572,8 @@ public void updateBrokerSubscribeRate() { subscribeRateInBroker(brokerService.pulsar().getConfiguration())); } - public Optional getMigratedClusterUrl() { - return getMigratedClusterUrl(brokerService.getPulsar(), topic); + public CompletableFuture> getMigratedClusterUrlAsync() { + return getMigratedClusterUrlAsync(brokerService.getPulsar(), topic); } public static CompletableFuture isClusterMigrationEnabled(PulsarService pulsar, @@ -1485,16 +1613,6 @@ private static CompletableFuture isNamespaceMigrationEnabledAsync(Pulsa .thenApply(policies -> policies.isPresent() && policies.get().migrated); } - public static Optional getMigratedClusterUrl(PulsarService pulsar, String topic) { - try { - return getMigratedClusterUrlAsync(pulsar, topic) - .get(pulsar.getPulsarResources().getClusterResources().getOperationTimeoutSec(), TimeUnit.SECONDS); - } catch (Exception e) { - log.warn("[{}] Failed to get migration cluster URL", topic, e); - } - return Optional.empty(); - } - public boolean isSystemCursor(String sub) { return COMPACTION_SUBSCRIPTION.equals(sub) || (additionalSystemCursorNames != null && additionalSystemCursorNames.contains(sub)); @@ -1506,4 +1624,8 @@ public static String getReplicatorDispatchRateKey(String localCluster, String re } return localCluster; } + + protected ExecutorService getPoliciesNotifyThread() { + return topicPoliciesNotifyThread; + } } diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/BrokerService.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/BrokerService.java index 947902644c35f..d355cb50e0a88 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/BrokerService.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/BrokerService.java @@ -26,6 +26,7 @@ import static org.apache.pulsar.client.util.RetryMessageUtil.DLQ_GROUP_TOPIC_SUFFIX; import static org.apache.pulsar.client.util.RetryMessageUtil.RETRY_GROUP_TOPIC_SUFFIX; import static org.apache.pulsar.common.naming.SystemTopicNames.isTransactionInternalName; +import static org.apache.pulsar.common.util.Runnables.catchingAndLoggingThrowables; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.Queues; import com.google.common.util.concurrent.RateLimiter; @@ -67,6 +68,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.Semaphore; @@ -231,6 +233,11 @@ public class BrokerService implements Closeable { private final Map>> topics = new ConcurrentHashMap<>(); + // A topic can reach the cache-cleanup path through both its close callback and bundle cleanup. + // Keep only one cleanup active for a topic future so re-entrant unload listeners cannot duplicate side effects. + private final Set>> topicCacheRemovalsInProgress = + ConcurrentHashMap.newKeySet(); + private final Map replicationClients = new ConcurrentHashMap<>(); private final Map clusterAdmins = new ConcurrentHashMap<>(); @@ -257,6 +264,7 @@ public class BrokerService implements Closeable { @Getter private final SingleThreadNonConcurrentFixedRateScheduler backlogQuotaChecker; + private final SingleThreadNonConcurrentFixedRateScheduler subscriptionBacklogAgeChecker; protected final AtomicReference lookupRequestSemaphore; @Getter @@ -388,6 +396,8 @@ public BrokerService(PulsarService pulsar, EventLoopGroup eventLoopGroup) throws new SingleThreadNonConcurrentFixedRateScheduler("pulsar-consumed-ledgers-monitor"); this.backlogQuotaManager = new BacklogQuotaManager(pulsar); this.backlogQuotaChecker = new SingleThreadNonConcurrentFixedRateScheduler("pulsar-backlog-quota-checker"); + this.subscriptionBacklogAgeChecker = + new SingleThreadNonConcurrentFixedRateScheduler("pulsar-subscription-backlog-age-checker"); this.authenticationService = new AuthenticationService(pulsar.getConfiguration(), pulsar.getOpenTelemetry().getOpenTelemetry()); this.topicFactory = createPersistentTopicFactory(); @@ -650,6 +660,7 @@ public void start() throws Exception { this.startCompactionMonitor(); this.startConsumedLedgersMonitor(); this.startBacklogQuotaChecker(); + this.startSubscriptionBacklogAgeChecker(); this.updateBrokerPublisherThrottlingMaxRate(); this.updateBrokerDispatchThrottlingMaxRate(); this.startCheckReplicationPolicies(); @@ -713,6 +724,10 @@ protected void startInactivityMonitor() { int interval = pulsar().getConfiguration().getBrokerDeleteInactiveTopicsFrequencySeconds(); inactivityMonitor.scheduleAtFixedRateNonConcurrently(() -> checkGC(), interval, interval, TimeUnit.SECONDS); + if (pulsar().getConfig().getBrokerReplicationInactiveThresholdSeconds() > 0) { + inactivityMonitor.scheduleAtFixedRateNonConcurrently(() -> checkInactiveReplication(), interval, + interval, TimeUnit.SECONDS); + } } // Deduplication info checker @@ -782,6 +797,19 @@ protected void startBacklogQuotaChecker() { } + protected void startSubscriptionBacklogAgeChecker() { + if (pulsar().getConfiguration().isExposeSubscriptionBacklogAgeInPrometheus()) { + final int interval = pulsar().getConfiguration().getBacklogQuotaCheckIntervalInSeconds(); + log.info("Scheduling a thread to refresh subscription backlog age in background after [{}] seconds", + interval); + subscriptionBacklogAgeChecker.scheduleAtFixedRateNonConcurrently( + catchingAndLoggingThrowables(() -> refreshSubscriptionBacklogAge().join()), interval, interval, + TimeUnit.SECONDS); + } else { + log.info("Subscription backlog age monitoring is disabled"); + } + } + public void close() throws IOException { try { closeAsync().get(); @@ -929,6 +957,7 @@ public CompletableFuture closeAsync() { compactionMonitor, consumedLedgersMonitor, backlogQuotaChecker, + subscriptionBacklogAgeChecker, topicOrderedExecutor, deduplicationSnapshotMonitor) .handle()); @@ -1211,32 +1240,35 @@ public CompletableFuture> getTopic(TopicLoadingContext context) return FutureUtil.failedFuture(new NotAllowedException( "Broker is unable to load persistent topic")); } + + final var timeoutSeconds = pulsar.getConfiguration().getTopicLoadTimeoutSeconds(); if (context.getTopicFuture() == null) { final CompletableFuture> topicFuture = FutureUtil.createFutureWithTimeout( - Duration.ofSeconds(pulsar.getConfiguration().getTopicLoadTimeoutSeconds()), executor(), + Duration.ofSeconds(timeoutSeconds), executor(), () -> FAILED_TO_LOAD_TOPIC_TIMEOUT_EXCEPTION); context.setTopicFuture(topicFuture); } var topicFuture = context.getTopicFuture(); topicFuture.exceptionally(t -> { - final var now = System.nanoTime(); + final var latency = context.traceAndGetLatency("fail").description(); if (FutureUtil.unwrapCompletionException(t) instanceof TimeoutException) { - log.warn("Failed to load {} after {} ms", topicName, context.latencyMs(now)); + log.warn("Failed to load {} within {} s (latency: {})", topicName, timeoutSeconds, latency); } else { - log.warn("Failed to load {} after {} ms", topicName, context.latencyString(now), t); + log.warn("Failed to load {} (latency: {})", topicName, latency, t); } pulsarStats.recordTopicLoadFailed(); return Optional.empty(); }); - checkNonPartitionedTopicExists(topicName).thenAccept(exists -> { + context.trace("topic exists", checkNonPartitionedTopicExists(topicName)).thenAccept(exists -> { if (!exists && !createIfMissing) { topicFuture.complete(Optional.empty()); return; } // The topic level policies are not needed now, but the meaning of calling // "getTopicPoliciesBypassSystemTopic" will wait for system topic policies initialization. - getTopicPoliciesBypassSystemTopic(topicName, TopicPoliciesService.GetType.LOCAL_ONLY) - .thenRun(() -> { + final var systemTopicLoadFuture = context.trace("system topic", + getTopicPoliciesBypassSystemTopic(topicName, TopicPoliciesService.GetType.LOCAL_ONLY)); + systemTopicLoadFuture.thenRun(() -> { final var inserted = new MutableBoolean(false); final var cachedFuture = topics.computeIfAbsent(topicName.toString(), ___ -> { inserted.setTrue(); @@ -1257,7 +1289,7 @@ public CompletableFuture> getTopic(TopicLoadingContext context) // checked if the `topics` cache includes this topic before, so the latency is not the // actual loading latency that should not be recorded in metrics. log.info("[{}] Finished loading from other concurrent loading task (latency: {})", - topicName, context.latencyString(System.nanoTime())); + topicName, context.getLatency().description()); cachedFuture.whenComplete((optTopic, e) -> { if (e == null) { topicFuture.complete(optTopic); @@ -1267,15 +1299,14 @@ public CompletableFuture> getTopic(TopicLoadingContext context) }); } }).exceptionally(e -> { - pulsar.getExecutor().execute(() -> topics.remove(topicName.toString(), topicFuture)); - final Throwable rc = FutureUtil.unwrapCompletionException(e); - final String errorInfo = String.format("Topic creation encountered an exception by initialize" - + " topic policies service. topic_name=%s error_message=%s", topicName, - rc.getMessage()); - log.error(errorInfo, rc); - topicFuture.completeExceptionally(rc); + log.warn("[{}] Topic creation encountered an exception" + + " by initialize topic policies service", topicName); + failTopicFuture(topicName.toString(), topicFuture, e); return null; }); + }).exceptionally(e -> { + failTopicFuture(topicName.toString(), topicFuture, e); + return null; }); return topicFuture; } else { @@ -1321,6 +1352,14 @@ public CompletableFuture> getTopic(TopicLoadingContext context) } } + private void failTopicFuture(String topic, CompletableFuture> topicFuture, Throwable throwable) { + pulsar.getExecutor().execute(() -> topics.remove(topic, topicFuture)); + final Throwable rc = FutureUtil.unwrapCompletionException(throwable); + // It will trigger the logging for exception and traced latencies in topicFuture's exceptionally callback, so + // we don't need to add an extra log before it. + topicFuture.completeExceptionally(rc); + } + private CompletableFuture> getTopicPoliciesBypassSystemTopic(@NonNull TopicName topicName, TopicPoliciesService.GetType type) { if (ExtensibleLoadManagerImpl.isInternalTopic(topicName.toString())) { @@ -1797,7 +1836,8 @@ public PulsarAdmin getClusterPulsarAdmin(String cluster, Optional c protected CompletableFuture> loadOrCreatePersistentTopic(TopicLoadingContext context) { final var topic = context.getTopicName().toString(); final var topicFuture = context.getTopicFuture(); - checkTopicNsOwnership(topic) + final var ownedFuture = checkTopicNsOwnership(topic); + context.trace("ownership", ownedFuture) .thenRun(() -> { final Semaphore topicLoadSemaphore = topicLoadRequestSemaphore.get(); @@ -1809,8 +1849,7 @@ protected CompletableFuture> loadOrCreatePersistentTopic(TopicLo // do not recreate topic if topic is already migrated and deleted by broker // so, avoid creating a new topic if migration is already started if (ex != null && (ex.getCause() instanceof TopicMigratedException)) { - pulsar.getExecutor().execute(() -> topics.remove(topic, topicFuture)); - topicFuture.completeExceptionally(ex.getCause()); + failTopicFuture(topic, topicFuture, ex); return null; } createPendingLoadTopic(); @@ -1823,8 +1862,7 @@ protected CompletableFuture> loadOrCreatePersistentTopic(TopicLo } } }).exceptionally(ex -> { - pulsar.getExecutor().execute(() -> topics.remove(topic, topicFuture)); - topicFuture.completeExceptionally(ex.getCause()); + failTopicFuture(topic, topicFuture, ex); return null; }); @@ -1864,7 +1902,7 @@ private void checkOwnershipAndCreatePersistentTopic(TopicLoadingContext context) TopicName topicName = context.getTopicName(); final var topic = topicName.toString(); final var topicFuture = context.getTopicFuture(); - checkTopicNsOwnership(topic).thenRun(() -> { + context.trace("2nd ownership", checkTopicNsOwnership(topic)).thenRun(() -> { CompletableFuture> propertiesFuture; if (context.getProperties() == null) { //Read properties from storage when loading topic. @@ -1872,19 +1910,16 @@ private void checkOwnershipAndCreatePersistentTopic(TopicLoadingContext context) } else { propertiesFuture = CompletableFuture.completedFuture(context.getProperties()); } - propertiesFuture.thenAccept(finalProperties -> { + context.trace("properties", propertiesFuture).thenAccept(finalProperties -> { context.setProperties(finalProperties); //TODO add topicName in properties? createPersistentTopic0(context); }).exceptionally(throwable -> { - log.warn("[{}] Read topic property failed", topic, throwable); - pulsar.getExecutor().execute(() -> topics.remove(topic, topicFuture)); - topicFuture.completeExceptionally(throwable); + failTopicFuture(topic, topicFuture, throwable); return null; }); }).exceptionally(e -> { - pulsar.getExecutor().execute(() -> topics.remove(topic, topicFuture)); - topicFuture.completeExceptionally(e.getCause()); + failTopicFuture(topic, topicFuture, e); return null; }); } @@ -1897,10 +1932,8 @@ public void createPersistentTopic0(TopicLoadingContext context) { final var createIfMissing = context.isCreateIfMissing(); if (isTransactionInternalName(topicName)) { - String msg = String.format("Can not create transaction system topic %s", topic); - log.warn(msg); - pulsar.getExecutor().execute(() -> topics.remove(topic, topicFuture)); - topicFuture.completeExceptionally(new NotAllowedException(msg)); + failTopicFuture(topic, topicFuture, new NotAllowedException("Can not create transaction system topic " + + topic)); return; } @@ -1917,6 +1950,7 @@ public void createPersistentTopic0(TopicLoadingContext context) { n.recycle(); return found; }), (managedLedgerConfig, exists) -> { + context.trace("ml-config"); if (isBrokerEntryMetadataEnabled() || isBrokerPayloadProcessorEnabled()) { // init managedLedger interceptor Set interceptors = new HashSet<>(); @@ -1962,25 +1996,24 @@ public void createPersistentTopic0(TopicLoadingContext context) { @Override public void openLedgerComplete(ManagedLedger ledger, Object ctx) { try { + context.trace("open-ml"); PersistentTopic persistentTopic = isSystemTopic(topic) ? new SystemTopic(topic, ledger, BrokerService.this) : newTopic(topic, ledger, BrokerService.this, PersistentTopic.class); persistentTopic.setCreateFuture(topicFuture); - persistentTopic - .initialize() - .thenCompose(__ -> persistentTopic.preCreateSubscriptionForCompactionIfNeeded()) - .thenCompose(__ -> persistentTopic.checkReplication()) - .thenCompose(v -> { - // Also check dedup status - return persistentTopic.checkDeduplicationStatus(); - }) + context.trace("init", persistentTopic.initialize()) + .thenCompose(__ -> context.trace("pre-create compacted sub", + persistentTopic.preCreateSubscriptionForCompactionIfNeeded())) + .thenCompose(__ -> context.trace("replication", + persistentTopic.initializeCheckReplication())) + .thenCompose(v -> context.trace("deduplication", + persistentTopic.checkDeduplicationStatus())) .thenRun(() -> { - long nowInNanos = System.nanoTime(); - long topicLoadLatencyMs = context.latencyMs(nowInNanos); + final var latency = context.traceAndGetLatency("done"); log.info("Created topic {} - dedup is {} (latency: {})", topic, persistentTopic.isDeduplicationEnabled() ? "enabled" : "disabled", - context.latencyString(nowInNanos)); - pulsarStats.recordTopicLoadTimeValue(topic, topicLoadLatencyMs); + latency.description()); + pulsarStats.recordTopicLoadTimeValue(topic, latency.elapsedInMillis()); if (createEventBuild != null) { createEventBuild .stage(EventStage.SUCCESS) @@ -2038,9 +2071,7 @@ public void openLedgerComplete(ManagedLedger ledger, Object ctx) { .error(e) .dispatch(); } - log.warn("Failed to create topic {}: {}", topic, e.getMessage()); - pulsar.getExecutor().execute(() -> topics.remove(topic, topicFuture)); - topicFuture.completeExceptionally(e); + failTopicFuture(topic, topicFuture, e); } } @@ -2051,9 +2082,7 @@ public void openLedgerFailed(ManagedLedgerException exception, Object ctx) { pulsar.getExecutor().execute(() -> topics.remove(topic, topicFuture)); topicFuture.complete(Optional.empty()); } else { - log.warn("Failed to create topic {}", topic, exception); - pulsar.getExecutor().execute(() -> topics.remove(topic, topicFuture)); - topicFuture.completeExceptionally(new PersistenceException(exception)); + failTopicFuture(topic, topicFuture, new PersistenceException(exception)); } } }, () -> isTopicNsOwnedByBrokerAsync(topicName), null); @@ -2061,12 +2090,9 @@ public void openLedgerFailed(ManagedLedgerException exception, Object ctx) { }).exceptionally((exception) -> { boolean migrationFailure = exception.getCause() instanceof TopicMigratedException; String msg = migrationFailure ? "Topic is already migrated" : - "Failed to get topic configuration:"; - log.warn("[{}] {} {}", topic, msg, exception.getMessage(), exception); - // remove topic from topics-map in different thread to avoid possible deadlock if - // createPersistentTopic-thread only tries to handle this future-result - pulsar.getExecutor().execute(() -> topics.remove(topic, topicFuture)); - topicFuture.completeExceptionally(exception); + "Failed to get topic configuration"; + log.warn("[{}] {}", topic, msg); + failTopicFuture(topic, topicFuture, exception); return null; }); } @@ -2208,6 +2234,8 @@ public CompletableFuture getManagedLedgerConfig(@NonNull To .setPersistIndividualAckAsLongArray(serviceConfig.isManagedLedgerPersistIndividualAckAsLongArray()); managedLedgerConfig.setPersistentUnackedRangesWithMultipleEntriesEnabled( serviceConfig.isPersistentUnackedRangesWithMultipleEntriesEnabled()); + managedLedgerConfig.setPersistentUnackedRangesWithPerLedgerEntryEnabled( + serviceConfig.isPersistentUnackedRangesWithPerLedgerEntryEnabled()); managedLedgerConfig.setMaxUnackedRangesToPersistInMetadataStore( serviceConfig.getManagedLedgerMaxUnackedRangesToPersistInMetadataStore()); managedLedgerConfig.setMaxEntriesPerLedger(serviceConfig.getManagedLedgerMaxEntriesPerLedger()); @@ -2226,8 +2254,6 @@ public CompletableFuture getManagedLedgerConfig(@NonNull To managedLedgerConfig .setAddEntryTimeoutSeconds(serviceConfig.getManagedLedgerAddEntryTimeoutSeconds()); managedLedgerConfig.setMetadataEnsembleSize(serviceConfig.getManagedLedgerDefaultEnsembleSize()); - managedLedgerConfig.setUnackedRangesOpenCacheSetEnabled( - serviceConfig.isManagedLedgerUnackedRangesOpenCacheSetEnabled()); managedLedgerConfig.setMetadataWriteQuorumSize(serviceConfig.getManagedLedgerDefaultWriteQuorum()); managedLedgerConfig.setMetadataAckQuorumSize(serviceConfig.getManagedLedgerDefaultAckQuorum()); managedLedgerConfig @@ -2398,6 +2424,14 @@ public void checkGC() { forEachTopic(Topic::checkGC); } + public void checkInactiveReplication() { + forEachTopic(topic -> { + if (topic instanceof AbstractTopic abstractTopic) { + abstractTopic.disconnectReplicatorsIfNoTrafficAndBacklog(); + } + }); + } + public void checkClusterMigration() { forEachTopic(Topic::checkClusterMigration); } @@ -2490,6 +2524,19 @@ public void monitorBacklogQuota() { }); } + public CompletableFuture refreshSubscriptionBacklogAge() { + if (!pulsar.getConfiguration().isExposeSubscriptionBacklogAgeInPrometheus()) { + return CompletableFuture.completedFuture(null); + } + + List> futures = new ArrayList<>(); + forEachPersistentTopic(topic -> futures.add(topic.updateSubscriptionOldPositionInfo())); + return FutureUtil.waitForAll(futures).exceptionally(throwable -> { + log.warn("Error when refreshSubscriptionBacklogAge()", throwable); + return null; + }); + } + public CompletableFuture isTopicNsOwnedByBrokerAsync(TopicName topicName) { return pulsar.getNamespaceService().isServiceUnitOwnedAsync(topicName) .handle((hasOwnership, t) -> { @@ -2609,13 +2656,19 @@ private CompletableFuture unloadServiceUnit(NamespaceBundle serviceUnit } public void cleanUnloadedTopicFromCache(NamespaceBundle serviceUnit) { - for (String topic : topics.keySet()) { + topics.forEach((topic, topicFuture) -> { TopicName topicName = TopicName.get(topic); - if (serviceUnit.includes(topicName) && getTopicReference(topic).isPresent()) { + Optional topicRef = extractTopic(topicFuture); + // The bundle cleanup should only remove topics already fenced by close/unload. + if (serviceUnit.includes(topicName) && topicRef.isPresent() && isTopicBeingUnloaded(topicRef.get())) { log.info("[{}][{}] Clean unloaded topic from cache.", serviceUnit.toString(), topic); - pulsar.getBrokerService().removeTopicFromCache(topicName.toString(), serviceUnit, null); + removeTopicFromCache(topicName.toString(), serviceUnit, topicFuture); } - } + }); + } + + private boolean isTopicBeingUnloaded(Topic topic) { + return topic instanceof AbstractTopic abstractTopic && abstractTopic.isFenced(); } public AuthorizationService getAuthorizationService() { @@ -2624,7 +2677,8 @@ public AuthorizationService getAuthorizationService() { /** * Removes the topic from the cache only if the topicName and associated createFuture match exactly. - * The TopicEvent.UNLOAD event will be triggered before and after removal. + * The TopicEvent.UNLOAD BEFORE event is triggered while the matching cache entry is still present; + * the SUCCESS event is triggered after it has been removed. * * @param topic The topic to be removed. * @return A CompletableFuture that completes when the operation is done. @@ -2639,45 +2693,71 @@ public CompletableFuture removeTopicFromCache(AbstractTopic topic) { private void removeTopicFromCache(String topic, NamespaceBundle namespaceBundle, CompletableFuture> createTopicFuture) { - String bundleName = namespaceBundle.toString(); - String namespaceName = TopicName.get(topic).getNamespaceObject().toString(); - - topicEventsDispatcher.newEvent(topic, TopicEvent.UNLOAD).stage(EventStage.BEFORE).dispatch(); - - synchronized (multiLayerTopicsMap) { - final var namespaceMap = multiLayerTopicsMap.get(namespaceName); - if (namespaceMap != null) { - final var bundleMap = namespaceMap.get(bundleName); - if (bundleMap != null) { - bundleMap.remove(topic); - if (bundleMap.isEmpty()) { - namespaceMap.remove(bundleName); - } + if (createTopicFuture == null) { + if (log.isDebugEnabled()) { + log.debug("[{}] Skip removing topic from cache without its expected future.", topic); + } + return; + } + if (!topicCacheRemovalsInProgress.add(createTopicFuture)) { + if (log.isDebugEnabled()) { + log.debug("[{}] Skip removing topic from cache since cleanup is already in progress.", topic); + } + return; + } + try { + if (topics.get(topic) != createTopicFuture) { + // A stale close/unload callback must not emit unload side effects for a superseded topic future. + if (log.isDebugEnabled()) { + log.debug("[{}] Skip removing topic from cache since it was already removed or superseded.", + topic); } + return; + } + String bundleName = namespaceBundle.toString(); + String namespaceName = TopicName.get(topic).getNamespaceObject().toString(); + + topicEventsDispatcher.newEvent(topic, TopicEvent.UNLOAD).stage(EventStage.BEFORE).dispatch(); + + synchronized (multiLayerTopicsMap) { + final var namespaceMap = multiLayerTopicsMap.get(namespaceName); + if (namespaceMap != null) { + final var bundleMap = namespaceMap.get(bundleName); + if (bundleMap != null) { + bundleMap.remove(topic); + if (bundleMap.isEmpty()) { + namespaceMap.remove(bundleName); + } + } - if (namespaceMap.isEmpty()) { - multiLayerTopicsMap.remove(namespaceName); - final ClusterReplicationMetrics clusterReplicationMetrics = pulsarStats - .getClusterReplicationMetrics(); - replicationClients.forEach((cluster, client) -> { - clusterReplicationMetrics.remove(clusterReplicationMetrics.getKeyName(namespaceName, - cluster)); - }); + if (namespaceMap.isEmpty()) { + multiLayerTopicsMap.remove(namespaceName); + final ClusterReplicationMetrics clusterReplicationMetrics = pulsarStats + .getClusterReplicationMetrics(); + replicationClients.forEach((cluster, client) -> { + clusterReplicationMetrics.remove(clusterReplicationMetrics.getKeyName(namespaceName, + cluster)); + }); + } } } - } - if (createTopicFuture == null) { - topics.remove(topic); - } else { - topics.remove(topic, createTopicFuture); - } + Compactor compactor = pulsar.getNullableCompactor(); + if (compactor != null) { + compactor.getStats().removeTopic(topic); + } - Compactor compactor = pulsar.getNullableCompactor(); - if (compactor != null) { - compactor.getStats().removeTopic(topic); + if (!topics.remove(topic, createTopicFuture)) { + if (log.isDebugEnabled()) { + log.debug("[{}] Skip unload success because the cache entry was already removed or superseded.", + topic); + } + return; + } + topicEventsDispatcher.notify(topic, TopicEvent.UNLOAD, EventStage.SUCCESS); + } finally { + topicCacheRemovalsInProgress.remove(createTopicFuture); } - topicEventsDispatcher.newEvent(topic, TopicEvent.UNLOAD).dispatch(); } public long getNumberOfNamespaceBundles() { @@ -2736,9 +2816,10 @@ private void handleLocalPoliciesUpdates(NamespaceName namespace) { log.debug("Notifying topic that local policies have changed: {}", name); } topic.ifPresent(t -> { - if (t instanceof PersistentTopic) { - PersistentTopic topic1 = (PersistentTopic) t; - topic1.onLocalPoliciesUpdate(); + if (t instanceof PersistentTopic persistentTopic) { + runOnTopicPoliciesNotifyThread(t, () -> { + persistentTopic.onLocalPoliciesUpdate(); + }); } }); }); @@ -2758,7 +2839,8 @@ private void handlePoliciesUpdates(NamespaceName namespace) { log.info("[{}] updating with {}", namespace, policies); topics.forEach((name, topicFuture) -> { - if (namespace.includes(TopicName.get(name))) { + TopicName topicName = TopicName.get(name); + if (namespace.includes(topicName)) { // If the topic is already created, immediately apply the updated policies, otherwise // once the topic is created it'll apply the policies update topicFuture.thenAccept(topic -> { @@ -2766,7 +2848,11 @@ private void handlePoliciesUpdates(NamespaceName namespace) { log.debug("Notifying topic that policies have changed: {}", name); } - topic.ifPresent(t -> t.onPoliciesUpdate(policies)); + topic.ifPresent(t -> { + runOnTopicPoliciesNotifyThread(t, () -> { + t.onPoliciesUpdate(policies); + }); + }); }); } }); @@ -2777,6 +2863,16 @@ private void handlePoliciesUpdates(NamespaceName namespace) { }, pulsar.getExecutor()); } + private void runOnTopicPoliciesNotifyThread(Topic t, Runnable runnable) { + ExecutorService policiesNotifyThread; + if (t instanceof AbstractTopic abstractTopic) { + policiesNotifyThread = abstractTopic.getPoliciesNotifyThread(); + } else { + policiesNotifyThread = getTopicPoliciesNotifyThread(TopicName.getPartitionedTopicName(t.getName())); + } + policiesNotifyThread.execute(runnable); + } + private void handleDynamicConfigurationUpdates() { DynamicConfigurationResources dynamicConfigResources = null; try { @@ -3395,7 +3491,7 @@ private void createPendingLoadTopic() { return; } - pendingTopic.polledFromQueue(); + pendingTopic.trace("queued"); final String topic = pendingTopic.getTopicName().toString(); checkTopicNsOwnership(topic).thenRun(() -> { CompletableFuture> pendingFuture = pendingTopic.getTopicFuture(); @@ -3624,6 +3720,24 @@ public OrderedExecutor getTopicOrderedExecutor() { return topicOrderedExecutor; } + /** + * Returns the single executor thread used to apply topic-policy updates for the given topic. All + * topic-policy notifications and policy application for a topic must run on this deterministically-chosen + * thread so that they are serialized and never run concurrently. Centralizing the topic-to-thread mapping + * here keeps it consistent between {@link AbstractTopic} and + * {@link SystemTopicBasedTopicPoliciesService} so the two cannot accidentally diverge. + */ + public ExecutorService getTopicPoliciesNotifyThread(TopicName topicName) { + TopicName baseTopicName; + if (topicName.isPartitioned()) { + // for partitioned topics, we need to use the base topic name + baseTopicName = TopicName.get(topicName.getPartitionedTopicName()); + } else { + baseTopicName = topicName; + } + return topicOrderedExecutor.chooseThread(baseTopicName); + } + /** * If per-broker unacked message reached to limit then it blocks dispatcher if its unacked message limit has been * reached to {@link #maxUnackedMsgsPerDispatcher}. diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/Consumer.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/Consumer.java index 705b5284ca294..b84de48f25015 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/Consumer.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/Consumer.java @@ -905,20 +905,16 @@ public void topicMigrated(Optional clusterUrl) { } } - public boolean checkAndApplyTopicMigration() { - if (subscription.isSubscriptionMigrated()) { - Optional clusterUrl = AbstractTopic.getMigratedClusterUrl(cnx.getBrokerService().getPulsar(), - topicName); - if (clusterUrl.isPresent()) { - ClusterUrl url = clusterUrl.get(); - cnx.getCommandSender().sendTopicMigrated(ResourceType.Consumer, consumerId, url.getBrokerServiceUrl(), - url.getBrokerServiceUrlTls()); - // disconnect consumer after sending migrated cluster url - disconnect(); - return true; - } + public CompletableFuture checkAndApplyTopicMigrationAsync() { + if (!subscription.isSubscriptionMigrated()) { + return CompletableFuture.completedFuture(false); } - return false; + return AbstractTopic.getMigratedClusterUrlAsync(cnx.getBrokerService().getPulsar(), topicName) + .thenApply(clusterUrl -> { + // topicMigrated() sends the migrated cluster url and disconnects the consumer if present + topicMigrated(clusterUrl); + return clusterUrl.isPresent(); + }); } /** * Checks if consumer-blocking on unAckedMessages is allowed for below conditions:
diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/ConsumerNameIndexTracker.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/ConsumerNameIndexTracker.java index 1f93313ab1b71..3ab9e013c2743 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/ConsumerNameIndexTracker.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/ConsumerNameIndexTracker.java @@ -22,7 +22,8 @@ import java.util.Map; import javax.annotation.concurrent.NotThreadSafe; import org.apache.commons.lang3.mutable.MutableInt; -import org.roaringbitmap.RoaringBitmap; +import org.apache.pulsar.common.util.collections.LongBitmap; +import org.apache.pulsar.common.util.collections.LongBitmaps; /** * Tracks the used consumer name indexes for each consumer name. @@ -39,7 +40,7 @@ * changes are minimized over time, although a better solution would be to avoid reusing the same consumer name * in the first place. * - * When a consumer is removed, the index is deallocated. RoaringBitmap is used to keep track of the used indexes. + * When a consumer is removed, the index is deallocated. LongBitmap is used to keep track of the used indexes. * The data structure to track a consumer name is removed when the reference count of the consumer name is zero. * * This class is not thread-safe and should be used in a synchronized context in the caller. @@ -56,18 +57,18 @@ record ConsumerEntry(String consumerName, int nameIndex, MutableInt refCount) { } /* - * Tracks the used indexes for a consumer name using a RoaringBitmap. + * Tracks the used indexes for a consumer name using a LongBitmap. * A specific index slot is used when the bit is set. * When all bits are cleared, the customer name can be removed from tracking. */ static class ConsumerNameIndexSlots { - private RoaringBitmap indexSlots = new RoaringBitmap(); + private LongBitmap indexSlots = LongBitmaps.create(); public int allocateIndexSlot() { // find the first index that is not set, if there is no such index, add a new one int index = (int) indexSlots.nextAbsentValue(0); if (index == -1) { - index = indexSlots.getCardinality(); + index = (int) indexSlots.cardinality(); } indexSlots.add(index); return index; diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/DrainingHashesTracker.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/DrainingHashesTracker.java index 8570e2f4d36fb..0e1d15e385e8d 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/DrainingHashesTracker.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/DrainingHashesTracker.java @@ -24,7 +24,6 @@ import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.PrimitiveIterator; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicIntegerFieldUpdater; import java.util.concurrent.locks.ReentrantReadWriteLock; @@ -33,7 +32,8 @@ import org.apache.pulsar.common.policies.data.DrainingHash; import org.apache.pulsar.common.policies.data.stats.ConsumerStatsImpl; import org.apache.pulsar.common.policies.data.stats.DrainingHashImpl; -import org.roaringbitmap.RoaringBitmap; +import org.apache.pulsar.common.util.collections.LongBitmap; +import org.apache.pulsar.common.util.collections.LongBitmaps; /** * A thread-safe map to store draining hashes in the consumer. @@ -142,7 +142,7 @@ int getBlockedCount() { } private class ConsumerDrainingHashesStats { - private final RoaringBitmap drainingHashes = new RoaringBitmap(); + private final LongBitmap drainingHashes = LongBitmaps.create(); private long drainingHashesClearedTotal; private final ReentrantReadWriteLock statsLock = new ReentrantReadWriteLock(); @@ -163,11 +163,7 @@ public boolean clearHash(int hash) { boolean empty = drainingHashes.isEmpty(); if (log.isDebugEnabled()) { log.debug("[{}] Cleared hash {} in stats. empty={} totalCleared={} hashes={}", - dispatcherName, hash, empty, drainingHashesClearedTotal, drainingHashes.getCardinality()); - } - if (empty) { - // reduce memory usage by trimming the bitmap when the RoaringBitmap instance is empty - drainingHashes.trim(); + dispatcherName, hash, empty, drainingHashesClearedTotal, drainingHashes.cardinality()); } return empty; } finally { @@ -178,16 +174,15 @@ public boolean clearHash(int hash) { public void updateConsumerStats(Consumer consumer, ConsumerStatsImpl consumerStats) { statsLock.readLock().lock(); try { - int drainingHashesUnackedMessages = 0; List drainingHashesStats = new ArrayList<>(); - PrimitiveIterator.OfInt hashIterator = drainingHashes.stream().iterator(); - while (hashIterator.hasNext()) { - int hash = hashIterator.nextInt(); + int[] drainingHashesUnackedMessages = {0}; + drainingHashes.forEachLong(hashLong -> { + int hash = (int) hashLong; DrainingHashEntry entry = getEntry(hash); if (entry == null) { log.debug("[{}] Draining hash {} not found in the tracker for consumer {}", dispatcherName, hash, consumer); - continue; + return; } int unackedMessages = entry.getRefCount(); DrainingHashImpl drainingHash = new DrainingHashImpl(); @@ -195,11 +190,11 @@ public void updateConsumerStats(Consumer consumer, ConsumerStatsImpl consumerSta drainingHash.unackMsgs = unackedMessages; drainingHash.blockedAttempts = entry.getBlockedCount(); drainingHashesStats.add(drainingHash); - drainingHashesUnackedMessages += unackedMessages; - } + drainingHashesUnackedMessages[0] += unackedMessages; + }); consumerStats.drainingHashesCount = drainingHashesStats.size(); consumerStats.drainingHashesClearedTotal = drainingHashesClearedTotal; - consumerStats.drainingHashesUnackedMessages = drainingHashesUnackedMessages; + consumerStats.drainingHashesUnackedMessages = drainingHashesUnackedMessages[0]; consumerStats.drainingHashes = drainingHashesStats; } finally { statsLock.readLock().unlock(); diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/LegacyAwareTopicPoliciesService.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/LegacyAwareTopicPoliciesService.java new file mode 100644 index 0000000000000..c9d6fbd3ac3ef --- /dev/null +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/LegacyAwareTopicPoliciesService.java @@ -0,0 +1,142 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.broker.service; + +import com.github.benmanes.caffeine.cache.AsyncCacheLoader; +import com.github.benmanes.caffeine.cache.AsyncLoadingCache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.google.common.annotations.VisibleForTesting; +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.function.Consumer; +import org.apache.pulsar.broker.PulsarService; +import org.apache.pulsar.broker.systopic.NamespaceEventsSystemTopicFactory; +import org.apache.pulsar.common.events.EventType; +import org.apache.pulsar.common.naming.NamespaceName; +import org.apache.pulsar.common.naming.TopicName; +import org.apache.pulsar.common.policies.data.TopicPolicies; +import org.jspecify.annotations.NonNull; + +/** + * Routes topic policy operations to the legacy system-topic backend when a namespace already has + * a topic-policy {@code __change_events} system topic, and otherwise to the configured backend. + */ +public class LegacyAwareTopicPoliciesService implements TopicPoliciesService { + + private final AsyncLoadingCache isLegacyNamespace; + @VisibleForTesting + final SystemTopicBasedTopicPoliciesService systemTopicService; + private final TopicPoliciesService configuredService; + + public LegacyAwareTopicPoliciesService(PulsarService pulsar, + SystemTopicBasedTopicPoliciesService systemTopicService, + TopicPoliciesService configuredService) { + // Generally, we only need to check if the __change_events topic exists once because the __change_events topic + // should only be created by broker before the upgrade, where `SystemTopicBasedTopicPoliciesService` is + // configured as the topic policies service. + this.isLegacyNamespace = Caffeine.newBuilder().expireAfterWrite(Duration.ofHours(1)) + .buildAsync(new AsyncCacheLoader<>() { + @NonNull + @Override + public CompletableFuture asyncLoad(NamespaceName key, + @NonNull Executor executor) { + return NamespaceEventsSystemTopicFactory.checkSystemTopicExists(key, EventType.TOPIC_POLICY, + pulsar); + } + }); + this.systemTopicService = systemTopicService; + this.configuredService = configuredService; + if (configuredService instanceof SystemTopicBasedTopicPoliciesService) { + throw new IllegalArgumentException( + "configuredService should not be an instance of SystemTopicBasedTopicPoliciesService"); + } + } + + @Override + public void start(PulsarService pulsarService) { + // We should not call `systemTopicService.start()`, which just registers a namespace bundle listener to create + // a reader on `/__change_events` when the namespace's bundle is loaded firstly. It's just an + // optimization to create the reader before loading any topic. However, it could create a reader on a namespace + // that does not even have the __change_events topic. + configuredService.start(pulsarService); + } + + @Override + public void close() throws Exception { + try { + configuredService.close(); + } finally { + systemTopicService.close(); + } + } + + @Override + public CompletableFuture> getTopicPoliciesAsync(TopicName topicName, GetType type) { + return resolveService(topicName.getNamespaceObject()) + .thenCompose(service -> service.getTopicPoliciesAsync(topicName, type)); + } + + @Override + public CompletableFuture updateTopicPoliciesAsync(TopicName topicName, boolean isGlobalPolicy, + boolean skipUpdateWhenTopicPolicyDoesntExist, + Consumer policyUpdater) { + return resolveService(topicName.getNamespaceObject()) + .thenCompose(service -> service.updateTopicPoliciesAsync(topicName, isGlobalPolicy, + skipUpdateWhenTopicPolicyDoesntExist, policyUpdater)); + } + + @Override + public CompletableFuture deleteTopicPoliciesAsync(TopicName topicName) { + return resolveService(topicName.getNamespaceObject()) + .thenCompose(service -> service.deleteTopicPoliciesAsync(topicName)); + } + + @Override + public CompletableFuture deleteTopicPoliciesAsync(TopicName topicName, + boolean keepGlobalPoliciesAfterDeleting) { + return resolveService(topicName.getNamespaceObject()) + .thenCompose(service -> service.deleteTopicPoliciesAsync(topicName, + keepGlobalPoliciesAfterDeleting)); + } + + @Override + public CompletableFuture registerListenerAsync(TopicName topicName, TopicPolicyListener listener) { + return resolveService(topicName.getNamespaceObject()) + .thenCompose(service -> service.registerListenerAsync(topicName, listener)); + } + + @Override + public boolean registerListener(TopicName topicName, TopicPolicyListener listener) { + throw new RuntimeException("should not be called"); + } + + @Override + public void unregisterListener(TopicName topicName, TopicPolicyListener listener) { + configuredService.unregisterListener(topicName, listener); + systemTopicService.unregisterListener(topicName, listener); + } + + @VisibleForTesting + CompletableFuture resolveService(NamespaceName namespace) { + return isLegacyNamespace.get(namespace) + .thenApply(isLegacy -> isLegacy ? systemTopicService : configuredService); + } +} diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/MetadataStoreTopicPoliciesService.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/MetadataStoreTopicPoliciesService.java new file mode 100644 index 0000000000000..218c37472a3d5 --- /dev/null +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/MetadataStoreTopicPoliciesService.java @@ -0,0 +1,277 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.broker.service; + +import com.google.common.annotations.VisibleForTesting; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import lombok.extern.slf4j.Slf4j; +import org.apache.pulsar.broker.PulsarService; +import org.apache.pulsar.broker.namespace.NamespaceService; +import org.apache.pulsar.common.naming.TopicName; +import org.apache.pulsar.common.policies.data.TopicPolicies; +import org.apache.pulsar.common.util.Codec; +import org.apache.pulsar.common.util.FutureUtil; +import org.apache.pulsar.metadata.api.MetadataCache; +import org.apache.pulsar.metadata.api.MetadataStore; +import org.apache.pulsar.metadata.api.MetadataStoreException.NotFoundException; +import org.apache.pulsar.metadata.api.Notification; +import org.apache.pulsar.metadata.api.NotificationType; +import org.jspecify.annotations.Nullable; + +/** + * Topic policies service backed by Pulsar metadata stores. + */ +@Slf4j +public class MetadataStoreTopicPoliciesService implements TopicPoliciesService { + + public static final String GLOBAL_POLICIES_ROOT = "/admin/topic-policies/global"; + public static final String LOCAL_POLICIES_ROOT = "/admin/topic-policies/local"; + + private final AtomicBoolean closed = new AtomicBoolean(false); + private final Map> listeners = new ConcurrentHashMap<>(); + private MetadataCache localPoliciesCache; + private MetadataCache globalPoliciesCache; + + @Override + public void start(PulsarService pulsar) { + MetadataStore localStore = pulsar.getLocalMetadataStore(); + MetadataStore configurationStore = pulsar.getConfigurationMetadataStore(); + this.localPoliciesCache = localStore.getMetadataCache(TopicPolicies.class); + this.globalPoliciesCache = configurationStore.getMetadataCache(TopicPolicies.class); + localStore.registerListener(notification -> handleNotification(notification, false)); + configurationStore.registerListener(notification -> handleNotification(notification, true)); + } + + @Override + public CompletableFuture deleteTopicPoliciesAsync(TopicName topicName) { + return deleteTopicPoliciesAsync(topicName, false); + } + + @Override + public CompletableFuture deleteTopicPoliciesAsync(TopicName topicName, + boolean keepGlobalPoliciesAfterDeleting) { + TopicName partitionedTopicName = normalizeTopicName(topicName); + if (NamespaceService.isHeartbeatNamespace(partitionedTopicName.getNamespaceObject())) { + return CompletableFuture.completedFuture(null); + } + if (closed.get()) { + return CompletableFuture.failedFuture(new BrokerServiceException(getClass().getName() + " is closed.")); + } + CompletableFuture deleteLocal = + deleteIfExists(localPoliciesCache, pathFor(partitionedTopicName, false)); + if (keepGlobalPoliciesAfterDeleting) { + return deleteLocal; + } + CompletableFuture deleteGlobal = + deleteIfExists(globalPoliciesCache, pathFor(partitionedTopicName, true)); + return CompletableFuture.allOf(deleteLocal, deleteGlobal); + } + + @Override + public CompletableFuture updateTopicPoliciesAsync(TopicName topicName, boolean isGlobalPolicy, + boolean skipUpdateWhenTopicPolicyDoesntExist, + Consumer policyUpdater) { + TopicName partitionedTopicName = normalizeTopicName(topicName); + if (NamespaceService.isHeartbeatNamespace(partitionedTopicName.getNamespaceObject())) { + return CompletableFuture.failedFuture(new BrokerServiceException.NotAllowedException( + "Not allowed to update topic policy for the heartbeat topic")); + } + if (closed.get()) { + return CompletableFuture.failedFuture(new BrokerServiceException(getClass().getName() + " is closed.")); + } + MetadataCache cache = cache(isGlobalPolicy); + String path = pathFor(partitionedTopicName, isGlobalPolicy); + CompletableFuture updateFuture; + if (skipUpdateWhenTopicPolicyDoesntExist) { + updateFuture = cache.readModifyUpdate(path, + current -> updatePolicies(Optional.of(current), isGlobalPolicy, policyUpdater)); + } else { + updateFuture = cache.readModifyUpdateOrCreate(path, + current -> updatePolicies(current, isGlobalPolicy, policyUpdater)); + } + return updateFuture.thenAccept(__ -> { }).exceptionally(error -> { + if (skipUpdateWhenTopicPolicyDoesntExist + && FutureUtil.unwrapCompletionException(error) instanceof NotFoundException) { + return null; + } + throw FutureUtil.wrapToCompletionException(error); + }); + } + + @Override + public CompletableFuture> getTopicPoliciesAsync(TopicName topicName, GetType type) { + TopicName partitionedTopicName = normalizeTopicName(topicName); + if (NamespaceService.isHeartbeatNamespace(partitionedTopicName.getNamespaceObject())) { + return CompletableFuture.completedFuture(Optional.empty()); + } + if (closed.get()) { + return CompletableFuture.completedFuture(Optional.empty()); + } + boolean global = type == GetType.GLOBAL_ONLY; + return cache(global).get(pathFor(partitionedTopicName, global)) + .thenApply(policies -> policies.map(policy -> cloneWithScope(policy, global))); + } + + @Override + public boolean registerListener(TopicName topicName, TopicPolicyListener listener) { + listeners.compute(normalizeTopicName(topicName), (__, topicListeners) -> { + if (topicListeners == null) { + topicListeners = new CopyOnWriteArrayList<>(); + } + topicListeners.add(listener); + return topicListeners; + }); + return true; + } + + @Override + public void unregisterListener(TopicName topicName, TopicPolicyListener listener) { + listeners.computeIfPresent(normalizeTopicName(topicName), (__, topicListeners) -> { + topicListeners.remove(listener); + return topicListeners.isEmpty() ? null : topicListeners; + }); + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + listeners.clear(); + if (localPoliciesCache != null) { + localPoliciesCache.invalidateAll(); + } + if (globalPoliciesCache != null) { + globalPoliciesCache.invalidateAll(); + } + } + } + + private MetadataCache cache(boolean isGlobalPolicy) { + return isGlobalPolicy ? globalPoliciesCache : localPoliciesCache; + } + + private CompletableFuture deleteIfExists(MetadataCache cache, String path) { + return cache.delete(path).handle((__, error) -> { + cache.invalidate(path); + if (error == null || FutureUtil.unwrapCompletionException(error) instanceof NotFoundException) { + return null; + } + throw FutureUtil.wrapToCompletionException(error); + }); + } + + private static TopicPolicies updatePolicies(Optional currentPolicies, + boolean isGlobalPolicy, + Consumer policyUpdater) { + TopicPolicies policies = currentPolicies.map(TopicPolicies::clone).orElseGet(TopicPolicies::new); + policies.setIsGlobal(isGlobalPolicy); + policyUpdater.accept(policies); + return policies; + } + + private void handleNotification(Notification notification, boolean isGlobalPolicy) { + if (closed.get() + || (notification.getType() != NotificationType.Created + && notification.getType() != NotificationType.Modified + && notification.getType() != NotificationType.Deleted)) { + return; + } + String path = notification.getPath(); + String root = isGlobalPolicy ? GLOBAL_POLICIES_ROOT : LOCAL_POLICIES_ROOT; + Optional topicName = topicNameFromPath(root, path); + if (topicName.isEmpty()) { + return; + } + MetadataCache cache = cache(isGlobalPolicy); + cache.invalidate(path); + if (notification.getType() == NotificationType.Deleted) { + notifyListeners(topicName.get(), null); + return; + } + cache.get(path).whenComplete((policies, error) -> { + if (error != null) { + log.warn("[{}] Failed to refresh topic policies after metadata notification", path, error); + return; + } + notifyListeners(topicName.get(), + policies.map(policy -> cloneWithScope(policy, isGlobalPolicy)).orElse(null)); + }); + } + + private void notifyListeners(TopicName topicName, @Nullable TopicPolicies policies) { + List topicListeners = listeners.get(topicName); + if (topicListeners == null) { + return; + } + for (TopicPolicyListener listener : topicListeners) { + try { + listener.onUpdate(policies == null ? null : policies.clone()); + } catch (Throwable error) { + log.error("[{}] Call topic policy listener error", topicName, error); + } + } + } + + private static TopicName normalizeTopicName(TopicName topicName) { + return TopicName.get(topicName.getPartitionedTopicName()); + } + + private static TopicPolicies cloneWithScope(TopicPolicies policies, boolean isGlobalPolicy) { + TopicPolicies cloned = policies.clone(); + cloned.setIsGlobal(isGlobalPolicy); + return cloned; + } + + @VisibleForTesting + public CompletableFuture> getTopicPoliciesDirectFromStore(TopicName topicName, + boolean isGlobal) { + String path = pathFor(topicName, isGlobal); + MetadataCache c = cache(isGlobal); + c.invalidate(path); + return c.get(path).thenApply(opt -> opt.map(p -> cloneWithScope(p, isGlobal))); + } + + @VisibleForTesting + static String pathFor(TopicName topicName, boolean isGlobalPolicy) { + TopicName partitionedTopicName = normalizeTopicName(topicName); + return (isGlobalPolicy ? GLOBAL_POLICIES_ROOT : LOCAL_POLICIES_ROOT) + + "/" + partitionedTopicName.getTenant() + + "/" + partitionedTopicName.getNamespacePortion() + + "/" + partitionedTopicName.getDomain() + + "/" + partitionedTopicName.getEncodedLocalName(); + } + + @VisibleForTesting + private static Optional topicNameFromPath(String root, String path) { + if (!path.startsWith(root + "/")) { + return Optional.empty(); + } + String[] parts = path.substring(root.length() + 1).split("/", 4); + if (parts.length != 4) { + return Optional.empty(); + } + return Optional.of(TopicName.get(parts[2], parts[0], parts[1], Codec.decode(parts[3]))); + } +} diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/PulsarMetadataEventSynchronizer.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/PulsarMetadataEventSynchronizer.java index a5fac333ae57e..6597dd337ab3d 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/PulsarMetadataEventSynchronizer.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/PulsarMetadataEventSynchronizer.java @@ -34,6 +34,7 @@ import org.apache.pulsar.broker.PulsarService; import org.apache.pulsar.client.api.Consumer; import org.apache.pulsar.client.api.ConsumerBuilder; +import org.apache.pulsar.client.api.Message; import org.apache.pulsar.client.api.MessageRoutingMode; import org.apache.pulsar.client.api.Producer; import org.apache.pulsar.client.api.Schema; @@ -197,28 +198,19 @@ private void startConsumer() { listeners.size()); try { if (listeners.size() == 0) { - c.acknowledgeAsync(msg); + acknowledgeAfter(CompletableFuture.completedFuture(null), c, msg); return; } if (listeners.size() == 1) { - listeners.get(0).apply(msg.getValue()).thenApply(__ -> c.acknowledgeAsync(msg)) - .exceptionally(ex -> { - log.warn("Failed to synchronize {} for {}", msg.getMessageId(), topicName, - ex.getCause()); - return null; - }); + acknowledgeAfter(listeners.get(0).apply(msg.getValue()), c, msg); } else { - FutureUtil - .waitForAll(listeners.stream().map(listener -> listener.apply(msg.getValue())) - .collect(Collectors.toList())) - .thenApply(__ -> c.acknowledgeAsync(msg)).exceptionally(ex -> { - log.warn("Failed to synchronize {} for {}", msg.getMessageId(), topicName); - return null; - }); + acknowledgeAfter( + FutureUtil.waitForAll(listeners.stream().map(listener -> listener.apply(msg.getValue())) + .collect(Collectors.toList())), c, msg); } } catch (Exception e) { - log.warn("Failed to synchronize {} for {}", msg.getMessageId(), topicName); + log.warn("Failed to synchronize {} for {}", msg.getMessageId(), topicName, e); } }); consumerBuilder.subscribeAsync().thenAccept(consumer -> { @@ -247,6 +239,19 @@ private void startConsumer() { }); } + /** + * Acknowledge {@code msg} only after {@code processed} completes, and log (rather than silently drop) + * an exception from either the processing stage or the acknowledgement itself. + */ + private CompletableFuture acknowledgeAfter(CompletableFuture processed, Consumer c, + Message msg) { + return processed.thenCompose(__ -> c.acknowledgeAsync(msg)) + .exceptionally(ex -> { + log.warn("Failed to synchronize {} for {}", msg.getMessageId(), topicName, ex); + return null; + }); + } + public boolean isStarted() { return this.state == State.Started; } diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/Replicator.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/Replicator.java index 64d03f6aa95dd..cea4a0a8b6296 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/Replicator.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/Replicator.java @@ -26,16 +26,12 @@ public interface Replicator { - void startProducer(); - Topic getLocalTopic(); ReplicatorStatsImpl computeStats(); CompletableFuture terminate(); - CompletableFuture disconnect(); - void updateRates(); String getRemoteCluster(); diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/ServerCnx.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/ServerCnx.java index 2bf7d019f3458..82e9c3f7f405e 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/ServerCnx.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/ServerCnx.java @@ -25,7 +25,7 @@ import static org.apache.pulsar.broker.admin.impl.PersistentTopicsBase.unsafeGetPartitionedTopicMetadataAsync; import static org.apache.pulsar.broker.lookup.TopicLookupBase.lookupTopicAsync; import static org.apache.pulsar.broker.service.ServerCnxThrottleTracker.ThrottleType; -import static org.apache.pulsar.broker.service.persistent.PersistentTopic.getMigratedClusterUrl; +import static org.apache.pulsar.broker.service.persistent.PersistentTopic.getMigratedClusterUrlAsync; import static org.apache.pulsar.broker.service.schema.BookkeeperSchemaStorage.ignoreUnrecoverableBKException; import static org.apache.pulsar.common.api.proto.ProtocolVersion.v5; import static org.apache.pulsar.common.protocol.Commands.DEFAULT_CONSUMER_EPOCH; @@ -753,8 +753,15 @@ protected void handlePartitionMetadataRequest(CommandPartitionedTopicMetadata pa topicExistsInfo.recycle(); }).exceptionally(ex -> { lookupSemaphore.release(); - log.error("{} {} Failed to get partition metadata", topicName, - ServerCnx.this.toString(), ex); + Throwable actEx = FutureUtil.unwrapCompletionException(ex); + if (actEx instanceof WebApplicationException restException + && restException.getResponse().getStatus() == NOT_FOUND.getStatusCode()) { + log.warn("{} {} Failed to get partition metadata for nonexistent resource: {}", + topicName, ServerCnx.this, actEx.getMessage()); + } else { + log.error("{} {} Failed to get partition metadata", topicName, + ServerCnx.this, ex); + } writeAndFlush( Commands.newPartitionMetadataResponse(ServerError.MetadataError, "Failed to get partition metadata", @@ -1345,8 +1352,9 @@ protected void handleSubscribe(final CommandSubscribe subscribe) { } }); }) - .thenAcceptAsync(consumer -> { - if (consumer.checkAndApplyTopicMigration()) { + .thenComposeAsync(consumer -> consumer.checkAndApplyTopicMigrationAsync() + .thenAcceptAsync(migrated -> { + if (migrated) { log.info("[{}] Disconnecting consumer {} on migrated subscription on topic {} / {}", remoteAddress, consumerId, subscriptionName, topicName); consumers.remove(consumerId, consumerFuture); @@ -1391,7 +1399,7 @@ protected void handleSubscribe(final CommandSubscribe subscribe) { consumers.remove(consumerId, consumerFuture); } - }, ctx.executor()) + }, ctx.executor()), ctx.executor()) .exceptionallyAsync(exception -> { if (exception.getCause() instanceof ConsumerBusyException) { if (log.isDebugEnabled()) { @@ -1402,26 +1410,12 @@ protected void handleSubscribe(final CommandSubscribe subscribe) { exception.getCause().getMessage()); } } else if (exception.getCause() instanceof BrokerServiceException.TopicMigratedException) { - Optional clusterURL = getMigratedClusterUrl(service.getPulsar(), - topicName.toString()); - if (clusterURL.isPresent()) { - log.info("[{}] redirect migrated consumer to topic {}: " - + "consumerId={}, subName={}, {}", remoteAddress, - topicName, consumerId, subscriptionName, exception.getCause().getMessage()); - boolean msgSent = commandSender.sendTopicMigrated(ResourceType.Consumer, consumerId, - clusterURL.get().getBrokerServiceUrl(), - clusterURL.get().getBrokerServiceUrlTls()); - if (!msgSent) { - log.info("consumer client doesn't support topic migration handling {}-{}-{}", - topicName, remoteAddress, consumerId); - } - consumers.remove(consumerId, consumerFuture); - dispatchCloseConsumerEvent(topicName.toString(), consumerId, consumerName, - this.clientSourceAddress(), subscriptionName, subType, - DisconnectInitiator.BROKER); - sendCloseConsumer(consumerId, Optional.empty()); - return null; - } + getMigratedClusterUrlAsync(service.getPulsar(), topicName.toString()) + .exceptionally(e -> Optional.empty()) + .thenAcceptAsync(clusterURL -> redirectOrFailMigratedConsumer(requestId, + consumerId, consumerName, subscriptionName, subType, topicName, + consumerFuture, exception, clusterURL), ctx.executor()); + return null; } else if (exception.getCause() instanceof BrokerServiceException) { log.warn("[{}][{}][{}] Failed to create consumer: consumerId={}, {}", remoteAddress, topicName, subscriptionName, @@ -1459,6 +1453,99 @@ protected void handleSubscribe(final CommandSubscribe subscribe) { }, ctx.executor()); } + private void redirectOrFailMigratedConsumer(long requestId, long consumerId, String consumerName, + String subscriptionName, + SubType subType, TopicName topicName, + CompletableFuture consumerFuture, Throwable exception, + Optional clusterURL) { + if (clusterURL.isPresent()) { + log.info("[{}] redirect migrated consumer to topic {}: consumerId={}, subName={}, {}", remoteAddress, + topicName, consumerId, subscriptionName, exception.getCause().getMessage()); + boolean msgSent = commandSender.sendTopicMigrated(ResourceType.Consumer, consumerId, + clusterURL.get().getBrokerServiceUrl(), + clusterURL.get().getBrokerServiceUrlTls()); + if (!msgSent) { + log.info("consumer client doesn't support topic migration handling {}-{}-{}", + topicName, remoteAddress, consumerId); + } + consumers.remove(consumerId, consumerFuture); + dispatchCloseConsumerEvent(topicName.toString(), consumerId, consumerName, + this.clientSourceAddress(), subscriptionName, subType, + DisconnectInitiator.BROKER); + sendCloseConsumer(consumerId, Optional.empty()); + } else { + // If client timed out, the future would have been completed by subsequent close. + // Send error back to client, only if not completed already. + if (consumerFuture.completeExceptionally(exception)) { + commandSender.sendErrorResponse(requestId, + BrokerServiceException.getClientErrorCode(exception.getCause()), + exception.getCause().getMessage()); + } + consumers.remove(consumerId, consumerFuture); + } + } + + private void redirectOrFailMigratedProducer(long requestId, long producerId, String producerName, + TopicName topicName, CompletableFuture producerFuture, Throwable exception, + Optional clusterURL) { + if (clusterURL.isPresent()) { + log.info("[{}] redirect migrated producer to topic {}: producerId={}, producerName = {}, {}", + remoteAddress, topicName, producerId, producerName, exception.getCause().getMessage()); + boolean msgSent = commandSender.sendTopicMigrated(ResourceType.Producer, producerId, + clusterURL.get().getBrokerServiceUrl(), clusterURL.get().getBrokerServiceUrlTls()); + if (!msgSent) { + log.info("client doesn't support topic migration handling {}-{}-{}", topicName, + remoteAddress, producerId); + } + producers.remove(producerId, producerFuture); + + dispatchCloseProducerEvent(topicName.toString(), producerId, producerName, + this.clientSourceAddress(), DisconnectInitiator.BROKER); + sendCloseProducer(producerId, -1L, Optional.empty()); + } else { + log.error("[{}] Failed to create topic {}, producerId={}", remoteAddress, topicName, producerId, + exception); + if (producerFuture.completeExceptionally(exception)) { + commandSender.sendErrorResponse(requestId, + BrokerServiceException.getClientErrorCode(exception.getCause()), + exception.getCause().getMessage()); + } + producers.remove(producerId, producerFuture); + } + } + + private void redirectOrFailMigratedProducerInQueue(long requestId, long producerId, String producerName, + TopicName topicName, Topic topic, Producer producer, CompletableFuture producerFuture, + Throwable ex, Optional clusterURL) { + if (clusterURL.isPresent() && topic.shouldProducerMigrate()) { + log.info("[{}] redirect migrated producer to topic {}: producerId={}, producerName = {}, {}", + remoteAddress, topicName, producerId, producerName, ex.getCause().getMessage()); + boolean msgSent = commandSender.sendTopicMigrated(ResourceType.Producer, producerId, + clusterURL.get().getBrokerServiceUrl(), clusterURL.get().getBrokerServiceUrlTls()); + if (!msgSent) { + log.info("client doesn't support topic migration handling {}-{}-{}", topic, + remoteAddress, producerId); + } + dispatchCloseProducerEvent(topicName.toString(), producerId, producerName, + this.clientSourceAddress(), DisconnectInitiator.BROKER); + sendCloseProducer(producerId, -1L, Optional.empty()); + } else { + if (clusterURL.isPresent()) { + log.info("Topic {} is migrated but replication backlog exist: " + + "producerId = {}, producerName = {}, {}", topicName, + producerId, producerName, ex.getCause().getMessage()); + } else { + log.warn("[{}] failed producer because migration url not configured topic {}: producerId={}, {}", + remoteAddress, topicName, producerId, ex.getCause().getMessage()); + } + producer.closeNow(true); + if (producerFuture.completeExceptionally(ex)) { + commandSender.sendErrorResponse(requestId, + BrokerServiceException.getClientErrorCode(ex), ex.getMessage()); + } + } + } + private SchemaData getSchema(Schema protocolSchema) { return SchemaData.builder() .data(protocolSchema.getSchemaData()) @@ -1696,23 +1783,11 @@ topicName, TopicOperation.PRODUCE, getAuthenticationData(), getOriginalAuthData( producers.remove(producerId, producerFuture); return null; } else if (cause instanceof BrokerServiceException.TopicMigratedException) { - Optional clusterURL = getMigratedClusterUrl(service.getPulsar(), topicName.toString()); - if (clusterURL.isPresent()) { - log.info("[{}] redirect migrated producer to topic {}: " - + "producerId={}, producerName = {}, {}", remoteAddress, - topicName, producerId, producerName, cause.getMessage()); - boolean msgSent = commandSender.sendTopicMigrated(ResourceType.Producer, producerId, - clusterURL.get().getBrokerServiceUrl(), clusterURL.get().getBrokerServiceUrlTls()); - if (!msgSent) { - log.info("client doesn't support topic migration handling {}-{}-{}", topicName, - remoteAddress, producerId); - } - producers.remove(producerId, producerFuture); - dispatchCloseProducerEvent(topicName.toString(), producerId, producerName, - this.clientSourceAddress(), DisconnectInitiator.BROKER); - sendCloseProducer(producerId, -1L, Optional.empty()); - return null; - } + getMigratedClusterUrlAsync(service.getPulsar(), topicName.toString()) + .exceptionally(e -> Optional.empty()) + .thenAcceptAsync(clusterURL -> redirectOrFailMigratedProducer(requestId, producerId, + producerName, topicName, producerFuture, exception, clusterURL), ctx.executor()); + return null; } // Do not print stack traces for expected exceptions @@ -1807,29 +1882,12 @@ private void buildProducerAndAddTopic(Topic topic, long producerId, String produ producers.remove(producerId, producerFuture); }, ctx.executor()).exceptionallyAsync(ex -> { if (ex.getCause() instanceof BrokerServiceException.TopicMigratedException) { - Optional clusterURL = getMigratedClusterUrl(service.getPulsar(), topic.getName()); - if (clusterURL.isPresent()) { - if (!topic.shouldProducerMigrate()) { - log.info("Topic {} is migrated but replication backlog exist: " - + "producerId = {}, producerName = {}, {}", topicName, - producerId, producerName, ex.getCause().getMessage()); - } else { - log.info("[{}] redirect migrated producer to topic {}: " - + "producerId={}, producerName = {}, {}", remoteAddress, - topicName, producerId, producerName, ex.getCause().getMessage()); - boolean msgSent = commandSender.sendTopicMigrated(ResourceType.Producer, producerId, - clusterURL.get().getBrokerServiceUrl(), clusterURL.get().getBrokerServiceUrlTls()); - if (!msgSent) { - log.info("client doesn't support topic migration handling {}-{}-{}", topic, - remoteAddress, producerId); - } - closeProducer(producer); - return null; - } - } else { - log.warn("[{}] failed producer because migration url not configured topic {}: producerId={}, {}", - remoteAddress, topicName, producerId, ex.getCause().getMessage()); - } + getMigratedClusterUrlAsync(service.getPulsar(), topic.getName()) + .exceptionally(e -> Optional.empty()) + .thenAcceptAsync(clusterURL -> redirectOrFailMigratedProducerInQueue(requestId, producerId, + producerName, topicName, topic, producer, producerFuture, ex, clusterURL), + ctx.executor()); + return null; } else if (ex.getCause() instanceof BrokerServiceException.ProducerFencedException) { if (log.isDebugEnabled()) { log.debug("[{}] Failed to add producer to topic {}: producerId={}, {}", @@ -2395,10 +2453,13 @@ public String toString() { }, null); CompletableFuture batchSizeFuture = entryFuture.thenApply(entry -> { - MessageMetadata metadata = Commands.parseMessageMetadata(entry.getDataBuffer()); - int batchSize = metadata.getNumMessagesInBatch(); - entry.release(); - return metadata.hasNumMessagesInBatch() ? batchSize : -1; + try { + MessageMetadata metadata = Commands.parseMessageMetadata(entry.getDataBuffer()); + int batchSize = metadata.getNumMessagesInBatch(); + return metadata.hasNumMessagesInBatch() ? batchSize : -1; + } finally { + entry.release(); + } }); batchSizeFuture.whenComplete((batchSize, e) -> { diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/SharedConsumerAssignor.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/SharedConsumerAssignor.java index bbf8dfd2b10fc..a317ad7560b23 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/SharedConsumerAssignor.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/SharedConsumerAssignor.java @@ -89,7 +89,7 @@ public Map> assign(final List if (metadata == null || !metadata.hasUuid() || !metadata.hasChunkId() || !metadata.hasNumChunksFromMsg()) { consumerToEntries.computeIfAbsent(consumer, __ -> new ArrayList<>()).add(entryAndMetadata); } else { - final Consumer consumerForUuid = getConsumerForUuid(metadata, consumer, availablePermits); + final Consumer consumerForUuid = getConsumerForUuid(metadata, consumer); if (consumerForUuid == null) { unassignedMessageProcessor.accept(entryAndMetadata); continue; @@ -120,9 +120,7 @@ private Consumer getConsumer(final int numConsumers) { return null; } - private Consumer getConsumerForUuid(final MessageMetadata metadata, - final Consumer defaultConsumer, - final int currentAvailablePermits) { + private Consumer getConsumerForUuid(final MessageMetadata metadata, final Consumer defaultConsumer) { final String uuid = metadata.getUuid(); Consumer consumer = uuidToConsumer.get(uuid); if (consumer == null) { @@ -141,7 +139,9 @@ private Consumer getConsumerForUuid(final MessageMetadata metadata, // The last chunk is received, we should remove the cache uuidToConsumer.remove(uuid); } - consumerToPermits.put(consumer, currentAvailablePermits - 1); + // Decrement target consumer's permits, not the loop's local availablePermits — on a cache + // redirect those track different consumers. + consumerToPermits.put(consumer, permits - 1); return consumer; } } diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/SystemTopicBasedTopicPoliciesService.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/SystemTopicBasedTopicPoliciesService.java index b056cedcdb87a..56b064fc4a04c 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/SystemTopicBasedTopicPoliciesService.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/SystemTopicBasedTopicPoliciesService.java @@ -24,6 +24,7 @@ import com.github.benmanes.caffeine.cache.Caffeine; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.Sets; +import io.opentelemetry.api.metrics.LongCounter; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -34,7 +35,10 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; @@ -80,10 +84,14 @@ */ public class SystemTopicBasedTopicPoliciesService implements TopicPoliciesService { + public static final String TOPIC_POLICIES_CACHE_INIT_TIMEOUT_METRIC_NAME = + "pulsar.broker.topic.policies.cache.init.timeout.count"; + private final PulsarService pulsarService; private final HashSet localCluster; private final String clusterName; private final AtomicBoolean closed = new AtomicBoolean(false); + private final LongCounter policyCacheInitTimeoutCounter; private final ConcurrentInitializer namespaceEventsSystemTopicFactoryLazyInitializer = new LazyInitializer<>() { @@ -127,6 +135,13 @@ public SystemTopicBasedTopicPoliciesService(PulsarService pulsarService) { this.pulsarService = pulsarService; this.clusterName = pulsarService.getConfiguration().getClusterName(); this.localCluster = Sets.newHashSet(clusterName); + this.policyCacheInitTimeoutCounter = pulsarService.getOpenTelemetry().getMeter() + .counterBuilder(TOPIC_POLICIES_CACHE_INIT_TIMEOUT_METRIC_NAME) + .setDescription("The number of times initializing a namespace's topic policies cache timed out " + + "because the __change_events system-topic reader was stuck. Each occurrence closes the " + + "stuck reader and clears the cached state so topic loading can be retried.") + .setUnit("{timeout}") + .build(); this.writerCaches = Caffeine.newBuilder() .expireAfterAccess(5, TimeUnit.MINUTES) .removalListener((namespaceName, writer, cause) -> { @@ -447,7 +462,7 @@ private CompletableFuture sendTopicPolicyEventInternal(TopicName topi // The cached writer will be closed when an exception happens // This is potentially not a great idea since we should be able to rely on the Pulsar client's // behavior for restoring a Producer after a failure. - writerCaches.synchronous().invalidate(topicName.getNamespaceObject()); + cleanWriterCache(topicName.getNamespaceObject()); throw FutureUtil.wrapToCompletionException(t); }); } @@ -478,16 +493,7 @@ private void notifyListener(Message msg) { if (msg.getValue() == null) { TopicName topicName = TopicName.get(TopicPoliciesService.unwrapEventKey(msg.getKey()) .getPartitionedTopicName()); - List listeners = this.listeners.get(topicName); - if (listeners != null) { - for (TopicPolicyListener listener : listeners) { - try { - listener.onUpdate(null); - } catch (Throwable error) { - log.error("[{}] call listener error.", topicName, error); - } - } - } + notifyListenersForTopic(topicName, null); return; } @@ -497,19 +503,62 @@ private void notifyListener(Message msg) { TopicPoliciesEvent event = msg.getValue().getTopicPoliciesEvent(); TopicName topicName = TopicName.get(event.getDomain(), event.getTenant(), event.getNamespace(), event.getTopic()); - List listeners = this.listeners.get(topicName); - if (listeners != null) { - TopicPolicies policies = event.getPolicies(); - for (TopicPolicyListener listener : listeners) { - try { - listener.onUpdate(policies); - } catch (Throwable error) { - log.error("[{}] call listener error.", topicName, error); - } + notifyListenersForTopic(topicName, event.getPolicies()); + } + + /** + * Notifies the topic-policy listeners registered for {@code topicName} of a policy update. + * + *

The {@link TopicPolicyListener#onUpdate} calls are dispatched to the per-topic ordered executor + * rather than run inline. The reader callbacks that drive notifications (the {@link #initPolicesCache} + * replay loop and {@link #readMorePoliciesAsync}) run on the single, process-wide shared + * {@code broker-client-shared-internal-executor} thread. A listener (e.g. + * {@code PersistentTopic.onUpdate} -> {@code applyUpdatedTopicPolicies}) can perform non-trivial and + * even blocking work, so running it inline serializes and can stall topic-policy loading for every + * namespace (issue #26037). Keying {@code executeOrdered} by {@code topicName} preserves per-topic + * notification ordering. + */ + private void notifyListenersForTopic(TopicName topicName, @Nullable TopicPolicies policies) { + // The per-topic value is a CopyOnWriteArrayList, so iterating it later on the executor thread stays + // safe even if a listener is registered/unregistered between dispatch and execution. + List topicListeners = listeners.get(topicName); + if (topicListeners == null || topicListeners.isEmpty()) { + return; + } + pulsarService.getBrokerService().getTopicPoliciesNotifyThread(topicName).execute(() -> { + internalNotifyTopicListeners(topicName, policies, topicListeners); + }); + } + + // this method should only be called from the topic ordered executor thread + // use notifyListenersForTopic/notifyListenersForTopicAsync instead + private static void internalNotifyTopicListeners(TopicName topicName, @Nullable TopicPolicies policies, + List topicListeners) { + for (TopicPolicyListener listener : topicListeners) { + try { + listener.onUpdate(policies); + } catch (Throwable error) { + log.error("[{}] Error in notifying listener {} on topic policy update.", + topicName, listener, error); } } } + private CompletableFuture notifyListenersForTopicAsync(TopicName topicName, + @Nullable TopicPolicies policies) { + // The per-topic value is a CopyOnWriteArrayList, so iterating it later on the executor thread stays + // safe even if a listener is registered/unregistered between dispatch and execution. + List topicListeners = listeners.get(topicName); + if (topicListeners == null || topicListeners.isEmpty()) { + return CompletableFuture.completedFuture(null); + } + ExecutorService pinnedTopicOrderedExecutor = + pulsarService.getBrokerService().getTopicPoliciesNotifyThread(topicName); + return CompletableFuture.runAsync(() -> { + internalNotifyTopicListeners(topicName, policies, topicListeners); + }, pinnedTopicOrderedExecutor); + } + @Override public CompletableFuture> getTopicPoliciesAsync(TopicName topicName, GetType type) { requireNonNull(topicName); @@ -590,6 +639,12 @@ public void addOwnedNamespaceBundleAsync(NamespaceBundle namespaceBundle) { CompletableFuture existingFuture = policyCacheInitMap.putIfAbsent(namespace, initNamespacePolicyFuture); if (existingFuture == null) { + // Topic loading waits on this future, so a system-topic reader that gets stuck (e.g. after + // __change_events is unloaded and the reconnected reader stops making progress) would pin the + // policy cache for the whole namespace until the broker restarts (issue #25294). Bound the + // initialization so it fails fast and cleans up, letting topic loading retry with a fresh + // reader. + scheduleInitPolicesCacheTimeout(namespace, initNamespacePolicyFuture); final CompletableFuture> readerCompletableFuture = newReader(namespace); readerCompletableFuture @@ -598,20 +653,25 @@ public void addOwnedNamespaceBundleAsync(NamespaceBundle namespaceBundle) { initPolicesCache(reader, stageFuture); return stageFuture // Read policies in background - .thenAccept(__ -> readMorePoliciesAsync(reader)); + .thenAccept(__ -> readMorePoliciesAsync(reader, initNamespacePolicyFuture)); }).thenApply(__ -> { initNamespacePolicyFuture.complete(null); return null; }).exceptionally(ex -> { try { + // Identity-guarded cleanup: a concurrent timeout cleanup or a namespace unload + // may already have dropped this future and let a retry install a fresh + // future/reader, so clean up by namespace key here would clobber that newer + // attempt. Tear down state only while this future still owns the namespace. if (readerCompletableFuture.isCompletedExceptionally()) { log.error("[{}] Failed to create reader on __change_events topic", namespace, ex); initNamespacePolicyFuture.completeExceptionally(ex); - cleanPoliciesCacheInitMap(namespace, true); + cleanupFailedPolicyCacheInit(namespace, initNamespacePolicyFuture, true); } else { initNamespacePolicyFuture.completeExceptionally(ex); - cleanPoliciesCacheInitMap(namespace, isAlreadyClosedException(ex)); + cleanupFailedPolicyCacheInit(namespace, initNamespacePolicyFuture, + isAlreadyClosedException(ex)); } } catch (Throwable cleanupEx) { // Adding this catch to avoid break callback chain @@ -629,13 +689,7 @@ public void addOwnedNamespaceBundleAsync(NamespaceBundle namespaceBundle) { } private CompletableFuture> newReader(NamespaceName ns) { - return readerCaches.compute(ns, (__, existingFuture) -> { - if (existingFuture == null) { - return createSystemTopicClient(ns); - } - - return existingFuture; - }); + return readerCaches.computeIfAbsent(ns, __ -> createSystemTopicClient(ns)); } protected CompletableFuture> createSystemTopicClient( @@ -654,14 +708,14 @@ protected CompletableFuture> createSystemT return systemTopicClient.newReaderAsync(); } - private void removeOwnedNamespaceBundleAsync(NamespaceBundle namespaceBundle) { + void removeOwnedNamespaceBundleAsync(NamespaceBundle namespaceBundle) { NamespaceName namespace = namespaceBundle.getNamespaceObject(); if (NamespaceService.isHeartbeatNamespace(namespace)) { return; } AtomicInteger bundlesCount = ownedBundlesCountPerNamespace.get(namespace); if (bundlesCount == null || bundlesCount.decrementAndGet() <= 0) { - cleanPoliciesCacheInitMap(namespace, true); + cleanPoliciesCacheInitMap(namespace); cleanWriterCache(namespace); cleanOwnedBundlesCount(namespace); } @@ -692,10 +746,99 @@ public boolean test(NamespaceBundle namespaceBundle) { }); } + /** + * Bound the topic-policies cache initialization for {@code namespace} so a stuck {@code __change_events} reader + * cannot pin the cache (and therefore topic loading) for the whole namespace indefinitely (issue #25294). If the + * timeout wins the race to complete {@code initNamespacePolicyFuture}, the cached state is cleared and the stuck + * reader is closed so a subsequent load retries from scratch rather than waiting until the broker restarts. + */ + private void scheduleInitPolicesCacheTimeout(@NonNull NamespaceName namespace, + @NonNull CompletableFuture initNamespacePolicyFuture) { + long timeoutSeconds = pulsarService.getConfiguration().getTopicPoliciesCacheInitTimeoutSeconds(); + if (timeoutSeconds <= 0) { + return; + } + final ScheduledFuture timeoutTask = pulsarService.getExecutor().schedule(() -> { + TimeoutException timeoutException = new TimeoutException(String.format( + "Timed out after %d seconds initializing the topic policies cache for namespace %s; the " + + "__change_events reader did not reach the end of the topic", timeoutSeconds, namespace)); + if (initNamespacePolicyFuture.completeExceptionally(timeoutException)) { + policyCacheInitTimeoutCounter.add(1); + log.error("[{}] Timed out initializing the topic policies cache after {} seconds; closing the stuck " + + "__change_events reader so the namespace can be loaded again", namespace, timeoutSeconds); + try { + cleanupFailedPolicyCacheInit(namespace, initNamespacePolicyFuture, true); + } catch (Throwable cleanupEx) { + log.error("[{}] Failed to clean up the topic policies cache after init timeout", namespace, + cleanupEx); + } + } + }, timeoutSeconds, TimeUnit.SECONDS); + // Cancel the timeout once initialization finishes (successfully or not) so we don't leak scheduled tasks. + initNamespacePolicyFuture.whenComplete((__, ex) -> timeoutTask.cancel(false)); + } + + /** + * Identity-guarded cleanup for an initialization that failed (it timed out, the {@code __change_events} reader + * could not be created, or reading the topic threw), or whose background reader was later closed (an + * {@code AlreadyClosedException} surfaced in {@link #readMorePoliciesAsync} after a namespace unload closed the + * reader). Unlike {@link #cleanPoliciesCacheInitMap}, which + * removes/closes by namespace key unconditionally, this only tears down state that still belongs to + * {@code initFuture}. By the time the failure is observed, a concurrent retry — or a namespace-bundle unload that + * left the init future orphaned — may already own the namespace with a fresh future and reader; removing by key + * would drop that newer future and close its reader, pinning the namespace again. Guarding on identity ensures a + * late failure never clobbers a newer initialization. + * + * @param closeReader when {@code true}, also closes the reader and message-handler tracker that belong to this + * initialization; when {@code false}, only the init future is dropped, leaving the reader + * cached for the retry to reuse. The cached policies are intentionally left in place; they + * are cleared only when the whole namespace is unloaded, so this cleanup cannot race a + * concurrent re-initialization. + */ + @VisibleForTesting + void cleanupFailedPolicyCacheInit(@NonNull NamespaceName namespace, + @NonNull CompletableFuture initFuture, boolean closeReader) { + // Capture the reader before dropping the init future so we only close the reader that belongs to this + // initialization, never one a concurrent retry creates immediately afterwards. + CompletableFuture> readerFuture = + closeReader ? readerCaches.get(namespace) : null; + TopicPolicyMessageHandlerTracker tracker = topicPolicyMessageHandlerTrackers.get(namespace); + + // Identity guard: only proceed while this initialization still owns the namespace's init future. + if (!policyCacheInitMap.remove(namespace, initFuture)) { + // Superseded by a retry or an unload; that owner is responsible for its own reader/state. + return; + } + + // Complete the dropped future (a no-op if the caller already completed it) outside any map remapping function, + // so awaiting topic loads fail fast and retry instead of hanging until the broker restarts (issue #25294). + failPendingPolicyCacheInit(namespace, initFuture); + if (!closeReader) { + return; + } + + // Close the tracker captured above only if it is still the one installed for this namespace, so a + // concurrent re-initialization that installed a newer tracker is left untouched. + if (tracker != null && topicPolicyMessageHandlerTrackers.remove(namespace, tracker)) { + tracker.close(); + } + + // Remove and close the reader captured above only if it is still the current one, so a reader + // created by a later initialization is never closed by this stale cleanup. + if (readerFuture != null && readerCaches.remove(namespace, readerFuture) + && !readerFuture.isCompletedExceptionally()) { + readerFuture.thenCompose(SystemTopicClient.Reader::closeAsync) + .exceptionally(ex -> { + log.warn("[{}] Close change_event reader fail.", namespace, ex); + return null; + }); + } + } + private void initPolicesCache(SystemTopicClient.Reader reader, CompletableFuture future) { if (closed.get()) { future.completeExceptionally(new BrokerServiceException(getClass().getName() + " is closed.")); - cleanPoliciesCacheInitMap(reader.getSystemTopic().getTopicName().getNamespaceObject(), true); + cleanPoliciesCacheInitMap(reader.getSystemTopic().getTopicName().getNamespaceObject()); return; } reader.hasMoreEventsAsync().whenComplete((hasMore, ex) -> { @@ -728,31 +871,47 @@ private void initPolicesCache(SystemTopicClient.Reader reader, Comp log.debug("[{}] Reach the end of the system topic.", reader.getSystemTopic().getTopicName()); } - // replay policy message - policiesCache.forEach(((topicName, topicPolicies) -> { - if (listeners.get(topicName) != null) { - for (TopicPolicyListener listener : listeners.get(topicName)) { - try { - listener.onUpdate(topicPolicies); - } catch (Throwable error) { - log.error("[{}] call listener error.", topicName, error); - } - } - } - })); - - future.complete(null); + // Optionally re-notify the topic-policy listeners for this namespace with the cached policies. + // Topics load their own policies on load (AbstractTopic#initTopicPolicy), so this replay is only + // needed for custom plugins that register TopicPolicyListeners and rely on the broadcast; it is + // off by default (topicPolicyListenerReplayEnabled). + if (pulsarService.getConfiguration().isTopicPolicyListenerReplayEnabled()) { + NamespaceName namespaceObject = reader.getSystemTopic().getTopicName().getNamespaceObject(); + FutureUtil.completeAfter(future, replayTopicPolicyListeners(namespaceObject)); + } else { + future.complete(null); + } } }); } + /** + * Re-notifies the registered topic-policy listeners for every topic in {@code namespace} with the currently + * cached policies (both local and global). Topics apply their own policies on load, so this is only needed for + * custom plugins that register {@link TopicPolicyListener}s and rely on the broadcast when a namespace's policy + * cache finishes loading (see {@code topicPolicyListenerReplayEnabled}). + */ @VisibleForTesting - void cleanPoliciesCacheInitMap(@NonNull NamespaceName namespace, boolean closeReader) { - if (!closeReader) { - policyCacheInitMap.remove(namespace); - return; + CompletableFuture replayTopicPolicyListeners(NamespaceName namespace) { + List> notifyFutures = new ArrayList<>(); + addNamespacePolicyNotifications(policiesCache, namespace, notifyFutures); + addNamespacePolicyNotifications(globalPoliciesCache, namespace, notifyFutures); + return FutureUtil.waitForAll(notifyFutures); + } + + private void addNamespacePolicyNotifications(Map cache, NamespaceName namespace, + List> notifyFutures) { + for (Map.Entry entry : cache.entrySet()) { + if (Objects.equals(entry.getKey().getNamespaceObject(), namespace)) { + notifyFutures.add(notifyListenersForTopicAsync(entry.getKey(), entry.getValue())); + } } + } + // Full teardown of a namespace's topic-policies state: removes and closes the reader, the message-handler + // tracker, the cached policies and the init future. Used when the whole namespace is unloaded. + @VisibleForTesting + void cleanPoliciesCacheInitMap(@NonNull NamespaceName namespace) { TopicPolicyMessageHandlerTracker topicPolicyMessageHandlerTracker = topicPolicyMessageHandlerTrackers.remove(namespace); if (topicPolicyMessageHandlerTracker != null) { @@ -760,12 +919,18 @@ void cleanPoliciesCacheInitMap(@NonNull NamespaceName namespace, boolean closeRe } CompletableFuture> readerFuture = readerCaches.remove(namespace); + MutableObject> removedInitFuture = new MutableObject<>(); policyCacheInitMap.compute(namespace, (k, v) -> { + removedInitFuture.setValue(v); policiesCache.entrySet().removeIf(entry -> Objects.equals(entry.getKey().getNamespaceObject(), namespace)); globalPoliciesCache.entrySet() .removeIf(entry -> Objects.equals(entry.getKey().getNamespaceObject(), namespace)); return null; }); + // Complete the removed init future outside the compute() remapping function: completing it can run the + // awaiting topic-load callbacks synchronously, and doing that while holding the ConcurrentHashMap bin lock + // risks a recursive map update / deadlock (see #24977). + failPendingPolicyCacheInit(namespace, removedInitFuture.getValue()); if (readerFuture != null && !readerFuture.isCompletedExceptionally()) { readerFuture .thenCompose(SystemTopicClient.Reader::closeAsync) @@ -776,6 +941,20 @@ void cleanPoliciesCacheInitMap(@NonNull NamespaceName namespace, boolean closeRe } } + /** + * Complete an init future that is being dropped from {@link #policyCacheInitMap} but never completed, so the + * topic loads awaiting it fail fast and retry instead of hanging until the broker restarts (issue #25294). + * No-op if the future was already completed by its own initialization chain. + */ + private void failPendingPolicyCacheInit(@NonNull NamespaceName namespace, + @Nullable CompletableFuture initFuture) { + if (initFuture != null && !initFuture.isDone()) { + initFuture.completeExceptionally(new BrokerServiceException( + "Topic policies cache initialization for namespace " + namespace + + " was aborted because the cached state was cleared")); + } + } + private void cleanWriterCache(@NonNull NamespaceName namespace) { writerCaches.synchronous().invalidate(namespace); } @@ -784,52 +963,17 @@ private void cleanOwnedBundlesCount(@NonNull NamespaceName namespace) { ownedBundlesCountPerNamespace.remove(namespace); } - - private void cleanCacheAndCloseReader(@NonNull NamespaceName namespace, boolean cleanOwnedBundlesCount, - boolean cleanWriterCache) { - if (cleanWriterCache) { - writerCaches.synchronous().invalidate(namespace); - } - CompletableFuture> readerFuture = readerCaches.remove(namespace); - - TopicPolicyMessageHandlerTracker topicPolicyMessageHandlerTracker = - topicPolicyMessageHandlerTrackers.remove(namespace); - if (topicPolicyMessageHandlerTracker != null) { - topicPolicyMessageHandlerTracker.close(); - } - - if (cleanOwnedBundlesCount) { - ownedBundlesCountPerNamespace.remove(namespace); - } - if (readerFuture != null && !readerFuture.isCompletedExceptionally()) { - readerFuture.thenCompose(SystemTopicClient.Reader::closeAsync) - .exceptionally(ex -> { - log.warn("[{}] Close change_event reader fail.", namespace, ex); - return null; - }); - } - - policyCacheInitMap.compute(namespace, (k, v) -> { - policiesCache.entrySet().removeIf(entry -> Objects.equals(entry.getKey().getNamespaceObject(), namespace)); - globalPoliciesCache.entrySet() - .removeIf(entry -> Objects.equals(entry.getKey().getNamespaceObject(), namespace)); - return null; - }); - } - - - - /** * This is an async method for the background reader to continue syncing new messages. * * Note: You should not do any blocking call here. because it will affect * #{@link SystemTopicBasedTopicPoliciesService#getTopicPoliciesAsync} method to block loading topic. */ - private void readMorePoliciesAsync(SystemTopicClient.Reader reader) { + private void readMorePoliciesAsync(SystemTopicClient.Reader reader, + CompletableFuture initFuture) { NamespaceName namespaceObject = reader.getSystemTopic().getTopicName().getNamespaceObject(); if (closed.get()) { - cleanPoliciesCacheInitMap(namespaceObject, true); + cleanupFailedPolicyCacheInit(namespaceObject, initFuture, true); return; } reader.readNextAsync() @@ -848,15 +992,22 @@ private void readMorePoliciesAsync(SystemTopicClient.Reader reader) }) .whenComplete((__, ex) -> { if (ex == null) { - readMorePoliciesAsync(reader); + readMorePoliciesAsync(reader, initFuture); } else { if (isAlreadyClosedException(ex)) { log.info("Closing the topic policies reader for {}", reader.getSystemTopic().getTopicName()); - cleanPoliciesCacheInitMap(namespaceObject, true); + // Tear down by init-future identity, not by namespace key: this reader may have been + // closed by a namespace unload while a concurrent reload already installed a fresh + // reader and init future for the same namespace (the close only surfaces here, on the + // client executor, afterwards). A namespace-keyed cleanup would clobber that newer + // generation and abort its init with "...aborted because the cached state was cleared", + // failing the reloading topic. cleanupFailedPolicyCacheInit only tears down state that + // still belongs to this initialization, so a superseded reader's late close is a no-op. + cleanupFailedPolicyCacheInit(namespaceObject, initFuture, true); } else { log.warn("Read more topic polices exception, read again.", ex); - readMorePoliciesAsync(reader); + readMorePoliciesAsync(reader, initFuture); } } }); @@ -1040,6 +1191,10 @@ public void close() throws Exception { } }); readerCaches.clear(); + // Release any topic loads still waiting on an in-progress policy cache initialization so they fail + // fast instead of hanging until the awaited future is completed indirectly (issue #25294). + policyCacheInitMap.forEach(this::failPendingPolicyCacheInit); + policyCacheInitMap.clear(); } } diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/TopicLoadingContext.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/TopicLoadingContext.java index 645cf18d8c733..cb9ab3f534074 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/TopicLoadingContext.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/TopicLoadingContext.java @@ -21,19 +21,16 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; +import java.util.concurrent.ConcurrentLinkedQueue; import lombok.Builder; import lombok.Getter; import lombok.Setter; import org.apache.pulsar.common.naming.TopicName; +import org.apache.pulsar.common.util.LatencyTracer; import org.jspecify.annotations.Nullable; -@Builder -public class TopicLoadingContext { +public class TopicLoadingContext extends LatencyTracer { - private static final String EXAMPLE_LATENCY_OUTPUTS = "1234 ms (queued: 567)"; - - private final long startNs = System.nanoTime(); @Getter private final TopicName topicName; @Getter @@ -41,12 +38,10 @@ public class TopicLoadingContext { private boolean createIfMissing; @Getter @Setter - private CompletableFuture> topicFuture; + private CompletableFuture> topicFuture; @Getter @Setter - @Nullable - private Map properties; - private long polledFromQueueNs = -1L; + @Nullable private Map properties; @Getter @Setter @@ -55,21 +50,18 @@ public class TopicLoadingContext { @Setter private String proxyVersion; - public void polledFromQueue() { - polledFromQueueNs = System.nanoTime(); - } - - public long latencyMs(long nowInNanos) { - return TimeUnit.NANOSECONDS.toMillis(nowInNanos - startNs); - } - - public String latencyString(long nowInNanos) { - final var builder = new StringBuilder(EXAMPLE_LATENCY_OUTPUTS.length()); - builder.append(latencyMs(nowInNanos)); - builder.append(" ms"); - if (polledFromQueueNs >= 0) { - builder.append(" (queued: ").append(latencyMs(polledFromQueueNs)).append(")"); - } - return builder.toString(); + @Builder + public TopicLoadingContext(TopicName topicName, boolean createIfMissing, + CompletableFuture> topicFuture, + @Nullable Map properties, String clientVersion, + String proxyVersion) { + // The topic loading could be ended asynchronously by a timeout event, so we need a thread safe queue here + super(new ConcurrentLinkedQueue<>(), System::nanoTime); + this.topicName = topicName; + this.createIfMissing = createIfMissing; + this.topicFuture = topicFuture; + this.properties = properties; + this.clientVersion = clientVersion; + this.proxyVersion = proxyVersion; } } diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/TopicPoliciesService.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/TopicPoliciesService.java index 239c1d3d9bad4..7f49686a17fcf 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/TopicPoliciesService.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/TopicPoliciesService.java @@ -100,6 +100,15 @@ default void start(PulsarService pulsar) { default void close() throws Exception { } + + /** + * @implNote This method is never called unless by the default implementation of + * {@link TopicPoliciesService#registerListenerAsync(TopicName, TopicPolicyListener)}, which is actually called + * internally. This method is only retained for backward compatibility on custom implementations. + */ + @Deprecated + boolean registerListener(TopicName topicName, TopicPolicyListener listener); + /** * Registers a listener for topic policies updates. * @@ -110,10 +119,10 @@ default void close() throws Exception { * guaranteed to be received by the listener. * In summary, the listener is guaranteed to receive only the latest value. *

- * - * @return true if the listener is registered successfully */ - boolean registerListener(TopicName topicName, TopicPolicyListener listener); + default CompletableFuture registerListenerAsync(TopicName topicName, TopicPolicyListener listener) { + return CompletableFuture.completedFuture(registerListener(topicName, listener)); + } /** * Unregister the topic policies listener. diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/TopicPolicyListenerWrapper.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/TopicPolicyListenerWrapper.java new file mode 100644 index 0000000000000..e65acd7741b62 --- /dev/null +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/TopicPolicyListenerWrapper.java @@ -0,0 +1,167 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.pulsar.broker.service; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import lombok.extern.slf4j.Slf4j; +import org.apache.pulsar.common.policies.data.TopicPolicies; + +/** + * This TopicPolicyListener is used as a wrapper for the real TopicPolicyListener. + * This prevents a race condition in initialization where the topic policy state can change while the topic policy + * state is being applied to the topic in AbstractTopic#initTopicPolicy(). The impact of the race condition is that the + * topic policy state would be left inconsistent until another update arrives. This is a rare corner case, but possible. + * + *

Updates received while initializing are buffered (only the latest per scope is kept) and applied by + * {@link #completeInitialization}; updates received afterwards are forwarded immediately. The wrapper is reusable so + * that AbstractTopic#initTopicPolicy() can be run again -- for example to retry it, which this enables but does not + * implement. {@link #startInitialization()} begins a new buffering phase and initialization completes at most once per + * phase. Concurrent initialization phases are not supported; {@code initTopicPolicy} runs are serialized by the caller. + */ +@Slf4j +public class TopicPolicyListenerWrapper implements TopicPolicyListener { + private final TopicPolicyListener realTopicListener; + // The latest value received during initialization, per scope. A null reference means no update was + // received during initialization (the loaded value should be used); an Optional that is present holds the + // received policies, and an empty Optional records that a delete (onUpdate(null)) was received, so the + // loaded value must not be applied. Optional is used because the map-like field cannot itself hold null + // while still distinguishing "not received" (null) from "received a delete" (Optional.empty()). + private Optional latestGlobalPolicies; + private Optional latestLocalPolicies; + private boolean initialized; + // Timestamp when the current initialization phase started, set by startInitialization(). Used only to warn if the + // phase takes too long (i.e. completeInitialization was never called after policy loading started). + private long initializationStartedNanos; + private static final long INITIALIZATION_WARNING_LOG_INTERVAL_NANOS = TimeUnit.SECONDS.toNanos(30); + private int lastIntervalLogged; + + public TopicPolicyListenerWrapper(TopicPolicyListener realTopicListener) { + this.realTopicListener = realTopicListener; + startInitialization(); + } + + /** + * Starts (or restarts) the initialization phase: {@link #onUpdate} buffers updates (keeping only the latest per + * scope) instead of forwarding them, until {@link #completeInitialization} applies them. Called at the start of + * every {@code initTopicPolicy} run so the method can be re-run cleanly (which is what would let a future change + * retry it; no retry is implemented here). Runs before the listener is registered, so no update can arrive before + * the phase (and its warning timer) has started. + */ + public synchronized void startInitialization() { + initialized = false; + latestGlobalPolicies = null; + latestLocalPolicies = null; + initializationStartedNanos = System.nanoTime(); + } + + @Override + public synchronized void onUpdate(TopicPolicies data) { + if (initialized) { + realTopicListener.onUpdate(data); + return; + } + + maybeLogWarning(); + + // Record the latest value received during initialization so it can be applied (preferring it over the + // loaded value) in completeInitialization. A received value is stored as Optional.of(data) and a delete + // as Optional.empty(), so the delete is propagated downstream instead of being lost. + if (data == null) { + // A delete (onUpdate(null)) does not carry the global/local scope through the listener interface, + // so record it for both scopes; a later scoped update received during initialization still + // overrides its own scope. + latestGlobalPolicies = Optional.empty(); + latestLocalPolicies = Optional.empty(); + } else if (data.isGlobalPolicies()) { + latestGlobalPolicies = Optional.of(data); + } else { + latestLocalPolicies = Optional.of(data); + } + } + + /** + * Complete initialization of the TopicPolicyListenerWrapper and emit the latest policies to the real listener. + * + * @param loadedGlobalPolicies the loaded global policies + * @param loadedLocalPolicies the loaded local policies + */ + public synchronized void completeInitialization(TopicPolicies loadedGlobalPolicies, + TopicPolicies loadedLocalPolicies) { + // Idempotent: an initialization phase completes at most once. initTopicPolicy runs a terminal + // completeInitializationUnlessAlreadyCompleted() after applying the loaded policies, so a later call must be a + // no-op and must not re-emit policies. A new phase is started explicitly via startInitialization(). + if (initialized) { + return; + } + + // The listener might have received a newer value (or a delete) than the loaded one while the loading + // was happening; prefer the latest value received during initialization, falling back to the loaded + // value only when nothing was received for that scope. + // + // Emit the local policy before the global policy. A local topic policy takes precedence over a global one, + // so applying the local value first means that by the time the global value is applied the local override is + // already in place and the merged (local-wins) result is what takes effect. Emitting the global value first + // would briefly apply it on its own and let a global-only setting act before the local policy overrides it -- + // e.g. a compaction subscription being created for a global compaction policy even though the local policy + // disables compaction. This does not fully solve such ordering hazards, but it removes them whenever a local + // policy exists. When no local policy exists nothing is emitted for the local scope (see emitInitialPolicies), + // so this ordering does not change behavior for topics that only have a global policy. + emitInitialPolicies(latestLocalPolicies, loadedLocalPolicies); + emitInitialPolicies(latestGlobalPolicies, loadedGlobalPolicies); + + latestGlobalPolicies = null; + latestLocalPolicies = null; + initialized = true; + } + + /** + * Completes initialization with no loaded policies, unless it has already completed. Used as a safety net at the + * end of {@code initTopicPolicy} so the wrapper always leaves the buffering phase -- even when the listener was not + * registered or policy loading failed -- and therefore stops dropping updates: it emits any buffered value and + * forwards all future live updates. A no-op once initialization has completed (e.g. with loaded policies). + */ + public synchronized void completeInitializationUnlessAlreadyCompleted() { + completeInitialization(null, null); + } + + private void emitInitialPolicies(Optional latestReceived, TopicPolicies loaded) { + if (latestReceived != null) { + // A value (or a delete) was received during initialization; it supersedes the loaded value. + realTopicListener.onUpdate(latestReceived.orElse(null)); + } else if (loaded != null) { + realTopicListener.onUpdate(loaded); + } + } + + // warn if the initialization takes too long and updates have been received + // this helps detect issues where completeInitialization didn't get called after loading policies + private void maybeLogWarning() { + long durationNanos = System.nanoTime() - initializationStartedNanos; + int warningLogIntervalCount = (int) (durationNanos / INITIALIZATION_WARNING_LOG_INTERVAL_NANOS); + if (warningLogIntervalCount > lastIntervalLogged) { + log.warn("TopicPolicyUpdate buffered. TopicPolicyListenerWrapper initialization phase took too long. " + + "completeInitialization should have been called to complete the phase. " + + "topicPolicyListener={} sinceInitializationStartedMs={}", + realTopicListener, TimeUnit.NANOSECONDS.toMillis(durationNanos)); + lastIntervalLogged = warningLogIntervalCount; + } + } +} diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/nonpersistent/NonPersistentReplicator.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/nonpersistent/NonPersistentReplicator.java index 38e1894c17854..0d66bddd378c6 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/nonpersistent/NonPersistentReplicator.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/nonpersistent/NonPersistentReplicator.java @@ -68,6 +68,11 @@ protected String getProducerName() { return getReplicatorName(replicatorPrefix, localCluster) + REPL_PRODUCER_NAME_DELIMITER + remoteCluster; } + @Override + public void startProducer() { + super.startProducer(); + } + @Override protected void setProducerAndTriggerReadEntries(Producer producer) { this.producer = (ProducerImpl) producer; @@ -86,6 +91,7 @@ protected void setProducerAndTriggerReadEntries(Producer producer) { } public void sendMessage(Entry entry) { + latestPublishTime = System.currentTimeMillis(); if ((STATE_UPDATER.get(this) == State.Started) && isWritable()) { int length = entry.getLength(); diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/nonpersistent/NonPersistentTopic.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/nonpersistent/NonPersistentTopic.java index d01375dfc8c30..fa617aea9f557 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/nonpersistent/NonPersistentTopic.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/nonpersistent/NonPersistentTopic.java @@ -80,7 +80,6 @@ import org.apache.pulsar.common.naming.SystemTopicNames; import org.apache.pulsar.common.naming.TopicName; import org.apache.pulsar.common.policies.data.BacklogQuota; -import org.apache.pulsar.common.policies.data.ClusterPolicies.ClusterUrl; import org.apache.pulsar.common.policies.data.ManagedLedgerInternalStats.CursorStats; import org.apache.pulsar.common.policies.data.PersistentTopicInternalStats; import org.apache.pulsar.common.policies.data.Policies; @@ -153,7 +152,6 @@ public void reset() { public NonPersistentTopic(String topic, BrokerService brokerService) { super(topic, brokerService); this.isFenced = false; - registerTopicPolicyListener(); } private CompletableFuture updateClusterMigrated() { @@ -164,7 +162,7 @@ private CompletableFuture updateClusterMigrated() { public CompletableFuture initialize() { return brokerService.pulsar().getPulsarResources().getNamespaceResources() .getPoliciesAsync(TopicName.get(topic).getNamespaceObject()) - .thenCompose(optPolicies -> { + .thenComposeAsync(optPolicies -> { final Policies policies; if (optPolicies.isEmpty()) { log.warn("[{}] Policies not present and isEncryptionRequired will be set to false", topic); @@ -179,6 +177,15 @@ public CompletableFuture initialize() { updatePublishRateLimiter(); updateResourceGroupLimiter(); return updateClusterMigrated(); + }, getPoliciesNotifyThread()) + // Load the topic's initial policies (global and local) and register the policy listener, so a + // non-persistent topic applies its own policies on load, the same as a persistent topic does. + .thenCompose(ignore -> initTopicPolicy()) + // a failure to load the initial topic policies must not fail topic loading. + .exceptionally(ex -> { + log.warn("[{}] Error loading topic policies during initialization. Ignoring the failure.", + topic, ex); + return null; }); } @@ -326,37 +333,47 @@ private CompletableFuture internalSubscribe(final TransportCnx cnx, St Consumer consumer = new Consumer(subscription, subType, topic, consumerId, priorityLevel, consumerName, false, cnx, cnx.getAuthRole(), metadata, readCompacted, keySharedMeta, MessageId.latest, DEFAULT_CONSUMER_EPOCH, schemaType); - if (isMigrated()) { - consumer.topicMigrated(getMigratedClusterUrl()); - } + consumer.checkAndApplyTopicMigrationAsync().thenCompose(migrated -> { + if (migrated) { + // The topic is migrated: checkAndApplyTopicMigrationAsync() has already sent the + // TopicMigrated redirect and disconnected the consumer (which also released the usage + // count taken by handleConsumerAdded above). Skip addConsumerToSubscription so the consumer + // is never attached to the subscription on the old cluster. Sequencing the migration check + // through thenCompose (instead of the previous fire-and-forget thenAccept that raced with + // addConsumerToSubscription) is what guarantees the consumer is not added once migrated. + log.info("[{}][{}] Skipped subscription on migrated topic; consumer {} was redirected", + topic, subscriptionName, consumerId); + future.complete(consumer); + return CompletableFuture.completedFuture(null); + } + return addConsumerToSubscription(subscription, consumer).thenRun(() -> { + if (!cnx.isActive()) { + try { + consumer.close(); + } catch (BrokerServiceException e) { + if (e instanceof ConsumerBusyException) { + log.warn("[{}][{}] Consumer {} {} already connected", topic, subscriptionName, + consumerId, consumerName); + } else if (e instanceof SubscriptionBusyException) { + log.warn("[{}][{}] {}", topic, subscriptionName, e.getMessage()); + } - addConsumerToSubscription(subscription, consumer).thenRun(() -> { - if (!cnx.isActive()) { - try { - consumer.close(); - } catch (BrokerServiceException e) { - if (e instanceof ConsumerBusyException) { - log.warn("[{}][{}] Consumer {} {} already connected", topic, subscriptionName, consumerId, - consumerName); - } else if (e instanceof SubscriptionBusyException) { - log.warn("[{}][{}] {}", topic, subscriptionName, e.getMessage()); + decrementUsageCount(); + future.completeExceptionally(e); + return; } - - decrementUsageCount(); - future.completeExceptionally(e); - return; - } - if (log.isDebugEnabled()) { - log.debug("[{}] [{}] [{}] Subscribe failed -- count: {}", topic, subscriptionName, - consumer.consumerName(), currentUsageCount()); + if (log.isDebugEnabled()) { + log.debug("[{}] [{}] [{}] Subscribe failed -- count: {}", topic, subscriptionName, + consumer.consumerName(), currentUsageCount()); + } + future.completeExceptionally( + new BrokerServiceException.ConnectionClosedException( + "Connection was closed while the opening the cursor ")); + } else { + log.info("[{}][{}] Created new subscription for {}", topic, subscriptionName, consumerId); + future.complete(consumer); } - future.completeExceptionally( - new BrokerServiceException.ConnectionClosedException( - "Connection was closed while the opening the cursor ")); - } else { - log.info("[{}][{}] Created new subscription for {}", topic, subscriptionName, consumerId); - future.complete(consumer); - } + }); }).exceptionally(e -> { Throwable throwable = e.getCause(); if (throwable instanceof ConsumerBusyException) { @@ -1006,20 +1023,21 @@ public CompletableFuture checkClusterMigration() { return CompletableFuture.completedFuture(null); } - Optional url = getMigratedClusterUrl(); - if (url.isPresent()) { - this.migrated = true; - producers.forEach((__, producer) -> { - producer.topicMigrated(url); - }); - subscriptions.forEach((__, sub) -> { - sub.getConsumers().forEach((consumer) -> { - consumer.topicMigrated(url); + return getMigratedClusterUrlAsync().thenCompose(url -> { + if (url.isPresent()) { + this.migrated = true; + producers.forEach((__, producer) -> { + producer.topicMigrated(url); }); - }); - return disconnectReplicators().thenCompose(__ -> checkAndUnsubscribeSubscriptions()); - } - return CompletableFuture.completedFuture(null); + subscriptions.forEach((__, sub) -> { + sub.getConsumers().forEach((consumer) -> { + consumer.topicMigrated(url); + }); + }); + return disconnectReplicators().thenCompose(__ -> checkAndUnsubscribeSubscriptions()); + } + return CompletableFuture.completedFuture(null); + }); } private CompletableFuture checkAndUnsubscribeSubscriptions() { @@ -1299,4 +1317,5 @@ public TopicAttributes getTopicAttributes() { return TOPIC_ATTRIBUTES_FIELD_UPDATER.updateAndGet(this, old -> old != null ? old : new TopicAttributes(TopicName.get(topic))); } + } diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/AbstractPersistentDispatcherMultipleConsumers.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/AbstractPersistentDispatcherMultipleConsumers.java index 79d365b9fee21..3a63dc6594704 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/AbstractPersistentDispatcherMultipleConsumers.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/AbstractPersistentDispatcherMultipleConsumers.java @@ -18,8 +18,10 @@ */ package org.apache.pulsar.broker.service.persistent; +import java.util.List; import java.util.Map; import org.apache.bookkeeper.mledger.AsyncCallbacks; +import org.apache.bookkeeper.mledger.Entry; import org.apache.bookkeeper.mledger.ManagedCursor; import org.apache.pulsar.broker.ServiceConfiguration; import org.apache.pulsar.broker.service.AbstractDispatcherMultipleConsumers; @@ -64,4 +66,12 @@ public AbstractPersistentDispatcherMultipleConsumers(Subscription subscription, public abstract Map getBucketDelayedIndexStats(); public abstract boolean isClassic(); + + static long getTotalBytesSize(List entries) { + long totalBytesSize = 0; + for (int i = 0, entriesSize = entries.size(); i < entriesSize; i++) { + totalBytesSize += entries.get(i).getLength(); + } + return totalBytesSize; + } } diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/GeoPersistentReplicator.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/GeoPersistentReplicator.java index 49260f9b6a06a..1c2fa73d1082c 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/GeoPersistentReplicator.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/GeoPersistentReplicator.java @@ -89,8 +89,7 @@ protected boolean replicateEntries(List entries, final InFlightTask inFli try { // This flag is set to true when we skip at least one local message, // in order to skip remaining local messages. - boolean isLocalMessageSkippedOnce = false; - boolean skipRemainingMessages = inFlightTask.isSkipReadResultDueToCursorRewind(); + boolean skipRemainingMessages = false; for (int i = 0; i < entries.size(); i++) { Entry entry = entries.get(i); // Skip the messages since the replicator need to fetch the schema info to replicate the schema to the @@ -171,14 +170,14 @@ protected boolean replicateEntries(List entries, final InFlightTask inFli continue; } - if (STATE_UPDATER.get(this) != State.Started || isLocalMessageSkippedOnce) { + if (STATE_UPDATER.get(this) != State.Started || inFlightTask.isSkipReadResultDueToCursorRewind()) { // The producer is not ready yet after having stopped/restarted. Drop the message because it will // recover when the producer is ready if (log.isDebugEnabled()) { log.debug("[{}] Dropping read message at {} because producer is not ready", replicatorId, entry.getPosition()); } - isLocalMessageSkippedOnce = true; + skipRemainingMessages = true; inFlightTask.incCompletedEntries(); entry.release(); msg.recycle(); @@ -199,9 +198,9 @@ protected boolean replicateEntries(List entries, final InFlightTask inFli * Explain the result of the race-condition between: * - {@link #readMoreEntries} * - {@link #beforeTerminateOrCursorRewinding(ReasonOfWaitForCursorRewinding)} - * Since {@link #acquirePermitsIfNotFetchingSchema} and - * {@link #beforeTerminateOrCursorRewinding(ReasonOfWaitForCursorRewinding)} acquire the - * same lock, it is safe. + * Since the read scheduling path in {@link #readMoreEntries()} and + * {@link #beforeTerminateOrCursorRewinding(ReasonOfWaitForCursorRewinding)} update in-flight + * read state under the same lock, it is safe. */ beforeTerminateOrCursorRewinding(ReasonOfWaitForCursorRewinding.Fetching_Schema); inFlightTask.incCompletedEntries(); diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/MessageRedeliveryController.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/MessageRedeliveryController.java index 46f1f0a535650..9e5ebcf38f432 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/MessageRedeliveryController.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/MessageRedeliveryController.java @@ -19,6 +19,7 @@ package org.apache.pulsar.broker.service.persistent; import static org.apache.pulsar.broker.service.StickyKeyConsumerSelector.STICKY_KEY_HASH_NOT_SET; +import com.google.common.annotations.VisibleForTesting; import java.util.ArrayList; import java.util.List; import java.util.NavigableSet; @@ -43,7 +44,11 @@ public class MessageRedeliveryController { private final boolean allowOutOfOrderDelivery; private final boolean isClassicDispatcher; private final ConcurrentBitmapSortedLongPairSet messagesToRedeliver; - private final ConcurrentLongLongPairHashMap hashesToBeBlocked; + // Not final: under out-of-order delivery, whether this map is ever needed isn't known at construction time. Only a + // Key_Shared dispatcher records hashes; a plain Shared dispatcher never does. The map is therefore allocated when + // add() first receives a real hash. Classic out-of-order returns before that allocation regardless of the hash. + private ConcurrentLongLongPairHashMap positionToStickyKeyHash; + // Final by contrast: this map is needed exactly when ordering is enforced, which is known at construction time. private final ConcurrentLongLongHashMap hashesRefCount; public MessageRedeliveryController(boolean allowOutOfOrderDelivery) { @@ -55,16 +60,27 @@ public MessageRedeliveryController(boolean allowOutOfOrderDelivery, boolean isCl this.isClassicDispatcher = isClassicDispatcher; this.messagesToRedeliver = new ConcurrentBitmapSortedLongPairSet(); if (!allowOutOfOrderDelivery) { - this.hashesToBeBlocked = ConcurrentLongLongPairHashMap - .newBuilder().concurrencyLevel(2).expectedItems(128).autoShrink(true).build(); + this.positionToStickyKeyHash = newPositionToStickyKeyHashMap(); this.hashesRefCount = ConcurrentLongLongHashMap .newBuilder().concurrencyLevel(2).expectedItems(128).autoShrink(true).build(); } else { - this.hashesToBeBlocked = null; + this.positionToStickyKeyHash = null; this.hashesRefCount = null; } } + private static ConcurrentLongLongPairHashMap newPositionToStickyKeyHashMap() { + return ConcurrentLongLongPairHashMap.newBuilder() + .concurrencyLevel(2).expectedItems(128).autoShrink(true).build(); + } + + private ConcurrentLongLongPairHashMap ensurePositionToStickyKeyHashMap() { + if (positionToStickyKeyHash == null) { + positionToStickyKeyHash = newPositionToStickyKeyHashMap(); + } + return positionToStickyKeyHash; + } + public void add(long ledgerId, long entryId) { messagesToRedeliver.add(ledgerId, entryId); } @@ -74,30 +90,38 @@ public void add(long ledgerId, long entryId, long stickyKeyHash) { if (!isClassicDispatcher && stickyKeyHash == STICKY_KEY_HASH_NOT_SET) { throw new IllegalArgumentException("Sticky key hash is not set. It is required."); } - boolean inserted = hashesToBeBlocked.putIfAbsent(ledgerId, entryId, stickyKeyHash, 0); - if (!inserted) { - hashesToBeBlocked.put(ledgerId, entryId, stickyKeyHash, 0); - } else { - // Return -1 means the key was not present - long stored = hashesRefCount.get(stickyKeyHash); - hashesRefCount.put(stickyKeyHash, stored > 0 ? ++stored : 1); - } + } else if (isClassicDispatcher || stickyKeyHash == STICKY_KEY_HASH_NOT_SET) { + // Classic out-of-order dispatchers never read position hashes. Non-classic dispatchers normalize real + // sticky-key hashes away from the sentinel, so the sentinel denotes a replay position without a known hash. + messagesToRedeliver.add(ledgerId, entryId); + return; + } + ConcurrentLongLongPairHashMap positionToStickyKeyHash = ensurePositionToStickyKeyHashMap(); + boolean inserted = positionToStickyKeyHash.putIfAbsent(ledgerId, entryId, stickyKeyHash, 0); + if (!inserted) { + positionToStickyKeyHash.put(ledgerId, entryId, stickyKeyHash, 0); + } else if (!allowOutOfOrderDelivery) { + // Return -1 means the key was not present + long stored = hashesRefCount.get(stickyKeyHash); + hashesRefCount.put(stickyKeyHash, stored > 0 ? ++stored : 1); } messagesToRedeliver.add(ledgerId, entryId); } public void remove(long ledgerId, long entryId) { - if (!allowOutOfOrderDelivery) { - removeFromHashBlocker(ledgerId, entryId); - } + removeFromStickyKeyHash(ledgerId, entryId); messagesToRedeliver.remove(ledgerId, entryId); } - private void removeFromHashBlocker(long ledgerId, long entryId) { - LongPair value = hashesToBeBlocked.get(ledgerId, entryId); + private void removeFromStickyKeyHash(long ledgerId, long entryId) { + ConcurrentLongLongPairHashMap positionToStickyKeyHash = this.positionToStickyKeyHash; + if (positionToStickyKeyHash == null) { + return; + } + LongPair value = positionToStickyKeyHash.get(ledgerId, entryId); if (value != null) { - boolean removed = hashesToBeBlocked.remove(ledgerId, entryId, value.first, 0); - if (removed) { + boolean removed = positionToStickyKeyHash.remove(ledgerId, entryId, value.first, 0); + if (removed && !allowOutOfOrderDelivery) { long exists = hashesRefCount.get(value.first); if (exists == 1) { hashesRefCount.remove(value.first, exists); @@ -109,7 +133,11 @@ private void removeFromHashBlocker(long ledgerId, long entryId) { } public Long getHash(long ledgerId, long entryId) { - LongPair value = hashesToBeBlocked.get(ledgerId, entryId); + ConcurrentLongLongPairHashMap positionToStickyKeyHash = this.positionToStickyKeyHash; + if (positionToStickyKeyHash == null) { + return null; + } + LongPair value = positionToStickyKeyHash.get(ledgerId, entryId); if (value == null) { return null; } @@ -118,17 +146,18 @@ public Long getHash(long ledgerId, long entryId) { public void removeAllUpTo(long markDeleteLedgerId, long markDeleteEntryId) { boolean bitsCleared = messagesToRedeliver.removeUpTo(markDeleteLedgerId, markDeleteEntryId + 1); - // only if bits have been clear, and we are not allowing out of order delivery, we need to remove the hashes - // removing hashes is a relatively expensive operation, so we should only do it when necessary - if (bitsCleared && !allowOutOfOrderDelivery) { + // Only remove the hashes when bits have been cleared. Removing hashes is a relatively expensive operation, + // so we should only do it when necessary. + ConcurrentLongLongPairHashMap positionToStickyKeyHash = this.positionToStickyKeyHash; + if (bitsCleared && positionToStickyKeyHash != null && !positionToStickyKeyHash.isEmpty()) { List keysToRemove = new ArrayList<>(); - hashesToBeBlocked.forEach((ledgerId, entryId, stickyKeyHash, none) -> { + positionToStickyKeyHash.forEach((ledgerId, entryId, stickyKeyHash, none) -> { if (ledgerId < markDeleteLedgerId || (ledgerId == markDeleteLedgerId && entryId <= markDeleteEntryId)) { keysToRemove.add(new LongPair(ledgerId, entryId)); } }); for (LongPair longPair : keysToRemove) { - removeFromHashBlocker(longPair.first, longPair.second); + removeFromStickyKeyHash(longPair.first, longPair.second); } } } @@ -138,8 +167,10 @@ public boolean isEmpty() { } public void clear() { + if (positionToStickyKeyHash != null) { + positionToStickyKeyHash.clear(); + } if (!allowOutOfOrderDelivery) { - hashesToBeBlocked.clear(); hashesRefCount.clear(); } messagesToRedeliver.clear(); @@ -197,4 +228,9 @@ public NavigableSet getMessagesToReplayNow(int maxMessagesToRead, Pred public int size() { return messagesToRedeliver.size(); } + + @VisibleForTesting + boolean isPositionToStickyKeyHashInitialized() { + return positionToStickyKeyHash != null; + } } diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentDispatcherMultipleConsumers.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentDispatcherMultipleConsumers.java index da6fc52b91638..04d362a93f183 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentDispatcherMultipleConsumers.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentDispatcherMultipleConsumers.java @@ -91,7 +91,7 @@ public class PersistentDispatcherMultipleConsumers extends AbstractPersistentDis protected final MessageRedeliveryController redeliveryMessages; protected final RedeliveryTracker redeliveryTracker; - private Optional delayedDeliveryTracker = Optional.empty(); + private volatile Optional delayedDeliveryTracker = Optional.empty(); protected volatile boolean havePendingRead = false; protected volatile boolean havePendingReplayRead = false; @@ -621,11 +621,13 @@ public CompletableFuture close(boolean disconnectConsumers, this.delayedDeliveryTracker = Optional.empty(); } - delayedDeliveryTracker.ifPresent(DelayedDeliveryTracker::close); + CompletableFuture closeTrackerFuture = delayedDeliveryTracker + .map(DelayedDeliveryTracker::closeAsync) + .orElseGet(() -> CompletableFuture.completedFuture(null)); dispatchRateLimiter.ifPresent(DispatchRateLimiter::close); - return disconnectConsumers - ? disconnectAllConsumers(false, assignedBrokerLookupData) : CompletableFuture.completedFuture(null); + return closeTrackerFuture.thenCompose(__ -> disconnectConsumers + ? disconnectAllConsumers(false, assignedBrokerLookupData) : CompletableFuture.completedFuture(null)); } @Override @@ -705,7 +707,7 @@ public final synchronized void readEntriesComplete(List entries, Object c log.debug("[{}] Distributing {} messages to {} consumers", name, entries.size(), consumerList.size()); } - long totalBytesSize = entries.stream().mapToLong(Entry::getLength).sum(); + long totalBytesSize = getTotalBytesSize(entries); updatePendingBytesToDispatch(totalBytesSize); // dispatch messages to a separate thread, but still in order for this subscription @@ -1361,13 +1363,12 @@ protected boolean isNormalReadAllowed() { } - - protected synchronized boolean shouldPauseDeliveryForDelayTracker() { - return delayedDeliveryTracker.isPresent() && delayedDeliveryTracker.get().shouldPauseAllDeliveries(); + protected boolean shouldPauseDeliveryForDelayTracker() { + return delayedDeliveryTracker.map(DelayedDeliveryTracker::shouldPauseAllDeliveries).orElse(false); } @Override - public synchronized long getNumberOfDelayedMessages() { + public long getNumberOfDelayedMessages() { return delayedDeliveryTracker.map(DelayedDeliveryTracker::getNumberOfDelayedMessages).orElse(0L); } @@ -1451,20 +1452,15 @@ public PersistentTopic getTopic() { } - public synchronized long getDelayedTrackerMemoryUsage() { + public long getDelayedTrackerMemoryUsage() { return delayedDeliveryTracker.map(DelayedDeliveryTracker::getBufferMemoryUsage).orElse(0L); } - public synchronized Map getBucketDelayedIndexStats() { - if (delayedDeliveryTracker.isEmpty()) { - return Collections.emptyMap(); - } - - if (delayedDeliveryTracker.get() instanceof BucketDelayedDeliveryTracker) { - return ((BucketDelayedDeliveryTracker) delayedDeliveryTracker.get()).genTopicMetricMap(); - } - - return Collections.emptyMap(); + public Map getBucketDelayedIndexStats() { + return delayedDeliveryTracker + .filter(BucketDelayedDeliveryTracker.class::isInstance) + .map(tracker -> ((BucketDelayedDeliveryTracker) tracker).genTopicMetricMap()) + .orElse(Collections.emptyMap()); } @Override diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentDispatcherMultipleConsumersClassic.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentDispatcherMultipleConsumersClassic.java index e87b789d8b9f4..5f62f5a2823e5 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentDispatcherMultipleConsumersClassic.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentDispatcherMultipleConsumersClassic.java @@ -92,7 +92,7 @@ public class PersistentDispatcherMultipleConsumersClassic extends AbstractPersis protected final MessageRedeliveryController redeliveryMessages; protected final RedeliveryTracker redeliveryTracker; - private Optional delayedDeliveryTracker = Optional.empty(); + private volatile Optional delayedDeliveryTracker = Optional.empty(); protected volatile boolean havePendingRead = false; protected volatile boolean havePendingReplayRead = false; @@ -534,11 +534,13 @@ public CompletableFuture close(boolean disconnectConsumers, this.delayedDeliveryTracker = Optional.empty(); } - delayedDeliveryTracker.ifPresent(DelayedDeliveryTracker::close); + CompletableFuture closeTrackerFuture = delayedDeliveryTracker + .map(DelayedDeliveryTracker::closeAsync) + .orElseGet(() -> CompletableFuture.completedFuture(null)); dispatchRateLimiter.ifPresent(DispatchRateLimiter::close); - return disconnectConsumers - ? disconnectAllConsumers(false, assignedBrokerLookupData) : CompletableFuture.completedFuture(null); + return closeTrackerFuture.thenCompose(__ -> disconnectConsumers + ? disconnectAllConsumers(false, assignedBrokerLookupData) : CompletableFuture.completedFuture(null)); } @Override @@ -618,7 +620,7 @@ public final synchronized void readEntriesComplete(List entries, Object c log.debug("[{}] Distributing {} messages to {} consumers", name, entries.size(), consumerList.size()); } - long size = entries.stream().mapToLong(Entry::getLength).sum(); + long size = getTotalBytesSize(entries); updatePendingBytesToDispatch(size); // dispatch messages to a separate thread, but still in order for this subscription @@ -1198,12 +1200,12 @@ protected boolean hasConsumersNeededNormalRead() { return true; } - protected synchronized boolean shouldPauseDeliveryForDelayTracker() { - return delayedDeliveryTracker.isPresent() && delayedDeliveryTracker.get().shouldPauseAllDeliveries(); + protected boolean shouldPauseDeliveryForDelayTracker() { + return delayedDeliveryTracker.map(DelayedDeliveryTracker::shouldPauseAllDeliveries).orElse(false); } @Override - public synchronized long getNumberOfDelayedMessages() { + public long getNumberOfDelayedMessages() { return delayedDeliveryTracker.map(DelayedDeliveryTracker::getNumberOfDelayedMessages).orElse(0L); } @@ -1281,21 +1283,15 @@ public PersistentTopic getTopic() { return topic; } - - public synchronized long getDelayedTrackerMemoryUsage() { + public long getDelayedTrackerMemoryUsage() { return delayedDeliveryTracker.map(DelayedDeliveryTracker::getBufferMemoryUsage).orElse(0L); } - public synchronized Map getBucketDelayedIndexStats() { - if (delayedDeliveryTracker.isEmpty()) { - return Collections.emptyMap(); - } - - if (delayedDeliveryTracker.get() instanceof BucketDelayedDeliveryTracker) { - return ((BucketDelayedDeliveryTracker) delayedDeliveryTracker.get()).genTopicMetricMap(); - } - - return Collections.emptyMap(); + public Map getBucketDelayedIndexStats() { + return delayedDeliveryTracker + .filter(BucketDelayedDeliveryTracker.class::isInstance) + .map(tracker -> ((BucketDelayedDeliveryTracker) tracker).genTopicMetricMap()) + .orElse(Collections.emptyMap()); } @Override diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentDispatcherSingleActiveConsumer.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentDispatcherSingleActiveConsumer.java index 0e568aa41c492..e48db73db4f31 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentDispatcherSingleActiveConsumer.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentDispatcherSingleActiveConsumer.java @@ -71,6 +71,11 @@ public class PersistentDispatcherSingleActiveConsumer extends AbstractDispatcher private Optional dispatchRateLimiter = Optional.empty(); protected volatile boolean havePendingRead = false; + // Monotonic identity of the read that havePendingRead currently refers to. Bumped under this monitor + // whenever readMoreEntries issues a read; a completion mutates dispatcher state only when its captured + // epoch still matches, so a completion whose read was disowned by a redeliver-driven rewind + re-arm + // is ignored instead of clearing havePendingRead for the newer outstanding read. Guarded by "this". + private long readOpEpoch = 0L; protected volatile int readBatchSize; protected final Backoff readFailureBackoff; @@ -153,7 +158,21 @@ protected void cancelPendingRead() { } } - private synchronized void readEntriesComplete(List entries, Consumer readConsumer, long epoch) { + private synchronized void readEntriesComplete(List entries, Consumer readConsumer, long epoch, + long readOpEpoch) { + if (readOpEpoch != this.readOpEpoch) { + // Stale completion: this read was disowned by a redeliver-driven cursor rewind + re-arm + // (internalRedeliverUnacknowledgedMessages) that already issued a newer read. Clearing + // havePendingRead or dispatching here would strand the newer armed read and double-deliver; + // release the entries (the newer read re-delivers from the rewound position) and do nothing else. + if (log.isDebugEnabled()) { + log.debug("[{}-{}] Discarding stale read completion: size={}, staleReadOpEpoch={}," + + " currentReadOpEpoch={}", + name, readConsumer, entries.size(), readOpEpoch, this.readOpEpoch); + } + entries.forEach(Entry::release); + return; + } if (log.isDebugEnabled()) { log.debug("[{}-{}] Got messages: {}", name, readConsumer, entries.size()); } @@ -366,6 +385,9 @@ void readMoreEntries(Consumer consumer) { log.debug("[{}-{}] Schedule read of {} messages", name, consumer, messagesToRead); } havePendingRead = true; + // Tag this read so a completion that has since been superseded by a redeliver-driven re-arm + // can be detected as stale (see readEntriesComplete / readEntriesFailed). + final long readOpEpoch = ++this.readOpEpoch; // TODO: should we pass the consumer epoch for compacted read path? See // https://github.com/apache/pulsar/issues/13690 final var epoch = consumer.readCompacted() ? DEFAULT_CONSUMER_EPOCH : consumer.getConsumerEpoch(); @@ -382,9 +404,9 @@ void readMoreEntries(Consumer consumer) { } entriesFuture.whenCompleteAsync((entries, e) -> { if (e == null) { - readEntriesComplete(entries, consumer, epoch); + readEntriesComplete(entries, consumer, epoch, readOpEpoch); } else { - readEntriesFailed(e, consumer); + readEntriesFailed(e, consumer, readOpEpoch); } }, executor); } @@ -457,7 +479,16 @@ protected Pair calculateToRead(Consumer consumer) { } @VisibleForTesting - public synchronized void readEntriesFailed(Throwable throwable, Consumer consumer) { + public synchronized void readEntriesFailed(Throwable throwable, Consumer consumer, long readOpEpoch) { + if (readOpEpoch != this.readOpEpoch) { + // Stale failure for a read already disowned by a redeliver-driven re-arm; clearing havePendingRead + // or rescheduling here would disturb the newer outstanding read. Ignore it. + if (log.isDebugEnabled()) { + log.debug("[{}-{}] Ignoring stale read failure: staleReadOpEpoch={}, currentReadOpEpoch={}", + name, consumer, readOpEpoch, this.readOpEpoch); + } + return; + } havePendingRead = false; final var exception = FutureUtil.unwrapCompletionException(throwable); diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentMessageExpiryMonitor.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentMessageExpiryMonitor.java index bc62263adc843..b327a2e05ed39 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentMessageExpiryMonitor.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentMessageExpiryMonitor.java @@ -260,7 +260,7 @@ public void findEntryComplete(Position position, Object ctx) { } log.info("[{}][{}] Expiring all messages until position {}", topicName, subName, position); Position prevMarkDeletePos = cursor.getMarkDeletedPosition(); - cursor.asyncMarkDelete(position, cursor.getProperties(), getMarkDeleteCallback(position), + cursor.asyncMarkDelete(position, null, getMarkDeleteCallback(position), cursor.getNumberOfEntriesInBacklog(false)); if (!Objects.equals(cursor.getMarkDeletedPosition(), prevMarkDeletePos) && subscription != null) { subscription.updateLastMarkDeleteAdvancedTimestamp(); diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentReplicator.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentReplicator.java index 7a32591cf6865..7e3b26d278bbf 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentReplicator.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentReplicator.java @@ -18,6 +18,8 @@ */ package org.apache.pulsar.broker.service.persistent; +import static org.apache.pulsar.broker.service.AbstractReplicator.State.Disconnected; +import static org.apache.pulsar.broker.service.AbstractReplicator.State.Disconnecting; import static org.apache.pulsar.broker.service.AbstractReplicator.State.Started; import static org.apache.pulsar.broker.service.AbstractReplicator.State.Starting; import static org.apache.pulsar.broker.service.AbstractReplicator.State.Terminated; @@ -36,7 +38,6 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; -import lombok.AllArgsConstructor; import lombok.Data; import lombok.Getter; import org.apache.bookkeeper.mledger.AsyncCallbacks; @@ -94,7 +95,7 @@ public abstract class PersistentReplicator extends AbstractReplicator resourceGroupDispatchRateLimiter = Optional.ofNullable(v); private final Object dispatchRateLimiterLock = new Object(); - private int readBatchSize; + private volatile int readBatchSize; private final int readMaxSizeBytes; private final int producerQueueThreshold; @@ -138,10 +139,8 @@ public PersistentReplicator(String localCluster, PersistentTopic localTopic, Man this.expiryMonitor = new PersistentMessageExpiryMonitor(localTopic, Codec.decode(cursor.getName()), cursor, null); - readBatchSize = Math.min( - producerQueueSize, - localTopic.getBrokerService().pulsar().getConfiguration().getDispatcherMaxReadBatchSize()); - readMaxSizeBytes = localTopic.getBrokerService().pulsar().getConfiguration().getDispatcherMaxReadSizeBytes(); + readBatchSize = getMaxReadBatchSize(); + readMaxSizeBytes = brokerService.pulsar().getConfiguration().getDispatcherMaxReadSizeBytes(); producerQueueThreshold = (int) (producerQueueSize * 0.9); this.initializeDispatchRateLimiterIfNeeded(); @@ -149,15 +148,12 @@ public PersistentReplicator(String localCluster, PersistentTopic localTopic, Man startProducer(); } + private int getMaxReadBatchSize() { + return Math.min(producerQueueSize, brokerService.pulsar().getConfiguration().getDispatcherMaxReadBatchSize()); + } + @Override protected void setProducerAndTriggerReadEntries(Producer producer) { - // Repeat until there are no read operations in progress - if (STATE_UPDATER.get(this) == State.Starting && hasPendingRead() && !cursor.cancelPendingReadRequest()) { - brokerService.getPulsar().getExecutor() - .schedule(() -> setProducerAndTriggerReadEntries(producer), 10, TimeUnit.MILLISECONDS); - return; - } - /** * 1. Try change state to {@link Started}. * 2. Atoms modify multiple properties if change state success, to avoid another thread get a null value @@ -229,107 +225,151 @@ protected void disableReplicatorRead() { this.cursor.setInactive(); } - @Data - @AllArgsConstructor - private static class AvailablePermits { - private int messages; - private long bytes; - - /** - * messages, bytes - * 0, O: Producer queue is full, no permits. - * -1, -1: Rate Limiter reaches limit. - * >0, >0: available permits for read entries. - */ - public boolean isExceeded() { - return messages == -1 && bytes == -1; - } - + private record ReadLimits(int messages, long bytes) { public boolean isReadable() { return messages > 0 && bytes > 0; } } /** - * Calculate available permits for read entries. + * Calculate read limits for a read operation. Takes the rate limiter into account if it's enabled. + * Also limits to current readBatchSize and readMaxSizeBytes. */ - private AvailablePermits getRateLimiterAvailablePermits(int availablePermits) { + private ReadLimits getReadLimits(int permits) { // return 0, if Producer queue is full, it will pause read entries. - if (availablePermits <= 0) { + if (permits <= 0) { if (log.isDebugEnabled()) { - log.debug("[{}] Producer queue is full, availablePermits: {}, pause reading", - replicatorId, availablePermits); + log.debug("[{}] Producer queue is full, permits: {}, pause reading", replicatorId, permits); } - return new AvailablePermits(0, 0); + return new ReadLimits(0, 0); } - long availablePermitsOnMsg = -1; - long availablePermitsOnByte = -1; + long readLimitOnMsg = permits; + long readLimitOnByte = readMaxSizeBytes; - // handle rate limit - if (dispatchRateLimiter.isPresent() && dispatchRateLimiter.get().isDispatchRateLimitingEnabled()) { - DispatchRateLimiter rateLimiter = dispatchRateLimiter.get(); - // if dispatch-rate is in msg then read only msg according to available permit - availablePermitsOnMsg = rateLimiter.getAvailableDispatchRateLimitOnMsg(); - availablePermitsOnByte = rateLimiter.getAvailableDispatchRateLimitOnByte(); - // no permits from rate limit - if (availablePermitsOnByte == 0 || availablePermitsOnMsg == 0) { + // Apply topic replicator dispatch rate limit. + DispatchRateLimiter dispatchRateLimiter = this.dispatchRateLimiter.orElse(null); + if (dispatchRateLimiter != null && dispatchRateLimiter.isDispatchRateLimitingEnabled()) { + long availableOnMsg = dispatchRateLimiter.getAvailableDispatchRateLimitOnMsg(); + long availableOnByte = dispatchRateLimiter.getAvailableDispatchRateLimitOnByte(); + + if (availableOnMsg == 0 || availableOnByte == 0) { if (log.isDebugEnabled()) { - log.debug("[{}] message-read exceeded topic replicator message-rate {}/{}," - + " schedule after a {}", + log.debug("[{}] Message-read exceeded topic replicator rate limit," + + " dispatchRateOnMsg: {}, dispatchRateOnByte: {}," + + " availableOnMsg: {}, availableOnByte: {}", replicatorId, - rateLimiter.getDispatchRateOnMsg(), - rateLimiter.getDispatchRateOnByte(), - MESSAGE_RATE_BACKOFF_MS); + dispatchRateLimiter.getDispatchRateOnMsg(), + dispatchRateLimiter.getDispatchRateOnByte(), + availableOnMsg, + availableOnByte); } - return new AvailablePermits(-1, -1); + return new ReadLimits(-1, -1); } + + readLimitOnMsg = applyLimit(readLimitOnMsg, availableOnMsg); + readLimitOnByte = applyLimit(readLimitOnByte, availableOnByte); } - if (resourceGroupDispatchRateLimiter.isPresent()) { - ResourceGroupDispatchLimiter rateLimiter = resourceGroupDispatchRateLimiter.get(); - long rgAvailablePermitsOnMsg = rateLimiter.getAvailableDispatchRateLimitOnMsg(); - long rgAvailablePermitsOnByte = rateLimiter.getAvailableDispatchRateLimitOnByte(); - if (rgAvailablePermitsOnMsg == 0 || rgAvailablePermitsOnByte == 0) { + // Apply resource group dispatch rate limit. + ResourceGroupDispatchLimiter resourceGroupLimiter = resourceGroupDispatchRateLimiter.orElse(null); + if (resourceGroupLimiter != null) { + long availableOnMsg = resourceGroupLimiter.getAvailableDispatchRateLimitOnMsg(); + long availableOnByte = resourceGroupLimiter.getAvailableDispatchRateLimitOnByte(); + + if (availableOnMsg == 0 || availableOnByte == 0) { if (log.isDebugEnabled()) { - log.debug("[{}] message-read exceeded resourcegroup message-rate {}/{}," - + " schedule after a {}", + log.debug("[{}] Message-read exceeded resource group rate limit {}/{}," + + " schedule after {} ms", replicatorId, - rateLimiter.getDispatchRateOnMsg(), - rateLimiter.getDispatchRateOnByte(), + resourceGroupLimiter.getDispatchRateOnMsg(), + resourceGroupLimiter.getDispatchRateOnByte(), MESSAGE_RATE_BACKOFF_MS); } - return new AvailablePermits(-1, -1); - } - if (availablePermitsOnMsg == -1) { - availablePermitsOnMsg = rgAvailablePermitsOnMsg; - } else { - availablePermitsOnMsg = Math.min(rgAvailablePermitsOnMsg, availablePermitsOnMsg); - } - if (availablePermitsOnByte == -1) { - availablePermitsOnByte = rgAvailablePermitsOnByte; - } else { - availablePermitsOnByte = Math.min(rgAvailablePermitsOnByte, availablePermitsOnByte); + return new ReadLimits(-1, -1); } + + readLimitOnMsg = applyLimit(readLimitOnMsg, availableOnMsg); + readLimitOnByte = applyLimit(readLimitOnByte, availableOnByte); } - availablePermitsOnMsg = - availablePermitsOnMsg == -1 ? availablePermits : Math.min(availablePermits, availablePermitsOnMsg); - availablePermitsOnMsg = Math.min(availablePermitsOnMsg, readBatchSize); + // Apply per-read batch limits. + readLimitOnMsg = Math.min(readLimitOnMsg, readBatchSize); - availablePermitsOnByte = - availablePermitsOnByte == -1 ? readMaxSizeBytes : Math.min(readMaxSizeBytes, availablePermitsOnByte); + return new ReadLimits((int) readLimitOnMsg, readLimitOnByte); + } - return new AvailablePermits((int) availablePermitsOnMsg, availablePermitsOnByte); + /** + * Applies {@code available} to the current limit. + * + *

{@code -1} means no limit is configured. + */ + private static long applyLimit(long current, long available) { + return available < 0 ? current : Math.min(current, available); + } + + public void disconnectIfNoTrafficAndBacklog() { + // Disabled the feature. + int threshold = brokerService.getPulsar().getConfig().getBrokerReplicationInactiveThresholdSeconds(); + if (threshold <= 0) { + return; + } + // Has backlog. + long backlog = getNumberOfEntriesInBacklog(); + if (backlog > 0) { + return; + } + // Already disconnected. + if (state != Started) { + return; + } + + // Disconnect if no backlog and no traffic for a long time. + if (System.currentTimeMillis() - latestPublishTime > threshold * 1000L) { + log.info("Disconnecting replication producers since no producer is active for a long time." + + " brokerReplicationInactiveThresholdSeconds: {}", threshold); + disconnect(); + } } protected void readMoreEntries() { if (state.equals(Terminated) || state.equals(Terminating)) { return; } - // Acquire permits and check state of producer. - InFlightTask newInFlightTask = acquirePermitsIfNotFetchingSchema(); + InFlightTask newInFlightTask = null; + ReadLimits readLimits = null; + synchronized (inFlightTasks) { + if (hasPendingRead()) { + log.debug("Skip the reading because there is a pending read task"); + } else if (waitForCursorRewindingRefCnf > 0) { + log.debug("Skip the reading due to new detected schema"); + } else if (state != Started) { + log.debug("Skip the reading because producer has not started"); + } else { + int permits = getPermitsIfNoPendingRead(); + if (permits > 0) { + if (!isWritable()) { + log.debug("Throttling replication traffic to a single message permit because producer is not " + + "writable"); + // Minimize the read size if the producer is disconnected or the window is already full. + permits = 1; + } + + readLimits = getReadLimits(permits); + if (readLimits.isReadable()) { + newInFlightTask = createOrRecycleInFlightTaskIntoQueue(cursor.getReadPosition(), + readLimits.messages); + } else { + // no rate limiter permits from rate limit + if (log.isDebugEnabled()) { + log.debug("[{}] Throttling replication traffic. Messages To Read {}, Bytes To Read {}", + replicatorId, readLimits.messages, readLimits.bytes); + } + } + } + } + } if (newInFlightTask == null) { // no permits from rate limit if (log.isDebugEnabled()) { @@ -338,48 +378,14 @@ protected void readMoreEntries() { if (!hasPendingRead()) { topic.getBrokerService().executor().schedule( () -> readMoreEntries(), MESSAGE_RATE_BACKOFF_MS, TimeUnit.MILLISECONDS); - return; - } else { - return; } - } - // If disabled RateLimiter. - if (!dispatchRateLimiter.isPresent() || !dispatchRateLimiter.get().isDispatchRateLimitingEnabled()) { - cursor.asyncReadEntriesOrWait(newInFlightTask.readingEntries, -1, this, - newInFlightTask/* Context object */, topic.getMaxReadPosition()); return; } - // No permits of RateLimiter. - AvailablePermits availablePermits = getRateLimiterAvailablePermits(newInFlightTask.readingEntries); - if (!availablePermits.isReadable()) { - // no rate limiter permits from rate limit - if (log.isDebugEnabled()) { - log.debug("[{}] Throttling replication traffic. Messages To Read {}, Bytes To Read {}", - replicatorId, availablePermits.getMessages(), availablePermits.getBytes()); - } - topic.getBrokerService().executor().schedule( - () -> readMoreEntries(), MESSAGE_RATE_BACKOFF_MS, TimeUnit.MILLISECONDS); - return; - } - // Has permits of RateLimiter. - int messagesToRead = availablePermits.getMessages(); - long bytesToRead = availablePermits.getBytes(); - if (!isWritable()) { - if (log.isDebugEnabled()) { - log.debug("[{}] Throttling replication traffic because producer is not writable", replicatorId); - } - // Minimize the read size if the producer is disconnected or the window is already full - messagesToRead = 1; - } - // Update acquired permits exceeds limitation. - if (messagesToRead < newInFlightTask.readingEntries) { - newInFlightTask.setReadingEntries(messagesToRead); - } if (log.isDebugEnabled()) { - log.debug("[{}] Schedule read of {} messages or {} bytes", replicatorId, newInFlightTask.readingEntries, - bytesToRead); + log.debug("[{}] Schedule read of {} messages or {} bytes", replicatorId, + newInFlightTask.readingEntries, readLimits.bytes); } - cursor.asyncReadEntriesOrWait(newInFlightTask.readingEntries, bytesToRead, this, + cursor.asyncReadEntriesOrWait(newInFlightTask.readingEntries, readLimits.bytes, this, newInFlightTask/* Context object */, topic.getMaxReadPosition()); } @@ -389,10 +395,81 @@ public void readEntriesComplete(List entries, Object ctx) { log.debug("[{}] Read entries complete of {} messages", replicatorId, entries.size()); } InFlightTask inFlightTask = (InFlightTask) ctx; + + latestPublishTime = System.currentTimeMillis(); + // The read result must be discarded because the replicator is terminating or the cursor was + // rewound while this read was in flight (e.g. after a failed publish or a schema change). + if (state == State.Terminated || state == State.Terminating + || inFlightTask.isSkipReadResultDueToCursorRewind()) { + for (Entry entry : entries) { + entry.release(); + } + boolean resumeReads = state != State.Terminated && state != State.Terminating; + // Hold the inFlightTasks lock (the same lock doRewindCursor() and readMoreEntries()'s + // read-scheduling path use) so completing the task and rewinding the cursor here are atomic with + // respect to readMoreEntries(), which reads both the free read slot and the cursor read position + // under that lock; otherwise a concurrent read could observe the freed slot but the stale + // (advanced) read position. + synchronized (inFlightTasks) { + if (resumeReads) { + // The discarded read already advanced the cursor read position past these still-unacked + // messages (ManagedCursor advances the read position when a read completes), which + // overwrites the earlier doRewindCursor(). Rewind again so the messages are re-read; + // otherwise they stay stranded behind the read position and the backlog never drains. + // rewind() resets the read position to the mark-delete position, so only unacked + // messages are re-read; entries already acknowledged (replicated) are skipped on the + // re-read. This makes the recovery at-least-once, with duplicates bounded to the + // messages that were in flight when the rewind happened. + cursor.rewind(); + } + // Complete the task with an empty result so it releases its permit and no longer counts + // as a pending cursor read. Without this, a pending read that could not be cancelled + // (cursor.cancelPendingReadRequest() returns false for an already-dispatched read, which + // is the common case when there is a backlog) would stay entries == null forever, keeping + // hasPendingRead() permanently true and stalling replication. + if (inFlightTask.getEntries() == null) { + inFlightTask.setEntries(Collections.emptyList()); + } + } + if (resumeReads) { + // Resume reading on the broker executor rather than calling readMoreEntries() directly: + // cursor reads can complete inline (cache hit), so a direct call risks deep + // readEntriesComplete -> readMoreEntries recursion (StackOverflowError), the same hazard + // PersistentDispatcherMultipleConsumers.readMoreEntriesAsync() guards against. + topic.getBrokerService().executor().execute(this::readMoreEntries); + } + return; + } + + // Retry to trigger read completes if it is not started. + Runnable retryReplicateEntries = () -> { + long estimatedTimeStampProducerConnected = this.estimatedTimeStampProducerConnected; + long delayMillis; + if (estimatedTimeStampProducerConnected > System.currentTimeMillis()) { + delayMillis = (estimatedTimeStampProducerConnected - System.currentTimeMillis()) + 100; + } else { + delayMillis = 100; + } + cursor.scheduleReadCallback(() -> readEntriesComplete(entries, ctx), delayMillis, TimeUnit.MILLISECONDS); + }; + + // Retry. + if (state == Disconnecting || state == Starting) { + retryReplicateEntries.run(); + return; + } + // Start producer and retry. + if (state == Disconnected) { + startProducer(); + retryReplicateEntries.run(); + return; + } + + // After set entries, the next reading can be start. inFlightTask.setEntries(entries); // After the replicator starts, the speed will be gradually increased. - int maxReadBatchSize = topic.getBrokerService().pulsar().getConfiguration().getDispatcherMaxReadBatchSize(); + int maxReadBatchSize = getMaxReadBatchSize(); if (readBatchSize < maxReadBatchSize) { int newReadBatchSize = Math.min(readBatchSize * 2, maxReadBatchSize); if (log.isDebugEnabled()) { @@ -452,6 +529,9 @@ public void sendComplete(Throwable exception, OpSendMsgStats opSendMsgStats) { // cursor should be rewound since it was incremented when readMoreEntries replicator.beforeTerminateOrCursorRewinding(ReasonOfWaitForCursorRewinding.Failed_Publishing); replicator.doRewindCursor(false); + // The failed send has completed from the producer queue perspective. The cursor rewind + // makes the entry readable again, so this in-flight task must release its permit. + inFlightTask.incCompletedEntries(); } else { if (log.isDebugEnabled()) { log.debug("[{}] Message persisted on remote broker", replicator.replicatorId, exception); @@ -548,7 +628,7 @@ public void readEntriesFailed(ManagedLedgerException exception, Object ctx) { } // Reduce read batch size to avoid flooding bookies with retries - readBatchSize = topic.getBrokerService().pulsar().getConfiguration().getDispatcherMinReadBatchSize(); + readBatchSize = brokerService.pulsar().getConfiguration().getDispatcherMinReadBatchSize(); long waitTimeMillis = readFailureBackoff.next(); @@ -952,29 +1032,6 @@ InFlightTask createOrRecycleInFlightTaskIntoQueue(Position readPos, int readingE } } - protected InFlightTask acquirePermitsIfNotFetchingSchema() { - synchronized (inFlightTasks) { - if (hasPendingRead()) { - log.info("[{}] Skip the reading because there is a pending read task", replicatorId); - return null; - } - if (waitForCursorRewindingRefCnf > 0) { - log.info("[{}] Skip the reading due to new detected schema", replicatorId); - return null; - } - if (state != Started) { - log.info("[{}] Skip the reading because producer has not started [{}]", replicatorId, state); - return null; - } - // Guarantee that there is a unique cursor reading task. - int permits = getPermitsIfNoPendingRead(); - if (permits == 0) { - return null; - } - return createOrRecycleInFlightTaskIntoQueue(cursor.getReadPosition(), permits); - } - } - protected int getPermitsIfNoPendingRead() { synchronized (inFlightTasks) { for (InFlightTask task : inFlightTasks) { @@ -1014,15 +1071,10 @@ protected CompletableFuture beforeDisconnect() { .TopicBusyException("Cannot close a replicator with backlog")); } } - beforeTerminateOrCursorRewinding(ReasonOfWaitForCursorRewinding.Disconnecting); return CompletableFuture.completedFuture(null); } } - protected void afterDisconnected() { - doRewindCursor(false); - } - protected void beforeTerminateOrCursorRewinding(ReasonOfWaitForCursorRewinding reason) { synchronized (inFlightTasks) { boolean hasCanceledPendingRead = cursor.cancelPendingReadRequest(); diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentStickyKeyDispatcherMultipleConsumers.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentStickyKeyDispatcherMultipleConsumers.java index 8a54d07f230aa..6d3ee0da11137 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentStickyKeyDispatcherMultipleConsumers.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentStickyKeyDispatcherMultipleConsumers.java @@ -316,17 +316,23 @@ protected synchronized boolean trySendMessagesToConsumers(ReadType readType, Lis acquirePermitsForDeliveredMessages(topic, cursor, totalEntries, totalMessagesSent, totalBytesSent); // trigger read more messages if necessary - if (triggerLookAhead.booleanValue()) { + if (triggerLookAhead.booleanValue() && cursor.hasMoreEntries()) { // When all messages get filtered and no messages are sent, we should read more entries, "look ahead" // so that a possible next batch of messages might contain messages that can be dispatched. // This is done only when there's a consumer with available permits, and it's not able to make progress // because of blocked hashes. Without this rule we would be looking ahead in the stream while the // new consumers are not ready to accept the new messages, // therefore would be most likely only increase the distance between read-position and mark-delete position. + // Look-ahead is engaged only when the cursor has more entries. Otherwise the next readMoreEntries call + // would skip replaying the replay queue and pulling due messages from the delayed delivery tracker, and + // instead issue a normal read that waits at the end of the topic for new entries. That would leave + // deliverable messages stuck in the replay queue or the delayed delivery tracker until an unrelated event + // (such as a consumer flow request) triggers another read, stalling dispatch (issue #21554). + // The former "allowOutOfOrderDelivery ||" escape here was dropped once ReplayPositionFilter started + // filtering out-of-order replay positions by available permits; don't re-add it without removing that. skipNextReplayToTriggerLookAhead = true; // skip backoff delay before reading ahead in the "look ahead" mode to prevent any additional latency - // only skip the delay if there are more entries to read - skipNextBackoff = cursor.hasMoreEntries(); + skipNextBackoff = true; return true; } @@ -555,19 +561,20 @@ private class ReplayPositionFilter implements Predicate { private final Map availablePermitsMap = new HashMap<>(); // tracks the hashes that have been blocked during the filtering // it is necessary to block all later messages after a hash gets blocked so that ordering is preserved - private final Set alreadyBlockedHashes = new HashSet<>(); + private final Set hashesBlockedForOrdering = new HashSet<>(); @Override public boolean test(Position position) { - // if out of order delivery is allowed, then any position will be replayed - if (isAllowOutOfOrderDelivery()) { - return true; - } - // lookup the sticky key hash for the entry at the replay position + // Out-of-order delivery relaxes ordering, not routing: filterAndGroupEntriesForDispatching selects the + // owning consumer by hash in both modes, so the hash is needed in both. It feeds the permit check below, + // which keeps this read's bounded budget (see MessageRedeliveryController#getMessagesToReplayNow) off + // positions that dispatch would only push straight back into the replay queue. Under out-of-order delivery, + // that check and the no-consumer check are the only live rejections here, and they made the + // "allowOutOfOrderDelivery ||" look-ahead escape removable. Long stickyKeyHash = redeliveryMessages.getHash(position.getLedgerId(), position.getEntryId()); if (stickyKeyHash == null) { - // the sticky key hash is missing for delayed messages, the filtering will happen at the time of - // dispatch after reading the entry from the ledger + // The sticky key hash is missing for delayed messages and positions added through hash-less + // redelivery paths. Filtering will happen at dispatch time after reading the entry from the ledger. if (log.isDebugEnabled()) { log.debug("[{}] replay of entry at position {} doesn't contain sticky key hash.", name, position); } @@ -575,7 +582,7 @@ public boolean test(Position position) { } // check if the hash is already blocked, if so, then replaying of the position should be skipped // to preserve ordering - if (alreadyBlockedHashes.contains(stickyKeyHash)) { + if (hashesBlockedForOrdering.contains(stickyKeyHash)) { return false; } @@ -583,7 +590,7 @@ public boolean test(Position position) { Consumer consumer = selector.select(stickyKeyHash.intValue()); // skip replaying the message position if there's no assigned consumer if (consumer == null) { - alreadyBlockedHashes.add(stickyKeyHash); + blockStickyKeyHashIfOrderingRequired(stickyKeyHash); return false; } @@ -593,20 +600,26 @@ public boolean test(Position position) { k -> new MutableInt(getAvailablePermits(consumer))); // skip replaying the message position if the consumer has no available permits if (availablePermits.intValue() <= 0) { - alreadyBlockedHashes.add(stickyKeyHash); + blockStickyKeyHashIfOrderingRequired(stickyKeyHash); return false; } if (drainingHashesRequired && drainingHashesTracker.shouldBlockStickyKeyHash(consumer, stickyKeyHash.intValue())) { // the hash is draining and the consumer is not the draining consumer - alreadyBlockedHashes.add(stickyKeyHash); + blockStickyKeyHashIfOrderingRequired(stickyKeyHash); return false; } availablePermits.decrement(); return true; } + + private void blockStickyKeyHashIfOrderingRequired(long stickyKeyHash) { + if (!allowOutOfOrderDelivery) { + hashesBlockedForOrdering.add(stickyKeyHash); + } + } } @Override diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentStickyKeyDispatcherMultipleConsumersClassic.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentStickyKeyDispatcherMultipleConsumersClassic.java index 500f6a9472c57..16e1f3d64370c 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentStickyKeyDispatcherMultipleConsumersClassic.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentStickyKeyDispatcherMultipleConsumersClassic.java @@ -546,8 +546,7 @@ private int getAvailablePermits(Consumer c) { @Override protected synchronized NavigableSet filterOutEntriesWillBeDiscarded(NavigableSet src) { - // The variable "hashesToBeBlocked" and "recentlyJoinedConsumers" will be null if "isAllowOutOfOrderDelivery()", - // So skip this filter out. + // Keep the classic out-of-order behavior: replay positions are not filtered before reading. if (isAllowOutOfOrderDelivery()) { return src; } @@ -595,8 +594,7 @@ protected synchronized NavigableSet filterOutEntriesWillBeDiscarded(Na */ @Override protected boolean hasConsumersNeededNormalRead() { - // The variable "hashesToBeBlocked" and "recentlyJoinedConsumers" will be null if "isAllowOutOfOrderDelivery()", - // So the method "filterOutEntriesWillBeDiscarded" will filter out nothing, just return "true" here. + // Classic out-of-order replay filtering is bypassed, so normal reads do not need the ordered-mode escape check. if (isAllowOutOfOrderDelivery()) { return true; } diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentSubscription.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentSubscription.java index 582af184b367b..63e7317062e69 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentSubscription.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentSubscription.java @@ -52,12 +52,12 @@ import org.apache.bookkeeper.mledger.ManagedLedgerException.InvalidCursorPositionException; import org.apache.bookkeeper.mledger.Position; import org.apache.bookkeeper.mledger.ScanOutcome; -import org.apache.commons.collections4.MapUtils; import org.apache.commons.lang3.tuple.MutablePair; import org.apache.pulsar.broker.ServiceConfiguration; import org.apache.pulsar.broker.intercept.BrokerInterceptor; import org.apache.pulsar.broker.loadbalance.extensions.ExtensibleLoadManagerImpl; import org.apache.pulsar.broker.loadbalance.extensions.data.BrokerLookupData; +import org.apache.pulsar.broker.service.AbstractDispatcherSingleActiveConsumer; import org.apache.pulsar.broker.service.AbstractSubscription; import org.apache.pulsar.broker.service.AnalyzeBacklogResult; import org.apache.pulsar.broker.service.BrokerServiceException; @@ -154,6 +154,14 @@ public PersistentSubscription(PersistentTopic topic, String subscriptionName, Ma this(topic, subscriptionName, cursor, replicated, Collections.emptyMap()); } + /** + * Creates a persistent subscription. + * + * @deprecated use {@link #PersistentSubscription(PersistentTopic, String, ManagedCursor, Boolean)} + * instead. The {@code subscriptionProperties} parameter is no longer read; the + * cursor already carries all subscription properties. + */ + @Deprecated public PersistentSubscription(PersistentTopic topic, String subscriptionName, ManagedCursor cursor, Boolean replicated, Map subscriptionProperties) { this.topic = topic; @@ -164,8 +172,6 @@ public PersistentSubscription(PersistentTopic topic, String subscriptionName, Ma this.fullName = MoreObjects.toStringHelper(this).add("topic", topicName).add("name", subName).toString(); this.expiryMonitor = new PersistentMessageExpiryMonitor(topic, subscriptionName, cursor, this); this.setReplicated(replicated); - this.subscriptionProperties = MapUtils.isEmpty(subscriptionProperties) - ? Collections.emptyMap() : Collections.unmodifiableMap(subscriptionProperties); if (config.isTransactionCoordinatorEnabled() && !isEventSystemTopic(TopicName.get(topicName)) && !ExtensibleLoadManagerImpl.isInternalTopic(topicName)) { @@ -443,6 +449,23 @@ public void acknowledgeMessage(List positions, AckType ackType, Map ml.getFirstPosition().getLedgerId()) { + log.warn("Received an ACK whose position is " + position + ", valid ledgers: " + + ml.getLedgersInfo().keySet()); + } + return; + } + } cursor.asyncMarkDelete(position, mergeCursorProperties(properties), markDeleteCallback, previousMarkDeletePosition); @@ -1424,6 +1447,8 @@ public CompletableFuture getStatsAsync(GetStatsOptions ge } } subStats.msgBacklog = getNumberOfEntriesInBacklog(getStatsOptions.isGetPreciseBacklog()); + subStats.oldestBacklogMessageAgeSeconds = + topic.getBestEffortOldestUnacknowledgedMessageAgeSeconds(subName); if (getStatsOptions.isSubscriptionBacklogSize()) { subStats.backlogSize = topic.getManagedLedger() .getEstimatedBacklogSize(cursor.getMarkDeletedPosition()); @@ -1434,7 +1459,7 @@ public CompletableFuture getStatsAsync(GetStatsOptions ge subStats.msgRateExpired = expiryMonitor.getMessageExpiryRate(); subStats.totalMsgExpired = expiryMonitor.getTotalMessageExpired(); subStats.isReplicated = isReplicated(); - subStats.subscriptionProperties = subscriptionProperties; + subStats.subscriptionProperties = getSubscriptionProperties(); subStats.isDurable = cursor.isDurable(); if (getType() == SubType.Key_Shared && dispatcher instanceof StickyKeyDispatcher) { StickyKeyDispatcher keySharedDispatcher = (StickyKeyDispatcher) dispatcher; @@ -1568,7 +1593,7 @@ public boolean isSubscriptionMigrated() { @Override public Map getSubscriptionProperties() { - return subscriptionProperties; + return cursor.getCursorProperties(); } public Position getPositionInPendingAck(Position position) { @@ -1582,10 +1607,7 @@ public CompletableFuture updateSubscriptionProperties(Map } else { newSubscriptionProperties = Collections.unmodifiableMap(subscriptionProperties); } - return cursor.setCursorProperties(newSubscriptionProperties) - .thenRun(() -> { - this.subscriptionProperties = newSubscriptionProperties; - }); + return cursor.setCursorProperties(newSubscriptionProperties); } /** * Return a merged map that contains the cursor properties specified by used diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentTopic.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentTopic.java index 5d397706f43cc..e6e6866b50659 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentTopic.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/PersistentTopic.java @@ -29,7 +29,6 @@ import com.carrotsearch.hppc.ObjectObjectHashMap; import com.carrotsearch.hppc.cursors.ObjectObjectCursor; import com.google.common.annotations.VisibleForTesting; -import com.google.common.collect.Sets; import io.netty.buffer.ByteBuf; import io.netty.util.concurrent.FastThreadLocal; import java.io.IOException; @@ -299,6 +298,7 @@ protected TopicStatsHelper initialValue() { // Record the last time max read position is moved forward, unless it's a marker message. @Getter private volatile long lastMaxReadPositionMovedForwardTimestamp = 0; + @Getter private final ExecutorService orderedExecutor; @@ -319,6 +319,8 @@ protected TopicStatsHelper initialValue() { PersistentTopic.class, OldestPositionInfo.class, "oldestPositionInfo"); + private volatile Map subscriptionOldestPositionInfos; + @Value private static class OldestPositionInfo { Position oldestCursorMarkDeletePosition; @@ -342,6 +344,9 @@ private static class EstimateTimeBasedBacklogQuotaCheckResult { // For active topics, the ledger provides the timestamp directly, so this cache is cleared. private volatile long cachedLastPublishTimestamp; + private final CompletableFuture initialReplicationCheck = new CompletableFuture<>(); + private final AtomicBoolean initialReplicationCheckInitialized = new AtomicBoolean(false); + /*** * We use 3 futures to prevent a new closing if there is an in-progress deletion or closing. We make Pulsar return * the in-progress one when it is called the second time. @@ -532,11 +537,12 @@ public CompletableFuture initialize() { this.isEncryptionRequired = policies.encryption_required; isAllowAutoUpdateSchema = policies.is_allow_auto_update_schema; - }, getOrderedExecutor()) + }, getPoliciesNotifyThread()) .thenCompose(ignore -> initTopicPolicy()) .thenCompose(ignore -> removeOrphanReplicationCursors()) .exceptionally(ex -> { - log.warn("[{}] Error getting policies {} and isEncryptionRequired will be set to false", + log.warn("[{}] Error loading topic policies during initialization. Ignoring the failure. " + + "isEncryptionRequired will be set to false. {}", topic, ex.getMessage()); isEncryptionRequired = false; return null; @@ -807,18 +813,23 @@ public synchronized void addFailed(ManagedLedgerException exception, Object ctx) // close all producers CompletableFuture disconnectProducersFuture; if (producers.size() > 0) { - List> futures = new ArrayList<>(); // send migration url metadata to producers before disconnecting them - if (isMigrated()) { - if (!shouldProducerMigrate()) { + CompletableFuture sendMigrationUrlFuture; + if (isMigrated() && shouldProducerMigrate()) { + sendMigrationUrlFuture = getMigratedClusterUrlAsync().thenAccept(clusterUrl -> + producers.forEach((__, producer) -> producer.topicMigrated(clusterUrl))); + } else { + if (isMigrated()) { log.info("Topic {} is migrated but replication-backlog exists or " + "subs not created. Closing producers.", topic); - } else { - producers.forEach((__, producer) -> producer.topicMigrated(getMigratedClusterUrl())); } + sendMigrationUrlFuture = CompletableFuture.completedFuture(null); } - producers.forEach((__, producer) -> futures.add(producer.disconnect())); - disconnectProducersFuture = FutureUtil.waitForAll(futures); + disconnectProducersFuture = sendMigrationUrlFuture.thenCompose(v -> { + List> futures = new ArrayList<>(); + producers.forEach((__, producer) -> futures.add(producer.disconnect())); + return FutureUtil.waitForAll(futures); + }); } else { disconnectProducersFuture = CompletableFuture.completedFuture(null); } @@ -854,9 +865,7 @@ public CompletableFuture> addProducer(Producer producer, CompletableFuture producerQueuedFuture) { return super.addProducer(producer, producerQueuedFuture).thenCompose(topicEpoch -> { messageDeduplication.producerAdded(producer.getProducerName()); - - // Start replication producers if not already - return startReplProducers().thenApply(__ -> topicEpoch); + return CompletableFuture.completedFuture(topicEpoch); }); } @@ -903,46 +912,6 @@ private boolean hasRemoteProducers() { return false; } - public CompletableFuture startReplProducers() { - // read repl-cluster from policies to avoid restart of replicator which are in process of disconnect and close - return brokerService.pulsar().getPulsarResources().getNamespaceResources() - .getPoliciesAsync(TopicName.get(topic).getNamespaceObject()) - .thenAcceptAsync(optPolicies -> { - if (optPolicies.isPresent()) { - if (optPolicies.get().replication_clusters != null) { - Set configuredClusters = Sets.newTreeSet(optPolicies.get().replication_clusters); - replicators.forEach((region, replicator) -> { - if (configuredClusters.contains(region)) { - replicator.startProducer(); - } - }); - } - } else { - replicators.forEach((region, replicator) -> replicator.startProducer()); - } - }, getOrderedExecutor()).exceptionally(ex -> { - if (log.isDebugEnabled()) { - log.debug("[{}] Error getting policies while starting repl-producers {}", topic, ex.getMessage()); - } - replicators.forEach((region, replicator) -> replicator.startProducer()); - return null; - }); - } - - public CompletableFuture stopReplProducers() { - List> closeFutures = new ArrayList<>(); - replicators.forEach((region, replicator) -> closeFutures.add(replicator.terminate())); - shadowReplicators.forEach((__, replicator) -> closeFutures.add(replicator.terminate())); - return FutureUtil.waitForAll(closeFutures); - } - - private synchronized CompletableFuture closeReplProducersIfNoBacklog() { - List> closeFutures = new ArrayList<>(); - replicators.forEach((region, replicator) -> closeFutures.add(replicator.disconnect())); - shadowReplicators.forEach((__, replicator) -> closeFutures.add(replicator.disconnect())); - return FutureUtil.waitForAll(closeFutures); - } - @Override protected void handleProducerRemoved(Producer producer) { super.handleProducerRemoved(producer); @@ -1017,8 +986,15 @@ private CompletableFuture internalSubscribe(final TransportCnx cnx, St new NamingException("Subscription with reserved subscription name attempted")); } - if (cnx.clientAddress() != null && cnx.clientAddress().toString().contains(":") - && subscribeRateLimiter.isPresent()) { + // The subscribe rate limit must not apply to the broker-internal compaction subscription: the + // compactor's reader re-subscribes after the phase-two seek, and throttling that re-subscribe stalls + // the compaction, which in turn blocks forced topic/namespace deletion waiting on the in-flight + // compaction. System topics are exempt as well, consistent with the publish/dispatch rate limiters, + // since throttling broker-internal readers (e.g. on __change_events) can stall topic policy updates. + if (subscribeRateLimiter.isPresent() + && !isSystemTopic() + && !isCompactionSubscription(subscriptionName) + && cnx.clientAddress() != null && cnx.clientAddress().toString().contains(":")) { SubscribeRateLimiter.ConsumerIdentifier consumer = new SubscribeRateLimiter.ConsumerIdentifier( cnx.clientAddress().toString().split(":")[0], consumerName, consumerId); if (!subscribeRateLimiter.get().subscribeAvailable(consumer) @@ -1221,6 +1197,7 @@ private CompletableFuture getNonDurableSubscription(Stri synchronized (ledger) { // Create a new non-durable cursor only for the first consumer that connects PersistentSubscription subscription = subscriptions.get(subscriptionName); + CompletableFuture initPropertiesFuture = CompletableFuture.completedFuture(null); if (subscription == null) { MessageIdImpl msgId = startMessageId != null ? (MessageIdImpl) startMessageId @@ -1247,9 +1224,16 @@ private CompletableFuture getNonDurableSubscription(Stri return FutureUtil.failedFuture(e); } - subscription = new PersistentSubscription(this, subscriptionName, cursor, false, - subscriptionProperties); + subscription = new PersistentSubscription(this, subscriptionName, cursor, false); subscriptions.put(subscriptionName, subscription); + + if (subscriptionProperties != null && !subscriptionProperties.isEmpty()) { + // Trade-off: subscriptionProperties should be received by the cursor at creation time, + // the way durable cursors take cursorProperties through ManagedLedger#asyncOpenCursor. + // ManagedLedger#newNonDurableCursor has no equivalent parameter, so we seed the cursor + // with a post-construction setCursorProperties call. + initPropertiesFuture = cursor.setCursorProperties(subscriptionProperties); + } } else { // if subscription exists, check if it's a durable subscription if (subscription.getCursor() != null && subscription.getCursor().isDurable()) { @@ -1258,11 +1242,19 @@ private CompletableFuture getNonDurableSubscription(Stri } } + final PersistentSubscription finalSubscription = subscription; if (startMessageRollbackDurationSec > 0) { - resetSubscriptionCursor(subscription, subscriptionFuture, startMessageRollbackDurationSec); + initPropertiesFuture.whenComplete((__, ex) -> { + if (ex != null) { + subscriptionFuture.completeExceptionally(ex); + } else { + resetSubscriptionCursor(finalSubscription, subscriptionFuture, + startMessageRollbackDurationSec); + } + }); return subscriptionFuture; } else { - return CompletableFuture.completedFuture(subscription); + return initPropertiesFuture.thenApply(__ -> finalSubscription); } } } @@ -1393,8 +1385,24 @@ private void asyncDeleteCursorWithCleanCompactionLedger(PersistentSubscription s return; } } - // Unsubscribe compaction cursor and delete compacted ledger. - currentCompaction.thenCompose(__ -> { + // Unsubscribe compaction cursor and delete compacted ledger. Normally we wait for any in-flight compaction + // to finish first, but a compaction that completed exceptionally must not block the cursor deletion: it + // would otherwise fail on every retry until the topic instance is reloaded (issue #24148). + // + // Moreover, when the topic is being closed or deleted it is already fenced and any in-flight compaction is + // being aborted. Waiting for that compaction to complete is both unnecessary and unsafe here: the + // compactor's reader is expected to fail once the topic is fenced, but that depends on how the client + // reconnect surfaces the failure (a retriable lookup-stage error keeps the reader reconnecting instead of + // failing the in-flight read), so the compaction future may stay pending for far longer than the deletion + // can wait. Proceed with the cursor deletion right away in that case so a forced topic/namespace deletion + // cannot hang (issue #24148). + CompletableFuture compactionToWait = + isClosingOrDeleting ? CompletableFuture.completedFuture(null) : currentCompaction; + compactionToWait.exceptionally(compactionEx -> { + log.info("[{}][{}] Last compaction task failed, proceeding to delete the compaction cursor", + topic, subscriptionName, compactionEx); + return null; + }).thenCompose(__ -> { asyncDeleteCursor(subscriptionName, unsubscribeFuture); return unsubscribeFuture; }).thenAccept(__ -> { @@ -1412,11 +1420,7 @@ private void asyncDeleteCursorWithCleanCompactionLedger(PersistentSubscription s disablingCompaction.compareAndSet(true, false); } }).exceptionally(ex -> { - if (currentCompaction.isCompletedExceptionally()) { - log.warn("[{}][{}] Last compaction task failed", topic, subscriptionName); - } else { - log.warn("[{}][{}] Failed to delete cursor task failed", topic, subscriptionName); - } + log.warn("[{}][{}] Failed to delete the compaction cursor", topic, subscriptionName, ex); // Reset the variable: disablingCompaction, disablingCompaction.compareAndSet(true, false); unsubscribeFuture.completeExceptionally(ex); @@ -1972,8 +1976,49 @@ CompletableFuture checkPersistencePolicies() { return future; } + /** + * Starts the replication check used while loading a topic. + * + *

The initialization state is tracked separately from regular replication checks so that a concurrent + * policy update does not start a second initial check. The check itself is still dispatched through + * {@link #checkReplication()} to preserve overrides supplied by + * {@link org.apache.pulsar.broker.service.TopicFactory}. + */ + public final CompletableFuture initializeCheckReplication() { + if (initialReplicationCheckInitialized.compareAndSet(false, true)) { + try { + checkReplication().whenComplete((__, ex) -> { + if (ex != null) { + initialReplicationCheck.completeExceptionally(ex); + } else { + initialReplicationCheck.complete(null); + } + }); + } catch (Throwable t) { + // checkReplication is overridable, so an implementation can fail before returning its future. + initialReplicationCheck.completeExceptionally(t); + } + } + // Do not expose the mutable internal future: callers can cancel or complete a CompletableFuture. + return initialReplicationCheck.thenApply(__ -> null); + } + @Override public CompletableFuture checkReplication() { + if (initialReplicationCheckInitialized.compareAndSet(false, true)) { + internalCheckReplication().whenComplete((__, ex) -> { + if (ex != null) { + initialReplicationCheck.completeExceptionally(ex); + } else { + initialReplicationCheck.complete(null); + } + }); + return initialReplicationCheck.thenApply(__ -> null); + } + return internalCheckReplication(); + } + + private CompletableFuture internalCheckReplication() { TopicName name = TopicName.get(topic); if (!name.isGlobal() || NamespaceService.isHeartbeatNamespace(name) || ExtensibleLoadManagerImpl.isInternalTopic(topic)) { @@ -3069,6 +3114,7 @@ public CompletableFuture getInternalStats(boolean stats.lastConfirmedEntry = ledgerInternalStats.getLastConfirmedEntry(); stats.state = ledgerInternalStats.getState(); stats.ledgers = ledgerInternalStats.ledgers; + stats.properties = ledgerInternalStats.getProperties(); // Add ledger info for compacted topic ledger if exist. LedgerInfo info = new LedgerInfo(); @@ -3253,9 +3299,9 @@ public boolean isActive(InactiveTopicDeleteMode deleteMode) { } if (TopicName.get(topic).isGlobal()) { // no local producers - return hasLocalProducers(); + return hasProducersActive() || hasActiveReplicators(); } else { - return currentUsageCount() != 0; + return currentUsageCount() != 0 || hasActiveReplicators(); } } @@ -3272,39 +3318,39 @@ public CompletableFuture checkClusterMigration() { return CompletableFuture.completedFuture(null); } - Optional clusterUrl = getMigratedClusterUrl(); - - if (!clusterUrl.isPresent()) { - return CompletableFuture.completedFuture(null); - } + return getMigratedClusterUrlAsync().thenCompose(clusterUrl -> { + if (!clusterUrl.isPresent()) { + return CompletableFuture.completedFuture(null); + } - if (isReplicated()) { - if (isReplicationBacklogExist()) { - if (!ledger.isMigrated()) { - log.info("{} applying migration with replication backlog", topic); - ledger.asyncMigrate(); - } - if (log.isDebugEnabled()) { - log.debug("{} has replication backlog and applied migration", topic); + if (isReplicated()) { + if (isReplicationBacklogExist()) { + if (!ledger.isMigrated()) { + log.info("{} applying migration with replication backlog", topic); + ledger.asyncMigrate(); + } + if (log.isDebugEnabled()) { + log.debug("{} has replication backlog and applied migration", topic); + } + return CompletableFuture.completedFuture(null); } - return CompletableFuture.completedFuture(null); } - } - return initMigration().thenCompose(subCreated -> { - migrationSubsCreated = true; - CompletableFuture migrated = !isMigrated() ? ledger.asyncMigrate() - : CompletableFuture.completedFuture(null); - return migrated.thenApply(__ -> { - subscriptions.forEach((name, sub) -> { - if (sub.isSubscriptionMigrated()) { - sub.getConsumers().forEach(Consumer::checkAndApplyTopicMigration); - } - }); - return null; - }).thenCompose(__ -> checkAndDisconnectReplicators()) - .thenCompose(__ -> checkAndUnsubscribeSubscriptions()) - .thenCompose(__ -> checkAndDisconnectProducers()); + return initMigration().thenCompose(subCreated -> { + migrationSubsCreated = true; + CompletableFuture migrated = !isMigrated() ? ledger.asyncMigrate() + : CompletableFuture.completedFuture(null); + return migrated.thenApply(__ -> { + subscriptions.forEach((name, sub) -> { + if (sub.isSubscriptionMigrated()) { + sub.getConsumers().forEach(consumer -> consumer.topicMigrated(clusterUrl)); + } + }); + return null; + }).thenCompose(__ -> checkAndDisconnectReplicators()) + .thenCompose(__ -> checkAndUnsubscribeSubscriptions()) + .thenCompose(__ -> checkAndDisconnectProducers()); + }); }); } @@ -3462,51 +3508,8 @@ public void checkGC() { // Topic activity is still within the retention period return; } else { - CompletableFuture replCloseFuture = new CompletableFuture<>(); - - if (TopicName.get(topic).isGlobal()) { - // For global namespace, close repl producers first. - // Once all repl producers are closed, we can delete the topic, - // provided no remote producers connected to the broker. - if (log.isDebugEnabled()) { - log.debug("[{}] Global topic inactive for {} seconds, closing repl producers.", topic, - maxInactiveDurationInSec); - } - /** - * There is a race condition that may cause a NPE: - * - task 1: a callback of "replicator.cursor.asyncRead" will trigger a replication. - * - task 2: "closeReplProducersIfNoBacklog" called by current thread will make the variable - * "replicator.producer" to a null value. - * Race condition: task 1 will get a NPE when it tries to send messages using the variable - * "replicator.producer", because task 2 will set this variable to "null". - * TODO Create a seperated PR to fix it. - */ - closeReplProducersIfNoBacklog().thenRun(() -> { - if (hasRemoteProducers()) { - if (log.isDebugEnabled()) { - log.debug("[{}] Global topic has connected remote producers. Not a candidate for GC", - topic); - } - replCloseFuture - .completeExceptionally(new TopicBusyException("Topic has connected remote producers")); - } else { - log.info("[{}] Global topic inactive for {} seconds, closed repl producers", topic, - maxInactiveDurationInSec); - replCloseFuture.complete(null); - } - }).exceptionally(e -> { - if (log.isDebugEnabled()) { - log.debug("[{}] Global topic has replication backlog. Not a candidate for GC", topic); - } - replCloseFuture.completeExceptionally(e.getCause()); - return null; - }); - } else { - replCloseFuture.complete(null); - } - - replCloseFuture.thenCompose(v -> delete(deleteMode == InactiveTopicDeleteMode.delete_when_no_subscriptions, - deleteMode == InactiveTopicDeleteMode.delete_when_subscriptions_caught_up, false)) + delete(deleteMode == InactiveTopicDeleteMode.delete_when_no_subscriptions, + deleteMode == InactiveTopicDeleteMode.delete_when_subscriptions_caught_up, false) .thenCompose((res) -> tryToDeletePartitionedMetadata()) .thenRun(() -> log.info("[{}] Topic deleted successfully due to inactivity", topic)) .exceptionally(e -> { @@ -3637,7 +3640,7 @@ public void checkInactiveSubscriptions(long expirationTimeMillis) { subscriptions.forEach((subName, sub) -> { if (sub.dispatcher != null && sub.dispatcher.isConsumerConnected() || sub.isReplicated() - || isCompactionSubscription(subName)) { + || isSystemCursor(subName)) { return; } if (System.currentTimeMillis() - sub.cursor.getLastActive() > expirationTimeMillis) { @@ -3863,6 +3866,44 @@ public long getBestEffortOldestUnacknowledgedMessageAgeSeconds() { } } + long getBestEffortOldestUnacknowledgedMessageAgeSeconds(String subscriptionName) { + if (!brokerService.pulsar().getConfiguration().isExposeSubscriptionBacklogAgeInPrometheus()) { + return -1; + } + Map positionInfos = subscriptionOldestPositionInfos; + if (positionInfos == null) { + return -1; + } + OldestPositionInfo positionInfo = positionInfos.get(subscriptionName); + if (positionInfo == null) { + return -1; + } else { + return TimeUnit.MILLISECONDS.toSeconds( + Clock.systemUTC().millis() - positionInfo.getPositionPublishTimestampInMillis()); + } + } + + private Map getSubscriptionOldestPositionInfos() { + Map positionInfos = subscriptionOldestPositionInfos; + if (positionInfos == null) { + synchronized (this) { + positionInfos = subscriptionOldestPositionInfos; + if (positionInfos == null) { + positionInfos = new ConcurrentHashMap<>(); + subscriptionOldestPositionInfos = positionInfos; + } + } + } + return positionInfos; + } + + private void clearSubscriptionOldestPositionInfos() { + Map positionInfos = subscriptionOldestPositionInfos; + if (positionInfos != null) { + positionInfos.clear(); + } + } + private void updateResultIfNewer(OldestPositionInfo updatedResult) { TIME_BASED_BACKLOG_QUOTA_CHECK_RESULT_UPDATER.updateAndGet(this, existingResult -> { @@ -3877,6 +3918,213 @@ private void updateResultIfNewer(OldestPositionInfo updatedResult) { } + private void updateSubscriptionResultIfNewer(String subscriptionName, OldestPositionInfo updatedResult) { + getSubscriptionOldestPositionInfos().compute(subscriptionName, + (__, existingResult) -> { + if (existingResult == null + || ManagedCursorContainer.DataVersion.compareVersions( + updatedResult.getDataVersion(), existingResult.getDataVersion()) > 0) { + return updatedResult; + } else { + return existingResult; + } + }); + } + + private void clearSubscriptionResultIfNewer(String subscriptionName, long dataVersion) { + Map positionInfos = subscriptionOldestPositionInfos; + if (positionInfos == null) { + return; + } + positionInfos.computeIfPresent(subscriptionName, + (__, existingResult) -> + ManagedCursorContainer.DataVersion.compareVersions( + dataVersion, existingResult.getDataVersion()) >= 0 ? null : existingResult); + } + + private boolean isSubscriptionOldPositionInfoReusable(String subscriptionName, Position markDeletePosition) { + Map positionInfos = subscriptionOldestPositionInfos; + OldestPositionInfo positionInfo = positionInfos == null ? null : positionInfos.get(subscriptionName); + return positionInfo != null + && markDeletePosition.compareTo(positionInfo.getOldestCursorMarkDeletePosition()) == 0 + && markDeletePosition.compareTo(ledger.getFirstPosition()) >= 0; + } + + private CompletableFuture updateSubscriptionOldPositionInfos(ManagedCursorContainer managedCursorContainer, + boolean preciseTimeBasedBacklogQuotaCheck) { + Set activeSubscriptionNames = ConcurrentHashMap.newKeySet(); + Set failedSubscriptionNames = ConcurrentHashMap.newKeySet(); + List> futures = new ArrayList<>(); + CursorInfo oldestCursorInfo = managedCursorContainer.getCursorWithOldestPosition(); + long dataVersion = oldestCursorInfo == null ? 0 : oldestCursorInfo.getVersion(); + + for (Map.Entry subscriptionEntry : subscriptions.entrySet()) { + String subscriptionName = subscriptionEntry.getKey(); + PersistentSubscription subscription = subscriptionEntry.getValue(); + ManagedCursor cursor = subscription.getCursor(); + if (!cursor.isDurable()) { + clearSubscriptionResultIfNewer(subscriptionName, dataVersion); + continue; + } + activeSubscriptionNames.add(subscriptionName); + Position markDeletePosition = cursor.getMarkDeletedPosition(); + if (markDeletePosition == null) { + clearSubscriptionResultIfNewer(subscriptionName, dataVersion); + continue; + } + if (isSubscriptionOldPositionInfoReusable(subscriptionName, markDeletePosition)) { + continue; + } + if (subscription.getNumberOfEntriesInBacklog(preciseTimeBasedBacklogQuotaCheck) <= 0) { + clearSubscriptionResultIfNewer(subscriptionName, dataVersion); + continue; + } + + if (preciseTimeBasedBacklogQuotaCheck) { + futures.add(updateSubscriptionOldPositionInfoPrecise( + subscriptionName, subscription, cursor, markDeletePosition, dataVersion, + failedSubscriptionNames, preciseTimeBasedBacklogQuotaCheck)); + } else { + try { + EstimateTimeBasedBacklogQuotaCheckResult checkResult = + estimatedTimeBasedBacklogQuotaCheck(markDeletePosition); + if (checkResult.getEstimatedOldestUnacknowledgedMessageTimestamp() != null) { + updateSubscriptionResultIfNewer(subscriptionName, + new OldestPositionInfo( + markDeletePosition, + cursor.getName(), + checkResult.getEstimatedOldestUnacknowledgedMessageTimestamp(), + dataVersion)); + } else { + clearSubscriptionResultIfNewer(subscriptionName, dataVersion); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + failedSubscriptionNames.add(subscriptionName); + clearSubscriptionResultIfNewer(subscriptionName, dataVersion); + break; + } catch (ExecutionException e) { + failedSubscriptionNames.add(subscriptionName); + clearSubscriptionResultIfNewer(subscriptionName, dataVersion); + } + } + } + + Map positionInfos = subscriptionOldestPositionInfos; + if (positionInfos != null) { + positionInfos.keySet().removeIf(subscriptionName -> !activeSubscriptionNames.contains(subscriptionName)); + } + if (futures.isEmpty()) { + logSubscriptionOldPositionInfoUpdateFailures(failedSubscriptionNames); + return CompletableFuture.completedFuture(null); + } + return FutureUtil.waitForAll(futures) + .thenRun(() -> logSubscriptionOldPositionInfoUpdateFailures(failedSubscriptionNames)); + } + + private void logSubscriptionOldPositionInfoUpdateFailures(Set failedSubscriptionNames) { + if (!failedSubscriptionNames.isEmpty()) { + log.warn("Failed to update subscription old position info for {} subscriptions", + failedSubscriptionNames.size()); + } + } + + private CompletableFuture updateSubscriptionOldPositionInfoPrecise(String subscriptionName, + PersistentSubscription subscription, + ManagedCursor cursor, + Position markDeletePosition, + long dataVersion, + Set failedSubscriptionNames, + boolean preciseBacklogQuotaCheck) { + Position position = ledger.getNextValidPosition(markDeletePosition); + if (position.compareTo(ledger.getLastConfirmedEntry()) > 0) { + clearSubscriptionResultIfNewer(subscriptionName, dataVersion); + return CompletableFuture.completedFuture(null); + } + + CompletableFuture future = new CompletableFuture<>(); + ledger.asyncReadEntry(position, new AsyncCallbacks.ReadEntryCallback() { + @Override + public void readEntryComplete(Entry entry, Object ctx) { + try { + if (isSubscriptionOldPositionInfoReadResultStillValid( + subscriptionName, subscription, cursor, markDeletePosition, dataVersion, + preciseBacklogQuotaCheck)) { + long entryTimestamp = Commands.getEntryTimestamp(entry.getDataBuffer()); + updateSubscriptionResultIfNewer(subscriptionName, + new OldestPositionInfo( + markDeletePosition, + cursor.getName(), + entryTimestamp, + dataVersion)); + } + future.complete(null); + } catch (Exception e) { + failedSubscriptionNames.add(subscriptionName); + clearSubscriptionResultIfNewer(subscriptionName, dataVersion); + future.complete(null); + } finally { + entry.release(); + } + } + + @Override + public void readEntryFailed(ManagedLedgerException exception, Object ctx) { + failedSubscriptionNames.add(subscriptionName); + clearSubscriptionResultIfNewer(subscriptionName, dataVersion); + future.complete(null); + } + }, null); + return future; + } + + private boolean isSubscriptionOldPositionInfoReadResultStillValid(String subscriptionName, + PersistentSubscription subscription, + ManagedCursor cursor, + Position markDeletePosition, + long dataVersion, + boolean preciseTimeBasedBacklogQuotaCheck) { + PersistentSubscription currentSubscription = subscriptions.get(subscriptionName); + if (currentSubscription != subscription || currentSubscription.getCursor() != cursor) { + clearSubscriptionResultIfNewer(subscriptionName, dataVersion); + return false; + } + Position currentMarkDeletePosition = cursor.getMarkDeletedPosition(); + if (currentMarkDeletePosition == null) { + clearSubscriptionResultIfNewer(subscriptionName, dataVersion); + return false; + } + if (currentMarkDeletePosition.compareTo(markDeletePosition) != 0) { + clearSubscriptionResultIfNewer(subscriptionName, dataVersion); + return false; + } + if (subscription.getNumberOfEntriesInBacklog(preciseTimeBasedBacklogQuotaCheck) <= 0) { + clearSubscriptionResultIfNewer(subscriptionName, dataVersion); + return false; + } + return true; + } + + public CompletableFuture updateSubscriptionOldPositionInfo() { + if (!brokerService.pulsar().getConfiguration().isExposeSubscriptionBacklogAgeInPrometheus()) { + clearSubscriptionOldestPositionInfos(); + return CompletableFuture.completedFuture(null); + } + + if (!(ledger.getCursors() instanceof ManagedCursorContainer managedCursorContainer)) { + // TODO: support this method with a customized managed ledger implementation + return CompletableFuture.completedFuture(null); + } + + boolean preciseTimeBasedBacklogQuotaCheck = + brokerService.pulsar().getConfiguration().isPreciseTimeBasedBacklogQuotaCheck(); + if (!hasBacklogs(preciseTimeBasedBacklogQuotaCheck)) { + clearSubscriptionOldestPositionInfos(); + return CompletableFuture.completedFuture(null); + } + return updateSubscriptionOldPositionInfos(managedCursorContainer, preciseTimeBasedBacklogQuotaCheck); + } + public CompletableFuture updateOldPositionInfo() { TopicName topicName = TopicName.get(getName()); @@ -3885,7 +4133,9 @@ public CompletableFuture updateOldPositionInfo() { return CompletableFuture.completedFuture(null); } - if (!hasBacklogs(brokerService.pulsar().getConfiguration().isPreciseTimeBasedBacklogQuotaCheck())) { + boolean preciseTimeBasedBacklogQuotaCheck = + brokerService.pulsar().getConfiguration().isPreciseTimeBasedBacklogQuotaCheck(); + if (!hasBacklogs(preciseTimeBasedBacklogQuotaCheck)) { if (log.isDebugEnabled()) { log.debug("[{}] No backlog. Update old position info is null", topicName); } @@ -3893,7 +4143,7 @@ public CompletableFuture updateOldPositionInfo() { return CompletableFuture.completedFuture(null); } - // If we have no durable cursor since `ledger.getCursors()` only managed durable cursors + // If the oldest-cursor heap has no durable cursor, there is no topic-level oldest backlog position. CursorInfo oldestMarkDeleteCursorInfo = managedCursorContainer.getCursorWithOldestPosition(); if (oldestMarkDeleteCursorInfo == null || oldestMarkDeleteCursorInfo.getPosition() == null) { if (log.isDebugEnabled()) { @@ -3926,7 +4176,7 @@ public CompletableFuture updateOldPositionInfo() { } return CompletableFuture.completedFuture(null); } - if (brokerService.pulsar().getConfiguration().isPreciseTimeBasedBacklogQuotaCheck()) { + if (preciseTimeBasedBacklogQuotaCheck) { CompletableFuture future = new CompletableFuture<>(); // Check if first unconsumed message(first message after mark delete position) // for slowest cursor's has expired. @@ -4776,25 +5026,6 @@ private void updateSubscriptionsDispatcherRateLimiter() { }); } - protected CompletableFuture initTopicPolicy() { - final var topicPoliciesService = brokerService.pulsar().getTopicPoliciesService(); - final var partitionedTopicName = TopicName.getPartitionedTopicName(topic); - if (topicPoliciesService.registerListener(partitionedTopicName, this)) { - if (ExtensibleLoadManagerImpl.isInternalTopic(topic)) { - return CompletableFuture.completedFuture(null); - } - return topicPoliciesService.getTopicPoliciesAsync(partitionedTopicName, - TopicPoliciesService.GetType.GLOBAL_ONLY) - .thenAcceptAsync(optionalPolicies -> optionalPolicies.ifPresent(this::onUpdate), - brokerService.getTopicOrderedExecutor()) - .thenCompose(__ -> topicPoliciesService.getTopicPoliciesAsync(partitionedTopicName, - TopicPoliciesService.GetType.LOCAL_ONLY)) - .thenAcceptAsync(optionalPolicies -> optionalPolicies.ifPresent(this::onUpdate), - brokerService.getTopicOrderedExecutor()); - } - return CompletableFuture.completedFuture(null); - } - @VisibleForTesting public MessageDeduplication getMessageDeduplication() { return messageDeduplication; @@ -4843,6 +5074,10 @@ public boolean isTxnAborted(TxnID txnID, Position readPosition) { return this.transactionBuffer.isTxnAborted(txnID, readPosition); } + public boolean isTxnOngoing(TxnID txnID) { + return this.transactionBuffer.isTxnOngoing(txnID); + } + public TransactionInBufferStats getTransactionInBufferStats(TxnID txnID) { return this.transactionBuffer.getTransactionInBufferStats(txnID); } @@ -4962,4 +5197,5 @@ public void readEntryFailed(ManagedLedgerException exception, Object ctx) { return future; } + } diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/ReplicatedSubscriptionsController.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/ReplicatedSubscriptionsController.java index a156566d0feaa..d14cd1d2fe73d 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/ReplicatedSubscriptionsController.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/ReplicatedSubscriptionsController.java @@ -30,6 +30,7 @@ import java.util.Map; import java.util.Optional; import java.util.TreeMap; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ScheduledFuture; @@ -145,7 +146,6 @@ public void localSubscriptionUpdated(String subscriptionName, } private void receivedSnapshotRequest(ReplicatedSubscriptionsSnapshotRequest request) { - // if replicator producer is already closed, restart it to send snapshot response Replicator replicator = topic.getReplicators().get(request.getSourceCluster()); if (replicator == null) { log.warn("[{}] Received replicated subscription snapshot request {} from cluster {}, but no replicator is" @@ -153,9 +153,6 @@ private void receivedSnapshotRequest(ReplicatedSubscriptionsSnapshotRequest requ topic.getName(), request.getSnapshotId(), request.getSourceCluster()); return; } - if (!replicator.isConnected()) { - topic.startReplProducers(); - } // Send response containing the current last written message id. The response // marker we're publishing locally and then replicating will have a higher @@ -293,6 +290,10 @@ private void cleanupTimedOutSnapshots() { while (it.hasNext()) { Map.Entry entry = it.next(); if (entry.getValue().isTimedOut()) { + if (!pendingSnapshots.remove(entry.getKey(), entry.getValue())) { + continue; + } + if (log.isDebugEnabled()) { log.debug("[{}] Snapshot creation timed out for {}", topic.getName(), entry.getKey()); } @@ -301,23 +302,23 @@ private void cleanupTimedOutSnapshots() { timedoutSnapshotsMetric.inc(); var latencyMillis = entry.getValue().getDurationMillis(); stats.recordSnapshotTimedOut(latencyMillis); - it.remove(); } } } void snapshotCompleted(String snapshotId) { ReplicatedSubscriptionsSnapshotBuilder snapshot = pendingSnapshots.remove(snapshotId); - lastCompletedSnapshotId = snapshotId; + if (snapshot == null) { + return; + } - if (snapshot != null) { - lastCompletedSnapshotStartTime = snapshot.getStartTimeMillis(); + lastCompletedSnapshotId = snapshotId; + lastCompletedSnapshotStartTime = snapshot.getStartTimeMillis(); - pendingSnapshotsMetric.dec(); - var latencyMillis = snapshot.getDurationMillis(); - ReplicatedSubscriptionsSnapshotBuilder.SNAPSHOT_METRIC.observe(latencyMillis); - stats.recordSnapshotCompleted(latencyMillis); - } + pendingSnapshotsMetric.dec(); + var latencyMillis = snapshot.getDurationMillis(); + ReplicatedSubscriptionsSnapshotBuilder.SNAPSHOT_METRIC.observe(latencyMillis); + stats.recordSnapshotCompleted(latencyMillis); } void writeMarker(ByteBuf marker) { @@ -328,6 +329,35 @@ void writeMarker(ByteBuf marker) { } } + CompletableFuture writeMarkerAndGetPosition(ByteBuf marker) { + CompletableFuture future = new CompletableFuture<>(); + Topic.PublishContext publishContext = new Topic.PublishContext() { + @Override + public void completed(Exception e, long ledgerId, long entryId) { + ReplicatedSubscriptionsController.this.completed(e, ledgerId, entryId); + if (e != null) { + future.completeExceptionally(e); + } else { + future.complete(PositionFactory.create(ledgerId, entryId)); + } + } + + @Override + public boolean isMarkerMessage() { + return true; + } + }; + + try { + topic.publishMessage(marker, publishContext); + } catch (Exception e) { + publishContext.completed(e, -1, -1); + } finally { + marker.release(); + } + return future; + } + /** * From Topic.PublishContext. */ diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/ReplicatedSubscriptionsSnapshotBuilder.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/ReplicatedSubscriptionsSnapshotBuilder.java index e08b549f8aec9..df66794d751f8 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/ReplicatedSubscriptionsSnapshotBuilder.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/ReplicatedSubscriptionsSnapshotBuilder.java @@ -47,6 +47,7 @@ public class ReplicatedSubscriptionsSnapshotBuilder { private final boolean needTwoRounds; private boolean firstRoundComplete; + private boolean finalSnapshotMarkerPending; private long startTimeMillis; private final long timeoutMillis; @@ -116,15 +117,33 @@ synchronized void receivedSnapshotResponse(Position position, ReplicatedSubscrip return; } + if (finalSnapshotMarkerPending) { + return; + } + + finalSnapshotMarkerPending = true; if (log.isDebugEnabled()) { log.debug("[{}] Snapshot is complete {}", controller.topic().getName(), snapshotId); } // Snapshot is now complete, store it in the local topic Position p = position; - controller.writeMarker( + controller.writeMarkerAndGetPosition( Markers.newReplicatedSubscriptionsSnapshot(snapshotId, controller.localCluster(), - p.getLedgerId(), p.getEntryId(), responses)); - controller.snapshotCompleted(snapshotId); + p.getLedgerId(), p.getEntryId(), responses)) + .whenComplete((ignored, exception) -> { + if (exception != null) { + synchronized (ReplicatedSubscriptionsSnapshotBuilder.this) { + finalSnapshotMarkerPending = false; + } + if (log.isDebugEnabled()) { + log.debug("[{}] Failed to publish completed snapshot marker {}", + controller.topic().getName(), snapshotId, exception); + } + return; + } + + controller.snapshotCompleted(snapshotId); + }); } diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/ShadowReplicator.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/ShadowReplicator.java index a5d0c6216f6d4..638a60a4cb0de 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/ShadowReplicator.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/service/persistent/ShadowReplicator.java @@ -61,8 +61,7 @@ protected boolean replicateEntries(List entries, InFlightTask inFlightTas try { // This flag is set to true when we skip at least one local message, // in order to skip remaining local messages. - boolean isLocalMessageSkippedOnce = false; - boolean skipRemainingMessages = inFlightTask.isSkipReadResultDueToCursorRewind(); + boolean skipRemainingMessages = false; for (int i = 0; i < entries.size(); i++) { Entry entry = entries.get(i); // Skip the messages since the replicator need to fetch the schema info to replicate the schema to the @@ -99,14 +98,14 @@ protected boolean replicateEntries(List entries, InFlightTask inFlightTas continue; } - if (STATE_UPDATER.get(this) != State.Started || isLocalMessageSkippedOnce) { + if (STATE_UPDATER.get(this) != State.Started || inFlightTask.isSkipReadResultDueToCursorRewind()) { // The producer is not ready yet after having stopped/restarted. Drop the message because it will // recovered when the producer is ready if (log.isDebugEnabled()) { log.debug("[{}] Dropping read message at {} because producer is not ready", replicatorId, entry.getPosition()); } - isLocalMessageSkippedOnce = true; + skipRemainingMessages = true; inFlightTask.incCompletedEntries(); entry.release(); msg.recycle(); diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/stats/metrics/ManagedCursorMetrics.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/stats/metrics/ManagedCursorMetrics.java index 639f51ead6cee..118c173cff83b 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/stats/metrics/ManagedCursorMetrics.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/stats/metrics/ManagedCursorMetrics.java @@ -77,6 +77,14 @@ private List aggregate() { metrics.put("brk_ml_cursor_writeLedgerSize", cStats.getWriteCursorLedgerSize()); metrics.put("brk_ml_cursor_writeLedgerLogicalSize", cStats.getWriteCursorLedgerLogicalSize()); metrics.put("brk_ml_cursor_readLedgerSize", cStats.getReadCursorLedgerSize()); + metrics.put("brk_ml_cursor_ackCount", cStats.getAckCount()); + metrics.put("brk_ml_cursor_ackLatencyAvgMs", (long) cStats.getAckLatencyAvgMillis()); + metrics.put("brk_ml_cursor_persistCount", cStats.getPersistCount()); + metrics.put("brk_ml_cursor_persistLatencyAvgMs", (long) cStats.getPersistLatencyAvgMillis()); + metrics.put("brk_ml_cursor_recoverCount", cStats.getRecoverCount()); + metrics.put("brk_ml_cursor_recoverSucceed", cStats.getRecoverSucceed()); + metrics.put("brk_ml_cursor_recoverErrors", cStats.getRecoverErrors()); + metrics.put("brk_ml_cursor_recoverLatencyAvgMs", (long) cStats.getRecoverLatencyAvgMillis()); metricsCollection.add(metrics); } } diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/stats/prometheus/AggregatedSubscriptionStats.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/stats/prometheus/AggregatedSubscriptionStats.java index ca03e97c8396d..aa6a710341474 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/stats/prometheus/AggregatedSubscriptionStats.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/stats/prometheus/AggregatedSubscriptionStats.java @@ -29,6 +29,8 @@ public class AggregatedSubscriptionStats { public long msgBacklogNoDelayed; + public long backlogAgeSeconds = -1; + public boolean blockedSubscriptionOnUnackedMsgs; public double msgRateRedeliver; diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/stats/prometheus/NamespaceStatsAggregator.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/stats/prometheus/NamespaceStatsAggregator.java index 1531672bef1dd..45156985de407 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/stats/prometheus/NamespaceStatsAggregator.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/stats/prometheus/NamespaceStatsAggregator.java @@ -83,6 +83,8 @@ public static void generate(PulsarService pulsar, boolean includeTopicMetrics, b Optional compactorMXBean = getCompactorMXBean(pulsar); LongAdder topicsCount = new LongAdder(); Map localNamespaceTopicCount = new HashMap<>(); + boolean exposeSubscriptionBacklogAge = + pulsar.getConfiguration().isExposeSubscriptionBacklogAgeInPrometheus(); pulsar.getBrokerService().getMultiLayerTopicsMap().forEach((namespace, bundlesMap) -> { namespaceStats.reset(); topicsCount.reset(); @@ -99,7 +101,7 @@ public static void generate(PulsarService pulsar, boolean includeTopicMetrics, b if (includeTopicMetrics) { topicsCount.add(1); TopicStats.printTopicStats(stream, topicStats, compactorMXBean, cluster, namespace, name, - splitTopicAndPartitionIndexLabel); + splitTopicAndPartitionIndexLabel, exposeSubscriptionBacklogAge); } else { namespaceStats.updateStats(topicStats); } @@ -133,6 +135,7 @@ private static void aggregateTopicStats(TopicStats stats, SubscriptionStatsImpl subsStats.bytesOutCounter = subscriptionStats.bytesOutCounter; subsStats.msgOutCounter = subscriptionStats.msgOutCounter; subsStats.msgBacklog = subscriptionStats.msgBacklog; + subsStats.backlogAgeSeconds = subscriptionStats.oldestBacklogMessageAgeSeconds; subsStats.msgDelayed = subscriptionStats.msgDelayed; subsStats.msgInReplay = subscriptionStats.msgInReplay; subsStats.msgRateExpired = subscriptionStats.msgRateExpired; diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/stats/prometheus/TopicStats.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/stats/prometheus/TopicStats.java index 8b208e85514a2..d496db0c4ba47 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/stats/prometheus/TopicStats.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/stats/prometheus/TopicStats.java @@ -147,7 +147,8 @@ public void reset() { @SuppressWarnings("OptionalUsedAsFieldOrParameterType") public static void printTopicStats(PrometheusMetricStreams stream, TopicStats stats, Optional compactorMXBean, String cluster, String namespace, - String topic, boolean splitTopicAndPartitionIndexLabel) { + String topic, boolean splitTopicAndPartitionIndexLabel, + boolean exposeSubscriptionBacklogAge) { writeMetric(stream, "pulsar_subscriptions_count", stats.subscriptionsCount, cluster, namespace, topic, splitTopicAndPartitionIndexLabel); writeMetric(stream, "pulsar_producers_count", stats.producersCount, @@ -308,6 +309,10 @@ public static void printTopicStats(PrometheusMetricStreams stream, TopicStats st cluster, namespace, topic, sub, splitTopicAndPartitionIndexLabel); writeSubscriptionMetric(stream, "pulsar_subscription_back_log_no_delayed", subsStats.msgBacklogNoDelayed, cluster, namespace, topic, sub, splitTopicAndPartitionIndexLabel); + if (exposeSubscriptionBacklogAge && subsStats.backlogAgeSeconds >= 0) { + writeSubscriptionMetric(stream, "pulsar_subscription_storage_backlog_age_seconds", + subsStats.backlogAgeSeconds, cluster, namespace, topic, sub, splitTopicAndPartitionIndexLabel); + } writeSubscriptionMetric(stream, "pulsar_subscription_delayed", subsStats.msgDelayed, cluster, namespace, topic, sub, splitTopicAndPartitionIndexLabel); writeSubscriptionMetric(stream, "pulsar_subscription_in_replay", diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/stats/prometheus/metrics/DataSketchesSummaryLogger.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/stats/prometheus/metrics/DataSketchesSummaryLogger.java index 42c189d4bf3a7..7495f057aa007 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/stats/prometheus/metrics/DataSketchesSummaryLogger.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/stats/prometheus/metrics/DataSketchesSummaryLogger.java @@ -22,6 +22,7 @@ import com.yahoo.sketches.quantiles.DoublesUnion; import com.yahoo.sketches.quantiles.DoublesUnionBuilder; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.DoubleAdder; import java.util.concurrent.atomic.LongAdder; public class DataSketchesSummaryLogger { @@ -37,7 +38,7 @@ public class DataSketchesSummaryLogger { */ private volatile DoublesSketch values; private final LongAdder countAdder = new LongAdder(); - private final LongAdder sumAdder = new LongAdder(); + private final DoubleAdder sumAdder = new DoubleAdder(); public DataSketchesSummaryLogger() { this.current = new ThreadLocalAccessor(); @@ -48,7 +49,7 @@ public void registerEvent(long eventLatency, TimeUnit unit) { double valueMillis = unit.toMicros(eventLatency) / 1000.0; countAdder.increment(); - sumAdder.add((long) valueMillis); + sumAdder.add(valueMillis); current.getLocalData().updateSuccess(valueMillis); } @@ -69,7 +70,7 @@ public long getCount() { return countAdder.sum(); } - public long getSum() { + public double getSum() { return sumAdder.sum(); } diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/transaction/buffer/TransactionBuffer.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/transaction/buffer/TransactionBuffer.java index 886f58fdc18b7..6bd7593506da0 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/transaction/buffer/TransactionBuffer.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/transaction/buffer/TransactionBuffer.java @@ -149,6 +149,14 @@ public interface TransactionBuffer { */ boolean isTxnAborted(TxnID txnID, Position readPosition); + /** + * Check if the transaction is still ongoing (not committed and not aborted). + * + * @param txnID {@link TxnID} the transaction id. + * @return whether the txn is ongoing (uncommitted). + */ + boolean isTxnOngoing(TxnID txnID); + /** * Sync max read position for normal publish. * @param position {@link Position} the position to sync. diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/transaction/buffer/impl/InMemTransactionBuffer.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/transaction/buffer/impl/InMemTransactionBuffer.java index d07370002127a..9164c2bb5fc58 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/transaction/buffer/impl/InMemTransactionBuffer.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/transaction/buffer/impl/InMemTransactionBuffer.java @@ -380,6 +380,16 @@ public boolean isTxnAborted(TxnID txnID, Position readPosition) { return false; } + @Override + public boolean isTxnOngoing(TxnID txnID) { + TxnBuffer txnBuffer = buffers.get(txnID); + if (txnBuffer == null) { + return false; + } + TxnStatus status = txnBuffer.status(); + return status == TxnStatus.OPEN || status == TxnStatus.COMMITTING || status == TxnStatus.ABORTING; + } + @Override public void syncMaxReadPositionForNormalPublish(Position position, boolean isMarkerMessage) { if (!isMarkerMessage) { diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/transaction/buffer/impl/TopicTransactionBuffer.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/transaction/buffer/impl/TopicTransactionBuffer.java index 77d28a6165407..e33ac707f1f74 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/transaction/buffer/impl/TopicTransactionBuffer.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/transaction/buffer/impl/TopicTransactionBuffer.java @@ -150,7 +150,7 @@ private void recover() { public void recoverComplete() { synchronized (TopicTransactionBuffer.this) { if (ongoingTxns.isEmpty()) { - maxReadPosition = topic.getManagedLedger().getLastConfirmedEntry(); + updateMaxReadPositionAfterRecovery(); } if (!changeToReadyState()) { log.error("[{}]Transaction buffer recover fail, current state: {}", @@ -171,7 +171,7 @@ public void recoverComplete() { @Override public void noNeedToRecover() { synchronized (TopicTransactionBuffer.this) { - maxReadPosition = topic.getManagedLedger().getLastConfirmedEntry(); + updateMaxReadPositionAfterRecovery(); if (!changeToNoSnapshotState()) { log.error("[{}]Transaction buffer recover fail", topic.getName()); } else { @@ -624,6 +624,23 @@ void updateMaxReadPosition(Position newPosition, boolean disableCallback) { } } + /** + * Advance the max read position to the last confirmed entry when recovery finishes. While the transaction + * buffer is recovering, {@link #syncMaxReadPositionForNormalPublish(Position, boolean)} ignores publishes, so + * messages published during recovery would otherwise never trigger the maxReadPositionMovedForward callback. + * {@link PersistentTopic} uses that callback to maintain lastMaxReadPositionMovedForwardTimestamp, which + * ReplicatedSubscriptionsController relies on to detect new data when deciding whether to start a snapshot. + * Must be called while synchronized on this transaction buffer. + */ + private void updateMaxReadPositionAfterRecovery() { + Position preMaxReadPosition = this.maxReadPosition; + this.maxReadPosition = topic.getManagedLedger().getLastConfirmedEntry(); + if (this.maxReadPosition != null + && (preMaxReadPosition == null || preMaxReadPosition.compareTo(this.maxReadPosition) < 0)) { + this.maxReadPositionCallBack.maxReadPositionMovedForward(preMaxReadPosition, this.maxReadPosition); + } + } + @Override public CompletableFuture purgeTxns(List dataLedgers) { return null; @@ -665,6 +682,11 @@ public synchronized boolean isTxnAborted(TxnID txnID, Position readPosition) { return snapshotAbortedTxnProcessor.checkAbortedTransaction(txnID); } + @Override + public synchronized boolean isTxnOngoing(TxnID txnID) { + return ongoingTxns.containsKey(txnID); + } + /** * Sync max read position for normal publish. * @param position {@link Position} the position to sync. diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/transaction/buffer/impl/TransactionBufferDisable.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/transaction/buffer/impl/TransactionBufferDisable.java index 7d59ab5dd14a2..3718dd8edf0cd 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/transaction/buffer/impl/TransactionBufferDisable.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/transaction/buffer/impl/TransactionBufferDisable.java @@ -102,6 +102,11 @@ public boolean isTxnAborted(TxnID txnID, Position readPosition) { return false; } + @Override + public boolean isTxnOngoing(TxnID txnID) { + return false; + } + @Override public void syncMaxReadPositionForNormalPublish(Position position, boolean isMarkerMessage) { if (!isMarkerMessage) { diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/broker/web/RequestWrapper.java b/pulsar-broker/src/main/java/org/apache/pulsar/broker/web/RequestWrapper.java index afebbd276eba8..0530f46abbb51 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/broker/web/RequestWrapper.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/broker/web/RequestWrapper.java @@ -36,36 +36,63 @@ */ public class RequestWrapper extends HttpServletRequestWrapper { - private final byte[] body; + private byte[] body; + private ServletInputStream inputStream; public RequestWrapper(HttpServletRequest request) throws IOException { super(request); - body = IOUtils.toByteArray(new InputStreamReader(request.getInputStream()), Charset.defaultCharset()); + } + + /** + * Buffers the request body on first access. Buffering is lazy on purpose: an interceptor that + * does not read the body (the common case) must not cause the body to be buffered at all, so the + * underlying request stream is consumed only once, by the downstream resource (Jersey). The read + * is bounded to Content-Length so it never reads past the request body. + */ + private byte[] body() throws IOException { + if (body == null) { + HttpServletRequest request = (HttpServletRequest) getRequest(); + int contentLength = request.getContentLength(); + if (contentLength >= 0) { + body = IOUtils.toByteArray(request.getInputStream(), contentLength); + } else { + body = IOUtils.toByteArray(request.getInputStream()); + } + } + return body; } @Override public ServletInputStream getInputStream() throws IOException { - final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body); - return new ServletInputStream() { - @Override - public boolean isFinished() { - return false; - } + if (inputStream == null) { + // Return a single, stable stream over the buffered body. Repeated getInputStream() calls + // must return the same stream (per the Servlet contract); returning a fresh stream each + // time lets a reader that re-fetches the stream after EOF read the body's first byte + // again, surfacing as a spurious "Trailing token" error. + final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body()); + inputStream = new ServletInputStream() { + @Override + public boolean isFinished() { + return byteArrayInputStream.available() == 0; + } - @Override - public boolean isReady() { - return true; - } + @Override + public boolean isReady() { + return true; + } - @Override - public void setReadListener(ReadListener readListener) { + @Override + public void setReadListener(ReadListener readListener) { - } + } - public int read() { - return byteArrayInputStream.read(); - } - }; + @Override + public int read() { + return byteArrayInputStream.read(); + } + }; + } + return inputStream; } @Override @@ -74,7 +101,7 @@ public BufferedReader getReader() throws IOException { } //Use this method to read the request body N times - public byte[] getBody() { - return this.body; + public byte[] getBody() throws IOException { + return body(); } } diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/client/impl/RawReaderImpl.java b/pulsar-broker/src/main/java/org/apache/pulsar/client/impl/RawReaderImpl.java index c3f8254fb5e30..940ec5dfe6011 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/client/impl/RawReaderImpl.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/client/impl/RawReaderImpl.java @@ -41,6 +41,7 @@ import org.apache.pulsar.common.api.proto.MessageMetadata; import org.apache.pulsar.common.naming.TopicName; import org.apache.pulsar.common.protocol.Commands; +import org.apache.pulsar.common.util.FutureUtil; import org.apache.pulsar.common.util.collections.GrowableArrayBlockingQueue; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -160,6 +161,25 @@ protected boolean isUnrecoverableError(Throwable t) { return super.isUnrecoverableError(t); } + @Override + public boolean connectionFailed(PulsarClientException exception) { + // A compaction reader is created with retryOnRecoverableErrors=false. When the topic is fenced or + // deleted, a reconnect can fail at the lookup/connection stage with a retriable error such as + // ServiceNotReadyException. The base ConsumerImpl.connectionFailed only consults isUnrecoverableError + // for non-retriable errors (or after the lookup deadline has passed), so for such a retriable error it + // would keep reconnecting and never fail the in-flight read, leaving the compaction future pending. + // Honor isUnrecoverableError here too so the reader is closed promptly and pending reads are failed, + // mirroring the subscribe-stage handling in ConsumerImpl.connectionOpened(). This matters for + // compaction: a never-failing read keeps the compaction future pending, which blocks forced + // topic/namespace deletion (issue #24148). + Throwable actError = FutureUtil.unwrapCompletionException(exception); + if (isUnrecoverableError(actError)) { + closeWhenReceivedUnrecoverableError(actError, null); + return false; + } + return super.connectionFailed(exception); + } + void tryCompletePending() { CompletableFuture future = null; RawMessageAndCnx messageAndCnx = null; @@ -230,6 +250,17 @@ CompletableFuture receiveRawAsync() { CompletableFuture result = new CompletableFuture<>(); pendingRawReceives.add(result); tryCompletePending(); + // Once the consumer has reached a terminal state (for example it was closed after an + // unrecoverable error such as the topic or namespace being deleted), no further message + // will arrive and no close callback will run for receives enqueued from now on, so the + // future would never complete. Re-checking the state after enqueueing closes the race + // with a concurrent close() draining the queue, since close() drains only after moving to + // a terminal state. This matters for compaction: a never-completing read leaves the + // compaction future pending, which in turn blocks forced topic/namespace deletion. + State state = getState(); + if (state == State.Closing || state == State.Closed || state == State.Failed) { + failPendingRawReceives(); + } return result; } diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/common/naming/NamespaceBundleFactory.java b/pulsar-broker/src/main/java/org/apache/pulsar/common/naming/NamespaceBundleFactory.java index 69f5208ce6711..846f64c32c097 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/common/naming/NamespaceBundleFactory.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/common/naming/NamespaceBundleFactory.java @@ -56,6 +56,7 @@ import org.apache.pulsar.common.policies.data.Policies; import org.apache.pulsar.common.stats.CacheMetricsCollector; import org.apache.pulsar.common.util.Backoff; +import org.apache.pulsar.common.util.FutureUtil; import org.apache.pulsar.metadata.api.Notification; import org.apache.pulsar.policies.data.loadbalancer.BundleData; import org.slf4j.Logger; @@ -244,19 +245,25 @@ public NamespaceBundle getBundle(TopicName topic) { public CompletableFuture getBundleWithHighestThroughputAsync(NamespaceName nsName) { LoadManager loadManager = pulsar.getLoadManager().get(); if (loadManager instanceof ModularLoadManagerWrapper) { - return getBundlesAsync(nsName).thenApply(bundles -> { - double maxMsgThroughput = -1; - NamespaceBundle bundleWithHighestThroughput = null; - for (NamespaceBundle bundle : bundles.getBundles()) { - BundleData bundleData = ((ModularLoadManagerWrapper) loadManager).getLoadManager() - .getBundleDataOrDefault(bundle.toString()); - if (bundleData.getTopics() > 0 - && bundleData.getLongTermData().totalMsgThroughput() > maxMsgThroughput) { - maxMsgThroughput = bundleData.getLongTermData().totalMsgThroughput(); - bundleWithHighestThroughput = bundle; + return getBundlesAsync(nsName).thenCompose(bundles -> { + List bundleList = bundles.getBundles(); + List> bundleDataFutures = bundleList.stream() + .map(bundle -> ((ModularLoadManagerWrapper) loadManager).getLoadManager() + .getBundleDataOrDefaultAsync(bundle.toString())) + .toList(); + return FutureUtil.waitForAll(bundleDataFutures).thenApply(__ -> { + double maxMsgThroughput = -1; + NamespaceBundle bundleWithHighestThroughput = null; + for (int i = 0; i < bundleList.size(); i++) { + BundleData bundleData = bundleDataFutures.get(i).join(); + if (bundleData.getTopics() > 0 + && bundleData.getLongTermData().totalMsgThroughput() > maxMsgThroughput) { + maxMsgThroughput = bundleData.getLongTermData().totalMsgThroughput(); + bundleWithHighestThroughput = bundleList.get(i); + } } - } - return bundleWithHighestThroughput; + return bundleWithHighestThroughput; + }); }); } return getBundleWithHighestTopicsAsync(nsName); diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/compaction/AbstractTwoPhaseCompactor.java b/pulsar-broker/src/main/java/org/apache/pulsar/compaction/AbstractTwoPhaseCompactor.java index f830d16fe162b..2550adac3aa19 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/compaction/AbstractTwoPhaseCompactor.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/compaction/AbstractTwoPhaseCompactor.java @@ -446,14 +446,24 @@ private CompletableFuture addToCompactedLedger(LedgerHandle lh, RawMessage return bkf; } + /** + * Extract the partition key and the payload size for a non-batch message. + * + * @return a pair of (partitionKey, payloadSize), or null if the message has no partition key. + */ protected Pair extractKeyAndSize(RawMessage m, MessageMetadata msgMetadata) { - ByteBuf headersAndPayload = m.getHeadersAndPayload(); if (msgMetadata.hasPartitionKey()) { - int size = headersAndPayload.readableBytes(); - if (msgMetadata.hasUncompressedSize()) { - size = msgMetadata.getUncompressedSize(); + int payloadSize; + if (msgMetadata.hasNullValue() && msgMetadata.isNullValue()) { + payloadSize = 0; + } else if (msgMetadata.hasUncompressedSize()) { + payloadSize = msgMetadata.getUncompressedSize(); + } else { + ByteBuf headersAndPayload = m.getHeadersAndPayload().duplicate(); + Commands.skipMessageMetadata(headersAndPayload); + payloadSize = headersAndPayload.readableBytes(); } - return Pair.of(msgMetadata.getPartitionKey(), size); + return Pair.of(msgMetadata.getPartitionKey(), payloadSize); } else { return null; } diff --git a/pulsar-broker/src/main/java/org/apache/pulsar/compaction/EventTimeOrderCompactor.java b/pulsar-broker/src/main/java/org/apache/pulsar/compaction/EventTimeOrderCompactor.java index db129b54533a8..ad6b5f28d306f 100644 --- a/pulsar-broker/src/main/java/org/apache/pulsar/compaction/EventTimeOrderCompactor.java +++ b/pulsar-broker/src/main/java/org/apache/pulsar/compaction/EventTimeOrderCompactor.java @@ -18,7 +18,6 @@ */ package org.apache.pulsar.compaction; -import io.netty.buffer.ByteBuf; import java.io.IOException; import java.util.List; import java.util.Map; @@ -139,17 +138,12 @@ protected boolean compactBatchMessage(String topic, Map keyAndSize = extractKeyAndSize(m, metadata); + if (keyAndSize == null) { return null; } + return new MessageCompactionData(m.getMessageId(), keyAndSize.getLeft(), + keyAndSize.getRight(), metadata.getEventTime()); } private List extractMessageCompactionDataFromBatch(RawMessage msg, MessageMetadata metadata) diff --git a/pulsar-broker/src/test/java/org/apache/bookkeeper/mledger/impl/PersistentMessageExpiryMonitorTest.java b/pulsar-broker/src/test/java/org/apache/bookkeeper/mledger/impl/PersistentMessageExpiryMonitorTest.java index 39aec66726e32..e487d4c1c8a22 100644 --- a/pulsar-broker/src/test/java/org/apache/bookkeeper/mledger/impl/PersistentMessageExpiryMonitorTest.java +++ b/pulsar-broker/src/test/java/org/apache/bookkeeper/mledger/impl/PersistentMessageExpiryMonitorTest.java @@ -24,7 +24,6 @@ import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.spy; import static org.testng.AssertJUnit.assertEquals; -import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -100,7 +99,7 @@ void testConcurrentlyExpireMessages() throws Exception { } }); return true; - }).when(spyCursor).asyncMarkDelete(any(Position.class), any(Map.class), + }).when(spyCursor).asyncMarkDelete(any(Position.class), any(), any(AsyncCallbacks.MarkDeleteCallback.class), any()); doAnswer(invocationOnMock -> { calledFindPositionCount.incrementAndGet(); diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/SameAuthParamsLookupAutoClusterFailoverTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/SameAuthParamsLookupAutoClusterFailoverTest.java index febd0c4aad0fa..eb091ff9f1f4f 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/SameAuthParamsLookupAutoClusterFailoverTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/SameAuthParamsLookupAutoClusterFailoverTest.java @@ -21,11 +21,11 @@ import static org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest.CA_CERT_FILE_PATH; import static org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest.getTlsFileForClient; import static org.apache.pulsar.client.impl.SameAuthParamsLookupAutoClusterFailover.PulsarServiceState; -import io.netty.channel.EventLoopGroup; import java.net.ServerSocket; import java.util.HashMap; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import org.apache.pulsar.broker.service.NetworkErrorTestBase; import org.apache.pulsar.broker.service.OneWayReplicatorTestBase; @@ -63,11 +63,11 @@ public Object[][] enabledTls () { }; } - // Each state-convergence phase below waits up to 2 minutes. With 3 phases plus - // cluster startup/teardown, the 240s overall timeout can be tight on slow CI agents - // (the probe timeout is 3s and recoverThreshold=5, so a single slow probe can - // stretch a phase to ~30s). Allow 8 minutes overall to match the per-phase budget. - @Test(dataProvider = "enabledTls", timeOut = 480 * 1000) + // Each state-convergence phase below waits up to 3 minutes. The probe timeout is 3s + // and recoverThreshold=5, so a transient probe failure during recovery resets the + // counter and a phase can need ~30s of healthy probes to recover. With 3 phases plus + // cluster startup/teardown, allow 12 minutes overall to absorb slow CI agents. + @Test(dataProvider = "enabledTls", timeOut = 720 * 1000) public void testAutoClusterFailover(boolean enabledTls) throws Exception { // Start clusters. setup(); @@ -98,8 +98,7 @@ public void testAutoClusterFailover(boolean enabledTls) throws Exception { .tlsTrustCertsFilePath(CA_CERT_FILE_PATH); } final PulsarClient client = clientBuilder.build(); - failover.initialize(client); - final EventLoopGroup executor = WhiteboxImpl.getInternalState(failover, "executor"); + final ScheduledExecutorService executor = WhiteboxImpl.getInternalState(failover, "executor"); final PulsarServiceState[] stateArray = WhiteboxImpl.getInternalState(failover, "pulsarServiceStateArray"); @@ -109,63 +108,30 @@ public void testAutoClusterFailover(boolean enabledTls) throws Exception { producer.send("0"); Assert.assertEquals(failover.getCurrentPulsarServiceIndex(), 0); - CompletableFuture checkStatesFuture1 = new CompletableFuture<>(); - executor.submit(() -> { - boolean res = stateArray[0] == PulsarServiceState.Healthy; - res = res & stateArray[1] == PulsarServiceState.Healthy; - res = res & stateArray[2] == PulsarServiceState.Healthy; - checkStatesFuture1.complete(res); - }); - Assert.assertTrue(checkStatesFuture1.join()); + assertStatesEqual(executor, stateArray, + PulsarServiceState.Healthy, PulsarServiceState.Healthy, PulsarServiceState.Healthy); // Test failover 0 --> 2. pulsar1.close(); - Awaitility.await().atMost(120, TimeUnit.SECONDS).untilAsserted(() -> { - CompletableFuture checkStatesFuture2 = new CompletableFuture<>(); - executor.submit(() -> { - boolean res = stateArray[0] == PulsarServiceState.Failed; - res = res & stateArray[1] == PulsarServiceState.Failed; - res = res & stateArray[2] == PulsarServiceState.Healthy; - checkStatesFuture2.complete(res); - }); - Assert.assertTrue(checkStatesFuture2.join()); - producer.send("0->2"); - Assert.assertEquals(failover.getCurrentPulsarServiceIndex(), 2); - }); + awaitStatesAndIndex(executor, stateArray, failover, 2, + PulsarServiceState.Failed, PulsarServiceState.Failed, PulsarServiceState.Healthy); + producer.send("0->2"); // Test recover 2 --> 1. executor.execute(() -> { urlArray[1] = url2; }); - Awaitility.await().atMost(120, TimeUnit.SECONDS).untilAsserted(() -> { - CompletableFuture checkStatesFuture3 = new CompletableFuture<>(); - executor.submit(() -> { - boolean res = stateArray[0] == PulsarServiceState.Failed; - res = res & stateArray[1] == PulsarServiceState.Healthy; - res = res & stateArray[2] == PulsarServiceState.Healthy; - checkStatesFuture3.complete(res); - }); - Assert.assertTrue(checkStatesFuture3.join()); - producer.send("2->1"); - Assert.assertEquals(failover.getCurrentPulsarServiceIndex(), 1); - }); + awaitStatesAndIndex(executor, stateArray, failover, 1, + PulsarServiceState.Failed, PulsarServiceState.Healthy, PulsarServiceState.Healthy); + producer.send("2->1"); // Test recover 1 --> 0. executor.execute(() -> { urlArray[0] = url2; }); - Awaitility.await().atMost(120, TimeUnit.SECONDS).untilAsserted(() -> { - CompletableFuture checkStatesFuture4 = new CompletableFuture<>(); - executor.submit(() -> { - boolean res = stateArray[0] == PulsarServiceState.Healthy; - res = res & stateArray[1] == PulsarServiceState.Healthy; - res = res & stateArray[2] == PulsarServiceState.Healthy; - checkStatesFuture4.complete(res); - }); - Assert.assertTrue(checkStatesFuture4.join()); - producer.send("1->0"); - Assert.assertEquals(failover.getCurrentPulsarServiceIndex(), 0); - }); + awaitStatesAndIndex(executor, stateArray, failover, 0, + PulsarServiceState.Healthy, PulsarServiceState.Healthy, PulsarServiceState.Healthy); + producer.send("1->0"); // cleanup. producer.close(); @@ -173,6 +139,44 @@ public void testAutoClusterFailover(boolean enabledTls) throws Exception { dummyServer.close(); } + @Test + public void testInitializeCanOnlyBeCalledOnce() throws Exception { + setup(); + final SameAuthParamsLookupAutoClusterFailover failover = SameAuthParamsLookupAutoClusterFailover.builder() + .pulsarServiceUrlArray(new String[]{pulsar1.getBrokerServiceUrl()}) + .checkHealthyIntervalMs(1000) + .build(); + + try (PulsarClient client = PulsarClient.builder().serviceUrlProvider(failover).build()) { + Throwable error = Assert.expectThrows(IllegalStateException.class, () -> failover.initialize(client)); + Assert.assertEquals(error.getMessage(), "ServiceUrlProvider has already been initialized"); + } + } + + /** + * Wait for the state machine to converge to the expected per-index states and current index. + * The state read happens on the failover executor to avoid races with the periodic check task, + * and producer/lookup operations are kept out of the polling loop so a slow message send does + * not consume the convergence budget. + */ + private static void awaitStatesAndIndex(ScheduledExecutorService executor, PulsarServiceState[] stateArray, + SameAuthParamsLookupAutoClusterFailover failover, + int expectedIndex, + PulsarServiceState... expectedStates) { + Awaitility.await().atMost(180, TimeUnit.SECONDS).untilAsserted(() -> { + assertStatesEqual(executor, stateArray, expectedStates); + Assert.assertEquals(failover.getCurrentPulsarServiceIndex(), expectedIndex); + }); + } + + private static void assertStatesEqual(ScheduledExecutorService executor, PulsarServiceState[] stateArray, + PulsarServiceState... expected) throws Exception { + CompletableFuture snapshot = new CompletableFuture<>(); + executor.submit(() -> snapshot.complete(stateArray.clone())); + PulsarServiceState[] actual = snapshot.get(10, TimeUnit.SECONDS); + Assert.assertEquals(actual, expected); + } + @Override protected void cleanupPulsarResources() { // Nothing to do. diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/AdminApi2Test.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/AdminApi2Test.java index c3703c5e9b938..00c5f076d4c5c 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/AdminApi2Test.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/AdminApi2Test.java @@ -27,6 +27,7 @@ import static org.apache.pulsar.common.policies.data.NamespaceIsolationPolicyUnloadScope.none; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -40,7 +41,6 @@ import static org.testng.Assert.fail; import com.google.common.collect.Lists; import com.google.common.collect.Sets; -import java.lang.reflect.Field; import java.net.URI; import java.net.URL; import java.net.http.HttpClient; @@ -69,7 +69,8 @@ import lombok.Cleanup; import lombok.Data; import lombok.extern.slf4j.Slf4j; -import org.apache.bookkeeper.mledger.ManagedLedger; +import org.apache.bookkeeper.mledger.AsyncCallbacks; +import org.apache.bookkeeper.mledger.ManagedLedgerException; import org.apache.bookkeeper.mledger.impl.ManagedCursorImpl; import org.apache.bookkeeper.mledger.impl.ManagedLedgerImpl; import org.apache.commons.lang3.reflect.FieldUtils; @@ -1230,6 +1231,42 @@ public void testUpdatePropertiesOnNonExistentTopic() throws Exception { } } + @Test + public void testGetInternalStatsWithProperties() throws Exception { + final var namespace = newUniqueName(defaultTenant + "/ns2"); + final var topicName = "persistent://" + namespace + "/testGetInternalStatsWithProperties"; + admin.namespaces().createNamespace(namespace); + + final var topicProperties = Map.of("key1", "value1", "key2", "value2"); + admin.topics().createNonPartitionedTopic(topicName, topicProperties); + + var stats = admin.topics().getInternalStats(topicName); + assertEquals(stats.properties, topicProperties); + + var persistentTopic = (PersistentTopic) pulsar.getBrokerService().getTopicIfExists(topicName).get() + .orElseThrow(); + final var future = new CompletableFuture>(); + persistentTopic.getManagedLedger().asyncSetProperty("new-key", "new-value", + new AsyncCallbacks.UpdatePropertiesCallback() { + @Override + public void updatePropertiesComplete(Map properties, Object ctx) { + future.complete(properties); + } + + @Override + public void updatePropertiesFailed(ManagedLedgerException exception, Object ctx) { + future.completeExceptionally(exception); + } + }, null); + assertEquals(future.get(), Map.of("key1", "value1", "key2", "value2", "new-key", "new-value")); + + admin.namespaces().unload(namespace); + persistentTopic = (PersistentTopic) pulsar.getBrokerService().getTopic(topicName, true).get() + .orElseThrow(); + stats = admin.topics().getInternalStats(topicName); + assertEquals(stats.properties, Map.of("key1", "value1", "key2", "value2", "new-key", "new-value")); + } + @Test public void testNonPersistentTopics() throws Exception { final String namespace = newUniqueName(defaultTenant + "/ns2"); @@ -3202,11 +3239,14 @@ public void testCompactionPriority() throws Exception { final String namespace = newUniqueName(defaultTenant + "/ns"); admin.namespaces().createNamespace(namespace, Set.of("test")); final String topic = "persistent://" + namespace + "/topic" + UUID.randomUUID(); - pulsarClient.newProducer().topic(topic).create().close(); + @Cleanup + Producer producer = pulsarClient.newProducer().topic(topic).create(); + producer.send("message".getBytes()); TopicName topicName = TopicName.get(topic); PersistentTopic persistentTopic = (PersistentTopic) pulsar.getBrokerService() .getTopicIfExists(topic).get().get(); PersistentTopic mockTopic = spy(persistentTopic); + doNothing().when(mockTopic).triggerCompaction(); mockTopic.checkCompaction(); // Disabled by default verify(mockTopic, times(0)).triggerCompaction(); @@ -3214,27 +3254,31 @@ public void testCompactionPriority() throws Exception { admin.namespaces().setCompactionThreshold(namespace, 1); Awaitility.await().untilAsserted(() -> assertNotNull(admin.namespaces().getCompactionThreshold(namespace))); - ManagedLedger managedLedger = persistentTopic.getManagedLedger(); - Field field = managedLedger.getClass().getDeclaredField("totalSize"); - field.setAccessible(true); - field.setLong(managedLedger, 1000L); + Awaitility.await().untilAsserted(() -> assertTrue(persistentTopic.isCompactionEnabled())); - mockTopic.checkCompaction(); - verify(mockTopic, times(1)).triggerCompaction(); + Awaitility.await().untilAsserted(() -> { + mockTopic.checkCompaction(); + verify(mockTopic, times(1)).triggerCompaction(); + }); //Set topic-level policy admin.topics().setCompactionThreshold(topic, 0); Awaitility.await().untilAsserted(() -> assertNotNull(admin.topics().getCompactionThreshold(topic))); + Awaitility.await().untilAsserted(() -> assertFalse(persistentTopic.isCompactionEnabled())); mockTopic.checkCompaction(); verify(mockTopic, times(1)).triggerCompaction(); // Remove topic-level policy admin.topics().removeCompactionThreshold(topic); Awaitility.await().untilAsserted(() -> assertNull(admin.topics().getCompactionThreshold(topic))); - mockTopic.checkCompaction(); - verify(mockTopic, times(2)).triggerCompaction(); + Awaitility.await().untilAsserted(() -> assertTrue(persistentTopic.isCompactionEnabled())); + Awaitility.await().untilAsserted(() -> { + mockTopic.checkCompaction(); + verify(mockTopic, times(2)).triggerCompaction(); + }); // Remove namespace-level policy admin.namespaces().removeCompactionThreshold(namespace); Awaitility.await().untilAsserted(() -> assertNull(admin.namespaces().getCompactionThreshold(namespace))); + Awaitility.await().untilAsserted(() -> assertFalse(persistentTopic.isCompactionEnabled())); mockTopic.checkCompaction(); verify(mockTopic, times(2)).triggerCompaction(); } diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/AdminApiTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/AdminApiTest.java index 0fdc42957c130..9212db6634053 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/AdminApiTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/AdminApiTest.java @@ -159,6 +159,8 @@ public class AdminApiTest extends MockedPulsarServiceBaseTest { private static final Logger LOG = LoggerFactory.getLogger(AdminApiTest.class); + private static final String BROKER_SHUTDOWN_TIMEOUT_MS = "brokerShutdownTimeoutMs"; + private MockedPulsarService mockPulsarSetup; private PulsarService otherPulsar; @@ -221,6 +223,14 @@ private void setupConfigAndStart(java.util.function.Consumer assertNotEquals(pulsar.getConfiguration().getBrokerShutdownTimeoutMs(), overriddenTimeoutMs)); + } + private void setupClusters() throws PulsarAdminException { admin.clusters().createCluster("test", ClusterData.builder().serviceUrl(pulsar.getWebServiceAddress()).build()); TenantInfoImpl tenantInfo = new TenantInfoImpl(Set.of("role1", "role2"), Set.of("test")); @@ -759,6 +788,8 @@ public void testGetDynamicLocalConfiguration() throws Exception { admin.brokers().updateDynamicConfiguration(configName, Long.toString(shutdownTime)); // Now, znode is created: updateConfigurationAndRegisterListeners and check if configuration updated assertEquals(Long.parseLong(admin.brokers().getAllDynamicConfigurations().get(configName)), shutdownTime); + // wait until the broker has applied the value, so that the @AfterMethod always has to restore it + Awaitility.await().until(() -> pulsar.getConfiguration().getBrokerShutdownTimeoutMs() == shutdownTime); } @Test diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/IncrementPartitionsTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/IncrementPartitionsTest.java index 091f4ae6c07df..4b2938982eafa 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/IncrementPartitionsTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/IncrementPartitionsTest.java @@ -18,7 +18,9 @@ */ package org.apache.pulsar.broker.admin; +import static org.apache.bookkeeper.mledger.ManagedCursor.CURSOR_INTERNAL_PROPERTY_PREFIX; import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -30,6 +32,7 @@ import org.apache.pulsar.broker.BrokerTestUtil; import org.apache.pulsar.broker.admin.AdminApiTest.MockedPulsarService; import org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest; +import org.apache.pulsar.broker.service.persistent.PersistentTopic; import org.apache.pulsar.client.api.Consumer; import org.apache.pulsar.client.api.MessageId; import org.apache.pulsar.client.api.Producer; @@ -150,6 +153,39 @@ public void testIncrementPartitionsOfTopicWithSubscriptionProperties() throws Ex Assert.assertEquals(properties, subscriptionProperties); } + @Test + public void testIncrementPartitionsDoesNotCopyInternalCursorProperties() throws Exception { + String partitionedTopicName = UUID.randomUUID() + + "-testIncrementPartitionsDoesNotCopyInternalCursorProperties"; + String subscriptionName = "sub-1"; + Map subscriptionProperties = Map.of("property", "value"); + + admin.topics().createPartitionedTopic(partitionedTopicName, 1); + @Cleanup + Consumer consumer = pulsarClient.newConsumer() + .topic(partitionedTopicName) + .subscriptionName(subscriptionName) + .subscriptionProperties(subscriptionProperties) + .subscribe(); + + String sourcePartition = TopicName.get(partitionedTopicName).getPartition(0).toString(); + PersistentTopic sourceTopic = (PersistentTopic) pulsar.getBrokerService().getTopicReference(sourcePartition) + .orElseThrow(); + String internalProperty = CURSOR_INTERNAL_PROPERTY_PREFIX + "test"; + sourceTopic.getSubscription(subscriptionName).getCursor() + .putCursorProperty(internalProperty, "internal-value").get(); + + admin.topics().updatePartitionedTopic(partitionedTopicName, 2); + + String newPartition = TopicName.get(partitionedTopicName).getPartition(1).toString(); + PersistentTopic newTopic = (PersistentTopic) pulsar.getBrokerService().getTopicReference(newPartition) + .orElseThrow(); + Map newCursorProperties = newTopic.getSubscription(subscriptionName) + .getCursor().getCursorProperties(); + assertEquals(newCursorProperties, subscriptionProperties); + assertFalse(newCursorProperties.containsKey(internalProperty)); + } + @Test public void testIncrementPartitionsWithNoSubscriptions() throws Exception { final String partitionedTopicName = diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/MetadataStoreTopicPoliciesTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/MetadataStoreTopicPoliciesTest.java new file mode 100644 index 0000000000000..b6927cd5ee8e5 --- /dev/null +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/MetadataStoreTopicPoliciesTest.java @@ -0,0 +1,80 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.pulsar.broker.admin; + +import org.apache.pulsar.broker.service.MetadataStoreTopicPoliciesService; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.Test; + +@Test(groups = "broker-admin") +public class MetadataStoreTopicPoliciesTest extends TopicPoliciesTest { + + @BeforeClass(alwaysRun = true) + @Override + protected void setup() throws Exception { + conf.setTopicPoliciesServiceClassName(MetadataStoreTopicPoliciesService.class.getName()); + super.setup(); + } + + @Override + protected void clearTopicPoliciesCache() { + } + + @Test(enabled = false) + @Override + public void testTopicPolicyInitialValueWithNamespaceAlreadyLoaded() throws Exception { + // This test is specific to SystemTopicBasedTopicPoliciesService (uses getPoliciesCacheInit). + // Not applicable to MetadataStoreTopicPoliciesService. + } + + @Test(enabled = false) + @Override + public void testNonPersistentTopicAppliesTopicPolicyOnLoad() throws Exception { + // This test is specific to SystemTopicBasedTopicPoliciesService (casts the service and uses + // getPoliciesCacheInit). The non-persistent load-path behavior itself is backend-agnostic and is + // covered against the default SystemTopicBasedTopicPoliciesService in TopicPoliciesTest. + } + + @Test(enabled = false) + @Override + public void testSystemTopicShouldBeCompacted() throws Exception { + // Relies on __change_events system topic, which does not exist with MetadataStoreTopicPoliciesService. + } + + @Test(enabled = false) + @Override + public void testPoliciesCanBeDeletedWithTopic() throws Exception { + // Directly accesses __change_events PersistentTopic for compaction. + // Not applicable to MetadataStoreTopicPoliciesService. + } + + @Test(enabled = false) + @Override + public void testProduceChangesWithEncryptionRequired() throws Exception { + // Checks __change_events LAC, which does not exist with MetadataStoreTopicPoliciesService. + } + + @Test(enabled = false) + @Override + public void testTopicPoliciesAfterCompaction(String reloadPolicyType) throws Exception { + // The "Recreate_Service" variant creates a new SystemTopicBasedTopicPoliciesService, + // which is not applicable to MetadataStoreTopicPoliciesService. + } +} diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/PersistentTopicsTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/PersistentTopicsTest.java index 40df6b0b5ffe1..5b465c6846f7e 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/PersistentTopicsTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/PersistentTopicsTest.java @@ -30,7 +30,6 @@ import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertNotNull; import static org.testng.Assert.assertSame; @@ -48,6 +47,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Function; import javax.servlet.ServletContext; import javax.ws.rs.InternalServerErrorException; import javax.ws.rs.WebApplicationException; @@ -58,6 +58,7 @@ import lombok.extern.slf4j.Slf4j; import org.apache.bookkeeper.mledger.ManagedCursor; import org.apache.commons.collections4.MapUtils; +import org.apache.commons.lang3.reflect.FieldUtils; import org.apache.pulsar.broker.BrokerTestUtil; import org.apache.pulsar.broker.admin.v2.ExtPersistentTopics; import org.apache.pulsar.broker.admin.v2.NonPersistentTopics; @@ -99,6 +100,7 @@ import org.apache.pulsar.common.naming.TopicName; import org.apache.pulsar.common.partition.PartitionedTopicMetadata; import org.apache.pulsar.common.policies.data.AuthAction; +import org.apache.pulsar.common.policies.data.AutoSubscriptionCreationOverride; import org.apache.pulsar.common.policies.data.ClusterData; import org.apache.pulsar.common.policies.data.PersistentTopicInternalStats; import org.apache.pulsar.common.policies.data.Policies; @@ -129,7 +131,8 @@ public class PersistentTopicsTest extends MockedPulsarServiceBaseTest { protected Field uriField; protected UriInfo uriInfo; private NonPersistentTopics nonPersistentTopic; - private NamespaceResources namespaceResources; + private volatile NamespaceResources namespaceResourcesOverride; + private volatile Function>> listPersistentTopicsAsyncHandler; @BeforeClass public void initPersistentTopics() throws Exception { @@ -172,7 +175,6 @@ protected void setup() throws Exception { nonPersistentTopic = spy(NonPersistentTopics.class); nonPersistentTopic.setServletContext(mock(ServletContext.class)); nonPersistentTopic.setPulsar(pulsar); - namespaceResources = mock(NamespaceResources.class); doReturn(false).when(nonPersistentTopic).isRequestHttps(); doReturn(null).when(nonPersistentTopic).originalPrincipal(); doReturn("test").when(nonPersistentTopic).clientAppId(); @@ -180,10 +182,27 @@ protected void setup() throws Exception { doNothing().when(nonPersistentTopic).validateAdminAccessForTenant(this.testTenant); doReturn(mock(AuthenticationDataHttps.class)).when(nonPersistentTopic).clientAuthData(); - PulsarResources resources = - spy(new PulsarResources(pulsar.getLocalMetadataStore(), pulsar.getConfigurationMetadataStore())); - doReturn(spy(new TopicResources(pulsar.getLocalMetadataStore()))).when(resources).getTopicResources(); - doReturn(resources).when(pulsar).getPulsarResources(); + TopicResources topicResources = BrokerTestUtil.spyWithoutRecordingInvocations( + new TopicResources(pulsar.getLocalMetadataStore())); + PulsarResources resources = BrokerTestUtil.spyWithoutRecordingInvocations( + new PulsarResources(pulsar.getLocalMetadataStore(), pulsar.getConfigurationMetadataStore())); + NamespaceResources namespaceResources = resources.getNamespaceResources(); + doAnswer(invocation -> { + NamespaceResources override = namespaceResourcesOverride; + return override != null ? override : namespaceResources; + }).when(resources).getNamespaceResources(); + doReturn(topicResources).when(resources).getTopicResources(); + doAnswer(invocation -> { + Function>> handler = listPersistentTopicsAsyncHandler; + if (handler != null) { + CompletableFuture> result = handler.apply(invocation.getArgument(0)); + if (result != null) { + return result; + } + } + return invocation.callRealMethod(); + }).when(topicResources).listPersistentTopicsAsync(any()); + FieldUtils.writeField(pulsar, "pulsarResources", resources, true); admin.clusters().createCluster("use", ClusterData.builder().serviceUrl("http://127.0.0.3:8082").build()); admin.clusters().createCluster("test", ClusterData.builder().serviceUrl(pulsar.getWebServiceAddress()).build()); @@ -199,6 +218,8 @@ protected void setup() throws Exception { @Override @AfterMethod(alwaysRun = true) protected void cleanup() throws Exception { + namespaceResourcesOverride = null; + listPersistentTopicsAsyncHandler = null; super.internalCleanup(); } @@ -606,8 +627,8 @@ public void testCreateTopicWithReplicationCluster() { CompletableFuture> policyFuture = new CompletableFuture<>(); Policies policies = new Policies(); policyFuture.complete(Optional.of(policies)); - when(pulsar.getPulsarResources().getNamespaceResources()).thenReturn(namespaceResources); - doReturn(policyFuture).when(namespaceResources).getPoliciesAsync(namespaceName); + namespaceResourcesOverride = mock(NamespaceResources.class); + doReturn(policyFuture).when(namespaceResourcesOverride).getPoliciesAsync(namespaceName); AsyncResponse response = mock(AsyncResponse.class); ArgumentCaptor errCaptor = ArgumentCaptor.forClass(RestException.class); persistentTopics.createPartitionedTopic(response, testTenant, testNamespace, topicName, 2, true); @@ -619,7 +640,7 @@ public void testCreateTopicWithReplicationCluster() { // Test policy not exist and return 'Namespace not found' CompletableFuture> policyFuture2 = new CompletableFuture<>(); policyFuture2.complete(Optional.empty()); - doReturn(policyFuture2).when(namespaceResources).getPoliciesAsync(namespaceName); + doReturn(policyFuture2).when(namespaceResourcesOverride).getPoliciesAsync(namespaceName); response = mock(AsyncResponse.class); errCaptor = ArgumentCaptor.forClass(RestException.class); persistentTopics.createPartitionedTopic(response, testTenant, testNamespace, topicName, 2, true); @@ -653,12 +674,13 @@ public void testCreatePartitionedTopicHavingNonPartitionTopicWithPartitionSuffix final String nonPartitionTopicName2 = "special-topic-partition-123"; final String partitionedTopicName = "special-topic"; - when(pulsar.getPulsarResources().getTopicResources() - .listPersistentTopicsAsync(NamespaceName.get("my-tenant/my-namespace"))) - .thenReturn(CompletableFuture.completedFuture(List.of( + NamespaceName namespaceName = NamespaceName.get("my-tenant/my-namespace"); + listPersistentTopicsAsyncHandler = namespace -> namespaceName.equals(namespace) + ? CompletableFuture.completedFuture(List.of( "persistent://my-tenant/my-namespace/" + nonPartitionTopicName1, "persistent://my-tenant/my-namespace/" + nonPartitionTopicName2 - ))); + )) + : null; // doReturn(ImmutableSet.of(nonPartitionTopicName1, nonPartitionTopicName2)).when(mockZooKeeperChildrenCache) // .get(anyString()); // doReturn(CompletableFuture.completedFuture(ImmutableSet.of(nonPartitionTopicName1, nonPartitionTopicName2)) @@ -1125,9 +1147,20 @@ public void testPeekWithSubscriptionNameNotExist() throws Exception { producer.send("test" + i); } + admin.namespaces().setAutoSubscriptionCreation("tenant-xyz/ns-abc", + AutoSubscriptionCreationOverride.builder().allowAutoSubscriptionCreation(false).build()); + PulsarAdminException.PreconditionFailedException exception = Assert.expectThrows( + PulsarAdminException.PreconditionFailedException.class, + () -> admin.topics().peekMessages(partitionedTopic, subscriptionName, 3)); + Assert.assertEquals(exception.getStatusCode(), Response.Status.PRECONDITION_FAILED.getStatusCode()); + Assert.assertTrue(admin.topics().getSubscriptions(partitionedTopic).isEmpty()); + + admin.namespaces().setAutoSubscriptionCreation("tenant-xyz/ns-abc", + AutoSubscriptionCreationOverride.builder().allowAutoSubscriptionCreation(true).build()); List> messages = admin.topics().peekMessages(partitionedTopic, subscriptionName, 3); Assert.assertEquals(messages.size(), 3); + Assert.assertEquals(admin.topics().getSubscriptions(partitionedTopic), List.of(subscriptionName)); producer.close(); } diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/TopicPoliciesTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/TopicPoliciesTest.java index c5537897e7bc7..302f060953bca 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/TopicPoliciesTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/TopicPoliciesTest.java @@ -117,7 +117,9 @@ import org.glassfish.jersey.client.JerseyClientBuilder; import org.mockito.Mockito; import org.testng.Assert; +import org.testng.annotations.AfterClass; import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeClass; import org.testng.annotations.BeforeMethod; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; @@ -142,10 +144,11 @@ public class TopicPoliciesTest extends MockedPulsarServiceBaseTest { private final int testTopicPartitions = 2; - @BeforeMethod + @BeforeClass(alwaysRun = true) @Override protected void setup() throws Exception { this.conf.setDefaultNumberOfNamespaceBundles(1); + this.conf.setForceDeleteNamespaceAllowed(true); super.internalSetup(); admin.clusters().createCluster("test", ClusterData.builder().serviceUrl(pulsar.getWebServiceAddress()).build()); @@ -156,15 +159,43 @@ protected void setup() throws Exception { admin.topics().createPartitionedTopic(testTopic, testTopicPartitions); Producer producer = pulsarClient.newProducer().topic(testTopic).create(); producer.close(); - waitForZooKeeperWatchers(); } - @AfterMethod(alwaysRun = true) + @AfterClass(alwaysRun = true) @Override public void cleanup() throws Exception { super.internalCleanup(); } + @BeforeMethod + void setupTestTopic() throws Exception { + // Recreate namespace to clear any policies set by previous tests + try { + admin.topics().deletePartitionedTopic(testTopic, true); + } catch (PulsarAdminException.NotFoundException e) { + // topic may already be deleted + } + // Use deleteNamespaceWithRetry since the forced namespace deletion can fail transiently with HTTP 422 when a + // topic deletion in the cascade races with concurrent topic loading; the helper retries and treats an + // already-deleted namespace as success. + deleteNamespaceWithRetry(myNamespace, true); + deleteNamespaceWithRetry(myNamespaceV1, true); + admin.namespaces().createNamespace(testTenant + "/" + testNamespace, Set.of("test")); + admin.namespaces().createNamespace(myNamespaceV1); + admin.topics().createPartitionedTopic(testTopic, testTopicPartitions); + // Acquire namespace bundle ownership so tests that call getOrCreateTopic() directly succeed. + // Without this, services that don't create a __change_events reader (e.g. MetadataStoreTopicPoliciesService) + // leave the bundle unowned after namespace recreation and the first broker-side topic load fails. + admin.lookups().lookupTopic(testTopic + "-partition-0"); + } + + @AfterMethod(alwaysRun = true) + void afterMethodCleanup() throws Exception{ + admin.brokers().updateDynamicConfiguration("maxPublishRatePerTopicInMessages", "0"); + admin.brokers().updateDynamicConfiguration("maxPublishRatePerTopicInBytes", "0"); + clearTopicPoliciesCache(); + } + @Test public void updatePropertiesForAutoCreatedTopicTest() throws Exception { TopicName topicName = TopicName.get( @@ -220,6 +251,73 @@ public void testTopicPolicyInitialValueWithNamespaceAlreadyLoaded() throws Excep assertEquals(topic1.getHierarchyTopicPolicies().getMaxSubscriptionsPerTopic().get(), Integer.valueOf(10)); } + @Test + public void testNonPersistentTopicAppliesTopicPolicyOnLoad() throws Exception { + // Non-persistent topics now load and apply their own topic policies on load (like persistent topics), so a + // freshly loaded non-persistent topic must already reflect its topic-level policy without waiting for a + // namespace-wide broadcast. Before this change a non-persistent topic never applied its policies on load. + TopicName topicName = TopicName.get( + TopicDomain.non_persistent.value(), + NamespaceName.get(myNamespace), + "test-np-" + UUID.randomUUID() + ); + String topic = topicName.toString(); + + SystemTopicBasedTopicPoliciesService policyService = + (SystemTopicBasedTopicPoliciesService) pulsar.getTopicPoliciesService(); + + admin.topics().createNonPartitionedTopic(topic); + admin.topicPolicies().setMaxSubscriptionsPerTopicAsync(topic, 10).get(); + + //wait until topic loaded with right policy value. + Awaitility.await().untilAsserted(() -> { + AbstractTopic loaded = (AbstractTopic) pulsar.getBrokerService().getTopic(topic, true).get().get(); + assertEquals(loaded.getHierarchyTopicPolicies().getMaxSubscriptionsPerTopic().get(), Integer.valueOf(10)); + }); + + //unload the topic + pulsar.getNamespaceService().unloadNamespaceBundle(pulsar.getNamespaceService().getBundle(topicName)).get(); + assertFalse(pulsar.getBrokerService().getTopics().containsKey(topic)); + + //re-own the namespace bundle without loading the topic + log.info("lookup={}", admin.lookups().lookupTopic(topic)); + assertTrue(pulsar.getBrokerService().isTopicNsOwnedByBrokerAsync(topicName).join()); + assertFalse(pulsar.getBrokerService().getTopics().containsKey(topic)); + //make sure namespace policy reader is fully started. + Awaitility.await().untilAsserted(() -> + assertTrue(policyService.getPoliciesCacheInit(topicName.getNamespaceObject()).isDone())); + + //load the topic: it must already reflect the topic policy, proving it was applied on load, not via a + //later broadcast. + AbstractTopic loaded = (AbstractTopic) pulsar.getBrokerService().getTopic(topic, true).get().get(); + assertEquals(loaded.getHierarchyTopicPolicies().getMaxSubscriptionsPerTopic().get(), Integer.valueOf(10)); + } + + @Test + public void testGlobalPolicyUpdateDoesNotClearLocalSubscriptionPolicy() throws Exception { + // A global topic policy carries no subscription-level overrides by default (an empty subscriptionPolicies + // map). Because AbstractTopic keeps the local and global per-subscription policies separately and merges them + // with local precedence, applying such a global policy must not clear the local per-subscription dispatch-rate + // policy -- which the previous direct assignment would do under the local-before-global ordering. + final String topic = "persistent://" + myNamespace + "/test-sub-policy-merge-" + UUID.randomUUID(); + final String subName = "sub-1"; + admin.topics().createNonPartitionedTopic(topic); + admin.topics().createSubscription(topic, subName, MessageId.earliest); + + DispatchRate localRate = DispatchRateImpl.builder() + .dispatchThrottlingRateInMsg(100).dispatchThrottlingRateInByte(2048).ratePeriodInSecond(1).build(); + admin.topicPolicies().setSubscriptionDispatchRate(topic, subName, localRate); + + AbstractTopic topicRef = (AbstractTopic) pulsar.getBrokerService().getTopic(topic, false).get().orElseThrow(); + Awaitility.await().untilAsserted(() -> + assertEquals(topicRef.getSubscriptionDispatchRate(subName).getDispatchThrottlingRateInMsg(), 100)); + + // Simulate a global topic-policy update that carries no subscription-level overrides. + topicRef.onUpdate(TopicPolicies.builder().isGlobal(true).build()); + + assertEquals(topicRef.getSubscriptionDispatchRate(subName).getDispatchThrottlingRateInMsg(), 100); + } + @Test public void testSetSizeBasedBacklogQuota() throws Exception { @@ -483,8 +581,8 @@ public Object[][] clientRequestType() { @Test(dataProvider = "clientRequestType") public void testPriorityOfGlobalPolicies(String clientRequestType) throws Exception { - final SystemTopicBasedTopicPoliciesService topicPoliciesService = - (SystemTopicBasedTopicPoliciesService) pulsar.getTopicPoliciesService(); + final TopicPoliciesService topicPoliciesService = + pulsar.getTopicPoliciesService(); final JerseyClient httpClient = JerseyClientBuilder.createClient(); // create topic and load it up. final String namespace = myNamespace; @@ -564,8 +662,8 @@ public void testPriorityOfGlobalPolicies(String clientRequestType) throws Except @Test(dataProvider = "clientRequestType") public void testPriorityOfGlobalPolicies2(String clientRequestType) throws Exception { - final SystemTopicBasedTopicPoliciesService topicPoliciesService = - (SystemTopicBasedTopicPoliciesService) pulsar.getTopicPoliciesService(); + final TopicPoliciesService topicPoliciesService = + pulsar.getTopicPoliciesService(); final JerseyClient httpClient = JerseyClientBuilder.createClient(); // create topic and load it up. final String namespace = myNamespace; @@ -651,8 +749,8 @@ public void testGlobalPolicyStillAffectsAfterUnloading() throws Exception { final TopicName topicName = TopicName.get(topic); admin.topics().createNonPartitionedTopic(topic); pulsarClient.newProducer().topic(topic).create().close(); - final SystemTopicBasedTopicPoliciesService topicPoliciesService = - (SystemTopicBasedTopicPoliciesService) pulsar.getTopicPoliciesService(); + final TopicPoliciesService topicPoliciesService = + pulsar.getTopicPoliciesService(); // Set non-global policy of the limitation of max consumers. // Set global policy of the limitation of max producers. @@ -693,8 +791,8 @@ public void testRetentionGlobalPolicyAffects() throws Exception { final TopicName topicName = TopicName.get(topic); admin.topics().createNonPartitionedTopic(topic); pulsarClient.newProducer().topic(topic).create().close(); - final SystemTopicBasedTopicPoliciesService topicPoliciesService = - (SystemTopicBasedTopicPoliciesService) pulsar.getTopicPoliciesService(); + final TopicPoliciesService topicPoliciesService = + pulsar.getTopicPoliciesService(); // Set non-global policy of the limitation of max consumers. // Set global policy of the persistence policies. @@ -2482,10 +2580,8 @@ public void testRemoveSubscribeRate() throws Exception { @Test public void testPublishRateInDifferentLevelPolicy() throws Exception { - cleanup(); - conf.setMaxPublishRatePerTopicInMessages(5); - conf.setMaxPublishRatePerTopicInBytes(50L); - setup(); + admin.brokers().updateDynamicConfiguration("maxPublishRatePerTopicInMessages", "5"); + admin.brokers().updateDynamicConfiguration("maxPublishRatePerTopicInBytes", "50"); final String topicName = "persistent://" + myNamespace + "/test-" + UUID.randomUUID(); pulsarClient.newProducer().topic(topicName).create().close(); @@ -2776,9 +2872,7 @@ public void testMaxSubscriptionsPerTopicWithExistingSubs() throws Exception { @Test public void testMaxUnackedMessagesOnSubscriptionPriority() throws Exception { - cleanup(); - conf.setMaxUnackedMessagesPerSubscription(30); - setup(); + restartBroker(conf -> conf.setMaxUnackedMessagesPerSubscription(30)); final String topic = "persistent://" + myNamespace + "/test-" + UUID.randomUUID(); // init cache @Cleanup @@ -2841,6 +2935,9 @@ public void testMaxUnackedMessagesOnSubscriptionPriority() throws Exception { && admin.topicPolicies().getMaxUnackedMessagesOnSubscription(topic) == null); messages = getMsgReceived(consumer1, Integer.MAX_VALUE); assertEquals(messages.size(), defaultMaxUnackedMsgOnBroker); + + // restore default config + restartBroker(conf -> conf.setMaxUnackedMessagesPerSubscription(4 * 50000)); } private void produceMsg(Producer producer, int msgNum) throws Exception{ @@ -3025,14 +3122,16 @@ public void testGetReplicatorRateApplied() throws Exception { @Test(timeOut = 30000) public void testAutoCreationDisabled() throws Exception { - cleanup(); - conf.setAllowAutoTopicCreation(false); - setup(); + admin.brokers().updateDynamicConfiguration("allowAutoTopicCreation", "false"); + final String topic = testTopic + UUID.randomUUID(); admin.topics().createPartitionedTopic(topic, 3); pulsarClient.newProducer().topic(topic).create().close(); //should not fail assertNull(admin.topicPolicies().getMessageTTL(topic)); + + // restore default + admin.brokers().updateDynamicConfiguration("allowAutoTopicCreation", "true"); } @Test @@ -3156,6 +3255,12 @@ public void testSubscriptionTypesEnabled() throws Exception { pulsarClient.newConsumer().topic(topic) .subscriptionType(SubscriptionType.Shared).subscriptionName("test") .subscribe().close(); + + // restore dynamic broker config and conf object + pulsar.getConfiguration().setSubscriptionTypesEnabled( + Set.of("Exclusive", "Shared", "Failover", "Key_Shared")); + admin.brokers().updateDynamicConfiguration("subscriptionTypesEnabled", + "Exclusive,Shared,Failover,Key_Shared"); } @Test(timeOut = 20000) @@ -3486,7 +3591,8 @@ public void testPolicyIsDeleteTogetherAutomatically() throws Exception { } @Test - public void testDoNotCreateSystemTopicForHeartbeatNamespace() { + public void testDoNotCreateSystemTopicForHeartbeatNamespace() throws Exception { + initEventsTopicAndPartitions(); assertTrue(pulsar.getBrokerService().getTopics().size() > 0); pulsar.getBrokerService().getTopics().forEach((k, v) -> { TopicName topicName = TopicName.get(k); @@ -3548,8 +3654,13 @@ public void testLoopCreateAndDeleteTopicPolicies() throws Exception { } private void triggerAndWaitNewTopicCompaction(String topicName) throws Exception { - PersistentTopic tp = - (PersistentTopic) pulsar.getBrokerService().getTopic(topicName, false).join().get(); + Optional topicOpt = + pulsar.getBrokerService().getTopic(topicName, false).join(); + if (topicOpt.isEmpty()) { + // Topic doesn't exist (e.g., when not using system-topic-based policies service), nothing to compact. + return; + } + PersistentTopic tp = (PersistentTopic) topicOpt.get(); // Wait for the old task finish. Awaitility.await().untilAsserted(() -> { CompletableFuture compactionTask = WhiteboxImpl.getInternalState(tp, "currentCompaction"); @@ -3568,7 +3679,7 @@ private void triggerAndWaitNewTopicCompaction(String topicName) throws Exception * It is not a thread safety method, something will go to a wrong pointer if there is a task is trying to load a * topic policies. */ - private void clearTopicPoliciesCache() { + protected void clearTopicPoliciesCache() { TopicPoliciesService topicPoliciesService = pulsar.getTopicPoliciesService(); if (topicPoliciesService instanceof TopicPoliciesService.TopicPoliciesServiceDisabled) { return; @@ -3798,8 +3909,8 @@ public void testGlobalTopicPolicies() throws Exception { .isNull()); admin.topicPolicies(true).setRetention(topic, new RetentionPolicies(1, 2)); - SystemTopicBasedTopicPoliciesService topicPoliciesService = - (SystemTopicBasedTopicPoliciesService) pulsar.getTopicPoliciesService(); + TopicPoliciesService topicPoliciesService = + pulsar.getTopicPoliciesService(); // check global topic policies can be added correctly. Awaitility.await().untilAsserted(() -> assertNotNull( @@ -3843,6 +3954,7 @@ public void testGlobalTopicPolicies() throws Exception { @Test public void testMaxMessageSizeWithChunking() throws Exception { + final var maxMessageSize = this.conf.getMaxMessageSize(); this.conf.setMaxMessageSize(1000); @Cleanup @@ -3871,6 +3983,7 @@ public void testMaxMessageSizeWithChunking() throws Exception { // chunk message send success producer.send(new byte[2000]); + this.conf.setMaxMessageSize(maxMessageSize); } @Test(timeOut = 30000) @@ -3924,6 +4037,7 @@ public void testGetTopicPoliciesWhenDeleteTopicPolicy() throws Exception { @Test public void testProduceChangesWithEncryptionRequired() throws Exception { + initEventsTopicAndPartitions(); final String beforeLac = admin.topics().getInternalStats(topicPolicyEventsTopic).lastConfirmedEntry; admin.namespaces().setEncryptionRequiredStatus(myNamespace, true); // just an update to trigger writes on __change_events @@ -4188,4 +4302,10 @@ public void testGetAppliedOffloadPoliciesWithLegacyNamespacePolicies() throws Ex assertEquals(offloadPolicies.getManagedLedgerOffloadThresholdInBytes(), (Long) (1024 * 1024 * 10L), "Should inherit offload threshold from legacy namespace policy"); } + + private void initEventsTopicAndPartitions() throws Exception { + try (Producer producer = pulsarClient.newProducer().topic(testTopic).create()) { + // No-op. Creating the producer initializes the events topic and partitions. + } + } } diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/v1/V1AdminApiTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/v1/V1AdminApiTest.java index 2d1a0acab843f..48e4dcbbb0174 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/v1/V1AdminApiTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/v1/V1AdminApiTest.java @@ -113,6 +113,7 @@ import org.apache.pulsar.compaction.Compactor; import org.apache.pulsar.compaction.PulsarCompactionServiceFactory; import org.apache.pulsar.metadata.cache.impl.MetadataCacheImpl; +import org.awaitility.Awaitility; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.testng.Assert; @@ -128,6 +129,8 @@ public class V1AdminApiTest extends MockedPulsarServiceBaseTest { private static final Logger LOG = LoggerFactory.getLogger(V1AdminApiTest.class); + private static final String BROKER_SHUTDOWN_TIMEOUT_MS = "brokerShutdownTimeoutMs"; + private MockedPulsarService mockPulsarSetup; private PulsarService otherPulsar; @@ -189,6 +192,14 @@ protected void customizeMainPulsarTestContextBuilder(PulsarTestContext.Builder p @AfterMethod(alwaysRun = true) public void reset() throws Exception { + if (pulsar == null || !pulsar.isRunning()) { + // A test method left the broker stopped, for example because stopBroker() failed. Throwing from + // here is a TestNG configuration failure, which makes TestNG skip every remaining test method of + // this class. TestRetrySupport recreates the shared test context before the next test method. + log.warn("Broker isn't running, skipping the cleanup of the previous test method"); + return; + } + restoreBrokerShutdownTimeout(); pulsar.getConfiguration().setForceDeleteNamespaceAllowed(true); for (String tenant : admin.tenants().getTenants()) { for (String namespace : admin.namespaces().getNamespaces(tenant)) { @@ -211,6 +222,25 @@ public void reset() throws Exception { admin.namespaces().createNamespace("prop-xyz/use/ns1"); } + /** + * Several test methods of this class lower the {@code brokerShutdownTimeoutMs} dynamic configuration to 10ms. + * The broker applies dynamic configuration changes asynchronously, so when the value is left behind it can + * become effective while a later test method is shutting the broker down in stopBroker() or restartBroker(). + * PulsarService#close then fails with "Timeout in close" and leaves the test holding a broker that is no + * longer listening, which fails this @AfterMethod and skips the remaining test methods of the class. + */ + private void restoreBrokerShutdownTimeout() throws Exception { + String overriddenValue = admin.brokers().getAllDynamicConfigurations().get(BROKER_SHUTDOWN_TIMEOUT_MS); + if (overriddenValue == null) { + return; + } + long overriddenTimeoutMs = Long.parseLong(overriddenValue); + // removing the dynamic configuration makes the broker restore the value it was started with + admin.brokers().deleteDynamicConfiguration(BROKER_SHUTDOWN_TIMEOUT_MS); + Awaitility.await().untilAsserted( + () -> assertNotEquals(pulsar.getConfiguration().getBrokerShutdownTimeoutMs(), overriddenTimeoutMs)); + } + @DataProvider(name = "numBundles") public static Object[][] numBundles() { return new Object[][] { { 1 }, { 4 } }; @@ -622,6 +652,9 @@ public void testGetDynamicLocalConfiguration() throws Exception { admin.brokers().updateDynamicConfiguration(configName, Long.toString(shutdownTime)); // Now, znode is created: updateConfigurationAndregisterListeners and check if configuration updated assertEquals(Long.parseLong(admin.brokers().getAllDynamicConfigurations().get(configName)), shutdownTime); + // wait until the broker has applied the value before restoring it below, so that the asynchronous + // update cannot set it back to shutdownTime once this test method has completed + Awaitility.await().until(() -> pulsar.getConfiguration().getBrokerShutdownTimeoutMs() == shutdownTime); pulsar.getConfiguration().setBrokerShutdownTimeoutMs(defaultValue); } diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/v3/AdminApiTransactionTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/v3/AdminApiTransactionTest.java index 2f1fcf0060505..b3034646293e8 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/v3/AdminApiTransactionTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/admin/v3/AdminApiTransactionTest.java @@ -935,6 +935,174 @@ public void testAbortTransaction() throws Exception { } } + @Test + public void testPeekMessageTxnHeaderAccuracy() throws Exception { + initTransaction(1); + + final String topic = BrokerTestUtil.newUniqueName("persistent://public/default/peek_txn_header_accuracy"); + + @Cleanup + Producer producer = pulsarClient.newProducer(Schema.STRING).topic(topic).create(); + + // Start transaction T1, send a message, keep it uncommitted + Transaction txn1 = pulsarClient.newTransaction().build().get(); + TxnID txnID1 = ((TransactionImpl) txn1).getTxnID(); + producer.newMessage(txn1).value("msg-uncommitted").send(); + + // Start transaction T2, send a message, commit it + Transaction txn2 = pulsarClient.newTransaction().build().get(); + TxnID txnID2 = ((TransactionImpl) txn2).getTxnID(); + producer.newMessage(txn2).value("msg-committed").send(); + txn2.commit().get(); + + // Send a normal (non-transactional) message + producer.newMessage().value("msg-normal").send(); + + // Peek all messages with READ_UNCOMMITTED to get both messages regardless of txn state + List> peekMsgs = admin.topics().peekMessages(topic, "t-sub", 10, + false, TransactionIsolationLevel.READ_UNCOMMITTED); + + boolean foundUncommitted = false; + boolean foundCommitted = false; + boolean foundNormal = false; + for (Message peekMsg : peekMsgs) { + String value = new String(peekMsg.getValue()); + MessageMetadata metadata = ((MessageImpl) peekMsg).getMessageBuilder(); + if ("msg-uncommitted".equals(value)) { + foundUncommitted = true; + // T1 is ongoing, so X-Pulsar-txn-uncommitted should be true + assertTrue(peekMsg.hasProperty("X-Pulsar-txn-uncommitted")); + assertEquals(peekMsg.getProperty("X-Pulsar-txn-uncommitted"), "true"); + // T1 is not aborted + assertFalse(peekMsg.hasProperty("X-Pulsar-txn-aborted"), + "T1 is not aborted, X-Pulsar-txn-aborted should be false"); + // Blocked by itself (uncommitted), so X-Pulsar-txn-consumable should be false + assertTrue(peekMsg.hasProperty("X-Pulsar-txn-consumable")); + assertEquals(peekMsg.getProperty("X-Pulsar-txn-consumable"), "false"); + // TxnID checks + assertTrue(metadata.hasTxnidMostBits()); + assertEquals(metadata.getTxnidMostBits(), txnID1.getMostSigBits()); + assertTrue(metadata.hasTxnidLeastBits()); + assertEquals(metadata.getTxnidLeastBits(), txnID1.getLeastSigBits()); + } else if ("msg-committed".equals(value)) { + foundCommitted = true; + // T2 is committed, so X-Pulsar-txn-uncommitted should be false (not present in properties) + assertFalse(peekMsg.hasProperty("X-Pulsar-txn-uncommitted"), + "T2 is committed, X-Pulsar-txn-uncommitted should be false"); + // T2 is not aborted + assertFalse(peekMsg.hasProperty("X-Pulsar-txn-aborted"), + "T2 is not aborted, X-Pulsar-txn-aborted should be false"); + // Blocked by T1 before it, so X-Pulsar-txn-consumable should be false + assertTrue(peekMsg.hasProperty("X-Pulsar-txn-consumable")); + assertEquals(peekMsg.getProperty("X-Pulsar-txn-consumable"), "false", + "T2 is blocked by uncommitted T1 before it"); + // TxnID checks + assertTrue(metadata.hasTxnidMostBits()); + assertEquals(metadata.getTxnidMostBits(), txnID2.getMostSigBits()); + assertTrue(metadata.hasTxnidLeastBits()); + assertEquals(metadata.getTxnidLeastBits(), txnID2.getLeastSigBits()); + } else if ("msg-normal".equals(value)) { + foundNormal = true; + // Normal message has no txnid, so X-Pulsar-txn-uncommitted should not be set + assertFalse(peekMsg.hasProperty("X-Pulsar-txn-uncommitted"), + "Normal message should not have X-Pulsar-txn-uncommitted"); + // Normal message has no txnid, so X-Pulsar-txn-aborted should not be set + assertFalse(peekMsg.hasProperty("X-Pulsar-txn-aborted"), + "Normal message should not have X-Pulsar-txn-aborted"); + // Blocked by T1, so X-Pulsar-txn-consumable should be false + assertTrue(peekMsg.hasProperty("X-Pulsar-txn-consumable")); + assertEquals(peekMsg.getProperty("X-Pulsar-txn-consumable"), "false", + "Normal message is blocked by uncommitted T1"); + // Normal message has no txnid + assertFalse(metadata.hasTxnidMostBits()); + assertFalse(metadata.hasTxnidLeastBits()); + } + } + assertTrue(foundUncommitted, "Should have found the uncommitted message"); + assertTrue(foundCommitted, "Should have found the committed message"); + assertTrue(foundNormal, "Should have found the normal message"); + + // Verify READ_COMMITTED filtering: all messages after an uncommitted txn should be filtered + List> committedPeek = admin.topics().peekMessages(topic, "t-sub-2", 10, + false, TransactionIsolationLevel.READ_COMMITTED); + assertEquals(committedPeek.size(), 0, + "READ_COMMITTED should filter all messages after an uncommitted transaction"); + + // Abort T1 and verify headers update correctly + txn1.abort().get(); + + List> peekAfterAbort = admin.topics().peekMessages(topic, "t-sub-3", 10, + false, TransactionIsolationLevel.READ_UNCOMMITTED); + + boolean foundAborted = false; + boolean foundCommittedAfterAbort = false; + boolean foundNormalAfterAbort = false; + for (Message peekMsg : peekAfterAbort) { + String value = new String(peekMsg.getValue()); + MessageMetadata metadata = ((MessageImpl) peekMsg).getMessageBuilder(); + if ("msg-uncommitted".equals(value)) { + foundAborted = true; + // T1 is now aborted, so X-Pulsar-txn-aborted should be true + assertTrue(peekMsg.hasProperty("X-Pulsar-txn-aborted"), + "T1 is aborted, X-Pulsar-txn-aborted should be true"); + assertEquals(peekMsg.getProperty("X-Pulsar-txn-aborted"), "true"); + // T1 is no longer ongoing + assertFalse(peekMsg.hasProperty("X-Pulsar-txn-uncommitted"), + "T1 is aborted, X-Pulsar-txn-uncommitted should be false"); + // maxReadPosition advanced past T1, so consumable should be true + assertTrue(peekMsg.hasProperty("X-Pulsar-txn-consumable")); + assertEquals(peekMsg.getProperty("X-Pulsar-txn-consumable"), "true", + "T1 is resolved, message should be consumable"); + // TxnID unchanged + assertTrue(metadata.hasTxnidMostBits()); + assertEquals(metadata.getTxnidMostBits(), txnID1.getMostSigBits()); + assertTrue(metadata.hasTxnidLeastBits()); + assertEquals(metadata.getTxnidLeastBits(), txnID1.getLeastSigBits()); + } else if ("msg-committed".equals(value)) { + foundCommittedAfterAbort = true; + // T2 is still committed, not aborted + assertFalse(peekMsg.hasProperty("X-Pulsar-txn-uncommitted"), + "T2 is committed, X-Pulsar-txn-uncommitted should be false"); + assertFalse(peekMsg.hasProperty("X-Pulsar-txn-aborted"), + "T2 is not aborted, X-Pulsar-txn-aborted should be false"); + // maxReadPosition advanced past T1, so consumable should be true + assertTrue(peekMsg.hasProperty("X-Pulsar-txn-consumable")); + assertEquals(peekMsg.getProperty("X-Pulsar-txn-consumable"), "true", + "No open txn before T2, message should be consumable"); + // TxnID unchanged + assertTrue(metadata.hasTxnidMostBits()); + assertEquals(metadata.getTxnidMostBits(), txnID2.getMostSigBits()); + assertTrue(metadata.hasTxnidLeastBits()); + assertEquals(metadata.getTxnidLeastBits(), txnID2.getLeastSigBits()); + } else if ("msg-normal".equals(value)) { + foundNormalAfterAbort = true; + // Normal message has no txnid, so no txn headers + assertFalse(peekMsg.hasProperty("X-Pulsar-txn-uncommitted"), + "Normal message should not have X-Pulsar-txn-uncommitted"); + assertFalse(peekMsg.hasProperty("X-Pulsar-txn-aborted"), + "Normal message should not have X-Pulsar-txn-aborted"); + // No open txn before it, so consumable should be true + assertTrue(peekMsg.hasProperty("X-Pulsar-txn-consumable")); + assertEquals(peekMsg.getProperty("X-Pulsar-txn-consumable"), "true", + "No open txn, normal message should be consumable"); + // No txnid + assertFalse(metadata.hasTxnidMostBits()); + assertFalse(metadata.hasTxnidLeastBits()); + } + } + assertTrue(foundAborted, "Should have found the aborted message after abort"); + assertTrue(foundCommittedAfterAbort, "Should have found the committed message after abort"); + assertTrue(foundNormalAfterAbort, "Should have found the normal message after abort"); + + // READ_COMMITTED should show committed + normal message (aborted is filtered out) + List> committedPeekAfterAbort = admin.topics().peekMessages(topic, "t-sub-4", 10, + false, TransactionIsolationLevel.READ_COMMITTED); + assertEquals(committedPeekAfterAbort.size(), 2, + "READ_COMMITTED should show committed and normal messages after abort"); + assertEquals(new String(committedPeekAfterAbort.get(0).getValue()), "msg-committed"); + assertEquals(new String(committedPeekAfterAbort.get(1).getValue()), "msg-normal"); + } + @Test public void testPeekMessageForSkipTxnMarker() throws Exception { initTransaction(1); diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/auth/MockedPulsarServiceBaseTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/auth/MockedPulsarServiceBaseTest.java index 42ff07524e78c..3cb43c43f66f5 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/auth/MockedPulsarServiceBaseTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/auth/MockedPulsarServiceBaseTest.java @@ -196,6 +196,10 @@ protected final void internalSetup(ServiceConfiguration serviceConfiguration) th internalSetup(); } + protected PulsarClient newPulsarClient() throws PulsarClientException { + return newPulsarClient(lookupUrl.toString(), 0); + } + protected PulsarClient newPulsarClient(String url, int intervalInSecs) throws PulsarClientException { ClientBuilder clientBuilder = PulsarClient.builder() diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/delayed/DelayedMessageIndexTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/delayed/DelayedMessageIndexTest.java new file mode 100644 index 0000000000000..28a245d0af0b5 --- /dev/null +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/delayed/DelayedMessageIndexTest.java @@ -0,0 +1,179 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.broker.delayed; + +import static org.assertj.core.api.Assertions.assertThat; +import it.unimi.dsi.fastutil.longs.Long2ObjectMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import org.apache.pulsar.common.util.collections.LongBitmap; +import org.apache.pulsar.common.util.collections.LongBitmaps; +import org.testng.annotations.Test; + +public class DelayedMessageIndexTest { + + @Test + public void trackThenContains() { + DelayedMessageIndex index = new DelayedMessageIndex(); + + index.track(7L, 100L); + + assertThat(index.contains(7L, 100L)).isTrue(); + assertThat(index.contains(7L, 101L)).isFalse(); + assertThat(index.contains(8L, 100L)).isFalse(); + assertThat(index.size()).isEqualTo(1L); + } + + @Test + public void untrackReturnsTrueFirstTimeAndFalseAfter() { + DelayedMessageIndex index = new DelayedMessageIndex(); + index.track(1L, 1L); + + assertThat(index.untrack(1L, 1L)).isTrue(); + assertThat(index.size()).isZero(); + + assertThat(index.untrack(1L, 1L)).isFalse(); + assertThat(index.size()).isZero(); + } + + @Test + public void untrackOnAbsentBitIsSafe() { + DelayedMessageIndex index = new DelayedMessageIndex(); + + assertThat(index.untrack(99L, 99L)).isFalse(); + assertThat(index.size()).isZero(); + assertThat(index.contains(99L, 99L)).isFalse(); + } + + @Test + public void trackIsIdempotent() { + DelayedMessageIndex index = new DelayedMessageIndex(); + + index.track(3L, 5L); + index.track(3L, 5L); + index.track(3L, 5L); + + assertThat(index.size()).isEqualTo(1L); + assertThat(index.contains(3L, 5L)).isTrue(); + } + + @Test + public void trackAcrossManyLedgersKeepsCounterCorrect() { + DelayedMessageIndex index = new DelayedMessageIndex(); + + for (long ledger = 1; ledger <= 5; ledger++) { + for (long entry = 1; entry <= 10; entry++) { + index.track(ledger, entry); + } + } + + assertThat(index.size()).isEqualTo(50L); + + // Drain half. + for (long ledger = 1; ledger <= 5; ledger++) { + for (long entry = 1; entry <= 5; entry++) { + assertThat(index.untrack(ledger, entry)).isTrue(); + } + } + assertThat(index.size()).isEqualTo(25L); + } + + @Test + public void clearResetsBitmapAndCounter() { + DelayedMessageIndex index = new DelayedMessageIndex(); + index.track(1L, 1L); + index.track(2L, 2L); + assertThat(index.size()).isEqualTo(2L); + + index.clear(); + + assertThat(index.size()).isZero(); + assertThat(index.contains(1L, 1L)).isFalse(); + assertThat(index.contains(2L, 2L)).isFalse(); + + // Index remains usable after clear. + index.track(3L, 3L); + assertThat(index.size()).isEqualTo(1L); + assertThat(index.contains(3L, 3L)).isTrue(); + } + + @Test + public void restoreLoadsBitsFromSnapshot() { + DelayedMessageIndex index = new DelayedMessageIndex(); + + Long2ObjectMap snapshot = new Long2ObjectOpenHashMap<>(); + LongBitmap ledger1 = LongBitmaps.create(); + ledger1.add(10L); + ledger1.add(11L); + snapshot.put(1L, ledger1); + LongBitmap ledger2 = LongBitmaps.create(); + ledger2.add(20L); + snapshot.put(2L, ledger2); + + index.restore(snapshot); + + assertThat(index.size()).isEqualTo(3L); + assertThat(index.contains(1L, 10L)).isTrue(); + assertThat(index.contains(1L, 11L)).isTrue(); + assertThat(index.contains(2L, 20L)).isTrue(); + } + + @Test + public void restoreIsIdempotentOnOverlappingSnapshots() { + DelayedMessageIndex index = new DelayedMessageIndex(); + + Long2ObjectMap firstBucket = new Long2ObjectOpenHashMap<>(); + LongBitmap bits = LongBitmaps.create(); + bits.add(5L); + bits.add(6L); + firstBucket.put(7L, bits); + + Long2ObjectMap secondBucket = new Long2ObjectOpenHashMap<>(); + LongBitmap overlapping = LongBitmaps.create(); + overlapping.add(5L); // overlap with firstBucket + overlapping.add(8L); + secondBucket.put(7L, overlapping); + + index.restore(firstBucket); + index.restore(secondBucket); + + assertThat(index.size()).isEqualTo(3L); // 5, 6, 8 — not 4 + assertThat(index.contains(7L, 5L)).isTrue(); + assertThat(index.contains(7L, 6L)).isTrue(); + assertThat(index.contains(7L, 8L)).isTrue(); + } + + @Test + public void restoreAfterTrackMergesCorrectly() { + DelayedMessageIndex index = new DelayedMessageIndex(); + index.track(1L, 1L); + assertThat(index.size()).isEqualTo(1L); + + Long2ObjectMap snapshot = new Long2ObjectOpenHashMap<>(); + LongBitmap bits = LongBitmaps.create(); + bits.add(1L); // overlap with the existing tracked bit + bits.add(2L); + snapshot.put(1L, bits); + + index.restore(snapshot); + + assertThat(index.size()).isEqualTo(2L); + assertThat(index.contains(1L, 1L)).isTrue(); + assertThat(index.contains(1L, 2L)).isTrue(); + } +} diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/delayed/InMemoryDeliveryTrackerTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/delayed/InMemoryDeliveryTrackerTest.java index 0e3f130cbf168..10a3dd6a3f91f 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/delayed/InMemoryDeliveryTrackerTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/delayed/InMemoryDeliveryTrackerTest.java @@ -34,10 +34,12 @@ import java.lang.reflect.Method; import java.time.Clock; import java.util.NavigableMap; +import java.util.NavigableSet; import java.util.TreeMap; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import lombok.Cleanup; +import org.apache.bookkeeper.mledger.Position; import org.apache.pulsar.broker.service.persistent.AbstractPersistentDispatcherMultipleConsumers; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; @@ -259,4 +261,21 @@ public void testAddMultipleMessagesSameWindow(InMemoryDelayedDeliveryTracker tra tracker.getScheduledMessages(10); } + + @Test(dataProvider = "delayedTracker") + public void testDuplicateAddMessageIsTrackedOnce(InMemoryDelayedDeliveryTracker tracker) throws Exception { + assertTrue(tracker.addMessage(1, 5, 10)); + assertTrue(tracker.addMessage(1, 5, 10)); + assertTrue(tracker.addMessage(1, 6, 10)); + assertTrue(tracker.addMessage(2, 5, 10)); + assertEquals(tracker.getNumberOfDelayedMessages(), 3); + + clockTime.set(20); + + NavigableSet scheduled = tracker.getScheduledMessages(100); + assertEquals(scheduled.size(), 3); + assertEquals(tracker.getNumberOfDelayedMessages(), 0); + + tracker.close(); + } } diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/delayed/MockBucketSnapshotStorage.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/delayed/MockBucketSnapshotStorage.java index a1f5b554b1520..67776c236d81d 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/delayed/MockBucketSnapshotStorage.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/delayed/MockBucketSnapshotStorage.java @@ -193,6 +193,15 @@ public void clean() { } } bucketSnapshots.clear(); - executorService.shutdownNow(); + // Gracefully shutdown: allow pending tasks to complete + executorService.shutdown(); + try { + if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) { + executorService.shutdownNow(); + } + } catch (InterruptedException e) { + executorService.shutdownNow(); + Thread.currentThread().interrupt(); + } } } diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedDeliveryTrackerTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedDeliveryTrackerTest.java index 6ff98fa7f7004..9284c08c8c120 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedDeliveryTrackerTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedDeliveryTrackerTest.java @@ -22,6 +22,7 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertNotSame; @@ -33,6 +34,7 @@ import java.lang.reflect.Method; import java.nio.ByteBuffer; import java.time.Clock; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.NavigableMap; @@ -40,13 +42,17 @@ import java.util.Set; import java.util.TreeMap; import java.util.TreeSet; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicLong; +import lombok.Cleanup; import org.apache.bookkeeper.mledger.ManagedCursor; +import org.apache.bookkeeper.mledger.ManagedLedger; import org.apache.bookkeeper.mledger.Position; import org.apache.bookkeeper.mledger.PositionFactory; +import org.apache.bookkeeper.mledger.proto.MLDataFormats; import org.apache.commons.lang3.mutable.MutableLong; import org.apache.pulsar.broker.delayed.AbstractDeliveryTrackerTest; import org.apache.pulsar.broker.delayed.MockBucketSnapshotStorage; @@ -143,7 +149,8 @@ public Object[][] provider(Method method) throws Exception { new BucketDelayedDeliveryTracker(dispatcher, timer, 500, clock, true, bucketSnapshotStorage, 5, TimeUnit.MILLISECONDS.toMillis(10), -1, 50) }}; - case "testMergeSnapshot", "testWithBkException", "testWithCreateFailDowngrade" -> new Object[][]{{ + case "testMergeSnapshot", "testWithBkException", "testWithCreateFailDowngrade", + "testMergePreservesAllSnapshotSegments" -> new Object[][]{{ new BucketDelayedDeliveryTracker(dispatcher, timer, 100000, clock, true, bucketSnapshotStorage, 5, TimeUnit.MILLISECONDS.toMillis(10), -1, 10) }}; @@ -151,6 +158,15 @@ public Object[][] provider(Method method) throws Exception { new BucketDelayedDeliveryTracker(dispatcher, timer, 100000, clock, true, bucketSnapshotStorage, 20, TimeUnit.HOURS.toMillis(1), 5, 100) }}; + case "testClear" -> new Object[][]{{ + new BucketDelayedDeliveryTracker(dispatcher, timer, 100000, clock, + true, bucketSnapshotStorage, 1000, TimeUnit.MILLISECONDS.toMillis(100), -1, 50) + }}; + case "testExpiredTrackedMessageReturnsFalse", "testRecoverThenExpireAddMessage", + "testExpiredTrackedMessageDecrementsCount" -> new Object[][]{{ + new BucketDelayedDeliveryTracker(dispatcher, timer, 1, clock, + true, bucketSnapshotStorage, 5, TimeUnit.MILLISECONDS.toMillis(10), -1, 50) + }}; default -> new Object[][]{{ new BucketDelayedDeliveryTracker(dispatcher, timer, 1, clock, true, bucketSnapshotStorage, 1000, TimeUnit.MILLISECONDS.toMillis(100), -1, 50) @@ -182,6 +198,83 @@ public void testContainsMessage(BucketDelayedDeliveryTracker tracker) { tracker.close(); } + @Test(dataProvider = "delayedTracker") + public void testExpiredTrackedMessageReturnsFalse(BucketDelayedDeliveryTracker tracker) { + clockTime.set(1000); + assertTrue(tracker.addMessage(1, 1, 2000)); + assertTrue(tracker.containsMessage(1, 1)); + + clockTime.set(2500); + + assertFalse( + "Expired tracked message should return false so dispatcher delivers immediately", + tracker.addMessage(1, 1, 2000)); + + tracker.close(); + } + + @Test(dataProvider = "delayedTracker") + public void testRecoverThenExpireAddMessage(BucketDelayedDeliveryTracker tracker) throws Exception { + clockTime.set(0); + for (int i = 1; i <= 6; i++) { + tracker.addMessage(i, i, i * 1000); + } + + Awaitility.await().untilAsserted(() -> + assertTrue(tracker.getImmutableBuckets().asMapOfRanges().values().stream() + .noneMatch(x -> x.merging || !x.getSnapshotCreateFuture().get().isDone()))); + + tracker.close(); + + clockTime.set(0); + @Cleanup + BucketDelayedDeliveryTracker tracker2 = new BucketDelayedDeliveryTracker( + dispatcher, timer, 100000, clock, true, + bucketSnapshotStorage, 5, TimeUnit.MILLISECONDS.toMillis(10), -1, 50); + + assertTrue(tracker2.containsMessage(1, 1)); + + clockTime.set(10000); + + assertFalse( + "Recovered message that is now expired should return false", + tracker2.addMessage(1, 1, 1000)); + } + + @Test(dataProvider = "delayedTracker") + public void testExpiredTrackedMessageDecrementsCount(BucketDelayedDeliveryTracker tracker) { + clockTime.set(1000); + tracker.addMessage(1, 1, 2000); + assertEquals(tracker.getNumberOfDelayedMessages(), 1); + + clockTime.set(2500); + assertFalse(tracker.addMessage(1, 1, 2000)); + assertEquals(tracker.getNumberOfDelayedMessages(), 0); + assertFalse(tracker.containsMessage(1, 1)); + tracker.close(); + } + + @Test(dataProvider = "delayedTracker") + public void testMergePreservesAllSnapshotSegments(BucketDelayedDeliveryTracker tracker) throws Exception { + clockTime.set(0); + for (int i = 1; i <= 56; i++) { + tracker.addMessage(i, i, i * 10); + } + Awaitility.await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> + assertTrue(tracker.getImmutableBuckets().asMapOfRanges().values().stream() + .noneMatch(x -> x.merging))); + assertEquals(tracker.getNumberOfDelayedMessages(), 56); + + tracker.close(); + clockTime.set(0); + BucketDelayedDeliveryTracker tracker2 = new BucketDelayedDeliveryTracker( + dispatcher, timer, 100000, clock, + true, bucketSnapshotStorage, 5, TimeUnit.MILLISECONDS.toMillis(10), -1, 10); + + assertEquals(tracker2.getNumberOfDelayedMessages(), 55); + tracker2.close(); + } + @Test(dataProvider = "delayedTracker", invocationCount = 10) public void testRecoverSnapshot(BucketDelayedDeliveryTracker tracker) throws Exception { for (int i = 1; i <= 100; i++) { @@ -306,7 +399,7 @@ public void testMergeSnapshot(final BucketDelayedDeliveryTracker tracker) throws clockTime.set(110 * 10); NavigableSet scheduledMessages = new TreeSet<>(); - Awaitility.await().untilAsserted(() -> { + Awaitility.await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> { scheduledMessages.addAll(tracker2.getScheduledMessages(110)); assertEquals(scheduledMessages.size(), 110); }); @@ -382,7 +475,7 @@ public void testWithBkException(final BucketDelayedDeliveryTracker tracker) thro assertEquals(tracker2.getScheduledMessages(100).size(), 0); Set scheduledMessages = new TreeSet<>(); - Awaitility.await().untilAsserted(() -> { + Awaitility.await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> { scheduledMessages.addAll(tracker2.getScheduledMessages(100)); assertEquals(scheduledMessages.size(), delayedMessagesInSnapshotValue); }); @@ -463,4 +556,690 @@ public void testClear(BucketDelayedDeliveryTracker tracker) tracker.close(); } + + private static class TrackerWithStorage { + final BucketDelayedDeliveryTracker tracker; + final MockBucketSnapshotStorage storage; + final AtomicLong clockTime; + + TrackerWithStorage(BucketDelayedDeliveryTracker tracker, MockBucketSnapshotStorage storage, + AtomicLong clockTime) { + this.tracker = tracker; + this.storage = storage; + this.clockTime = clockTime; + } + + void close() throws Exception { + tracker.close(); + storage.close(); + } + } + + private static class BlockingDeleteStorage extends MockBucketSnapshotStorage { + final CompletableFuture firstDeleteFuture = new CompletableFuture<>(); + final AtomicLong deleteCalls = new AtomicLong(); + + @Override + public CompletableFuture deleteBucketSnapshot(long bucketId) { + if (deleteCalls.incrementAndGet() <= 4) { + return firstDeleteFuture; + } + return super.deleteBucketSnapshot(bucketId); + } + } + + private static class RecordingDeleteStorage extends MockBucketSnapshotStorage { + final AtomicLong deleteCalls = new AtomicLong(); + + @Override + public CompletableFuture deleteBucketSnapshot(long bucketId) { + deleteCalls.incrementAndGet(); + return super.deleteBucketSnapshot(bucketId); + } + } + + private ImmutableBucket createMergeableBucket(TrackerWithStorage trackerWithStorage, long startLedgerId, + long endLedgerId, List firstScheduleTimestamps) { + ImmutableBucket bucket = new ImmutableBucket(trackerWithStorage.tracker.getCtx(), startLedgerId, endLedgerId); + bucket.setCurrentSegmentEntryId(1); + bucket.setLastSegmentEntryId(firstScheduleTimestamps.size()); + bucket.setFirstScheduleTimestamps(firstScheduleTimestamps); + bucket.setNumberBucketDelayedMessages(1); + return bucket; + } + + private TrackerWithStorage createTrackerWithMockLedger(long firstLedgerId, int maxNumBuckets) + throws Exception { + return createTrackerWithMockLedger(firstLedgerId, maxNumBuckets, new MockBucketSnapshotStorage()); + } + + private TrackerWithStorage createTrackerWithMockLedger(long firstLedgerId, int maxNumBuckets, + MockBucketSnapshotStorage storage) + throws Exception { + storage.start(); + + ManagedLedger mockLedger = mock(ManagedLedger.class); + NavigableMap ledgerInfo = new TreeMap<>(); + ledgerInfo.put(firstLedgerId, mock(MLDataFormats.ManagedLedgerInfo.LedgerInfo.class)); + when(mockLedger.getLedgersInfo()).thenReturn(ledgerInfo); + when(mockLedger.getName()).thenReturn("test-ledger"); + + ManagedCursor mockCursor = new MockManagedCursor("test-cursor") { + @Override + public ManagedLedger getManagedLedger() { + return mockLedger; + } + + @Override + public Position getMarkDeletedPosition() { + return PositionFactory.create(firstLedgerId, -1); + } + }; + + AbstractPersistentDispatcherMultipleConsumers disp = + mock(AbstractPersistentDispatcherMultipleConsumers.class); + Clock mockClock = mock(Clock.class); + AtomicLong mockClockTime = new AtomicLong(); + when(mockClock.millis()).then(x -> mockClockTime.get()); + doReturn(mockCursor).when(disp).getCursor(); + doReturn("persistent://public/default/testDelay" + " / " + mockCursor.getName()).when(disp).getName(); + + BucketDelayedDeliveryTracker tracker = new BucketDelayedDeliveryTracker(disp, mock(Timer.class), + 100000, mockClock, true, storage, 5, TimeUnit.MILLISECONDS.toMillis(10), -1, maxNumBuckets); + return new TrackerWithStorage(tracker, storage, mockClockTime); + } + + @DataProvider(name = "smallMaxNumBuckets") + private Object[][] smallMaxNumBuckets() { + return new Object[][]{{1}, {2}, {3}}; + } + + @Test(dataProvider = "smallMaxNumBuckets") + public void testMergeSupportsSmallMaxNumBuckets(int maxNumBuckets) throws Exception { + TrackerWithStorage ts = createTrackerWithMockLedger(0L, maxNumBuckets); + int messageCount = (maxNumBuckets + 1) * 5 + 1; + NavigableSet expectedMessages = new TreeSet<>(); + try { + for (int i = 1; i <= messageCount; i++) { + assertTrue(ts.tracker.addMessage(i, i, i % 5 == 0 ? 20L : 10L)); + expectedMessages.add(PositionFactory.create(i, i)); + } + + Awaitility.await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { + synchronized (ts.tracker) { + List buckets = List.copyOf( + ts.tracker.getImmutableBuckets().asMapOfRanges().values()); + assertTrue(!buckets.isEmpty()); + assertTrue(buckets.size() <= maxNumBuckets); + assertTrue(buckets.stream().noneMatch(bucket -> bucket.merging + || bucket.getSnapshotCreateFuture() + .map(future -> !future.isDone() || future.isCompletedExceptionally()) + .orElse(true))); + } + }); + + ts.clockTime.set(20L); + List scheduledMessages = new ArrayList<>(); + Awaitility.await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { + scheduledMessages.addAll(ts.tracker.getScheduledMessages(expectedMessages.size())); + assertEquals(scheduledMessages.size(), expectedMessages.size()); + }); + assertEquals(new TreeSet<>(scheduledMessages), expectedMessages); + assertEquals(ts.tracker.getNumberOfDelayedMessages(), 0L); + } finally { + ts.close(); + } + } + + @Test + public void testTrimRemovesOrphanedBuckets() throws Exception { + long firstLedgerId = 31L; + int messageCount = 36; + TrackerWithStorage ts = createTrackerWithMockLedger(firstLedgerId, 5); + + for (int i = 1; i <= messageCount; i++) { + ts.tracker.addMessage(i, i, i * 10); + } + Awaitility.await().untilAsserted(() -> + Assert.assertTrue(ts.tracker.getImmutableBuckets().asMapOfRanges().values().stream() + .noneMatch(x -> x.merging))); + + int bucketCount = ts.tracker.getImmutableBuckets().asMapOfRanges().size(); + assertTrue(bucketCount <= 5, + "Bucket count " + bucketCount + " should be <= maxNumBuckets=5 after trim+merge"); + + ts.tracker.getImmutableBuckets().asMapOfRanges().forEach((range, bucket) -> + assertTrue(range.lowerEndpoint() >= firstLedgerId, + "Remaining bucket range " + range + " should be >= " + firstLedgerId)); + + long messagesAfterTrim = ts.tracker.getNumberOfDelayedMessages(); + ts.clockTime.set(messageCount * 10); + NavigableSet scheduledMessages = ts.tracker.getScheduledMessages(1); + assertTrue(scheduledMessages.stream().noneMatch(position -> position.getLedgerId() < firstLedgerId), + "Trimmed ledgers should not be returned from the loaded shared queue"); + assertEquals(ts.tracker.getNumberOfDelayedMessages(), messagesAfterTrim - scheduledMessages.size()); + + ts.close(); + } + + @Test + public void testSelectMergedBucketsSupportsTwoBuckets() throws Exception { + TrackerWithStorage ts = createTrackerWithMockLedger(0L, 1); + try { + ImmutableBucket firstBucket = createMergeableBucket(ts, 1L, 1L, List.of(10L, 20L)); + ImmutableBucket secondBucket = createMergeableBucket(ts, 2L, 2L, List.of(10L, 20L)); + + assertEquals(ts.tracker.selectMergedBuckets(List.of(firstBucket, secondBucket), 4), + List.of(firstBucket, secondBucket)); + } finally { + ts.close(); + } + } + + @Test + public void testSelectMergedBucketsHandlesOneUnloadedSegment() throws Exception { + TrackerWithStorage ts = createTrackerWithMockLedger(0L, 1); + try { + ImmutableBucket firstBucket = createMergeableBucket(ts, 1L, 1L, List.of(10L, 20L)); + ImmutableBucket secondBucket = createMergeableBucket(ts, 2L, 2L, List.of(10L, 20L)); + ImmutableBucket thirdBucket = createMergeableBucket(ts, 3L, 3L, List.of(10L, 20L)); + + assertEquals(ts.tracker.selectMergedBuckets(List.of(firstBucket, secondBucket, thirdBucket), 2), + List.of(firstBucket, secondBucket)); + } finally { + ts.close(); + } + } + + @Test + public void testSelectMergedBucketsUsesNextUnloadedSegmentTimestamp() throws Exception { + TrackerWithStorage ts = createTrackerWithMockLedger(0L, 1); + try { + ImmutableBucket firstBucket = createMergeableBucket(ts, 1L, 1L, List.of(10L, 100L, 10L)); + ImmutableBucket secondBucket = createMergeableBucket(ts, 2L, 2L, List.of(10L, 100L, 10L)); + ImmutableBucket thirdBucket = createMergeableBucket(ts, 3L, 3L, List.of(10L, 50L, 1000L)); + + assertEquals(ts.tracker.selectMergedBuckets(List.of(firstBucket, secondBucket, thirdBucket), 2), + List.of(secondBucket, thirdBucket)); + } finally { + ts.close(); + } + } + + @Test + public void testTrimDoesNotDeleteBucketOverlappingFirstActiveLedger() throws Exception { + RecordingDeleteStorage storage = new RecordingDeleteStorage(); + TrackerWithStorage ts = createTrackerWithMockLedger(3L, 4, storage); + try { + for (long ledgerId = 1; ledgerId <= 6; ledgerId++) { + ts.tracker.addMessage(ledgerId, 0L, 1000L); + } + + Awaitility.await().untilAsserted(() -> { + assertEquals(ts.tracker.getImmutableBuckets().asMapOfRanges().size(), 1); + ImmutableBucket bucket = ts.tracker.getImmutableBuckets().asMapOfRanges() + .values().iterator().next(); + assertTrue(bucket.getSnapshotCreateFuture().orElseThrow().isDone()); + }); + + // The fifth immutable bucket triggers trimming. All buckets have one snapshot segment, + // so merging does not remove any snapshots during this test. + for (long ledgerId = 7; ledgerId <= 26; ledgerId++) { + ts.tracker.addMessage(ledgerId, 0L, 1000L); + } + Awaitility.await().untilAsserted( + () -> assertEquals(ts.tracker.getImmutableBuckets().asMapOfRanges().size(), 5)); + + Awaitility.await().pollDelay(1, TimeUnit.SECONDS).atMost(2, TimeUnit.SECONDS).untilAsserted( + () -> assertEquals(storage.deleteCalls.get(), 0L, + "A bucket overlapping the first active ledger must not be deleted")); + } finally { + ts.close(); + } + } + + @Test + public void testTrimHandlesDeleteFailure() throws Exception { + long firstLedgerId = 50L; + int messageCount = 31; + TrackerWithStorage ts = createTrackerWithMockLedger(firstLedgerId, 5); + + // MaxRetryTimes=3 means the first trim delete attempt plus 3 retries = 4 exceptions consumed. + for (int i = 0; i < 4; i++) { + ts.storage.injectDeleteException( + new BucketSnapshotPersistenceException("Delete failed")); + } + + for (int i = 1; i <= messageCount; i++) { + ts.tracker.addMessage(i, i, i * 10); + } + Awaitility.await().untilAsserted(() -> + Assert.assertTrue(ts.tracker.getImmutableBuckets().asMapOfRanges().values().stream() + .noneMatch(x -> x.merging))); + + Awaitility.await().untilAsserted(() -> + assertTrue(ts.storage.deleteExceptionQueue.isEmpty(), + "Delete exception should have been consumed")); + + // Trim failed on the first orphaned bucket; the sequential chain stopped, so all + // 6 orphaned buckets remain in immutableBuckets. + assertTrue(ts.tracker.getImmutableBuckets().asMapOfRanges().size() > 0, + "Orphaned buckets should remain when trim delete fails"); + ts.tracker.getImmutableBuckets().asMapOfRanges().forEach((range, bucket) -> + assertTrue(range.upperEndpoint() < firstLedgerId, + "Remaining bucket " + range + " should be an orphaned bucket")); + + // numberDelayedMessages is unchanged because failed deletes do not decrement the count. + assertEquals(ts.tracker.getNumberOfDelayedMessages(), messageCount); + + ts.close(); + } + + @Test + public void testClearRunsAfterInFlightTrimFailure() throws Exception { + long firstLedgerId = 50L; + int messageCount = 31; + BlockingDeleteStorage storage = new BlockingDeleteStorage(); + TrackerWithStorage ts = createTrackerWithMockLedger(firstLedgerId, 5, storage); + + for (int i = 1; i <= messageCount; i++) { + ts.tracker.addMessage(i, i, i * 10); + } + Awaitility.await().untilAsserted(() -> + assertTrue(storage.deleteCalls.get() > 0, "Trim delete should be in flight")); + + CompletableFuture clearFuture = ts.tracker.clear(); + storage.firstDeleteFuture.completeExceptionally(new BucketSnapshotPersistenceException("Delete failed")); + + clearFuture.get(1, TimeUnit.MINUTES); + assertEquals(ts.tracker.getNumberOfDelayedMessages(), 0); + assertEquals(ts.tracker.getImmutableBuckets().asMapOfRanges().size(), 0); + assertEquals(ts.tracker.getLastMutableBucket().size(), 0); + assertEquals(ts.tracker.getSharedBucketPriorityQueue().size(), 0); + + ts.close(); + } + + @Test + public void testTrimWithNoOrphanedBuckets() throws Exception { + TrackerWithStorage ts = createTrackerWithMockLedger(0L, 5); + + for (int i = 1; i <= 31; i++) { + ts.tracker.addMessage(i, i, i * 10); + } + Awaitility.await().untilAsserted(() -> + Assert.assertTrue(ts.tracker.getImmutableBuckets().asMapOfRanges().values().stream() + .noneMatch(x -> x.merging))); + + int bucketCount = ts.tracker.getImmutableBuckets().asMapOfRanges().size(); + assertTrue(bucketCount <= 5, + "Bucket count " + bucketCount + " should be <= maxNumBuckets=5"); + assertTrue(bucketCount > 0, "Should have at least one bucket after merge"); + + ts.close(); + } + + @Test + public void testMergeEarlyReturnWhenWithinLimit() throws Exception { + TrackerWithStorage ts = createTrackerWithMockLedger(0L, 50); + + for (int i = 1; i <= 30; i++) { + ts.tracker.addMessage(i, i, i * 10); + } + Awaitility.await().untilAsserted(() -> + Assert.assertTrue(ts.tracker.getImmutableBuckets().asMapOfRanges().values().stream() + .noneMatch(x -> x.merging))); + + int bucketCount = ts.tracker.getImmutableBuckets().asMapOfRanges().size(); + assertTrue(bucketCount < 50, + "Bucket count " + bucketCount + " should be well below maxNumBuckets=50"); + + long msgsBefore = ts.tracker.getNumberOfDelayedMessages(); + ts.tracker.addMessage(200, 200, 200 * 10); + Awaitility.await().untilAsserted(() -> + Assert.assertTrue(ts.tracker.getImmutableBuckets().asMapOfRanges().values().stream() + .noneMatch(x -> x.merging))); + + assertEquals(ts.tracker.getNumberOfDelayedMessages(), msgsBefore + 1); + + ts.close(); + } + + @Test + public void testGetScheduledMessagesWhenAllOrphaned() throws Exception { + // Reproduces IAE in nextDeliveryTime: when every delayed message lies below the + // mark-delete position, the filter in getScheduledMessages pops the in-memory + // messages without returning them. If the immutable bucket has additional messages + // still in storage (later snapshot segments), numberDelayedMessages stays > 0 + // while both the mutable bucket and the shared priority queue are empty. + // The trailing updateTimer -> nextDeliveryTime must not throw. + long firstLedgerId = 50L; + TrackerWithStorage ts = createTrackerWithMockLedger(firstLedgerId, 50); + + // Five delayed messages on the same orphaned ledger (ledgerId < firstLedgerId). + // They share a mutable bucket because seal requires a strictly greater ledgerId. + // Timestamps are 100ms apart so each lands in its own snapshot segment + // (timeStep=10ms); only the first segment is loaded into the shared queue at seal. + for (int i = 1; i <= 5; i++) { + ts.tracker.addMessage(1, i, i * 100); + } + // A new orphaned ledgerId triggers the seal, producing immutable bucket [1..1] + // with 5 messages across 5 segments; shared queue holds just the first segment. + ts.tracker.addMessage(2, 1, 600); + + Awaitility.await().untilAsserted(() -> + Assert.assertTrue(ts.tracker.getImmutableBuckets().asMapOfRanges().values().stream() + .noneMatch(x -> x.merging))); + + // In strict deliver-at mode getCutoffTime() is just clock.millis(), so advancing the + // clock past the trigger message's deliverAt (600) is enough for + // moveScheduledMessageToSharedQueue to flush the mutable bucket into the shared queue. + ts.clockTime.set(700); + + // Both queues end up empty (filter pops the two in-memory messages), but + // numberDelayedMessages is still 4 (segments 2..5 remain in storage). + NavigableSet scheduledMessages = ts.tracker.getScheduledMessages(10); + assertTrue(scheduledMessages.isEmpty(), + "Orphaned messages should be filtered out, not returned"); + assertTrue(ts.tracker.getNumberOfDelayedMessages() > 0, + "Remaining storage-only messages should keep the counter > 0"); + + // hasMessageAvailable calls nextDeliveryTime while numberDelayedMessages > 0; + // it must not throw IAE. + assertFalse(ts.tracker.hasMessageAvailable()); + + ts.close(); + } + + /** + * Test that overlapping buckets are correctly cleaned up during recovery. + * This verifies the fix for the subRangeMap clipped key issue where + * putAndCleanOverlapRange would store clipped keys that couldn't be + * removed by exact key matching in removeBucket(). + */ + @Test + public void testOverlappingBucketsCleanupDuringRecovery() throws Exception { + // Setup mocks + AbstractPersistentDispatcherMultipleConsumers testDispatcher = + mock(AbstractPersistentDispatcherMultipleConsumers.class); + Clock testClock = mock(Clock.class); + AtomicLong testClockTime = new AtomicLong(); + when(testClock.millis()).then(x -> testClockTime.get()); + + MockBucketSnapshotStorage storage = new MockBucketSnapshotStorage(); + storage.start(); + + ManagedCursor cursor = new MockManagedCursor("test_overlap_cursor"); + doReturn(cursor).when(testDispatcher).getCursor(); + doReturn("persistent://public/default/testOverlap / " + cursor.getName()) + .when(testDispatcher).getName(); + + try { + // Create first tracker with small minIndexCountPerBucket + BucketDelayedDeliveryTracker tracker1 = new BucketDelayedDeliveryTracker( + testDispatcher, timer, 100000, testClock, true, storage, + 3, TimeUnit.MILLISECONDS.toMillis(10), -1, 50); + + // Add messages to create multiple immutable buckets + for (int i = 1; i <= 12; i++) { + tracker1.addMessage(i, i, i * 10); + } + + // Wait for all bucket operations to complete + Awaitility.await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + assertTrue(tracker1.getImmutableBuckets().asMapOfRanges().values().stream() + .noneMatch(x -> x.merging), + "All buckets should finish merging"); + assertTrue(tracker1.getImmutableBuckets().asMapOfRanges().size() >= 2, + "Should have created multiple buckets"); + }); + + int bucketCountBeforeClose = tracker1.getImmutableBuckets().asMapOfRanges().size(); + + tracker1.close(); + + // Create second tracker - triggers recovery with putAndCleanOverlapRange + BucketDelayedDeliveryTracker tracker2 = new BucketDelayedDeliveryTracker( + testDispatcher, timer, 100000, testClock, true, storage, + 3, TimeUnit.MILLISECONDS.toMillis(10), -1, 50); + + // Verify buckets were recovered + int bucketCountAfterRecovery = tracker2.getImmutableBuckets().asMapOfRanges().size(); + assertTrue(bucketCountAfterRecovery > 0, "Should have recovered buckets"); + + // Key assertion: verify no orphaned buckets remain + // If clipped keys weren't fixed, removeBucket() would fail and buckets would accumulate + assertTrue(bucketCountAfterRecovery <= bucketCountBeforeClose, + String.format("Orphaned buckets detected: %d after recovery > %d before close", + bucketCountAfterRecovery, bucketCountBeforeClose)); + + // Verify messages were recovered + assertTrue(tracker2.getNumberOfDelayedMessages() > 0, + "Should have recovered messages"); + + // Verify snapshot length tracking is correct + long totalSnapshotLength = tracker2.getImmutableBuckets().asMapOfRanges().values().stream() + .mapToLong(ImmutableBucket::getSnapshotLength) + .sum(); + assertTrue(totalSnapshotLength >= 0, + "Snapshot length tracking broken - likely due to failed removeBucket()"); + + tracker2.close(); + } finally { + storage.clean(); + } + } + + /** + * Test that putAndCleanOverlapRange correctly uses original keys instead of truncated keys + * when checking if a new range encloses existing buckets. + * + * This prevents the bug where a truncated key from subRangeMap() would incorrectly pass + * the encloses() check, causing a bucket to be replaced when it shouldn't be. + */ + @Test + public void testPutAndCleanOverlapRangeWithTruncatedKeys() throws Exception { + // Setup mocks + AbstractPersistentDispatcherMultipleConsumers testDispatcher = + mock(AbstractPersistentDispatcherMultipleConsumers.class); + Clock testClock = mock(Clock.class); + AtomicLong testClockTime = new AtomicLong(); + when(testClock.millis()).then(x -> testClockTime.get()); + + MockBucketSnapshotStorage storage = new MockBucketSnapshotStorage(); + storage.start(); + + ManagedCursor cursor = new MockManagedCursor("test_truncated_cursor"); + doReturn(cursor).when(testDispatcher).getCursor(); + doReturn("persistent://public/default/testTruncated / " + cursor.getName()) + .when(testDispatcher).getName(); + + try { + // Create tracker + BucketDelayedDeliveryTracker tracker = new BucketDelayedDeliveryTracker( + testDispatcher, timer, 100000, testClock, true, storage, + 3, TimeUnit.MILLISECONDS.toMillis(10), -1, 50); + + // Add messages to create a bucket [1-6] + for (int i = 1; i <= 6; i++) { + tracker.addMessage(i, i, i * 10); + } + + // Wait for bucket to be created + Awaitility.await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + assertTrue(tracker.getImmutableBuckets().asMapOfRanges().size() >= 1, + "Should have created at least one bucket"); + }); + + int initialBucketCount = tracker.getImmutableBuckets().asMapOfRanges().size(); + long initialMessageCount = tracker.getNumberOfDelayedMessages(); + + // Now add messages that would create a bucket [7-9] + // This should NOT replace the existing bucket [1-6] + for (int i = 7; i <= 9; i++) { + tracker.addMessage(i, i, i * 10); + } + + // Wait for new bucket operations + Awaitility.await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + assertTrue(tracker.getImmutableBuckets().asMapOfRanges().values().stream() + .noneMatch(x -> x.merging), + "All buckets should finish processing"); + }); + + // Verify bucket count increased (or stayed same if they got merged) + int finalBucketCount = tracker.getImmutableBuckets().asMapOfRanges().size(); + assertTrue(finalBucketCount >= initialBucketCount, + "Bucket count should not decrease when adding non-overlapping ranges"); + + // Verify all messages are tracked + long finalMessageCount = tracker.getNumberOfDelayedMessages(); + assertTrue(finalMessageCount >= initialMessageCount, + String.format("Message count should not decrease: initial=%d, final=%d", + initialMessageCount, finalMessageCount)); + + // Verify no bucket was incorrectly replaced + // If putAndCleanOverlapRange used truncated keys, it might have incorrectly + // removed a bucket that shouldn't have been removed + tracker.getImmutableBuckets().asMapOfRanges().forEach((range, bucket) -> { + assertTrue(bucket.getNumberBucketDelayedMessages() > 0, + "All buckets should have messages - bucket " + range + " is empty"); + }); + + tracker.close(); + } finally { + storage.clean(); + } + } + + @Test + public void testLateSnapshotLengthUpdateAfterClearDoesNotInflateCounter() throws Exception { + AbstractPersistentDispatcherMultipleConsumers testDispatcher = + mock(AbstractPersistentDispatcherMultipleConsumers.class); + Clock testClock = mock(Clock.class); + AtomicLong testClockTime = new AtomicLong(); + when(testClock.millis()).then(x -> testClockTime.get()); + + MockBucketSnapshotStorage storage = new MockBucketSnapshotStorage(); + storage.start(); + MockBucketSnapshotStorage spyStorage = spy(storage); + + CompletableFuture blockedLength = new CompletableFuture<>(); + when(spyStorage.getBucketSnapshotLength(anyLong())).thenReturn(blockedLength); + + ManagedCursor cursor = new MockManagedCursor("test_late_update_cursor"); + doReturn(cursor).when(testDispatcher).getCursor(); + doReturn("persistent://public/default/testLateUpdate / " + cursor.getName()) + .when(testDispatcher).getName(); + + try { + BucketDelayedDeliveryTracker tracker = new BucketDelayedDeliveryTracker( + testDispatcher, timer, 100000, testClock, true, spyStorage, + 3, TimeUnit.MILLISECONDS.toMillis(10), -1, 50); + + for (int i = 1; i <= 6; i++) { + tracker.addMessage(i, i, i * 10); + } + + Awaitility.await().untilAsserted(() -> + assertTrue(tracker.getBucketsCount().get() >= 1, + "Should have created at least one immutable bucket")); + assertCountersConsistent(tracker); + + tracker.clear(); + + assertEquals(tracker.getBucketsCount().get(), 0, "All buckets should be removed"); + assertCountersConsistent(tracker); + + blockedLength.complete(999_999L); + + Awaitility.await().untilAsserted(() -> { + assertEquals(tracker.getTotalSnapshotLengthBytes().get(), 0, + "Late length update inflated totalSnapshotLengthBytes after clear"); + }); + + tracker.close(); + } finally { + storage.clean(); + } + } + + @Test + public void testLateSnapshotLengthUpdateAfterTrimDoesNotInflateCounter() throws Exception { + AbstractPersistentDispatcherMultipleConsumers testDispatcher = + mock(AbstractPersistentDispatcherMultipleConsumers.class); + Clock testClock = mock(Clock.class); + AtomicLong testClockTime = new AtomicLong(); + when(testClock.millis()).then(x -> testClockTime.get()); + + MockBucketSnapshotStorage storage = new MockBucketSnapshotStorage(); + storage.start(); + MockBucketSnapshotStorage spyStorage = spy(storage); + + CompletableFuture blockedLength = new CompletableFuture<>(); + when(spyStorage.getBucketSnapshotLength(anyLong())).thenReturn(blockedLength); + + ManagedCursor spyCursor = spy(new MockManagedCursor("test_late_trim_cursor")); + AtomicLong markDeletedLedger = new AtomicLong(0); + when(spyCursor.getMarkDeletedPosition()).thenAnswer(inv -> + PositionFactory.create(markDeletedLedger.get(), 0)); + ManagedLedger mockLedger = mock(ManagedLedger.class); + when(mockLedger.getName()).thenReturn("test_ledger"); + when(spyCursor.getManagedLedger()).thenReturn(mockLedger); + + doReturn(spyCursor).when(testDispatcher).getCursor(); + doReturn("persistent://public/default/testLateTrim / " + spyCursor.getName()) + .when(testDispatcher).getName(); + + try { + BucketDelayedDeliveryTracker tracker = new BucketDelayedDeliveryTracker( + testDispatcher, timer, 100000, testClock, true, spyStorage, + 3, TimeUnit.MILLISECONDS.toMillis(10), -1, 3); + + for (int i = 1; i <= 12; i++) { + tracker.addMessage(i, i, i * 10); + } + + Awaitility.await().untilAsserted(() -> + assertTrue(tracker.getBucketsCount().get() >= 3, + "Should have created at least 3 immutable buckets")); + assertCountersConsistent(tracker); + + markDeletedLedger.set(5); + + for (int i = 13; i <= 15; i++) { + tracker.addMessage(i, i, i * 10); + } + + Awaitility.await().untilAsserted(() -> { + boolean hasOldBucket = tracker.getImmutableBuckets().asMapOfRanges().keySet().stream() + .anyMatch(r -> r.upperEndpoint() < 5); + Assert.assertFalse(hasOldBucket, "Buckets with endLedgerId < 5 should be trimmed"); + }); + assertCountersConsistent(tracker); + + blockedLength.complete(999_999L); + + Awaitility.await().untilAsserted(() -> + assertCountersConsistent(tracker)); + + tracker.close(); + } finally { + storage.clean(); + } + } + + private static void assertCountersConsistent(BucketDelayedDeliveryTracker tracker) { + int liveBucketCount = tracker.getImmutableBuckets().asMapOfRanges().size(); + long liveSnapshotLength = tracker.getImmutableBuckets().asMapOfRanges().values().stream() + .mapToLong(ImmutableBucket::getSnapshotLength) + .sum(); + + assertEquals(tracker.getBucketsCount().get(), liveBucketCount, + String.format("bucketsCount drift: cached=%d live=%d", + tracker.getBucketsCount().get(), liveBucketCount)); + assertEquals(tracker.getTotalSnapshotLengthBytes().get(), liveSnapshotLength, + String.format("totalSnapshotLengthBytes drift: cached=%d live=%d", + tracker.getTotalSnapshotLengthBytes().get(), liveSnapshotLength)); + } } diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedMessageIndexStatsTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedMessageIndexStatsTest.java new file mode 100644 index 0000000000000..b1b7d8248cc37 --- /dev/null +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/delayed/bucket/BucketDelayedMessageIndexStatsTest.java @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.broker.delayed.bucket; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import java.util.Map; +import org.apache.pulsar.common.policies.data.stats.TopicMetricBean; +import org.testng.annotations.Test; + +public class BucketDelayedMessageIndexStatsTest { + + private static final String OP_COUNT = BucketDelayedMessageIndexStats.OP_COUNT_NAME; + private static final String OP_LATENCY_COUNT = BucketDelayedMessageIndexStats.OP_LATENCY_NAME + "_count"; + private static final String OP_LATENCY_SUM = BucketDelayedMessageIndexStats.OP_LATENCY_NAME + "_sum"; + + @Test + public void testMetricsAreCumulativeAcrossScrapes() { + BucketDelayedMessageIndexStats stats = new BucketDelayedMessageIndexStats(); + var type = BucketDelayedMessageIndexStats.Type.create; + + stats.recordTriggerEvent(type); + stats.recordSuccessEvent(type, 200); + stats.recordSuccessEvent(type, 300); + + // First scrape + var m1 = stats.genTopicMetricMap(); + assertEquals(get(m1, OP_COUNT, "state", "succeed", "type", "create"), 2); + assertEquals(get(m1, OP_LATENCY_COUNT, "type", "create"), 2); + assertEquals(get(m1, OP_LATENCY_SUM, "type", "create"), 500); + + // Second scrape — must not reset + var m2 = stats.genTopicMetricMap(); + assertEquals(get(m2, OP_COUNT, "state", "succeed", "type", "create"), 2); + assertEquals(get(m2, OP_LATENCY_COUNT, "type", "create"), 2); + assertEquals(get(m2, OP_LATENCY_SUM, "type", "create"), 500); + + // Third event — cumulative + stats.recordSuccessEvent(type, 100); + var m3 = stats.genTopicMetricMap(); + assertEquals(get(m3, OP_COUNT, "state", "succeed", "type", "create"), 3); + assertEquals(get(m3, OP_LATENCY_COUNT, "type", "create"), 3); + assertEquals(get(m3, OP_LATENCY_SUM, "type", "create"), 600); + } + + private static long get(Map metrics, String name, String... labelsAndValues) { + String key = name + BucketDelayedMessageIndexStats.joinKey(labelsAndValues); + TopicMetricBean bean = metrics.get(key); + assertNotNull(bean, "Metric not found: " + key); + return (long) bean.value; + } +} diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/LeaderElectionServiceTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/LeaderElectionServiceTest.java index 3014c185cc802..a54a492144b99 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/LeaderElectionServiceTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/LeaderElectionServiceTest.java @@ -21,6 +21,7 @@ import static org.apache.pulsar.broker.BrokerTestUtil.spyWithClassAndConstructorArgs; import com.google.common.collect.Sets; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; @@ -113,6 +114,9 @@ public void anErrorShouldBeThrowBeforeLeaderElected() throws PulsarServerExcepti leaderBrokerReference.get() != null); Mockito.when(leaderElectionService.getCurrentLeader()) .thenAnswer(invocation -> Optional.ofNullable(leaderBrokerReference.get())); + Mockito.when(leaderElectionService.readCurrentLeader()) + .thenAnswer(invocation -> + CompletableFuture.completedFuture(Optional.ofNullable(leaderBrokerReference.get()))); leaderElectionServiceReference.set(leaderElectionService); // broker, webService and leaderElectionService is started, but elect not ready; diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/extensions/ExtensibleLoadManagerImplBaseTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/extensions/ExtensibleLoadManagerImplBaseTest.java index 55e9b8d6baf54..afd8681e24d4b 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/extensions/ExtensibleLoadManagerImplBaseTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/extensions/ExtensibleLoadManagerImplBaseTest.java @@ -20,6 +20,7 @@ import static org.mockito.Mockito.reset; import static org.mockito.Mockito.spy; +import static org.testng.Assert.assertTrue; import com.google.common.collect.Sets; import com.google.common.io.Resources; import java.util.ArrayList; @@ -38,7 +39,6 @@ import org.apache.pulsar.broker.loadbalance.extensions.channel.ServiceUnitStateTableViewImpl; import org.apache.pulsar.broker.loadbalance.extensions.scheduler.TransferShedder; import org.apache.pulsar.broker.testcontext.PulsarTestContext; -import org.apache.pulsar.client.admin.PulsarAdminException; import org.apache.pulsar.client.api.PulsarClient; import org.apache.pulsar.client.api.PulsarClientException; import org.apache.pulsar.client.impl.LookupService; @@ -47,6 +47,8 @@ import org.apache.pulsar.common.naming.TopicName; import org.apache.pulsar.common.policies.data.ClusterData; import org.apache.pulsar.common.policies.data.TenantInfoImpl; +import org.awaitility.Awaitility; +import org.awaitility.core.ConditionTimeoutException; import org.testng.annotations.AfterClass; import org.testng.annotations.BeforeClass; import org.testng.annotations.BeforeMethod; @@ -195,14 +197,81 @@ protected void cleanup() throws Exception { } @BeforeMethod(alwaysRun = true) - protected void initializeState() throws PulsarAdminException, IllegalAccessException { - admin.namespaces().unload(defaultTestNamespace); + protected void initializeState() throws Exception { + // Reset to a clean state before each test: reconcile each broker's role with the channel + // ownership and unload the test namespace so no bundle ownership carries over. The unload + // publishes a state change on the channel system topic. + // + // A prior role-churning test (e.g. the direct playLeader()/playFollower() calls in + // testRoleChangeIdempotency) can leave the channel system topic owned by a broker that no + // longer serves it ("not served by this instance, redo the lookup"), with the channel + // producer stuck in escalating reconnect backoff, so the unload's channel publish keeps + // failing. Each unload attempt force-serves the channel topic (an admin lookup re-assigns + // the pulsar/system bundle and getStats makes the owner load it); if that still does not + // recover, force a clean channel owner via leader re-election (which reassigns and + // re-serves the channel topic and makes clients redo their lookups) and retry. + try { + awaitTestNamespaceUnloaded(30); + } catch (ConditionTimeoutException channelWedged) { + recoverChannelOwnership(); + awaitTestNamespaceUnloaded(60); + } reset(primaryLoadManager, secondaryLoadManager); FieldUtils.writeDeclaredField(pulsarClient, "lookup", lookupService, true); pulsar1.getConfig().setLoadBalancerMultiPhaseBundleUnload(true); pulsar2.getConfig().setLoadBalancerMultiPhaseBundleUnload(true); } + // Drive monitor() to reconcile roles and force-serve the channel topic (monitor() only + // self-heals when there is *no* channel owner, not when an owner is recorded but the bundle is + // not served), then unload. ignoreExceptions() retries transient channel-publish failures; each + // unload attempt is bounded so a synchronous unload cannot block longer than the retry window. + private void awaitTestNamespaceUnloaded(long atMostSeconds) { + boolean systemTopicChannel = + serviceUnitStateTableViewClassName.equals(ServiceUnitStateTableViewImpl.class.getName()); + Awaitility.await().atMost(atMostSeconds, TimeUnit.SECONDS) + .pollInterval(1, TimeUnit.SECONDS) + .ignoreExceptions() + .untilAsserted(() -> { + primaryLoadManager.monitor(); + secondaryLoadManager.monitor(); + if (systemTopicChannel) { + admin.lookups().lookupTopic(ServiceUnitStateTableViewImpl.TOPIC); + admin.topics().getStats(ServiceUnitStateTableViewImpl.TOPIC); + } + admin.namespaces().unloadAsync(defaultTestNamespace).get(15, TimeUnit.SECONDS); + }); + } + + /** + * Force a clean channel owner via leader re-election. After heavy direct + * playLeader()/playFollower() churn the channel system topic can be left owned by a broker + * that no longer serves it, leaving the channel producer stuck on a stale lookup in + * escalating reconnect backoff. Closing the current owner's LeaderElectionService moves + * ownership to the other broker; its playLeader() re-creates and re-serves the channel + * topic, and the ownership change makes clients redo their (stale) lookups. + */ + private void recoverChannelOwnership() throws Exception { + boolean pulsar1Owns; + try { + pulsar1Owns = channel1.isChannelOwner(); + } catch (Exception e) { + // Owner can't be determined (e.g. no channel owner now); default to moving to pulsar2. + pulsar1Owns = true; + } + PulsarService currentOwner = pulsar1Owns ? pulsar1 : pulsar2; + ServiceUnitStateChannelImpl newOwnerChannel = pulsar1Owns ? channel2 : channel1; + currentOwner.getLeaderElectionService().close(); + try { + Awaitility.await().atMost(30, TimeUnit.SECONDS).ignoreExceptions() + .untilAsserted(() -> assertTrue(newOwnerChannel.isChannelOwner())); + } catch (ConditionTimeoutException ignore) { + // Best effort: the subsequent unload retry is the real backstop. + } finally { + currentOwner.getLeaderElectionService().start(); + } + } + protected void setPrimaryLoadManager() throws IllegalAccessException { ExtensibleLoadManagerWrapper wrapper = (ExtensibleLoadManagerWrapper) pulsar1.getLoadManager().get(); diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/extensions/ExtensibleLoadManagerImplTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/extensions/ExtensibleLoadManagerImplTest.java index 9792148800298..24a62650125a5 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/extensions/ExtensibleLoadManagerImplTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/extensions/ExtensibleLoadManagerImplTest.java @@ -206,6 +206,41 @@ public void testAssign() throws Exception { assertEquals(webServiceUrl.get().toString(), brokerLookupData.get().getWebServiceUrl()); } + // Test that the load manager will use round-robin assignment + // if the namespace is in loadBalancerSheddingExcludedNamespaces. + @Test + public void testSelectBrokerForSheddingExcludedNamespaces() throws Exception { + pulsar1.getConfiguration().setLoadBalancerSheddingExcludedNamespaces(Set.of(defaultTestNamespace)); + try { + Pair topicAndBundle = + getBundleIsNotOwnByChangeEventTopic("test-topic" + UUID.randomUUID()); + NamespaceBundle bundle1 = topicAndBundle.getRight(); + Optional brokerLookupData1 = primaryLoadManager.assign(Optional.empty(), bundle1, + LookupOptions.builder().build()).get(); + assertTrue(brokerLookupData1.isPresent()); + log.info("Assign the bundle1 {} to {}", bundle1, brokerLookupData1); + + String webServiceUrl1 = brokerLookupData1.get().getWebServiceUrl(); + + Pair topicAndBundle2 = + getBundleIsNotOwnByChangeEventTopic("test-topic-" + UUID.randomUUID()); + + while (topicAndBundle2.getRight().toString().equals(topicAndBundle.getRight().toString()) + || primaryLoadManager.checkOwnershipAsync(Optional.empty(), topicAndBundle2.getRight()).get()) { + topicAndBundle2 = getBundleIsNotOwnByChangeEventTopic("test-topic-" + UUID.randomUUID()); + } + NamespaceBundle bundle2 = topicAndBundle2.getRight(); + Optional brokerLookupData2 = primaryLoadManager.assign(Optional.empty(), bundle2, + LookupOptions.builder().build()).get(); + assertTrue(brokerLookupData2.isPresent()); + log.info("Assign the bundle2 {} to {}", bundle2, brokerLookupData2); + String webServiceUrl2 = brokerLookupData2.get().getWebServiceUrl(); + assertNotEquals(webServiceUrl1, webServiceUrl2); + } finally { + pulsar1.getConfiguration().setLoadBalancerSheddingExcludedNamespaces(Set.of()); + } + } + @Test public void testLookupOptions() throws Exception { Pair topicAndBundle = @@ -1296,7 +1331,11 @@ public void testDeployAndRollbackLoadManager() throws Exception { } } - @Test(priority = 200) + // Cap below the 300s suite default (AnnotationListener) so a hung ServiceUnitStateTableViewSyncer + // start() fails fast and is retried instead of consuming the full 300s slot and corrupting the + // next @BeforeMethod. Combined with the shortened sync-wait budget set below, a real divergence + // surfaces within the shortened budget instead of a 5-minute ThreadTimeoutException. + @Test(priority = 200, timeOut = 240 * 1000) public void testLoadBalancerServiceUnitTableViewSyncer() throws Exception { // Make pulsar1 the leader so primaryLoadManager is the syncer-running broker. makePrimaryAsLeader(); @@ -1326,120 +1365,142 @@ public void testLoadBalancerServiceUnitTableViewSyncer() throws Exception { String syncerType = serviceUnitStateTableViewClassName.equals(ServiceUnitStateTableViewImpl.class.getName()) ? "SystemTopicToMetadataStoreSyncer" : "MetadataStoreToSystemTopicSyncer"; + // Shrink the sync-wait budget on both brokers' live syncers BEFORE the first start() + // (driven by monitor() below) so any tableview-size divergence fails in ~30s with the + // syncer's own TimeoutException instead of spinning for the full 300s default — the + // exact hang observed in CI happened inside that first start(). + primaryLoadManager.getServiceUnitStateTableViewSyncer().setSyncWaitTimeInSecs(30); + secondaryLoadManager.getServiceUnitStateTableViewSyncer().setSyncWaitTimeInSecs(30); + pulsar.getAdminClient().brokers() .updateDynamicConfiguration("loadBalancerServiceUnitTableViewSyncer", syncerType); Awaitility.await().untilAsserted(() -> assertTrue(pulsar1.getConfiguration().isLoadBalancerServiceUnitTableViewSyncerEnabled())); - primaryLoadManager.monitor(); - Awaitility.await().atMost(30, TimeUnit.SECONDS) - .untilAsserted(() -> assertTrue(primaryLoadManager.getServiceUnitStateTableViewSyncer() - .isActive())); - assertFalse(secondaryLoadManager.getServiceUnitStateTableViewSyncer().isActive()); - - // === Phase 2: add a 3rd broker using the OTHER table view impl === - // pulsar1/pulsar2 use serviceUnitStateTableViewClassName; pulsar3 deliberately - // uses the other one so the test exercises cross-impl lookups regardless of - // which parametrization we're running. - String otherClassName = - serviceUnitStateTableViewClassName.equals(ServiceUnitStateTableViewImpl.class.getName()) - ? ServiceUnitStateMetadataStoreTableViewImpl.class.getName() - : ServiceUnitStateTableViewImpl.class.getName(); - - ServiceConfiguration crossImplConf = getDefaultConf(); - crossImplConf.setAllowAutoTopicCreation(true); - crossImplConf.setForceDeleteNamespaceAllowed(true); - crossImplConf.setLoadManagerClassName(ExtensibleLoadManagerImpl.class.getCanonicalName()); - crossImplConf.setLoadBalancerLoadSheddingStrategy(TransferShedder.class.getName()); - crossImplConf.setLoadManagerServiceUnitStateTableViewClassName(otherClassName); - - try (var crossImplCtx = createAdditionalPulsarTestContext(crossImplConf)) { - var pulsar3 = crossImplCtx.getPulsarService(); - - // All three brokers (across both impls) must agree on topic ownership. - assertEquals(pulsar2.getAdminClient().lookups().lookupTopic(topic), ownershipBefore); - assertEquals(pulsar3.getAdminClient().lookups().lookupTopic(topic), ownershipBefore); - Optional webUrlPulsar3 = pulsar3.getNamespaceService().getWebServiceUrl(bundle, options); - assertTrue(webUrlPulsar3.isPresent()); - assertEquals(webUrlPulsar3.get().toString(), webUrlBefore.get().toString()); - - // SLA monitor and heartbeat lookups must agree across impls in every direction. - List brokers = List.of(pulsar1, pulsar2, pulsar3); - for (PulsarService viewer : brokers) { - for (PulsarService owner : brokers) { - assertLookupHeartbeatOwner(viewer, owner.getBrokerId(), owner.getBrokerServiceUrl()); - assertLookupSLANamespaceOwner(viewer, owner.getBrokerId(), owner.getBrokerServiceUrl()); + // Drive monitor() inside the await so a transient start() failure (swallowed by + // monitor()'s catch) is retried instead of waiting for the 120s background monitor task. + Awaitility.await().atMost(120, TimeUnit.SECONDS).untilAsserted(() -> { + primaryLoadManager.monitor(); + assertTrue(primaryLoadManager.getServiceUnitStateTableViewSyncer().isActive()); + }); + + try { + assertFalse(secondaryLoadManager.getServiceUnitStateTableViewSyncer().isActive()); + + // === Phase 2: add a 3rd broker using the OTHER table view impl === + // pulsar1/pulsar2 use serviceUnitStateTableViewClassName; pulsar3 deliberately + // uses the other one so the test exercises cross-impl lookups regardless of + // which parametrization we're running. + String otherClassName = + serviceUnitStateTableViewClassName.equals(ServiceUnitStateTableViewImpl.class.getName()) + ? ServiceUnitStateMetadataStoreTableViewImpl.class.getName() + : ServiceUnitStateTableViewImpl.class.getName(); + + ServiceConfiguration crossImplConf = getDefaultConf(); + crossImplConf.setAllowAutoTopicCreation(true); + crossImplConf.setForceDeleteNamespaceAllowed(true); + crossImplConf.setLoadManagerClassName(ExtensibleLoadManagerImpl.class.getCanonicalName()); + crossImplConf.setLoadBalancerLoadSheddingStrategy(TransferShedder.class.getName()); + crossImplConf.setLoadManagerServiceUnitStateTableViewClassName(otherClassName); + + try (var crossImplCtx = createAdditionalPulsarTestContext(crossImplConf)) { + var pulsar3 = crossImplCtx.getPulsarService(); + + // All three brokers (across both impls) must agree on topic ownership. + assertEquals(pulsar2.getAdminClient().lookups().lookupTopic(topic), ownershipBefore); + assertEquals(pulsar3.getAdminClient().lookups().lookupTopic(topic), ownershipBefore); + Optional webUrlPulsar3 = pulsar3.getNamespaceService().getWebServiceUrl(bundle, options); + assertTrue(webUrlPulsar3.isPresent()); + assertEquals(webUrlPulsar3.get().toString(), webUrlBefore.get().toString()); + + // SLA monitor and heartbeat lookups must agree across impls in every direction. + List brokers = List.of(pulsar1, pulsar2, pulsar3); + for (PulsarService viewer : brokers) { + for (PulsarService owner : brokers) { + assertLookupHeartbeatOwner(viewer, owner.getBrokerId(), owner.getBrokerServiceUrl()); + assertLookupSLANamespaceOwner(viewer, owner.getBrokerId(), owner.getBrokerServiceUrl()); + } } - } - // === Phase 3: simulate the cross-impl broker going offline === - // Its SLA namespace must reassign to a remaining broker, and the ownership - // change must propagate through the syncer to brokers using the other impl. - var wrapper3 = (ExtensibleLoadManagerWrapper) pulsar3.getLoadManager().get(); - var loadManager3 = (ExtensibleLoadManagerImpl) - FieldUtils.readField(wrapper3, "loadManager", true); - ServiceUnitStateChannel channel3 = (ServiceUnitStateChannel) - FieldUtils.readField(loadManager3, "serviceUnitStateChannel", true); - channel3.cleanOwnerships(); - // Set state to Closed BEFORE deleting the ZK node to prevent the notification - // handler's session-expiry recovery from auto-re-registering broker3. In - // production the PulsarService shuts down after unregister(), so the handler - // never fires; in tests the service stays running and creates a race. - var registry3 = (BrokerRegistryImpl) loadManager3.getBrokerRegistry(); - registry3.state.set(BrokerRegistryImpl.State.Closed); - pulsar3.getLocalMetadataStore() - .delete("/loadbalance/brokers/" + pulsar3.getBrokerId(), Optional.empty()).get(); - - String slaMonitorTopic = getSLAMonitorNamespace(pulsar3.getBrokerId(), pulsar.getConfiguration()) - .getPersistentTopicName("test"); - String pulsar3BrokerUrl = pulsar3.getBrokerServiceUrl(); - Awaitility.await().atMost(30, TimeUnit.SECONDS).ignoreExceptions().untilAsserted(() -> { - String reassigned = pulsar.getAdminClient().lookups().lookupTopic(slaMonitorTopic); - assertNotNull(reassigned); - assertNotEquals(reassigned, pulsar3BrokerUrl); - }); + // === Phase 3: simulate the cross-impl broker going offline === + // Its SLA namespace must reassign to a remaining broker, and the ownership + // change must propagate through the syncer to brokers using the other impl. + var wrapper3 = (ExtensibleLoadManagerWrapper) pulsar3.getLoadManager().get(); + var loadManager3 = (ExtensibleLoadManagerImpl) + FieldUtils.readField(wrapper3, "loadManager", true); + ServiceUnitStateChannel channel3 = (ServiceUnitStateChannel) + FieldUtils.readField(loadManager3, "serviceUnitStateChannel", true); + channel3.cleanOwnerships(); + // Set state to Closed BEFORE deleting the ZK node to prevent the notification + // handler's session-expiry recovery from auto-re-registering broker3. In + // production the PulsarService shuts down after unregister(), so the handler + // never fires; in tests the service stays running and creates a race. + var registry3 = (BrokerRegistryImpl) loadManager3.getBrokerRegistry(); + registry3.state.set(BrokerRegistryImpl.State.Closed); + pulsar3.getLocalMetadataStore() + .delete("/loadbalance/brokers/" + pulsar3.getBrokerId(), Optional.empty()).get(); + + String slaMonitorTopic = getSLAMonitorNamespace(pulsar3.getBrokerId(), pulsar.getConfiguration()) + .getPersistentTopicName("test"); + String pulsar3BrokerUrl = pulsar3.getBrokerServiceUrl(); + Awaitility.await().atMost(30, TimeUnit.SECONDS).ignoreExceptions().untilAsserted(() -> { + String reassigned = pulsar.getAdminClient().lookups().lookupTopic(slaMonitorTopic); + assertNotNull(reassigned); + assertNotEquals(reassigned, pulsar3BrokerUrl); + }); - // Send a message while the topic is owned by the reassigned broker; this must - // remain durable when ownership migrates back below. - @Cleanup - Producer producer = pulsar.getClient().newProducer(Schema.STRING) - .topic(slaMonitorTopic).create(); - producer.send("offline"); - - // === Phase 4: re-register the broker and verify ownership returns === - registry3.state.set(BrokerRegistryImpl.State.Started); - registry3.registerAsync().get(); - Awaitility.await().atMost(30, TimeUnit.SECONDS).ignoreExceptions().untilAsserted(() -> - assertEquals(pulsar.getAdminClient().lookups().lookupTopic(slaMonitorTopic), - pulsar3.getBrokerServiceUrl())); - - // Same producer reconnects to the new owner; a fresh producer also works. - producer.send("after-reconnect"); - @Cleanup - Producer producer2 = pulsar.getClient().newProducer(Schema.STRING) - .topic(slaMonitorTopic).create(); - producer2.send("from-new-producer"); + // Send a message while the topic is owned by the reassigned broker; this must + // remain durable when ownership migrates back below. + @Cleanup + Producer producer = pulsar.getClient().newProducer(Schema.STRING) + .topic(slaMonitorTopic).create(); + producer.send("offline"); - @Cleanup - Consumer consumer = pulsar.getClient().newConsumer(Schema.STRING) - .topic(slaMonitorTopic) - .subscriptionInitialPosition(SubscriptionInitialPosition.Earliest) - .subscriptionName("test") - .subscribe(); - assertEquals(consumer.receive().getValue(), "offline"); - assertEquals(consumer.receive().getValue(), "after-reconnect"); - assertEquals(consumer.receive().getValue(), "from-new-producer"); - } + // === Phase 4: re-register the broker and verify ownership returns === + registry3.state.set(BrokerRegistryImpl.State.Started); + registry3.registerAsync().get(); + Awaitility.await().atMost(30, TimeUnit.SECONDS).ignoreExceptions().untilAsserted(() -> + assertEquals(pulsar.getAdminClient().lookups().lookupTopic(slaMonitorTopic), + pulsar3.getBrokerServiceUrl())); - // === Phase 5: disable the syncer and verify it deactivates === - pulsar.getAdminClient().brokers() - .deleteDynamicConfiguration("loadBalancerServiceUnitTableViewSyncer"); - Awaitility.await().untilAsserted(() -> - assertFalse(pulsar1.getConfiguration().isLoadBalancerServiceUnitTableViewSyncerEnabled())); - primaryLoadManager.monitor(); - Awaitility.await().atMost(30, TimeUnit.SECONDS) - .untilAsserted(() -> assertFalse(primaryLoadManager.getServiceUnitStateTableViewSyncer() - .isActive())); - assertFalse(secondaryLoadManager.getServiceUnitStateTableViewSyncer().isActive()); + // Same producer reconnects to the new owner; a fresh producer also works. + producer.send("after-reconnect"); + @Cleanup + Producer producer2 = pulsar.getClient().newProducer(Schema.STRING) + .topic(slaMonitorTopic).create(); + producer2.send("from-new-producer"); + + @Cleanup + Consumer consumer = pulsar.getClient().newConsumer(Schema.STRING) + .topic(slaMonitorTopic) + .subscriptionInitialPosition(SubscriptionInitialPosition.Earliest) + .subscriptionName("test") + .subscribe(); + assertEquals(consumer.receive().getValue(), "offline"); + assertEquals(consumer.receive().getValue(), "after-reconnect"); + assertEquals(consumer.receive().getValue(), "from-new-producer"); + } + } finally { + // === Phase 5: disable the syncer and verify it deactivates === + // Guarantee the dynamic config is removed and the syncer is driven inactive even if + // the body threw, so the syncer cannot stay enabled and poison later tests. Note this + // cannot tear down a start() that failed before isActive=true (close() short-circuits + // on !isActive); leftover tail views from such a partial start are recovered by the + // next successful start(), and the next test's initializeState() retry absorbs any + // residual channel disruption. + try { + pulsar.getAdminClient().brokers() + .deleteDynamicConfiguration("loadBalancerServiceUnitTableViewSyncer"); + } catch (Exception e) { + log.warn("Failed to delete syncer dynamic config in cleanup", e); + } + Awaitility.await().atMost(60, TimeUnit.SECONDS).ignoreExceptions().untilAsserted(() -> { + assertFalse(pulsar1.getConfiguration().isLoadBalancerServiceUnitTableViewSyncerEnabled()); + primaryLoadManager.monitor(); + secondaryLoadManager.monitor(); + assertFalse(primaryLoadManager.getServiceUnitStateTableViewSyncer().isActive()); + assertFalse(secondaryLoadManager.getServiceUnitStateTableViewSyncer().isActive()); + }); + } } private void assertLookupHeartbeatOwner(PulsarService pulsar, @@ -1506,7 +1567,53 @@ private void makeSecondaryAsLeader() throws Exception { }); } - @Test(timeOut = 30 * 1000, priority = 2100) + // After a test churns leader election, the channel-topic bundle can be transiently + // unowned and the channel producer can be in reconnect backoff. The next @BeforeMethod + // (initializeState -> namespaces().unload(...)) publishes a state change on the channel + // topic; if it runs in that window the producer send times out (HTTP 500). Give the + // re-election a best-effort chance to settle before yielding to the next test. + // + // This is a best-effort smoothing wait, not an assertion: the budget is deliberately a + // fraction (20s) of the callers' 60s method timeout so it cannot consume the whole slot + // and trip TestNG's ThreadTimeoutException mid-poll, and any failure to settle + // is swallowed-and-logged rather than thrown. That matters because callers invoke this from + // a finally block — a settling delay here must never replace (mask) the body's exception. + // The next test's initializeState() is the real backstop: it retries the unload and, if the + // channel stays wedged, forces a clean channel owner via leader re-election before retrying. + private void awaitChannelOwnerStable() { + try { + Awaitility.await().atMost(20, TimeUnit.SECONDS).ignoreExceptions().untilAsserted(() -> { + // monitor() reconciles each broker's role with the channel ownership and + // re-serves the channel-topic bundle if the leadership churn left it unserved + // ("not served by this instance") — the same self-healing the 120s background + // monitor task provides, driven eagerly so the next test does not start inside + // the broken window. + primaryLoadManager.monitor(); + secondaryLoadManager.monitor(); + Optional owner1 = channel1.getChannelOwnerAsync().get(5, TimeUnit.SECONDS); + Optional owner2 = channel2.getChannelOwnerAsync().get(5, TimeUnit.SECONDS); + assertTrue(owner1.isPresent()); + assertEquals(owner1, owner2); + assertTrue(channel1.isChannelOwner() ^ channel2.isChannelOwner()); + // Probe that the channel topic is actually served: the lookup re-assigns the + // pulsar/system bundle if it is unowned, and getStats proves the owner loads + // the topic (the lookup layer alone can claim an owner that refuses to serve). + String channelTopic = ServiceUnitStateTableViewImpl.TOPIC; + assertNotNull(pulsar.getAdminClient().lookups().lookupTopic(channelTopic)); + if (serviceUnitStateTableViewClassName.equals( + ServiceUnitStateTableViewImpl.class.getName())) { + assertNotNull(pulsar.getAdminClient().topics().getStats(channelTopic)); + } + }); + } catch (Throwable t) { + log.warn("Channel owner did not stabilize within the best-effort window; " + + "relying on the next test's initializeState() retry", t); + } + } + + // 60s: the body's repeated role transitions plus the trailing awaitChannelOwnerStable() can + // exceed 30s under load. + @Test(timeOut = 60 * 1000, priority = 2100) public void testRoleChangeIdempotency() throws Exception { makePrimaryAsLeader(); @@ -1586,7 +1693,8 @@ public void testRoleChangeIdempotency() throws Exception { assertEquals(ExtensibleLoadManagerImpl.Role.Follower, secondaryLoadManager.getRole()); - + // Confirm a stable channel owner before yielding to the next test's @BeforeMethod. + awaitChannelOwnerStable(); } @DataProvider(name = "noChannelOwnerMonitorHandler") @@ -1594,7 +1702,9 @@ public Object[][] noChannelOwnerMonitorHandler() { return new Object[][] { { true }, { false } }; } - @Test(dataProvider = "noChannelOwnerMonitorHandler", timeOut = 30 * 1000, priority = 2101) + // 60s: the body's leader-election churn plus the trailing awaitChannelOwnerStable() can + // exceed 30s under load. + @Test(dataProvider = "noChannelOwnerMonitorHandler", timeOut = 60 * 1000, priority = 2101) public void testHandleNoChannelOwner(boolean noChannelOwnerMonitorHandler) throws Exception { makePrimaryAsLeader(); @@ -1661,10 +1771,25 @@ public void testHandleNoChannelOwner(boolean noChannelOwnerMonitorHandler) throw // clean up for monitor test pulsar1.getLeaderElectionService().start(); pulsar2.getLeaderElectionService().start(); + // If the body failed mid-churn, both restarted elections can keep flapping the + // leadership between the brokers, leaving the channel-topic bundle unserved + // beyond what the next test's initializeState() retry can absorb. Force a + // deterministic single leader before stabilizing (best-effort: must not mask + // the body's exception). + try { + makePrimaryAsLeader(); + } catch (Throwable t) { + log.warn("Failed to re-establish primary as leader in cleanup", t); + } + // Re-establish a stable channel owner before yielding to the next test's + // @BeforeMethod, which publishes to the channel topic via namespace unload. + awaitChannelOwnerStable(); } } - @Test(timeOut = 30 * 1000, priority = 2000) + // 60s: the body's role transitions plus the trailing awaitChannelOwnerStable() can exceed + // 30s under load (observed locally at 30.017s). + @Test(timeOut = 60 * 1000, priority = 2000) public void testRoleChange() throws Exception { makePrimaryAsLeader(); @@ -1684,6 +1809,11 @@ public void testRoleChange() throws Exception { new NamespaceBundleStats())); Awaitility.await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> { + // The internal topics live in the pulsar/system bundle; if the leadership churn + // left it unserved, only a monitor() role reconciliation re-serves it (the + // background monitor task would take up to 120s) — drive it while waiting. + leader.monitor(); + follower.monitor(); assertNotNull(FieldUtils.readDeclaredField(leader.getTopBundlesLoadDataStore(), "tableView", true)); @@ -1721,6 +1851,9 @@ public void testRoleChange() throws Exception { topBundlesExpected.getTopBundlesLoadData().get(0).stats().msgRateIn = 1; Awaitility.await().atMost(30, TimeUnit.SECONDS).ignoreExceptions().untilAsserted(() -> { + // Same monitor()-driven healing as above for the post-transfer assertions. + leader2.monitor(); + follower2.monitor(); assertNotNull(FieldUtils.readDeclaredField(leader2.getTopBundlesLoadDataStore(), "tableView", true)); assertNull(FieldUtils.readDeclaredField(follower2.getTopBundlesLoadDataStore(), "tableView", true)); @@ -1745,6 +1878,9 @@ public void testRoleChange() throws Exception { follower2.getBrokerLoadDataStore().pushAsync(key, brokerLoadExpected).get(3, TimeUnit.SECONDS); follower2.getTopBundlesLoadDataStore().pushAsync(bundle, topBundlesExpected) .get(3, TimeUnit.SECONDS); + + // Confirm a stable channel owner before yielding to the next test's @BeforeMethod. + awaitChannelOwnerStable(); } @Test(priority = Integer.MIN_VALUE) diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/extensions/channel/ServiceUnitStateChannelTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/extensions/channel/ServiceUnitStateChannelTest.java index 6381a2851a271..e5be773d7fda9 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/extensions/channel/ServiceUnitStateChannelTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/extensions/channel/ServiceUnitStateChannelTest.java @@ -95,6 +95,7 @@ import org.apache.pulsar.client.api.TableView; import org.apache.pulsar.common.naming.NamespaceBundle; import org.apache.pulsar.common.naming.TopicName; +import org.apache.pulsar.common.naming.TopicVersion; import org.apache.pulsar.common.policies.data.TopicType; import org.apache.pulsar.common.stats.Metrics; import org.apache.pulsar.common.util.FutureUtil; @@ -280,6 +281,104 @@ public void channelOwnerTest() throws Exception { } } + @Test(priority = 1) + public void testCompletedGetOwnerRequestDoesNotRemoveNewRequest() { + ServiceUnitStateChannelImpl channel = (ServiceUnitStateChannelImpl) channel1; + String serviceUnit = namespaceName + "/0x10000000_0x10000001"; + var getOwnerRequests = channel.getOwnerRequests(); + getOwnerRequests.remove(serviceUnit); + CompletableFuture oldRequest = channel.dedupeGetOwnerRequest(serviceUnit); + assertEquals(getOwnerRequests.get(serviceUnit), oldRequest); + + CompletableFuture newRequest = null; + try { + // State-event handlers remove the current request before completing it, allowing a later lookup + // to install a new request generation before the old request's completion cleanup runs. + assertTrue(getOwnerRequests.remove(serviceUnit, oldRequest)); + newRequest = channel.dedupeGetOwnerRequest(serviceUnit); + assertTrue(newRequest != oldRequest); + assertTrue(getOwnerRequests.get(serviceUnit) == newRequest); + + // The previous unconditional removal would remove newRequest here. + assertTrue(oldRequest.complete(brokerId1)); + + assertTrue(getOwnerRequests.get(serviceUnit) == newRequest, + "A stale get-owner cleanup must not remove a newer request future"); + + assertTrue(newRequest.complete(brokerId2)); + assertFalse(getOwnerRequests.containsKey(serviceUnit), + "The newer request must remove itself after completion"); + } finally { + getOwnerRequests.remove(serviceUnit); + oldRequest.cancel(false); + if (newRequest != null) { + newRequest.cancel(false); + } + } + } + + @Test(priority = 1) + public void testSkippedEventDoesNotRemoveNewGetOwnerRequest() throws Exception { + ServiceUnitStateChannelImpl channel = (ServiceUnitStateChannelImpl) channel1; + String serviceUnit = namespaceName + "/0x10000002_0x10000003"; + var getOwnerRequests = channel.getOwnerRequests(); + CompletableFuture oldRequest = new CompletableFuture<>(); + CompletableFuture newRequest = new CompletableFuture<>(); + try { + overrideTableView(channel, serviceUnit, new ServiceUnitStateData(Owned, brokerId1, 1)); + getOwnerRequests.put(serviceUnit, oldRequest); + oldRequest.whenComplete((__, ___) -> getOwnerRequests.put(serviceUnit, newRequest)); + + channel.handleSkippedEvent(serviceUnit); + + assertEquals(oldRequest.getNow(null), brokerId1); + assertTrue(getOwnerRequests.get(serviceUnit) == newRequest, + "A stale skipped-event cleanup must not remove a newer request future"); + } finally { + getOwnerRequests.remove(serviceUnit); + oldRequest.cancel(false); + overrideTableView(channel, serviceUnit, null); + } + } + + @Test(priority = 1) + public void testCompletedCleanupJobDoesNotRemoveNewCleanupJob() { + ServiceUnitStateChannelImpl channel = (ServiceUnitStateChannelImpl) channel1; + String broker = brokerId3; + var cleanupJobs = channel.getCleanupJobs(); + cleanupJobs.remove(broker); + channel.scheduleCleanup(broker, 60L); + CompletableFuture oldJob = cleanupJobs.get(broker); + assertNotNull(oldJob); + + CompletableFuture newJob = null; + try { + // Broker-creation handling removes a cleanup job before cancelling it. A later broker-deletion + // event can therefore schedule a new job before the old job's completion cleanup runs. + assertTrue(cleanupJobs.remove(broker, oldJob)); + channel.scheduleCleanup(broker, 60L); + newJob = cleanupJobs.get(broker); + assertNotNull(newJob); + assertTrue(newJob != oldJob); + + // The previous unconditional removal would remove newJob here. + assertTrue(oldJob.cancel(false)); + + assertTrue(cleanupJobs.get(broker) == newJob, + "A stale cleanup job completion must not remove a newer cleanup job future"); + + assertTrue(newJob.cancel(false)); + assertFalse(cleanupJobs.containsKey(broker), + "The newer cleanup job must remove itself after completion"); + } finally { + cleanupJobs.remove(broker); + oldJob.cancel(false); + if (newJob != null) { + newJob.cancel(false); + } + } + } + @Test(priority = 100) public void channelValidationTest() throws ExecutionException, InterruptedException, IllegalAccessException, PulsarServerException, @@ -822,6 +921,59 @@ public void handleBrokerCreationEventTest() throws IllegalAccessException { } + @Test(priority = 8) + public void handleBrokerCreationEventDoesNotCancelNewCleanupJobTest() { + ServiceUnitStateChannelImpl channel = (ServiceUnitStateChannelImpl) channel1; + var cleanupJobs = channel.getCleanupJobs(); + String broker = brokerId2; + CompletableFuture healthCheck = new CompletableFuture<>(); + cleanupJobs.remove(broker); + channel.scheduleCleanup(broker, 60L); + CompletableFuture oldJob = cleanupJobs.get(broker); + assertNotNull(oldJob); + + reset(brokers); + doReturn(healthCheck).when(brokers).healthcheckAsync(eq(TopicVersion.V2), any()); + doReturn(brokers).when(pulsarAdmin).brokers(); + CompletableFuture newJob = null; + try { + channel.handleBrokerRegistrationEvent(broker, NotificationType.Created); + verify(brokers, times(1)).healthcheckAsync(eq(TopicVersion.V2), any()); + + // The old cleanup can finish while the asynchronous health check is still pending. A later + // broker-deletion event can then schedule a new cleanup job for the same broker. + assertTrue(oldJob.complete(null)); + assertFalse(cleanupJobs.containsKey(broker)); + channel.scheduleCleanup(broker, 60L); + newJob = cleanupJobs.get(broker); + assertNotNull(newJob); + assertTrue(newJob != oldJob); + + healthCheck.complete(null); + + CompletableFuture expectedNewJob = newJob; + Awaitility.await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + assertTrue(cleanupJobs.get(broker) == expectedNewJob, + "A stale broker-creation callback must not remove a newer cleanup job"); + assertFalse(expectedNewJob.isCancelled()); + }); + + assertTrue(newJob.cancel(false)); + assertFalse(cleanupJobs.containsKey(broker), + "The newer cleanup job must remove itself after cancellation"); + } finally { + cleanupJobs.remove(broker); + oldJob.cancel(false); + if (newJob != null) { + newJob.cancel(false); + } + reset(brokers); + doReturn(CompletableFuture.failedFuture(new RuntimeException("failed"))).when(brokers) + .healthcheckAsync(eq(TopicVersion.V2), any()); + reset(pulsarAdmin); + } + } + @Test(priority = 9) public void handleBrokerDeletionEventTest() throws Exception { @@ -2075,9 +2227,9 @@ public void testHandleExistingResolvesAssigningStateOnChannelRestart() } } - private static ConcurrentHashMap>> getOwnerRequests( + private static ConcurrentHashMap> getOwnerRequests( ServiceUnitStateChannel channel) throws IllegalAccessException { - return (ConcurrentHashMap>>) + return (ConcurrentHashMap>) FieldUtils.readDeclaredField(channel, "getOwnerRequests", true); } diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/extensions/models/TopKBundlesTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/extensions/models/TopKBundlesTest.java index 0f6ae9b2629c4..2be3108c63832 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/extensions/models/TopKBundlesTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/extensions/models/TopKBundlesTest.java @@ -31,6 +31,7 @@ import java.util.Map; import java.util.Optional; import java.util.Random; +import java.util.Set; import org.apache.pulsar.broker.PulsarService; import org.apache.pulsar.broker.ServiceConfiguration; import org.apache.pulsar.broker.loadbalance.impl.LoadManagerShared; @@ -136,6 +137,27 @@ public void testSystemNamespace() { assertEquals(top0.bundleName(), bundle1); } + @Test + public void testSheddingExcludedNamespaces() { + Map bundleStats = new HashMap<>(); + var topKBundles = new TopKBundles(pulsar); + pulsar.getConfiguration().setLoadBalancerSheddingExcludedNamespaces(Set.of("my-tenant/my-namespace2")); + NamespaceBundleStats stats1 = new NamespaceBundleStats(); + stats1.msgRateIn = 500; + bundleStats.put("my-tenant/my-namespace2/0x00000000_0x0FFFFFFF", stats1); + + NamespaceBundleStats stats2 = new NamespaceBundleStats(); + stats2.msgRateIn = 10000; + stats2.msgThroughputOut = 10; + bundleStats.put(bundle1, stats2); + + topKBundles.update(bundleStats, 2); + + assertEquals(topKBundles.getLoadData().getTopBundlesLoadData().size(), 1); + var top0 = topKBundles.getLoadData().getTopBundlesLoadData().get(0); + assertEquals(top0.bundleName(), bundle1); + } + @Test public void testZeroMsgThroughputBundleStats() { Map bundleStats = new HashMap<>(); diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/extensions/scheduler/TransferShedderTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/extensions/scheduler/TransferShedderTest.java index 716e1f2a2a280..a144d84fd7a59 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/extensions/scheduler/TransferShedderTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/extensions/scheduler/TransferShedderTest.java @@ -621,6 +621,25 @@ public void testRecentlyUnloadedBundles() { assertEquals(counter.getLoadStd(), setupLoadStd, delta); } + @Test + public void testSheddingExcludedNamespaces() { + UnloadCounter counter = new UnloadCounter(); + TransferShedder transferShedder = new TransferShedder(counter); + var ctx = setupContext(); + ctx.brokerConfiguration().setLoadBalancerSheddingExcludedNamespaces( + Set.of("my-tenant/my-namespaceE", "my-tenant/my-namespaceD")); + + var res = transferShedder.findBundlesForUnloading(ctx, new HashMap<>(), Map.of()); + var expected = new HashSet(); + expected.add(new UnloadDecision(new Unload("broker3:8080", + "my-tenant/my-namespaceC/0x00000000_0x0FFFFFFF", + Optional.of("broker1:8080")), + Success, Overloaded)); + assertEquals(res, expected); + assertEquals(counter.getLoadAvg(), setupLoadAvg); + assertEquals(counter.getLoadStd(), setupLoadStd); + } + @Test public void testGetAvailableBrokersFailed() { UnloadCounter counter = new UnloadCounter(); diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/extensions/store/LoadDataStoreTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/extensions/store/LoadDataStoreTest.java index fe03446e58b7d..d456369b1e768 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/extensions/store/LoadDataStoreTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/extensions/store/LoadDataStoreTest.java @@ -18,9 +18,12 @@ */ package org.apache.pulsar.broker.loadbalance.extensions.store; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; import static org.testng.Assert.assertThrows; @@ -29,12 +32,15 @@ import java.util.HashMap; import java.util.Map; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; import lombok.AllArgsConstructor; import lombok.Cleanup; import lombok.Data; import lombok.NoArgsConstructor; import org.apache.commons.lang3.reflect.FieldUtils; import org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest; +import org.apache.pulsar.client.api.TableView; import org.apache.pulsar.common.naming.NamespaceName; import org.apache.pulsar.common.naming.TopicDomain; import org.apache.pulsar.common.policies.data.TenantInfoImpl; @@ -187,6 +193,53 @@ public void testProducerStop() throws Exception { loadDataStore.removeAsync("2").get(); } + @Test(timeOut = 30_000) + @SuppressWarnings("unchecked") + public void testShutdownDoesNotDeadlockWithConcurrentStoreAccess() throws Exception { + String topic = TopicDomain.persistent + "://" + NamespaceName.SYSTEM_NAMESPACE + "/" + UUID.randomUUID(); + var loadDataStore = + (TableViewLoadDataStoreImpl) LoadDataStoreFactory.create(pulsar, topic, Integer.class); + loadDataStore.start(); + loadDataStore.closeTableView(); + + // Replace the table view with a stub whose close future completes only after another thread has + // called a synchronized method of the store. This mirrors the production interleaving where the + // reader close future completes on the client's internal executor thread while a channel + // StateChangeListener on that same thread is blocked in removeAsync() waiting for the store + // monitor held by the closing thread. + CompletableFuture closeSignal = new CompletableFuture<>(); + CountDownLatch closeStarted = new CountDownLatch(1); + TableView stubTableView = mock(TableView.class); + when(stubTableView.closeAsync()).thenAnswer(invocation -> { + closeStarted.countDown(); + return closeSignal; + }); + doAnswer(invocation -> { + closeStarted.countDown(); + closeSignal.get(); + return null; + }).when(stubTableView).close(); + loadDataStore.setTableView(stubTableView); + + Thread concurrentAccess = new Thread(() -> { + try { + closeStarted.await(); + loadDataStore.removeAsync("key"); + closeSignal.complete(null); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + concurrentAccess.start(); + try { + loadDataStore.shutdown(); + } finally { + concurrentAccess.join(5000); + } + assertFalse(concurrentAccess.isAlive()); + assertTrue(closeSignal.isDone()); + } + @Test public void testShutdown() throws Exception { String topic = TopicDomain.persistent + "://" + NamespaceName.SYSTEM_NAMESPACE + "/" + UUID.randomUUID(); diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/impl/ModularLoadManagerImplTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/impl/ModularLoadManagerImplTest.java index f6061204b62ab..60486c96a1845 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/impl/ModularLoadManagerImplTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/loadbalance/impl/ModularLoadManagerImplTest.java @@ -1026,7 +1026,7 @@ public void testBundleDataDefaultValue(boolean isV1) throws Exception { // get the bundleData of the first bundle range. // The default value of the bundleData be the same as resourceQuota because the resourceQuota is present. - BundleData defaultBundleData = lm.getBundleDataOrDefault(namespaceBundle.toString()); + BundleData defaultBundleData = lm.getBundleDataOrDefaultAsync(namespaceBundle.toString()).join(); TimeAverageMessageData shortTermData = defaultBundleData.getShortTermData(); TimeAverageMessageData longTermData = defaultBundleData.getLongTermData(); diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/namespace/NamespaceServiceLookupOptionsTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/namespace/NamespaceServiceLookupOptionsTest.java new file mode 100644 index 0000000000000..13fe6a4d20618 --- /dev/null +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/namespace/NamespaceServiceLookupOptionsTest.java @@ -0,0 +1,163 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.broker.namespace; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import java.net.URI; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import org.apache.commons.lang3.reflect.FieldUtils; +import org.apache.pulsar.broker.loadbalance.extensions.manager.RedirectManager; +import org.apache.pulsar.broker.lookup.LookupResult; +import org.apache.pulsar.broker.service.BrokerTestBase; +import org.apache.pulsar.common.naming.NamespaceBundle; +import org.apache.pulsar.common.naming.TopicName; +import org.apache.pulsar.policies.data.loadbalancer.AdvertisedListener; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +@Test(groups = "broker") +public class NamespaceServiceLookupOptionsTest extends BrokerTestBase { + + @BeforeMethod + @Override + protected void setup() throws Exception { + super.baseSetup(); + } + + @AfterMethod(alwaysRun = true) + @Override + protected void cleanup() throws Exception { + super.internalCleanup(); + } + + @DataProvider(name = "differentLookupOptions") + public Object[][] differentLookupOptions() { + return new Object[][] { + { + LookupOptions.builder().advertisedListenerName("listener-a").build(), + LookupOptions.builder().advertisedListenerName("listener-b").build() + }, + { + LookupOptions.builder().readOnly(true).build(), + LookupOptions.builder().readOnly(false).build() + }, + { + LookupOptions.builder().loadTopicsInBundle(true).build(), + LookupOptions.builder().loadTopicsInBundle(false).build() + } + }; + } + + @Test(dataProvider = "differentLookupOptions") + public void testLookupRequestsWithDifferentOptionsAreNotCoalesced( + LookupOptions firstOptions, LookupOptions secondOptions) throws Exception { + TopicName topic = TopicName.get("persistent://public/default/lookup-options-" + UUID.randomUUID()); + NamespaceBundle bundle = pulsar.getNamespaceService().getBundle(topic); + NamespaceService namespaceService = pulsar.getNamespaceService(); + OwnershipCache ownershipCache = mock(OwnershipCache.class); + CompletableFuture> ownerFuture = new CompletableFuture<>(); + doReturn(CompletableFuture.completedFuture(bundle)).when(namespaceService).getBundleAsync(any()); + doReturn(ownershipCache).when(namespaceService).getOwnershipCache(); + when(ownershipCache.getOwnerAsync(bundle)).thenReturn(ownerFuture); + stubRedirectManager(namespaceService); + + CompletableFuture> firstLookup = + namespaceService.getBrokerServiceUrlAsync(topic, firstOptions); + CompletableFuture> secondLookup = + namespaceService.getBrokerServiceUrlAsync(topic, secondOptions); + + verify(ownershipCache, times(2)).getOwnerAsync(bundle); + ownerFuture.complete(Optional.of(lookupOwnerWithAdvertisedListeners())); + + assertLookupResultUsesOptions(firstLookup.get(5, TimeUnit.SECONDS).orElseThrow(), firstOptions); + assertLookupResultUsesOptions(secondLookup.get(5, TimeUnit.SECONDS).orElseThrow(), secondOptions); + } + + @Test + public void testEquivalentLookupRequestsAreCoalesced() throws Exception { + TopicName topic = TopicName.get("persistent://public/default/lookup-options-" + UUID.randomUUID()); + NamespaceBundle bundle = pulsar.getNamespaceService().getBundle(topic); + NamespaceService namespaceService = pulsar.getNamespaceService(); + OwnershipCache ownershipCache = mock(OwnershipCache.class); + CompletableFuture> ownerFuture = new CompletableFuture<>(); + LookupOptions options = LookupOptions.builder().advertisedListenerName("listener-a").build(); + doReturn(CompletableFuture.completedFuture(bundle)).when(namespaceService).getBundleAsync(any()); + doReturn(ownershipCache).when(namespaceService).getOwnershipCache(); + when(ownershipCache.getOwnerAsync(bundle)).thenReturn(ownerFuture); + stubRedirectManager(namespaceService); + + CompletableFuture> firstLookup = + namespaceService.getBrokerServiceUrlAsync(topic, options); + CompletableFuture> secondLookup = namespaceService.getBrokerServiceUrlAsync(topic, + LookupOptions.builder().advertisedListenerName("listener-a").build()); + + verify(ownershipCache).getOwnerAsync(bundle); + ownerFuture.complete(Optional.of(lookupOwnerWithAdvertisedListeners())); + + assertLookupResultUsesOptions(firstLookup.get(5, TimeUnit.SECONDS).orElseThrow(), options); + assertLookupResultUsesOptions(secondLookup.get(5, TimeUnit.SECONDS).orElseThrow(), options); + } + + /** + * Replaces the redirect manager with one that answers immediately, so that the lookup reaches the + * request coalescing logic on the calling thread. Otherwise the redirect check performs a metadata + * store read and the lookups would be handled asynchronously. + */ + private static void stubRedirectManager(NamespaceService namespaceService) throws Exception { + RedirectManager redirectManager = mock(RedirectManager.class); + when(redirectManager.findRedirectLookupResultAsync()) + .thenReturn(CompletableFuture.completedFuture(Optional.empty())); + FieldUtils.writeDeclaredField(namespaceService, "redirectManager", redirectManager, true); + } + + private static NamespaceEphemeralData lookupOwnerWithAdvertisedListeners() { + Map advertisedListeners = Map.of( + "listener-a", AdvertisedListener.builder() + .brokerServiceUrl(URI.create("pulsar://listener-a:6650")) + .brokerHttpUrl(URI.create("http://listener-a:8080")) + .build(), + "listener-b", AdvertisedListener.builder() + .brokerServiceUrl(URI.create("pulsar://listener-b:6650")) + .brokerHttpUrl(URI.create("http://listener-b:8080")) + .build()); + return new NamespaceEphemeralData("pulsar://broker-1:6650", null, + "http://broker-1:8080", null, false, advertisedListeners); + } + + private static void assertLookupResultUsesOptions(LookupResult lookupResult, LookupOptions options) { + if (options.hasAdvertisedListenerName()) { + assertEquals(lookupResult.getLookupData().getBrokerUrl(), + "pulsar://" + options.getAdvertisedListenerName() + ":6650"); + } else { + assertEquals(lookupResult.getLookupData().getBrokerUrl(), "pulsar://broker-1:6650"); + } + } +} diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/namespace/NamespaceServiceTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/namespace/NamespaceServiceTest.java index 3483ce3809967..32259285b1153 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/namespace/NamespaceServiceTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/namespace/NamespaceServiceTest.java @@ -690,7 +690,7 @@ public void testSplitBundleWithHighestThroughput() throws Exception { LoadManager loadManager = pulsar.getLoadManager().get(); Awaitility.await().untilAsserted(() -> { BundleData targetBundleData = ((ModularLoadManagerWrapper) loadManager).getLoadManager() - .getBundleDataOrDefault(namespace + "/" + bundle); + .getBundleDataOrDefaultAsync(namespace + "/" + bundle).join(); assertEquals(targetBundleData.getTopics(), 10); }); diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/BacklogQuotaManagerTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/BacklogQuotaManagerTest.java index ccecf37486d3b..1dda4a4133ccc 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/BacklogQuotaManagerTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/BacklogQuotaManagerTest.java @@ -41,6 +41,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.concurrent.CountDownLatch; @@ -78,6 +79,7 @@ import org.apache.pulsar.common.policies.data.BacklogQuota; import org.apache.pulsar.common.policies.data.ClusterData; import org.apache.pulsar.common.policies.data.PersistentTopicInternalStats; +import org.apache.pulsar.common.policies.data.SubscriptionStats; import org.apache.pulsar.common.policies.data.TenantInfoImpl; import org.apache.pulsar.common.policies.data.TopicStats; import org.apache.pulsar.common.policies.data.TopicType; @@ -202,14 +204,28 @@ void shutdown() throws Exception { } @BeforeMethod(alwaysRun = true) - void createNamespaces() throws PulsarAdminException { + void createNamespaces() throws Exception { config.setPreciseTimeBasedBacklogQuotaCheck(false); - admin.namespaces().createNamespace("prop/ns-quota"); - admin.namespaces().setNamespaceReplicationClusters("prop/ns-quota", Sets.newHashSet("usc")); - admin.namespaces().createNamespace("prop/quotahold"); - admin.namespaces().setNamespaceReplicationClusters("prop/quotahold", Sets.newHashSet("usc")); - admin.namespaces().createNamespace("prop/quotaholdasync"); - admin.namespaces().setNamespaceReplicationClusters("prop/quotaholdasync", Sets.newHashSet("usc")); + config.setExposeSubscriptionBacklogAgeInPrometheus(false); + createNamespaceForTest("prop/ns-quota"); + createNamespaceForTest("prop/quotahold"); + createNamespaceForTest("prop/quotaholdasync"); + } + + /** + * If a previous test's @AfterMethod timed out before the namespace was fully removed, the + * leftover would otherwise cascade as HTTP 409 here and fail every subsequent test. + * Force-delete and retry so each test starts with clean namespace state. + */ + private void createNamespaceForTest(String ns) throws Exception { + try { + admin.namespaces().createNamespace(ns); + } catch (PulsarAdminException.ConflictException e) { + log.warn("Namespace {} already exists from previous test, force-deleting and recreating", ns); + deleteNamespaceWithRetry(ns, true); + admin.namespaces().createNamespace(ns); + } + admin.namespaces().setNamespaceReplicationClusters(ns, Sets.newHashSet("usc")); } @AfterMethod(alwaysRun = true) @@ -314,6 +330,13 @@ private TopicStats getTopicStats(String topic1, boolean getPreciseBacklog) throw return stats; } + private void refreshSubscriptionBacklogAge(String topic) throws Exception { + PersistentTopic topicRef = (PersistentTopic) pulsar.getBrokerService().getTopicReference(topic).get(); + topicRef.updateSubscriptionOldPositionInfo().get(5, SECONDS); + } + + @SuppressWarnings("deprecation") + @Test public void testTriggerBacklogQuotaSizeWithReader() throws Exception { assertEquals(admin.namespaces().getBacklogQuotaMap("prop/ns-quota"), @@ -385,8 +408,9 @@ public void testTriggerBacklogQuotaSizeWithReader() throws Exception { } @Test - public void backlogsStatsPrecise() throws PulsarAdminException, PulsarClientException, InterruptedException { + public void backlogsStatsPrecise() throws Exception { config.setPreciseTimeBasedBacklogQuotaCheck(true); + config.setExposeSubscriptionBacklogAgeInPrometheus(true); final String namespace = "prop/ns-quota"; assertEquals(admin.namespaces().getBacklogQuotaMap(namespace), new HashMap<>()); final int sizeLimitBytes = 15 * 1024 * 1024; @@ -443,6 +467,7 @@ public void backlogsStatsPrecise() throws PulsarAdminException, PulsarClientExce c1MarkDeletePositionBefore = waitForMarkDeletePositionToChange(topic1, subName1, c1MarkDeletePositionBefore); waitForQuotaCheckToRunTwice(); + refreshSubscriptionBacklogAge(topic1); Metrics metrics = prometheusMetricsClient.getMetrics(); TopicStats topicStats = getTopicStats(topic1); @@ -454,6 +479,11 @@ public void backlogsStatsPrecise() throws PulsarAdminException, PulsarClientExce assertThat(topicStats.getOldestBacklogMessageAgeSeconds()) .isCloseTo(expectedMessageAgeSeconds, within(1L)); assertThat(topicStats.getOldestBacklogMessageSubscriptionName()).isEqualTo(subName2); + assertThat(topicStats.getSubscriptions().get(subName2).getOldestBacklogMessageAgeSeconds()) + .isCloseTo(expectedMessageAgeSeconds, within(1L)); + assertThat(topicStats.getSubscriptions().get(subName1).getOldestBacklogMessageAgeSeconds()) + .isGreaterThanOrEqualTo(0L) + .isLessThan(expectedMessageAgeSeconds); Metric backlogAgeMetric = metrics.findSingleMetricByNameAndLabels("pulsar_storage_backlog_age_seconds", @@ -463,6 +493,15 @@ public void backlogsStatsPrecise() throws PulsarAdminException, PulsarClientExce entry("namespace", namespace), entry("topic", topic1)); assertThat((long) backlogAgeMetric.value).isCloseTo(expectedMessageAgeSeconds, within(2L)); + Metric subBacklogAgeMetric = + metrics.findSingleMetricByNameAndLabels("pulsar_subscription_storage_backlog_age_seconds", + Pair.of("topic", topic1), Pair.of("subscription", subName2)); + assertThat(subBacklogAgeMetric.tags).containsExactly( + entry("cluster", CLUSTER_NAME), + entry("namespace", namespace), + entry("subscription", subName2), + entry("topic", topic1)); + assertThat((long) subBacklogAgeMetric.value).isCloseTo(expectedMessageAgeSeconds, within(2L)); // Move subscription 2 away from being the oldest mark delete // S2/S1 @@ -482,6 +521,7 @@ public void backlogsStatsPrecise() throws PulsarAdminException, PulsarClientExce c1MarkDeletePositionBefore = waitForMarkDeletePositionToChange(topic1, subName1, c1MarkDeletePositionBefore); waitForQuotaCheckToRunTwice(); + refreshSubscriptionBacklogAge(topic1); metrics = prometheusMetricsClient.getMetrics(); long actualAge = (long) metrics.findByNameAndLabels( @@ -508,6 +548,7 @@ public void backlogsStatsPrecise() throws PulsarAdminException, PulsarClientExce waitForMarkDeletePositionToChange(topic1, subName1, c1MarkDeletePositionBefore); waitForQuotaCheckToRunTwice(); + refreshSubscriptionBacklogAge(topic1); // Cache shouldn't be used, since position has changed long readEntries = getReadEntries(topic1); @@ -518,8 +559,11 @@ public void backlogsStatsPrecise() throws PulsarAdminException, PulsarClientExce MILLISECONDS.toSeconds(System.currentTimeMillis() - secondOldestMessage.getPublishTime()); assertThat(topicStats.getOldestBacklogMessageAgeSeconds()).isCloseTo(expectedMessageAgeSeconds, within(2L)); assertThat(topicStats.getOldestBacklogMessageSubscriptionName()).isEqualTo(subName2); + assertThat(topicStats.getSubscriptions().get(subName2).getOldestBacklogMessageAgeSeconds()) + .isCloseTo(expectedMessageAgeSeconds, within(2L)); waitForQuotaCheckToRunTwice(); + refreshSubscriptionBacklogAge(topic1); // Cache should be used, since position hasn't changed assertThat(getReadEntries(topic1)).isEqualTo(readEntries); @@ -537,11 +581,14 @@ public void backlogsStatsPrecise() throws PulsarAdminException, PulsarClientExce log.info("Subscription 1 and 2 moved to end. Now should not backlog"); waitForMarkDeletePositionToChange(topic1, subName1, c1MarkDeletePositionBefore); waitForQuotaCheckToRunTwice(); + refreshSubscriptionBacklogAge(topic1); topicStats = getTopicStats(topic1); assertThat(topicStats.getBacklogSize()).isEqualTo(0); assertThat(topicStats.getSubscriptions().get(subName1).getMsgBacklog()).isEqualTo(0); assertThat(topicStats.getSubscriptions().get(subName2).getMsgBacklog()).isEqualTo(0); + assertThat(topicStats.getSubscriptions().get(subName1).getOldestBacklogMessageAgeSeconds()).isEqualTo(-1); + assertThat(topicStats.getSubscriptions().get(subName2).getOldestBacklogMessageAgeSeconds()).isEqualTo(-1); assertThat(topicStats.getOldestBacklogMessageAgeSeconds()).isEqualTo(-1); assertThat(topicStats.getOldestBacklogMessageSubscriptionName()).isNull(); @@ -561,6 +608,117 @@ public void backlogsStatsPrecise() throws PulsarAdminException, PulsarClientExce } } + @Test + public void subscriptionBacklogAgeStatsDisabledByDefault() throws Exception { + config.setPreciseTimeBasedBacklogQuotaCheck(true); + + try (PulsarClient client = PulsarClient.builder().serviceUrl(adminUrl.toString()) + .statsInterval(0, SECONDS).build()) { + final String topic1 = "persistent://prop/ns-quota/topic-disabled" + UUID.randomUUID(); + final String subName = "c1"; + + client.newConsumer().topic(topic1).subscriptionName(subName) + .acknowledgmentGroupTime(0, SECONDS) + .subscribe(); + Producer producer = createProducer(client, topic1); + producer.send(new byte[1024]); + + PersistentTopic topicRef = (PersistentTopic) pulsar.getBrokerService().getTopicReference(topic1).get(); + topicRef.updateSubscriptionOldPositionInfo().get(5, SECONDS); + + TopicStats topicStats = getTopicStats(topic1); + assertThat(topicStats.getSubscriptions().get(subName).getOldestBacklogMessageAgeSeconds()).isEqualTo(-1); + + Metrics metrics = prometheusMetricsClient.getMetrics(); + assertThat(metrics.findByNameAndLabels("pulsar_subscription_storage_backlog_age_seconds", + Pair.of("topic", topic1), Pair.of("subscription", subName))).isEmpty(); + } + } + + @Test + public void subscriptionBacklogAgeSkipsNonDurableReader() throws Exception { + config.setPreciseTimeBasedBacklogQuotaCheck(true); + config.setExposeSubscriptionBacklogAgeInPrometheus(true); + + try (PulsarClient client = PulsarClient.builder().serviceUrl(adminUrl.toString()) + .statsInterval(0, SECONDS).build()) { + final String topic1 = "persistent://prop/ns-quota/topic-reader" + UUID.randomUUID(); + final String subName = "c1"; + + client.newConsumer().topic(topic1).subscriptionName(subName) + .acknowledgmentGroupTime(0, SECONDS) + .subscribe(); + client.newReader().topic(topic1).startMessageId(MessageId.earliest).create(); + Producer producer = createProducer(client, topic1); + producer.send(new byte[1024]); + + PersistentTopic topicRef = (PersistentTopic) pulsar.getBrokerService().getTopicReference(topic1).get(); + topicRef.updateSubscriptionOldPositionInfo().get(5, SECONDS); + + TopicStats topicStats = getTopicStats(topic1); + assertThat(topicStats.getSubscriptions().get(subName).getOldestBacklogMessageAgeSeconds()) + .isGreaterThanOrEqualTo(0L); + + Map.Entry readerStats = topicStats.getSubscriptions().entrySet() + .stream() + .filter(entry -> !entry.getValue().isDurable()) + .findFirst() + .orElseThrow(); + assertThat(readerStats.getValue().getOldestBacklogMessageAgeSeconds()).isEqualTo(-1); + + Metrics metrics = prometheusMetricsClient.getMetrics(); + assertThat(metrics.findByNameAndLabels("pulsar_subscription_storage_backlog_age_seconds", + Pair.of("topic", topic1), Pair.of("subscription", readerStats.getKey()))).isEmpty(); + } + } + + @Test + public void subscriptionBacklogAgeCacheRemovedAfterSubscriptionDeleteAndRecreate() throws Exception { + config.setPreciseTimeBasedBacklogQuotaCheck(true); + config.setExposeSubscriptionBacklogAgeInPrometheus(true); + + try (PulsarClient client = PulsarClient.builder().serviceUrl(adminUrl.toString()) + .statsInterval(0, SECONDS).build()) { + final String topic1 = "persistent://prop/ns-quota/topic-recreated" + UUID.randomUUID(); + final String subName1 = "c1"; + final String subName2 = "c2"; + + Consumer consumer1 = client.newConsumer().topic(topic1).subscriptionName(subName1) + .acknowledgmentGroupTime(0, SECONDS) + .subscribe(); + client.newConsumer().topic(topic1).subscriptionName(subName2) + .acknowledgmentGroupTime(0, SECONDS) + .subscribe(); + Producer producer = createProducer(client, topic1); + producer.send(new byte[1024]); + + PersistentTopic topicRef = (PersistentTopic) pulsar.getBrokerService().getTopicReference(topic1).get(); + topicRef.updateSubscriptionOldPositionInfo().get(5, SECONDS); + + TopicStats topicStats = getTopicStats(topic1); + assertThat(topicStats.getSubscriptions().get(subName1).getOldestBacklogMessageAgeSeconds()) + .isGreaterThanOrEqualTo(0L); + assertThat(topicStats.getSubscriptions().get(subName2).getOldestBacklogMessageAgeSeconds()) + .isGreaterThanOrEqualTo(0L); + + consumer1.unsubscribe(); + topicRef.updateSubscriptionOldPositionInfo().get(5, SECONDS); + + client.newConsumer().topic(topic1).subscriptionName(subName1) + .acknowledgmentGroupTime(0, SECONDS) + .subscribe(); + topicRef.updateSubscriptionOldPositionInfo().get(5, SECONDS); + + topicStats = getTopicStats(topic1); + assertThat(topicStats.getSubscriptions().get(subName1).getMsgBacklog()).isEqualTo(0); + assertThat(topicStats.getSubscriptions().get(subName1).getOldestBacklogMessageAgeSeconds()).isEqualTo(-1); + assertThat(topicStats.getSubscriptions().get(subName2).getOldestBacklogMessageAgeSeconds()) + .isGreaterThanOrEqualTo(0L); + } + } + + @SuppressWarnings("deprecation") + @Test public void backlogsStatsPreciseWithNoBacklog() throws PulsarAdminException, PulsarClientException, InterruptedException { @@ -782,8 +940,9 @@ private long getReadEntries(String topic1) { } @Test - public void backlogsStatsNotPrecise() throws PulsarAdminException, PulsarClientException, InterruptedException { + public void backlogsStatsNotPrecise() throws Exception { config.setPreciseTimeBasedBacklogQuotaCheck(false); + config.setExposeSubscriptionBacklogAgeInPrometheus(true); config.setManagedLedgerMaxEntriesPerLedger(6); final String namespace = "prop/ns-quota"; assertEquals(admin.namespaces().getBacklogQuotaMap(namespace), new HashMap<>()); @@ -852,12 +1011,15 @@ public void backlogsStatsNotPrecise() throws PulsarAdminException, PulsarClientE long unloadTime = System.currentTimeMillis(); waitForQuotaCheckToRunTwice(); + refreshSubscriptionBacklogAge(topic1); topicStats = getTopicStats(topic1); assertThat(topicStats.getOldestBacklogMessageSubscriptionName()).isEqualTo(subName2); // age is measured against the ledger closing time long expectedAge = MILLISECONDS.toSeconds(System.currentTimeMillis() - unloadTime); assertThat(topicStats.getOldestBacklogMessageAgeSeconds()).isCloseTo(expectedAge, within(1L)); + assertThat(topicStats.getSubscriptions().get(subName2).getOldestBacklogMessageAgeSeconds()) + .isCloseTo(expectedAge, within(1L)); String c2MarkDeletePositionBefore = admin.topics().getInternalStats(topic1).cursors.get(subName2).markDeletePosition; @@ -872,11 +1034,14 @@ public void backlogsStatsNotPrecise() throws PulsarAdminException, PulsarClientE waitForMarkDeletePositionToChange(topic1, subName1, c1MarkDeletePositionBefore); waitForMarkDeletePositionToChange(topic1, subName2, c2MarkDeletePositionBefore); waitForQuotaCheckToRunTwice(); + refreshSubscriptionBacklogAge(topic1); topicStats = getTopicStats(topic1); assertThat(topicStats.getOldestBacklogMessageSubscriptionName()).isEqualTo(subName2); expectedAge = MILLISECONDS.toSeconds(System.currentTimeMillis() - unloadTime); assertThat(topicStats.getOldestBacklogMessageAgeSeconds()).isCloseTo(expectedAge, within(1L)); + assertThat(topicStats.getSubscriptions().get(subName2).getOldestBacklogMessageAgeSeconds()) + .isCloseTo(expectedAge, within(1L)); // Unsubscribe consume1 and consumer2 consumer1.unsubscribe(); diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/BrokerServiceTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/BrokerServiceTest.java index 96fb38e62620a..9df76f4a2bf2a 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/BrokerServiceTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/BrokerServiceTest.java @@ -1327,6 +1327,203 @@ public void testCheckInactiveSubscriptionsShouldNotDeleteCompactionCursor() thro } + @Test + public void testCleanUnloadedTopicFromCacheDoesNotRemoveNewTopicFuture() throws Exception { + final String namespace = "prop/ns-abc"; + final String topicName = "persistent://" + namespace + "/cleanUnloadedTopicFromCache-" + + UUID.randomUUID(); + final BrokerService brokerService = pulsar.getBrokerService(); + Producer producer = pulsarClient.newProducer().topic(topicName).create(); + producer.close(); + Topic topic = brokerService.getTopicReference(topicName).orElseThrow(); + CompletableFuture> originalTopicFuture = brokerService.getTopics().get(topicName); + assertNotNull(originalTopicFuture); + NamespaceBundle bundle = pulsar.getNamespaceService().getBundle(TopicName.get(topicName)); + BlockingCompletedFuture> oldTopicFuture = + new BlockingCompletedFuture<>(Optional.of(topic)); + brokerService.getTopics().put(topicName, oldTopicFuture); + ((AbstractTopic) topic).isFenced = true; + + CompletableFuture> newTopicFuture = + CompletableFuture.completedFuture(Optional.of(mock(Topic.class))); + List unloadEvents = new ArrayList<>(); + TopicEventsListener listener = new TopicEventsListener() { + @Override + public void handleEvent(String name, TopicEventsListener.TopicEvent event, + TopicEventsListener.EventStage stage, Throwable t) { + if (topicName.equals(name) && event == TopicEventsListener.TopicEvent.UNLOAD) { + unloadEvents.add(stage); + } + } + }; + brokerService.addTopicEventListener(listener); + try { + CompletableFuture cleanupFuture = CompletableFuture.runAsync( + () -> brokerService.cleanUnloadedTopicFromCache(bundle)); + assertTrue(oldTopicFuture.awaitIsDone(5, TimeUnit.SECONDS), + "cleanup should capture the old future before it is replaced"); + + // Simulate a reload racing after the bundle cleanup has captured the old future. + brokerService.getTopics().put(topicName, newTopicFuture); + oldTopicFuture.allowIsDone(); + cleanupFuture.get(5, TimeUnit.SECONDS); + + assertTrue(brokerService.getTopics().get(topicName) == newTopicFuture, + "A stale bundle unload cleanup must not remove a newer topic future"); + assertTrue(unloadEvents.isEmpty(), + "A stale bundle unload cleanup must not dispatch unload events"); + } finally { + oldTopicFuture.allowIsDone(); + brokerService.removeTopicEventListener(listener); + brokerService.getTopics().put(topicName, originalTopicFuture); + topic.close(false, true).get(5, TimeUnit.SECONDS); + } + } + + @Test + public void testUnloadBeforeDoesNotStartTopicReload() throws Exception { + final String namespace = "prop/ns-abc"; + final String topicName = "persistent://" + namespace + "/unloadBeforeDoesNotStartTopicReload-" + + UUID.randomUUID(); + final BrokerService brokerService = pulsar.getBrokerService(); + Producer producer = pulsarClient.newProducer().topic(topicName).create(); + producer.close(); + Topic topic = brokerService.getTopicReference(topicName).orElseThrow(); + CompletableFuture> topicFuture = brokerService.getTopics().get(topicName); + assertNotNull(topicFuture); + + AtomicReference>> futureAtUnloadBefore = new AtomicReference<>(); + TopicEventsListener listener = new TopicEventsListener() { + @Override + public void handleEvent(String name, TopicEventsListener.TopicEvent event, + TopicEventsListener.EventStage stage, Throwable t) { + if (topicName.equals(name) && event == TopicEventsListener.TopicEvent.UNLOAD) { + if (stage == TopicEventsListener.EventStage.BEFORE) { + futureAtUnloadBefore.set(brokerService.getTopic(TopicName.get(topicName), true, null)); + } + } + } + }; + brokerService.addTopicEventListener(listener); + try { + topic.close(true, true).get(5, TimeUnit.SECONDS); + + assertTrue(futureAtUnloadBefore.get() == topicFuture, + "UNLOAD BEFORE must return the existing topic future instead of starting a reload"); + assertNull(brokerService.getTopics().get(topicName), + "UNLOAD SUCCESS must observe the topic cache entry removed"); + } finally { + brokerService.removeTopicEventListener(listener); + } + } + + @Test + public void testRemoveTopicFromCacheIgnoresReentrantUnloadCallback() throws Exception { + final String namespace = "prop/ns-abc"; + final String topicName = "persistent://" + namespace + "/removeTopicFromCacheReentrant-" + + UUID.randomUUID(); + final BrokerService brokerService = pulsar.getBrokerService(); + Producer producer = pulsarClient.newProducer().topic(topicName).create(); + producer.close(); + Topic topic = brokerService.getTopicReference(topicName).orElseThrow(); + + List unloadEvents = new ArrayList<>(); + AtomicReference reentrantCallbackError = new AtomicReference<>(); + TopicEventsListener listener = new TopicEventsListener() { + @Override + public void handleEvent(String name, TopicEventsListener.TopicEvent event, + TopicEventsListener.EventStage stage, Throwable t) { + if (topicName.equals(name) && event == TopicEventsListener.TopicEvent.UNLOAD) { + unloadEvents.add(stage); + if (stage == TopicEventsListener.EventStage.BEFORE) { + try { + brokerService.removeTopicFromCache((AbstractTopic) topic).get(5, TimeUnit.SECONDS); + } catch (Throwable e) { + reentrantCallbackError.set(e); + } + } + } + } + }; + brokerService.addTopicEventListener(listener); + try { + brokerService.removeTopicFromCache((AbstractTopic) topic).get(5, TimeUnit.SECONDS); + + assertNull(reentrantCallbackError.get(), "A reentrant cache cleanup should complete without error"); + assertEquals(unloadEvents, List.of(TopicEventsListener.EventStage.BEFORE, + TopicEventsListener.EventStage.SUCCESS)); + } finally { + brokerService.removeTopicEventListener(listener); + topic.close(false, true).get(5, TimeUnit.SECONDS); + } + } + + @Test + public void testRemoveTopicFromCacheDoesNotRemoveSupersededTopicFuture() throws Exception { + final String namespace = "prop/ns-abc"; + final String topicName = "persistent://" + namespace + "/removeSupersededTopicFromCache-" + + UUID.randomUUID(); + final BrokerService brokerService = pulsar.getBrokerService(); + Producer producer = pulsarClient.newProducer().topic(topicName).create(); + producer.close(); + Topic topic = brokerService.getTopicReference(topicName).orElseThrow(); + CompletableFuture> oldTopicFuture = brokerService.getTopics().get(topicName); + assertNotNull(oldTopicFuture); + + List unloadEvents = new ArrayList<>(); + TopicEventsListener listener = new TopicEventsListener() { + @Override + public void handleEvent(String name, TopicEventsListener.TopicEvent event, + TopicEventsListener.EventStage stage, Throwable t) { + if (topicName.equals(name) && event == TopicEventsListener.TopicEvent.UNLOAD) { + unloadEvents.add(stage); + } + } + }; + brokerService.addTopicEventListener(listener); + + CompletableFuture> newTopicFuture = + CompletableFuture.completedFuture(Optional.of(topic)); + // Simulate the same topic being reloaded before the old topic close callback removes it. + brokerService.getTopics().put(topicName, newTopicFuture); + try { + brokerService.removeTopicFromCache((AbstractTopic) topic).get(5, TimeUnit.SECONDS); + + assertTrue(brokerService.getTopics().get(topicName) == newTopicFuture, + "A stale topic close callback must not remove a newer topic future"); + assertTrue(unloadEvents.isEmpty(), + "A stale topic close callback must not dispatch unload events"); + } finally { + brokerService.removeTopicEventListener(listener); + brokerService.getTopics().put(topicName, oldTopicFuture); + brokerService.removeTopicFromCache((AbstractTopic) topic).get(5, TimeUnit.SECONDS); + } + } + + @Test + public void testCleanUnloadedTopicFromCacheRemovesInactiveBundleTopicFuture() throws Exception { + final String namespace = "prop/ns-abc"; + final String topicName = "persistent://" + namespace + "/cleanInactiveUnloadedTopicFromCache-" + + UUID.randomUUID(); + final BrokerService brokerService = pulsar.getBrokerService(); + Producer producer = pulsarClient.newProducer().topic(topicName).create(); + producer.close(); + Topic topic = brokerService.getTopicReference(topicName).orElseThrow(); + NamespaceBundle bundle = pulsar.getNamespaceService().getBundle(TopicName.get(topicName)); + + pulsar.getNamespaceService().getOwnershipCache().updateBundleState(bundle, false).get(5, TimeUnit.SECONDS); + ((AbstractTopic) topic).isFenced = true; + try { + brokerService.cleanUnloadedTopicFromCache(bundle); + + assertFalse(brokerService.getTopics().containsKey(topicName), + "Inactive bundle cleanup should remove the matching topic future"); + } finally { + pulsar.getNamespaceService().getOwnershipCache().updateBundleState(bundle, true).get(5, TimeUnit.SECONDS); + topic.close(false, true).get(5, TimeUnit.SECONDS); + } + } + @Test public void testCheckInactiveSubscriptionWhenNoMessageToAck() throws Exception { String namespace = "prop/testInactiveSubscriptionWhenNoMessageToAck"; @@ -2049,6 +2246,37 @@ public void testGetTopicWhenTopicPoliciesFail() throws Exception { assertFalse(MockTopicPoliciesService.FAILED_TOPICS.contains(topicName)); } + private static class BlockingCompletedFuture extends CompletableFuture { + private final CountDownLatch isDoneEntered = new CountDownLatch(1); + private final CountDownLatch allowIsDone = new CountDownLatch(1); + + BlockingCompletedFuture(T value) { + complete(value); + } + + @Override + public boolean isDone() { + isDoneEntered.countDown(); + try { + if (!allowIsDone.await(10, TimeUnit.SECONDS)) { + throw new AssertionError("Timed out waiting to resume topic-cache cleanup"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError("Interrupted while waiting to resume topic-cache cleanup", e); + } + return super.isDone(); + } + + boolean awaitIsDone(long timeout, TimeUnit unit) throws InterruptedException { + return isDoneEntered.await(timeout, unit); + } + + void allowIsDone() { + allowIsDone.countDown(); + } + } + static class MockTopicPoliciesService extends TopicPoliciesService.TopicPoliciesServiceDisabled { static final Set FAILED_TOPICS = ConcurrentHashMap.newKeySet(); @@ -2064,4 +2292,3 @@ public CompletableFuture> getTopicPoliciesAsync(TopicNam } } } - diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/GetLastMessageIdEntryLeakTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/GetLastMessageIdEntryLeakTest.java new file mode 100644 index 0000000000000..ce612bbbc3355 --- /dev/null +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/GetLastMessageIdEntryLeakTest.java @@ -0,0 +1,109 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.broker.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.spy; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertThrows; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import lombok.Cleanup; +import org.apache.bookkeeper.mledger.AsyncCallbacks.ReadEntryCallback; +import org.apache.bookkeeper.mledger.Position; +import org.apache.bookkeeper.mledger.impl.EntryImpl; +import org.apache.bookkeeper.mledger.impl.ManagedLedgerImpl; +import org.apache.commons.lang3.reflect.FieldUtils; +import org.apache.pulsar.broker.service.persistent.PersistentTopic; +import org.apache.pulsar.client.api.Consumer; +import org.apache.pulsar.client.api.Producer; +import org.apache.pulsar.client.api.ProducerConsumerBase; +import org.apache.pulsar.client.api.Schema; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +@Test(groups = "broker") +public class GetLastMessageIdEntryLeakTest extends ProducerConsumerBase { + @BeforeMethod + @Override + protected void setup() throws Exception { + super.internalSetup(); + super.producerBaseSetup(); + } + + @AfterMethod(alwaysRun = true) + @Override + protected void cleanup() throws Exception { + super.internalCleanup(); + } + + /** + * Reproduces the ByteBuf leak in ServerCnx#getLargestBatchIndexWhenPossible: when + * Commands.parseMessageMetadata throws on the entry read for the last position, the entry (and + * its backing ByteBuf) must still be released. + */ + @Test + public void testEntryReleasedWhenParseMetadataThrows() throws Exception { + final String topic = newTopicName(); + + @Cleanup + Producer producer = pulsarClient.newProducer().topic(topic).create(); + producer.send("payload".getBytes()); + + @Cleanup + Consumer consumer = pulsarClient.newConsumer(Schema.BYTES) + .topic(topic).subscriptionName("sub").subscribe(); + + PersistentTopic persistentTopic = (PersistentTopic) pulsar.getBrokerService().getTopicReference(topic).get(); + + // A 2-byte, non-magic buffer: parseMessageMetadata reads it but fails at readUnsignedInt + // (needs 4 bytes), which is exactly the corrupt-entry case that triggers the leak. + ByteBuf corruptBuf = Unpooled.buffer(2); + corruptBuf.writeShort(0x0000); + + // Build the entry eagerly so its retain happens now; then drop our own ref so the entry + // "owns" the only ref and we can observe whether the broker releases it. + Position lastPosition = ((ManagedLedgerImpl) persistentTopic.getManagedLedger()).getLastConfirmedEntry(); + EntryImpl corruptEntry = + EntryImpl.create(lastPosition.getLedgerId(), lastPosition.getEntryId(), corruptBuf); + corruptBuf.release(); + assertEquals(corruptBuf.refCnt(), 1); + + // Spy the real ManagedLedgerImpl so asyncReadEntry hands back our corrupt entry, while + // getLastPosition() etc. still delegate to the real ledger. + ManagedLedgerImpl spyLedger = spy((ManagedLedgerImpl) persistentTopic.getManagedLedger()); + doAnswer(inv -> { + ReadEntryCallback callback = inv.getArgument(1); + callback.readEntryComplete(corruptEntry, inv.getArgument(2)); + return null; + }).when(spyLedger).asyncReadEntry(any(Position.class), any(ReadEntryCallback.class), any()); + + FieldUtils.writeField(persistentTopic, "ledger", spyLedger, true); + + // The broker fails the request (MetadataError) - that's expected; the point is the buffer. + assertThrows(Exception.class, consumer::getLastMessageId); + + // Before the fix: parseMessageMetadata throws, entry.release() is skipped -> refCnt stays 1. + // After the fix: release runs in finally -> refCnt drops to 0. + assertEquals(corruptBuf.refCnt(), 0, "entry's ByteBuf leaked when parseMessageMetadata threw"); + } + +} diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/LegacyAwareTopicPoliciesServiceTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/LegacyAwareTopicPoliciesServiceTest.java new file mode 100644 index 0000000000000..47a7de0528de9 --- /dev/null +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/LegacyAwareTopicPoliciesServiceTest.java @@ -0,0 +1,190 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.broker.service; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest; +import org.apache.pulsar.common.naming.NamespaceName; +import org.apache.pulsar.common.naming.SystemTopicNames; +import org.apache.pulsar.common.naming.TopicName; +import org.apache.pulsar.common.policies.data.TopicPolicies; +import org.awaitility.Awaitility; +import org.awaitility.core.ThrowingRunnable; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +/** + * Test order: testUpgrade() -> other tests (with MetadataStoreTopicPoliciesService configured) -> testDowngrade(). + */ +@Test(groups = "broker") +public class LegacyAwareTopicPoliciesServiceTest extends MockedPulsarServiceBaseTest { + + private static final String metaNamespace = "public/meta-ns"; + + @BeforeClass + @Override + protected void setup() throws Exception { + super.internalSetup(); + super.setupDefaultTenantAndNamespace(); + } + + @AfterClass + @Override + protected void cleanup() throws Exception { + super.internalCleanup(); + } + + @Test(priority = -1) + public void testUpgrade() throws Exception { + final var topic = "test-upgrade"; + admin.topics().createNonPartitionedTopic(topic); + admin.topicPolicies().setCompactionThreshold(topic, 100); + waitUntilAssert(() -> assertEquals(admin.topicPolicies().getCompactionThreshold(topic), 100)); + + restartBroker(conf -> { + conf.setSystemTopicEnabled(false); + conf.setTopicPoliciesServiceClassName(MetadataStoreTopicPoliciesService.class.getName()); + }); + // The policies will be lost because when system topic is disabled, it will not try to read policies from the + // __change_events topic + assertNull(admin.topicPolicies().getCompactionThreshold(topic)); + + restartBroker(conf -> conf.setSystemTopicEnabled(true)); + // The default namespace still read policies from the __change_events topic if it exists + assertEquals(admin.topicPolicies().getCompactionThreshold(topic), 100); + assertFalse(pulsar.getLocalMetadataStore().exists(MetadataStoreTopicPoliciesService.LOCAL_POLICIES_ROOT).get()); + + // The global policies are still stored in the __change_events topic + admin.topicPolicies(true).setCompactionThreshold(topic, 200); + waitUntilAssert(() -> assertEquals(admin.topicPolicies(true).getCompactionThreshold(topic), 200)); + assertFalse(pulsar.getConfigurationMetadataStore() + .exists(MetadataStoreTopicPoliciesService.GLOBAL_POLICIES_ROOT).get()); + + admin.topicPolicies().deleteTopicPolicies(topic); + waitUntilAssert(() -> assertNull(admin.topicPolicies().getCompactionThreshold(topic))); + + admin.namespaces().createNamespace(metaNamespace); + } + + @Test(priority = 1) + public void testDowngrade() throws Exception { + final var topic1 = "downgrade"; // in default namespace + admin.topics().createNonPartitionedTopic(topic1); + admin.topicPolicies().setCompactionThreshold(topic1, 1); + waitUntilAssert(() -> assertEquals(admin.topicPolicies().getCompactionThreshold(topic1), 1)); + + final var topic2 = metaNamespace + "/downgrade"; + admin.topics().createNonPartitionedTopic(topic2); + admin.topicPolicies().setCompactionThreshold(topic2, 2); + waitUntilAssert(() -> assertEquals(admin.topicPolicies().getCompactionThreshold(topic2), 2)); + + restartBroker(conf -> + conf.setTopicPoliciesServiceClassName(SystemTopicBasedTopicPoliciesService.class.getName())); + assertEquals(admin.topicPolicies().getCompactionThreshold(topic1), 1); + // The policies will be lost because they are not stored in the __change_events topic + assertNull(admin.topicPolicies().getCompactionThreshold(topic2)); + } + + @DataProvider + public Object[][] namespaces() { + return new Object[][] { + { "public/default" }, + { metaNamespace } + }; + } + + @Test(dataProvider = "namespaces") + public void testPoliciesOperations(String namespace) throws Exception { + final var topicName = TopicName.get(namespace + "/test-policies-operations"); + final var topic = topicName.toString(); + admin.topics().createNonPartitionedTopic(topic); + + final var compactionThreshold = new AtomicLong(0); + // Verify the exception thrown from one listener does not affect other listeners + pulsar.getTopicPoliciesService().registerListenerAsync(topicName, __ -> { + throw new RuntimeException("injected failure"); + }).get(); + pulsar.getTopicPoliciesService().registerListenerAsync(topicName, policies -> + Optional.ofNullable(policies).map(TopicPolicies::getCompactionThreshold).ifPresentOrElse( + compactionThreshold::set, () -> compactionThreshold.set(-1))).get(); + + // Verify Created events are handled + admin.topicPolicies(false).setCompactionThreshold(topic, 100); + waitUntilAssert(() -> assertEquals(compactionThreshold.get(), 100)); + final var localStore = pulsar.getLocalMetadataStore(); + final var configurationStore = pulsar.getConfigurationMetadataStore(); + + if (namespace.equals(metaNamespace)) { + assertTrue(localStore.exists(MetadataStoreTopicPoliciesService.pathFor(topicName, false)).get()); + assertFalse(configurationStore.exists(MetadataStoreTopicPoliciesService.pathFor(topicName, true)).get()); + } + + admin.topicPolicies(true).setCompactionThreshold(topic, 200); + waitUntilAssert(() -> assertEquals(compactionThreshold.get(), 200)); + if (namespace.equals(metaNamespace)) { + assertTrue(configurationStore.exists(MetadataStoreTopicPoliciesService.pathFor(topicName, true)).get()); + } + + // Verify Modified events are handled + admin.topicPolicies(false).setCompactionThreshold(topic, 300); + waitUntilAssert(() -> assertEquals(compactionThreshold.get(), 300)); + + admin.topicPolicies(true).setCompactionThreshold(topic, 400); + waitUntilAssert(() -> assertEquals(compactionThreshold.get(), 400)); + + final var readerNamespaces = ((LegacyAwareTopicPoliciesService) pulsar.getTopicPoliciesService()) + .systemTopicService.getReaderCaches().keySet(); + assertFalse(readerNamespaces.contains(NamespaceName.get(metaNamespace))); + + // Verify Deleted events are handled + admin.topicPolicies(false).deleteTopicPolicies(topic); + waitUntilAssert(() -> assertEquals(compactionThreshold.get(), -1)); + if (namespace.equals(metaNamespace)) { + assertFalse(localStore.exists(MetadataStoreTopicPoliciesService.pathFor(topicName, false)).get()); + assertFalse(configurationStore.exists(MetadataStoreTopicPoliciesService.pathFor(topicName, true)).get()); + } + } + + @Test + public void testUserCreatedEventsTopicAreIgnored() throws Exception { + final var topic = TopicName.get(metaNamespace + "/" + System.currentTimeMillis()).toString(); + admin.topics().createNonPartitionedTopic(topic); + admin.topicPolicies().setCompactionThreshold(topic, 1); + waitUntilAssert(() -> assertEquals(admin.topicPolicies().getCompactionThreshold(topic), 1)); + + final var eventsTopic = metaNamespace + "/" + SystemTopicNames.NAMESPACE_EVENTS_LOCAL_NAME; + admin.topics().createNonPartitionedTopic(eventsTopic); + // Even if the __change_events topic is created, since it has detected the namespace didn't have the events + // topic before, it will be ignored and the policies are still read from metadata store. + waitUntilAssert(() -> assertEquals(admin.topicPolicies().getCompactionThreshold(topic), 1)); + admin.topics().delete(eventsTopic); + } + + private static void waitUntilAssert(ThrowingRunnable assertion) { + Awaitility.await().atMost(Duration.ofSeconds(1)).untilAsserted(assertion); + } +} diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/MessagePublishBufferThrottleTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/MessagePublishBufferThrottleTest.java index 0faae14da08ba..6a74df3eecc00 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/MessagePublishBufferThrottleTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/MessagePublishBufferThrottleTest.java @@ -20,10 +20,9 @@ import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.assertThat; import static org.apache.pulsar.broker.stats.BrokerOpenTelemetryTestUtil.assertMetricLongSumValue; -import static org.testng.Assert.fail; -import java.util.concurrent.CompletableFuture; +import io.opentelemetry.sdk.metrics.data.MetricData; +import java.util.Collection; import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import lombok.Cleanup; import org.apache.pulsar.broker.BrokerTestUtil; import org.apache.pulsar.broker.testcontext.PulsarTestContext; @@ -134,33 +133,32 @@ public void testBlockByPublishRateLimiting() throws Exception { pulsarTestContext.getMockBookKeeper().addEntryDelay(5, TimeUnit.SECONDS); - // Block by publish buffer. + // Block by publish buffer: 10 x 1MB messages with a 1MB buffer limit. byte[] payload = new byte[1024 * 1024]; for (int i = 0; i < 10; i++) { producer.sendAsync(payload); } - Awaitility.await().untilAsserted(() -> assertRateLimitCounter(ConnectionRateLimitOperationName.PAUSED, 1)); + // Wait for at least one pause event to be recorded. + Awaitility.await().untilAsserted( + () -> assertRateLimitCounterAtLeast(ConnectionRateLimitOperationName.PAUSED, 1)); - CompletableFuture flushFuture = producer.flushAsync(); + // Verify that no resume has happened yet while messages are still blocked. + Awaitility.await().untilAsserted( + () -> assertRateLimitCounter(ConnectionRateLimitOperationName.RESUMED, 0)); - // Block by publish rate. - // After 1 second, the message buffer throttling will be lifted, but the rate limiting will still be in place. - assertRateLimitCounter(ConnectionRateLimitOperationName.PAUSED, 1); - assertRateLimitCounter(ConnectionRateLimitOperationName.RESUMED, 0); - - try { - flushFuture.get(2, TimeUnit.SECONDS); - fail("Should have timed out"); - } catch (TimeoutException e) { - // Ok - } - - flushFuture.join(); + // Flush and wait for all messages to complete. + producer.flush(); + // After all messages are sent, the number of pauses and resumes should match: + // every pause must eventually be followed by a resume. Awaitility.await().untilAsserted(() -> { - assertRateLimitCounter(ConnectionRateLimitOperationName.PAUSED, 10); - assertRateLimitCounter(ConnectionRateLimitOperationName.RESUMED, 10); + var metrics = pulsarTestContext.getOpenTelemetryMetricReader().collectAllMetrics(); + long pausedCount = getMetricLongSumValue(metrics, ConnectionRateLimitOperationName.PAUSED); + long resumedCount = getMetricLongSumValue(metrics, ConnectionRateLimitOperationName.RESUMED); + Assert.assertTrue(pausedCount > 0, "Expected at least one pause event"); + Assert.assertEquals(pausedCount, resumedCount, + "Paused and resumed counts should match after all messages are sent"); }); } @@ -206,4 +204,24 @@ private void assertRateLimitCounter(ConnectionRateLimitOperationName connectionR connectionRateLimitState.attributes, expectedCount); } } + + private void assertRateLimitCounterAtLeast(ConnectionRateLimitOperationName connectionRateLimitState, + int minExpectedCount) { + var metrics = pulsarTestContext.getOpenTelemetryMetricReader().collectAllMetrics(); + assertMetricLongSumValue(metrics, BrokerService.CONNECTION_RATE_LIMIT_COUNT_METRIC_NAME, + connectionRateLimitState.attributes, + actual -> assertThat(actual).isGreaterThanOrEqualTo(minExpectedCount)); + } + + private long getMetricLongSumValue(Collection metrics, + ConnectionRateLimitOperationName connectionRateLimitState) { + var attributesMap = connectionRateLimitState.attributes.asMap(); + return metrics.stream() + .filter(m -> m.getName().equals(BrokerService.CONNECTION_RATE_LIMIT_COUNT_METRIC_NAME)) + .flatMap(m -> m.getLongSumData().getPoints().stream()) + .filter(point -> point.getAttributes().asMap().equals(attributesMap)) + .mapToLong(io.opentelemetry.sdk.metrics.data.LongPointData::getValue) + .findFirst() + .orElse(0L); + } } diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorSchemaValidationEnforcedTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorSchemaValidationEnforcedTest.java index ed8f8ac479ddd..27579c1ea2fda 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorSchemaValidationEnforcedTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorSchemaValidationEnforcedTest.java @@ -70,12 +70,15 @@ public void testReplicationWithAvroSchemaWithSchemaValidationEnforced() throws E Schema myClassSchema = Schema.AVRO(MyClass.class); final String topicName = BrokerTestUtil.newUniqueName("persistent://" + sourceClusterAlwaysSchemaCompatibleNamespace + "/tp_"); + // Create the topic and schema in the remote cluster (r2) first. Creating the topic in the local + // cluster starts the replicator, which creates the topic on the remote cluster itself when it is + // missing (GeoPersistentReplicator#createRemoteTopicIfDoesNotExist), so creating r2's topic + // afterwards races with it and fails with "This topic already exists". + admin2.topics().createNonPartitionedTopic(topicName); + admin2.schemas().createSchema(topicName, myClassSchema.getSchemaInfo()); // create the topic and schema in the local cluster (r1) admin1.topics().createNonPartitionedTopic(topicName); admin1.schemas().createSchema(topicName, myClassSchema.getSchemaInfo()); - // create the topic and schema in the remote cluster (r2) - admin2.topics().createNonPartitionedTopic(topicName); - admin2.schemas().createSchema(topicName, myClassSchema.getSchemaInfo()); // consume from the remote cluster (r2) org.apache.pulsar.client.api.Consumer consumer2 = client2.newConsumer(myClassSchema) diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorTest.java index f77615b77a84a..99f608a200c34 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorTest.java @@ -35,6 +35,7 @@ import com.github.benmanes.caffeine.cache.AsyncLoadingCache; import com.google.common.collect.Multimap; import com.google.common.collect.Sets; +import com.google.common.util.concurrent.MoreExecutors; import io.netty.channel.Channel; import io.netty.util.concurrent.FastThreadLocalThread; import java.lang.reflect.Field; @@ -54,6 +55,9 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -73,6 +77,7 @@ import org.apache.bookkeeper.mledger.impl.ManagedLedgerTest; import org.apache.commons.collections4.CollectionUtils; import org.apache.pulsar.broker.BrokerTestUtil; +import org.apache.pulsar.broker.ServiceConfiguration; import org.apache.pulsar.broker.resources.ClusterResources; import org.apache.pulsar.broker.service.nonpersistent.NonPersistentReplicator; import org.apache.pulsar.broker.service.nonpersistent.NonPersistentTopic; @@ -91,6 +96,7 @@ import org.apache.pulsar.client.api.ProducerBuilder; import org.apache.pulsar.client.api.PulsarClient; import org.apache.pulsar.client.api.Schema; +import org.apache.pulsar.client.api.SubscriptionInitialPosition; import org.apache.pulsar.client.api.schema.GenericRecord; import org.apache.pulsar.client.impl.ClientBuilderImpl; import org.apache.pulsar.client.impl.ClientCnx; @@ -104,6 +110,7 @@ import org.apache.pulsar.common.partition.PartitionedTopicMetadata; import org.apache.pulsar.common.policies.data.AutoTopicCreationOverride; import org.apache.pulsar.common.policies.data.ClusterData; +import org.apache.pulsar.common.policies.data.DispatchRate; import org.apache.pulsar.common.policies.data.HierarchyTopicPolicies; import org.apache.pulsar.common.policies.data.PublishRate; import org.apache.pulsar.common.policies.data.ReplicatorStats; @@ -115,6 +122,8 @@ import org.apache.pulsar.common.schema.SchemaInfo; import org.apache.pulsar.common.schema.SchemaType; import org.apache.pulsar.common.util.FutureUtil; +import org.apache.pulsar.zookeeper.LocalBookkeeperEnsemble; +import org.apache.pulsar.zookeeper.ZookeeperServerTest; import org.awaitility.Awaitility; import org.awaitility.reflect.WhiteboxImpl; import org.glassfish.jersey.client.JerseyClient; @@ -142,6 +151,11 @@ public void cleanup() throws Exception { super.cleanup(); } + protected void setConfigDefaults(ServiceConfiguration config, String clusterName, + LocalBookkeeperEnsemble bookkeeperEnsemble, ZookeeperServerTest brokerConfigZk) { + super.setConfigDefaults(config, clusterName, bookkeeperEnsemble, brokerConfigZk); + } + @Test(timeOut = 45 * 1000) public void testReceiverSideReplicationStats() throws Exception { final String topic = BrokerTestUtil.newUniqueName("persistent://" + replicatedNamespace + "/tp_"); @@ -221,6 +235,189 @@ public void testDeleteTopicWhenReplicating() throws Exception { }); } + @DataProvider + public Object[][] paramsDisconnectReplicator() { + // Binary way replication. + // local producers on cluster-1 registered. + // local producers on cluster-1 have traffic. + // replicator producer from cluster-2 has traffic. + // replicator producer from cluster-2 is present. + return new Object[][] { + {true, true, false, true, true}, // verify-cluster-2: no replicator terminate occurs. + {true, true, false, false, true}, // verify-cluster-2: replicator terminated and resumed. + {true, true, false, false, false}, // verify-cluster-2: replicator terminated and resumed. + {true, false, false, true, true}, // verify-cluster-2: no replicator terminate occurs. + {true, false, false, false, true}, // verify-cluster-2: replicator terminated and resumed. + {true, false, false, false, false}, // verify-cluster-2: replicator terminated and resumed. + + {false, false, false, false, false}, // verify-cluster-2: replicator terminated and resumed. + {false, true, false, false, false} // verify-cluster-2: replicator terminated and resumed. + }; + } + + @Test(timeOut = 240 * 1000, dataProvider = "paramsDisconnectReplicator") + public void testDisconnectAndReconnectReplicator(boolean binaryWayRepl, + boolean hasLocalProducerRegistered, + boolean localProducerHasTraffic, + boolean hasRemoteProducerTraffic, + boolean hasRemoteProducerRegistered) throws Exception { + ScheduledExecutorService executor1 = Executors.newScheduledThreadPool(1); + ScheduledExecutorService executor2 = Executors.newScheduledThreadPool(1); + ScheduledFuture checkInactiveTopic = executor1.scheduleWithFixedDelay(() -> { + pulsar1.getBrokerService().checkInactiveReplication(); + }, 10, 10, TimeUnit.SECONDS); + // local cluster: let inactive replicator check faster. + int replicationInactiveThresholdSeconds1 = pulsar1.getConfig().getBrokerReplicationInactiveThresholdSeconds(); + pulsar1.getConfig().setBrokerReplicationInactiveThresholdSeconds(30); + // remote cluster: let inactive topic deletion never occur. + int replicationInactiveThresholdSeconds2 = pulsar2.getConfig().getBrokerReplicationInactiveThresholdSeconds(); + pulsar2.getConfig().setBrokerReplicationInactiveThresholdSeconds(3600 * 24); + // Lat topic GC does not execute. + int inactiveTopicsMaxInactiveDurationSeconds = pulsar1.getConfig() + .getBrokerDeleteInactiveTopicsMaxInactiveDurationSeconds(); + pulsar1.getConfig().setBrokerDeleteInactiveTopicsMaxInactiveDurationSeconds(3600 * 24); + + // Check params. + if (hasRemoteProducerTraffic && !hasRemoteProducerRegistered) { + throw new Exception("If has traffic from remote cluster, the param \"hasRemoteProducer\" can not be false"); + } + // Check params. + if (localProducerHasTraffic && !hasLocalProducerRegistered) { + throw new Exception("If has local traffic, the param \"localProducerEmpty\" can not be true"); + } + + ScheduledFuture scheduledPublish1 = null; + ScheduledFuture scheduledPublish2 = null; + final String topic = BrokerTestUtil.newUniqueName("persistent://" + replicatedNamespace + "/tp_"); + + // Init by params: local producers. + final Producer producer1A = client1.newProducer(Schema.STRING).topic(topic).create(); + Producer producer1B = null; + if (!hasLocalProducerRegistered) { + producer1A.close(); + } + // Init by params: local producer traffic. + if (localProducerHasTraffic) { + AtomicInteger msgCount = new AtomicInteger(); + scheduledPublish1 = executor1.scheduleWithFixedDelay(() -> { + producer1A.sendAsync(msgCount.incrementAndGet() + ""); + }, 1, 1, TimeUnit.SECONDS); + } + // Init by params: binary way replication. + waitReplicatorStarted(topic, pulsar2); + if (binaryWayRepl) { + admin2.topics().setReplicationClusters(topic, Arrays.asList(cluster1, cluster2)); + waitReplicatorStarted(topic, pulsar1); + } + final PersistentTopic persistentTopic1 = (PersistentTopic) broker1.getTopic(topic, false).join().get(); + final PersistentTopic persistentTopic2 = (PersistentTopic) broker2.getTopic(topic, false).join().get(); + // Init by params: remote producer traffic. + final Producer producer2 = client2.newProducer(Schema.STRING).topic(topic).create(); + if (hasRemoteProducerTraffic) { + AtomicInteger msgCount = new AtomicInteger(); + scheduledPublish2 = executor2.scheduleWithFixedDelay(() -> { + producer2.sendAsync(msgCount.incrementAndGet() + ""); + }, 1, 1, TimeUnit.SECONDS); + } + // Init by params: remote producers. + if (binaryWayRepl && !hasRemoteProducerTraffic && !hasRemoteProducerRegistered) { + persistentTopic2.getReplicators().get(cluster1).terminate(); + } + + // Verify: all states match params. + Thread.sleep(3000); + // All states match: local producers. + if (!hasLocalProducerRegistered) { + assertFalse(persistentTopic1.getProducers().values().stream() + .filter(p -> !p.isRemote()).findAny().isPresent()); + } else { + Optional serviceProducer1 = persistentTopic1.getProducers() + .values().stream().filter(p -> !p.isRemote()).findAny(); + assertTrue(serviceProducer1.isPresent()); + } + // All states match: remote producers. + if (binaryWayRepl) { + if (!hasRemoteProducerRegistered) { + assertFalse(persistentTopic1.getProducers().values().stream() + .filter(p -> p.isRemote()).findAny().isPresent()); + } else { + Optional serviceProducer1 = persistentTopic1.getProducers() + .values().stream().filter(p -> p.isRemote()).findAny(); + assertTrue(serviceProducer1.isPresent()); + } + } + + // Verify: replicator terminated or not. + if (hasRemoteProducerTraffic || localProducerHasTraffic) { + long verifyStartTime = System.currentTimeMillis(); + while (System.currentTimeMillis() - verifyStartTime < 100_000) { + assertFalse(persistentTopic1.getReplicators().isEmpty()); + PersistentReplicator persistentReplicator = + (PersistentReplicator) persistentTopic1.getReplicators().get(cluster2); + assertTrue(persistentReplicator.isConnected()); + assertEquals(persistentReplicator.getState(), AbstractReplicator.State.Started); + Thread.sleep(1000); + } + } else { + Thread.sleep(100_000); + assertFalse(persistentTopic1.getReplicators().isEmpty()); + PersistentReplicator persistentReplicatorA = + (PersistentReplicator) persistentTopic1.getReplicators().get(cluster2); + assertFalse(persistentReplicatorA.isConnected()); + assertEquals(persistentReplicatorA.getState(), AbstractReplicator.State.Disconnected); + + // Verify: resume. + if (hasRemoteProducerRegistered && !hasRemoteProducerTraffic) { + producer2.send("msg-remote"); + } + if (!hasLocalProducerRegistered) { + producer1B = client1.newProducer(Schema.STRING).topic(topic).create(); + producer1B.send("msg-local"); + } else { + producer1A.send("msg-local"); + } + Awaitility.await().untilAsserted(() -> { + assertFalse(persistentTopic1.getReplicators().isEmpty()); + PersistentReplicator persistentReplicatorB = + (PersistentReplicator) persistentTopic1.getReplicators().get(cluster2); + assertTrue(persistentReplicatorB.isConnected()); + assertEquals(persistentReplicatorB.getState(), AbstractReplicator.State.Started); + }); + } + + // cleanup. + pulsar1.getConfig().setBrokerReplicationInactiveThresholdSeconds(replicationInactiveThresholdSeconds1); + pulsar2.getConfig().setBrokerReplicationInactiveThresholdSeconds(replicationInactiveThresholdSeconds2); + pulsar1.getConfig().setBrokerDeleteInactiveTopicsMaxInactiveDurationSeconds( + inactiveTopicsMaxInactiveDurationSeconds); + if (scheduledPublish1 != null) { + scheduledPublish1.cancel(true); + } + if (scheduledPublish2 != null) { + scheduledPublish2.cancel(true); + } + checkInactiveTopic.cancel(true); + if (producer1A.isConnected()) { + producer1A.close(); + } + if (producer1B != null && producer1B.isConnected()) { + producer1B.close(); + } + if (producer2.isConnected()) { + producer2.close(); + } + if (binaryWayRepl) { + admin2.topics().setReplicationClusters(topic, Arrays.asList(cluster2)); + waitReplicatorStopped(pulsar2, pulsar1, topic); + } + cleanupTopics(() -> { + admin1.topics().delete(topic); + admin2.topics().delete(topic); + }); + executor1.shutdown(); + executor2.shutdown(); + } + @Test(timeOut = 45 * 1000) public void testReplicatorProducerStatInTopic() throws Exception { final String topicName = BrokerTestUtil.newUniqueName("persistent://" + replicatedNamespace + "/tp_"); @@ -527,7 +724,8 @@ public void testProbBKErrorWhenReplicating() throws Exception { } return new ManagedLedgerException.TooManyRequestsException("mocked error"); }; - ManagedLedgerTest.makeReadEntryProbFail(ml1, bkErrorOrNot); + // bkErrorOrNot doesn't block, so evaluate it inline on the calling read thread via directExecutor(). + ManagedLedgerTest.makeReadEntryProbFail(ml1, bkErrorOrNot, MoreExecutors.directExecutor()); // Verify: the replication will finish even though received ManagedLedgerException.TooManyRequestsException. pulsar1.getConfig().setReplicationStartAt("earliest"); @@ -2010,4 +2208,83 @@ public void testReplicatorsInflightTaskListIsEmptyAfterReplicationFinished() thr // Verify: all inflight tasks are done. ensureNoBacklogByInflightTask(getReplicator(topicName)); } + + @DataProvider + public Object[][] replicatorDispatchRateLimits() { + return new Object[][] { + {1, -1L}, + {-1, 1L} + }; + } + + @Test(timeOut = 90_000, dataProvider = "replicatorDispatchRateLimits") + public void testReplicatorContinuesAfterRateLimiterHasNoPermits(int messageRate, long byteRate) throws Exception { + final String topicName = BrokerTestUtil.newUniqueName("persistent://" + replicatedNamespace + "/tp_"); + final String subscriptionName = "sub"; + final List messages = Arrays.asList("msg-0", "msg-1", "msg-2"); + DispatchRate dispatchRate = DispatchRate.builder() + .dispatchThrottlingRateInMsg(messageRate) + .dispatchThrottlingRateInByte(byteRate) + .ratePeriodInSecond(2) + .build(); + Producer producer = null; + Consumer consumer = null; + boolean topicCreated = false; + boolean dispatchRateConfigured = false; + try { + admin1.topics().createNonPartitionedTopic(topicName); + topicCreated = true; + admin1.topicPolicies().setReplicatorDispatchRate(topicName, dispatchRate); + dispatchRateConfigured = true; + GeoPersistentReplicator replicator = getReplicator(topicName); + Awaitility.await().untilAsserted(() -> { + assertTrue(replicator.getRateLimiter().isPresent()); + assertEquals(replicator.getRateLimiter().get().getDispatchRateOnMsg(), messageRate); + assertEquals(replicator.getRateLimiter().get().getDispatchRateOnByte(), byteRate); + }); + consumer = client2.newConsumer(Schema.STRING) + .topic(topicName) + .subscriptionName(subscriptionName) + .subscriptionInitialPosition(SubscriptionInitialPosition.Earliest) + .subscribe(); + producer = client1.newProducer(Schema.STRING) + .topic(topicName) + .enableBatching(false) + .create(); + + for (String message : messages) { + producer.send(message); + } + + Set expected = new HashSet<>(messages); + Set received = new HashSet<>(); + Consumer subscribedConsumer = consumer; + Awaitility.await().atMost(Duration.ofSeconds(60)).untilAsserted(() -> { + Message message = subscribedConsumer.receive(1, TimeUnit.SECONDS); + if (message != null) { + received.add(message.getValue()); + subscribedConsumer.acknowledge(message); + } + assertEquals(received, expected); + }); + waitForReplicationTaskFinish(topicName); + ensureNoBacklogByInflightTask(replicator); + } finally { + if (producer != null) { + producer.close(); + } + if (consumer != null) { + consumer.close(); + } + if (dispatchRateConfigured) { + admin1.topicPolicies().removeReplicatorDispatchRate(topicName); + } + if (topicCreated) { + admin1.topics().setReplicationClusters(topicName, Arrays.asList(cluster1)); + waitReplicatorStopped(topicName, false); + admin1.topics().delete(topicName, true); + admin2.topics().delete(topicName, true); + } + } + } } diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorTestBase.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorTestBase.java index 0b033ec269cc2..da486b7d94991 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorTestBase.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorTestBase.java @@ -44,6 +44,7 @@ import org.apache.bookkeeper.mledger.impl.ManagedLedgerImpl; import org.apache.pulsar.broker.PulsarService; import org.apache.pulsar.broker.ServiceConfiguration; +import org.apache.pulsar.broker.service.nonpersistent.NonPersistentTopic; import org.apache.pulsar.broker.service.persistent.GeoPersistentReplicator; import org.apache.pulsar.broker.service.persistent.PersistentReplicator; import org.apache.pulsar.broker.service.persistent.PersistentTopic; @@ -55,6 +56,7 @@ import org.apache.pulsar.client.api.Schema; import org.apache.pulsar.client.impl.ProducerImpl; import org.apache.pulsar.common.naming.SystemTopicNames; +import org.apache.pulsar.common.naming.TopicDomain; import org.apache.pulsar.common.naming.TopicName; import org.apache.pulsar.common.partition.PartitionedTopicMetadata; import org.apache.pulsar.common.policies.data.ClusterData; @@ -407,8 +409,13 @@ protected void waitReplicatorStarted(String topicName, PulsarService remoteClust Awaitility.await().untilAsserted(() -> { Optional topicOptional2 = remoteCluster.getBrokerService().getTopic(topicName, false).get(); assertTrue(topicOptional2.isPresent()); - PersistentTopic persistentTopic2 = (PersistentTopic) topicOptional2.get(); - assertFalse(persistentTopic2.getProducers().isEmpty()); + if (TopicName.get(topicName).getDomain().equals(TopicDomain.persistent)) { + PersistentTopic persistentTopic2 = (PersistentTopic) topicOptional2.get(); + assertFalse(persistentTopic2.getProducers().isEmpty()); + } else { + NonPersistentTopic nonPersistentTopic2 = (NonPersistentTopic) topicOptional2.get(); + assertFalse(nonPersistentTopic2.getProducers().isEmpty()); + } }); } diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorUsingGlobalPartitionedTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorUsingGlobalPartitionedTest.java index cec0f16187429..0d34479068b40 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorUsingGlobalPartitionedTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorUsingGlobalPartitionedTest.java @@ -23,6 +23,7 @@ import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; import static org.testng.Assert.assertTrue; +import static org.testng.Assert.fail; import java.time.Duration; import java.util.Arrays; import java.util.HashSet; @@ -84,6 +85,16 @@ public void testDeleteTopicWhenReplicating() throws Exception { super.testDeleteTopicWhenReplicating(); } + @Test(enabled = false) + public void testDisconnectAndReconnectReplicator(boolean binaryWayRepl, + boolean hasLocalProducerRegistered, + boolean localProducerHasTraffic, + boolean hasRemoteProducerTraffic, + boolean hasRemoteProducerRegistered) throws Exception { + super.testDisconnectAndReconnectReplicator(binaryWayRepl, hasLocalProducerRegistered, localProducerHasTraffic, + hasRemoteProducerTraffic, hasRemoteProducerRegistered); + } + @Override @Test(enabled = false) public void testReplicatorProducerStatInTopic() throws Exception { @@ -324,6 +335,17 @@ public void testRemoveCluster(String removeClusterLevel) throws Exception { "Remote cluster should have local policies: publish rate."); }); + CompletableFuture> future = pulsar1.getBrokerService().getTopic(topicP1, true); + if ("topic".equals(removeClusterLevel)) { + future.get(90, TimeUnit.SECONDS); + } else { + try { + future.get(90, TimeUnit.SECONDS); + fail("Should have thrown an exception since the __change_event topic can not be access anymore"); + } catch (Exception e) { + assertTrue(e.getMessage().contains("Namespace missing local cluster name")); + } + } // cleanup. if ("topic".equals(removeClusterLevel)) { admin1.namespaces().setNamespaceReplicationClusters(ns1, new HashSet<>(Arrays.asList(cluster2))); diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorUsingGlobalZKTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorUsingGlobalZKTest.java index 6c73f9548f068..63f199d59950f 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorUsingGlobalZKTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/OneWayReplicatorUsingGlobalZKTest.java @@ -54,6 +54,8 @@ import org.apache.pulsar.common.policies.data.AutoFailoverPolicyData; import org.apache.pulsar.common.policies.data.AutoFailoverPolicyType; import org.apache.pulsar.common.policies.data.AutoTopicCreationOverride; +import org.apache.pulsar.common.policies.data.InactiveTopicDeleteMode; +import org.apache.pulsar.common.policies.data.InactiveTopicPolicies; import org.apache.pulsar.common.policies.data.NamespaceIsolationData; import org.apache.pulsar.common.policies.data.RetentionPolicies; import org.apache.pulsar.common.policies.data.TopicPolicies; @@ -810,6 +812,82 @@ public void testSystemTopicCreationWithDifferentTopicCreationRule(int localSyste admin2.topics().delete(tp, false); } + @Test(enabled = false) + public void testDisconnectAndReconnectReplicator(boolean binaryWayRepl, + boolean hasLocalProducerRegistered, + boolean localProducerHasTraffic, + boolean hasRemoteProducerTraffic, + boolean hasRemoteProducerRegistered) throws Exception { + super.testDisconnectAndReconnectReplicator(binaryWayRepl, hasLocalProducerRegistered, localProducerHasTraffic, + hasRemoteProducerTraffic, hasRemoteProducerRegistered); + } + + @Test + public void testTopicGCDoesNotDisconnectReplicatorWhenRemoteProducerIsActive() throws Exception { + int replicationInactiveThresholdSeconds = pulsar1.getConfig().getBrokerReplicationInactiveThresholdSeconds(); + pulsar1.getConfig().setBrokerReplicationInactiveThresholdSeconds(3600); + final String topic = BrokerTestUtil.newUniqueName("persistent://" + replicatedNamespace + "/tp_"); + admin1.topics().createNonPartitionedTopic(topic); + Producer producer1 = client1.newProducer(Schema.STRING).topic(topic).create(); + + try { + producer1.send("msg-1"); + waitReplicatorStarted(topic, pulsar1); + waitReplicatorStarted(topic, pulsar2); + PersistentTopic persistentTopic2 = (PersistentTopic) broker2.getTopic(topic, false) + .join().get(); + + // Set inactive policies. + InactiveTopicPolicies inactiveTopicPolicies = new InactiveTopicPolicies(); + inactiveTopicPolicies.setInactiveTopicDeleteMode(InactiveTopicDeleteMode.delete_when_no_subscriptions); + inactiveTopicPolicies.setMaxInactiveDurationSeconds(10); + inactiveTopicPolicies.setDeleteWhileInactive(true); + admin2.topicPolicies().setInactiveTopicPolicies(topic, inactiveTopicPolicies); + + // Ensure policies were set successfully. + Awaitility.await().untilAsserted(() -> { + assertFalse(persistentTopic2.getProducers().values().stream() + .anyMatch(producer -> !producer.isRemote())); + assertTrue(persistentTopic2.getSubscriptions().isEmpty()); + assertTrue(persistentTopic2.getInactiveTopicPolicies().isDeleteWhileInactive()); + assertEquals(persistentTopic2.getInactiveTopicPolicies().getMaxInactiveDurationSeconds(), 10); + + Replicator replicator = persistentTopic2.getReplicators().get(cluster1); + assertNotNull(replicator); + assertTrue(replicator.isConnected()); + assertEquals(replicator.getNumberOfEntriesInBacklog(), 0); + }); + + // Trigger GC. + persistentTopic2.disconnectReplicatorsIfNoTrafficAndBacklog(); + persistentTopic2.checkGC(); + Thread.sleep(15 * 1000); + persistentTopic2.disconnectReplicatorsIfNoTrafficAndBacklog(); + persistentTopic2.checkGC(); + + // Verify: the replication is not disconnected due to Topic GC. + Replicator replicator = persistentTopic2.getReplicators().get(cluster1); + assertNotNull(replicator); + assertTrue(replicator.isConnected()); + + // Verify: the replication still works. + producer1.send("msg-2"); + Awaitility.await().untilAsserted(() -> { + assertEquals(admin2.topics().getStats(topic).getReplication().get(cluster1).getReplicationBacklog(), 0); + }); + + } finally { + pulsar1.getConfig().setBrokerReplicationInactiveThresholdSeconds(replicationInactiveThresholdSeconds); + producer1.close(); + admin1.topics().setReplicationClusters(topic, Arrays.asList(cluster1)); + admin2.topics().setReplicationClusters(topic, Arrays.asList(cluster2)); + waitReplicatorStopped(topic, pulsar1, pulsar2, false); + waitReplicatorStopped(topic, pulsar2, pulsar1, false); + admin1.topics().delete(topic, false); + admin2.topics().delete(topic, false); + } + } + @Test public void testUpdateNamespacePolicies() throws Exception { // Create a namespace and allow both clusters to access. diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/PersistentMessageFinderTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/PersistentMessageFinderTest.java index cafa551ce242a..942a58da5fd95 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/PersistentMessageFinderTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/PersistentMessageFinderTest.java @@ -19,6 +19,7 @@ package org.apache.pulsar.broker.service; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; @@ -36,11 +37,14 @@ import io.netty.buffer.UnpooledByteBufAllocator; import java.lang.reflect.Field; import java.util.ArrayList; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -1088,4 +1092,58 @@ public void testGetFindPositionRange_SingleClosedLedger() { assertNull(range.getRight()); assertEquals(range.getLeft(), PositionFactory.create(1, 9)); } + + @Test + @SuppressWarnings("unchecked") + void testExpireMessagesNeverLoseMarkDeleteProperties() throws Exception { + final String ledgerAndCursorName = "testExpireMessagesNeverLoseMarkDeleteProperties"; + + ManagedLedgerConfig config = new ManagedLedgerConfig(); + config.setRetentionSizeInMB(10); + config.setRetentionTime(1, TimeUnit.HOURS); + ManagedLedgerImpl ledger = (ManagedLedgerImpl) factory.open(ledgerAndCursorName, config); + ManagedCursorImpl cursor = (ManagedCursorImpl) ledger.openCursor(ledgerAndCursorName); + ManagedCursorImpl spyCursor = spy(cursor); + + Position pos1 = ledger.addEntry(createMessageWrittenToLedger("msg-1")); + Position pos2 = ledger.addEntry(createMessageWrittenToLedger("msg-2")); + + CountDownLatch expiryMarkDeleteEnteredLatch = new CountDownLatch(1); + CountDownLatch cursorMarkDeleteCompletedLatch = new CountDownLatch(1); + CountDownLatch expiryMarkDeleteCompletedLatch = new CountDownLatch(1); + + doAnswer(invocation -> { + Map invocationProperties = invocation.getArgument(1); + // Pause the expiry-triggered mark-delete so the user markDelete() can complete first. + if (invocationProperties == null || invocationProperties.isEmpty()) { + expiryMarkDeleteEnteredLatch.countDown(); + assertTrue(cursorMarkDeleteCompletedLatch.await(5, TimeUnit.SECONDS)); + try { + return invocation.callRealMethod(); + } finally { + expiryMarkDeleteCompletedLatch.countDown(); + } + } + + return invocation.callRealMethod(); + }).when(spyCursor) + .asyncMarkDelete(any(Position.class), nullable(Map.class), any(AsyncCallbacks.MarkDeleteCallback.class), + nullable(Object.class)); + + PersistentTopic topic = mockPersistentTopic("topicname"); + PersistentMessageExpiryMonitor monitor = new PersistentMessageExpiryMonitor(topic, + spyCursor.getName(), spyCursor, null); + + CompletableFuture.runAsync(() -> monitor.findEntryComplete(pos2, null)); + assertTrue(expiryMarkDeleteEnteredLatch.await(5, TimeUnit.SECONDS)); + + Map properties = new HashMap<>(); + properties.put("test-property", 1L); + spyCursor.markDelete(pos1, properties); + cursorMarkDeleteCompletedLatch.countDown(); + + assertTrue(expiryMarkDeleteCompletedLatch.await(5, TimeUnit.SECONDS)); + assertEquals(spyCursor.getMarkDeletedPosition(), pos2); + assertEquals(spyCursor.getProperties(), properties); + } } diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/PersistentTopicInitializeDelayTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/PersistentTopicInitializeDelayTest.java index f528032299aee..7968fd45cdbb4 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/PersistentTopicInitializeDelayTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/PersistentTopicInitializeDelayTest.java @@ -72,10 +72,12 @@ public void testTopicInitializeDelay() throws Exception { admin.topicPolicies().setMaxConsumers(topicName, 10); Awaitility.await().untilAsserted(() -> assertEquals(admin.topicPolicies().getMaxConsumers(topicName), 10)); admin.topics().unload(topicName); + MyPersistentTopic.checkReplicationInvocationCount.set(0); CompletableFuture> optionalFuture = pulsar.getBrokerService().getTopic(topicName, true); Optional topic = optionalFuture.get(15, TimeUnit.SECONDS); assertTrue(topic.isPresent()); + assertEquals(MyPersistentTopic.checkReplicationInvocationCount.get(), 1); } public static class MyTopicFactory implements TopicFactory { diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/PersistentTopicTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/PersistentTopicTest.java index 19ccc9d4ec533..7b1ec8b8ac09d 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/PersistentTopicTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/PersistentTopicTest.java @@ -380,7 +380,7 @@ public void testDispatcherSingleConsumerReadFailed() { PersistentDispatcherSingleActiveConsumer dispatcher = new PersistentDispatcherSingleActiveConsumer(cursor, SubType.Exclusive, 1, topic, null); Consumer consumer = mock(Consumer.class); - dispatcher.readEntriesFailed(new ManagedLedgerException.InvalidCursorPositionException("failed"), consumer); + dispatcher.readEntriesFailed(new ManagedLedgerException.InvalidCursorPositionException("failed"), consumer, 0L); verify(topic, atLeast(1)).getBrokerService(); } @@ -1664,17 +1664,19 @@ public void testFailoverSubscription() throws Exception { } /** - * NonPersistentReplicator.removeReplicator doesn't remove replicator in atomic way and does in multiple step: - * 1. disconnect replicator producer + * PersistentReplicator.removeReplicator doesn't remove replicator in atomic way and does in multiple step: + * 1. Turn off replication. *

- * 2. close cursor + * 2. Broker will do two things: + * 2-1. terminate replication. + * 2-2. delete cursor that named "repl.x" *

- * 3. remove from replicator-list. + * 3. remove the terminated replicator from "topic.replicators". *

* - * If we try to startReplicationProducer before step-c finish then it should not avoid restarting repl-producer. - * - * @throws Exception + * Test: + * do: try to restart replicator producer before step-2-2 finish. + * verify: the replicator producer will not be started. */ @Test public void testAtomicReplicationRemoval() throws Exception { @@ -1724,9 +1726,14 @@ public CompletableFuture createAsync() { // step-2 now, policies doesn't have removed replication cluster so, it should not invoke "startProducer" of the // replicator // try to start replicator again - topic.startReplProducers().join(); + Awaitility.await().untilAsserted(() -> { + assertEquals(replicator.getState(), AbstractReplicator.State.Terminated); + }); + replicator.startProducer(); + Thread.sleep(10_000); // verify: replicator.startProducer is not invoked - verify(replicator, Mockito.times(1)).startProducer(); + assertEquals(replicator.getState(), AbstractReplicator.State.Terminated); + assertFalse(replicator.isConnected()); // step-3 : complete the callback to remove replicator from the list ArgumentCaptor captor = ArgumentCaptor.forClass(DeleteCursorCallback.class); @@ -2025,6 +2032,7 @@ public void addFailed(ManagedLedgerException exception, Object ctx) { @Test public void testCheckInactiveSubscriptions() throws Exception { + pulsarTestContext.getConfig().setAdditionalSystemCursorNames(Set.of("additionalSystemCursor")); PersistentTopic topic = new PersistentTopic(successTopicName, ledgerMock, brokerService); final var subscriptions = new ConcurrentHashMap(); @@ -2043,6 +2051,11 @@ public void testCheckInactiveSubscriptions() throws Exception { spyWithClassAndConstructorArgsRecordingInvocations(PersistentSubscription.class, topic, "nonDeletableSubscription2", cursorMock, true); subscriptions.put(nonDeletableSubscription2.getName(), nonDeletableSubscription2); + // This subscription is an additional system cursor. + PersistentSubscription nonDeletableSubscription3 = + spyWithClassAndConstructorArgsRecordingInvocations(PersistentSubscription.class, topic, + "additionalSystemCursor", cursorMock, false); + subscriptions.put(nonDeletableSubscription3.getName(), nonDeletableSubscription3); Field field = topic.getClass().getDeclaredField("subscriptions"); field.setAccessible(true); @@ -2069,6 +2082,7 @@ public void testCheckInactiveSubscriptions() throws Exception { verify(nonDeletableSubscription1, times(0)).delete(); verify(deletableSubscription1, times(1)).delete(); verify(nonDeletableSubscription2, times(0)).delete(); + verify(nonDeletableSubscription3, times(0)).delete(); } @Test diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/ReplicationTopicGcTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/ReplicationTopicGcTest.java index c2b6d4281a9b0..c72d2bcef53d0 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/ReplicationTopicGcTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/ReplicationTopicGcTest.java @@ -28,6 +28,8 @@ import org.apache.commons.collections4.CollectionUtils; import org.apache.pulsar.broker.BrokerTestUtil; import org.apache.pulsar.broker.ServiceConfiguration; +import org.apache.pulsar.broker.service.persistent.GeoPersistentReplicator; +import org.apache.pulsar.broker.service.persistent.PersistentTopic; import org.apache.pulsar.client.api.Message; import org.apache.pulsar.client.api.MessageId; import org.apache.pulsar.client.api.Producer; @@ -77,6 +79,7 @@ protected void setConfigDefaults(ServiceConfiguration config, String clusterName config.setBrokerDeleteInactiveTopicsFrequencySeconds(5); config.setBrokerDeleteInactiveTopicsMaxInactiveDurationSeconds(5); config.setReplicationPolicyCheckDurationSeconds(1); + config.setBrokerReplicationInactiveThresholdSeconds(5); } @Test(dataProvider = "topicTypes") @@ -144,6 +147,7 @@ public void testRemoteClusterStillConsumeAfterCurrentClusterGc(TopicType topicTy // Wait for replicator started. Producer producer1 = client1.newProducer(Schema.STRING).topic(topicName).create(); waitReplicatorStarted(subTopic); + PersistentTopic persistentTopic1 = (PersistentTopic) broker1.getTopic(subTopic, false).get().get(); admin2.topics().createSubscription(topicName, subscription, MessageId.earliest); if (usingGlobalZK) { @@ -159,6 +163,10 @@ public void testRemoteClusterStillConsumeAfterCurrentClusterGc(TopicType topicTy // Trigger GC through close all clients. producer1.close(); + // Manually skip the check "brokerReplicationInactiveThresholdSeconds". + GeoPersistentReplicator geoPersistentReplicator1 = + (GeoPersistentReplicator) persistentTopic1.getReplicators().get(cluster2); + geoPersistentReplicator1.disconnect(); // Verify: the topic was removed on the source cluster. Awaitility.await().atMost(60, TimeUnit.SECONDS).untilAsserted(() -> { // sub topic. diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/ReplicatorTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/ReplicatorTest.java index 0426bc3656aec..0b19e948125ed 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/ReplicatorTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/ReplicatorTest.java @@ -71,7 +71,9 @@ import org.apache.pulsar.broker.PulsarService; import org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest; import org.apache.pulsar.broker.service.BrokerServiceException.NotAllowedException; +import org.apache.pulsar.broker.service.persistent.GeoPersistentReplicator; import org.apache.pulsar.broker.service.persistent.PersistentReplicator; +import org.apache.pulsar.broker.service.persistent.PersistentReplicatorInflightTaskTest; import org.apache.pulsar.broker.service.persistent.PersistentTopic; import org.apache.pulsar.broker.stats.OpenTelemetryReplicatorStats; import org.apache.pulsar.client.admin.PulsarAdmin; @@ -1827,21 +1829,26 @@ public void testReplicatorWithTTL() throws Exception { waitReplicateFinish(topic, admin1); // Pause replicator + List resumeReplicatorFunctions = new ArrayList<>(); persistentTopic.getReplicators().forEach((cluster, replicator) -> { PersistentReplicator persistentReplicator = (PersistentReplicator) replicator; - pauseReplicator(persistentReplicator); + resumeReplicatorFunctions.add(PersistentReplicatorInflightTaskTest.pauseReplicator(persistentReplicator)); }); // Send V2 and V3 messages, then let them expire. These messages will not be replicated to the remote cluster. persistentProducer1.send("V2".getBytes()); persistentProducer1.send("V3".getBytes()); - Thread.sleep(1000); - admin1.topics().expireMessagesForAllSubscriptions(topic.toString(), 1); + Thread.sleep(2000); + GeoPersistentReplicator persistentReplicator = + (GeoPersistentReplicator) persistentTopic.getReplicators().values().iterator().next(); + persistentReplicator.expireMessages(1); + waitReplicateFinish(topic, admin1); // Start replicator + for (Runnable r : resumeReplicatorFunctions) { + r.run(); + } persistentTopic.getReplicators().forEach((cluster, replicator) -> { - PersistentReplicator persistentReplicator = (PersistentReplicator) replicator; - resumeReplicator(persistentReplicator); Awaitility.await().untilAsserted(() -> { CompletableFuture> topic2 = pulsar2.getBrokerService().getTopic(topic.toString(), false); @@ -1918,7 +1925,7 @@ public void testReplicationMetrics() throws Exception { .stream() .map(PersistentReplicator.class::cast) .toList(); - persistentReplicators.forEach(this::pauseReplicator); + persistentReplicators.forEach(PersistentReplicatorInflightTaskTest::pauseReplicator); producer1.produce(5); Awaitility.await().untilAsserted(() -> { persistentReplicators.forEach(repl -> repl.expireMessages(1)); @@ -1998,18 +2005,4 @@ public void testEnableReplicationWithNamespaceAllowedClustersPolices() throws Ex assertTrue(replicator.isConnected()); }); } - - private void pauseReplicator(PersistentReplicator replicator) { - Awaitility.await().untilAsserted(() -> { - assertTrue(replicator.isConnected()); - }); - Awaitility.await().until(() -> { - replicator.disconnect().join(); - return true; - }); - } - - private void resumeReplicator(PersistentReplicator replicator) { - replicator.startProducer(); - } } diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/SystemTopicBasedTopicPoliciesServiceTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/SystemTopicBasedTopicPoliciesServiceTest.java index ad02b4707a3bb..03081d6d58e7a 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/SystemTopicBasedTopicPoliciesServiceTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/SystemTopicBasedTopicPoliciesServiceTest.java @@ -28,6 +28,7 @@ import static org.testng.AssertJUnit.assertFalse; import static org.testng.AssertJUnit.assertNotNull; import static org.testng.AssertJUnit.assertNull; +import static org.testng.AssertJUnit.assertSame; import static org.testng.AssertJUnit.assertTrue; import java.time.Duration; import java.util.HashSet; @@ -38,23 +39,31 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import lombok.Cleanup; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.reflect.FieldUtils; +import org.apache.pulsar.broker.ServiceConfiguration; import org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest; import org.apache.pulsar.broker.systopic.SystemTopicClient; import org.apache.pulsar.client.admin.PulsarAdminException; +import org.apache.pulsar.client.api.Message; +import org.apache.pulsar.client.api.PulsarClientException; import org.apache.pulsar.client.api.Schema; import org.apache.pulsar.common.events.PulsarEvent; import org.apache.pulsar.common.naming.NamespaceName; +import org.apache.pulsar.common.naming.SystemTopicNames; import org.apache.pulsar.common.naming.TopicName; import org.apache.pulsar.common.policies.data.ClusterData; import org.apache.pulsar.common.policies.data.TenantInfoImpl; import org.apache.pulsar.common.policies.data.TopicPolicies; import org.apache.pulsar.utils.TestLogAppender; +import org.assertj.core.api.Assertions; import org.awaitility.Awaitility; import org.mockito.Mockito; import org.testng.Assert; @@ -110,7 +119,7 @@ public void onUpdate(TopicPolicies data) { CompletableFuture f = CompletableFuture.completedFuture(null).thenRunAsync(() -> { for (int i = 0; i < 100; i++) { TopicPolicyListener listener = new TopicPolicyListenerImpl(); - systemTopicBasedTopicPoliciesService.registerListener(topicName, listener); + systemTopicBasedTopicPoliciesService.registerListenerAsync(topicName, listener); Assert.assertNotNull(systemTopicBasedTopicPoliciesService.listeners.get(topicName)); Assert.assertTrue(systemTopicBasedTopicPoliciesService.listeners.get(topicName).size() >= 1); systemTopicBasedTopicPoliciesService.unregisterListener(topicName, listener); @@ -119,7 +128,7 @@ public void onUpdate(TopicPolicies data) { for (int i = 0; i < 100; i++) { TopicPolicyListener listener = new TopicPolicyListenerImpl(); - systemTopicBasedTopicPoliciesService.registerListener(topicName, listener); + systemTopicBasedTopicPoliciesService.registerListenerAsync(topicName, listener); Assert.assertNotNull(systemTopicBasedTopicPoliciesService.listeners.get(topicName)); Assert.assertTrue(systemTopicBasedTopicPoliciesService.listeners.get(topicName).size() >= 1); systemTopicBasedTopicPoliciesService.unregisterListener(topicName, listener); @@ -130,6 +139,36 @@ public void onUpdate(TopicPolicies data) { Assert.assertFalse(systemTopicBasedTopicPoliciesService.listeners.containsKey(topicName)); } + @Test + public void testListenerNotificationRunsOffSharedReaderThread() throws Exception { + // Regression test for #26037: topic-policy listener callbacks must not run on the single, + // process-wide shared "broker-client-shared-internal-executor" reader thread. A slow or blocking + // onUpdate there serializes — and can stall — topic-policy loading for every namespace. The + // notification must instead be dispatched to the per-topic ordered executor ("broker-topic-workers"). + + // Initialize the policy cache and start the change-events reader for the namespace. + systemTopicBasedTopicPoliciesService.updateTopicPoliciesAsync(TOPIC1, false, false, topicPolicies -> + topicPolicies.setMaxConsumerPerTopic(10)).get(); + Awaitility.await().untilAsserted(() -> Assert.assertTrue(systemTopicBasedTopicPoliciesService + .getPoliciesCacheInit(TOPIC1.getNamespaceObject()).isDone())); + + // Register a listener that records the thread its onUpdate runs on. + CompletableFuture onUpdateThreadName = new CompletableFuture<>(); + TopicPolicyListener listener = data -> onUpdateThreadName.complete(Thread.currentThread().getName()); + systemTopicBasedTopicPoliciesService.registerListenerAsync(TOPIC1, listener).get(); + + // A live policy update flows through readMorePoliciesAsync -> notifyListener -> listener.onUpdate. + systemTopicBasedTopicPoliciesService.updateTopicPoliciesAsync(TOPIC1, false, false, topicPolicies -> + topicPolicies.setMaxConsumerPerTopic(20)).get(); + + String threadName = onUpdateThreadName.get(30, TimeUnit.SECONDS); + assertFalse("listener.onUpdate must not run on the shared broker-client reader thread, but ran on: " + + threadName, + threadName.contains("broker-client-shared-internal-executor")); + assertTrue("listener.onUpdate should run on the per-topic ordered executor, but ran on: " + threadName, + threadName.contains("broker-topic-workers")); + } + @Test public void testGetPolicy() throws Exception { @@ -525,14 +564,28 @@ public void testPrepareInitPoliciesCacheAsyncThrowExceptionAfterCreateReader() t assertTrue(logFound); }); - - // Since cleanPoliciesCacheInitMap() is executed, should add the failed reader into readerCache again. - // Then in SystemTopicBasedTopicPoliciesService, readerCache has a closed reader, - // and policyCacheInitMap do not contain a future. - // To simulate the situation: when getTopicPolicy() execute, it will do prepareInitPoliciesCacheAsync() and - // use a closed reader to read the __change_event topic. Then throw exception - spyReaderCaches.put(NamespaceName.get(NAMESPACE5), readerCompletableFuture); + // The reader.close() above drives readMorePoliciesAsync() into cleanPoliciesCacheInitMap(), which logs + // "Closing the topic policies reader for" and removes the namespace from policyCacheInitMap and readerCaches. + // Now exercise a follow-up prepareInitPoliciesCacheAsync() that reuses a closed reader and must fail in + // initPolicesCache(). Two timing hazards made this flaky (#25081), so both are pinned deterministically: + // 1) A real closed reader's hasMoreEventsAsync() (Reader.hasMessageAvailableAsync()) can answer from cached + // state instead of failing with AlreadyClosedException, in which case initPolicesCache() reaches the end + // of the topic and completes successfully and the expected exception never happens. Use a spy whose + // hasMoreEventsAsync() always fails with AlreadyClosedException. + // 2) A background topic load can re-run prepareInitPoliciesCacheAsync() for the namespace and leave a + // completed init future behind; the next call would then short-circuit through the existing-future + // branch and never re-initialize. Drop any init future right before the call so it re-initializes, and + // stub createSystemTopicClient() so every reader created for this namespace is the failing spy — then + // whichever initialization wins the race fails in the same (asserted) way. + SystemTopicClient.Reader closedReader = Mockito.spy(reader); + Mockito.doReturn(CompletableFuture.failedFuture( + new PulsarClientException.AlreadyClosedException("Reader is already closed"))) + .when(closedReader).hasMoreEventsAsync(); + Mockito.doReturn(CompletableFuture.completedFuture(closedReader)) + .when(spyService).createSystemTopicClient(NamespaceName.get(NAMESPACE5)); + spyReaderCaches.put(NamespaceName.get(NAMESPACE5), CompletableFuture.completedFuture(closedReader)); FieldUtils.writeDeclaredField(spyService, "readerCaches", spyReaderCaches, true); + spyService.policyCacheInitMap.remove(NamespaceName.get(NAMESPACE5)); CompletableFuture prepareFuture = new CompletableFuture<>(); try { @@ -554,17 +607,20 @@ public void testPrepareInitPoliciesCacheAsyncThrowExceptionAfterCreateReader() t Assert.assertNull(readerCompletableFuture1); }); - - // make sure not do cleanPoliciesCacheInitMap() twice - // totally trigger prepareInitPoliciesCacheAsync() twice, so the time of cleanPoliciesCacheInitMap() is 2. - // in previous code, the time would be 3 + // Cleanup must run exactly once per trigger and not repeat recursively (in older code it ran 3 times). + // Two failures are triggered here, and both tear down through the identity-guarded cleanupFailedPolicyCacheInit + // (2x): the reader.close() above drives readMorePoliciesAsync's AlreadyClosed branch into it, and the second + // prepareInitPoliciesCacheAsync fails in initPolicesCache and is torn down by it as well. The namespace-keyed + // cleanPoliciesCacheInitMap must not be reached from the reader-close path, otherwise a superseded reader could + // clobber a newer generation's init future. boolean logFound = testLogAppender.getEvents().stream().anyMatch(logEvent -> logEvent.getMessage().toString().contains("Failed to create reader on __change_events topic")); assertFalse(logFound); boolean logFound2 = testLogAppender.getEvents().stream().anyMatch(logEvent -> logEvent.getMessage().toString().contains("Failed to check the move events for the system topic")); assertTrue(logFound2); - verify(spyService, times(2)).cleanPoliciesCacheInitMap(any(), anyBoolean()); + verify(spyService, times(0)).cleanPoliciesCacheInitMap(any()); + verify(spyService, times(2)).cleanupFailedPolicyCacheInit(any(), any(), anyBoolean()); // make sure not occur Recursive update boolean logFound3 = testLogAppender.getEvents().stream().anyMatch(logEvent -> @@ -623,9 +679,9 @@ public void testPrepareInitPoliciesCacheAsyncThrowExceptionInCreateReader() thro Assert.assertNull(readerCompletableFuture1); }); - - // make sure not do cleanPoliciesCacheInitMap() twice - // totally trigger prepareInitPoliciesCacheAsync() once, so the time of cleanPoliciesCacheInitMap() is 1. + // Reader creation fails, so the single cleanup runs once via the identity-guarded + // cleanupFailedPolicyCacheInit (the reader-creation-failure branch no longer goes through + // cleanPoliciesCacheInitMap), and must not run more than once. boolean logFound = testLogAppender.getEvents().stream().anyMatch(logEvent -> logEvent.getMessage().toString().contains("Failed to create reader on __change_events topic")); assertTrue(logFound); @@ -633,6 +689,221 @@ public void testPrepareInitPoliciesCacheAsyncThrowExceptionInCreateReader() thro logEvent.getMessage().toString().contains("Failed to check the move events for the system topic") || logEvent.getMessage().toString().contains("Failed to read event from the system topic")); assertFalse(logFound2); - verify(spyService, times(1)).cleanPoliciesCacheInitMap(any(), anyBoolean()); + verify(spyService, times(1)).cleanupFailedPolicyCacheInit(any(), any(), anyBoolean()); + verify(spyService, times(0)).cleanPoliciesCacheInitMap(any()); + } + + @Test(timeOut = 60_000) + public void testPrepareInitPoliciesCacheAsyncTimesOutWhenReaderStuck() throws Exception { + // Bound the policy-cache initialization to a short timeout for the test. + pulsar.getConfiguration().setTopicPoliciesCacheInitTimeoutSeconds(3); + + pulsar.getTopicPoliciesService().close(); + SystemTopicBasedTopicPoliciesService spyService = + Mockito.spy(new SystemTopicBasedTopicPoliciesService(pulsar)); + FieldUtils.writeField(pulsar, "topicPoliciesService", spyService, true); + + admin.namespaces().createNamespace(NAMESPACE5); + final NamespaceName namespace = NamespaceName.get(NAMESPACE5); + + // Create a real __change_events reader, then spy it so that it reports more events but never delivers one — + // i.e. a reader that reconnected but is stuck (issue #25294). initPolicesCache would otherwise never complete. + SystemTopicClient.Reader stuckReader = Mockito.spy(spyService.createSystemTopicClient(namespace) + .get(30, TimeUnit.SECONDS)); + Mockito.doReturn(CompletableFuture.completedFuture(true)).when(stuckReader).hasMoreEventsAsync(); + Mockito.doReturn(new CompletableFuture>()).when(stuckReader).readNextAsync(); + Mockito.doReturn(CompletableFuture.completedFuture(stuckReader)) + .when(spyService).createSystemTopicClient(namespace); + + // Without the timeout the returned future never completes and topic loading for the namespace hangs forever. + CompletableFuture prepareFuture = spyService.prepareInitPoliciesCacheAsync(namespace); + try { + prepareFuture.get(15, TimeUnit.SECONDS); + Assert.fail("Expected the topic policies cache initialization to time out"); + } catch (ExecutionException e) { + assertTrue("Expected a TimeoutException cause but got " + e.getCause(), + e.getCause() instanceof TimeoutException); + } + + // The poisoned cache entry must be cleared and the stuck reader closed so a subsequent load can retry with a + // fresh reader instead of being pinned until the broker restarts. + Awaitility.await().untilAsserted(() -> assertNull(spyService.getPoliciesCacheInit(namespace))); + Mockito.verify(stuckReader, Mockito.atLeastOnce()).closeAsync(); + } + + @Test + public void testCleanPoliciesCacheInitMapCompletesPendingInitFuture() { + SystemTopicBasedTopicPoliciesService service = + (SystemTopicBasedTopicPoliciesService) pulsar.getTopicPoliciesService(); + final NamespaceName namespace = NamespaceName.get(NAMESPACE1); + + // Dropping the cached init future (e.g. on a namespace-bundle unload) must complete it so the topic loads + // awaiting it fail fast and retry, instead of hanging until the broker restarts (issue #25294). + CompletableFuture pendingInitFuture = new CompletableFuture<>(); + service.policyCacheInitMap.put(namespace, pendingInitFuture); + service.cleanPoliciesCacheInitMap(namespace); + assertTrue(pendingInitFuture.isCompletedExceptionally()); + assertNull(service.getPoliciesCacheInit(namespace)); + + // An already-completed init future must not be overwritten/disturbed. + CompletableFuture alreadyDone = CompletableFuture.completedFuture(null); + service.policyCacheInitMap.put(namespace, alreadyDone); + service.cleanPoliciesCacheInitMap(namespace); + assertFalse(alreadyDone.isCompletedExceptionally()); + } + + @Test + @SuppressWarnings("unchecked") + public void testCleanupFailedPolicyCacheInitIsIdentityGuarded() { + SystemTopicBasedTopicPoliciesService service = + (SystemTopicBasedTopicPoliciesService) pulsar.getTopicPoliciesService(); + final NamespaceName namespace = NamespaceName.get(NAMESPACE1); + + // A newer init attempt (B) already owns the namespace with a fresh future and reader. + CompletableFuture newerInitFuture = new CompletableFuture<>(); + SystemTopicClient.Reader newerReader = Mockito.mock(SystemTopicClient.Reader.class); + Mockito.doReturn(CompletableFuture.completedFuture(null)).when(newerReader).closeAsync(); + service.policyCacheInitMap.put(namespace, newerInitFuture); + service.getReaderCaches().put(namespace, CompletableFuture.completedFuture(newerReader)); + + // A stale init attempt (A) whose reader was already torn down (e.g. by the timeout cleanup) fires its failure + // callback late. Cleaning it up by identity must be a no-op: it must not clobber B's future or close B's reader + // (issue #25294 follow-up). + CompletableFuture staleInitFuture = new CompletableFuture<>(); + service.cleanupFailedPolicyCacheInit(namespace, staleInitFuture, true); + assertSame(newerInitFuture, service.getPoliciesCacheInit(namespace)); + assertFalse(newerInitFuture.isDone()); + assertNotNull(service.getReaderCaches().get(namespace)); + Mockito.verify(newerReader, Mockito.never()).closeAsync(); + + // Cleaning up the owning attempt does drop its future and close its reader. + service.cleanupFailedPolicyCacheInit(namespace, newerInitFuture, true); + assertNull(service.getPoliciesCacheInit(namespace)); + assertTrue(newerInitFuture.isCompletedExceptionally()); + assertNull(service.getReaderCaches().get(namespace)); + Mockito.verify(newerReader, Mockito.times(1)).closeAsync(); + } + + @Test + @SuppressWarnings("unchecked") + public void testClosedSupersededReaderDoesNotAbortReloadedInit() throws Exception { + // Reproduces the race behind the flaky AdminApi2Test.testGetInternalStatsWithProperties: a namespace unload + // closes the __change_events reader while a reload (e.g. getTopic right after unload) installs a fresh reader + // and init future for the same namespace. The old reader's close only surfaces later, on the pulsar-client + // executor, as an AlreadyClosedException in readMorePoliciesAsync. That late cleanup must NOT clobber the newer + // generation and abort its init future ("...aborted because the cached state was cleared"), which would fail + // the reloading topic load. + @Cleanup + TestLogAppender testLogAppender = TestLogAppender.create(log); + + pulsar.getTopicPoliciesService().close(); + SystemTopicBasedTopicPoliciesService spyService = + Mockito.spy(new SystemTopicBasedTopicPoliciesService(pulsar)); + FieldUtils.writeField(pulsar, "topicPoliciesService", spyService, true); + + final NamespaceName namespace = NamespaceName.get(NAMESPACE5); + admin.namespaces().createNamespace(NAMESPACE5); + + // A real reader, spied so its background read loop is fully controllable: it reports "no more events" so the + // initialization completes and readMorePoliciesAsync starts, then parks on a read future we complete by hand. + SystemTopicClient.Reader oldReader = + Mockito.spy(spyService.createSystemTopicClient(namespace).get(30, TimeUnit.SECONDS)); + CompletableFuture> parkedRead = new CompletableFuture<>(); + Mockito.doReturn(CompletableFuture.completedFuture(false)).when(oldReader).hasMoreEventsAsync(); + Mockito.doReturn(parkedRead).when(oldReader).readNextAsync(); + Mockito.doReturn(CompletableFuture.completedFuture(oldReader)) + .when(spyService).createSystemTopicClient(namespace); + spyService.getReaderCaches().put(namespace, CompletableFuture.completedFuture(oldReader)); + + // Drive initialization: readMorePoliciesAsync(oldReader, ) is now looping, parked on + // parkedRead, having registered its whenComplete callback. + assertTrue(spyService.prepareInitPoliciesCacheAsync(namespace).get(30, TimeUnit.SECONDS)); + Mockito.verify(oldReader, Mockito.atLeastOnce()).readNextAsync(); + + // Simulate the concurrent unload+reload having already replaced the generation: a fresh reader and a fresh, + // still-pending init future that a reloading topic is awaiting. + SystemTopicClient.Reader reloadReader = Mockito.mock(SystemTopicClient.Reader.class); + Mockito.doReturn(CompletableFuture.completedFuture(null)).when(reloadReader).closeAsync(); + CompletableFuture> reloadReaderFuture = + CompletableFuture.completedFuture(reloadReader); + CompletableFuture reloadInitFuture = new CompletableFuture<>(); + spyService.getReaderCaches().put(namespace, reloadReaderFuture); + spyService.policyCacheInitMap.put(namespace, reloadInitFuture); + + // The old reader finally observes it was closed; this runs readMorePoliciesAsync's AlreadyClosed cleanup + // synchronously on this thread. + parkedRead.completeExceptionally(new PulsarClientException.AlreadyClosedException("reader is already closed")); + + // The cleanup ran (it logged), but being identity-guarded on the init future it left the newer generation + // untouched. Before the fix it cleared readerCaches/policyCacheInitMap by namespace key and aborted the reload. + assertTrue(testLogAppender.getEvents().stream().anyMatch(e -> + e.getMessage().toString().contains("Closing the topic policies reader for"))); + assertFalse("the reload's init future must not be aborted by the superseded reader's late close", + reloadInitFuture.isCompletedExceptionally()); + assertFalse(reloadInitFuture.isDone()); + assertSame("the reload's reader must remain cached", reloadReaderFuture, + spyService.getReaderCaches().get(namespace)); + assertSame(reloadInitFuture, spyService.getPoliciesCacheInit(namespace)); + Mockito.verify(reloadReader, Mockito.never()).closeAsync(); + } + + @Test + public void testReplayTopicPolicyListenersNotifiesOnlyNamespaceScopedLocalAndGlobalPolicies() throws Exception { + SystemTopicBasedTopicPoliciesService service = + (SystemTopicBasedTopicPoliciesService) pulsar.getTopicPoliciesService(); + final NamespaceName namespaceA = NamespaceName.get(NAMESPACE1); + final NamespaceName namespaceB = NamespaceName.get(NAMESPACE2); + final TopicName localTopicA = TopicName.get("persistent", namespaceA, "replay-local-a"); + final TopicName globalTopicA = TopicName.get("persistent", namespaceA, "replay-global-a"); + final TopicName topicB = TopicName.get("persistent", namespaceB, "replay-b"); + + // Seed the caches: namespace A has one topic with a cached local policy and another with a cached global + // policy; namespace B has a topic with a cached local policy that must not be replayed for namespace A. + service.policiesCache.put(localTopicA, TopicPolicies.builder().isGlobal(false).build()); + service.globalPoliciesCache.put(globalTopicA, TopicPolicies.builder().isGlobal(true).build()); + service.policiesCache.put(topicB, TopicPolicies.builder().isGlobal(false).build()); + + final Map> received = new ConcurrentHashMap<>(); + for (TopicName topicName : List.of(localTopicA, globalTopicA, topicB)) { + final List updates = new CopyOnWriteArrayList<>(); + received.put(topicName, updates); + service.registerListenerAsync(topicName, updates::add).get(); + } + + service.replayTopicPolicyListeners(namespaceA).get(30, TimeUnit.SECONDS); + + // Only namespace A's topics are notified, once each, and both the local and the global cache are replayed. + Assertions.assertThat(received.get(localTopicA)).hasSize(1); + Assertions.assertThat(received.get(globalTopicA)).hasSize(1); + // Namespace B is left untouched. The pre-fix code iterated the whole cache and replayed every namespace. + Assertions.assertThat(received.get(topicB)).isEmpty(); + } + + @Test + public void testTopicPolicyListenerReplayDisabledByDefault() { + Assertions.assertThat(new ServiceConfiguration().isTopicPolicyListenerReplayEnabled()).isFalse(); + } + + @Test + public void testChangeEventsTopicPolicyLoadDoesNotRecurse() throws Exception { + SystemTopicBasedTopicPoliciesService service = + (SystemTopicBasedTopicPoliciesService) pulsar.getTopicPoliciesService(); + final String namespaceStr = "system-topic/change-events-recursion"; + admin.namespaces().createNamespace(namespaceStr); + final NamespaceName namespace = NamespaceName.get(namespaceStr); + final TopicName changeEvents = + TopicName.get("persistent", namespace, SystemTopicNames.NAMESPACE_EVENTS_LOCAL_NAME); + + // The __change_events system topic must not load topic-level policies: that would create a policy-cache + // reader on __change_events while __change_events is still loading -- a recursive, deadlocking dependency. + // isSelf() guards getTopicPoliciesAsync so it returns empty for the __change_events topic without ever + // creating a reader. AbstractTopic#initTopicPolicy (now called for persistent AND non-persistent topics) + // relies on this short-circuit when a __change_events topic itself is loaded. + Assertions.assertThat(service.getTopicPoliciesAsync(changeEvents, TopicPoliciesService.GetType.LOCAL_ONLY) + .get(30, TimeUnit.SECONDS)).isEmpty(); + Assertions.assertThat(service.getTopicPoliciesAsync(changeEvents, TopicPoliciesService.GetType.GLOBAL_ONLY) + .get(30, TimeUnit.SECONDS)).isEmpty(); + // No policy-cache reader was created as a side effect, which is what would recurse. + Assertions.assertThat(service.getReaderCaches()).doesNotContainKey(namespace); } } diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/TopicPolicyListenerWrapperTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/TopicPolicyListenerWrapperTest.java new file mode 100644 index 0000000000000..9d32342aab5d0 --- /dev/null +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/TopicPolicyListenerWrapperTest.java @@ -0,0 +1,212 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.broker.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import java.util.ArrayList; +import java.util.List; +import org.apache.pulsar.common.policies.data.TopicPolicies; +import org.testng.annotations.Test; + +@Test(groups = "broker") +public class TopicPolicyListenerWrapperTest { + + private static TopicPolicies globalPolicies() { + return TopicPolicies.builder().isGlobal(true).build(); + } + + private static TopicPolicies localPolicies() { + return TopicPolicies.builder().isGlobal(false).build(); + } + + private static final class RecordingListener implements TopicPolicyListener { + final List updates = new ArrayList<>(); + + @Override + public void onUpdate(TopicPolicies data) { + updates.add(data); + } + } + + @Test + public void shouldBufferUpdatesUntilInitializedThenForwardLive() { + RecordingListener real = new RecordingListener(); + TopicPolicyListenerWrapper wrapper = new TopicPolicyListenerWrapper(real); + + // Updates received before initialization are buffered, not forwarded. + TopicPolicies bufferedLocal = localPolicies(); + wrapper.onUpdate(bufferedLocal); + assertThat(real.updates).isEmpty(); + + // On completion, the buffered local value wins over the loaded local value; the loaded global value + // is applied since none was buffered. The local policy is emitted before the global one. + TopicPolicies loadedGlobal = globalPolicies(); + wrapper.completeInitialization(loadedGlobal, localPolicies()); + assertThat(real.updates).containsExactly(bufferedLocal, loadedGlobal); + + // After initialization, updates are forwarded immediately. + TopicPolicies liveUpdate = localPolicies(); + wrapper.onUpdate(liveUpdate); + assertThat(real.updates).containsExactly(bufferedLocal, loadedGlobal, liveUpdate); + } + + @Test + public void shouldPreferBufferedOverLoadedForBothScopes() { + RecordingListener real = new RecordingListener(); + TopicPolicyListenerWrapper wrapper = new TopicPolicyListenerWrapper(real); + + TopicPolicies bufferedGlobal = globalPolicies(); + TopicPolicies bufferedLocal = localPolicies(); + wrapper.onUpdate(bufferedGlobal); + wrapper.onUpdate(bufferedLocal); + + wrapper.completeInitialization(globalPolicies(), localPolicies()); + assertThat(real.updates).containsExactly(bufferedLocal, bufferedGlobal); + } + + @Test + public void shouldApplyLoadedWhenNothingBuffered() { + RecordingListener real = new RecordingListener(); + TopicPolicyListenerWrapper wrapper = new TopicPolicyListenerWrapper(real); + + // The local policy is emitted before the global policy, so a local topic policy takes precedence over a + // global one once both have been applied. + TopicPolicies loadedGlobal = globalPolicies(); + TopicPolicies loadedLocal = localPolicies(); + wrapper.completeInitialization(loadedGlobal, loadedLocal); + assertThat(real.updates).containsExactly(loadedLocal, loadedGlobal); + } + + @Test + public void shouldSuppressLoadedValuesWhenDeletedBeforeInitialization() { + RecordingListener real = new RecordingListener(); + TopicPolicyListenerWrapper wrapper = new TopicPolicyListenerWrapper(real); + + // A delete (null) arriving before initialization must not NPE and must not be forwarded yet (#26037). + assertThatCode(() -> wrapper.onUpdate(null)).doesNotThrowAnyException(); + assertThat(real.updates).isEmpty(); + + // The delete supersedes the (now-stale) loaded values: they are not applied, and the delete (null) is + // propagated downstream instead. + wrapper.completeInitialization(globalPolicies(), localPolicies()); + assertThat(real.updates).containsExactly(null, null); + } + + @Test + public void shouldApplyLatestScopedUpdateOverEarlierDeleteDuringInitialization() { + RecordingListener real = new RecordingListener(); + TopicPolicyListenerWrapper wrapper = new TopicPolicyListenerWrapper(real); + + // A delete records empty for both scopes, then a newer global update overrides only the global scope. + wrapper.onUpdate(null); + TopicPolicies newerGlobal = globalPolicies(); + wrapper.onUpdate(newerGlobal); + + wrapper.completeInitialization(globalPolicies(), localPolicies()); + // Local (emitted first): the delete (null) wins over the loaded local value; Global: the newer update wins. + assertThat(real.updates).containsExactly(null, newerGlobal); + } + + @Test + public void shouldNotEmitLocalScopeWhenNoLocalPolicyExists() { + RecordingListener real = new RecordingListener(); + TopicPolicyListenerWrapper wrapper = new TopicPolicyListenerWrapper(real); + + // With no local policy, only the global policy is emitted; no local onUpdate happens, so the + // local-before-global ordering leaves behavior unchanged for topics that only have a global policy. + TopicPolicies loadedGlobal = globalPolicies(); + wrapper.completeInitialization(loadedGlobal, null); + assertThat(real.updates).containsExactly(loadedGlobal); + } + + @Test + public void shouldIgnoreCompleteInitializationAfterAlreadyCompleted() { + RecordingListener real = new RecordingListener(); + TopicPolicyListenerWrapper wrapper = new TopicPolicyListenerWrapper(real); + wrapper.startInitialization(); + + TopicPolicies loadedLocal = localPolicies(); + wrapper.completeInitialization(null, loadedLocal); + assertThat(real.updates).containsExactly(loadedLocal); + + // Completing again (e.g. from initTopicPolicy's terminal handler) must be a no-op and must not re-emit. + wrapper.completeInitialization(globalPolicies(), localPolicies()); + wrapper.completeInitializationUnlessAlreadyCompleted(); + assertThat(real.updates).containsExactly(loadedLocal); + } + + @Test + public void shouldEmitBufferedValueAndForwardLiveUpdatesWhenCompletedWithoutLoadedPolicies() { + RecordingListener real = new RecordingListener(); + TopicPolicyListenerWrapper wrapper = new TopicPolicyListenerWrapper(real); + wrapper.startInitialization(); + + // A policy update arrives while initializing and is buffered. + TopicPolicies buffered = localPolicies(); + wrapper.onUpdate(buffered); + assertThat(real.updates).isEmpty(); + + // initTopicPolicy's terminal handler completes initialization with no loaded policies -- the path taken after + // a policy-load error or when the listener was not registered. The buffered value is emitted and the wrapper + // leaves the buffering phase. + wrapper.completeInitializationUnlessAlreadyCompleted(); + assertThat(real.updates).containsExactly(buffered); + + // Subsequent live updates now flow through instead of being dropped. + TopicPolicies live = globalPolicies(); + wrapper.onUpdate(live); + assertThat(real.updates).containsExactly(buffered, live); + } + + @Test + public void shouldForwardLiveUpdatesAfterCompletingWithNothingBuffered() { + RecordingListener real = new RecordingListener(); + TopicPolicyListenerWrapper wrapper = new TopicPolicyListenerWrapper(real); + wrapper.startInitialization(); + + // Completed with nothing buffered and no loaded policies (e.g. after a failed load): nothing is emitted, but + // the wrapper still leaves the buffering phase so later live updates are forwarded rather than dropped. + wrapper.completeInitializationUnlessAlreadyCompleted(); + assertThat(real.updates).isEmpty(); + + TopicPolicies live = localPolicies(); + wrapper.onUpdate(live); + assertThat(real.updates).containsExactly(live); + } + + @Test + public void shouldRebufferAndReapplyAfterStartInitializationIsCalledAgain() { + RecordingListener real = new RecordingListener(); + TopicPolicyListenerWrapper wrapper = new TopicPolicyListenerWrapper(real); + wrapper.startInitialization(); + TopicPolicies firstLocal = localPolicies(); + wrapper.completeInitialization(null, firstLocal); + assertThat(real.updates).containsExactly(firstLocal); + + // A new initialization phase (e.g. re-running initTopicPolicy): updates are buffered again until it completes, + // and a value buffered during the phase is applied on completion. + wrapper.startInitialization(); + TopicPolicies bufferedGlobal = globalPolicies(); + wrapper.onUpdate(bufferedGlobal); + assertThat(real.updates).containsExactly(firstLocal); + wrapper.completeInitialization(null, null); + assertThat(real.updates).containsExactly(firstLocal, bufferedGlobal); + } +} diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/TopicPolicyTestUtils.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/TopicPolicyTestUtils.java index 6b9735d59b21a..7e9c697fb5d03 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/TopicPolicyTestUtils.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/TopicPolicyTestUtils.java @@ -72,6 +72,13 @@ public static TopicPolicies getGlobalTopicPolicies(TopicPoliciesService topicPol public static Optional getTopicPoliciesBypassCache(TopicPoliciesService topicPoliciesService, TopicName topicName, boolean isGlobal) throws Exception { + if (topicPoliciesService instanceof LegacyAwareTopicPoliciesService legacyService) { + TopicPoliciesService resolved = legacyService.resolveService(topicName.getNamespaceObject()).get(); + return getTopicPoliciesBypassCache(resolved, topicName, isGlobal); + } + if (topicPoliciesService instanceof MetadataStoreTopicPoliciesService metadataStoreService) { + return metadataStoreService.getTopicPoliciesDirectFromStore(topicName, isGlobal).get(); + } @Cleanup final var reader = ((SystemTopicBasedTopicPoliciesService) topicPoliciesService) .getNamespaceEventsSystemTopicFactory() .createTopicPoliciesSystemTopicClient(topicName.getNamespaceObject()) diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/nonpersistent/NonPersistentTopicTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/nonpersistent/NonPersistentTopicTest.java index 12b6cd2761a19..d09f3068c2f09 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/nonpersistent/NonPersistentTopicTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/nonpersistent/NonPersistentTopicTest.java @@ -23,6 +23,7 @@ import static org.testng.Assert.assertThrows; import static org.testng.Assert.assertTrue; import java.lang.reflect.Field; +import java.util.Collections; import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -30,7 +31,9 @@ import lombok.Cleanup; import org.apache.pulsar.broker.service.AbstractTopic; import org.apache.pulsar.broker.service.BrokerTestBase; +import org.apache.pulsar.broker.service.PulsarCommandSender; import org.apache.pulsar.broker.service.SubscriptionOption; +import org.apache.pulsar.broker.service.TransportCnx; import org.apache.pulsar.client.admin.PulsarAdminException; import org.apache.pulsar.client.api.Consumer; import org.apache.pulsar.client.api.Message; @@ -39,7 +42,9 @@ import org.apache.pulsar.client.api.Schema; import org.apache.pulsar.client.api.SubscriptionMode; import org.apache.pulsar.client.api.SubscriptionType; +import org.apache.pulsar.common.api.proto.CommandSubscribe; import org.apache.pulsar.common.naming.TopicName; +import org.apache.pulsar.common.policies.data.ClusterPolicies.ClusterUrl; import org.apache.pulsar.common.policies.data.TopicStats; import org.awaitility.Awaitility; import org.mockito.Mockito; @@ -126,6 +131,90 @@ public void testCreateNonExistentPartitions() throws PulsarAdminException { } + /** + * Regression test for the migration-redirect race in {@code NonPersistentTopic.internalSubscribe} + * (introduced by PR #26051, which turned a blocking, ordered migration redirect into a fire-and-forget + * async one). When the topic is migrated, the migration check must complete before + * {@code addConsumerToSubscription}, and the consumer must NOT be attached to the subscription on the + * old cluster. The previous code ran {@code getMigratedClusterUrlAsync().thenAccept(consumer::topicMigrated)} + * concurrently with {@code addConsumerToSubscription}, so the consumer could be added before the redirect + * and disconnect ran. The fix sequences the check through {@link org.apache.pulsar.broker.service.Consumer + * #checkAndApplyTopicMigrationAsync()} and skips the add when migrated. + */ + @Test + public void testSubscribeOnMigratedTopicSkipsAddingConsumer() throws Exception { + final String topicName = "non-persistent://prop/ns-abc/migration-race-" + UUID.randomUUID(); + final String subName = "migration-sub"; + + // Materialize the real topic on the broker and acquire namespace-bundle ownership via a client lookup. + @Cleanup + Producer producer = pulsarClient.newProducer().topic(topicName).create(); + NonPersistentTopic realTopic = + (NonPersistentTopic) pulsar.getBrokerService().getTopicReference(topicName).get(); + + // Mark the local cluster as migrated so AbstractTopic.getMigratedClusterUrlAsync() (used by + // Consumer.checkAndApplyTopicMigrationAsync()) resolves to a present migrated-cluster URL. + ClusterUrl migratedUrl = new ClusterUrl("http://migrated:8080", "https://migrated:8443", + "pulsar://migrated:6650", "pulsar+ssl://migrated:6651"); + admin.clusters().updateClusterMigration(conf.getClusterName(), true, migratedUrl); + Awaitility.await().atMost(10, TimeUnit.SECONDS).until(() -> + AbstractTopic.getMigratedClusterUrlAsync(pulsar, topicName).get().isPresent()); + + // Spy the topic and force isMigrated() so the subscription is considered migrated. + NonPersistentTopic spyTopic = Mockito.spy(realTopic); + Mockito.doReturn(true).when(spyTopic).isMigrated(); + + // Pre-install a spy subscription so we can assert addConsumer is never invoked when migrated. + NonPersistentSubscription spySubscription = + Mockito.spy(new NonPersistentSubscription(spyTopic, subName, Collections.emptyMap())); + spyTopic.getSubscriptions().put(subName, spySubscription); + + // An active transport connection that records the migration redirect. + PulsarCommandSender commandSender = Mockito.mock(PulsarCommandSender.class); + TransportCnx cnx = Mockito.mock(TransportCnx.class); + Mockito.doReturn(true).when(cnx).isActive(); + Mockito.doReturn(true).when(cnx).isBatchMessageCompatibleVersion(); + Mockito.doReturn("test-role").when(cnx).getAuthRole(); + Mockito.doReturn(pulsar.getBrokerService()).when(cnx).getBrokerService(); + Mockito.doReturn(commandSender).when(cnx).getCommandSender(); + + SubscriptionOption option = SubscriptionOption.builder() + .cnx(cnx) + .subscriptionName(subName) + .consumerId(1L) + .subType(CommandSubscribe.SubType.Shared) + .priorityLevel(0) + .consumerName("consumer-1") + .isDurable(false) + .startMessageId(null) + .metadata(Collections.emptyMap()) + .readCompacted(false) + .initialPosition(CommandSubscribe.InitialPosition.Latest) + .startMessageRollbackDurationSec(0) + .replicatedSubscriptionStateArg(false) + .keySharedMeta(null) + .subscriptionProperties(Optional.empty()) + .build(); + + long usageBefore = spyTopic.currentUsageCount(); + + org.apache.pulsar.broker.service.Consumer consumer = + spyTopic.subscribe(option).get(10, TimeUnit.SECONDS); + assertNotNull(consumer); + + // The migration redirect must have been sent to the client. + Mockito.verify(commandSender).sendTopicMigrated(Mockito.any(), Mockito.eq(1L), + Mockito.eq(migratedUrl.getBrokerServiceUrl()), Mockito.eq(migratedUrl.getBrokerServiceUrlTls())); + + // The core regression assertion: a migrated topic must never attach the consumer to the + // subscription. The buggy code added it before the async redirect/disconnect could run. + Mockito.verify(spySubscription, Mockito.never()).addConsumer(Mockito.any()); + assertTrue(spySubscription.getConsumers().isEmpty()); + + // No usage-count leak: handleConsumerAdded's increment is balanced by the disconnect's removeConsumer. + assertEquals(spyTopic.currentUsageCount(), usageBefore); + } + @Test public void testSubscriptionsOnNonPersistentTopic() throws Exception { final String topicName = "non-persistent://prop/ns-abc/topic_" + UUID.randomUUID(); diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/BucketDelayedDeliveryTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/BucketDelayedDeliveryTest.java index 3af7b1b4e0cf3..84020f6aea778 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/BucketDelayedDeliveryTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/BucketDelayedDeliveryTest.java @@ -22,6 +22,7 @@ import static org.apache.pulsar.broker.stats.prometheus.PrometheusMetricsClient.Metric; import static org.apache.pulsar.broker.stats.prometheus.PrometheusMetricsClient.parseMetrics; import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; import static org.testng.Assert.assertNotNull; import static org.testng.Assert.assertTrue; import com.google.common.collect.Multimap; @@ -48,6 +49,7 @@ import org.apache.pulsar.client.api.Producer; import org.apache.pulsar.client.api.Schema; import org.apache.pulsar.client.api.SubscriptionType; +import org.apache.pulsar.common.naming.TopicName; import org.awaitility.Awaitility; import org.testng.Assert; import org.testng.annotations.AfterClass; @@ -126,6 +128,61 @@ public void testBucketDelayedDeliveryWithAllConsumersDisconnecting() throws Exce Assert.assertEquals(bucketKeys, bucketKeys2); } + @Test + public void testIncrementPartitionsDoesNotCopyBucketDelayedDeliveryState() throws Exception { + String topic = BrokerTestUtil.newUniqueName("persistent://public/default/testBucketStatePartitionExpansion"); + String subscriptionName = "sub"; + admin.topics().createPartitionedTopic(topic, 1); + String sourcePartition = TopicName.get(topic).getPartition(0).toString(); + + @Cleanup + Consumer sourceConsumer = pulsarClient.newConsumer(Schema.STRING) + .topic(sourcePartition) + .subscriptionName(subscriptionName) + .subscriptionType(SubscriptionType.Shared) + .subscribe(); + @Cleanup + Producer producer = pulsarClient.newProducer(Schema.STRING) + .topic(sourcePartition) + .enableBatching(false) + .create(); + + for (int i = 0; i < 1000; i++) { + producer.newMessage().value("msg").deliverAfter(1, TimeUnit.HOURS).send(); + } + + Dispatcher sourceDispatcher = pulsar.getBrokerService().getTopicReference(sourcePartition) + .get().getSubscription(subscriptionName).getDispatcher(); + Awaitility.await().untilAsserted( + () -> Assert.assertEquals(sourceDispatcher.getNumberOfDelayedMessages(), 1000)); + List bucketKeys = ((AbstractPersistentDispatcherMultipleConsumers) sourceDispatcher) + .getCursor().getCursorProperties().keySet().stream() + .filter(key -> key.startsWith(CURSOR_INTERNAL_PROPERTY_PREFIX + "delayed.bucket")).toList(); + assertFalse(bucketKeys.isEmpty()); + + admin.topics().updatePartitionedTopic(topic, 2); + + String newPartition = TopicName.get(topic).getPartition(1).toString(); + PersistentTopic newTopic = (PersistentTopic) pulsar.getBrokerService().getTopicReference(newPartition) + .orElseThrow(); + Map newCursorProperties = newTopic.getSubscription(subscriptionName) + .getCursor().getCursorProperties(); + assertTrue(newCursorProperties == null + || newCursorProperties.keySet().stream().noneMatch(bucketKeys::contains)); + + @Cleanup + Consumer newPartitionConsumer = pulsarClient.newConsumer(Schema.STRING) + .topic(newPartition) + .subscriptionName(subscriptionName) + .subscriptionType(SubscriptionType.Shared) + .subscribe(); + Dispatcher newPartitionDispatcher = newTopic.getSubscription(subscriptionName).getDispatcher(); + Awaitility.await().untilAsserted( + () -> assertEquals(newPartitionDispatcher.getNumberOfDelayedMessages(), 0)); + assertTrue(((AbstractPersistentDispatcherMultipleConsumers) sourceDispatcher).getCursor().getCursorProperties() + .keySet().containsAll(bucketKeys)); + } + @Test public void testUnsubscribe() throws Exception { diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/MessageRedeliveryControllerTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/MessageRedeliveryControllerTest.java index bc73ba64d5006..03c7e6cf439e3 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/MessageRedeliveryControllerTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/MessageRedeliveryControllerTest.java @@ -18,20 +18,16 @@ */ package org.apache.pulsar.broker.service.persistent; +import static org.apache.pulsar.broker.service.StickyKeyConsumerSelector.STICKY_KEY_HASH_NOT_SET; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertEqualsNoOrder; import static org.testng.Assert.assertFalse; -import static org.testng.Assert.assertNotNull; import static org.testng.Assert.assertNull; import static org.testng.Assert.assertTrue; -import java.lang.reflect.Field; import java.util.Set; import java.util.TreeSet; import org.apache.bookkeeper.mledger.Position; import org.apache.bookkeeper.mledger.PositionFactory; -import org.apache.bookkeeper.util.collections.ConcurrentLongLongHashMap; -import org.apache.pulsar.common.util.collections.ConcurrentLongLongPairHashMap; -import org.apache.pulsar.utils.ConcurrentBitmapSortedLongPairSet; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; @@ -43,103 +39,61 @@ public Object[][] dataProvider() { } @Test(dataProvider = "allowOutOfOrderDelivery", timeOut = 10000) - public void testAddAndRemove(boolean allowOutOfOrderDelivery) throws Exception { + public void testAddAndRemove(boolean allowOutOfOrderDelivery) { MessageRedeliveryController controller = new MessageRedeliveryController(allowOutOfOrderDelivery); - Field messagesToRedeliverField = MessageRedeliveryController.class.getDeclaredField("messagesToRedeliver"); - messagesToRedeliverField.setAccessible(true); - ConcurrentBitmapSortedLongPairSet messagesToRedeliver = - (ConcurrentBitmapSortedLongPairSet) messagesToRedeliverField.get(controller); - - Field hashesToBeBlockedField = MessageRedeliveryController.class.getDeclaredField("hashesToBeBlocked"); - hashesToBeBlockedField.setAccessible(true); - ConcurrentLongLongPairHashMap hashesToBeBlocked = (ConcurrentLongLongPairHashMap) hashesToBeBlockedField - .get(controller); - - Field hashesRefCountField = MessageRedeliveryController.class.getDeclaredField("hashesRefCount"); - hashesRefCountField.setAccessible(true); - ConcurrentLongLongHashMap hashesRefCount = (ConcurrentLongLongHashMap) hashesRefCountField.get(controller); - - if (allowOutOfOrderDelivery) { - assertNull(hashesToBeBlocked); - assertNull(hashesRefCount); - } else { - assertNotNull(hashesToBeBlocked); - assertNotNull(hashesRefCount); - } - + assertEquals(controller.isPositionToStickyKeyHashInitialized(), !allowOutOfOrderDelivery); assertTrue(controller.isEmpty()); - assertEquals(messagesToRedeliver.size(), 0); - if (!allowOutOfOrderDelivery) { - assertEquals(hashesToBeBlocked.size(), 0); - assertEquals(hashesRefCount.size(), 0); - } + assertEquals(controller.size(), 0); controller.add(1, 1); controller.add(1, 2); assertFalse(controller.isEmpty()); - assertEquals(messagesToRedeliver.size(), 2); - assertTrue(messagesToRedeliver.contains(1, 1)); - assertTrue(messagesToRedeliver.contains(1, 2)); - if (!allowOutOfOrderDelivery) { - assertEquals(hashesToBeBlocked.size(), 0); - assertFalse(hashesToBeBlocked.containsKey(1, 1)); - assertFalse(hashesToBeBlocked.containsKey(1, 2)); - assertEquals(hashesRefCount.size(), 0); - } + assertEquals(controller.size(), 2); + assertNull(controller.getHash(1, 1)); + assertNull(controller.getHash(1, 2)); + assertEquals(controller.isPositionToStickyKeyHashInitialized(), !allowOutOfOrderDelivery); controller.remove(1, 1); controller.remove(1, 2); assertTrue(controller.isEmpty()); - assertEquals(messagesToRedeliver.size(), 0); - assertFalse(messagesToRedeliver.contains(1, 1)); - assertFalse(messagesToRedeliver.contains(1, 2)); - if (!allowOutOfOrderDelivery) { - assertEquals(hashesToBeBlocked.size(), 0); - assertEquals(hashesRefCount.size(), 0); - } + assertEquals(controller.size(), 0); controller.add(2, 1, 100); controller.add(2, 2, 101); controller.add(2, 3, 101); assertFalse(controller.isEmpty()); - assertEquals(messagesToRedeliver.size(), 3); - assertTrue(messagesToRedeliver.contains(2, 1)); - assertTrue(messagesToRedeliver.contains(2, 2)); - assertTrue(messagesToRedeliver.contains(2, 3)); + assertEquals(controller.size(), 3); + assertTrue(controller.isPositionToStickyKeyHashInitialized()); + assertEquals(controller.getHash(2, 1), Long.valueOf(100)); + assertEquals(controller.getHash(2, 2), Long.valueOf(101)); + assertEquals(controller.getHash(2, 3), Long.valueOf(101)); if (!allowOutOfOrderDelivery) { - assertEquals(hashesToBeBlocked.size(), 3); - assertEquals(hashesToBeBlocked.get(2, 1).first, 100); - assertEquals(hashesToBeBlocked.get(2, 2).first, 101); - assertEquals(hashesToBeBlocked.get(2, 3).first, 101); - assertEquals(hashesRefCount.size(), 2); - assertEquals(hashesRefCount.get(100), 1); - assertEquals(hashesRefCount.get(101), 2); + assertTrue(controller.containsStickyKeyHash(100)); + assertTrue(controller.containsStickyKeyHash(101)); } controller.remove(2, 1); controller.remove(2, 2); + assertEquals(controller.size(), 1); + assertNull(controller.getHash(2, 1)); + assertNull(controller.getHash(2, 2)); + assertEquals(controller.getHash(2, 3), Long.valueOf(101)); if (!allowOutOfOrderDelivery) { - assertEquals(hashesToBeBlocked.size(), 1); - assertEquals(hashesToBeBlocked.get(2, 3).first, 101); - assertEquals(hashesRefCount.size(), 1); - assertEquals(hashesRefCount.get(100), -1); - assertEquals(hashesRefCount.get(101), 1); + assertFalse(controller.containsStickyKeyHash(100)); + assertTrue(controller.containsStickyKeyHash(101)); } controller.clear(); assertTrue(controller.isEmpty()); - assertEquals(messagesToRedeliver.size(), 0); - assertTrue(messagesToRedeliver.isEmpty()); + assertEquals(controller.size(), 0); + assertNull(controller.getHash(2, 3)); if (!allowOutOfOrderDelivery) { - assertEquals(hashesToBeBlocked.size(), 0); - assertTrue(hashesToBeBlocked.isEmpty()); - assertEquals(hashesRefCount.size(), 0); - assertTrue(hashesRefCount.isEmpty()); + assertFalse(controller.containsStickyKeyHash(101)); } controller.add(2, 2, 201); @@ -151,43 +105,71 @@ public void testAddAndRemove(boolean allowOutOfOrderDelivery) throws Exception { controller.add(1, 1, 100); controller.removeAllUpTo(1, 3); - assertEquals(messagesToRedeliver.size(), 4); - assertTrue(messagesToRedeliver.contains(2, 1)); - assertTrue(messagesToRedeliver.contains(2, 2)); - assertTrue(messagesToRedeliver.contains(3, 1)); - assertTrue(messagesToRedeliver.contains(3, 2)); + assertEquals(controller.size(), 4); + assertNull(controller.getHash(1, 1)); + assertNull(controller.getHash(1, 2)); + assertNull(controller.getHash(1, 3)); + assertEquals(controller.getHash(2, 1), Long.valueOf(200)); + assertEquals(controller.getHash(2, 2), Long.valueOf(201)); + assertEquals(controller.getHash(3, 1), Long.valueOf(300)); + assertEquals(controller.getHash(3, 2), Long.valueOf(301)); if (!allowOutOfOrderDelivery) { - assertEquals(hashesToBeBlocked.size(), 4); - assertEquals(hashesToBeBlocked.get(2, 1).first, 200); - assertEquals(hashesToBeBlocked.get(2, 2).first, 201); - assertEquals(hashesToBeBlocked.get(3, 1).first, 300); - assertEquals(hashesToBeBlocked.get(3, 2).first, 301); - assertEquals(hashesRefCount.size(), 4); - assertEquals(hashesRefCount.get(200), 1); - assertEquals(hashesRefCount.get(201), 1); - assertEquals(hashesRefCount.get(300), 1); - assertEquals(hashesRefCount.get(301), 1); + assertFalse(controller.containsStickyKeyHash(100)); + assertFalse(controller.containsStickyKeyHash(101)); + assertTrue(controller.containsStickyKeyHash(200)); + assertTrue(controller.containsStickyKeyHash(201)); + assertTrue(controller.containsStickyKeyHash(300)); + assertTrue(controller.containsStickyKeyHash(301)); } controller.removeAllUpTo(3, 1); - assertEquals(messagesToRedeliver.size(), 1); - assertTrue(messagesToRedeliver.contains(3, 2)); + assertEquals(controller.size(), 1); + assertNull(controller.getHash(2, 1)); + assertNull(controller.getHash(2, 2)); + assertNull(controller.getHash(3, 1)); + assertEquals(controller.getHash(3, 2), Long.valueOf(301)); if (!allowOutOfOrderDelivery) { - assertEquals(hashesToBeBlocked.size(), 1); - assertEquals(hashesToBeBlocked.get(3, 2).first, 301); - assertEquals(hashesRefCount.size(), 1); - assertEquals(hashesRefCount.get(301), 1); + assertFalse(controller.containsStickyKeyHash(200)); + assertFalse(controller.containsStickyKeyHash(201)); + assertFalse(controller.containsStickyKeyHash(300)); + assertTrue(controller.containsStickyKeyHash(301)); } controller.removeAllUpTo(5, 10); assertTrue(controller.isEmpty()); - assertEquals(messagesToRedeliver.size(), 0); + assertEquals(controller.size(), 0); + assertNull(controller.getHash(3, 2)); if (!allowOutOfOrderDelivery) { - assertEquals(hashesToBeBlocked.size(), 0); - assertEquals(hashesRefCount.size(), 0); + assertFalse(controller.containsStickyKeyHash(301)); } } + @Test(timeOut = 10000) + public void testOutOfOrderSentinelHashDoesNotInitializePositionHashMap() { + MessageRedeliveryController controller = new MessageRedeliveryController(true); + + controller.add(1, 1, STICKY_KEY_HASH_NOT_SET); + + assertFalse(controller.isPositionToStickyKeyHashInitialized()); + assertEquals(controller.size(), 1); + assertNull(controller.getHash(1, 1)); + + controller.removeAllUpTo(1, 1); + assertTrue(controller.isEmpty()); + assertFalse(controller.isPositionToStickyKeyHashInitialized()); + } + + @Test(timeOut = 10000) + public void testClassicOutOfOrderDoesNotInitializePositionHashMap() { + MessageRedeliveryController controller = new MessageRedeliveryController(true, true); + + controller.add(1, 1, 100); + + assertFalse(controller.isPositionToStickyKeyHashInitialized()); + assertEquals(controller.size(), 1); + assertNull(controller.getHash(1, 1)); + } + @Test(dataProvider = "allowOutOfOrderDelivery", timeOut = 10000) public void testContainsStickyKeyHashes(boolean allowOutOfOrderDelivery) throws Exception { MessageRedeliveryController controller = new MessageRedeliveryController(allowOutOfOrderDelivery); diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentDispatcherSingleActiveConsumerStuckReadTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentDispatcherSingleActiveConsumerStuckReadTest.java new file mode 100644 index 0000000000000..9d2c54a4f34a8 --- /dev/null +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentDispatcherSingleActiveConsumerStuckReadTest.java @@ -0,0 +1,671 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.broker.service.persistent; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import io.netty.util.concurrent.ImmediateEventExecutor; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.AbstractExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import lombok.extern.slf4j.Slf4j; +import org.apache.bookkeeper.common.util.OrderedExecutor; +import org.apache.bookkeeper.mledger.Entry; +import org.apache.bookkeeper.mledger.ManagedLedgerConfig; +import org.apache.bookkeeper.mledger.Position; +import org.apache.bookkeeper.mledger.impl.ManagedCursorImpl; +import org.apache.bookkeeper.mledger.impl.ManagedLedgerImpl; +import org.apache.bookkeeper.test.MockedBookKeeperTestCase; +import org.apache.pulsar.broker.ServiceConfiguration; +import org.apache.pulsar.broker.service.BrokerService; +import org.apache.pulsar.broker.service.Consumer; +import org.apache.pulsar.broker.service.Dispatcher; +import org.apache.pulsar.broker.service.EntryBatchIndexesAcks; +import org.apache.pulsar.broker.service.EntryBatchSizes; +import org.apache.pulsar.broker.testcontext.PulsarTestContext; +import org.apache.pulsar.common.api.proto.CommandSubscribe.SubType; +import org.apache.pulsar.common.policies.data.stats.ConsumerStatsImpl; +import org.awaitility.Awaitility; +import org.testng.Assert; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.Test; + +/** + * Regression test for a single-active-consumer (Failover) subscription that becomes permanently stuck + * when a client redeliver command races the completion of an already-dispatched managed-ledger read. + * + *

Four coupled state variables. A caught-up Failover subscription is described by four pieces + * of state that must stay consistent: + *

    + *
  1. {@code cursor.waitingReadOp} — the armed tail-wait read op + * ({@link ManagedCursorImpl#hasPendingReadRequest()});
  2. + *
  3. the cursor's membership in {@link ManagedLedgerImpl}'s {@code waitingCursors} queue — the + * set a publish walks to wake parked readers;
  4. + *
  5. {@code cursor.pendingReadOps} — reads currently in flight + * ({@link ManagedCursorImpl#getPendingReadOpsCount()});
  6. + *
  7. {@code dispatcher.havePendingRead} — the dispatcher's belief that a read is outstanding.
  8. + *
+ * In healthy operation an armed wait op implies {@code havePendingRead}, and implies the cursor is + * either queued in {@code waitingCursors} or has an in-flight read. + * + *

The race. + * {@code PersistentDispatcherSingleActiveConsumer#internalRedeliverUnacknowledgedMessages} unconditionally + * clears {@code havePendingRead} and arms a fresh read via {@code readMoreEntries} without draining + * a read that {@code notifyEntriesAvailable} has already dispatched. The stale read's completion + * ({@code readEntriesComplete} / {@code readEntriesFailed}) then clears {@code havePendingRead} again: + * there is no read-generation guard on {@code havePendingRead}, and the only staleness check is the + * {@code readConsumer != currentConsumer} identity comparison in {@code readEntriesComplete}, which runs + * after that same method has already cleared the flag at its top. When the redeliver's re-arm and + * the stale completion interleave in the order [redeliver, then stale completion], the result is the seed + * state — an armed wait op with {@code havePendingRead == false}. This is still benign while the + * cursor remains in {@code waitingCursors}. + * + *

The absorbing conversion. When the last consumer disconnects, the durable-cursor path + * ({@code AbstractDispatcherSingleActiveConsumer#removeConsumer} → {@code cancelPendingRead}, then + * {@code ManagedLedger#removeWaitingCursor}) deliberately does not cancel the pending read for a durable + * cursor — see the durable-cursor comment in {@code PersistentSubscription#removeConsumer}. Because + * {@code havePendingRead} is already {@code false}, {@code cancelPendingRead} short-circuits on its + * {@code havePendingRead} guard and leaves the wait op armed, while {@code removeWaitingCursor} strips the + * cursor from the queue. The subscription is now in the absorbing orphan state: an armed wait op, absent + * from {@code waitingCursors}, with no in-flight read and {@code havePendingRead == false}. Every + * subsequent re-arm CAS-fails on the {@code WAITING_READ_OP_UPDATER} compare-and-set in + * {@code ManagedCursorImpl#asyncReadEntriesWithSkipOrWait}, raising {@code ConcurrentWaitCallbackException} + * — which {@code readEntriesFailed} returns early on instead of rescheduling — and every + * publish's {@code notifyCursors} poll-misses. The subscription never reads again — which is exactly + * what both tests assert behaviourally: after the disconnect a fresh consumer reconnects, and a message + * published afterwards must actually reach that consumer's {@code sendMessages}. + * + *

The two racing events genuinely arrive on different threads: the read completion is posted to the + * dispatcher's ordered executor by the managed-ledger/BookKeeper completion chain (the + * {@code entriesFuture.whenCompleteAsync(..., executor)} hand-off at the end of {@code readMoreEntries}), + * while the redeliver is posted by {@code redeliverUnacknowledgedMessages} from the client-command thread. + * Their arrival order is a real race. + * + *

Fidelity notes / test seams. + *

    + *
  • The cursor, ledger, subscription, dispatcher, {@code waitingReadOp} compare-and-set, + * {@code checkForNewEntries}, {@code notifyEntriesAvailable}, {@code notifyCursors}, + * {@code cancelPendingReadRequest}, {@code addWaitingCursor}/{@code removeWaitingCursor} and + * {@code havePendingRead} management are all real production code.
  • + *
  • The consumer lifecycle runs through the real {@code PersistentSubscription#addConsumer} and + * {@code PersistentSubscription#removeConsumer(Consumer, boolean)}, so dispatcher removal, + * {@code deactivateCursor()} and {@code removeWaitingCursor()} happen in the production order. The + * spied dispatcher is handed to the subscription through the existing production test seam + * {@code PersistentSubscription#reuseOrCreateDispatcher}.
  • + *
  • Only the metadata-parsing filter step ({@code filterEntriesForConsumer}) is stubbed on a Mockito + * spy, because the entries this test publishes are raw bytes rather than serialized Pulsar messages. + * The delivery tail itself is real: {@code dispatchEntriesToConsumer} runs and calls + * {@code Consumer#sendMessages}, which the mock consumer records before releasing the entries and + * completing immediately — so the production {@code readMoreEntries} continuation is posted by + * the real listener. Neither seam touches the four state variables above.
  • + *
  • The dispatcher's ordered executor is replaced with a {@link ManualExecutor} (injected through a + * spied {@code getTopicOrderedExecutor()} scoped to dispatcher construction) so every dispatcher + * async hop becomes an explicit task the test drains in a chosen order.
  • + *
  • The consumer's ack of {@code m1} is telescoped: it is applied before the dispatcher delivery of + * {@code m1}'s in-flight read completes. This is what keeps the schedule deterministic — after + * the ack the redeliver's {@code rewind} lands at the tail with no backlog, so the re-arm is a pure + * in-memory tail-wait and no BookKeeper read escapes the {@link ManualExecutor} to race the drain. + * Without the ack the {@code rewind} re-exposes the unacked entry and {@code readMoreEntries} takes + * the immediate-read branch of {@code ManagedCursorImpl#asyncReadEntriesWithSkipOrWait} (the + * {@code hasMoreEntries()} fast path that delegates straight to {@code asyncReadEntriesWithSkip}), + * issuing a real async read whose completion is off-board. The staged state (mark-delete at the + * tail plus an already-dispatched read whose completion is still pending) is production-reachable + * via a redeliver→ack→redeliver sequence.
  • + *
  • {@code newEntriesCheckDelayInMillis} is pinned to {@code 0} so {@code checkForNewEntries} runs + * inline at arm time rather than on the ledger scheduler. This is a determinism pin only; the bug + * itself is not an artifact of the zero delay. With the telescoped ack in place, every + * dispatcher-visible async hop is then an explicit task on the {@link ManualExecutor} board that the + * test drains in a chosen order.
  • + *
+ * + *

Reproduces apache/pulsar#26164. + */ +@Slf4j +public class PersistentDispatcherSingleActiveConsumerStuckReadTest extends MockedBookKeeperTestCase { + + private static final String TOPIC = "persistent://prop/ns/sac-stuck-read"; + private static final int BOARD_WAIT_SECONDS = 5; + private static final long QUIESCE_TIMEOUT_SECONDS = 10; + private static final int QUIESCE_IDLE_ROUNDS = 4; + private static final long DELIVERY_TIMEOUT_SECONDS = 5; + + private PulsarTestContext pulsarTestContext; + private BrokerService brokerService; + + // Real managed-ledger objects under test. + private ManagedLedgerImpl ledger; + private ManagedCursorImpl cursor; + + // Real topic + subscription; the dispatcher is a Mockito spy so we can stub the entry filter step. + private PersistentTopic topic; + private PersistentSubscription subscription; + private PersistentDispatcherSingleActiveConsumer dispatcher; + private Consumer consumer; + + // The dispatcher's ordered executor: a manual task board we drain in a chosen order. + private ManualExecutor dispatcherExecutor; + + // Everything the dispatcher handed to a Consumer#sendMessages tail, in delivery order. + private final List deliveries = Collections.synchronizedList(new ArrayList<>()); + + private final AtomicLong msgCounter = new AtomicLong(); + + private ManagedLedgerConfig initManagedLedgerConfig(ManagedLedgerConfig config) { + // Inline checkForNewEntries: the +10ms task runs synchronously at arm time -> deterministic. + config.setNewEntriesCheckDelayInMillis(0); + config.setMaxEntriesPerLedger(1_000_000); + config.setRetentionTime(1, TimeUnit.HOURS); + config.setRetentionSizeInMB(-1); + return config; + } + + // ----------------------------------------------------------------------------------------------- + // Fixture + // ----------------------------------------------------------------------------------------------- + + /** Build a fresh topic/subscription/dispatcher on top of a real ledger + cursor. */ + private void buildFixture(String ledgerName) throws Exception { + ServiceConfiguration svcConfig = new ServiceConfiguration(); + svcConfig.setBrokerShutdownTimeoutMs(0L); + svcConfig.setLoadBalancerOverrideBrokerNicSpeedGbps(Optional.of(1.0d)); + svcConfig.setClusterName("test"); + svcConfig.setActiveConsumerFailoverDelayTimeMillis(0); + svcConfig.setSystemTopicEnabled(false); + svcConfig.setTopicLevelPoliciesEnabled(false); + + // Real BrokerService via PulsarTestContext, but backed by the real MockedBookKeeper-based + // ManagedLedgerFactory so we open a REAL ledger + cursor. + pulsarTestContext = PulsarTestContext.builderForNonStartableContext() + .config(svcConfig) + .spyByDefault() + .managedLedgerClients(bkc, factory) + .build(); + brokerService = pulsarTestContext.getBrokerService(); + + ledger = (ManagedLedgerImpl) factory.open(ledgerName, initManagedLedgerConfig(new ManagedLedgerConfig())); + topic = new PersistentTopic(TOPIC, ledger, brokerService); + // Open the cursor AFTER topic construction so the topic does not auto-create a second + // subscription sharing this cursor. + cursor = (ManagedCursorImpl) ledger.openCursor("sub"); + // A real subscription that adopts the spied dispatcher through the production test seam, so + // addConsumer/removeConsumer drive the real consumer lifecycle. The returned reference must be + // qualified: the inherited PersistentSubscription.dispatcher field would otherwise shadow ours. + subscription = new PersistentSubscription(topic, "sub", cursor, false) { + @Override + protected Dispatcher reuseOrCreateDispatcher(Dispatcher existingDispatcher, Consumer newConsumer) { + return PersistentDispatcherSingleActiveConsumerStuckReadTest.this.dispatcher; + } + }; + + // Inject the manual dispatcher executor via getTopicOrderedExecutor().chooseThread(), scoped to + // just the dispatcher construction so unrelated broker work keeps using the real executor. + OrderedExecutor realTopicOrdered = brokerService.getTopicOrderedExecutor(); + dispatcherExecutor = new ManualExecutor(); + OrderedExecutor topicOrdered = mock(OrderedExecutor.class); + doReturn(dispatcherExecutor).when(topicOrdered).chooseThread(); + doReturn(dispatcherExecutor).when(topicOrdered).chooseThread(any()); + doReturn(topicOrdered).when(brokerService).getTopicOrderedExecutor(); + dispatcher = spy(new PersistentDispatcherSingleActiveConsumer(cursor, SubType.Failover, -1, topic, + subscription)); + doReturn(realTopicOrdered).when(brokerService).getTopicOrderedExecutor(); + + // The published payloads are raw bytes, not serialized Pulsar messages, so the metadata-parsing + // filter step is stubbed out. The rest of the delivery tail (dispatchEntriesToConsumer -> + // Consumer#sendMessages) is real production code. + doReturn(0).when(dispatcher).filterEntriesForConsumer(any(), any(), any(), any(), any(), + anyBoolean(), any()); + + deliveries.clear(); + consumer = newMockConsumer("c1"); + } + + /** + * A mock consumer whose {@code sendMessages} tail records what the dispatcher delivered, releases the + * entries and completes immediately. The succeeded future notifies its listener inline, so the + * production {@code readMoreEntries} continuation lands on the manual board exactly as it does in + * production. + */ + private Consumer newMockConsumer(String consumerName) { + Consumer mockConsumer = mock(Consumer.class); + doReturn(1000).when(mockConsumer).getAvailablePermits(); + doReturn(1).when(mockConsumer).getAvgMessagesPerEntry(); + doReturn(true).when(mockConsumer).isWritable(); + doReturn(false).when(mockConsumer).readCompacted(); + doReturn(false).when(mockConsumer).isPreciseDispatcherFlowControl(); + doReturn(false).when(mockConsumer).isBlocked(); + doReturn(consumerName).when(mockConsumer).consumerName(); + doReturn(0).when(mockConsumer).getPriorityLevel(); + doReturn(0L).when(mockConsumer).getConsumerEpoch(); + doReturn(SubType.Failover).when(mockConsumer).subType(); + // PersistentSubscription.removeConsumer folds the removed consumer's counters into the subscription. + doReturn(new ConsumerStatsImpl()).when(mockConsumer).getStats(); + doAnswer(inv -> { + List entries = inv.getArgument(0); + for (Entry entry : entries) { + deliveries.add(Delivery.of(consumerName, entry.getPosition())); + entry.release(); + } + EntryBatchSizes batchSizes = inv.getArgument(1); + if (batchSizes != null) { + batchSizes.recyle(); + } + EntryBatchIndexesAcks batchIndexesAcks = inv.getArgument(2); + if (batchIndexesAcks != null) { + batchIndexesAcks.recycle(); + } + return ImmediateEventExecutor.INSTANCE.newSucceededFuture(null); + }).when(mockConsumer).sendMessages(any(), any(), any(), anyInt(), anyLong(), anyLong(), any(), anyLong()); + return mockConsumer; + } + + private void tearDownFixture() { + try { + if (cursor != null && !cursor.isClosed()) { + cursor.close(); + } + } catch (Exception ignore) { + // best-effort cleanup + } + try { + if (ledger != null) { + ledger.close(); + } + } catch (Exception ignore) { + // best-effort cleanup + } + try { + if (pulsarTestContext != null) { + pulsarTestContext.close(); + } + } catch (Exception ignore) { + // best-effort cleanup + } + cursor = null; + ledger = null; + brokerService = null; + pulsarTestContext = null; + } + + @AfterMethod(alwaysRun = true) + public void afterMethod() { + tearDownFixture(); + } + + // ----------------------------------------------------------------------------------------------- + // Manual executor task board + // ----------------------------------------------------------------------------------------------- + + /** A deterministic executor: submitted Runnables queue up and run only when the test drains them. */ + static final class ManualExecutor extends AbstractExecutorService { + private final ArrayDeque queue = new ArrayDeque<>(); + + @Override + public synchronized void execute(Runnable command) { + queue.add(command); + } + + synchronized int size() { + return queue.size(); + } + + /** Remove and return all currently-queued tasks (tasks added later are NOT included). */ + synchronized List takeAll() { + List snapshot = new ArrayList<>(queue); + queue.clear(); + return snapshot; + } + + /** Run one task FIFO; returns false if empty. */ + boolean runOne() { + Runnable r; + synchronized (this) { + r = queue.poll(); + } + if (r == null) { + return false; + } + r.run(); + return true; + } + + @Override + public void shutdown() { + } + + @Override + public List shutdownNow() { + return takeAll(); + } + + @Override + public boolean isShutdown() { + return false; + } + + @Override + public boolean isTerminated() { + return false; + } + + @Override + public boolean awaitTermination(long timeout, TimeUnit unit) { + return true; + } + } + + // ----------------------------------------------------------------------------------------------- + // Operation alphabet (each drives REAL code) + // ----------------------------------------------------------------------------------------------- + + /** Connect a consumer via the REAL PersistentSubscription.addConsumer path; for Failover with a zero + * failover delay this arms the tail-wait read synchronously through the dispatcher's + * scheduleReadOnActiveConsumer -> readMoreEntries. */ + private void connect(Consumer consumerToAdd) throws Exception { + subscription.addConsumer(consumerToAdd).get(); + } + + /** + * Disconnect a consumer via the REAL {@code PersistentSubscription.removeConsumer(consumer, false)} path, + * so dispatcher removal (which calls {@code cancelPendingRead()}), {@code deactivateCursor()} and + * {@code removeWaitingCursor()} all run in the production order. For a durable cursor the subscription + * deliberately does not cancel the pending read — see the durable-cursor comment in + * {@code PersistentSubscription#removeConsumer}. + */ + private void disconnect(Consumer consumerToRemove) throws Exception { + subscription.removeConsumer(consumerToRemove, false); + } + + private Position publish() throws Exception { + return ledger.addEntry(("m" + msgCounter.incrementAndGet()).getBytes()); + } + + private void ackUpTo(Position p) throws Exception { + cursor.markDelete(p); + } + + /** Block (with an explicit timeout) until the dispatcher board holds at least {@code n} tasks. */ + private void awaitBoardHasAtLeast(int n) { + Awaitility.await("dispatcher board should hold at least " + n + " task(s)") + .atMost(Duration.ofSeconds(BOARD_WAIT_SECONDS)) + .pollInterval(Duration.ofMillis(2)) + .until(() -> dispatcherExecutor.size() >= n); + } + + /** + * Drain the dispatcher board until nothing new is produced, with a hard deadline so a regression can + * never hang CI. Async read completions posted by the (real) managed-ledger worker pool land on the + * board between idle rounds. + */ + private void quiesce() { + long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(QUIESCE_TIMEOUT_SECONDS); + for (int idleRounds = 0; idleRounds < QUIESCE_IDLE_ROUNDS;) { + if (System.nanoTime() > deadlineNanos) { + throw new IllegalStateException("quiesce() did not settle within " + QUIESCE_TIMEOUT_SECONDS + + "s; board=" + dispatcherExecutor.size() + " pendingReadOps=" + + cursor.getPendingReadOpsCount()); + } + boolean did = false; + while (dispatcherExecutor.runOne()) { + did = true; + } + if (did) { + idleRounds = 0; + continue; + } + if (dispatcherExecutor.size() == 0 && cursor.getPendingReadOpsCount() == 0) { + idleRounds++; + } + sleepQuietly(); + } + } + + /** + * Keep draining the dispatcher board until {@code position} has been handed to {@code target}'s + * {@code sendMessages}, with a hard deadline so a regression fails instead of hanging CI. + */ + private boolean pumpUntilDelivered(Consumer target, Position position) { + Delivery expected = Delivery.of(target.consumerName(), position); + long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(DELIVERY_TIMEOUT_SECONDS); + do { + while (dispatcherExecutor.runOne()) { + // A delivery posts follow-up work onto the board; keep draining before re-checking. + } + if (deliveries.contains(expected)) { + return true; + } + sleepQuietly(); + } while (System.nanoTime() < deadlineNanos); + return false; + } + + private static void sleepQuietly() { + try { + Thread.sleep(3); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + // ----------------------------------------------------------------------------------------------- + // State snapshot + predicates + // ----------------------------------------------------------------------------------------------- + + /** One entry handed to a consumer by the dispatcher's real delivery tail. */ + private record Delivery(String consumerName, long ledgerId, long entryId) { + + static Delivery of(String consumerName, Position position) { + return new Delivery(consumerName, position.getLedgerId(), position.getEntryId()); + } + + @Override + public String toString() { + return consumerName + "@" + ledgerId + ":" + entryId; + } + } + + /** + * A snapshot of the four coupled state variables. {@code inQueue} is derived from + * {@code ledger.getWaitingCursorsCount()}; the single-cursor fixture makes that ledger-wide count a + * per-cursor membership proxy (0 ⇔ this cursor is not queued). + */ + private record StuckState(boolean armed, boolean inQueue, int pendingReadOps, boolean havePendingRead) { + + /** The absorbing orphan state: armed, not queued, no in-flight read, and havePendingRead cleared. */ + boolean stranded() { + return armed && !inQueue && pendingReadOps == 0 && !havePendingRead; + } + + /** The benign seed: armed with havePendingRead cleared, but still queued (so not yet stranded). */ + boolean seed() { + return armed && inQueue && !havePendingRead; + } + + @Override + public String toString() { + return "armed=" + armed + " inQueue=" + inQueue + " pendingReadOps=" + pendingReadOps + + " havePendingRead=" + havePendingRead; + } + } + + private StuckState snapshot() { + return new StuckState(cursor.hasPendingReadRequest(), ledger.getWaitingCursorsCount() >= 1, + cursor.getPendingReadOpsCount(), dispatcher.havePendingRead); + } + + /** + * Tail shared by both schedules, asserting the externally observable contract of #26164: after the last + * consumer disconnects and a fresh consumer reconnects to the same durable subscription, a message + * published afterwards must actually be delivered to that consumer. On buggy master the primary + * schedule leaves the cursor in the absorbing orphan state, the reconnect's re-arm CAS-fails and the + * message never reaches {@code sendMessages}. + */ + private void assertSubscriptionDeliversAfterReconnect(String schedule, StuckState preDisconnect) + throws Exception { + disconnect(consumer); + quiesce(); + StuckState postDisconnect = snapshot(); + log.info("post-disconnect quiescent state state={}", postDisconnect); + + // A client reconnect: a brand new consumer takes over the (still durable) subscription. + Consumer reconnected = newMockConsumer("c2"); + connect(reconnected); + quiesce(); + StuckState postReconnect = snapshot(); + log.info("post-reconnect quiescent state state={}", postReconnect); + + Position m2 = publish(); + boolean deliveredM2 = pumpUntilDelivered(reconnected, m2); + quiesce(); + StuckState afterWake = snapshot(); + log.info("post-wake-publish state state={} delivered={}", afterWake, deliveredM2); + + String states = "schedule=[" + schedule + "] pre-disconnect=[" + preDisconnect + "] post-disconnect=[" + + postDisconnect + "] post-reconnect=[" + postReconnect + "] after-wake-publish=[" + afterWake + + "] deliveries=" + deliveries; + + // Primary, behavioural: the reconnected consumer must actually receive the new message. + Assert.assertTrue(deliveredM2, + "BUG REPRODUCED: after the last consumer disconnected and a new one reconnected, the message " + + "published next (" + m2 + ") never reached the reconnected consumer's sendMessages within " + + DELIVERY_TIMEOUT_SECONDS + "s; the Failover subscription is permanently stuck. " + states); + Assert.assertTrue(cursor.getReadPosition().compareTo(m2) > 0, + "the cursor read position must have advanced past the delivered message " + m2 + ", but it is " + + cursor.getReadPosition() + ". " + states); + + // Secondary, diagnostic: name the internal tuple that causes the stall, so a failure points at it. + Assert.assertFalse(afterWake.stranded(), + "the cursor must not be left in the absorbing orphan state (armed waitingReadOp, absent from " + + "ManagedLedger.waitingCursors, no in-flight read, havePendingRead=false). " + states); + } + + /** Stage the shared precondition: an armed tail-wait read plus a read completion pending on the board. */ + private void stageInFlightReadCompletion() throws Exception { + // Arm the tail-wait read: addConsumer -> scheduleReadOnActiveConsumer -> readMoreEntries. + connect(consumer); + Assert.assertTrue(cursor.hasPendingReadRequest(), "the tail-wait read op should be armed after addConsumer"); + Assert.assertEquals(ledger.getWaitingCursorsCount(), 1, "the cursor should be registered in waitingCursors"); + Assert.assertTrue(dispatcher.havePendingRead, "havePendingRead should be true while the tail read is armed"); + + // Publish m1: notifyCursors -> notifyEntriesAvailable dispatches the armed op's read, advancing the + // read position past m1 and posting readEntriesComplete onto the board. The wait op is now consumed, + // so hasPendingReadRequest() is false, but havePendingRead is still true. + Position m1 = publish(); + awaitBoardHasAtLeast(1); + Assert.assertFalse(cursor.hasPendingReadRequest(), + "the armed op should have been consumed by notifyEntriesAvailable"); + + // Telescoped ack: mark-delete m1 before its already-dispatched read completes. This lands the + // redeliver's subsequent rewind at the tail with no backlog, so the re-arm is a pure in-memory + // tail-wait and no BookKeeper read escapes the ManualExecutor to race quiesce(). The staged state + // (mark-delete at tail + an already-dispatched read whose completion is still pending) is + // production-reachable via a redeliver->ack->redeliver sequence (see the class Javadoc). + ackUpTo(m1); + + // Redeliver: posts internalRedeliver onto the board, behind the still-pending read completion. + dispatcher.redeliverUnacknowledgedMessages(consumer, 1L); + awaitBoardHasAtLeast(2); + } + + /** + * The board now holds exactly two tasks from two different producers: + *

    + *
  1. {@code readEntriesComplete} — posted by the managed-ledger/BookKeeper completion chain + * ({@code whenCompleteAsync});
  2. + *
  3. {@code internalRedeliver} — posted by {@code redeliverUnacknowledgedMessages} (the + * client-command thread).
  4. + *
+ */ + private List takeTwoRacingTasks() { + List board = dispatcherExecutor.takeAll(); + Assert.assertEquals(board.size(), 2, + "the board must hold exactly two tasks from two producers: the read completion " + + "(posted by the managed-ledger/BookKeeper completion chain) and internalRedeliver " + + "(posted by the client-command thread)"); + return board; + } + + // =============================================================================================== + // (a) PRIMARY reproduction: a redeliver races the completion of an already-dispatched read. m1's ack is + // telescoped (applied before that read completes) so the redeliver's rewind lands at the tail and the + // re-arm is a pure in-memory tail-wait -- fully deterministic. See the class Javadoc fidelity notes. + // =============================================================================================== + + @Test(groups = "broker") + public void testFailoverConsumerStuckWhenRedeliverRacesInFlightReadCompletion() throws Exception { + buildFixture("sac-stuck-read-race"); + stageInFlightReadCompletion(); + List board = takeTwoRacingTasks(); + + // Critical interleaving: run the redeliver's re-arm BEFORE the stale read completion. internalRedeliver + // clears havePendingRead and arms a fresh read; the stale completion then clears havePendingRead again + // while the fresh op stays armed -> the seed state. + board.get(1).run(); // internalRedeliver + board.get(0).run(); // stale readEntriesComplete + quiesce(); + + // Observation only (NOT asserted): the race mints the seed (armed op + havePendingRead=false, still + // queued). The seed is deliberately not asserted -- a completion-side fix (a read-generation guard on + // havePendingRead) would prevent the seed from ever forming, so binding on it would couple this test to + // one fix strategy. The binding assertions are the behavioural ones after the disconnect + reconnect. + StuckState seed = snapshot(); + log.info("post-redeliver-race quiescent state (observation only) state={} seedPreconditionHeld={}", + seed, seed.seed()); + + assertSubscriptionDeliversAfterReconnect("redeliver, then stale completion", seed); + } + + // =============================================================================================== + // (b) NEGATIVE CONTROL: the same setup but the benign FIFO order [stale completion, then redeliver]. + // The invariant holds end to end; this PASSES on master. + // =============================================================================================== + + @Test(groups = "broker") + public void testRedeliverAfterReadCompletionDoesNotStrandCursor() throws Exception { + buildFixture("sac-stuck-read-fifo"); + stageInFlightReadCompletion(); + List board = takeTwoRacingTasks(); + + // Benign FIFO order: the stale completion runs FIRST (clearing havePendingRead), then the redeliver + // re-arms and sets havePendingRead=true. The armed op and havePendingRead stay coupled, so no seed + // forms and the later disconnect can cancel the op cleanly. + board.get(0).run(); // stale readEntriesComplete + board.get(1).run(); // internalRedeliver + quiesce(); + + StuckState healthy = snapshot(); + log.info("post-FIFO-race quiescent state state={}", healthy); + Assert.assertFalse(healthy.stranded(), "the benign FIFO order must not strand the cursor: " + healthy); + + // The disconnect now finds havePendingRead=true, so cancelPendingRead actually cancels the armed op: + // no orphan is created, the reconnect re-arms cleanly and the next publish is delivered. + assertSubscriptionDeliversAfterReconnect("stale completion, then redeliver (negative control)", healthy); + } +} diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentDispatcherSingleActiveConsumerTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentDispatcherSingleActiveConsumerTest.java index 466f55436ea40..10295d06dac87 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentDispatcherSingleActiveConsumerTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentDispatcherSingleActiveConsumerTest.java @@ -119,7 +119,7 @@ public void testSkipReadEntriesFromCloseCursor() throws Exception { Mockito.doAnswer(inv -> { callReadEntriesFailed.getAndIncrement(); return inv.callRealMethod(); - }).when(dispatcher).readEntriesFailed(Mockito.any(), Mockito.any()); + }).when(dispatcher).readEntriesFailed(Mockito.any(), Mockito.any(), Mockito.anyLong()); Mockito.doReturn(false).when(cursor).isClosed(); diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentDispatcherTotalBytesTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentDispatcherTotalBytesTest.java new file mode 100644 index 0000000000000..239a698ab011b --- /dev/null +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentDispatcherTotalBytesTest.java @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.broker.service.persistent; + +import static org.testng.Assert.assertEquals; +import java.util.ArrayList; +import org.apache.bookkeeper.mledger.Entry; +import org.apache.bookkeeper.mledger.impl.EntryImpl; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +@Test(groups = "broker") +public class PersistentDispatcherTotalBytesTest { + + @DataProvider(name = "entryCounts") + public Object[][] entryCounts() { + return new Object[][] { + {0}, + {1}, + {32}, + {1024} + }; + } + + @Test(dataProvider = "entryCounts") + public void testGetTotalBytesSize(int entryCount) { + EntriesAndExpectedSize entries = entriesWithVaryingPayloadSizes(entryCount); + try { + assertEquals(AbstractPersistentDispatcherMultipleConsumers.getTotalBytesSize(entries.entries), + entries.expectedSize); + } finally { + entries.release(); + } + } + + private static EntriesAndExpectedSize entriesWithVaryingPayloadSizes(int entryCount) { + EntriesAndExpectedSize entries = new EntriesAndExpectedSize(entryCount); + for (int i = 0; i < entryCount; i++) { + int payloadSize = payloadSize(i); + entries.entries.add(EntryImpl.create(1, i, new byte[payloadSize])); + entries.expectedSize += payloadSize; + } + return entries; + } + + private static int payloadSize(int index) { + return index % 97 == 0 ? 4096 : 1 + ((index * 31) & 1023); + } + + private static final class EntriesAndExpectedSize { + private final ArrayList entries; + private long expectedSize; + + private EntriesAndExpectedSize(int entryCount) { + this.entries = new ArrayList<>(entryCount); + } + + private void release() { + entries.forEach(Entry::release); + } + } +} diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentReplicatorInflightTaskTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentReplicatorInflightTaskTest.java index c825d49caa62e..9a01a40a35e55 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentReplicatorInflightTaskTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentReplicatorInflightTaskTest.java @@ -18,40 +18,76 @@ */ package org.apache.pulsar.broker.service.persistent; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; import static org.testng.Assert.assertTrue; +import io.netty.channel.EventLoopGroup; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashSet; import java.util.LinkedList; import java.util.List; +import java.util.Optional; +import java.util.Set; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import lombok.Cleanup; import lombok.extern.slf4j.Slf4j; import org.apache.bookkeeper.mledger.Entry; +import org.apache.bookkeeper.mledger.ManagedCursor; import org.apache.bookkeeper.mledger.ManagedLedgerException; import org.apache.bookkeeper.mledger.Position; import org.apache.bookkeeper.mledger.PositionFactory; import org.apache.bookkeeper.mledger.impl.ManagedLedgerImpl; import org.apache.bookkeeper.mledger.impl.ManagedLedgerTest; import org.apache.pulsar.broker.BrokerTestUtil; +import org.apache.pulsar.broker.PulsarServerException; +import org.apache.pulsar.broker.PulsarService; +import org.apache.pulsar.broker.ServiceConfiguration; import org.apache.pulsar.broker.service.AbstractReplicator; -import org.apache.pulsar.broker.service.BrokerServiceInternalMethodInvoker; +import org.apache.pulsar.broker.service.BrokerService; import org.apache.pulsar.broker.service.OneWayReplicatorTestBase; import org.apache.pulsar.broker.service.persistent.PersistentReplicator.InFlightTask; +import org.apache.pulsar.broker.service.persistent.PersistentReplicator.ProducerSendCallback; +import org.apache.pulsar.broker.service.persistent.PersistentReplicator.ReasonOfWaitForCursorRewinding; +import org.apache.pulsar.client.admin.PulsarAdmin; +import org.apache.pulsar.client.api.Consumer; +import org.apache.pulsar.client.api.Message; import org.apache.pulsar.client.api.MessageId; import org.apache.pulsar.client.api.Producer; +import org.apache.pulsar.client.api.ProducerBuilder; +import org.apache.pulsar.client.api.PulsarClientException; import org.apache.pulsar.client.api.Schema; +import org.apache.pulsar.client.api.SubscriptionInitialPosition; +import org.apache.pulsar.client.impl.PulsarClientImpl; +import org.apache.pulsar.common.policies.data.HierarchyTopicPolicies; import org.awaitility.Awaitility; import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; import org.testng.Assert; import org.testng.annotations.AfterClass; import org.testng.annotations.BeforeClass; +import org.testng.annotations.DataProvider; import org.testng.annotations.Test; @Slf4j @@ -130,6 +166,10 @@ public void testReadEntriesFailedCompletesInFlightTaskAfterReplicatorTerminated( PersistentTopic topic = (PersistentTopic) pulsar1.getBrokerService().getTopic(topicName, false) .join().get(); ManagedLedgerImpl ml = (ManagedLedgerImpl) topic.getManagedLedger(); + // errorOrNot blocks on failRead, so run it on a dedicated single-threaded executor instead of the + // calling read thread; otherwise the blocked read thread would stall replicator.terminate(). + @Cleanup("shutdownNow") + ExecutorService readFailExecutor = Executors.newSingleThreadExecutor(); ManagedLedgerTest.makeReadEntryProbFail(ml, () -> { readStarted.countDown(); try { @@ -141,7 +181,7 @@ public void testReadEntriesFailedCompletesInFlightTaskAfterReplicatorTerminated( return new ManagedLedgerException(e); } return new ManagedLedgerException.TooManyRequestsException("mocked read failure"); - }); + }, readFailExecutor); pulsar1.getConfig().setReplicationStartAt("earliest"); admin1.topics().setReplicationClusters(topicName, Arrays.asList(cluster1, cluster2)); @@ -167,6 +207,421 @@ public void testReadEntriesFailedCompletesInFlightTaskAfterReplicatorTerminated( } } + @Test + public void testFailedPublishCompletesInFlightTask() throws Exception { + PersistentReplicator replicator = spy(getReplicator(topicName)); + doNothing().when(replicator).beforeTerminateOrCursorRewinding(ReasonOfWaitForCursorRewinding.Failed_Publishing); + doNothing().when(replicator).doRewindCursor(false); + doNothing().when(replicator).readMoreEntries(); + + LinkedList inFlightTasks = replicator.inFlightTasks; + List originalTasks = new ArrayList<>(inFlightTasks); + inFlightTasks.clear(); + + try { + InFlightTask task = new InFlightTask(PositionFactory.create(1, 1), 1, replicator.getReplicatorId()); + task.setEntries(Collections.singletonList(mock(Entry.class))); + inFlightTasks.add(task); + assertEquals(replicator.getPermitsIfNoPendingRead(), 999); + + ProducerSendCallback callback = ProducerSendCallback.create(replicator, mock(Entry.class), null, task); + callback.sendComplete(new PulsarClientException.ProducerBlockedQuotaExceededException("mocked"), null); + + assertTrue(task.isDone()); + assertEquals(replicator.getPermitsIfNoPendingRead(), 1000); + } finally { + inFlightTasks.clear(); + inFlightTasks.addAll(originalTasks); + } + } + + /** + * Reproduces a geo-replication stall on the cursor-rewind path. + * + *

When a cursor rewind happens while a cursor read has already been dispatched to bookies, + * {@code cursor.cancelPendingReadRequest()} returns {@code false} (there is no registered waiting + * read op to cancel), so {@code cancelPendingReadTasks()} only flags the in-flight task with + * {@code skipReadResultDueToCursorRewind=true} without completing it. When that dispatched read + * later completes, {@link PersistentReplicator#readEntriesComplete} hits the skip branch and must + * still complete the task; otherwise the task stays {@code entries == null} forever, + * {@link PersistentReplicator#hasPendingRead()} stays {@code true}, all permits remain occupied, + * and reads never resume — replication stalls with backlog. + */ + @Test + public void testCursorRewindSkippedReadCompletesInFlightTask() throws Exception { + PersistentReplicator replicator = spy(getReplicator(topicName)); + // Isolate the unit: don't issue a real cursor read, only verify reads are resumed. + doNothing().when(replicator).readMoreEntries(); + + LinkedList inFlightTasks = replicator.inFlightTasks; + List originalTasks = new ArrayList<>(inFlightTasks); + inFlightTasks.clear(); + + try { + int fullPermits = replicator.getPermitsIfNoPendingRead(); + assertTrue(fullPermits > 0, "precondition: replicator should have free permits"); + + // A pending cursor read (entries == null) flagged to be skipped because of a cursor rewind + // whose pending read could not be cancelled (cancelPendingReadRequest() returned false). + InFlightTask task = + new InFlightTask(PositionFactory.create(1, 1), 1, replicator.getReplicatorId()); + task.setSkipReadResultDueToCursorRewind(true); + inFlightTasks.add(task); + + // Precondition: the uncompleted task blocks all reads. + assertTrue(replicator.hasPendingRead(), "precondition: task must look like a pending read"); + assertEquals(replicator.getPermitsIfNoPendingRead(), 0, + "precondition: pending read must occupy all permits"); + + // The dispatched read finally completes; its result is discarded because of the rewind. + replicator.readEntriesComplete(Collections.singletonList(mock(Entry.class)), task); + + // The task must be completed so it no longer blocks replication. + assertTrue(task.isDone(), "skipped read must complete the in-flight task"); + assertFalse(replicator.hasPendingRead(), + "replication must not stay stuck on an uncompleted pending read"); + assertEquals(replicator.getPermitsIfNoPendingRead(), fullPermits, + "permits must be released after the skipped read completes"); + // Reads must be resumed once the slot is freed (dispatched on the broker executor to + // avoid recursing in the read-completion thread). + Awaitility.await().untilAsserted(() -> verify(replicator, atLeastOnce()).readMoreEntries()); + } finally { + inFlightTasks.clear(); + inFlightTasks.addAll(originalTasks); + } + } + + /** + * End-to-end reproduction of the cursor-rewind stall over a real two-cluster replication setup. + * + *

A real cursor read is held in flight (entries == null) while the cursor is rewound the same way + * {@link ProducerSendCallback#sendComplete} does on a failed publish to the remote cluster: + * {@code beforeTerminateOrCursorRewinding(Failed_Publishing)} followed by {@code doRewindCursor(false)}. + * Because the read was already dispatched, {@code cursor.cancelPendingReadRequest()} returns false, so + * {@code cancelPendingReadTasks()} only flags the task and does not complete it. When the dispatched + * read then completes through the skip branch, the task must still be completed and reads resumed — + * otherwise the replicator stalls and the backlog is never delivered to the remote cluster. + * + *

Before the fix this test times out: the task stays {@code entries == null}, {@code hasPendingRead()} + * stays true, and the backlog never drains. + */ + @Test + public void testReplicationRecoversAfterPublishFailureRewindWithInflightRead() throws Exception { + final String topicName = BrokerTestUtil.newUniqueName("persistent://" + nonReplicatedNamespace + "/tp_"); + final int messageCount = 5; + final CountDownLatch readBlocked = new CountDownLatch(1); + final CountDownLatch releaseRead = new CountDownLatch(1); + final AtomicBoolean blockNextRead = new AtomicBoolean(true); + Producer producer = null; + Consumer remoteConsumer = null; + try { + admin1.topics().createNonPartitionedTopic(topicName); + admin2.topics().createNonPartitionedTopic(topicName); + // Create the verifier subscription on the remote topic up front so replicated messages are + // retained for the end-to-end assertion regardless of retention policy. + admin2.topics().createSubscription(topicName, "e2e-verify", MessageId.earliest); + + // Produce a backlog before replication starts so the replicator must read it from the ledger. + producer = client1.newProducer(Schema.STRING).topic(topicName).enableBatching(false).create(); + for (int i = 0; i < messageCount; i++) { + producer.send("msg-" + i); + } + + PersistentTopic topic = (PersistentTopic) pulsar1.getBrokerService().getTopic(topicName, false) + .join().get(); + ManagedLedgerImpl ml = (ManagedLedgerImpl) topic.getManagedLedger(); + // Clear the entry cache and intercept ledger reads: block the first read so it stays in flight + // (the in-flight task keeps entries == null), then let it and all later reads succeed. + // errorOrNot blocks the first read on releaseRead, so run it on a dedicated single-threaded executor + // instead of the calling read thread. + @Cleanup("shutdownNow") + ExecutorService readFailExecutor = Executors.newSingleThreadExecutor(); + ManagedLedgerTest.makeReadEntryProbFail(ml, () -> { + if (blockNextRead.compareAndSet(true, false)) { + readBlocked.countDown(); + try { + releaseRead.await(30, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + // Never fail the read; it must complete successfully to reach the skip branch. + return null; + }, readFailExecutor); + + // Start replication from earliest; the first read blocks inside the ledger read (in flight). + pulsar1.getConfig().setReplicationStartAt("earliest"); + admin1.topics().setReplicationClusters(topicName, Arrays.asList(cluster1, cluster2)); + assertTrue(readBlocked.await(30, TimeUnit.SECONDS), "the replicator's cursor read should start"); + + PersistentReplicator replicator = (PersistentReplicator) topic.getReplicators().get(cluster2); + Assert.assertNotNull(replicator, "Replicator should not be null"); + // The dispatched read is in flight and occupies the only read slot. + assertTrue(replicator.hasPendingRead()); + + // Rewind the cursor exactly as a failed publish to the remote cluster does. The in-flight read + // was already dispatched, so cancelPendingReadRequest() returns false and the task is only + // flagged for skipping (not completed). + replicator.beforeTerminateOrCursorRewinding(ReasonOfWaitForCursorRewinding.Failed_Publishing); + replicator.doRewindCursor(false); + + // The in-flight read now completes successfully and is discarded by the skip branch. + releaseRead.countDown(); + + // Recovery: the freed slot lets reading resume, so the whole backlog is replicated and the + // cursor mark-delete advances, draining the backlog to 0. Before the fix the leaked task keeps + // getPermitsIfNoPendingRead() == 0, so no read is ever issued and the backlog stays at + // messageCount (this await times out). Note: hasPendingRead() is intentionally NOT used as the + // recovery signal because a healthy idle replicator also holds a pending "wait for new entries" + // read, so it stays true even after a successful recovery. + Awaitility.await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> + assertEquals(replicator.getNumberOfEntriesInBacklog(), 0, + "replication backlog must drain after recovery")); + + // End-to-end: verify every produced message is delivered to the remote cluster, with no loss + // and (in this single-batch scenario, where the discarded read sent nothing) no duplicates. + remoteConsumer = client2.newConsumer(Schema.STRING).topic(topicName) + .subscriptionName("e2e-verify") + .subscriptionInitialPosition(SubscriptionInitialPosition.Earliest) + .subscribe(); + List deliveredValues = new ArrayList<>(); + for (int i = 0; i < messageCount; i++) { + Message received = remoteConsumer.receive(30, TimeUnit.SECONDS); + Assert.assertNotNull(received, "remote cluster should receive replicated message " + i); + deliveredValues.add(received.getValue()); + remoteConsumer.acknowledge(received); + } + // No duplicate/extra messages were replicated on the rewind/recovery path. + Assert.assertNull(remoteConsumer.receive(3, TimeUnit.SECONDS), + "no duplicate messages should be replicated after recovery"); + Set expectedValues = new HashSet<>(); + for (int i = 0; i < messageCount; i++) { + expectedValues.add("msg-" + i); + } + assertEquals(new HashSet<>(deliveredValues), expectedValues, + "every produced message must be replicated exactly once (no loss)"); + } finally { + releaseRead.countDown(); + if (producer != null) { + producer.close(); + } + if (remoteConsumer != null) { + remoteConsumer.close(); + } + admin1.topics().delete(topicName, true); + admin2.topics().delete(topicName, true); + } + } + + /** + * End-to-end reproduction of the cursor-rewind stall on the schema-fetch path. + * + *

{@link GeoPersistentReplicator#replicateEntries} rewinds the cursor when it reaches a message + * whose schema is not yet available locally: it calls + * {@code beforeTerminateOrCursorRewinding(Fetching_Schema)} synchronously and, once the schema has + * been fetched, {@code doRewindCursor(true)} (which rewinds the cursor and resumes reads). If a + * cursor read was already dispatched when the schema-fetch rewind happens, + * {@code cursor.cancelPendingReadRequest()} returns false, so the in-flight task is only flagged + * for skipping and is not completed. When that dispatched read later completes through the + * {@link PersistentReplicator#readEntriesComplete} skip branch it must still be completed and reads + * resumed; otherwise the task stays {@code entries == null}, {@link + * PersistentReplicator#hasPendingRead()} stays true, all permits remain occupied and replication + * stalls with backlog. This is the same root cause as + * {@link #testReplicationRecoversAfterPublishFailureRewindWithInflightRead}, reached through the + * {@code Fetching_Schema} rewind reason (with {@code doRewindCursor(true)}) instead of + * {@code Failed_Publishing}; it is the stall observed as the flaky + * {@code ReplicatorTest.testReplicationWithSchema}. + * + *

The schema-fetch rewind is driven directly (rather than by crossing a real schema boundary) + * because the bug requires a cursor read to be in flight at the exact moment the rewind runs. On + * the natural path that needs a read to be pipelined — dispatched by an earlier message's + * {@code sendComplete} — concurrently with {@code replicateEntries} reaching the schema boundary, + * an inherently racy interleaving that cannot be reproduced deterministically (which is why + * {@code testReplicationWithSchema} only fails intermittently). This test issues the same two + * rewind calls ({@code beforeTerminateOrCursorRewinding(Fetching_Schema)} then + * {@code doRewindCursor(true)}) that {@code GeoPersistentReplicator.replicateEntries} issues at a + * schema boundary; in production they are separated by the asynchronous schema fetch and the + * stranded read is a separately-pipelined one, so this test collapses that timing into a + * deterministic sequence on a single held-in-flight read. It therefore guards the + * {@code readEntriesComplete} skip-branch recovery for the {@code Fetching_Schema} rewind, not the + * schema-detection wiring in {@code replicateEntries} itself. + * + *

Before the fix this test times out: the task stays {@code entries == null} and the backlog + * never drains. + */ + @Test + public void testReplicationRecoversAfterSchemaFetchRewindWithInflightRead() throws Exception { + final String topicName = BrokerTestUtil.newUniqueName("persistent://" + nonReplicatedNamespace + "/tp_"); + final int messageCount = 5; + final CountDownLatch readBlocked = new CountDownLatch(1); + final CountDownLatch releaseRead = new CountDownLatch(1); + final AtomicBoolean blockNextRead = new AtomicBoolean(true); + // Capture and restore the shared per-class broker config so this method does not leak + // "earliest" into sibling tests that rely on the default replicationStartAt. + final String prevReplicationStartAt = pulsar1.getConfig().getReplicationStartAt(); + Producer producer = null; + Consumer remoteConsumer = null; + try { + admin1.topics().createNonPartitionedTopic(topicName); + admin2.topics().createNonPartitionedTopic(topicName); + // Create the verifier subscription on the remote topic up front so replicated messages are + // retained for the end-to-end assertion regardless of retention policy. + admin2.topics().createSubscription(topicName, "e2e-verify", MessageId.earliest); + + // Produce a backlog before replication starts so the replicator must read it from the ledger. + producer = client1.newProducer(Schema.STRING).topic(topicName).enableBatching(false).create(); + for (int i = 0; i < messageCount; i++) { + producer.send("msg-" + i); + } + + PersistentTopic topic = (PersistentTopic) pulsar1.getBrokerService().getTopic(topicName, false) + .join().get(); + ManagedLedgerImpl ml = (ManagedLedgerImpl) topic.getManagedLedger(); + // Clear the entry cache and intercept ledger reads: block the first read so it stays in flight + // (the in-flight task keeps entries == null), then let it and all later reads succeed. + // errorOrNot blocks the first read on releaseRead, so run it on a dedicated single-threaded executor + // instead of the calling read thread. + @Cleanup("shutdownNow") + ExecutorService readFailExecutor = Executors.newSingleThreadExecutor(); + ManagedLedgerTest.makeReadEntryProbFail(ml, () -> { + if (blockNextRead.compareAndSet(true, false)) { + readBlocked.countDown(); + try { + releaseRead.await(30, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + // Never fail the read; it must complete successfully to reach the skip branch. + return null; + }, readFailExecutor); + + // Start replication from earliest; the first read blocks inside the ledger read (in flight). + pulsar1.getConfig().setReplicationStartAt("earliest"); + admin1.topics().setReplicationClusters(topicName, Arrays.asList(cluster1, cluster2)); + assertTrue(readBlocked.await(30, TimeUnit.SECONDS), "the replicator's cursor read should start"); + + PersistentReplicator replicator = (PersistentReplicator) topic.getReplicators().get(cluster2); + Assert.assertNotNull(replicator, "Replicator should not be null"); + // The dispatched read is in flight and occupies the only read slot. + assertTrue(replicator.hasPendingRead()); + + // Rewind the cursor with the same two calls GeoPersistentReplicator.replicateEntries issues + // when it reaches a message whose schema must be fetched from the local cluster: flag the + // in-flight read for skipping (beforeTerminateOrCursorRewinding(Fetching_Schema)), then, once + // the schema has been fetched, rewind and resume reads (doRewindCursor(true)). The in-flight + // read was already dispatched, so cancelPendingReadRequest() returns false and the task is only + // flagged, not completed. doRewindCursor(true)'s readMoreEntries() is a no-op here because the + // still-pending (skip-flagged) read keeps hasPendingRead() true. + replicator.beforeTerminateOrCursorRewinding(ReasonOfWaitForCursorRewinding.Fetching_Schema); + replicator.doRewindCursor(true); + + // The in-flight read now completes successfully and is discarded by the skip branch. + releaseRead.countDown(); + + // Recovery: the freed slot lets reading resume, so the whole backlog is replicated and the + // cursor mark-delete advances, draining the backlog to 0. Before the fix the leaked task keeps + // getPermitsIfNoPendingRead() == 0, so no read is ever issued and the backlog stays at + // messageCount (this await times out). Note: hasPendingRead() is intentionally NOT used as the + // recovery signal because a healthy idle replicator also holds a pending "wait for new entries" + // read, so it stays true even after a successful recovery. + Awaitility.await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> + assertEquals(replicator.getNumberOfEntriesInBacklog(), 0, + "replication backlog must drain after recovery")); + + // End-to-end: verify every produced message is delivered to the remote cluster, with no loss + // and (in this single-batch scenario, where the discarded read sent nothing) no duplicates. + remoteConsumer = client2.newConsumer(Schema.STRING).topic(topicName) + .subscriptionName("e2e-verify") + .subscriptionInitialPosition(SubscriptionInitialPosition.Earliest) + .subscribe(); + List deliveredValues = new ArrayList<>(); + for (int i = 0; i < messageCount; i++) { + Message received = remoteConsumer.receive(30, TimeUnit.SECONDS); + Assert.assertNotNull(received, "remote cluster should receive replicated message " + i); + deliveredValues.add(received.getValue()); + remoteConsumer.acknowledge(received); + } + // No duplicate/extra messages were replicated on the rewind/recovery path. + Assert.assertNull(remoteConsumer.receive(3, TimeUnit.SECONDS), + "no duplicate messages should be replicated after recovery"); + Set expectedValues = new HashSet<>(); + for (int i = 0; i < messageCount; i++) { + expectedValues.add("msg-" + i); + } + assertEquals(new HashSet<>(deliveredValues), expectedValues, + "every produced message must be replicated exactly once (no loss)"); + } finally { + releaseRead.countDown(); + pulsar1.getConfig().setReplicationStartAt(prevReplicationStartAt); + if (producer != null) { + producer.close(); + } + if (remoteConsumer != null) { + remoteConsumer.close(); + } + admin1.topics().delete(topicName, true); + admin2.topics().delete(topicName, true); + } + } + + @DataProvider + public Object[][] readSchedulingLimits() { + return new Object[][] { + {"message permits exhausted", 0, -1L, true, false, 0, 0L}, + {"byte permits exhausted", -1, 0L, true, false, 0, 0L}, + {"message permits limit read batch", 5, -1L, true, true, 5, 1024L}, + {"byte permits limit read size", -1, 512L, true, true, 100, 512L}, + {"non-writable producer limits read batch", 5, 512L, false, true, 1, 512L} + }; + } + + @Test(dataProvider = "readSchedulingLimits") + public void testReadMoreEntriesSchedulesCursorReadWithReadLimits(String scenario, + long availableMessages, + long availableBytes, + boolean writable, + boolean expectRead, + int expectedMessages, + long expectedBytes) throws Exception { + TestReplicatorFixture fixture = newTestReplicatorFixture(writable); + PersistentReplicator replicator = fixture.replicator; + DispatchRateLimiter rateLimiter = mock(DispatchRateLimiter.class); + when(rateLimiter.isDispatchRateLimitingEnabled()).thenReturn(true); + when(rateLimiter.getAvailableDispatchRateLimitOnMsg()).thenReturn(availableMessages); + when(rateLimiter.getAvailableDispatchRateLimitOnByte()).thenReturn(availableBytes); + replicator.dispatchRateLimiter = Optional.of(rateLimiter); + + replicator.readMoreEntries(); + + if (expectRead) { + assertEquals(replicator.inFlightTasks.size(), 1, scenario); + InFlightTask inFlightTask = replicator.inFlightTasks.peek(); + verify(fixture.cursor).asyncReadEntriesOrWait(eq(expectedMessages), eq(expectedBytes), + same(replicator), same(inFlightTask), any(Position.class)); + assertEquals(inFlightTask.getReadingEntries(), expectedMessages, scenario); + verify(fixture.executor, never()).schedule(any(Runnable.class), anyLong(), any(TimeUnit.class)); + } else { + verify(fixture.cursor, never()).asyncReadEntriesOrWait(anyInt(), anyLong(), any(), any(), any()); + assertTrue(replicator.inFlightTasks.isEmpty(), scenario); + verify(fixture.executor).schedule(any(Runnable.class), eq((long) PersistentTopic.MESSAGE_RATE_BACKOFF_MS), + eq(TimeUnit.MILLISECONDS)); + } + } + + @Test + public void testReadMoreEntriesSkipsReadWhenPendingReadExists() throws Exception { + TestReplicatorFixture fixture = newTestReplicatorFixture(true); + PersistentReplicator replicator = fixture.replicator; + replicator.inFlightTasks.add(new InFlightTask(PositionFactory.create(1, 1), 5, replicator.getReplicatorId())); + + replicator.readMoreEntries(); + + verify(fixture.cursor, never()).asyncReadEntriesOrWait(anyInt(), anyLong(), any(), any(), any()); + verify(fixture.executor, never()).schedule(any(Runnable.class), anyLong(), any(TimeUnit.class)); + assertEquals(replicator.inFlightTasks.size(), 1); + } + @Test public void testCreateOrRecycleInFlightTaskIntoQueue() throws Exception { log.info("Starting testCreateOrRecycleInFlightTaskIntoQueue"); @@ -357,111 +812,110 @@ public void testGetPermitsIfNoPendingRead() throws Exception { } } - @Test - public void testAcquirePermitsIfNotFetchingSchema() throws Exception { - log.info("Starting testAcquirePermitsIfNotFetchingSchema"); - // Get the replicator for the test topic - PersistentReplicator replicator = getReplicator(topicName); - Assert.assertNotNull(replicator, "Replicator should not be null"); + @SuppressWarnings("unchecked") + private TestReplicatorFixture newTestReplicatorFixture(boolean writable) throws Exception { + ServiceConfiguration configuration = new ServiceConfiguration(); + configuration.setClusterName("local"); + configuration.setReplicationProducerQueueSize(1000); + configuration.setDispatcherMaxReadBatchSize(100); + configuration.setDispatcherMaxReadSizeBytes(1024); + + PulsarService pulsar = mock(PulsarService.class); + when(pulsar.getConfiguration()).thenReturn(configuration); + when(pulsar.getConfig()).thenReturn(configuration); + when(pulsar.getClient()).thenReturn(mock(PulsarClientImpl.class)); + when(pulsar.getAdminClient()).thenReturn(mock(PulsarAdmin.class)); + + BrokerService brokerService = mock(BrokerService.class); + EventLoopGroup executor = mock(EventLoopGroup.class); + when(brokerService.pulsar()).thenReturn(pulsar); + when(brokerService.getPulsar()).thenReturn(pulsar); + when(brokerService.executor()).thenReturn(executor); + + ProducerBuilder producerBuilder = mock(ProducerBuilder.class); + when(producerBuilder.topic(anyString())).thenReturn(producerBuilder); + when(producerBuilder.messageRoutingMode(any())).thenReturn(producerBuilder); + when(producerBuilder.enableBatching(anyBoolean())).thenReturn(producerBuilder); + when(producerBuilder.sendTimeout(anyInt(), any(TimeUnit.class))).thenReturn(producerBuilder); + when(producerBuilder.maxPendingMessages(anyInt())).thenReturn(producerBuilder); + when(producerBuilder.producerName(anyString())).thenReturn(producerBuilder); + + PulsarClientImpl replicationClient = mock(PulsarClientImpl.class); + when(replicationClient.newProducer(any(Schema.class))).thenReturn(producerBuilder); + + PersistentTopic topic = mock(PersistentTopic.class); + when(topic.getName()).thenReturn("persistent://prop/ns/test-read-scheduling"); + when(topic.getReplicatorPrefix()).thenReturn("pulsar.repl"); + when(topic.getBrokerService()).thenReturn(brokerService); + when(topic.getMaxReadPosition()).thenReturn(PositionFactory.create(1, 100)); + // PersistentReplicator's constructor reads hierarchy topic policies; without this stub + // Mockito returns null and the constructor throws an NPE. + when(topic.getHierarchyTopicPolicies()).thenReturn(new HierarchyTopicPolicies()); + + ManagedCursor cursor = mock(ManagedCursor.class); + when(cursor.getName()).thenReturn("pulsar.repl.remote"); + when(cursor.getReadPosition()).thenReturn(PositionFactory.create(1, 1)); + + TestPersistentReplicator replicator = new TestPersistentReplicator(topic, cursor, brokerService, + replicationClient, writable); + return new TestReplicatorFixture(replicator, cursor, executor); + } - // Get access to the inFlightTasks list for setup - LinkedList inFlightTasks = replicator.inFlightTasks; - Assert.assertNotNull(inFlightTasks, "InFlightTasks list should not be null"); + private static class TestReplicatorFixture { + final TestPersistentReplicator replicator; + final ManagedCursor cursor; + final EventLoopGroup executor; - // Save original tasks and clear for testing - List originalTasks = new ArrayList<>(inFlightTasks); - inFlightTasks.clear(); + TestReplicatorFixture(TestPersistentReplicator replicator, ManagedCursor cursor, EventLoopGroup executor) { + this.replicator = replicator; + this.cursor = cursor; + this.executor = executor; + } + } - // Save original state - int originalWaitForCursorRewinding = replicator.waitForCursorRewindingRefCnf; - AbstractReplicator.State originalState = replicator.getState(); + private static class TestPersistentReplicator extends PersistentReplicator { + private final boolean writable; - try { - // Test Case 1: Normal case - no pending read, not waiting for cursor rewinding, state is Started - // Should return a new InFlightTask - // First, check the current permits available - int expectedPermits = replicator.getPermitsIfNoPendingRead(); - Assert.assertTrue(expectedPermits > 0, "Should have available permits for the test"); - InFlightTask task1 = replicator.acquirePermitsIfNotFetchingSchema(); - Assert.assertNotNull(task1, "Should return a new InFlightTask in normal case"); - Assert.assertNotNull(task1.getReadPos(), "Task should have a read position"); - Assert.assertEquals(task1.getReadingEntries(), expectedPermits, - "Task readingEntries should equal the number of permits available"); - Assert.assertTrue(inFlightTasks.contains(task1), - "Task should be added to the inFlightTasks list"); - - // Test Case 2: With pending read - should return null - inFlightTasks.clear(); - Position position1 = PositionFactory.create(1, 1); - InFlightTask pendingReadTask = new InFlightTask(position1, 5, ""); - // Don't set readoutEntries to simulate pending read - inFlightTasks.add(pendingReadTask); - InFlightTask task2 = replicator.acquirePermitsIfNotFetchingSchema(); - Assert.assertNull(task2, "Should return null when there is a pending read"); + TestPersistentReplicator(PersistentTopic topic, ManagedCursor cursor, BrokerService brokerService, + PulsarClientImpl replicationClient, boolean writable) + throws PulsarServerException { + super("local", topic, cursor, "remote", topic.getName(), brokerService, replicationClient); + this.writable = writable; + this.state = State.Started; + } - // Test Case 3: With waitForCursorRewinding=true - should return null - inFlightTasks.clear(); - replicator.waitForCursorRewindingRefCnf = 1; - InFlightTask task3 = replicator.acquirePermitsIfNotFetchingSchema(); - Assert.assertNull(task3, "Should return null when waiting for cursor rewinding"); - // Reset for next test - replicator.waitForCursorRewindingRefCnf = 0; - - // Test Case 4: With state != Started - should return null - // We need to use reflection to modify the state since it's protected by AtomicReferenceFieldUpdater - BrokerServiceInternalMethodInvoker.replicatorSetState(replicator, AbstractReplicator.State.Starting); - InFlightTask task4 = replicator.acquirePermitsIfNotFetchingSchema(); - Assert.assertNull(task4, "Should return null when state is not Started"); - // Reset state for next test - BrokerServiceInternalMethodInvoker.replicatorSetState(replicator, AbstractReplicator.State.Started); - - // Test Case 5: With limited permits - verify readingEntries is set correctly - inFlightTasks.clear(); - // Add a task with some in-flight messages to reduce available permits - Position positionLimited = PositionFactory.create(10, 10); - InFlightTask limitedTask = new InFlightTask(positionLimited, 5, ""); - // Add enough entries to leave just a small number of permits (e.g., 10) - List limitedEntries = new ArrayList<>(); - int entriesCount = 990; - for (int j = 0; j < entriesCount; j++) { - limitedEntries.add(mock(Entry.class)); - } - limitedTask.setEntries(limitedEntries); - inFlightTasks.add(limitedTask); - // Check that we have limited permits available - int limitedPermits = replicator.getPermitsIfNoPendingRead(); - Assert.assertTrue(limitedPermits > 0 && limitedPermits < 20, - "Should have a small number of permits available for testing"); - // Now acquire permits and verify readingEntries matches the limited permits - InFlightTask task5 = replicator.acquirePermitsIfNotFetchingSchema(); - Assert.assertNotNull(task5, "Should return a task with limited permits"); - Assert.assertEquals(task5.getReadingEntries(), limitedPermits, - "Task readingEntries should equal the limited number of permits available"); - - // Test Case 6: With permits=0 - should return null - inFlightTasks.clear(); - // Add tasks that will make getPermitsIfNoPendingRead() return 0 - // We need enough in-flight messages to equal producerQueueSize - for (int i = 0; i < 10; i++) { - Position position = PositionFactory.create(i, i); - InFlightTask task = new InFlightTask(position, 5, ""); - List entries = new ArrayList<>(); - for (int j = 0; j < 100; j++) { - entries.add(mock(Entry.class)); - } - task.setEntries(entries); - inFlightTasks.add(task); - } - InFlightTask task6 = replicator.acquirePermitsIfNotFetchingSchema(); - Assert.assertNull(task6, "Should return null when permits is 0"); - log.info("Completed testAcquirePermitsIfNotFetchingSchema"); - } finally { - // Restore original state - replicator.waitForCursorRewindingRefCnf = originalWaitForCursorRewinding; - BrokerServiceInternalMethodInvoker.replicatorSetState(replicator, originalState); - // Restore original tasks - inFlightTasks.clear(); - inFlightTasks.addAll(originalTasks); + @Override + protected void startProducer() { + // No-op for scheduling behavior tests. + } + + @Override + protected String getProducerName() { + return "test-replicator"; + } + + @Override + protected boolean isWritable() { + return writable; } + + @Override + protected boolean replicateEntries(List entries, InFlightTask inFlightTask) { + return true; + } + } + + public static Runnable pauseReplicator(PersistentReplicator replicator) { + Awaitility.await().untilAsserted(() -> { + assertTrue(replicator.isConnected()); + }); + replicator.beforeTerminateOrCursorRewinding(PersistentReplicator.ReasonOfWaitForCursorRewinding.Disconnecting); + replicator.doRewindCursor(false); + InFlightTask inFlightTask = + replicator.createOrRecycleInFlightTaskIntoQueue(PositionFactory.create(1, 1), 1); + return () -> { + inFlightTask.setEntries(Collections.emptyList()); + replicator.readMoreEntries(); + }; } } diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentStickyKeyDispatcherMultipleConsumersTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentStickyKeyDispatcherMultipleConsumersTest.java index 26e16616d1add..3a5c73315a011 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentStickyKeyDispatcherMultipleConsumersTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentStickyKeyDispatcherMultipleConsumersTest.java @@ -463,6 +463,161 @@ public void testSkipRedeliverTemporally() throws InterruptedException { allEntries.forEach(Entry::release); } + @DataProvider(name = "allowOutOfOrderDelivery") + private Object[][] allowOutOfOrderDelivery() { + return new Object[][] { { false }, { true } }; + } + + /** + * Reproduces the dispatch stall behind the flaky + * KeySharedSubscriptionTest.testContinueDispatchMessagesWhenMessageDelayed (issue #21554). + * + * When a replay read gets fully discarded (for example because the target consumer ran out of permits while + * the read was in flight) and the cursor has no more entries, engaging the "look ahead" mode made the follow-up + * read skip the replay queue and issue a normal read that waits at the end of the topic for new entries. Messages + * in the replay queue for consumers with available permits were then stuck until an unrelated event, such as a + * consumer flow request, triggered another read. + */ + @Test(dataProvider = "allowOutOfOrderDelivery", timeOut = 30000) + public void testLookAheadNotEngagedWhenCursorHasNoMoreEntries(boolean allowOutOfOrderDelivery) throws Exception { + persistentDispatcher.close(); + + // the mocked executor doesn't support schedule(), so run the rescheduled read directly + persistentDispatcher = new PersistentStickyKeyDispatcherMultipleConsumers( + topicMock, cursorMock, subscriptionMock, configMock, + new KeySharedMeta().setKeySharedMode(KeySharedMode.AUTO_SPLIT) + .setAllowOutOfOrderDelivery(allowOutOfOrderDelivery)) { + @Override + protected void reScheduleReadInMs(long readAfterMs) { + orderedExecutor.execute(this::readMoreEntries); + } + }; + + // consumer1 has available permits at all times + persistentDispatcher.addConsumer(consumerMock).join(); + + // the slow consumer initially has 2 permits + final Consumer slowConsumerMock = createMockConsumer(); + doReturn("consumer2").when(slowConsumerMock).consumerName(); + doReturn(true).when(slowConsumerMock).isWritable(); + AtomicInteger slowConsumerAvailablePermits = new AtomicInteger(2); + doAnswer(invocation -> slowConsumerAvailablePermits.get()).when(slowConsumerMock).getAvailablePermits(); + persistentDispatcher.addConsumer(slowConsumerMock).join(); + + StickyKeyConsumerSelector selector = persistentDispatcher.getSelector(); + String keyForConsumer1 = generateKeyForConsumer(selector, consumerMock); + String keyForSlowConsumer = generateKeyForConsumer(selector, slowConsumerMock); + + final Entry entry1 = createEntry(1, 1, "message1", 1, keyForSlowConsumer); + final Entry entry2 = createEntry(1, 2, "message2", 2, keyForSlowConsumer); + final Entry entry3 = createEntry(1, 3, "message3", 3, keyForConsumer1); + final List allEntries = List.of(entry1, entry2, entry3); + + // the cursor has no more entries; a normal read would wait for new entries without completing + doReturn(false).when(cursorMock).hasMoreEntries(); + doAnswer(invocationOnMock -> null) + .when(cursorMock).asyncReadEntriesWithSkipOrWait(anyInt(), anyLong(), any(), any(), any(), any()); + + // Mock Cursor#asyncReplayEntries. While the first replay read is in flight, the slow consumer runs out of + // permits and a message for consumer1 becomes replayable. This mirrors the delayed delivery tracker feeding + // messages to the replay queue while dispatching is in progress. + doAnswer(invocationOnMock -> { + Set positionsArg = invocationOnMock.getArgument(0); + Set positions = new TreeSet<>(positionsArg); + if (!positions.contains(entry3.getPosition())) { + slowConsumerAvailablePermits.set(0); + // add extra retain since addEntryToReplay will release it + ((EntryImpl) entry3).retain(); + persistentDispatcher.addEntryToReplay(entry3); + } + List entries = allEntries.stream() + .filter(entry -> positions.contains(entry.getPosition())) + .toList(); + AsyncCallbacks.ReadEntriesCallback callback = invocationOnMock.getArgument(1); + Object ctx = invocationOnMock.getArgument(2); + callback.readEntriesComplete(copyEntries(entries), ctx); + return Collections.emptySet(); + }).when(cursorMock).asyncReplayEntries(anySet(), any(), any(), anyBoolean()); + + CountDownLatch consumer1ReceivedMessage3 = new CountDownLatch(1); + mockSendMessages(consumerMock, entries -> { + boolean message3Found = entries.stream() + .anyMatch(entry -> entry.getPosition().equals(entry3.getPosition())); + if (message3Found) { + consumer1ReceivedMessage3.countDown(); + } + }); + mockSendMessages(slowConsumerMock, entries -> { }); + + // seed the replay queue with the slow consumer's entries + for (Entry entry : List.of(entry1, entry2)) { + // add extra retain since addEntryToReplay will release it + ((EntryImpl) entry).retain(); + persistentDispatcher.addEntryToReplay(entry); + } + + // trigger the replay read. The batch gets fully discarded since the slow consumer runs out of permits while + // the read is in flight. The message for consumer1 that was added to the replay queue in the meantime must + // get dispatched by the follow-up read instead of the dispatcher parking a normal read at the end of the + // topic. + persistentDispatcher.readMoreEntries(); + + assertTrue(consumer1ReceivedMessage3.await(5, TimeUnit.SECONDS), + "The replayed message for consumer1 with available permits should have been dispatched"); + + allEntries.forEach(Entry::release); + } + + @Test(timeOut = 10000) + public void testOutOfOrderReplayFilterDoesNotSpendReadBudgetOnConsumerWithoutPermits() { + persistentDispatcher.close(); + persistentDispatcher = new PersistentStickyKeyDispatcherMultipleConsumers( + topicMock, cursorMock, subscriptionMock, configMock, + new KeySharedMeta().setKeySharedMode(KeySharedMode.AUTO_SPLIT) + .setAllowOutOfOrderDelivery(true)); + + persistentDispatcher.addConsumer(consumerMock).join(); + + Consumer slowConsumer = createMockConsumer(); + doReturn("consumer2").when(slowConsumer).consumerName(); + doReturn(0).when(slowConsumer).getAvailablePermits(); + persistentDispatcher.addConsumer(slowConsumer).join(); + + StickyKeyConsumerSelector selector = persistentDispatcher.getSelector(); + String keyForConsumer = generateKeyForConsumer(selector, consumerMock); + String keyForSlowConsumer = generateKeyForConsumer(selector, slowConsumer); + Entry entry1 = createEntry(1, 1, "message1", 1, keyForSlowConsumer); + Entry entry2 = createEntry(1, 2, "message2", 2, keyForSlowConsumer); + Entry entry3 = createEntry(1, 3, "message3", 3, keyForConsumer); + List entries = List.of(entry1, entry2, entry3); + + try { + for (Entry entry : entries) { + ((EntryImpl) entry).retain(); + persistentDispatcher.addEntryToReplay(entry); + } + + Set positions = persistentDispatcher.getMessagesToReplayNow(1, Long.MAX_VALUE); + assertThat(positions).containsExactly(entry3.getPosition()); + } finally { + entries.forEach(Entry::release); + } + } + + @Test(timeOut = 10000) + public void testOutOfOrderReplayFilterIncludesPositionWithoutStickyKeyHash() { + persistentDispatcher.close(); + persistentDispatcher = new PersistentStickyKeyDispatcherMultipleConsumers( + topicMock, cursorMock, subscriptionMock, configMock, + new KeySharedMeta().setKeySharedMode(KeySharedMode.AUTO_SPLIT) + .setAllowOutOfOrderDelivery(true)); + + assertTrue(persistentDispatcher.addMessageToReplay(1, 1)); + + Set positions = persistentDispatcher.getMessagesToReplayNow(1, Long.MAX_VALUE); + assertThat(positions).containsExactly(PositionFactory.create(1, 1)); + } + @Test(timeOut = 30000) public void testMessageRedelivery() throws Exception { final List actualEntriesToConsumer1 = new CopyOnWriteArrayList<>(); diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentSubscriptionTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentSubscriptionTest.java index 3b911d0a87c24..6fe5675c94461 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentSubscriptionTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentSubscriptionTest.java @@ -18,6 +18,7 @@ */ package org.apache.pulsar.broker.service.persistent; +import static org.apache.bookkeeper.mledger.ManagedCursor.CURSOR_INTERNAL_PROPERTY_PREFIX; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; @@ -35,6 +36,7 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import org.apache.bookkeeper.mledger.AsyncCallbacks; @@ -49,6 +51,7 @@ import org.apache.commons.lang3.tuple.MutablePair; import org.apache.pulsar.broker.resources.NamespaceResources; import org.apache.pulsar.broker.service.Consumer; +import org.apache.pulsar.broker.service.GetStatsOptions; import org.apache.pulsar.broker.testcontext.PulsarTestContext; import org.apache.pulsar.broker.transaction.buffer.impl.InMemTransactionBufferProvider; import org.apache.pulsar.broker.transaction.pendingack.PendingAckStore; @@ -60,6 +63,7 @@ import org.apache.pulsar.common.api.proto.CommandSubscribe; import org.apache.pulsar.common.api.proto.TxnAction; import org.apache.pulsar.common.policies.data.Policies; +import org.apache.pulsar.common.policies.data.stats.SubscriptionStatsImpl; import org.apache.pulsar.transaction.common.exception.TransactionConflictException; import org.awaitility.Awaitility; import org.testng.annotations.AfterMethod; @@ -230,6 +234,31 @@ public void testAcknowledgeUpdateCursorLastActive() throws Exception { assertTrue(persistentSubscription.cursor.getLastActive() > beforeAcknowledgeTimestamp); } + @Test + public void testGetSubscriptionPropertiesReflectsLiveCursorUpdates() throws Exception { + Map backing = new ConcurrentHashMap<>(); + doReturn(backing).when(cursorMock).getCursorProperties(); + doAnswer(inv -> { + backing.put(inv.getArgument(0), inv.getArgument(1)); + return CompletableFuture.completedFuture(null); + }).when(cursorMock).putCursorProperty(any(), any()); + doReturn(false).when(cursorMock).isDurable(); + + assertThat(persistentSubscription.getSubscriptionProperties()).isEmpty(); + + String bucketKey = CURSOR_INTERNAL_PROPERTY_PREFIX + "delayed.bucket_100_100"; + persistentSubscription.getCursor().putCursorProperty(bucketKey, "42").get(); + + Map live = persistentSubscription.getSubscriptionProperties(); + assertThat(live).containsEntry(bucketKey, "42"); + + SubscriptionStatsImpl stats = persistentSubscription + .getStatsAsync(new GetStatsOptions(false, false, false, false, false)).get(); + assertThat(stats.subscriptionProperties) + .isSameAs(live) + .containsEntry(bucketKey, "42"); + } + public static class CustomTransactionPendingAckStoreProvider implements TransactionPendingAckStoreProvider { @Override public CompletableFuture newPendingAckStore(PersistentSubscription subscription) { diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentTopicTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentTopicTest.java index 364360573da70..9322946f00e27 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentTopicTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/PersistentTopicTest.java @@ -64,6 +64,7 @@ import org.apache.bookkeeper.mledger.AsyncCallbacks; import org.apache.bookkeeper.mledger.ManagedCursor; import org.apache.bookkeeper.mledger.ManagedLedger; +import org.apache.bookkeeper.mledger.ManagedLedgerConfig; import org.apache.bookkeeper.mledger.Position; import org.apache.bookkeeper.mledger.PositionFactory; import org.apache.bookkeeper.mledger.impl.ManagedCursorContainer; @@ -74,6 +75,7 @@ import org.apache.pulsar.broker.service.BrokerTestBase; import org.apache.pulsar.broker.service.Topic; import org.apache.pulsar.broker.service.TopicPoliciesService; +import org.apache.pulsar.broker.service.TopicPolicyListener; import org.apache.pulsar.broker.stats.prometheus.PrometheusMetricsClient.Metric; import org.apache.pulsar.client.admin.PulsarAdminException; import org.apache.pulsar.client.api.Consumer; @@ -97,6 +99,7 @@ import org.apache.pulsar.common.policies.data.TopicPolicies; import org.apache.pulsar.common.policies.data.TopicStats; import org.awaitility.Awaitility; +import org.mockito.ArgumentCaptor; import org.testng.Assert; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeMethod; @@ -727,6 +730,56 @@ public void testCheckPersistencePolicies() throws Exception { TimeUnit.MINUTES.toMillis(1)); } + @Test + public void testTopicPolicyListenerForwardsLiveUpdatesAfterInitialLoadFailure() throws Exception { + class RecordingPersistentTopic extends PersistentTopic { + final List receivedUpdates = new ArrayList<>(); + + RecordingPersistentTopic(String topic, ManagedLedger ledger, BrokerService brokerService) { + super(topic, ledger, brokerService); + } + + @Override + public void onUpdate(TopicPolicies policies) { + receivedUpdates.add(policies); + } + + // initTopicPolicy() moved to AbstractTopic (a different package), so widen it to public here to keep + // this same-package test able to invoke it directly. + @Override + public CompletableFuture initTopicPolicy() { + return super.initTopicPolicy(); + } + } + + final String topic = "persistent://prop/ns-abc/testTopicPolicyInitFailure-" + UUID.randomUUID(); + ManagedLedger ledger = mock(ManagedLedger.class); + doReturn(new ManagedLedgerConfig()).when(ledger).getConfig(); + doReturn(Collections.emptyMap()).when(ledger).getProperties(); + + TopicPoliciesService policiesService = mock(TopicPoliciesService.class); + doReturn(policiesService).when(pulsar).getTopicPoliciesService(); + doReturn(CompletableFuture.completedFuture(true)).when(policiesService) + .registerListenerAsync(any(TopicName.class), any(TopicPolicyListener.class)); + doReturn(CompletableFuture.failedFuture(new RuntimeException("initial topic policy load failed"))) + .when(policiesService).getTopicPoliciesAsync(any(TopicName.class), + any(TopicPoliciesService.GetType.class)); + + RecordingPersistentTopic persistentTopic = + new RecordingPersistentTopic(topic, ledger, pulsar.getBrokerService()); + persistentTopic.initTopicPolicy().handle((ignored, ex) -> null).get(3, TimeUnit.SECONDS); + + ArgumentCaptor listenerCaptor = ArgumentCaptor.forClass(TopicPolicyListener.class); + verify(policiesService).registerListenerAsync(any(TopicName.class), listenerCaptor.capture()); + + TopicPolicies livePolicies = new TopicPolicies(); + livePolicies.setIsGlobal(false); + livePolicies.setMaxConsumerPerTopic(10); + listenerCaptor.getValue().onUpdate(livePolicies); + + assertEquals(persistentTopic.receivedUpdates, Collections.singletonList(livePolicies)); + } + @Test public void testDynamicConfigurationAutoSkipNonRecoverableData() throws Exception { pulsar.getConfiguration().setAutoSkipNonRecoverableData(false); diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/ReplicatedSubscriptionsControllerTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/ReplicatedSubscriptionsControllerTest.java index 818091ade2d1c..9af9a08cc3c0a 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/ReplicatedSubscriptionsControllerTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/ReplicatedSubscriptionsControllerTest.java @@ -24,11 +24,14 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import io.netty.buffer.ByteBuf; import java.time.Clock; import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; @@ -37,6 +40,8 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import lombok.Cleanup; import org.apache.bookkeeper.mledger.AsyncCallbacks.DeleteCursorCallback; import org.apache.bookkeeper.mledger.ManagedLedger; @@ -47,6 +52,7 @@ import org.apache.pulsar.broker.service.BacklogQuotaManager; import org.apache.pulsar.broker.service.BrokerService; import org.apache.pulsar.broker.service.Replicator; +import org.apache.pulsar.broker.service.Topic; import org.apache.pulsar.broker.stats.OpenTelemetryReplicatedSubscriptionStats; import org.apache.pulsar.common.api.proto.MarkerType; import org.apache.pulsar.common.policies.data.BacklogQuota; @@ -60,6 +66,140 @@ @Test(groups = "broker-replication") public class ReplicatedSubscriptionsControllerTest { + @Test + @SuppressWarnings("unchecked") + public void testFinalSnapshotMarkerPublishFailureKeepsSnapshotPending() { + PulsarService pulsar = mock(PulsarService.class); + ScheduledExecutorService executor = mock(ScheduledExecutorService.class); + @SuppressWarnings("rawtypes") + ScheduledFuture timer = mock(ScheduledFuture.class); + ServiceConfiguration config = new ServiceConfiguration(); + config.setReplicatedSubscriptionsSnapshotFrequencyMillis(60_000); + config.setReplicatedSubscriptionsSnapshotTimeoutSeconds(3); + OpenTelemetryReplicatedSubscriptionStats stats = mock(OpenTelemetryReplicatedSubscriptionStats.class); + BrokerService brokerService = mock(BrokerService.class); + PersistentTopic topic = mock(PersistentTopic.class); + Replicator replicator = mock(Replicator.class); + List publishContexts = new ArrayList<>(); + AtomicReference scheduledSnapshotTask = new AtomicReference<>(); + + when(topic.getName()).thenReturn("persistent://public/default/t1"); + when(topic.getBrokerService()).thenReturn(brokerService); + when(topic.getLastMaxReadPositionMovedForwardTimestamp()).thenReturn(1L); + when(topic.getReplicators()).thenReturn(Map.of("remote", replicator)); + when(replicator.isConnected()).thenReturn(true); + when(brokerService.pulsar()).thenReturn(pulsar); + when(pulsar.getExecutor()).thenReturn(executor); + when(pulsar.getConfiguration()).thenReturn(config); + when(pulsar.getOpenTelemetryReplicatedSubscriptionStats()).thenReturn(stats); + when(executor.scheduleAtFixedRate(any(Runnable.class), anyLong(), anyLong(), any(TimeUnit.class))) + .thenAnswer(invocation -> { + scheduledSnapshotTask.set(invocation.getArgument(0, Runnable.class)); + return timer; + }); + doAnswer(invocation -> { + publishContexts.add(invocation.getArgument(1, Topic.PublishContext.class)); + return null; + }).when(topic).publishMessage(any(ByteBuf.class), any(Topic.PublishContext.class)); + + ReplicatedSubscriptionsController controller = new ReplicatedSubscriptionsController(topic, "local"); + String snapshotId = null; + try { + Assert.assertNotNull(scheduledSnapshotTask.get()); + scheduledSnapshotTask.get().run(); + Assert.assertEquals(controller.pendingSnapshots().size(), 1); + snapshotId = controller.pendingSnapshots().keySet().iterator().next(); + Assert.assertEquals(publishContexts.size(), 1); + + ByteBuf responseMarker = Markers.newReplicatedSubscriptionsSnapshotResponse(snapshotId, "local", + "remote", 11, 11); + try { + Commands.skipMessageMetadata(responseMarker); + controller.receivedReplicatedSubscriptionMarker(PositionFactory.create(1, 1), + MarkerType.REPLICATED_SUBSCRIPTION_SNAPSHOT_RESPONSE_VALUE, responseMarker); + } finally { + responseMarker.release(); + } + Assert.assertEquals(publishContexts.size(), 2); + + publishContexts.get(1).completed(new RuntimeException("final marker publish failed"), -1, -1); + + Assert.assertTrue(controller.pendingSnapshots().containsKey(snapshotId), + "Snapshot should remain pending after the final snapshot marker publish fails"); + Assert.assertFalse(controller.getLastCompletedSnapshotId().isPresent(), + "Failed final snapshot marker publish should not update the last completed snapshot id"); + } finally { + if (snapshotId != null && controller.pendingSnapshots().containsKey(snapshotId)) { + controller.snapshotCompleted(snapshotId); + } + controller.close(); + } + } + + @Test + @SuppressWarnings("unchecked") + public void testTimeoutCleanupDoesNotRecordTimeoutWhenSnapshotCompletedConcurrently() { + PulsarService pulsar = mock(PulsarService.class); + ScheduledExecutorService executor = mock(ScheduledExecutorService.class); + @SuppressWarnings("rawtypes") + ScheduledFuture timer = mock(ScheduledFuture.class); + ServiceConfiguration config = new ServiceConfiguration(); + config.setReplicatedSubscriptionsSnapshotFrequencyMillis(60_000); + config.setReplicatedSubscriptionsSnapshotTimeoutSeconds(3); + OpenTelemetryReplicatedSubscriptionStats stats = mock(OpenTelemetryReplicatedSubscriptionStats.class); + BrokerService brokerService = mock(BrokerService.class); + PersistentTopic topic = mock(PersistentTopic.class); + Replicator replicator = mock(Replicator.class); + AtomicReference scheduledSnapshotTask = new AtomicReference<>(); + + when(topic.getName()).thenReturn("persistent://public/default/t1"); + when(topic.getBrokerService()).thenReturn(brokerService); + when(topic.getLastMaxReadPositionMovedForwardTimestamp()).thenReturn(1L, 1L, 0L, 0L); + when(topic.getReplicators()).thenReturn(Map.of("remote", replicator)); + when(replicator.isConnected()).thenReturn(true); + when(brokerService.pulsar()).thenReturn(pulsar); + when(pulsar.getExecutor()).thenReturn(executor); + when(pulsar.getConfiguration()).thenReturn(config); + when(pulsar.getOpenTelemetryReplicatedSubscriptionStats()).thenReturn(stats); + when(executor.scheduleAtFixedRate(any(Runnable.class), anyLong(), anyLong(), any(TimeUnit.class))) + .thenAnswer(invocation -> { + scheduledSnapshotTask.set(invocation.getArgument(0, Runnable.class)); + return timer; + }); + + ReplicatedSubscriptionsController controller = new ReplicatedSubscriptionsController(topic, "local"); + try { + Assert.assertNotNull(scheduledSnapshotTask.get()); + scheduledSnapshotTask.get().run(); + Assert.assertEquals(controller.pendingSnapshots().size(), 1); + String snapshotId = controller.pendingSnapshots().keySet().iterator().next(); + ReplicatedSubscriptionsSnapshotBuilder originalBuilder = controller.pendingSnapshots().get(snapshotId); + + AtomicBoolean completedDuringTimeoutCheck = new AtomicBoolean(); + ReplicatedSubscriptionsSnapshotBuilder completedBuilder = + new ReplicatedSubscriptionsSnapshotBuilder(controller, Set.of("remote"), config, + Clock.systemUTC()) { + @Override + boolean isTimedOut() { + if (completedDuringTimeoutCheck.compareAndSet(false, true)) { + controller.snapshotCompleted(snapshotId); + } + return true; + } + }; + Assert.assertTrue(controller.pendingSnapshots().replace(snapshotId, originalBuilder, completedBuilder)); + + scheduledSnapshotTask.get().run(); + + Assert.assertTrue(completedDuringTimeoutCheck.get()); + Assert.assertFalse(controller.pendingSnapshots().containsKey(snapshotId)); + verify(stats).recordSnapshotCompleted(anyLong()); + verify(stats, never()).recordSnapshotTimedOut(anyLong()); + } finally { + controller.close(); + } + } + @Test public void testSnapshotRequestWhenReplicatorRemovedConcurrentlyDoesNotThrow() throws Exception { // Use a real PersistentTopic instance so that the replicator removal happens via the production code path diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/ReplicatedSubscriptionsSnapshotBuilderTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/ReplicatedSubscriptionsSnapshotBuilderTest.java index 69c11edb3469d..400046136f5d4 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/ReplicatedSubscriptionsSnapshotBuilderTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/persistent/ReplicatedSubscriptionsSnapshotBuilderTest.java @@ -31,6 +31,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Set; +import java.util.concurrent.CompletableFuture; import org.apache.bookkeeper.mledger.PositionFactory; import org.apache.pulsar.broker.ServiceConfiguration; import org.apache.pulsar.common.api.proto.ReplicatedSubscriptionsSnapshot; @@ -74,6 +75,14 @@ public void setup() { }) .when(controller) .writeMarker(any(ByteBuf.class)); + doAnswer(invocation -> { + ByteBuf marker = invocation.getArgument(0, ByteBuf.class); + Commands.skipMessageMetadata(marker); + markers.add(marker); + return CompletableFuture.completedFuture(PositionFactory.create(1, 1)); + }) + .when(controller) + .writeMarkerAndGetPosition(any(ByteBuf.class)); } @AfterMethod diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/schema/SchemaServiceTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/schema/SchemaServiceTest.java index d164559e85858..757dfd827ff52 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/schema/SchemaServiceTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/service/schema/SchemaServiceTest.java @@ -173,12 +173,16 @@ public void testSchemaRegistryMetrics() throws Exception { String metricsStr = output.toString(StandardCharsets.UTF_8); Multimap metrics = parseMetrics(metricsStr); + // The *_ops_failed_total counters are registered on the default Prometheus + // registry (a JVM-wide static), so labels accumulated by other tests in the + // same JVM persist here. Only assert that no failed metric exists for THIS + // test's namespace. Collection delMetrics = metrics.get("pulsar_schema_del_ops_failed_total"); - Assert.assertEquals(delMetrics.size(), 0); + assertThat(delMetrics).noneMatch(metric -> namespace.equals(metric.tags.get("namespace"))); Collection getMetrics = metrics.get("pulsar_schema_get_ops_failed_total"); - Assert.assertEquals(getMetrics.size(), 0); + assertThat(getMetrics).noneMatch(metric -> namespace.equals(metric.tags.get("namespace"))); Collection putMetrics = metrics.get("pulsar_schema_put_ops_failed_total"); - Assert.assertEquals(putMetrics.size(), 0); + assertThat(putMetrics).noneMatch(metric -> namespace.equals(metric.tags.get("namespace"))); Collection deleteLatency = metrics.get("pulsar_schema_del_ops_latency_count"); assertThat(deleteLatency).anySatisfy(metric -> { diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/stats/prometheus/NamespaceStatsAggregatorTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/stats/prometheus/NamespaceStatsAggregatorTest.java index e51d6a886e010..25c926724f0d1 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/broker/stats/prometheus/NamespaceStatsAggregatorTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/stats/prometheus/NamespaceStatsAggregatorTest.java @@ -108,7 +108,10 @@ public void testGenerateSubscriptionsStats() { PrometheusMetricStreams metricStreams = Mockito.spy(new PrometheusMetricStreams()); // Populate subscriptions stats + ServiceConfiguration config = pulsar.getConfiguration(); + doReturn(true).when(config).isExposeSubscriptionBacklogAgeInPrometheus(); subStats.blockedSubscriptionOnUnackedMsgs = true; + subStats.oldestBacklogMessageAgeSeconds = 123; consumerStats.blockedConsumerOnUnackedMsgs = false; // should not affect blockedSubscriptionOnUnackedMsgs consumerStats.unackedMessages = 1; consumerStats.msgRateRedeliver = 0.7; @@ -124,6 +127,7 @@ public void testGenerateSubscriptionsStats() { verifySubscriptionMetric(metricStreams, "pulsar_subscription_msg_rate_redeliver", 0.7); verifySubscriptionMetric(metricStreams, "pulsar_subscription_unacked_messages", 1L); + verifySubscriptionMetric(metricStreams, "pulsar_subscription_storage_backlog_age_seconds", 123L); } private void verifySubscriptionMetric(PrometheusMetricStreams metricStreams, String metricName, Number value) { diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/broker/transaction/buffer/impl/TopicTransactionBufferRecoveryTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/broker/transaction/buffer/impl/TopicTransactionBufferRecoveryTest.java new file mode 100644 index 0000000000000..101f69b263f81 --- /dev/null +++ b/pulsar-broker/src/test/java/org/apache/pulsar/broker/transaction/buffer/impl/TopicTransactionBufferRecoveryTest.java @@ -0,0 +1,144 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.broker.transaction.buffer.impl; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import lombok.Cleanup; +import org.apache.bookkeeper.mledger.Position; +import org.apache.bookkeeper.mledger.PositionFactory; +import org.apache.pulsar.broker.BrokerTestUtil; +import org.apache.pulsar.broker.service.persistent.PersistentTopic; +import org.apache.pulsar.broker.transaction.buffer.AbortedTxnProcessor; +import org.apache.pulsar.broker.transaction.buffer.TransactionBufferProvider; +import org.apache.pulsar.client.api.Producer; +import org.apache.pulsar.client.api.ProducerConsumerBase; +import org.awaitility.Awaitility; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +/** + * Tests for {@link TopicTransactionBuffer} recovery completion behavior. + */ +@Test(groups = "broker") +public class TopicTransactionBufferRecoveryTest extends ProducerConsumerBase { + + @BeforeClass(alwaysRun = true) + @Override + protected void setup() throws Exception { + conf.setTransactionCoordinatorEnabled(true); + super.internalSetup(); + super.producerBaseSetup(); + } + + @AfterClass(alwaysRun = true) + @Override + protected void cleanup() throws Exception { + super.internalCleanup(); + } + + @DataProvider(name = "snapshotExists") + public Object[][] snapshotExists() { + return new Object[][] { { false }, { true } }; + } + + /** + * While the transaction buffer is recovering, normal publishes don't move the max read position, so they + * don't update the topic's lastMaxReadPositionMovedForwardTimestamp either. When recovery completes, the + * transaction buffer must account for the messages published during recovery and trigger the + * maxReadPositionMovedForward callback; otherwise ReplicatedSubscriptionsController would consider the topic + * to have no new data and never start a subscription snapshot until further traffic arrives. + */ + @Test(dataProvider = "snapshotExists") + public void testMaxReadPositionMovedForwardForMessagesPublishedDuringRecovery(boolean snapshotExists) + throws Exception { + String tpName = BrokerTestUtil.newUniqueName("persistent://public/default/tp-tb-recovery"); + CompletableFuture recoverFuture = new CompletableFuture<>(); + TransactionBufferProvider originalProvider = pulsar.getTransactionBufferProvider(); + pulsar.setTransactionBufferProvider(originTopic -> { + AbortedTxnProcessor processor = mock(AbortedTxnProcessor.class); + when(processor.recoverFromSnapshot()).thenReturn(recoverFuture); + when(processor.closeAsync()).thenReturn(CompletableFuture.completedFuture(null)); + return new TopicTransactionBuffer( + (PersistentTopic) originTopic, processor, AbortedTxnProcessor.SnapshotType.Single); + }); + try { + @Cleanup + Producer producer = pulsarClient.newProducer().topic(tpName).create(); + // The transaction buffer is stuck in the recovering state until recoverFuture completes, so these + // messages are published while it is still recovering. + for (int i = 0; i < 3; i++) { + producer.send(("msg-" + i).getBytes(StandardCharsets.UTF_8)); + } + PersistentTopic persistentTopic = (PersistentTopic) pulsar.getBrokerService() + .getTopicIfExists(tpName).get().orElseThrow(); + assertEquals(persistentTopic.getLastMaxReadPositionMovedForwardTimestamp(), 0L, + "Publishes during recovery are not expected to move the max read position forward"); + + // Let the recovery finish, either replaying from a snapshot position or without any snapshot. + recoverFuture.complete(snapshotExists ? PositionFactory.EARLIEST : null); + + Awaitility.await().untilAsserted(() -> { + assertTrue(persistentTopic.getLastMaxReadPositionMovedForwardTimestamp() > 0, + "Completed recovery should move the max read position forward for the messages" + + " published during recovery"); + assertEquals(persistentTopic.getTransactionBuffer().getMaxReadPosition(), + persistentTopic.getManagedLedger().getLastConfirmedEntry()); + }); + } finally { + pulsar.setTransactionBufferProvider(originalProvider); + } + } + + /** + * When nothing is published while the transaction buffer recovers, completing the recovery must not trigger + * the maxReadPositionMovedForward callback, so an idle topic isn't mistaken for one with new data. + */ + @Test + public void testMaxReadPositionNotMovedForwardWhenNothingPublishedDuringRecovery() throws Exception { + String tpName = BrokerTestUtil.newUniqueName("persistent://public/default/tp-tb-recovery-idle"); + CompletableFuture recoverFuture = new CompletableFuture<>(); + TransactionBufferProvider originalProvider = pulsar.getTransactionBufferProvider(); + pulsar.setTransactionBufferProvider(originTopic -> { + AbortedTxnProcessor processor = mock(AbortedTxnProcessor.class); + when(processor.recoverFromSnapshot()).thenReturn(recoverFuture); + when(processor.closeAsync()).thenReturn(CompletableFuture.completedFuture(null)); + return new TopicTransactionBuffer( + (PersistentTopic) originTopic, processor, AbortedTxnProcessor.SnapshotType.Single); + }); + try { + PersistentTopic persistentTopic = (PersistentTopic) pulsar.getBrokerService() + .getTopic(tpName, true).get().orElseThrow(); + recoverFuture.complete(null); + Awaitility.await().untilAsserted(() -> + assertEquals(persistentTopic.getTransactionBuffer().getMaxReadPosition(), + persistentTopic.getManagedLedger().getLastConfirmedEntry())); + assertEquals(persistentTopic.getLastMaxReadPositionMovedForwardTimestamp(), 0L, + "Recovery of an idle topic should not move the max read position forward"); + } finally { + pulsar.setTransactionBufferProvider(originalProvider); + } + } +} diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/DeadLetterTopicTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/DeadLetterTopicTest.java index 001edb4de4ff7..c15d40e6061e5 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/DeadLetterTopicTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/DeadLetterTopicTest.java @@ -1697,5 +1697,76 @@ public void testCheckUnnecessaryGetPartitionedTopicMetadataWhenUseRetryAndDQL() verify(client, times(0)).getPartitionedTopicMetadata(anyString(), anyBoolean(), anyBoolean()); } + @Test + public void testAckedBatchMessageNotSentToDeadLetterTopicOnFinalRedeliveryRound() throws Exception { + final String topic = newTopicName(); + final int maxRedeliveryCount = 3; + final int batchSize = 5; + final String subscriptionName = "my-subscription"; + + Consumer consumer = pulsarClient.newConsumer(Schema.BYTES) + .topic(topic) + .subscriptionName(subscriptionName) + .subscriptionType(SubscriptionType.Shared) + .subscriptionInitialPosition(SubscriptionInitialPosition.Earliest) + .enableBatchIndexAcknowledgment(true) + .deadLetterPolicy(DeadLetterPolicy.builder().maxRedeliverCount(maxRedeliveryCount).build()) + .ackTimeout(1, TimeUnit.SECONDS) + .receiverQueueSize(100) + .subscribe(); + + @Cleanup + PulsarClient newPulsarClient = newPulsarClient(); + Consumer deadLetterConsumer = newPulsarClient.newConsumer(Schema.BYTES) + .topic(topic + "-" + subscriptionName + "-DLQ") + .subscriptionName(subscriptionName) + .subscriptionInitialPosition(SubscriptionInitialPosition.Earliest) + .subscribe(); + + Producer producer = pulsarClient.newProducer(Schema.BYTES) + .topic(topic) + .enableBatching(true) + .batchingMaxPublishDelay(1, TimeUnit.SECONDS) + .create(); + List> sendFutures = new ArrayList<>(); + for (int i = 0; i < batchSize; i++) { + sendFutures.add(producer.newMessage().value(("message-" + i).getBytes()).sendAsync()); + } + for (CompletableFuture future : sendFutures) { + future.get(); + } + producer.close(); + + // Batch indices 1 and 2 are deliberately left to time out for the first `maxRedeliveryCount` + // rounds, then explicitly acked on the final round (redeliveryCount == maxRedeliveryCount) -- + // the app's last chance to prevent them from being routed to the DLQ. The other 3 messages in + // the batch are acked immediately on the first delivery. + // Expected deliveries: (batchSize - 2) once each, plus indices 1 and 2 redelivered on every + // round from 0 through maxRedeliveryCount inclusive. + final int expectedDeliveries = (batchSize - 2) + 2 * (maxRedeliveryCount + 1); + int received = 0; + while (received < expectedDeliveries) { + Message message = consumer.receive(5, TimeUnit.SECONDS); + assertNotNull(message, "consumer should keep receiving messages until the batch settles"); + received++; + MessageIdAdv messageId = (MessageIdAdv) message.getMessageId(); + int batchIndex = messageId.getBatchIndex(); + int redeliveryCount = message.getRedeliveryCount(); + if ((batchIndex == 1 || batchIndex == 2) && redeliveryCount < maxRedeliveryCount) { + // Let it time out instead of acking. + continue; + } + consumer.acknowledge(message); + } + + // No message should ever be routed to the DLQ, since every message was explicitly acked at or + // before its final allowed redelivery round. + Message deadLetterMessage = deadLetterConsumer.receive(5, TimeUnit.SECONDS); + assertNull(deadLetterMessage, "no message should have been routed to the DLQ, " + + "but received: " + deadLetterMessage); + + deadLetterConsumer.close(); + consumer.close(); + } } diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/NonPersistentTopicTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/NonPersistentTopicTest.java index 4fb67b4123f1c..6b6b75a1e3f99 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/NonPersistentTopicTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/NonPersistentTopicTest.java @@ -59,7 +59,6 @@ import org.apache.pulsar.broker.testcontext.PulsarTestContext; import org.apache.pulsar.client.admin.PulsarAdmin; import org.apache.pulsar.client.impl.ConsumerImpl; -import org.apache.pulsar.client.impl.MessageIdImpl; import org.apache.pulsar.client.impl.MultiTopicsConsumerImpl; import org.apache.pulsar.client.impl.PartitionedProducerImpl; import org.apache.pulsar.client.impl.ProducerImpl; @@ -886,30 +885,31 @@ public void testMsgDropStat() throws Exception { ExecutorService executor = Executors.newFixedThreadPool(threads); byte[] msgData = "testData".getBytes(); + NonPersistentTopic topic = + (NonPersistentTopic) pulsar.getBrokerService().getOrCreateTopic(topicName).get(); + /* - * Trigger at least one publisher drop through concurrent send() calls. + * Send concurrent bursts until publisher AND subscription drop rates are all > 0. + * + * Each burst uses a CyclicBarrier so all threads send simultaneously. With + * maxConcurrentNonPersistentMessagePerConnection = 0, ServerCnx drops overlapping + * sends (publisher drops). Once subscriber queues (size 1) are full, the dispatcher + * also drops delivered messages (subscription drops). * - * Uses CyclicBarrier to ensure all threads send simultaneously, creating overlap. - * With maxConcurrentNonPersistentMessagePerConnection = 0, ServerCnx#handleSend - * drops any send while another is in-flight, returning MessageId with entryId = -1. - * Awaitility repeats whole bursts (bounded to 20s) until a drop is observed. + * IMPORTANT: updateRates() calls Rate.calculateRate() which resets counters via + * sumThenReset(). We must keep sending fresh bursts so each updateRates() call + * sees new drops, rather than retrying with stale (reset) counters. */ - AtomicBoolean publisherDropSeen = new AtomicBoolean(false); - Awaitility.await().atMost(Duration.ofSeconds(20)).until(() -> { + Awaitility.await().atMost(Duration.ofSeconds(20)).pollInterval(Duration.ofMillis(100)).until(() -> { CyclicBarrier barrier = new CyclicBarrier(threads); CountDownLatch completionLatch = new CountDownLatch(threads); AtomicReference error = new AtomicReference<>(); - publisherDropSeen.set(false); for (int i = 0; i < threads; i++) { executor.submit(() -> { try { barrier.await(); - MessageId msgId = producer.send(msgData); - // Publisher drop is signaled by MessageIdImpl.entryId == -1 - if (msgId instanceof MessageIdImpl && ((MessageIdImpl) msgId).getEntryId() == -1) { - publisherDropSeen.set(true); - } + producer.send(msgData); } catch (Throwable t) { if (t instanceof InterruptedException) { Thread.currentThread().interrupt(); @@ -921,27 +921,23 @@ public void testMsgDropStat() throws Exception { }); } - // Wait for all sends to complete. - assertTrue(completionLatch.await(20, TimeUnit.SECONDS)); - - assertNull(error.get(), "Concurrent send encountered an exception"); - return publisherDropSeen.get(); - }); - - assertTrue(publisherDropSeen.get(), "Expected at least one publisher drop (entryId == -1)"); - - NonPersistentTopic topic = - (NonPersistentTopic) pulsar.getBrokerService().getOrCreateTopic(topicName).get(); + completionLatch.await(20, TimeUnit.SECONDS); + if (error.get() != null) { + return false; + } - Awaitility.await().ignoreExceptions().untilAsserted(() -> { pulsar.getBrokerService().updateRates(); NonPersistentTopicStats stats = topic.getStats(false, false, false); + if (stats.getPublishers().isEmpty()) { + return false; + } NonPersistentPublisherStats npStats = stats.getPublishers().get(0); NonPersistentSubscriptionStats sub1Stats = stats.getSubscriptions().get("subscriber-1"); NonPersistentSubscriptionStats sub2Stats = stats.getSubscriptions().get("subscriber-2"); - assertTrue(npStats.getMsgDropRate() > 0); - assertTrue(sub1Stats.getMsgDropRate() > 0); - assertTrue(sub2Stats.getMsgDropRate() > 0); + return sub1Stats != null && sub2Stats != null + && npStats.getMsgDropRate() > 0 + && sub1Stats.getMsgDropRate() > 0 + && sub2Stats.getMsgDropRate() > 0; }); } finally { diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/ProducerCleanupTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/ProducerCleanupTest.java index 5ad3c85441b6a..5508de7f4cf51 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/ProducerCleanupTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/ProducerCleanupTest.java @@ -22,6 +22,7 @@ import java.util.UUID; import java.util.concurrent.TimeUnit; import org.apache.pulsar.client.impl.PulsarClientImpl; +import org.awaitility.Awaitility; import org.testng.Assert; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeMethod; @@ -50,8 +51,9 @@ public void testAllTimerTaskShouldCanceledAfterProducerClosed() throws PulsarCli .sendTimeout(1, TimeUnit.SECONDS) .create(); producer.close(); - Thread.sleep(2000); HashedWheelTimer timer = (HashedWheelTimer) ((PulsarClientImpl) pulsarClient).timer(); - Assert.assertEquals(timer.pendingTimeouts(), 0); + Awaitility.await() + .atMost(10, TimeUnit.SECONDS) + .untilAsserted(() -> Assert.assertEquals(timer.pendingTimeouts(), 0)); } } diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/ProducerQueueSizeTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/ProducerQueueSizeTest.java index 0c470ae587512..42272a6951263 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/ProducerQueueSizeTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/ProducerQueueSizeTest.java @@ -18,10 +18,14 @@ */ package org.apache.pulsar.client.api; +import static org.assertj.core.api.Assertions.assertThat; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; import lombok.Cleanup; +import org.apache.pulsar.client.impl.ProducerBase; +import org.apache.pulsar.client.impl.conf.ProducerConfigurationData; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeMethod; import org.testng.annotations.DataProvider; @@ -29,6 +33,14 @@ public class ProducerQueueSizeTest extends ProducerConsumerBase { + /** + * The bounds {@code PulsarClientImpl} falls back to when the client memory limit is disabled. + * Duplicated here on purpose: these are a documented client default, so a change to them should + * break a test rather than pass silently. + */ + private static final int NO_MEMORY_LIMIT_MAX_PENDING_MESSAGES = 1000; + private static final int NO_MEMORY_LIMIT_MAX_PENDING_MESSAGES_ACROSS_PARTITIONS = 50000; + @BeforeMethod @Override protected void setup() throws Exception { @@ -42,6 +54,15 @@ protected void cleanup() throws Exception { super.internalCleanup(); } + private static ProducerConfigurationData confOf(Producer producer) { + return ((ProducerBase) producer).getConfiguration(); + } + + @DataProvider(name = "partitioned") + public Object[][] partitioned() { + return new Object[][]{{Boolean.FALSE}, {Boolean.TRUE}}; + } + @DataProvider(name = "matrix") public Object[][] matrix() { return new Object[][]{ @@ -85,4 +106,380 @@ public void testRemoveMaxQueueLimit(boolean blockIfQueueFull, boolean partitione f.get(); } } + + /** + * A client with the memory limit disabled has no byte-based backpressure, so producers must fall + * back to a bounded pending-message queue. This has to hold for the no-argument + * {@code newProducer()} overload as well, not just {@code newProducer(Schema)}. + */ + @Test + public void testNoArgNewProducerIsBoundedWhenMemoryLimitDisabled() throws Exception { + @Cleanup + PulsarClient client = PulsarClient.builder() + .serviceUrl(brokerUrl.toString()) + .memoryLimit(0, SizeUnit.BYTES) + .build(); + + @Cleanup + Producer producer = client.newProducer().topic(newTopicName()).create(); + + assertThat(confOf(producer).getMaxPendingMessages()) + .isEqualTo(NO_MEMORY_LIMIT_MAX_PENDING_MESSAGES); + assertThat(confOf(producer).getMaxPendingMessagesAcrossPartitions()) + .isEqualTo(NO_MEMORY_LIMIT_MAX_PENDING_MESSAGES_ACROSS_PARTITIONS); + } + + /** + * The fallback is a default, not a floor. An application that asks for no message-count limit at + * all still gets it, by passing 0 explicitly. This is what keeps 0 a usable value rather than an + * alias for "unset". + * + *

A single {@code maxPendingMessages(0)} has to be enough whatever the topic's shape: filling + * in the across-partitions budget would put a per-partition limit back on a partitioned topic. + */ + @Test(dataProvider = "partitioned") + public void testExplicitZeroDisablesTheBoundWhenMemoryLimitDisabled(boolean partitioned) throws Exception { + String topic = newTopicName(); + if (partitioned) { + admin.topics().createPartitionedTopic(topic, 10); + } + + @Cleanup + PulsarClient client = PulsarClient.builder() + .serviceUrl(brokerUrl.toString()) + .memoryLimit(0, SizeUnit.BYTES) + .build(); + + @Cleanup + Producer producer = client.newProducer(Schema.BYTES) + .topic(topic) + .maxPendingMessages(0) + .create(); + + assertThat(confOf(producer).getMaxPendingMessages()).isZero(); + assertThat(confOf(producer).getMaxPendingMessagesAcrossPartitions()).isZero(); + } + + /** + * Mirror of the above: disabling only the across-partitions budget must not take the per-producer + * default down with it. Filling in that default would otherwise be capped by a budget of 0. + */ + @SuppressWarnings("deprecation") + @Test + public void testExplicitZeroAcrossPartitionsKeepsThePerProducerDefault() throws Exception { + @Cleanup + PulsarClient client = PulsarClient.builder() + .serviceUrl(brokerUrl.toString()) + .memoryLimit(0, SizeUnit.BYTES) + .build(); + + @Cleanup + Producer producer = client.newProducer() + .topic(newTopicName()) + .maxPendingMessagesAcrossPartitions(0) + .create(); + + assertThat(confOf(producer).getMaxPendingMessages()) + .isEqualTo(NO_MEMORY_LIMIT_MAX_PENDING_MESSAGES); + assertThat(confOf(producer).getMaxPendingMessagesAcrossPartitions()).isZero(); + } + + /** + * {@code loadConf} is the other way an application configures a limit. A limit present in the map + * counts as configured, including a 0, even though {@code loadConf} rebuilds the configuration + * object and so cannot carry any marker on it. + */ + @Test + public void testLoadConfZeroDisablesTheBoundWhenMemoryLimitDisabled() throws Exception { + @Cleanup + PulsarClient client = PulsarClient.builder() + .serviceUrl(brokerUrl.toString()) + .memoryLimit(0, SizeUnit.BYTES) + .build(); + + @Cleanup + Producer producer = client.newProducer() + .topic(newTopicName()) + .loadConf(Map.of("maxPendingMessages", 0)) + .create(); + + assertThat(confOf(producer).getMaxPendingMessages()).isZero(); + assertThat(confOf(producer).getMaxPendingMessagesAcrossPartitions()).isZero(); + } + + /** + * A {@code loadConf} that does not mention the limits leaves them unconfigured, so the defaults + * still apply. Pins that rebuilding the configuration is not mistaken for configuring it. + */ + @Test + public void testLoadConfWithoutTheLimitsKeepsTheDefaults() throws Exception { + @Cleanup + PulsarClient client = PulsarClient.builder() + .serviceUrl(brokerUrl.toString()) + .memoryLimit(0, SizeUnit.BYTES) + .build(); + + @Cleanup + Producer producer = client.newProducer() + .topic(newTopicName()) + .loadConf(Map.of("producerName", "loadConfWithoutLimits")) + .create(); + + assertThat(confOf(producer).getMaxPendingMessages()) + .isEqualTo(NO_MEMORY_LIMIT_MAX_PENDING_MESSAGES); + assertThat(confOf(producer).getMaxPendingMessagesAcrossPartitions()) + .isEqualTo(NO_MEMORY_LIMIT_MAX_PENDING_MESSAGES_ACROSS_PARTITIONS); + } + + /** + * A cloned builder has to keep knowing which limits were configured, or the clone would silently + * get the defaults back. + */ + @Test + public void testCloneKeepsAnExplicitlyDisabledBound() throws Exception { + @Cleanup + PulsarClient client = PulsarClient.builder() + .serviceUrl(brokerUrl.toString()) + .memoryLimit(0, SizeUnit.BYTES) + .build(); + + ProducerBuilder builder = client.newProducer().maxPendingMessages(0); + + @Cleanup + Producer producer = builder.clone().topic(newTopicName()).create(); + + assertThat(confOf(producer).getMaxPendingMessages()).isZero(); + } + + /** + * {@code maxPendingMessagesAcrossPartitions} must be {@code >= maxPendingMessages}. Filling in + * the across-partitions fallback must therefore never lower it below an explicitly configured + * per-partition limit, which would fail producer creation. + */ + @Test + public void testExplicitMaxPendingMessagesAboveTheFallbackDoesNotFailCreation() throws Exception { + int maxPendingMessages = NO_MEMORY_LIMIT_MAX_PENDING_MESSAGES_ACROSS_PARTITIONS + 10_000; + + @Cleanup + PulsarClient client = PulsarClient.builder() + .serviceUrl(brokerUrl.toString()) + .memoryLimit(0, SizeUnit.BYTES) + .build(); + + @Cleanup + Producer producer = client.newProducer() + .topic(newTopicName()) + .maxPendingMessages(maxPendingMessages) + .create(); + + assertThat(confOf(producer).getMaxPendingMessages()).isEqualTo(maxPendingMessages); + assertThat(confOf(producer).getMaxPendingMessagesAcrossPartitions()) + .isGreaterThanOrEqualTo(maxPendingMessages); + } + + /** + * Filling in the fallback must not write it back into the builder's own configuration. The + * builder stays reusable, and a limit set on it afterwards is still validated against what the + * caller configured rather than against a filled-in default. + */ + @SuppressWarnings("deprecation") + @Test + public void testFallbackLeavesTheBuilderReusable() throws Exception { + @Cleanup + PulsarClient client = PulsarClient.builder() + .serviceUrl(brokerUrl.toString()) + .memoryLimit(0, SizeUnit.BYTES) + .build(); + + ProducerBuilder builder = client.newProducer(); + + @Cleanup + Producer first = builder.topic(newTopicName()).create(); + assertThat(confOf(first).getMaxPendingMessages()) + .isEqualTo(NO_MEMORY_LIMIT_MAX_PENDING_MESSAGES); + + // Rejected if creating the first producer had left the fallback in the builder, since the + // across-partitions limit has to be >= maxPendingMessages. + @Cleanup + Producer second = builder.topic(newTopicName()) + .maxPendingMessagesAcrossPartitions(500) + .create(); + assertThat(confOf(second).getMaxPendingMessages()).isEqualTo(500); + } + + /** + * The fallback has to reach partitioned producers too, where the per-partition queue is derived + * from the across-partitions budget. + */ + @Test + public void testPartitionedProducerIsBoundedWhenMemoryLimitDisabled() throws Exception { + String topic = newTopicName(); + admin.topics().createPartitionedTopic(topic, 10); + + @Cleanup + PulsarClient client = PulsarClient.builder() + .serviceUrl(brokerUrl.toString()) + .memoryLimit(0, SizeUnit.BYTES) + .build(); + + @Cleanup + Producer producer = client.newProducer().topic(topic).create(); + + // The budget spread over 10 partitions is well above the per-producer default, so each + // partition keeps the full default. + assertThat(confOf(producer).getMaxPendingMessages()) + .isEqualTo(NO_MEMORY_LIMIT_MAX_PENDING_MESSAGES); + assertThat(confOf(producer).getMaxPendingMessagesAcrossPartitions()) + .isEqualTo(NO_MEMORY_LIMIT_MAX_PENDING_MESSAGES_ACROSS_PARTITIONS); + } + + /** + * The across-partitions limit is a budget shared by every partition, so the per-producer + * fallback must be capped by it. Otherwise the fallback would exceed an explicitly configured + * budget, which producer creation rejects. + */ + @SuppressWarnings("deprecation") + @Test + public void testExplicitAcrossPartitionsLimitCapsTheFallback() throws Exception { + int maxPendingMessagesAcrossPartitions = 500; + + @Cleanup + PulsarClient client = PulsarClient.builder() + .serviceUrl(brokerUrl.toString()) + .memoryLimit(0, SizeUnit.BYTES) + .build(); + + @Cleanup + Producer producer = client.newProducer() + .topic(newTopicName()) + .maxPendingMessagesAcrossPartitions(maxPendingMessagesAcrossPartitions) + .create(); + + assertThat(confOf(producer).getMaxPendingMessages()) + .isEqualTo(maxPendingMessagesAcrossPartitions); + assertThat(confOf(producer).getMaxPendingMessagesAcrossPartitions()) + .isEqualTo(maxPendingMessagesAcrossPartitions); + } + + /** + * The fallback only exists to replace the missing byte-based backpressure. When a memory limit + * is configured, an unset pending-message limit keeps meaning "no message-count limit". + */ + @Test + public void testMemoryLimitedClientKeepsUnboundedPendingMessages() throws Exception { + @Cleanup + PulsarClient client = PulsarClient.builder() + .serviceUrl(brokerUrl.toString()) + .memoryLimit(64, SizeUnit.MEGA_BYTES) + .build(); + + @Cleanup + Producer producer = client.newProducer().topic(newTopicName()).create(); + + assertThat(confOf(producer).getMaxPendingMessages()).isZero(); + assertThat(confOf(producer).getMaxPendingMessagesAcrossPartitions()).isZero(); + } + + /** + * A budget the application asked for is what gets divided between the partitions. Pins that the + * per-producer default filled in alongside it does not win the division and cap every partition at + * that default instead of at its share of the budget. + */ + @SuppressWarnings("deprecation") + @Test + public void testExplicitAcrossPartitionsBudgetIsDividedBetweenThePartitions() throws Exception { + String topic = newTopicName(); + admin.topics().createPartitionedTopic(topic, 10); + + @Cleanup + PulsarClient client = PulsarClient.builder() + .serviceUrl(lookupUrl.toString()) + .memoryLimit(0, SizeUnit.BYTES) + .build(); + + @Cleanup + Producer producer = client.newProducer() + .topic(topic) + .maxPendingMessagesAcrossPartitions(60_000) + .create(); + + assertThat(confOf(producer).getMaxPendingMessages()).isEqualTo(6_000); + } + + /** + * The mirror image: a budget that was only filled in as a default must not be divided, because + * dividing it would lower a per-producer limit the application did ask for. + */ + @Test + public void testFilledInBudgetDoesNotLowerAnExplicitPerProducerLimit() throws Exception { + String topic = newTopicName(); + admin.topics().createPartitionedTopic(topic, 10); + + @Cleanup + PulsarClient client = PulsarClient.builder() + .serviceUrl(lookupUrl.toString()) + .memoryLimit(0, SizeUnit.BYTES) + .build(); + + @Cleanup + Producer producer = client.newProducer() + .topic(topic) + .maxPendingMessages(60_000) + .create(); + + assertThat(confOf(producer).getMaxPendingMessages()).isEqualTo(60_000); + } + + /** + * A budget smaller than the partition count divides to zero, which used to remove the queue bound + * altogether — asking for a tighter budget made the producer unbounded. Each partition keeps the + * smallest possible queue instead. + */ + @SuppressWarnings("deprecation") + @Test + public void testBudgetSmallerThanThePartitionCountStillBoundsEachPartition() throws Exception { + String topic = newTopicName(); + admin.topics().createPartitionedTopic(topic, 10); + + @Cleanup + PulsarClient client = PulsarClient.builder() + .serviceUrl(lookupUrl.toString()) + .memoryLimit(0, SizeUnit.BYTES) + .build(); + + @Cleanup + Producer producer = client.newProducer() + .topic(topic) + .maxPendingMessagesAcrossPartitions(5) + .create(); + + assertThat(confOf(producer).getMaxPendingMessages()).isEqualTo(1); + } + + /** + * An explicit {@code 0} means "no message-count limit" and stays that way on a partitioned topic, + * even next to an across-partitions budget. Reading the unset value instead of the marker used to + * overwrite it with the budget's per-partition share. + */ + @SuppressWarnings("deprecation") + @Test + public void testExplicitZeroIsKeptAlongsideAnAcrossPartitionsBudget() throws Exception { + String topic = newTopicName(); + admin.topics().createPartitionedTopic(topic, 10); + + @Cleanup + PulsarClient client = PulsarClient.builder() + .serviceUrl(lookupUrl.toString()) + .memoryLimit(0, SizeUnit.BYTES) + .build(); + + @Cleanup + Producer producer = client.newProducer() + .topic(topic) + .maxPendingMessages(0) + .maxPendingMessagesAcrossPartitions(60_000) + .create(); + + assertThat(confOf(producer).getMaxPendingMessages()).isZero(); + } } diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/SimpleProducerConsumerTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/SimpleProducerConsumerTest.java index c553950014f6e..e09b3b6047f26 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/client/api/SimpleProducerConsumerTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/client/api/SimpleProducerConsumerTest.java @@ -21,6 +21,7 @@ import static java.nio.charset.StandardCharsets.UTF_8; import static org.apache.pulsar.common.naming.TopicName.PARTITIONED_TOPIC_SUFFIX; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.any; import static org.mockito.Mockito.atLeastOnce; @@ -3540,6 +3541,137 @@ public void testConsumerSubscriptionInitialize() throws Exception { log.info("-- Exiting {} test --", methodName); } + @Test(timeOut = 100000) + public void testConsumerImplHasMessageAvailableDoesNotThrowBeforeReceive() throws Exception { + log.info("-- Starting {} test --", methodName); + String topicName = BrokerTestUtil.newUniqueName("persistent://my-property/my-ns/has-message-available"); + + @Cleanup + Producer producer = pulsarClient.newProducer() + .topic(topicName) + .enableBatching(false) + .create(); + producer.send("existing-message".getBytes(UTF_8)); + + // hasMessageAvailable is exposed by ConsumerImpl, not the Consumer public API. + @Cleanup + ConsumerImpl latestConsumer = (ConsumerImpl) pulsarClient.newConsumer() + .topic(topicName) + .subscriptionName("test-has-message-available-latest") + .receiverQueueSize(0) + .subscribe(); + assertThat(latestConsumer.hasMessageAvailableAsync()) + .succeedsWithin(Duration.ofSeconds(10)) + .isEqualTo(false); + assertThatCode(latestConsumer::hasMessageAvailable) + .doesNotThrowAnyException(); + assertFalse(latestConsumer.hasMessageAvailable()); + + @Cleanup + ConsumerImpl earliestConsumer = (ConsumerImpl) pulsarClient.newConsumer() + .topic(topicName) + .subscriptionName("test-has-message-available-earliest") + .subscriptionInitialPosition(SubscriptionInitialPosition.Earliest) + .receiverQueueSize(0) + .subscribe(); + assertThat(earliestConsumer.hasMessageAvailableAsync()) + .succeedsWithin(Duration.ofSeconds(10)) + .isEqualTo(true); + assertThatCode(earliestConsumer::hasMessageAvailable) + .doesNotThrowAnyException(); + assertTrue(earliestConsumer.hasMessageAvailable()); + + log.info("-- Exiting {} test --", methodName); + } + + @Test(timeOut = 100000) + public void testReaderHasMessageAvailableDoesNotThrowBeforeRead() throws Exception { + log.info("-- Starting {} test --", methodName); + String topicName = BrokerTestUtil.newUniqueName("persistent://my-property/my-ns/reader-has-message-available"); + + @Cleanup + Producer producer = pulsarClient.newProducer() + .topic(topicName) + .enableBatching(false) + .create(); + producer.send("existing-message".getBytes(UTF_8)); + + @Cleanup + Reader latestReader = pulsarClient.newReader() + .topic(topicName) + .startMessageId(MessageId.latest) + .receiverQueueSize(0) + .create(); + assertThat(latestReader.hasMessageAvailableAsync()) + .succeedsWithin(Duration.ofSeconds(10)) + .isEqualTo(false); + assertThatCode(latestReader::hasMessageAvailable) + .doesNotThrowAnyException(); + assertFalse(latestReader.hasMessageAvailable()); + + @Cleanup + Reader earliestReader = pulsarClient.newReader() + .topic(topicName) + .startMessageId(MessageId.earliest) + .receiverQueueSize(0) + .create(); + assertThat(earliestReader.hasMessageAvailableAsync()) + .succeedsWithin(Duration.ofSeconds(10)) + .isEqualTo(true); + assertThatCode(earliestReader::hasMessageAvailable) + .doesNotThrowAnyException(); + assertTrue(earliestReader.hasMessageAvailable()); + + log.info("-- Exiting {} test --", methodName); + } + + @Test(timeOut = 100000) + public void testHasMessageAvailableDoesNotThrowOnEmptyTopic() throws Exception { + log.info("-- Starting {} test --", methodName); + String topicName = BrokerTestUtil.newUniqueName("persistent://my-property/my-ns/empty-has-message-available"); + + @Cleanup + ConsumerImpl consumer = (ConsumerImpl) pulsarClient.newConsumer() + .topic(topicName) + .subscriptionName("test-empty-has-message-available") + .receiverQueueSize(0) + .subscribe(); + assertThat(consumer.hasMessageAvailableAsync()) + .succeedsWithin(Duration.ofSeconds(10)) + .isEqualTo(false); + assertThatCode(consumer::hasMessageAvailable) + .doesNotThrowAnyException(); + assertFalse(consumer.hasMessageAvailable()); + + @Cleanup + Reader latestReader = pulsarClient.newReader() + .topic(topicName) + .startMessageId(MessageId.latest) + .receiverQueueSize(0) + .create(); + assertThat(latestReader.hasMessageAvailableAsync()) + .succeedsWithin(Duration.ofSeconds(10)) + .isEqualTo(false); + assertThatCode(latestReader::hasMessageAvailable) + .doesNotThrowAnyException(); + assertFalse(latestReader.hasMessageAvailable()); + + @Cleanup + Reader earliestReader = pulsarClient.newReader() + .topic(topicName) + .startMessageId(MessageId.earliest) + .receiverQueueSize(0) + .create(); + assertThat(earliestReader.hasMessageAvailableAsync()) + .succeedsWithin(Duration.ofSeconds(10)) + .isEqualTo(false); + assertThatCode(earliestReader::hasMessageAvailable) + .doesNotThrowAnyException(); + assertFalse(earliestReader.hasMessageAvailable()); + + log.info("-- Exiting {} test --", methodName); + } + @Test(timeOut = 100000) public void testMultiTopicsConsumerImplPauseForPartitionNumberChange() throws Exception { log.info("-- Starting {} test --", methodName); diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/LookupRetryTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/LookupRetryTest.java index 7796671307196..f8ebebd53e43a 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/LookupRetryTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/LookupRetryTest.java @@ -20,9 +20,12 @@ import static org.apache.pulsar.common.protocol.Commands.newLookupErrorResponse; import static org.apache.pulsar.common.protocol.Commands.newPartitionMetadataResponse; +import static org.testng.AssertJUnit.fail; import com.google.common.collect.Sets; import java.util.Queue; +import java.util.Set; import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -34,13 +37,16 @@ import org.apache.pulsar.client.api.MessageId; import org.apache.pulsar.client.api.Producer; import org.apache.pulsar.client.api.PulsarClient; +import org.apache.pulsar.client.api.PulsarClientException; import org.apache.pulsar.client.api.Reader; import org.apache.pulsar.common.api.proto.CommandLookupTopic; import org.apache.pulsar.common.api.proto.CommandPartitionedTopicMetadata; import org.apache.pulsar.common.api.proto.ServerError; import org.apache.pulsar.common.naming.TopicName; +import org.apache.pulsar.common.partition.PartitionedTopicMetadata; import org.apache.pulsar.common.policies.data.ClusterData; import org.apache.pulsar.common.policies.data.TenantInfoImpl; +import org.apache.pulsar.common.util.FutureUtil; import org.testng.Assert; import org.testng.annotations.AfterClass; import org.testng.annotations.BeforeClass; @@ -51,6 +57,7 @@ public class LookupRetryTest extends MockedPulsarServiceBaseTest { private static final String subscription = "reader-sub"; private final AtomicInteger connectionsCreated = new AtomicInteger(0); private final ConcurrentHashMap> failureMap = new ConcurrentHashMap<>(); + private static final String TEST_TIME_OUT_SEMAPHORE_RELEASE = "testTimeoutReleasePendingLookupRequestSemaphore"; @BeforeClass @Override @@ -110,6 +117,30 @@ public void testGetPartitionedMetadataRetries() throws Exception { } } + @Test + public void testTimeoutReleasePendingLookupRequestSemaphore() throws Exception { + PulsarClientImpl client = (PulsarClientImpl) newClient(); + + LookupService lookup = client.getLookup(); + + CompletableFuture future = + lookup.getPartitionedTopicMetadata(TopicName.get(TEST_TIME_OUT_SEMAPHORE_RELEASE), false); + try { + future.get(); + fail(); + } catch (Exception e) { + Assert.assertTrue(FutureUtil.unwrapCompletionException(e) + instanceof PulsarClientException.TimeoutException); + } + + Set> clientCnxs = client.getCnxPool().getConnections(); + Assert.assertEquals(clientCnxs.size(), 1); + ClientCnx clientCnx = ((CompletableFuture) clientCnxs.toArray()[0]).get(); + Assert.assertEquals(clientCnx.getPendingLookupRequestSemaphore().availablePermits(), + client.conf.getConcurrentLookupRequest()); + client.close(); + } + @Test public void testTimeoutRetriesOnPartitionMetadata() throws Exception { try (PulsarClient client = newClient(); @@ -282,6 +313,9 @@ private Queue errorList(String topicName) { @Override protected void handlePartitionMetadataRequest(CommandPartitionedTopicMetadata partitionMetadata) { TopicName t = TopicName.get(partitionMetadata.getTopic()); + if (t.getLocalName().equals(TEST_TIME_OUT_SEMAPHORE_RELEASE)) { + return; + } LookupError error = errorList(t.getLocalName()).poll(); if (error == LookupError.TOO_MANY) { final long requestId = partitionMetadata.getRequestId(); diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/MessageRedeliveryTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/MessageRedeliveryTest.java index 962749fbd49e9..d9fecdb58887d 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/MessageRedeliveryTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/MessageRedeliveryTest.java @@ -91,7 +91,6 @@ public void testRedelivery(boolean useOpenRangeSet) throws Exception { this.conf.setManagedLedgerMaxEntriesPerLedger(5); this.conf.setManagedLedgerMinLedgerRolloverTimeMinutes(0); - this.conf.setManagedLedgerUnackedRangesOpenCacheSetEnabled(useOpenRangeSet); @Cleanup("shutdownNow") final ScheduledExecutorService executor = Executors.newScheduledThreadPool(20, new DefaultThreadFactory("pulsar")); diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/RawReaderTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/RawReaderTest.java index bb98ca1e4e77d..a043f71923a6a 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/RawReaderTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/client/impl/RawReaderTest.java @@ -51,6 +51,7 @@ import org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest; import org.apache.pulsar.broker.service.persistent.PersistentTopic; import org.apache.pulsar.client.admin.PulsarAdminException; +import org.apache.pulsar.client.api.Consumer; import org.apache.pulsar.client.api.MessageId; import org.apache.pulsar.client.api.MessageRoutingMode; import org.apache.pulsar.client.api.Producer; @@ -624,6 +625,87 @@ public Object answer(InvocationOnMock invocationOnMock) throws Throwable { admin.topics().delete(topic, false); } + @Test(timeOut = 30000) + public void testReadNextAsyncCompletesAfterConsumerClosed() throws Exception { + String topic = "persistent://my-property/my-ns/" + BrokerTestUtil.newUniqueName("reader"); + admin.topics().createNonPartitionedTopic(topic); + RawReader reader = RawReader.create(pulsarClient, topic, subscription).get(); + + // Put the reader's underlying consumer into a terminal state. In production this happens when a + // compaction's RawReader hits an unrecoverable error (e.g. the topic/namespace is being deleted). + reader.closeAsync().get(5, TimeUnit.SECONDS); + + // A read issued once the consumer has reached a terminal state must complete instead of hanging + // forever: a never-completing read keeps the compaction future pending, which blocks forced + // topic/namespace deletion (issue #24148). + CompletableFuture readFuture = reader.readNextAsync(); + Awaitility.await().atMost(10, TimeUnit.SECONDS) + .untilAsserted(() -> assertTrue(readFuture.isDone())); + assertTrue(readFuture.isCompletedExceptionally()); + } + + @Test(timeOut = 30000) + public void testConnectionFailureTerminatesReadWhenNotRetryingRecoverableErrors() throws Exception { + String topic = "persistent://my-property/my-ns/" + BrokerTestUtil.newUniqueName("reader"); + admin.topics().createNonPartitionedTopic(topic); + + // A compaction reader is created with retryOnRecoverableErrors=false. When the topic is fenced or deleted, + // a reconnect can fail at the lookup/connection stage (handled by ConsumerImpl.connectionFailed) with a + // retriable error such as ServiceNotReadyException. The base class would keep reconnecting, leaving the + // in-flight read pending forever; for a compaction reader that keeps the compaction future pending and + // blocks forced topic/namespace deletion (issue #24148). Such an unrecoverable error must instead + // terminate the reader and fail the in-flight read promptly. + ConsumerConfigurationData conf = new ConsumerConfigurationData<>(); + conf.getTopicNames().add(topic); + conf.setSubscriptionName(subscription); + conf.setSubscriptionType(SubscriptionType.Exclusive); + conf.setReceiverQueueSize(DEFAULT_RECEIVER_QUEUE_SIZE); + conf.setSubscriptionInitialPosition(SubscriptionInitialPosition.Earliest); + conf.setReadCompacted(true); + CompletableFuture> consumerFuture = new CompletableFuture<>(); + RawReaderImpl.RawConsumerImpl consumer = new RawReaderImpl.RawConsumerImpl( + (PulsarClientImpl) pulsarClient, conf, consumerFuture, false, false); + consumerFuture.get(10, TimeUnit.SECONDS); + + CompletableFuture readFuture = consumer.receiveRawAsync(); + boolean keepReconnecting = + consumer.connectionFailed(new PulsarClientException.ServiceNotReadyException("injected")); + + Assert.assertFalse(keepReconnecting); + Awaitility.await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> assertTrue(readFuture.isDone())); + assertTrue(readFuture.isCompletedExceptionally()); + } + + @Test(timeOut = 30000) + public void testConnectionFailureBeforeSubscribeFailsReaderCreation() throws Exception { + String topic = "persistent://my-property/my-ns/" + BrokerTestUtil.newUniqueName("reader"); + admin.topics().createNonPartitionedTopic(topic); + + // Same compaction-reader scenario as the test above, but the unrecoverable error (e.g. a lookup + // failure with ServiceNotReadyException) arrives BEFORE the initial subscribe completes. The + // subscribe (consumer) future must then be completed exceptionally; otherwise RawReader.create(...) + // would stay pending forever, which keeps the compaction future pending and blocks forced + // topic/namespace deletion (issue #24148). + ConsumerConfigurationData conf = new ConsumerConfigurationData<>(); + conf.getTopicNames().add(topic); + conf.setSubscriptionName(subscription); + conf.setSubscriptionType(SubscriptionType.Exclusive); + conf.setReceiverQueueSize(DEFAULT_RECEIVER_QUEUE_SIZE); + conf.setSubscriptionInitialPosition(SubscriptionInitialPosition.Earliest); + conf.setReadCompacted(true); + CompletableFuture> consumerFuture = new CompletableFuture<>(); + RawReaderImpl.RawConsumerImpl consumer = new RawReaderImpl.RawConsumerImpl( + (PulsarClientImpl) pulsarClient, conf, consumerFuture, false, false); + // Inject the failure without waiting for the subscribe to complete, i.e. while the consumer + // future is still pending (construction returns before the async subscribe round-trip finishes). + boolean keepReconnecting = + consumer.connectionFailed(new PulsarClientException.ServiceNotReadyException("injected")); + + Assert.assertFalse(keepReconnecting); + Awaitility.await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> assertTrue(consumerFuture.isDone())); + assertTrue(consumerFuture.isCompletedExceptionally()); + } + @Test(timeOut = 100000) public void testPauseAndResume() throws Exception { log.info("-- Starting testPauseAndResume test --"); diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/compaction/CompactionTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/compaction/CompactionTest.java index 15866e3e6626c..ac236c4f0770b 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/compaction/CompactionTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/compaction/CompactionTest.java @@ -56,6 +56,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.function.BiConsumer; @@ -70,12 +71,15 @@ import org.apache.bookkeeper.mledger.ManagedLedgerException; import org.apache.bookkeeper.mledger.ManagedLedgerInfo; import org.apache.bookkeeper.mledger.Position; +import org.apache.bookkeeper.mledger.PositionFactory; import org.apache.bookkeeper.mledger.impl.ManagedLedgerImpl; import org.apache.commons.lang3.mutable.MutableLong; import org.apache.commons.lang3.reflect.FieldUtils; import org.apache.commons.lang3.tuple.Pair; import org.apache.pulsar.broker.auth.MockedPulsarServiceBaseTest; +import org.apache.pulsar.broker.intercept.MockBrokerInterceptor; import org.apache.pulsar.broker.namespace.NamespaceService; +import org.apache.pulsar.broker.service.ServerCnx; import org.apache.pulsar.broker.service.Topic; import org.apache.pulsar.broker.service.persistent.PersistentSubscription; import org.apache.pulsar.broker.service.persistent.PersistentTopic; @@ -87,6 +91,7 @@ import org.apache.pulsar.client.api.EncryptionKeyInfo; import org.apache.pulsar.client.api.Message; import org.apache.pulsar.client.api.MessageId; +import org.apache.pulsar.client.api.MessageIdAdv; import org.apache.pulsar.client.api.MessageRoutingMode; import org.apache.pulsar.client.api.Producer; import org.apache.pulsar.client.api.ProducerBuilder; @@ -105,6 +110,7 @@ import org.apache.pulsar.common.policies.data.ClusterData; import org.apache.pulsar.common.policies.data.PersistentTopicInternalStats; import org.apache.pulsar.common.policies.data.RetentionPolicies; +import org.apache.pulsar.common.policies.data.SubscribeRate; import org.apache.pulsar.common.policies.data.TenantInfoImpl; import org.apache.pulsar.common.protocol.Markers; import org.apache.pulsar.common.util.FutureUtil; @@ -124,17 +130,27 @@ public class CompactionTest extends MockedPulsarServiceBaseTest { protected ScheduledExecutorService compactionScheduler; protected BookKeeper bk; private PublishingOrderCompactor compactor; + private volatile java.util.function.Consumer consumerCreated = __ -> {}; @Override protected void doInitConf() throws Exception { super.doInitConf(); conf.setDispatcherMaxReadBatchSize(1); + conf.setForceDeleteNamespaceAllowed(true); } @BeforeClass @Override public void setup() throws Exception { super.internalSetup(); + pulsar.getBrokerService().setInterceptor(new MockBrokerInterceptor() { + + @Override + public void consumerCreated(ServerCnx cnx, org.apache.pulsar.broker.service.Consumer consumer, + Map metadata) { + consumerCreated.accept(consumer); + } + }); admin.clusters().createCluster(configClusterName, ClusterData.builder().serviceUrl(pulsar.getWebServiceAddress()).build()); @@ -164,6 +180,8 @@ public void beforeMethod() throws Exception { admin.namespaces().removeRetention("my-tenant/my-ns"); AbstractTwoPhaseCompactor.injectionAfterSeekInPhaseTwo = () -> {}; AbstractTwoPhaseCompactor.injectionPhaseTwoSeek = RawReader::seekAsync; + consumerCreated = __ -> {}; + pulsarTestContext.getMockBookKeeper().setDefaultReadEntriesDelayMillis(1); } protected long compact(String topic) throws ExecutionException, InterruptedException { @@ -180,6 +198,34 @@ protected PublishingOrderCompactor getCompactor() { return compactor; } + @Test + public void testCompactionNotBlockedBySubscribeRateLimit() throws Exception { + String namespace = "my-tenant/my-ns"; + String topic = "persistent://" + namespace + "/compaction-with-subscribe-rate-limit"; + + try (Producer producer = pulsarClient.newProducer().topic(topic).enableBatching(false).create()) { + for (int i = 0; i < 10; i++) { + producer.newMessage().key("key" + (i % 2)).value(("my-message-" + i).getBytes()).send(); + } + } + + // Allow a single subscribe per consumer within a long period. The compactor's reader consumes the only + // token when it first subscribes, so the re-subscribe triggered by the phase-two seek would be throttled + // if the limit applied to the compaction subscription, stalling the compaction. + admin.namespaces().setSubscribeRate(namespace, new SubscribeRate(1, 3600)); + Awaitility.await().untilAsserted(() -> { + PersistentTopic persistentTopic = + (PersistentTopic) pulsar.getBrokerService().getTopicReference(topic).get(); + assertTrue(persistentTopic.getSubscribeRateLimiter().isPresent()); + }); + + try { + compactor.compact(topic).get(30, TimeUnit.SECONDS); + } finally { + admin.namespaces().removeSubscribeRate(namespace); + } + } + @Test public void testCompaction() throws Exception { String topic = "persistent://my-tenant/my-ns/compaction"; @@ -640,6 +686,80 @@ public void testBatchMessageWithNullValue() throws Exception { assertEquals(messages.get(2).getKey(), "key5"); } + /** + * Write raw non-batch entries directly to the managed ledger without + * uncompressedSize, as seen with some non-Java clients. Verifies that + * null-value tombstones remove keys during compaction. + */ + @Test + public void testNonBatchedMessageWithNullValue() throws Exception { + String topic = "persistent://my-tenant/my-ns/non-batched-message-with-null-value"; + + admin.topics().createNonPartitionedTopic(topic); + pulsarClient.newConsumer().topic(topic).subscriptionName("sub1") + .receiverQueueSize(1).readCompacted(true).subscribe().close(); + + PersistentTopic persistentTopic = + (PersistentTopic) pulsar.getBrokerService().getTopic(topic, false).join().get(); + ManagedLedgerImpl ml = (ManagedLedgerImpl) persistentTopic.getManagedLedger(); + + long seqId = 0; + + // key1: value then null-value tombstone + ml.addEntry(buildNonBatchEntry("key1", "my-message-1".getBytes(), seqId++)); + ml.addEntry(buildNonBatchEntry("key1", null, seqId++)); + + // key2: value only (should survive) + ml.addEntry(buildNonBatchEntry("key2", "my-message-3".getBytes(), seqId++)); + + // key3: value then null-value tombstone + ml.addEntry(buildNonBatchEntry("key3", "my-message-4".getBytes(), seqId++)); + ml.addEntry(buildNonBatchEntry("key3", null, seqId++)); + + // key4: value only (should survive) + ml.addEntry(buildNonBatchEntry("key4", "my-message-6".getBytes(), seqId++)); + + compact(topic); + + List> messages = new ArrayList<>(); + try (Consumer consumer = pulsarClient.newConsumer().topic(topic) + .subscriptionName("sub1").receiverQueueSize(1).readCompacted(true).subscribe()) { + while (true) { + Message message = consumer.receive(5, TimeUnit.SECONDS); + if (message == null) { + break; + } + messages.add(message); + } + } + + assertEquals(messages.size(), 2); + assertEquals(messages.get(0).getKey(), "key2"); + assertEquals(messages.get(1).getKey(), "key4"); + } + + private byte[] buildNonBatchEntry(String key, byte[] payload, long sequenceId) { + org.apache.pulsar.common.api.proto.MessageMetadata metadata = + new org.apache.pulsar.common.api.proto.MessageMetadata(); + metadata.setPartitionKey(key); + metadata.setPublishTime(System.currentTimeMillis()); + metadata.setProducerName("test-non-batch"); + metadata.setSequenceId(sequenceId); + if (payload == null) { + metadata.setNullValue(true); + } + ByteBuf payloadBuf = io.netty.buffer.Unpooled.wrappedBuffer( + payload != null ? payload : new byte[0]); + ByteBuf entry = org.apache.pulsar.common.protocol.Commands.serializeMetadataAndPayload( + org.apache.pulsar.common.protocol.Commands.ChecksumType.Crc32c, + metadata, payloadBuf); + byte[] bytes = new byte[entry.readableBytes()]; + entry.readBytes(bytes); + entry.release(); + payloadBuf.release(); + return bytes; + } + @Test public void testWholeBatchCompactedOut() throws Exception { String topic = "persistent://my-tenant/my-ns/whole-batch-compacted-out"; @@ -2421,10 +2541,96 @@ public Object answer(InvocationOnMock invocationOnMock) throws Throwable { Thread.sleep(3000); delayReadSignal.countDown(); - // Verify: topic deletion is successfully executed. - Awaitility.await().atMost(15, TimeUnit.SECONDS).untilAsserted(() -> { - assertTrue(deleteTopicFuture.isDone()); - }); + // Verify: topic deletion is successfully executed. Asserting success (not just completion) covers the + // case where fencing the topic terminates the in-flight compaction exceptionally: the failed compaction + // must not fail the deletion (issue #24148). + deleteTopicFuture.get(15, TimeUnit.SECONDS); + } + + @Test(timeOut = 60 * 1000) + public void testForcedDeleteCompletesWhileCompactionStuck() throws Exception { + final String topicName = newUniqueName("persistent://my-tenant/my-ns/forced-delete-stuck-compaction"); + admin.topics().createNonPartitionedTopic(topicName); + try (Producer producer = pulsarClient.newProducer(Schema.STRING).topic(topicName).create()) { + for (int i = 0; i < 10; i++) { + producer.newMessage().key("key" + (i % 2)).value("value-" + i).send(); + } + } + + // Block the compaction at the phase-two seek so its future never completes on its own. This reproduces an + // in-flight compaction whose reader does not fail promptly when the topic is fenced (e.g. because a + // reconnect keeps retrying a retriable lookup-stage error): the compaction future stays pending (issue + // #24148). + CompletableFuture blockedSeek = new CompletableFuture<>(); + CountDownLatch reachedSeek = new CountDownLatch(1); + AbstractTwoPhaseCompactor.injectionPhaseTwoSeek = (reader, id) -> { + reachedSeek.countDown(); + return blockedSeek; + }; + try { + PersistentTopic persistentTopic = + (PersistentTopic) pulsar.getBrokerService().getTopic(topicName, false).join().get(); + persistentTopic.triggerCompaction(); + assertTrue(reachedSeek.await(30, TimeUnit.SECONDS)); + assertEquals(persistentTopic.compactionStatus().status, LongRunningProcessStatus.Status.RUNNING); + + // The compaction future is stuck, but a forced deletion must complete promptly instead of waiting for + // the in-flight compaction to finish (issue #24148). + persistentTopic.deleteForcefully().get(15, TimeUnit.SECONDS); + } finally { + AbstractTwoPhaseCompactor.injectionPhaseTwoSeek = RawReader::seekAsync; + // Unblock the stuck compaction so it can unwind and release its reader. + blockedSeek.complete(null); + } + } + + @Test + public void testForcedDeleteSucceedsAfterFailedCompaction() throws Exception { + String topicName = newUniqueName("persistent://my-tenant/my-ns/delete-after-failed-compaction"); + admin.topics().createNonPartitionedTopic(topicName); + try (Producer producer = pulsarClient.newProducer(Schema.STRING).topic(topicName).create()) { + for (int i = 0; i < 10; i++) { + producer.newMessage().key("key" + (i % 2)).value("value-" + i).send(); + } + } + + // Fail the compaction after the phase-two seek + AbstractTwoPhaseCompactor.injectionPhaseTwoSeek = + (reader, id) -> CompletableFuture.failedFuture(new RuntimeException("injected compaction failure")); + PersistentTopic persistentTopic = + (PersistentTopic) pulsar.getBrokerService().getTopic(topicName, false).join().get(); + persistentTopic.triggerCompaction(); + Awaitility.await().untilAsserted(() -> + assertEquals(persistentTopic.compactionStatus().status, LongRunningProcessStatus.Status.ERROR)); + AbstractTwoPhaseCompactor.injectionPhaseTwoSeek = RawReader::seekAsync; + + // The failed compaction must not block the deletion of the compaction cursor + admin.topics().delete(topicName, true); + } + + @Test + public void testForcedNamespaceDeleteWithInflightCompaction() throws Exception { + String namespace = "my-tenant/my-ns-inflight-compaction"; + admin.namespaces().createNamespace(namespace, Set.of(configClusterName)); + final String topicName = newUniqueName("persistent://" + namespace + "/inflight-compaction"); + admin.topics().createNonPartitionedTopic(topicName); + try (Producer producer = pulsarClient.newProducer(Schema.STRING).topic(topicName).create()) { + // dispatcherMaxReadBatchSize=1 makes the compactor read these one at a time, keeping the compaction + // in-flight for several seconds while the namespace is deleted + for (int i = 0; i < 2000; i++) { + producer.newMessage().key(String.valueOf(i)).value(String.valueOf(i)).send(); + } + } + PersistentTopic persistentTopic = + (PersistentTopic) pulsar.getBrokerService().getTopic(topicName, false).join().get(); + persistentTopic.triggerCompaction(); + Awaitility.await().untilAsserted(() -> + assertEquals(persistentTopic.getSubscriptions().get(COMPACTION_SUBSCRIPTION).getConsumers().size(), + 1)); + + // Forced namespace deletion must succeed while the compaction is in-flight: fencing the topic terminates + // the compaction exceptionally, which must not fail the deletion of the compaction cursor (issue #24148) + deleteNamespaceWithRetry(namespace, true, admin); } @Test @@ -2576,4 +2782,73 @@ private void triggerAndWaitCompaction(String topic) throws Exception { Awaitility.await().untilAsserted(() -> assertEquals( admin.topics().compactionStatus(topic).status, LongRunningProcessStatus.Status.SUCCESS)); } + + @Test + public void testReaderReadOnDeletedLedger() throws Exception { + final var topic = "persistent://my-tenant/my-ns/reader-read-on-deleted-ledger"; + try (final var producer = pulsarClient.newProducer(Schema.STRING).topic(topic).create()) { + for (int i = 0; i < 3; i++) { + producer.newMessage().key("key-" + i).value("value-" + i).send(); + } + } + // Trigger the ledger rollover + var ml = (ManagedLedgerImpl) ((PersistentTopic) pulsar.getBrokerService().getTopicIfExists(topic).get() + .orElseThrow()).getManagedLedger(); + ml.getConfig().setMaxEntriesPerLedger(1); + ml.getConfig().setMaxSizePerLedgerMb(0); + ml.getConfig().setMinimumRolloverTime(0, TimeUnit.MILLISECONDS); + ml.rollCurrentLedgerIfFull(); + Awaitility.await().untilAsserted(() -> assertEquals(ml.getLedgersInfo().size(), 2)); + + final var subName = "sub-" + System.currentTimeMillis(); + @Cleanup final var reader = pulsarClient.newReader(Schema.STRING).readCompacted(true).topic(topic) + .subscriptionName(subName) + .startMessageId(MessageId.earliest).create(); + + // Slow down the pre-fetching + pulsarTestContext.getMockBookKeeper().setDefaultReadEntriesDelayMillis(500); + + // Receive 1 message so that the startMessageId will be reset to ledger_id:0 after reconnection + assertTrue(reader.hasMessageAvailable()); + final var firstMsg = reader.readNext(3, TimeUnit.SECONDS); + assertNotNull(firstMsg); + + triggerAndWaitCompaction(topic); + + // Simulate the pending cumulative acknowledgment is flushed after the consumer is created + // We don't need such interception if we can support controlling the acknowledgment flush for reader. + final var firstTime = new AtomicBoolean(true); + consumerCreated = serverConsumer -> { + final var subscription = serverConsumer.getSubscription(); + if (subscription.getName().contains(subName) && firstTime.compareAndSet(true, false)) { + final var msgId = (MessageIdAdv) firstMsg.getMessageId(); + subscription.acknowledgeMessage(List.of(PositionFactory.create(msgId.getLedgerId(), + msgId.getEntryId())), CommandAck.AckType.Cumulative, Map.of()); + } + }; + + // Trigger the reconnection and trim the first ledger. + admin.namespaces().unload("my-tenant/my-ns"); + admin.lookups().lookupTopic(topic); + final var persistentTopic = (PersistentTopic) pulsar.getBrokerService().getTopic(topic, true).get() + .orElseThrow(); + final var trimFuture = new CompletableFuture(); + persistentTopic.getManagedLedger().trimConsumedLedgersInBackground(trimFuture); + trimFuture.get(); + assertEquals(persistentTopic.getManagedLedger().getLedgersInfo().size(), 1); + + pulsarTestContext.getMockBookKeeper().setDefaultReadEntriesDelayMillis(1); + + while (reader.hasMessageAvailable()) { + final var msg = reader.readNextAsync().get(3, TimeUnit.SECONDS); + log.info("read id={} key={} value={}", msg.getMessageId(), msg.getKey(), msg.getValue()); + } + + final var serverConsumer = persistentTopic.getSubscription(subName).getDispatcher().getConsumers().get(0); + assertEquals(((MessageIdAdv) serverConsumer.getStartMessageId()).getEntryId(), 0L); + + final var emptyLedgerId = persistentTopic.getManagedLedger().getLedgersInfo().lastEntry().getKey(); + assertEquals(persistentTopic.getTopicCompactionService().getLastCompactedPosition().get(), + PositionFactory.create(emptyLedgerId, -1L)); + } } diff --git a/pulsar-broker/src/test/java/org/apache/pulsar/functions/worker/PulsarFunctionTlsTest.java b/pulsar-broker/src/test/java/org/apache/pulsar/functions/worker/PulsarFunctionTlsTest.java index 50d81c6efbf82..7872b79566715 100644 --- a/pulsar-broker/src/test/java/org/apache/pulsar/functions/worker/PulsarFunctionTlsTest.java +++ b/pulsar-broker/src/test/java/org/apache/pulsar/functions/worker/PulsarFunctionTlsTest.java @@ -20,8 +20,6 @@ import static org.apache.pulsar.common.util.PortManager.nextLockedFreePort; import static org.testng.Assert.assertEquals; -import static org.testng.Assert.assertNotNull; -import static org.testng.Assert.assertTrue; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.collect.Sets; @@ -42,10 +40,10 @@ import org.apache.pulsar.broker.ServiceConfiguration; import org.apache.pulsar.broker.authentication.AuthenticationProviderTls; import org.apache.pulsar.client.admin.PulsarAdmin; +import org.apache.pulsar.client.admin.PulsarAdminException; import org.apache.pulsar.client.api.Authentication; import org.apache.pulsar.client.impl.auth.AuthenticationTls; import org.apache.pulsar.common.functions.FunctionConfig; -import org.apache.pulsar.common.functions.WorkerInfo; import org.apache.pulsar.common.policies.data.TenantInfo; import org.apache.pulsar.common.util.ClassLoaderUtils; import org.apache.pulsar.common.util.ObjectMapperFactory; @@ -260,22 +258,16 @@ public void testFunctionsCreation() throws Exception { log.info(" -------- Start test function : {}", functionName); - int finalI = i; - Awaitility.await().atMost(1, TimeUnit.MINUTES).pollInterval(1, TimeUnit.SECONDS).untilAsserted(() -> { - final PulsarWorkerService workerService = ((PulsarWorkerService) fnWorkerServices[finalI]); - final LeaderService leaderService = workerService.getLeaderService(); - assertNotNull(leaderService); - if (leaderService.isLeader()) { - assertTrue(true); - } else { - final WorkerInfo workerInfo = workerService.getMembershipManager().getLeader(); - assertTrue(workerInfo != null - && !workerInfo.getWorkerId().equals(workerService.getWorkerConfig().getWorkerId())); - } - }); - pulsarAdmins[i].functions().createFunctionWithUrl( - functionConfig, jarFilePathUrl - ); + final PulsarAdmin createAdmin = pulsarAdmins[i]; + // During function-worker leadership election/switchover, the coordination topic can already point to + // the new leader while that worker is still finishing its leader initialization. In that short window + // the internal /functions/leader request returns a transient 503 "Leader not yet ready", so retry only + // that condition and let all other failures surface immediately. + Awaitility.await().atMost(1, TimeUnit.MINUTES) + .pollInterval(1, TimeUnit.SECONDS) + .ignoreExceptionsMatching(PulsarFunctionTlsTest::isLeaderNotReady) + .untilAsserted(() -> createAdmin.functions() + .createFunctionWithUrl(functionConfig, jarFilePathUrl)); // Function creation is not strongly consistent, so this test can fail with a get that is too eager and // does not have retries. @@ -292,6 +284,14 @@ public void testFunctionsCreation() throws Exception { } } + private static boolean isLeaderNotReady(Throwable e) { + return e instanceof PulsarAdminException + && ((PulsarAdminException) e).getStatusCode() == 503 + && String.valueOf(((PulsarAdminException) e).getHttpError()) + .contains("Leader not yet ready"); + } + + protected static FunctionConfig createFunctionConfig( String jarFile, String tenant, diff --git a/pulsar-broker/src/test/resources/log4j2.xml b/pulsar-broker/src/test/resources/log4j2.xml index b348a1d04b797..543d35e115319 100644 --- a/pulsar-broker/src/test/resources/log4j2.xml +++ b/pulsar-broker/src/test/resources/log4j2.xml @@ -25,7 +25,7 @@ - + diff --git a/pulsar-cli-utils/pom.xml b/pulsar-cli-utils/pom.xml index ae319bdfb125a..d7c19798bb596 100644 --- a/pulsar-cli-utils/pom.xml +++ b/pulsar-cli-utils/pom.xml @@ -26,7 +26,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-cli-utils diff --git a/pulsar-client-admin-api/pom.xml b/pulsar-client-admin-api/pom.xml index 1196a932007b7..92d3e19b2e5f6 100644 --- a/pulsar-client-admin-api/pom.xml +++ b/pulsar-client-admin-api/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-client-admin-api diff --git a/pulsar-client-admin-api/src/main/java/org/apache/pulsar/client/admin/Topics.java b/pulsar-client-admin-api/src/main/java/org/apache/pulsar/client/admin/Topics.java index a2fcd60deb5a6..8cc577d8c4641 100644 --- a/pulsar-client-admin-api/src/main/java/org/apache/pulsar/client/admin/Topics.java +++ b/pulsar-client-admin-api/src/main/java/org/apache/pulsar/client/admin/Topics.java @@ -1499,6 +1499,8 @@ PartitionedTopicInternalStats getPartitionedInternalStats(String topic) * Don't have admin permission * @throws NotFoundException * Topic or subscription does not exist + * @throws PreconditionFailedException + * Subscription does not exist and automatic subscription creation is disabled * @throws PulsarAdminException * Unexpected error */ @@ -1532,6 +1534,8 @@ PartitionedTopicInternalStats getPartitionedInternalStats(String topic) * Don't have admin permission * @throws NotFoundException * Topic or subscription does not exist + * @throws PreconditionFailedException + * Subscription does not exist and automatic subscription creation is disabled * @throws PulsarAdminException * Unexpected error */ diff --git a/pulsar-client-admin-api/src/main/java/org/apache/pulsar/common/policies/data/ManagedLedgerInternalStats.java b/pulsar-client-admin-api/src/main/java/org/apache/pulsar/common/policies/data/ManagedLedgerInternalStats.java index b68b6308c8f3b..85829881927ce 100644 --- a/pulsar-client-admin-api/src/main/java/org/apache/pulsar/common/policies/data/ManagedLedgerInternalStats.java +++ b/pulsar-client-admin-api/src/main/java/org/apache/pulsar/common/policies/data/ManagedLedgerInternalStats.java @@ -71,6 +71,9 @@ public class ManagedLedgerInternalStats { /** The list of all cursors on this topic. Each subscription in the topic stats has a cursor. */ public Map cursors; + /** The properties map of the managed ledger. */ + public Map properties; + /** * Ledger information. */ @@ -96,6 +99,9 @@ public static class CursorStats { public long messagesConsumedCounter; public long cursorLedger; public long cursorLedgerLastEntry; + public long individualDeletedMessagesCount; + public String firstIndividualDeletedMessage; + @Deprecated public String individuallyDeletedMessages; public String lastLedgerSwitchTimestamp; public String state; diff --git a/pulsar-client-admin-api/src/main/java/org/apache/pulsar/common/policies/data/SubscriptionStats.java b/pulsar-client-admin-api/src/main/java/org/apache/pulsar/common/policies/data/SubscriptionStats.java index 05cfd55b0456c..aa26c8bc40d9a 100644 --- a/pulsar-client-admin-api/src/main/java/org/apache/pulsar/common/policies/data/SubscriptionStats.java +++ b/pulsar-client-admin-api/src/main/java/org/apache/pulsar/common/policies/data/SubscriptionStats.java @@ -57,6 +57,18 @@ public interface SubscriptionStats { /** Get the publish time of the earliest message in the backlog. */ long getEarliestMsgPublishTimeInBacklog(); + /** + * Age of oldest unacknowledged message for this subscription, in seconds. + *

+ * This is a best-effort cached value from the broker's periodic subscription backlog-age refresh. The value is + * {@code -1} when it is unknown, not applicable, the subscription has no backlog, or the broker has disabled + * subscription backlog-age computation. + *

+ */ + default long getOldestBacklogMessageAgeSeconds() { + return -1; + } + /** Number of entries in the subscription backlog that do not contain the delay messages. */ long getMsgBacklogNoDelayed(); diff --git a/pulsar-client-admin-shaded/pom.xml b/pulsar-client-admin-shaded/pom.xml index 13ab2777bdfd8..65e40395f30f1 100644 --- a/pulsar-client-admin-shaded/pom.xml +++ b/pulsar-client-admin-shaded/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-client-admin Pulsar Client Admin diff --git a/pulsar-client-admin/pom.xml b/pulsar-client-admin/pom.xml index ff75c7e87a716..59c0328f996bc 100644 --- a/pulsar-client-admin/pom.xml +++ b/pulsar-client-admin/pom.xml @@ -26,7 +26,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-client-admin-original diff --git a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/TopicsImpl.java b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/TopicsImpl.java index 11dd69a23ce58..4a9c510b5dcb4 100644 --- a/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/TopicsImpl.java +++ b/pulsar-client-admin/src/main/java/org/apache/pulsar/client/admin/internal/TopicsImpl.java @@ -133,6 +133,7 @@ public class TopicsImpl extends BaseResource implements Topics { private static final String ENCRYPTION_KEYS = "X-Pulsar-Base64-encryption-keys"; public static final String TXN_ABORTED = "X-Pulsar-txn-aborted"; public static final String TXN_UNCOMMITTED = "X-Pulsar-txn-uncommitted"; + public static final String TXN_CONSUMABLE = "X-Pulsar-txn-consumable"; // CHECKSTYLE.ON: MemberName public static final String PROPERTY_SHADOW_SOURCE_KEY = "PULSAR.SHADOW_SOURCE"; @@ -1330,6 +1331,15 @@ private List> getMessagesFromHttpResponse( } } + tmp = headers.getFirst(TXN_CONSUMABLE); + if (tmp != null) { + properties.put(TXN_CONSUMABLE, tmp.toString()); + if (!Boolean.parseBoolean(tmp.toString()) + && transactionIsolationLevel == TransactionIsolationLevel.READ_COMMITTED) { + return new ArrayList<>(); + } + } + tmp = headers.getFirst(PUBLISH_TIME); if (tmp != null) { messageMetadata.setPublishTime(DateFormatter.parse(tmp.toString())); diff --git a/pulsar-client-admin/src/test/resources/log4j2.xml b/pulsar-client-admin/src/test/resources/log4j2.xml index 9b57b450ffa43..bfe1958cd9d12 100644 --- a/pulsar-client-admin/src/test/resources/log4j2.xml +++ b/pulsar-client-admin/src/test/resources/log4j2.xml @@ -25,7 +25,7 @@ - + diff --git a/pulsar-client-all/pom.xml b/pulsar-client-all/pom.xml index 0b7bb0f781090..40ffb7bf11212 100644 --- a/pulsar-client-all/pom.xml +++ b/pulsar-client-all/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-client-all Pulsar Client All diff --git a/pulsar-client-api/pom.xml b/pulsar-client-api/pom.xml index 7ccbbf49e6d9d..398fe265b7689 100644 --- a/pulsar-client-api/pom.xml +++ b/pulsar-client-api/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-client-api diff --git a/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/ProducerBuilder.java b/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/ProducerBuilder.java index 6015bc7af2174..ec9d93dbfea7d 100644 --- a/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/ProducerBuilder.java +++ b/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/ProducerBuilder.java @@ -174,7 +174,15 @@ public interface ProducerBuilder extends Cloneable { * the client application. Until the producer gets a successful acknowledgment back from the broker, * it will keep in memory (direct memory pool) all the messages in the pending queue. * - *

Default is 0, which disables the pending messages check. + *

Default is 0, which disables the pending messages check. Disabling it only removes the + * message-count limit; the memory the pending queue may hold is then bounded by the client + * memory limit ({@link ClientBuilder#memoryLimit(long, SizeUnit)}) instead. + * + *

On a client whose memory limit is disabled there would be no backpressure left at all, so a + * producer that does not configure this setting falls back to a default queue size of 1000 rather + * than buffering without limit. Calling this method always wins over that default, so passing 0 + * explicitly is how an application asks for a producer with no message-count limit, on a + * partitioned topic as well. * * @param maxPendingMessages * the max size of the pending messages queue for the producer @@ -190,7 +198,10 @@ public interface ProducerBuilder extends Cloneable { * The purpose of this setting is to have an upper-limit on the number * of pending messages when publishing on a partitioned topic. * - *

Default is 0, which disables the pending messages across partitions check. + *

Default is 0, which disables the pending messages across partitions check. As with + * {@link #maxPendingMessages(int)}, a producer that does not configure this setting on a client + * whose memory limit is disabled falls back to a default budget of 50000 instead, since no + * backpressure would otherwise be left, and calling this method always wins over that default. * *

If publishing at a high rate over a topic with many partitions (especially when publishing messages without a * partitioning key), it might be beneficial to increase this parameter to allow for more pipelining within the diff --git a/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/ServiceUrlProvider.java b/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/ServiceUrlProvider.java index e8b513b103f65..f95f650cbb731 100644 --- a/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/ServiceUrlProvider.java +++ b/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/ServiceUrlProvider.java @@ -27,6 +27,11 @@ *

This allows applications to retrieve the service URL from an external configuration provider and, * more importantly, to force the Pulsar client to reconnect if the service URL has been changed. * + *

Each provider instance is tied to the lifecycle of one {@link PulsarClient} instance. The client + * initializes the provider when the client is created and closes the provider when the owning client is + * closed. Applications that create multiple Pulsar clients should create a separate provider instance + * for each client instead of sharing one provider. + * *

It can be passed with {@link ClientBuilder#serviceUrlProvider(ServiceUrlProvider)} */ @InterfaceAudience.Public @@ -39,6 +44,9 @@ public interface ServiceUrlProvider extends AutoCloseable { *

This can be used by the provider to force the Pulsar client to reconnect whenever the service url might have * changed. See {@link PulsarClient#updateServiceUrl(String)}. * + *

This method is invoked by the Pulsar client and is expected to be called once for a provider + * instance. Implementations may reject repeated initialization. + * * @param client * created pulsar client. */ @@ -52,7 +60,8 @@ public interface ServiceUrlProvider extends AutoCloseable { String getServiceUrl(); /** - * Close the resource that the provider allocated. + * Close the resource that the provider allocated. The owning Pulsar client invokes this method when + * it is closed. * */ @Override diff --git a/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/TopicMessageId.java b/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/TopicMessageId.java index 4d02a7f4096d6..e2791ebf26a9f 100644 --- a/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/TopicMessageId.java +++ b/pulsar-client-api/src/main/java/org/apache/pulsar/client/api/TopicMessageId.java @@ -41,6 +41,19 @@ public interface TopicMessageId extends MessageId { */ String getOwnerTopic(); + /** + * Checks if this message's owner topic and the given topic refer to the same base + * partitioned topic by comparing their base partitioned topic names. + * + *

For example, {@code persistent://public/default/my-topic-partition-0} matches + * {@code persistent://public/default/my-topic} or any other partition of that topic. + * Topics sharing only a name prefix (e.g., {@code my-topic} vs {@code my-topic-v2}) do not match. + * + * @param topicName a full topic name (non-partitioned, partitioned, or specific partition) + * @return {@code true} if both topics resolve to the same base partitioned topic name + */ + boolean hasSameBasePartitionedTopic(String topicName); + static TopicMessageId create(String topic, MessageId messageId) { if (messageId instanceof TopicMessageId) { return (TopicMessageId) messageId; diff --git a/pulsar-client-auth-athenz/pom.xml b/pulsar-client-auth-athenz/pom.xml index 907cb08502157..96ac32158bd33 100644 --- a/pulsar-client-auth-athenz/pom.xml +++ b/pulsar-client-auth-athenz/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-client-auth-athenz diff --git a/pulsar-client-auth-sasl/pom.xml b/pulsar-client-auth-sasl/pom.xml index 496e469c4c020..5f5c358878794 100644 --- a/pulsar-client-auth-sasl/pom.xml +++ b/pulsar-client-auth-sasl/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-client-auth-sasl diff --git a/pulsar-client-dependencies-minimized/pom.xml b/pulsar-client-dependencies-minimized/pom.xml index 0ac7112fac66f..13ed1e3787ed7 100644 --- a/pulsar-client-dependencies-minimized/pom.xml +++ b/pulsar-client-dependencies-minimized/pom.xml @@ -26,7 +26,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-client-dependencies-minimized diff --git a/pulsar-client-messagecrypto-bc/pom.xml b/pulsar-client-messagecrypto-bc/pom.xml index c19c41648e0d2..4e3798430b463 100644 --- a/pulsar-client-messagecrypto-bc/pom.xml +++ b/pulsar-client-messagecrypto-bc/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-client-messagecrypto-bc diff --git a/pulsar-client-shaded/pom.xml b/pulsar-client-shaded/pom.xml index 02ad0d09a7eab..0e69532c6a818 100644 --- a/pulsar-client-shaded/pom.xml +++ b/pulsar-client-shaded/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-client Pulsar Client Java diff --git a/pulsar-client-tools-api/pom.xml b/pulsar-client-tools-api/pom.xml index 358085be2f5f7..5b2155544a905 100644 --- a/pulsar-client-tools-api/pom.xml +++ b/pulsar-client-tools-api/pom.xml @@ -24,7 +24,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-client-tools-api diff --git a/pulsar-client-tools-customcommand-example/pom.xml b/pulsar-client-tools-customcommand-example/pom.xml index 3c5482e7e8abe..a1f8d5bb17e12 100644 --- a/pulsar-client-tools-customcommand-example/pom.xml +++ b/pulsar-client-tools-customcommand-example/pom.xml @@ -22,7 +22,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT 4.0.0 pulsar-client-tools-customcommand-example diff --git a/pulsar-client-tools-test/pom.xml b/pulsar-client-tools-test/pom.xml index a9ac5b7f215b5..a1729152761ca 100644 --- a/pulsar-client-tools-test/pom.xml +++ b/pulsar-client-tools-test/pom.xml @@ -24,7 +24,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-client-tools-test diff --git a/pulsar-client-tools-test/src/test/java/org/apache/pulsar/client/cli/PulsarClientToolForceBatchNum.java b/pulsar-client-tools-test/src/test/java/org/apache/pulsar/client/cli/PulsarClientToolForceBatchNum.java index 896bee0e030af..a5d3ddc22a35a 100644 --- a/pulsar-client-tools-test/src/test/java/org/apache/pulsar/client/cli/PulsarClientToolForceBatchNum.java +++ b/pulsar-client-tools-test/src/test/java/org/apache/pulsar/client/cli/PulsarClientToolForceBatchNum.java @@ -39,7 +39,7 @@ * An implement of {@link PulsarClientTool} for test, which will publish messages iff there is enough messages * in the batch. */ -public class PulsarClientToolForceBatchNum extends PulsarClientTool{ +public class PulsarClientToolForceBatchNum extends PulsarClientTool { private final String topic; private final int batchNum; @@ -68,18 +68,35 @@ public void updateConfig(ClientBuilder newBuilder, Authentication authentication private ClientBuilder mockClientBuilder(ClientBuilder newBuilder) throws Exception { PulsarClientImpl client = (PulsarClientImpl) newBuilder.build(); + // Batch exactly batchNum messages and (practically) never flush on the timer, so that batching is + // deterministic. CmdProduce leaves batching alone unless it was asked to disable it. ProducerBuilder producerBuilder = client.newProducer() .batchingMaxBytes(Integer.MAX_VALUE) .batchingMaxMessages(batchNum) .batchingMaxPublishDelay(Long.MAX_VALUE, TimeUnit.MILLISECONDS) .topic(topic); - Producer producer = producerBuilder.create(); PulsarClientImpl mockClient = spy(client); ProducerBuilder mockProducerBuilder = spy(producerBuilder); - Producer mockProducer = spy(producer); ClientBuilder mockClientBuilder = spy(newBuilder); + // Create the producer only once CmdProduce asks for it. It configures the builder it was handed + // first, and options such as --disable-batching have to reach the producer that is created from + // it: a producer holds the configuration it was created with, so a builder change made after + // creation no longer affects it. + doAnswer(invocation -> forceAsyncSend((Producer) invocation.callRealMethod())) + .when(mockProducerBuilder).create(); + doReturn(mockProducerBuilder).when(mockClient).newProducer(any(Schema.class)); + doReturn(mockClient).when(mockClientBuilder).build(); + return mockClientBuilder; + } + + /** + * Wraps the producer so that the synchronous {@code newMessage().send()} the CLI uses is dispatched + * asynchronously, letting messages accumulate into a batch instead of flushing one by one. + */ + private static Producer forceAsyncSend(Producer producer) { + Producer mockProducer = spy(producer); doAnswer((Answer) invocation -> { TypedMessageBuilder typedMessageBuilder = spy((TypedMessageBuilder) invocation.callRealMethod()); doAnswer((Answer) invocation1 -> { @@ -90,10 +107,6 @@ private ClientBuilder mockClientBuilder(ClientBuilder newBuilder) throws Excepti }).when(typedMessageBuilder).send(); return typedMessageBuilder; }).when(mockProducer).newMessage(); - - doReturn(mockProducer).when(mockProducerBuilder).create(); - doReturn(mockProducerBuilder).when(mockClient).newProducer(any(Schema.class)); - doReturn(mockClient).when(mockClientBuilder).build(); - return mockClientBuilder; + return mockProducer; } } diff --git a/pulsar-client-tools/pom.xml b/pulsar-client-tools/pom.xml index f1028ae2f4c3e..797d975f8c79c 100644 --- a/pulsar-client-tools/pom.xml +++ b/pulsar-client-tools/pom.xml @@ -24,7 +24,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-client-tools diff --git a/pulsar-client/pom.xml b/pulsar-client/pom.xml index 56d5638b2100a..901bb3701c727 100644 --- a/pulsar-client/pom.xml +++ b/pulsar-client/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-client-original diff --git a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/AutoClusterFailover.java b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/AutoClusterFailover.java index 844d1e2d25349..5657d5067c094 100644 --- a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/AutoClusterFailover.java +++ b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/AutoClusterFailover.java @@ -39,6 +39,14 @@ import org.apache.pulsar.client.impl.conf.ClientConfigurationData; import org.apache.pulsar.client.util.ExecutorProvider; +/** + * A service URL provider that automatically fails over from the primary Pulsar service URL to one of + * the secondary service URLs and switches back after the primary service recovers. + * + *

Each instance is tied to the lifecycle of one {@link PulsarClient}. Once initialized by a + * Pulsar client, it must not be reused by another client. Create a new provider instance for each + * Pulsar client. + */ @Slf4j @Data public class AutoClusterFailover implements ServiceUrlProvider { @@ -84,7 +92,10 @@ private AutoClusterFailover(AutoClusterFailoverBuilderImpl builder) { } @Override - public void initialize(PulsarClient client) { + public synchronized void initialize(PulsarClient client) { + if (this.pulsarClient != null) { + throw new IllegalStateException("ServiceUrlProvider has already been initialized"); + } this.pulsarClient = (PulsarClientImpl) client; ClientConfigurationData config = pulsarClient.getConfiguration(); if (config != null) { @@ -120,7 +131,7 @@ public String getServiceUrl() { } @Override - public void close() { + public synchronized void close() { this.executor.shutdown(); } diff --git a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ClientCnx.java b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ClientCnx.java index 35c49fb46a898..0a81fdfaeebf9 100644 --- a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ClientCnx.java +++ b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ClientCnx.java @@ -40,7 +40,9 @@ import java.util.List; import java.util.Optional; import java.util.Queue; +import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.Semaphore; @@ -131,6 +133,11 @@ public class ClientCnx extends PulsarHandler { .expectedItems(16) .concurrencyLevel(1) .build(); + // pendingRequests stores all pending request futures but does not preserve the command type, + // so there is no way to distinguish lookup requests from other requests (e.g. producer/consumer creation, + // getTopics, getLastMessageId). This set tracks which requestIds belong to lookup requests, + // so that removePendingRequest can release the lookup semaphore correctly. + private final Set pendingLookupRequestIds = ConcurrentHashMap.newKeySet(); // LookupRequests that waiting in client side. private final Queue>>> waitingLookupRequests; @@ -340,13 +347,16 @@ public void channelInactive(ChannelHandlerContext ctx) throws Exception { ConnectException e = new ConnectException( "Disconnected from server at " + ctx.channel().remoteAddress()); + // Fail out all waiting lookup requests first, and clear the queue so that + // releasePermitAndDriveWaitingQueue won't dispatch requests on a dead connection. + waitingLookupRequests.forEach(pair -> pair.getRight().getRight().completeExceptionally(e)); + waitingLookupRequests.clear(); // Fail out all the pending ops pendingRequests.forEach((key, future) -> { - if (pendingRequests.remove(key, future) && !future.isDone()) { + if (removePendingRequest(key, future) && !future.isDone()) { future.completeExceptionally(e); } }); - waitingLookupRequests.forEach(pair -> pair.getRight().getRight().completeExceptionally(e)); // Notify all attached producers/consumers so they have a chance to reconnect producers.forEach((id, producer) -> producer.connectionClosed(this, Optional.empty(), Optional.empty())); @@ -354,8 +364,6 @@ public void channelInactive(ChannelHandlerContext ctx) throws Exception { transactionMetaStoreHandlers.forEach((id, handler) -> handler.connectionClosed(this)); topicListWatchers.forEach((__, watcher) -> watcher.connectionClosed(this)); - waitingLookupRequests.clear(); - producers.clear(); consumers.clear(); topicListWatchers.clear(); @@ -516,8 +524,9 @@ protected void handleSendReceipt(CommandSendReceipt sendReceipt) { protected void handleAckResponse(CommandAckResponse ackResponse) { checkArgument(state == State.Ready); checkArgument(ackResponse.getRequestId() >= 0); - CompletableFuture completableFuture = pendingRequests.remove(ackResponse.getRequestId()); - if (completableFuture != null && !completableFuture.isDone()) { + long requestId = ackResponse.getRequestId(); + CompletableFuture completableFuture = pendingRequests.get(requestId); + if (completableFuture != null && removePendingRequest(requestId, completableFuture)) { if (!ackResponse.hasError()) { completableFuture.complete(null); } else { @@ -567,8 +576,8 @@ protected void handleSuccess(CommandSuccess success) { log.debug("{} Received success response from server: {}", ctx.channel(), success.getRequestId()); } long requestId = success.getRequestId(); - CompletableFuture requestFuture = pendingRequests.remove(requestId); - if (requestFuture != null) { + CompletableFuture requestFuture = pendingRequests.get(requestId); + if (requestFuture != null && removePendingRequest(requestId, requestFuture)) { requestFuture.complete(null); } else { duplicatedResponseCounter.incrementAndGet(); @@ -586,8 +595,8 @@ protected void handleGetLastMessageIdSuccess(CommandGetLastMessageIdResponse suc } long requestId = success.getRequestId(); CompletableFuture requestFuture = - (CompletableFuture) pendingRequests.remove(requestId); - if (requestFuture != null) { + (CompletableFuture) pendingRequests.get(requestId); + if (requestFuture != null && removePendingRequest(requestId, requestFuture)) { requestFuture.complete(new CommandGetLastMessageIdResponse().copyFrom(success)); } else { duplicatedResponseCounter.incrementAndGet(); @@ -618,8 +627,8 @@ protected void handleProducerSuccess(CommandProducerSuccess success) { } CompletableFuture requestFuture = - (CompletableFuture) pendingRequests.remove(requestId); - if (requestFuture != null) { + (CompletableFuture) pendingRequests.get(requestId); + if (requestFuture != null && removePendingRequest(requestId, requestFuture)) { ProducerResponse pr = new ProducerResponse(success.getProducerName(), success.getLastSequenceId(), success.getSchemaVersion(), @@ -640,9 +649,10 @@ protected void handleLookupResponse(CommandLookupTopicResponse lookupResult) { } long requestId = lookupResult.getRequestId(); - CompletableFuture requestFuture = getAndRemovePendingLookupRequest(requestId); + CompletableFuture requestFuture = + (CompletableFuture) pendingRequests.get(requestId); - if (requestFuture != null) { + if (requestFuture != null && removePendingRequest(requestId, requestFuture)) { if (requestFuture.isCompletedExceptionally()) { if (log.isDebugEnabled()) { log.debug("{} Request {} already timed-out", ctx.channel(), lookupResult.getRequestId()); @@ -667,6 +677,7 @@ protected void handleLookupResponse(CommandLookupTopicResponse lookupResult) { requestFuture.complete(new LookupDataResult(lookupResult)); } } else { + duplicatedResponseCounter.incrementAndGet(); log.warn("{} Received unknown request id from server: {}", ctx.channel(), lookupResult.getRequestId()); } } @@ -682,9 +693,10 @@ protected void handlePartitionResponse(CommandPartitionedTopicMetadataResponse l } long requestId = lookupResult.getRequestId(); - CompletableFuture requestFuture = getAndRemovePendingLookupRequest(requestId); + CompletableFuture requestFuture = + (CompletableFuture) pendingRequests.get(requestId); - if (requestFuture != null) { + if (requestFuture != null && removePendingRequest(requestId, requestFuture)) { if (requestFuture.isCompletedExceptionally()) { if (log.isDebugEnabled()) { log.debug("{} Request {} already timed-out", ctx.channel(), lookupResult.getRequestId()); @@ -709,6 +721,7 @@ protected void handlePartitionResponse(CommandPartitionedTopicMetadataResponse l requestFuture.complete(new LookupDataResult(lookupResult.getPartitions())); } } else { + duplicatedResponseCounter.incrementAndGet(); log.warn("{} Received unknown request id from server: {}", ctx.channel(), lookupResult.getRequestId()); } } @@ -750,39 +763,61 @@ protected void handleTopicMigrated(CommandTopicMigrated commandTopicMigrated) { // caller of this method needs to be protected under pendingLookupRequestSemaphore private void addPendingLookupRequests(long requestId, TimedCompletableFuture future) { + pendingLookupRequestIds.add(requestId); pendingRequests.put(requestId, future); requestTimeoutQueue.add(new RequestTime(requestId, RequestType.Lookup)); } - private CompletableFuture getAndRemovePendingLookupRequest(long requestId) { - CompletableFuture result = - (CompletableFuture) pendingRequests.remove(requestId); - if (result != null) { - Pair>> firstOneWaiting = - waitingLookupRequests.poll(); - if (firstOneWaiting != null) { - maxLookupRequestSemaphore.release(); - // schedule a new lookup in. - eventLoopGroup.execute(() -> { - long newId = firstOneWaiting.getLeft(); - TimedCompletableFuture newFuture = firstOneWaiting.getRight().getRight(); - addPendingLookupRequests(newId, newFuture); - ctx.writeAndFlush(firstOneWaiting.getRight().getLeft()).addListener(writeFuture -> { - if (!writeFuture.isSuccess()) { - log.warn("{} Failed to send request {} to broker: {}", ctx.channel(), newId, + /** + * Release the lookup semaphore permit and drive the waiting queue. + * This is the single centralized primitive for permit release/transfer. + */ + private void releasePermitAndDriveWaitingQueue() { + Pair>> firstOneWaiting = + waitingLookupRequests.poll(); + if (firstOneWaiting != null) { + maxLookupRequestSemaphore.release(); + // schedule a new lookup in. + eventLoopGroup.execute(() -> { + long newId = firstOneWaiting.getLeft(); + TimedCompletableFuture newFuture = firstOneWaiting.getRight().getRight(); + addPendingLookupRequests(newId, newFuture); + ctx.writeAndFlush(firstOneWaiting.getRight().getLeft()).addListener(writeFuture -> { + if (!writeFuture.isSuccess()) { + log.warn("{} Failed to send request {} to broker: {}", ctx.channel(), newId, writeFuture.cause().getMessage()); - getAndRemovePendingLookupRequest(newId); + if (removePendingRequest(newId, newFuture)) { newFuture.completeExceptionally(writeFuture.cause()); } - }); + } }); - } else { - pendingLookupRequestSemaphore.release(); - } + }); } else { - duplicatedResponseCounter.incrementAndGet(); + pendingLookupRequestSemaphore.release(); } - return result; + } + + /** + * Unified cleanup primitive for all pending requests. + * Uses pendingRequests.remove(requestId, expectedFuture) as the single CAS contention point. + * Only the thread that successfully removes the entry from pendingRequests owns the cleanup + * responsibility (including permit release for lookup requests). + * + * @param requestId the request ID to remove + * @param expectedFuture the expected future value for CAS comparison + * @return true if this call successfully obtained ownership and performed cleanup + */ + private boolean removePendingRequest(long requestId, CompletableFuture expectedFuture) { + // CAS: only one thread can successfully remove from pendingRequests + if (!pendingRequests.remove(requestId, expectedFuture)) { + return false; + } + // Won the CAS. If it's a lookup request, release the permit and drive waiting queue. + if (pendingLookupRequestIds.contains(requestId)) { + releasePermitAndDriveWaitingQueue(); + pendingLookupRequestIds.remove(requestId); + } + return true; } @Override @@ -834,8 +869,8 @@ protected void handleError(CommandError error) { log.error("Get not allowed error, {}", error.getMessage()); connectionFuture.completeExceptionally(new PulsarClientException.NotAllowedException(error.getMessage())); } - CompletableFuture requestFuture = pendingRequests.remove(requestId); - if (requestFuture != null) { + CompletableFuture requestFuture = pendingRequests.get(requestId); + if (requestFuture != null && removePendingRequest(requestId, requestFuture)) { requestFuture.completeExceptionally( getPulsarClientException(error.getError(), buildError(error.getRequestId(), error.getMessage()))); @@ -925,19 +960,14 @@ public CompletableFuture newLookup(ByteBuf request, long reque TimedCompletableFuture future = new TimedCompletableFuture<>(); if (pendingLookupRequestSemaphore.tryAcquire()) { - future.whenComplete((lookupDataResult, throwable) -> { - if (throwable instanceof ConnectException - || throwable instanceof PulsarClientException.LookupException) { - pendingLookupRequestSemaphore.release(); - } - }); addPendingLookupRequests(requestId, future); ctx.writeAndFlush(request).addListener(writeFuture -> { if (!writeFuture.isSuccess()) { log.warn("{} Failed to send request {} to broker: {}", ctx.channel(), requestId, writeFuture.cause().getMessage()); - getAndRemovePendingLookupRequest(requestId); - future.completeExceptionally(writeFuture.cause()); + if (removePendingRequest(requestId, future)) { + future.completeExceptionally(writeFuture.cause()); + } } }); } else { @@ -989,8 +1019,8 @@ protected void handleGetTopicsOfNamespaceSuccess(CommandGetTopicsOfNamespaceResp } CompletableFuture requestFuture = - (CompletableFuture) pendingRequests.remove(requestId); - if (requestFuture != null) { + (CompletableFuture) pendingRequests.get(requestId); + if (requestFuture != null && removePendingRequest(requestId, requestFuture)) { requestFuture.complete(new GetTopicsResult(topics, success.hasTopicsHash() ? success.getTopicsHash() : null, success.isFiltered(), @@ -1008,8 +1038,8 @@ protected void handleGetSchemaResponse(CommandGetSchemaResponse commandGetSchema long requestId = commandGetSchemaResponse.getRequestId(); CompletableFuture future = - (CompletableFuture) pendingRequests.remove(requestId); - if (future == null) { + (CompletableFuture) pendingRequests.get(requestId); + if (future == null || !removePendingRequest(requestId, future)) { duplicatedResponseCounter.incrementAndGet(); log.warn("{} Received unknown request id from server: {}", ctx.channel(), requestId); return; @@ -1022,10 +1052,11 @@ protected void handleGetOrCreateSchemaResponse(CommandGetOrCreateSchemaResponse checkArgument(state == State.Ready); long requestId = commandGetOrCreateSchemaResponse.getRequestId(); CompletableFuture future = - (CompletableFuture) pendingRequests.remove(requestId); - if (future == null) { + (CompletableFuture) pendingRequests.get(requestId); + if (future == null || !removePendingRequest(requestId, future)) { duplicatedResponseCounter.incrementAndGet(); - log.warn("{} Received unknown request id from server: {}", ctx.channel(), requestId); + log.warn("{} Received unknown request id from server: {}", ctx.channel(), + commandGetOrCreateSchemaResponse.getRequestId()); return; } future.complete(new CommandGetOrCreateSchemaResponse().copyFrom(commandGetOrCreateSchemaResponse)); @@ -1053,27 +1084,23 @@ CompletableFuture sendRequestWithId(ByteBuf cmd, long requestI } private void sendRequestAndHandleTimeout(ByteBuf requestMessage, long requestId, - RequestType requestType, boolean flush, - TimedCompletableFuture future) { + RequestType requestType, boolean flush, + TimedCompletableFuture future) { pendingRequests.put(requestId, future); - if (flush) { - ctx.writeAndFlush(requestMessage).addListener(writeFuture -> { - if (!writeFuture.isSuccess()) { - if (pendingRequests.remove(requestId, future) && !future.isDone()) { - log.warn("{} Failed to send {} to broker: {}", ctx.channel(), - requestType.getDescription(), writeFuture.cause().getMessage()); - future.completeExceptionally(writeFuture.cause()); - } + (flush ? ctx.writeAndFlush(requestMessage) : ctx.write(requestMessage)).addListener(writeFuture -> { + if (!writeFuture.isSuccess()) { + if (removePendingRequest(requestId, future)) { + log.warn("{} Failed to send {} to broker: {}", ctx.channel(), + requestType.getDescription(), writeFuture.cause().getMessage()); + future.completeExceptionally(writeFuture.cause()); } - }); - } else { - ctx.write(requestMessage, ctx().voidPromise()); - } + } + }); requestTimeoutQueue.add(new RequestTime(requestId, requestType)); } private CompletableFuture sendRequestAndHandleTimeout(ByteBuf requestMessage, long requestId, - RequestType requestType, boolean flush) { + RequestType requestType, boolean flush) { TimedCompletableFuture future = new TimedCompletableFuture<>(); sendRequestAndHandleTimeout(requestMessage, requestId, requestType, flush, future); return future; @@ -1182,9 +1209,8 @@ protected void handleTcClientConnectResponse(CommandTcClientConnectResponse resp + "from server: {}", ctx.channel(), response.getRequestId()); } long requestId = response.getRequestId(); - CompletableFuture requestFuture = pendingRequests.remove(requestId); - - if (requestFuture != null && !requestFuture.isDone()) { + CompletableFuture requestFuture = pendingRequests.get(requestId); + if (requestFuture != null && removePendingRequest(requestId, requestFuture)) { if (!response.hasError()) { requestFuture.complete(null); } else { @@ -1254,8 +1280,8 @@ protected void handleCommandWatchTopicListSuccess(CommandWatchTopicListSuccess c } long requestId = commandWatchTopicListSuccess.getRequestId(); CompletableFuture requestFuture = - (CompletableFuture) pendingRequests.remove(requestId); - if (requestFuture != null) { + (CompletableFuture) pendingRequests.get(requestId); + if (requestFuture != null && removePendingRequest(requestId, requestFuture)) { requestFuture.complete(new CommandWatchTopicListSuccess().copyFrom(commandWatchTopicListSuccess)); } else { duplicatedResponseCounter.incrementAndGet(); @@ -1475,10 +1501,9 @@ private void checkRequestTimeout() { continue; } TimedCompletableFuture requestFuture = pendingRequests.get(request.requestId); - if (requestFuture != null - && !requestFuture.hasGotResponse()) { - pendingRequests.remove(request.requestId, requestFuture); - if (!requestFuture.isDone()) { + if (requestFuture != null && !requestFuture.hasGotResponse()) { + boolean removed = removePendingRequest(request.requestId, requestFuture); + if (removed) { String timeoutMessage = String.format( "%s timeout {'durationMs': '%d', 'reqId':'%d', 'remote':'%s', 'local':'%s'}", request.requestType.getDescription(), operationTimeoutMs, diff --git a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ConsumerBase.java b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ConsumerBase.java index 4e0f44669250d..65cdbf07d5b5c 100644 --- a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ConsumerBase.java +++ b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ConsumerBase.java @@ -331,10 +331,14 @@ protected CompletableFuture> nextPendingReceive() { protected void completePendingReceive(CompletableFuture> receivedFuture, Message message) { getInternalExecutor(message).execute(() -> { - if (!receivedFuture.complete(message)) { - log.warn("Race condition detected. receive future was already completed (cancelled={}) and message was " - + "dropped. message={}", - receivedFuture.isCancelled(), message); + if (!receivedFuture.complete(message) && getState() != State.Closing && getState() != State.Closed) { + log.error("Race condition detected, receive future was already completed and message was dropped." + + " In other words, the message was dropped internally, the client-side will encounter a" + + " crucial issue: this message will never be consumed until the consumer is restarted or" + + " the topic is unloaded. Under normal circumstances, this won't happen. It only occurs when" + + " user itself has completed the completable future object returned by" + + " \"consumer.receiveAsync()\". message={}, cancelled={}", message, + receivedFuture.isCancelled()); } }); } @@ -967,13 +971,17 @@ protected boolean enqueueMessageAndCheckBatchReceive(Message message) { // synchronize redeliverUnacknowledgedMessages(). incomingQueueLock.lock(); try { - if (canEnqueueMessage(message) && incomingMessages.offer(message)) { - // After we have enqueued the messages on `incomingMessages` queue, we cannot touch the message - // instance anymore, since for pooled messages, this instance was possibly already been released - // and recycled. + if (canEnqueueMessage(message)) { INCOMING_MESSAGES_SIZE_UPDATER.addAndGet(this, messageSize); - getMemoryLimitController().ifPresent(limiter -> limiter.forceReserveMemory(messageSize)); - updateAutoScaleReceiverQueueHint(); + if (incomingMessages.offer(message)) { + // After we have enqueued the messages on `incomingMessages` queue, we cannot touch the message + // instance anymore, since for pooled messages, this instance was possibly already been released + // and recycled. + getMemoryLimitController().ifPresent(limiter -> limiter.forceReserveMemory(messageSize)); + updateAutoScaleReceiverQueueHint(); + } else { + INCOMING_MESSAGES_SIZE_UPDATER.addAndGet(this, -messageSize); + } } } finally { incomingQueueLock.unlock(); @@ -1098,9 +1106,12 @@ protected final void notifyPendingBatchReceivedCallBack(CompletableFuture> future, Messages messages) { if (!future.complete(messages)) { - log.warn("Race condition detected. batch receive future was already completed (cancelled={}) and messages" - + " were dropped. messages={}", - future.isCancelled(), messages); + log.warn("Race condition detected, receive future was already completed and message was dropped." + + " In other words, the message was dropped internally, the client-side will encounter a" + + " crucial issue: these message will never be consumed until the consumer is restarted or" + + " the topic is unloaded. Under normal circumstances, this won't happen. It only occurs when" + + " user itself has completed the completable future object returned by" + + " \"consumer.batchReceiveAsync()\". messages={}, cancelled={}", messages, future.isCancelled()); } } diff --git a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ConsumerImpl.java b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ConsumerImpl.java index cd2a205c41d18..46897f2950ee4 100644 --- a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ConsumerImpl.java +++ b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ConsumerImpl.java @@ -23,6 +23,7 @@ import static org.apache.pulsar.common.protocol.Commands.hasChecksum; import static org.apache.pulsar.common.protocol.Commands.serializeWithSize; import static org.apache.pulsar.common.util.Runnables.catchingAndLoggingThrowables; +import static org.apache.pulsar.common.util.SafeCollectionUtils.longArrayToList; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ComparisonChain; import com.google.common.collect.Iterables; @@ -134,7 +135,6 @@ import org.apache.pulsar.common.util.CompletableFutureCancellationHandler; import org.apache.pulsar.common.util.ExceptionHandler; import org.apache.pulsar.common.util.FutureUtil; -import org.apache.pulsar.common.util.SafeCollectionUtils; import org.apache.pulsar.common.util.collections.BitSetRecyclable; import org.apache.pulsar.common.util.collections.ConcurrentBitSet; import org.apache.pulsar.common.util.collections.GrowableArrayBlockingQueue; @@ -142,6 +142,7 @@ import org.slf4j.LoggerFactory; public class ConsumerImpl extends ConsumerBase implements ConnectionHandler.Connection { + private static final long[] EMPTY_ACK_SET = new long[0]; private static final int MAX_REDELIVER_UNACKNOWLEDGED = 1000; final long consumerId; @@ -1030,6 +1031,10 @@ protected void closeWhenReceivedUnrecoverableError(Throwable t, ClientCnx cnx) { final String cnxStr = cnx == null ? "null" : String.valueOf(cnx.channel().remoteAddress()); log.warn("[{}][{}] {} Closed consumer because get an error that does not support to retry: {} {}", topic, subscription, cnxStr, t.getClass().getName(), t.getMessage()); + // If the unrecoverable error occurs before the initial subscribe completes, fail the subscribe + // future as well; otherwise callers waiting on it (e.g. RawReader.create() / subscribeAsync()) + // would hang forever. This is a no-op when the subscribe future has already completed. + subscribeFuture.completeExceptionally(t); closeAsync().whenComplete((__, ex) -> { if (ex == null) { fail(t); @@ -1404,11 +1409,11 @@ protected void processPayloadByProcessor(final BrokerEntryMetadata brokerEntryMe } void messageReceived(CommandMessage cmdMessage, ByteBuf headersAndPayload, ClientCnx cnx) { - List ackSet = Collections.emptyList(); + long[] ackSet = EMPTY_ACK_SET; if (cmdMessage.getAckSetsCount() > 0) { - ackSet = new ArrayList<>(cmdMessage.getAckSetsCount()); + ackSet = new long[cmdMessage.getAckSetsCount()]; for (int i = 0; i < cmdMessage.getAckSetsCount(); i++) { - ackSet.add(cmdMessage.getAckSetAt(i)); + ackSet[i] = cmdMessage.getAckSetAt(i); } } int redeliveryCount = cmdMessage.getRedeliveryCount(); @@ -1478,7 +1483,7 @@ void messageReceived(CommandMessage cmdMessage, ByteBuf headersAndPayload, Clien if (conf.getPayloadProcessor() != null) { // uncompressedPayload is released in this method so we don't need to call release() again processPayloadByProcessor(brokerEntryMetadata, msgMetadata, - uncompressedPayload, msgId, schema, redeliveryCount, ackSet, consumerEpoch); + uncompressedPayload, msgId, schema, redeliveryCount, longArrayToList(ackSet), consumerEpoch); return; } @@ -1701,12 +1706,24 @@ private ByteBuf processMessageChunk(ByteBuf compressedPayload, MessageMetadata m */ void notifyPendingReceivedCallback(final Message message, Exception exception) { if (pendingReceives.isEmpty()) { + if (getState() != State.Closing && getState() != State.Closed) { + log.error("If you received this log, it means that you encountered a bug: a message was" + + " dropped internally, the client-side will encounter a crucial issue: this message will" + + " never be consumed until the consumer is restarted or the topic is unloaded. message={}," + + " pendingReceives-size={}", message, pendingReceives.size()); + } return; } // fetch receivedCallback from queue final CompletableFuture> receivedFuture = nextPendingReceive(); if (receivedFuture == null) { + if (getState() != State.Closing && getState() != State.Closed) { + log.error("The pendingReceives pulled out a null conpletableFuture object. If you received this log," + + " it means that you encountered a bug: a message was" + + " dropped internally, the client-side will encounter a crucial issue: this message will never" + + " be consumed until the consumer is restarted or the topic is unloaded. message={}", message); + } return; } @@ -1744,7 +1761,7 @@ private void interceptAndComplete(final Message message, final CompletableFut } void receiveIndividualMessagesFromBatch(BrokerEntryMetadata brokerEntryMetadata, MessageMetadata msgMetadata, - int redeliveryCount, List ackSet, ByteBuf uncompressedPayload, + int redeliveryCount, long[] ackSet, ByteBuf uncompressedPayload, MessageIdData messageId, ClientCnx cnx, long consumerEpoch) { int batchSize = msgMetadata.getNumMessagesInBatch(); @@ -1758,8 +1775,9 @@ void receiveIndividualMessagesFromBatch(BrokerEntryMetadata brokerEntryMetadata, BitSet ackSetInMessageId = BatchMessageIdImpl.newAckSet(batchSize); BitSetRecyclable ackBitSet = null; - if (ackSet != null && ackSet.size() > 0) { - ackBitSet = BitSetRecyclable.valueOf(SafeCollectionUtils.longListToArray(ackSet)); + if (ackSet != null && ackSet.length > 0) { + ackBitSet = BitSetRecyclable.valueOf(ackSet); + ackSetInMessageId.and(BitSet.valueOf(ackSet)); } SingleMessageMetadata singleMessageMetadata = new SingleMessageMetadata(); @@ -2653,7 +2671,7 @@ public CompletableFuture seekAsync(MessageId messageId) { final ByteBuf seek; if (msgId.getFirstChunkMessageId() != null) { seek = Commands.newSeek(consumerId, requestId, firstChunkMsgId.getLedgerId(), - firstChunkMsgId.getEntryId(), new long[0]); + firstChunkMsgId.getEntryId(), EMPTY_ACK_SET); } else { final long[] ackSetArr; if (MessageIdAdvUtils.isBatch(msgId)) { @@ -2663,7 +2681,7 @@ public CompletableFuture seekAsync(MessageId messageId) { ackSetArr = ackSet.toLongArray(); ackSet.recycle(); } else { - ackSetArr = new long[0]; + ackSetArr = EMPTY_ACK_SET; } seek = Commands.newSeek(consumerId, requestId, msgId.getLedgerId(), msgId.getEntryId(), ackSetArr); } @@ -2687,6 +2705,19 @@ public CompletableFuture hasMessageAvailableAsync() { // we haven't read yet. use startMessageId for comparison if (lastDequeuedMessageId == MessageId.earliest) { + if (startMessageId == null) { + internalGetLastMessageIdAsync().thenAccept(response -> { + lastMessageIdInBroker = response.lastMessageId; + completehasMessageAvailableWithValue(booleanFuture, + hasMoreMessagesThanMarkDeletePosition(response, false, false)); + }).exceptionally(e -> { + log.error("[{}][{}] Failed getLastMessageId command", topic, subscription, e); + booleanFuture.completeExceptionally(FutureUtil.unwrapCompletionException(e)); + return null; + }); + return booleanFuture; + } + // If the last seek is called with timestamp, startMessageId cannot represent the position to start, so we // have to get the mark-delete position from the GetLastMessageId response to compare as well. // if we are starting from latest, we should seek to the actual last message first. @@ -2702,33 +2733,12 @@ public CompletableFuture hasMessageAvailableAsync() { } future.thenAccept(response -> { - MessageIdAdv lastMessageId = (MessageIdAdv) response.lastMessageId; - MessageIdAdv markDeletePosition = (MessageIdAdv) response.markDeletePosition; - - if (markDeletePosition != null && !(markDeletePosition.getEntryId() < 0 - && markDeletePosition.getLedgerId() > lastMessageId.getLedgerId())) { - // we only care about comparing ledger ids and entry ids as mark delete position doesn't have - // other ids such as batch index - int result = ComparisonChain.start() - .compare(markDeletePosition.getLedgerId(), lastMessageId.getLedgerId()) - .compare(markDeletePosition.getEntryId(), lastMessageId.getEntryId()) - .result(); - if (lastMessageId.getEntryId() < 0) { - completehasMessageAvailableWithValue(booleanFuture, false); - } else if (hasSoughtByTimestamp) { - completehasMessageAvailableWithValue(booleanFuture, result < 0); - } else { - completehasMessageAvailableWithValue(booleanFuture, - resetIncludeHead ? result <= 0 : result < 0); - } - } else if (lastMessageId == null || lastMessageId.getEntryId() < 0) { - completehasMessageAvailableWithValue(booleanFuture, false); - } else { - completehasMessageAvailableWithValue(booleanFuture, resetIncludeHead); - } + completehasMessageAvailableWithValue(booleanFuture, + hasMoreMessagesThanMarkDeletePosition(response, + !hasSoughtByTimestamp && resetIncludeHead, resetIncludeHead)); }).exceptionally(ex -> { log.error("[{}][{}] Failed getLastMessageId command", topic, subscription, ex); - booleanFuture.completeExceptionally(ex.getCause()); + booleanFuture.completeExceptionally(FutureUtil.unwrapCompletionException(ex)); return null; }); @@ -2745,8 +2755,8 @@ public CompletableFuture hasMessageAvailableAsync() { completehasMessageAvailableWithValue(booleanFuture, hasMoreMessages(lastMessageIdInBroker, startMessageId, resetIncludeHead)); }).exceptionally(e -> { - log.error("[{}][{}] Failed getLastMessageId command", topic, subscription); - booleanFuture.completeExceptionally(e.getCause()); + log.error("[{}][{}] Failed getLastMessageId command", topic, subscription, e); + booleanFuture.completeExceptionally(FutureUtil.unwrapCompletionException(e)); return null; }); @@ -2762,8 +2772,8 @@ public CompletableFuture hasMessageAvailableAsync() { completehasMessageAvailableWithValue(booleanFuture, hasMoreMessages(lastMessageIdInBroker, lastDequeuedMessageId, false)); }).exceptionally(e -> { - log.error("[{}][{}] Failed getLastMessageId command", topic, subscription); - booleanFuture.completeExceptionally(e.getCause()); + log.error("[{}][{}] Failed getLastMessageId command", topic, subscription, e); + booleanFuture.completeExceptionally(FutureUtil.unwrapCompletionException(e)); return null; }); } @@ -2787,6 +2797,28 @@ private boolean hasMoreMessages(MessageId lastMessageIdInBroker, MessageId messa && ((MessageIdImpl) lastMessageIdInBroker).getEntryId() != -1; } + private boolean hasMoreMessagesThanMarkDeletePosition(GetLastMessageIdResponse response, boolean inclusive, + boolean includeHeadWhenMarkDeleteIsAfterLastMessage) { + MessageIdAdv lastMessageId = (MessageIdAdv) response.lastMessageId; + if (lastMessageId == null || lastMessageId.getEntryId() < 0) { + return false; + } + + MessageIdAdv markDeletePosition = (MessageIdAdv) response.markDeletePosition; + if (markDeletePosition == null || (markDeletePosition.getEntryId() < 0 + && markDeletePosition.getLedgerId() > lastMessageId.getLedgerId())) { + return includeHeadWhenMarkDeleteIsAfterLastMessage; + } + + // We only care about comparing ledger ids and entry ids as mark delete position doesn't have + // other ids such as batch index. + int result = ComparisonChain.start() + .compare(markDeletePosition.getLedgerId(), lastMessageId.getLedgerId()) + .compare(markDeletePosition.getEntryId(), lastMessageId.getEntryId()) + .result(); + return inclusive ? result <= 0 : result < 0; + } + private static final class GetLastMessageIdResponse { final MessageId lastMessageId; final MessageId markDeletePosition; diff --git a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ControlledClusterFailover.java b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ControlledClusterFailover.java index 1d4740b847240..db3d7939d3b54 100644 --- a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ControlledClusterFailover.java +++ b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ControlledClusterFailover.java @@ -55,6 +55,14 @@ import org.asynchttpclient.channel.DefaultKeepAliveStrategy; import org.jspecify.annotations.Nullable; +/** + * A service URL provider that fetches controlled failover configuration from an external HTTP service + * and updates the Pulsar client when the returned configuration changes. + * + *

Each instance is tied to the lifecycle of one {@link PulsarClient}. Once initialized by a + * Pulsar client, it must not be reused by another client. Create a new provider instance for each + * Pulsar client. + */ @Slf4j public class ControlledClusterFailover implements ServiceUrlProvider { private static final int DEFAULT_CONNECT_TIMEOUT_IN_SECONDS = 10; @@ -107,7 +115,10 @@ public boolean keepAlive(InetSocketAddress remoteAddress, Request ahcRequest, } @Override - public void initialize(PulsarClient client) { + public synchronized void initialize(PulsarClient client) { + if (this.pulsarClient != null) { + throw new IllegalStateException("ServiceUrlProvider has already been initialized"); + } this.pulsarClient = (PulsarClientImpl) client; // Initialize currentControlledConfiguration from client's current configuration @@ -210,7 +221,7 @@ public String getServiceUrl() { } @Override - public void close() { + public synchronized void close() { this.executor.shutdown(); if (httpClient != null) { try { diff --git a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/MessagePayloadContextImpl.java b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/MessagePayloadContextImpl.java index f4c9aa2707477..23ebf0a92a15d 100644 --- a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/MessagePayloadContextImpl.java +++ b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/MessagePayloadContextImpl.java @@ -75,8 +75,13 @@ public static MessagePayloadContextImpl get(final BrokerEntryMetadata brokerEntr context.consumer = consumer; context.redeliveryCount = redeliveryCount; context.ackSetInMessageId = BatchMessageIdImpl.newAckSet(context.getNumMessages()); - context.ackBitSet = (ackSet != null && ackSet.size() > 0) - ? BitSetRecyclable.valueOf(SafeCollectionUtils.longListToArray(ackSet)) + boolean isAckSetNotEmpty = ackSet != null && ackSet.size() > 0; + long[] ackSetArray = SafeCollectionUtils.longListToArray(ackSet); + if (isAckSetNotEmpty) { + context.ackSetInMessageId.and(BitSet.valueOf(ackSetArray)); + } + context.ackBitSet = isAckSetNotEmpty + ? BitSetRecyclable.valueOf(ackSetArray) : null; return context; } diff --git a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/MultiTopicsConsumerImpl.java b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/MultiTopicsConsumerImpl.java index f5f24cb5b89d5..ee87c124545c7 100644 --- a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/MultiTopicsConsumerImpl.java +++ b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/MultiTopicsConsumerImpl.java @@ -529,7 +529,7 @@ protected CompletableFuture doAcknowledge(List messageIdList, } consumerToMessageIds.forEach((consumer, messageIds) -> { resultFutures.add(consumer.doAcknowledgeWithTxn(messageIds, ackType, properties, txn) - .thenAccept((res) -> messageIdList.forEach(unAckedMessageTracker::remove))); + .thenAccept((res) -> messageIds.forEach(unAckedMessageTracker::remove))); }); } return CompletableFuture.allOf(resultFutures.toArray(new CompletableFuture[0])); @@ -968,6 +968,14 @@ private void removeTopic(String topic) { } } + protected void removeTopicMessagesFromUnackedTracker(String topicName) { + if (unAckedMessageTracker instanceof UnAckedTopicMessageTracker) { + ((UnAckedTopicMessageTracker) unAckedMessageTracker).removeTopicMessages(topicName); + } else if (unAckedMessageTracker instanceof UnAckedTopicMessageRedeliveryTracker) { + ((UnAckedTopicMessageRedeliveryTracker) unAckedMessageTracker).removeTopicMessages(topicName); + } + } + /*** * Subscribe one more given topic. * @param topicName topic name without the partition suffix. @@ -1302,9 +1310,7 @@ public CompletableFuture unsubscribeAsync(String topicName) { }); removeTopic(topicName); - if (unAckedMessageTracker instanceof UnAckedTopicMessageTracker) { - ((UnAckedTopicMessageTracker) unAckedMessageTracker).removeTopicMessages(topicName); - } + removeTopicMessagesFromUnackedTracker(topicName); unsubscribeFuture.complete(null); log.info("[{}] [{}] [{}] Unsubscribed Topics Consumer, allTopicPartitionsNumber: {}", @@ -1431,7 +1437,8 @@ private CompletableFuture subscribeIncreasedTopicPartitions(String topicNa } } - return FutureUtil.waitForAll(futures); + return FutureUtil.waitForAll(futures) + .thenRun(() -> removeTopicMessagesFromUnackedTracker(topicName)); } else if (oldPartitionNumber < currentPartitionNumber) { allTopicPartitionsNumber.addAndGet(currentPartitionNumber - oldPartitionNumber); partitionedTopics.put(topicName, currentPartitionNumber); diff --git a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/PartitionedProducerImpl.java b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/PartitionedProducerImpl.java index cca1899851507..dfc020ccb5721 100644 --- a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/PartitionedProducerImpl.java +++ b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/PartitionedProducerImpl.java @@ -19,8 +19,6 @@ package org.apache.pulsar.client.impl; import static com.google.common.base.Preconditions.checkArgument; -import static org.apache.pulsar.client.impl.conf.ProducerConfigurationData.DEFAULT_MAX_PENDING_MESSAGES; -import static org.apache.pulsar.client.impl.conf.ProducerConfigurationData.DEFAULT_MAX_PENDING_MESSAGES_ACROSS_PARTITIONS; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; import io.netty.util.Timeout; @@ -85,14 +83,23 @@ public PartitionedProducerImpl(PulsarClientImpl client, String topic, ProducerCo ? new PartitionedTopicProducerStatsRecorderImpl() : null; + // The across-partitions budget is a total shared by every partition, so it is divided between + // them here, where the partition count is finally known. Both limits are read through their + // "configured" markers rather than by comparing against their unset value, because that value + // is 0 for both and 0 is also a meaningful explicit setting. // MaxPendingMessagesAcrossPartitions doesn't support partial partition such as SinglePartition correctly int maxPendingMessages = conf.getMaxPendingMessages(); int maxPendingMessagesAcrossPartitions = conf.getMaxPendingMessagesAcrossPartitions(); - if (maxPendingMessagesAcrossPartitions != DEFAULT_MAX_PENDING_MESSAGES_ACROSS_PARTITIONS) { - int maxPendingMsgsForOnePartition = maxPendingMessagesAcrossPartitions / numPartitions; - maxPendingMessages = (maxPendingMessages == DEFAULT_MAX_PENDING_MESSAGES) - ? maxPendingMsgsForOnePartition - : Math.min(maxPendingMessages, maxPendingMsgsForOnePartition); + if (conf.isMaxPendingMessagesAcrossPartitionsConfigured() && maxPendingMessagesAcrossPartitions > 0) { + // Never divide down to 0: a budget smaller than the partition count still asks for the + // smallest possible queue, not for the queue bound to be removed altogether. + int maxPendingMsgsForOnePartition = + Math.max(1, maxPendingMessagesAcrossPartitions / numPartitions); + // A per-producer limit the application set wins, including an explicit 0 ("no message-count + // limit"). Only a limit it left unset — or one filled in as a default — adopts the share. + maxPendingMessages = conf.isMaxPendingMessagesConfigured() + ? Math.min(maxPendingMessages, maxPendingMsgsForOnePartition) + : maxPendingMsgsForOnePartition; conf.setMaxPendingMessages(maxPendingMessages); } diff --git a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/PatternMultiTopicsConsumerImpl.java b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/PatternMultiTopicsConsumerImpl.java index 3da14637a1a6f..c0da46c13c77a 100644 --- a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/PatternMultiTopicsConsumerImpl.java +++ b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/PatternMultiTopicsConsumerImpl.java @@ -366,6 +366,7 @@ public CompletableFuture onTopicsRemoved(Collection removedTopics) removedPartitionedTopicsForLog.add(String.format("%s with %s partitions", groupedTopicRemoved, partitions)); partitionedTopics.remove(groupedTopicRemoved, partitions); + removeTopicMessagesFromUnackedTracker(groupedTopicRemoved); } } } diff --git a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/PersistentAcknowledgmentsGroupingTracker.java b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/PersistentAcknowledgmentsGroupingTracker.java index 0598dc4fb3626..0a366a759d3f4 100644 --- a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/PersistentAcknowledgmentsGroupingTracker.java +++ b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/PersistentAcknowledgmentsGroupingTracker.java @@ -26,6 +26,7 @@ import java.util.ArrayList; import java.util.BitSet; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -279,6 +280,11 @@ private CompletableFuture doIndividualAckAsync(MessageIdAdv messageId) { return CompletableFuture.completedFuture(null); } + @VisibleForTesting + int getPendingIndividualAcksSize() { + return pendingIndividualAcks.size(); + } + private CompletableFuture doIndividualBatchAck(MessageIdAdv batchMessageId, Map properties) { if (acknowledgementGroupTimeMicros == 0 || (properties != null && !properties.isEmpty())) { @@ -405,7 +411,7 @@ private CompletableFuture doImmediateBatchIndexAck(MessageIdAdv msgId, int } CompletableFuture completableFuture = newMessageAckCommandAndWrite(cnx, consumer.consumerId, - msgId.getLedgerId(), msgId.getEntryId(), bitSet, ackType, properties, true, null, null); + msgId.getLedgerId(), msgId.getEntryId(), bitSet, ackType, properties, true, null, null, null); bitSet.recycle(); return completableFuture; } @@ -441,13 +447,15 @@ private void flushAsync(ClientCnx cnx) { newMessageAckCommandAndWrite(cnx, consumer.consumerId, messageId.getLedgerId(), messageId.getEntryId(), lastCumulativeAckToFlush.getBitSetRecyclable(), AckType.Cumulative, Collections.emptyMap(), false, - (TimedCompletableFuture) this.currentCumulativeAckFuture, null); + (TimedCompletableFuture) this.currentCumulativeAckFuture, null, null); this.consumer.unAckedChunkedMessageIdSequenceMap.remove(messageId); } // Flush all individual acks List> entriesToAck = new ArrayList<>(pendingIndividualAcks.size() + pendingIndividualBatchIndexAcks.size()); + List individualAcksToFlush = new ArrayList<>(pendingIndividualAcks.size()); + Map chunkedMessageIdsToRestore = new HashMap<>(); if (!pendingIndividualAcks.isEmpty()) { if (Commands.peerSupportsMultiMessageAcknowledgment(cnx.getRemoteEndpointProtocolVersion())) { // We can send 1 single protobuf command with all individual acks @@ -456,11 +464,13 @@ private void flushAsync(ClientCnx cnx) { if (msgId == null) { break; } + individualAcksToFlush.add(msgId); // if messageId is checked then all the chunked related to that msg also processed so, ack all of // them MessageIdImpl[] chunkMsgIds = this.consumer.unAckedChunkedMessageIdSequenceMap.get(msgId); if (chunkMsgIds != null && chunkMsgIds.length > 1) { + chunkedMessageIdsToRestore.put(msgId, chunkMsgIds); for (MessageIdImpl cMsgId : chunkMsgIds) { if (cMsgId != null) { entriesToAck.add(Triple.of(cMsgId.getLedgerId(), cMsgId.getEntryId(), null)); @@ -479,14 +489,16 @@ private void flushAsync(ClientCnx cnx) { if (msgId == null) { break; } + individualAcksToFlush.add(msgId); newMessageAckCommandAndWrite(cnx, consumer.consumerId, msgId.getLedgerId(), msgId.getEntryId(), null, AckType.Individual, Collections.emptyMap(), false, - null, null); + null, null, () -> restoreIndividualAck(msgId, null)); shouldFlush = true; } } } + List> batchIndexAcksToFlush = new ArrayList<>(); while (true) { Map.Entry entry = pendingIndividualBatchIndexAcks.pollFirstEntry(); @@ -494,6 +506,7 @@ private void flushAsync(ClientCnx cnx) { // The entry has been removed in a different thread break; } + batchIndexAcksToFlush.add(entry); entriesToAck.add(Triple.of( entry.getKey().getLedgerId(), entry.getKey().getEntryId(), entry.getValue())); } @@ -502,7 +515,9 @@ private void flushAsync(ClientCnx cnx) { newMessageAckCommandAndWrite(cnx, consumer.consumerId, 0L, 0L, null, AckType.Individual, null, true, - (TimedCompletableFuture) currentIndividualAckFuture, entriesToAck); + (TimedCompletableFuture) currentIndividualAckFuture, entriesToAck, + () -> restoreIndividualAndBatchIndexAcks(individualAcksToFlush, chunkedMessageIdsToRestore, + batchIndexAcksToFlush)); shouldFlush = true; } @@ -547,19 +562,19 @@ private CompletableFuture newImmediateAckAndFlush(long consumerId, Message } } completableFuture = newMessageAckCommandAndWrite(cnx, consumer.consumerId, 0L, 0L, - null, ackType, null, true, null, entriesToAck); + null, ackType, null, true, null, entriesToAck, null); } else { // if don't support multi message ack, it also support ack receipt, so we should not think about the // ack receipt in this logic for (MessageIdImpl cMsgId : chunkMsgIds) { newMessageAckCommandAndWrite(cnx, consumerId, cMsgId.getLedgerId(), cMsgId.getEntryId(), - bitSet, ackType, map, true, null, null); + bitSet, ackType, map, true, null, null, null); } completableFuture = CompletableFuture.completedFuture(null); } } else { completableFuture = newMessageAckCommandAndWrite(cnx, consumerId, msgId.getLedgerId(), msgId.getEntryId(), - bitSet, ackType, map, true, null, null); + bitSet, ackType, map, true, null, null, null); } return completableFuture; } @@ -569,7 +584,8 @@ private CompletableFuture newMessageAckCommandAndWrite( long entryId, BitSetRecyclable ackSet, AckType ackType, Map properties, boolean flush, TimedCompletableFuture timedCompletableFuture, - List> entriesToAck) { + List> entriesToAck, + Runnable writeFailureCallback) { if (consumer.isAckReceiptEnabled()) { final long requestId = consumer.getClient().newRequestId(); final ByteBuf cmd; @@ -611,14 +627,53 @@ private CompletableFuture newMessageAckCommandAndWrite( cmd = Commands.newMultiMessageAck(consumerId, entriesToAck, -1); } if (flush) { - cnx.ctx().writeAndFlush(cmd, cnx.ctx().voidPromise()); + if (writeFailureCallback == null) { + cnx.ctx().writeAndFlush(cmd, cnx.ctx().voidPromise()); + } else { + cnx.ctx().writeAndFlush(cmd).addListener(writeFuture -> { + if (!writeFuture.isSuccess()) { + writeFailureCallback.run(); + } + }); + } } else { - cnx.ctx().write(cmd, cnx.ctx().voidPromise()); + if (writeFailureCallback == null) { + cnx.ctx().write(cmd, cnx.ctx().voidPromise()); + } else { + cnx.ctx().write(cmd).addListener(writeFuture -> { + if (!writeFuture.isSuccess()) { + writeFailureCallback.run(); + } + }); + } } return CompletableFuture.completedFuture(null); } } + private void restoreIndividualAck(MessageIdAdv messageId, @Nullable MessageIdImpl[] chunkMsgIds) { + pendingIndividualAcks.add(messageId); + restoreChunkedMessageIds(messageId, chunkMsgIds); + } + + private void restoreIndividualAndBatchIndexAcks(List messageIds, + Map chunkMsgIds, + List> batchIndexAcks) { + pendingIndividualAcks.addAll(messageIds); + chunkMsgIds.forEach(this::restoreChunkedMessageIds); + batchIndexAcks.forEach(entry -> pendingIndividualBatchIndexAcks.merge(entry.getKey(), entry.getValue(), + (currentValue, valueToRestore) -> { + currentValue.and(valueToRestore); + return currentValue; + })); + } + + private void restoreChunkedMessageIds(MessageIdAdv messageId, @Nullable MessageIdImpl[] chunkMsgIds) { + if (chunkMsgIds != null) { + consumer.unAckedChunkedMessageIdSequenceMap.putIfAbsent(messageId, chunkMsgIds); + } + } + public Optional acquireReadLock() { Optional optionalLock = Optional.ofNullable(consumer.isAckReceiptEnabled() ? lock.readLock() : null); optionalLock.ifPresent(Lock::lock); diff --git a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ProducerBuilderImpl.java b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ProducerBuilderImpl.java index 3457aac0402fa..bc5abdf0d6868 100644 --- a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ProducerBuilderImpl.java +++ b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ProducerBuilderImpl.java @@ -108,15 +108,28 @@ public CompletableFuture> createAsync() { return FutureUtil.failedFuture(pce); } + ProducerConfigurationData producerConf = client.applyNoMemoryLimitProducerDefaults(conf); + return interceptorList == null || interceptorList.size() == 0 - ? client.createProducerAsync(conf, schema, null) - : client.createProducerAsync(conf, schema, new ProducerInterceptors(interceptorList)); + ? client.createProducerAsync(producerConf, schema, null) + : client.createProducerAsync(producerConf, schema, new ProducerInterceptors(interceptorList)); } @Override public ProducerBuilder loadConf(Map config) { + // A limit present in the map was configured by the application, even when its value is the + // same as the unset one. loadData builds a new configuration instance by replaying every + // property through its setters, so the markers have to be carried over rather than read off + // the result. + boolean maxPendingMessagesConfigured = + conf.isMaxPendingMessagesConfigured() || config.containsKey("maxPendingMessages"); + boolean maxPendingMessagesAcrossPartitionsConfigured = + conf.isMaxPendingMessagesAcrossPartitionsConfigured() + || config.containsKey("maxPendingMessagesAcrossPartitions"); conf = ConfigurationDataUtils.loadData( config, conf, ProducerConfigurationData.class); + conf.setMaxPendingMessagesConfigured(maxPendingMessagesConfigured); + conf.setMaxPendingMessagesAcrossPartitionsConfigured(maxPendingMessagesAcrossPartitionsConfigured); return this; } diff --git a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/PulsarClientImpl.java b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/PulsarClientImpl.java index 5f9acf2ff313c..e738307e0b00a 100644 --- a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/PulsarClientImpl.java +++ b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/PulsarClientImpl.java @@ -305,14 +305,7 @@ public ProducerBuilder newProducer() { @Override public ProducerBuilder newProducer(Schema schema) { - ProducerBuilderImpl producerBuilder = new ProducerBuilderImpl<>(this, schema); - if (!memoryLimitController.isMemoryLimited()) { - // set default limits for producers when memory limit controller is disabled - producerBuilder.maxPendingMessages(NO_MEMORY_LIMIT_DEFAULT_MAX_PENDING_MESSAGES); - producerBuilder.maxPendingMessagesAcrossPartitions( - NO_MEMORY_LIMIT_DEFAULT_MAX_PENDING_MESSAGES_ACROSS_PARTITIONS); - } - return producerBuilder; + return new ProducerBuilderImpl<>(this, schema); } @Override @@ -401,6 +394,83 @@ public CompletableFuture> createProducerAsync(ProducerConfigurat } + /** + * Apply the default pending-message limits a producer gets when this client has no memory limit. + * + *

The client memory limit is a producer's primary backpressure: it bounds the memory held by + * messages that have been queued but not yet acknowledged by the broker. When it is disabled + * there is nothing left to bound that queue, so producers fall back to the pre-PIP-120 + * message-count defaults rather than buffering without any limit at all. + * + *

These are defaults, not a floor. A limit the application configured is always kept — including + * an explicit {@code 0}, which is how an application asks for no message-count limit at all. Only a + * limit that was never configured is filled in, which is why this reads the markers the + * configuration carries rather than inferring it from the values: {@code 0} is both the unset value + * and a meaningful explicit one. + * + *

What is filled in here stays marked as unconfigured, so on a partitioned topic + * {@link PartitionedProducerImpl} divides only a budget the application actually asked for. A + * filled-in budget never lowers a per-producer limit that was asked for. + * + *

Called by {@link ProducerBuilderImpl}. + * + * @param conf the requested producer configuration, carrying the markers that say which limits the + * application configured + * @return the configuration to create the producer with; a resolved copy when a default applies, + * otherwise {@code conf} unchanged + */ + public ProducerConfigurationData applyNoMemoryLimitProducerDefaults(ProducerConfigurationData conf) { + // A limit that is already positive was configured by definition, whichever way the + // configuration was populated. The markers only tell an explicit 0 apart from an unset one. + boolean maxPendingMessagesConfigured = + conf.isMaxPendingMessagesConfigured() || conf.getMaxPendingMessages() > 0; + boolean maxPendingMessagesAcrossPartitionsConfigured = + conf.isMaxPendingMessagesAcrossPartitionsConfigured() + || conf.getMaxPendingMessagesAcrossPartitions() > 0; + if ((maxPendingMessagesConfigured && maxPendingMessagesAcrossPartitionsConfigured) + || memoryLimitController.isMemoryLimited()) { + return conf; + } + int maxPendingMessages = maxPendingMessagesConfigured + ? conf.getMaxPendingMessages() + : NO_MEMORY_LIMIT_DEFAULT_MAX_PENDING_MESSAGES; + final int maxPendingMessagesAcrossPartitions; + if (maxPendingMessagesAcrossPartitionsConfigured) { + maxPendingMessagesAcrossPartitions = conf.getMaxPendingMessagesAcrossPartitions(); + } else if (maxPendingMessages == 0) { + // The application asked for no per-producer limit at all, so there is no queue for a + // budget to bound. Leaving it unset keeps the resolved configuration honest about that. + maxPendingMessagesAcrossPartitions = 0; + } else { + maxPendingMessagesAcrossPartitions = + Math.max(maxPendingMessages, NO_MEMORY_LIMIT_DEFAULT_MAX_PENDING_MESSAGES_ACROSS_PARTITIONS); + } + if (maxPendingMessagesAcrossPartitions > 0) { + // The across-partitions limit is a budget shared by every partition, so a single producer's + // queue can never exceed it. A configured 0 means there is no such budget and is left + // alone, rather than capping every producer at zero. + maxPendingMessages = Math.min(maxPendingMessages, maxPendingMessagesAcrossPartitions); + } + + // Resolve on a copy: the builder hands over its own configuration instance, so filling in a + // limit here would otherwise leak into the next producer built from the same builder. + ProducerConfigurationData resolved = conf.clone(); + resolved.setMaxPendingMessages(maxPendingMessages); + resolved.setMaxPendingMessagesAcrossPartitions(maxPendingMessagesAcrossPartitions); + // The setters mark whatever they are given as configured, so restore the markers: what is + // filled in here is a default, and a partitioned producer still has to be able to tell it apart + // from a limit the application asked for, so that a budget it did ask for is the one that gets + // divided. + resolved.setMaxPendingMessagesConfigured(maxPendingMessagesConfigured); + resolved.setMaxPendingMessagesAcrossPartitionsConfigured(maxPendingMessagesAcrossPartitionsConfigured); + if (log.isDebugEnabled()) { + log.debug("[{}] Client memory limit is disabled, applying default producer pending message limits." + + " maxPendingMessages: {}, maxPendingMessagesAcrossPartitions: {}", + conf.getTopicName(), maxPendingMessages, maxPendingMessagesAcrossPartitions); + } + return resolved; + } + public CompletableFuture reloadSchemaForAutoProduceProducer(String topic, AutoProduceBytesSchema autoSchema) { return lookup.getSchema(TopicName.get(topic)).thenAccept(schemaInfoOptional -> { if (schemaInfoOptional.isPresent()) { diff --git a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/SameAuthParamsLookupAutoClusterFailover.java b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/SameAuthParamsLookupAutoClusterFailover.java index 743f2e15164ff..b137e37996efa 100644 --- a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/SameAuthParamsLookupAutoClusterFailover.java +++ b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/SameAuthParamsLookupAutoClusterFailover.java @@ -19,10 +19,11 @@ package org.apache.pulsar.client.impl; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import io.netty.channel.EventLoopGroup; -import io.netty.util.concurrent.ScheduledFuture; import java.util.Arrays; import java.util.HashSet; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -35,14 +36,21 @@ import org.apache.pulsar.client.util.ExecutorProvider; import org.apache.pulsar.common.naming.TopicName; import org.apache.pulsar.common.util.FutureUtil; -import org.apache.pulsar.common.util.netty.EventLoopUtil; +/** + * A service URL provider that probes multiple Pulsar service URLs with the same authentication + * parameters and fails over according to service health. + * + *

Each instance is tied to the lifecycle of one {@link PulsarClient}. Once initialized by a + * Pulsar client, it must not be reused by another client. Create a new provider instance for each + * Pulsar client. + */ @Slf4j @SuppressFBWarnings(value = {"EI_EXPOSE_REP2"}) public class SameAuthParamsLookupAutoClusterFailover implements ServiceUrlProvider { private PulsarClientImpl pulsarClient; - private EventLoopGroup executor; + private ScheduledExecutorService executor; private volatile boolean closed; private ScheduledFuture scheduledCheckTask; @Getter @@ -65,12 +73,18 @@ public class SameAuthParamsLookupAutoClusterFailover implements ServiceUrlProvid private SameAuthParamsLookupAutoClusterFailover() {} @Override - public void initialize(PulsarClient client) { + public synchronized void initialize(PulsarClient client) { + if (this.pulsarClient != null) { + throw new IllegalStateException("ServiceUrlProvider has already been initialized"); + } this.currentPulsarServiceIndex = 0; this.pulsarClient = (PulsarClientImpl) client; - this.executor = EventLoopUtil.newEventLoopGroup(1, false, + this.executor = Executors.newSingleThreadScheduledExecutor( new ExecutorProvider.ExtendedThreadFactory("broker-service-url-check")); - scheduledCheckTask = executor.scheduleAtFixedRate(() -> { + // Use fixed-delay (not fixed-rate) scheduling: a probe can block up to its timeout, and with a + // plain single-threaded scheduled executor fixed-rate runs would otherwise pile up back-to-back + // and monopolize the thread. Fixed-delay leaves a gap after each check completes. + scheduledCheckTask = executor.scheduleWithFixedDelay(() -> { try { if (closed) { return; @@ -111,7 +125,7 @@ public String getServiceUrl() { } @Override - public void close() throws Exception { + public synchronized void close() throws Exception { if (closed) { return; } @@ -143,6 +157,11 @@ private int findFailoverTo() { for (int i = currentPulsarServiceIndex + 1; i < pulsarServiceUrlArray.length; i++) { if (probeAvailable(i)) { return i; + } else { + // Mark the service as Failed to prevent a spurious recovery to it + // after we failover to a higher-indexed service. + pulsarServiceStateArray[i] = PulsarServiceState.Failed; + checkCounterArray[i].setValue(0); } } return -1; @@ -256,6 +275,17 @@ private void updateServiceUrl(int targetIndex) { try { pulsarClient.updateServiceUrl(targetUrl); pulsarClient.reloadLookUp(); + // When recovering to a higher-priority service, the check loop will only probe + // indices 0..targetIndex going forward. Any transient state (e.g., PreFail from + // a single timed-out probe) at higher indices would become stuck because those + // indices are no longer probed. Reset them so they start fresh if a future + // failover needs to consider them again. + if (targetIndex < currentPulsarServiceIndex) { + for (int i = targetIndex + 1; i < pulsarServiceStateArray.length; i++) { + pulsarServiceStateArray[i] = PulsarServiceState.Healthy; + checkCounterArray[i].setValue(0); + } + } currentPulsarServiceIndex = targetIndex; } catch (Exception e) { log.error("Failed to {}", logMsg, e); @@ -355,4 +385,3 @@ public SameAuthParamsLookupAutoClusterFailover build() { } } } - diff --git a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/TopicMessageIdImpl.java b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/TopicMessageIdImpl.java index 3dc9b23e93e86..a75b7278666ae 100644 --- a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/TopicMessageIdImpl.java +++ b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/TopicMessageIdImpl.java @@ -22,6 +22,7 @@ import org.apache.pulsar.client.api.MessageId; import org.apache.pulsar.client.api.MessageIdAdv; import org.apache.pulsar.client.api.TopicMessageId; +import org.apache.pulsar.common.naming.TopicName; public class TopicMessageIdImpl implements MessageIdAdv, TopicMessageId { @@ -90,6 +91,12 @@ public String getOwnerTopic() { return ownerTopic; } + @Override + public boolean hasSameBasePartitionedTopic(String topicName) { + return TopicName.get(getOwnerTopic()).getPartitionedTopicName() + .equals(TopicName.get(topicName).getPartitionedTopicName()); + } + @Override public long getLedgerId() { return msgId.getLedgerId(); diff --git a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/UnAckedMessageRedeliveryTracker.java b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/UnAckedMessageRedeliveryTracker.java index 1405e279f8fe5..076616d87d406 100644 --- a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/UnAckedMessageRedeliveryTracker.java +++ b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/UnAckedMessageRedeliveryTracker.java @@ -59,6 +59,10 @@ public UnAckedMessageRedeliveryTracker(PulsarClientImpl client, ConsumerBase timeout = client.timer().newTimeout(new TimerTask() { @Override public void run(Timeout t) throws Exception { + if (t.isCancelled()) { + return; + } + writeLock.lock(); try { HashSet headPartition = redeliveryTimePartitions.removeFirst(); @@ -73,8 +77,11 @@ public void run(Timeout t) throws Exception { redeliveryTimePartitions.addLast(headPartition); triggerRedelivery(consumerBase); } finally { - writeLock.unlock(); - timeout = client.timer().newTimeout(this, tickDurationInMs, TimeUnit.MILLISECONDS); + try { + timeout = client.timer().newTimeout(this, tickDurationInMs, TimeUnit.MILLISECONDS); + } finally { + writeLock.unlock(); + } } } }, this.tickDurationInMs, TimeUnit.MILLISECONDS); diff --git a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/UnAckedTopicMessageRedeliveryTracker.java b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/UnAckedTopicMessageRedeliveryTracker.java index 823dd4ad5f488..a13b636e3ec78 100644 --- a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/UnAckedTopicMessageRedeliveryTracker.java +++ b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/UnAckedTopicMessageRedeliveryTracker.java @@ -43,8 +43,7 @@ public int removeTopicMessages(String topicName) { UnackMessageIdWrapper messageIdWrapper = entry.getKey(); MessageId messageId = messageIdWrapper.getMessageId(); if (messageId instanceof TopicMessageId - && ((TopicMessageId) messageId).getOwnerTopic().contains(topicName)) { - HashSet exist = redeliveryMessageIdPartitionMap.get(messageIdWrapper); + && ((TopicMessageId) messageId).hasSameBasePartitionedTopic(topicName)) { entry.getValue().remove(messageIdWrapper); iterator.remove(); messageIdWrapper.recycle(); @@ -53,11 +52,11 @@ public int removeTopicMessages(String topicName) { } Iterator iteratorAckTimeOut = ackTimeoutMessages.keySet().iterator(); - while (iterator.hasNext()) { + while (iteratorAckTimeOut.hasNext()) { MessageId messageId = iteratorAckTimeOut.next(); if (messageId instanceof TopicMessageId - && ((TopicMessageId) messageId).getOwnerTopic().contains(topicName)) { - iterator.remove(); + && ((TopicMessageId) messageId).hasSameBasePartitionedTopic(topicName)) { + iteratorAckTimeOut.remove(); removed++; } } diff --git a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/UnAckedTopicMessageTracker.java b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/UnAckedTopicMessageTracker.java index 1cbab5844046f..669a2ed961ba7 100644 --- a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/UnAckedTopicMessageTracker.java +++ b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/UnAckedTopicMessageTracker.java @@ -41,7 +41,7 @@ public int removeTopicMessages(String topicName) { Entry> entry = iterator.next(); MessageId messageId = entry.getKey(); if (messageId instanceof TopicMessageId - && ((TopicMessageId) messageId).getOwnerTopic().contains(topicName)) { + && ((TopicMessageId) messageId).hasSameBasePartitionedTopic(topicName)) { entry.getValue().remove(messageId); iterator.remove(); removed++; diff --git a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ZeroQueueConsumerImpl.java b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ZeroQueueConsumerImpl.java index cba7af69f8b09..a5ed76f1368b7 100644 --- a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ZeroQueueConsumerImpl.java +++ b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/ZeroQueueConsumerImpl.java @@ -193,7 +193,7 @@ protected void tryTriggerListener() { @Override void receiveIndividualMessagesFromBatch(BrokerEntryMetadata brokerEntryMetadata, MessageMetadata msgMetadata, - int redeliveryCount, List ackSet, ByteBuf uncompressedPayload, + int redeliveryCount, long[] ackSet, ByteBuf uncompressedPayload, MessageIdData messageId, ClientCnx cnx, long consumerEpoch) { rejectBatchMessageByClosingConsumer( diff --git a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/auth/oauth2/ClientCredentialsFlow.java b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/auth/oauth2/ClientCredentialsFlow.java index 6692e86848b73..95f7bc56cb85e 100644 --- a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/auth/oauth2/ClientCredentialsFlow.java +++ b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/auth/oauth2/ClientCredentialsFlow.java @@ -147,7 +147,7 @@ public void initialize() throws PulsarClientException { assert this.metadata != null; URL tokenUrl = this.metadata.getTokenEndpoint(); - this.exchanger = new TokenClient(tokenUrl, httpClient); + this.exchanger = new TokenClient(tokenUrl, getHttpClient()); initialized = true; } diff --git a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/auth/oauth2/FlowBase.java b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/auth/oauth2/FlowBase.java index d1caa82e07c22..b6d7b39f236ff 100644 --- a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/auth/oauth2/FlowBase.java +++ b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/auth/oauth2/FlowBase.java @@ -67,21 +67,31 @@ abstract class FlowBase implements Flow { private static final long serialVersionUID = 1L; protected final URL issuerUrl; - protected transient AsyncHttpClient httpClient; + private final Duration connectTimeout; + private final Duration readTimeout; + private final String trustCertsFilePath; + private final String certFile; + private final String keyFile; + private final long autoCertRefreshSeconds; protected final String wellKnownMetadataPath; protected transient PulsarSslFactory sslFactory; protected transient ScheduledExecutorService sslRefreshScheduler; protected transient Metadata metadata; + private transient AsyncHttpClient httpClient; protected FlowBase(URL issuerUrl, Duration connectTimeout, Duration readTimeout, String trustCertsFilePath, String certFile, String keyFile, Duration autoCertRefreshDuration, String wellKnownMetadataPath) { this.issuerUrl = issuerUrl; - this.httpClient = defaultHttpClient(readTimeout, connectTimeout, trustCertsFilePath, certFile, keyFile); - long autoCertRefreshSeconds = getParameterDurationToSeconds(CONFIG_PARAM_AUTO_CERT_REFRESH_DURATION, + this.connectTimeout = connectTimeout; + this.readTimeout = readTimeout; + this.trustCertsFilePath = trustCertsFilePath; + this.certFile = certFile; + this.keyFile = keyFile; + this.autoCertRefreshSeconds = getParameterDurationToSeconds(CONFIG_PARAM_AUTO_CERT_REFRESH_DURATION, autoCertRefreshDuration, DEFAULT_AUTO_CERT_REFRESH_DURATION); - scheduleSslContextRefreshIfEnabled(autoCertRefreshSeconds); this.wellKnownMetadataPath = wellKnownMetadataPath; + getHttpClient(); } private AsyncHttpClient defaultHttpClient(Duration readTimeout, Duration connectTimeout, @@ -132,6 +142,14 @@ private AsyncHttpClient defaultHttpClient(Duration readTimeout, Duration connect return new DefaultAsyncHttpClient(confBuilder.build()); } + protected synchronized AsyncHttpClient getHttpClient() { + if (httpClient == null) { + httpClient = defaultHttpClient(readTimeout, connectTimeout, trustCertsFilePath, certFile, keyFile); + scheduleSslContextRefreshIfEnabled(autoCertRefreshSeconds); + } + return httpClient; + } + private void scheduleSslContextRefreshIfEnabled(long refreshSeconds) { if (sslFactory == null || refreshSeconds <= 0 || sslRefreshScheduler != null) { return; @@ -185,7 +203,7 @@ public void initialize() throws PulsarClientException { } protected MetadataResolver createMetadataResolver() { - return DefaultMetadataResolver.fromIssuerUrl(issuerUrl, httpClient, wellKnownMetadataPath); + return DefaultMetadataResolver.fromIssuerUrl(issuerUrl, getHttpClient(), wellKnownMetadataPath); } static String parseParameterString(Map params, String name) { diff --git a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/auth/oauth2/TlsClientAuthFlow.java b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/auth/oauth2/TlsClientAuthFlow.java index d61dadef83bfb..d592002b4a458 100644 --- a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/auth/oauth2/TlsClientAuthFlow.java +++ b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/auth/oauth2/TlsClientAuthFlow.java @@ -113,7 +113,7 @@ public void initialize() throws PulsarClientException { assert this.metadata != null; URL tokenUrl = this.metadata.getTokenEndpoint(); - this.exchanger = new TokenClient(tokenUrl, httpClient); + this.exchanger = new TokenClient(tokenUrl, getHttpClient()); initialized = true; } @@ -152,4 +152,4 @@ public void close() throws Exception { String getClientId() { return clientId; } -} \ No newline at end of file +} diff --git a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/conf/ProducerConfigurationData.java b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/conf/ProducerConfigurationData.java index a7da82599398d..1d085e5f1c047 100644 --- a/pulsar-client/src/main/java/org/apache/pulsar/client/impl/conf/ProducerConfigurationData.java +++ b/pulsar-client/src/main/java/org/apache/pulsar/client/impl/conf/ProducerConfigurationData.java @@ -109,6 +109,30 @@ public class ProducerConfigurationData implements Serializable, Cloneable { ) private int maxPendingMessagesAcrossPartitions = DEFAULT_MAX_PENDING_MESSAGES_ACROSS_PARTITIONS; + /** + * Whether the application configured {@link #maxPendingMessages}, and whether it configured + * {@link #maxPendingMessagesAcrossPartitions}. + * + *

The unset value of both limits is {@code 0}, which is also a meaningful explicit value ("no + * message-count limit" and "no across-partitions budget"), so the value alone cannot tell the two + * apart. Recording it here rather than on the builder lets everything that resolves these limits + * read the same answer, including {@code PartitionedProducerImpl}, which only sees the + * configuration. + * + *

Setting either limit through its setter marks it as configured, so a configuration populated + * directly rather than through {@code ProducerBuilderImpl} behaves the same way. + * + *

Deliberately not part of the serialized configuration: {@code loadConf} rebuilds the instance + * by replaying every property through its setters, which would mark both limits as configured + * whatever the application passed, so {@code ProducerBuilderImpl} restores them across that + * round-trip. {@code PulsarClientImpl} likewise restores them after filling in a default, which is + * not application input. + */ + @JsonIgnore + private boolean maxPendingMessagesConfigured; + @JsonIgnore + private boolean maxPendingMessagesAcrossPartitionsConfigured; + @ApiModelProperty( name = "messageRoutingMode", value = "Message routing logic for producers on [partitioned topics]" @@ -248,12 +272,22 @@ public void setProducerName(String producerName) { public void setMaxPendingMessages(int maxPendingMessages) { checkArgument(maxPendingMessages >= 0, "maxPendingMessages needs to be >= 0"); this.maxPendingMessages = maxPendingMessages; + this.maxPendingMessagesConfigured = true; } + /** + * The across-partitions budget used to be rejected when it was below {@link #maxPendingMessages}, + * which made the two setters order-dependent: it depended on which of them had been called first, + * and it made {@code loadConf} fail outright for any positive {@code maxPendingMessages}, since + * that replays every property through the setters in an order the caller does not control. The + * relationship is enforced where it is used instead — {@code PartitionedProducerImpl} lowers the + * per-partition limit to the share of the budget when a budget is set. + */ public void setMaxPendingMessagesAcrossPartitions(int maxPendingMessagesAcrossPartitions) { - checkArgument(maxPendingMessagesAcrossPartitions >= maxPendingMessages, - "maxPendingMessagesAcrossPartitions needs to be >= maxPendingMessages"); + checkArgument(maxPendingMessagesAcrossPartitions >= 0, + "maxPendingMessagesAcrossPartitions needs to be >= 0"); this.maxPendingMessagesAcrossPartitions = maxPendingMessagesAcrossPartitions; + this.maxPendingMessagesAcrossPartitionsConfigured = true; } public void setBatchingMaxMessages(int batchingMaxMessages) { diff --git a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/AcknowledgementsGroupingTrackerTest.java b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/AcknowledgementsGroupingTrackerTest.java index 76f2f45a0304c..410819ef732b6 100644 --- a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/AcknowledgementsGroupingTrackerTest.java +++ b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/AcknowledgementsGroupingTrackerTest.java @@ -18,6 +18,7 @@ */ package org.apache.pulsar.client.impl; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; @@ -28,9 +29,13 @@ import static org.testng.Assert.assertNotNull; import static org.testng.Assert.assertTrue; import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.EventLoopGroup; import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.util.ReferenceCountUtil; +import io.netty.util.concurrent.Future; +import io.netty.util.concurrent.GenericFutureListener; import java.util.ArrayList; import java.util.BitSet; import java.util.Collections; @@ -46,6 +51,7 @@ import org.apache.pulsar.client.util.TimedCompletableFuture; import org.apache.pulsar.common.api.proto.CommandAck.AckType; import org.apache.pulsar.common.api.proto.ProtocolVersion; +import org.apache.pulsar.common.util.FutureUtil; import org.testng.annotations.AfterClass; import org.testng.annotations.BeforeClass; import org.testng.annotations.DataProvider; @@ -57,6 +63,8 @@ public class AcknowledgementsGroupingTrackerTest { private ConsumerImpl consumer; private EventLoopGroup eventLoopGroup; private AtomicBoolean returnCnx = new AtomicBoolean(true); + private ChannelHandlerContext successCtx; + private AtomicBoolean failAckCommandSend = new AtomicBoolean(false); @BeforeClass public void setup() throws NoSuchFieldException, IllegalAccessException { @@ -71,9 +79,9 @@ public void setup() throws NoSuchFieldException, IllegalAccessException { doReturn(new ConsumerStatsRecorderImpl()).when(consumer).getStats(); doReturn(UnAckedMessageTracker.UNACKED_MESSAGE_TRACKER_DISABLED) .when(consumer).getUnAckedMessageTracker(); - ChannelHandlerContext ctx = ClientTestFixtures.mockChannelHandlerContext(); + successCtx = ClientTestFixtures.mockChannelHandlerContext(); doAnswer(invocation -> returnCnx.get() ? cnx : null).when(consumer).getClientCnx(); - doReturn(ctx).when(cnx).ctx(); + doReturn(successCtx).when(cnx).ctx(); } @DataProvider(name = "isNeedReceipt") @@ -324,6 +332,60 @@ public void testAckTrackerMultiAck(boolean isNeedReceipt) { tracker.close(); } + @Test + public void testFlushRetainsPendingIndividualAckOnSendFailureWithoutAckReceipt() throws Exception { + ConsumerConfigurationData conf = new ConsumerConfigurationData<>(); + conf.setAcknowledgementsGroupTimeMicros(TimeUnit.SECONDS.toMicros(10)); + conf.setAckReceiptEnabled(false); + doReturn(false).when(consumer).isAckReceiptEnabled(); + PersistentAcknowledgmentsGroupingTracker tracker = + new PersistentAcknowledgmentsGroupingTracker(consumer, conf, eventLoopGroup); + + MessageIdImpl msg1 = new MessageIdImpl(5, 1, 0); + tracker.addAcknowledgment(msg1, AckType.Individual, Collections.emptyMap()); + assertEquals(tracker.getPendingIndividualAcksSize(), 1); + + doReturn(createFailedChannelHandlerContext()).when(cnx).ctx(); + + tracker.flush(); + + assertTrue(tracker.isDuplicate(msg1)); + assertEquals(tracker.getPendingIndividualAcksSize(), 1); + + doReturn(successCtx).when(cnx).ctx(); + + tracker.flush(); + + assertFalse(tracker.isDuplicate(msg1)); + assertEquals(tracker.getPendingIndividualAcksSize(), 0); + tracker.close(); + } + + @Test + public void testFlushFailsAckFutureOnSendFailureWithAckReceipt() throws Exception { + ConsumerConfigurationData conf = new ConsumerConfigurationData<>(); + conf.setAcknowledgementsGroupTimeMicros(TimeUnit.SECONDS.toMicros(10)); + conf.setAckReceiptEnabled(true); + doReturn(true).when(consumer).isAckReceiptEnabled(); + PersistentAcknowledgmentsGroupingTracker tracker = + new PersistentAcknowledgmentsGroupingTracker(consumer, conf, eventLoopGroup); + + MessageIdImpl msg1 = new MessageIdImpl(5, 1, 0); + CompletableFuture ackFuture = + tracker.addAcknowledgment(msg1, AckType.Individual, Collections.emptyMap()); + assertEquals(tracker.getPendingIndividualAcksSize(), 1); + + failAckCommandSend.set(true); + tracker.flush(); + + assertTrue(ackFuture.isCompletedExceptionally()); + assertFalse(tracker.isDuplicate(msg1)); + assertEquals(tracker.getPendingIndividualAcksSize(), 0); + + failAckCommandSend.set(false); + tracker.close(); + } + @Test(dataProvider = "isNeedReceipt") public void testBatchAckTrackerMultiAck(boolean isNeedReceipt) throws Exception { ConsumerConfigurationData conf = new ConsumerConfigurationData<>(); @@ -464,12 +526,40 @@ public ClientCnxTest(ClientConfigurationData conf, EventLoopGroup eventLoopGroup @Override public CompletableFuture newAckForReceipt(ByteBuf request, long requestId) { + if (failAckCommandSend.get()) { + return FutureUtil.failedFuture(new RuntimeException("ack send failed")); + } return CompletableFuture.completedFuture(null); } @Override public void newAckForReceiptWithFuture(ByteBuf request, long requestId, TimedCompletableFuture future) { + if (failAckCommandSend.get()) { + future.completeExceptionally(new RuntimeException("ack send failed")); + } } } + + private ChannelHandlerContext createFailedChannelHandlerContext() { + ChannelHandlerContext ctx = mock(ChannelHandlerContext.class); + ChannelFuture listenerFuture = mock(ChannelFuture.class); + ChannelFuture failedFuture = mock(ChannelFuture.class); + when(failedFuture.isSuccess()).thenReturn(false); + when(failedFuture.cause()).thenReturn(new RuntimeException("ack send failed")); + doAnswer(invocation -> { + GenericFutureListener> listener = invocation.getArgument(0); + listener.operationComplete(failedFuture); + return listenerFuture; + }).when(listenerFuture).addListener(any()); + doAnswer(invocation -> { + ReferenceCountUtil.release(invocation.getArgument(0)); + return listenerFuture; + }).when(ctx).write(any()); + doAnswer(invocation -> { + ReferenceCountUtil.release(invocation.getArgument(0)); + return listenerFuture; + }).when(ctx).writeAndFlush(any()); + return ctx; + } } diff --git a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/AutoClusterFailoverTest.java b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/AutoClusterFailoverTest.java index b275ffb6012ca..00688db800fe3 100644 --- a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/AutoClusterFailoverTest.java +++ b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/AutoClusterFailoverTest.java @@ -31,10 +31,12 @@ import lombok.extern.slf4j.Slf4j; import org.apache.pulsar.client.api.Authentication; import org.apache.pulsar.client.api.AuthenticationFactory; +import org.apache.pulsar.client.api.PulsarClient; import org.apache.pulsar.client.api.ServiceUrlProvider; import org.apache.pulsar.client.impl.conf.ClientConfigurationData; import org.awaitility.Awaitility; import org.mockito.Mockito; +import org.testng.Assert; import org.testng.annotations.Test; @Test(groups = "broker-impl") @@ -148,6 +150,25 @@ public void testInitialize() throws Exception { } } + @Test + public void testInitializeCanOnlyBeCalledOnce() throws Exception { + String primary = "pulsar://localhost:6650"; + String secondary = "pulsar://localhost:6651"; + + ServiceUrlProvider provider = AutoClusterFailover.builder() + .primary(primary) + .secondary(Collections.singletonList(secondary)) + .failoverDelay(1, TimeUnit.SECONDS) + .switchBackDelay(1, TimeUnit.SECONDS) + .checkInterval(30, TimeUnit.SECONDS) + .build(); + + try (PulsarClient client = PulsarClient.builder().serviceUrlProvider(provider).build()) { + Throwable error = Assert.expectThrows(IllegalStateException.class, () -> provider.initialize(client)); + assertEquals(error.getMessage(), "ServiceUrlProvider has already been initialized"); + } + } + @Test public void testAutoClusterFailoverSwitchWithoutAuthentication() throws Exception { String primary = "pulsar://localhost:6650"; diff --git a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/ClientCnxRequestTimeoutQueueTest.java b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/ClientCnxRequestTimeoutQueueTest.java index c4dab1ad351ab..7e5d98b136a40 100644 --- a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/ClientCnxRequestTimeoutQueueTest.java +++ b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/ClientCnxRequestTimeoutQueueTest.java @@ -64,6 +64,7 @@ void setupClientCnx() throws Exception { ChannelHandlerContext ctx = mock(ChannelHandlerContext.class); Channel channel = mock(Channel.class); when(ctx.writeAndFlush(any())).thenAnswer(args -> mock(ChannelFuture.class)); + when(ctx.write(any())).thenAnswer(args -> mock(ChannelFuture.class)); when(ctx.channel()).thenReturn(channel); when(channel.remoteAddress()).thenReturn(new InetSocketAddress(1234)); cnx.channelActive(ctx); diff --git a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/ClientCnxTest.java b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/ClientCnxTest.java index c0a75b09ccea1..20de6eacf55bf 100644 --- a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/ClientCnxTest.java +++ b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/ClientCnxTest.java @@ -37,6 +37,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import org.apache.pulsar.client.api.PulsarClientException; import org.apache.pulsar.client.api.PulsarClientException.BrokerMetadataException; @@ -46,6 +47,7 @@ import org.apache.pulsar.common.api.proto.CommandCloseProducer; import org.apache.pulsar.common.api.proto.CommandConnected; import org.apache.pulsar.common.api.proto.CommandError; +import org.apache.pulsar.common.api.proto.CommandLookupTopicResponse; import org.apache.pulsar.common.api.proto.CommandWatchTopicListSuccess; import org.apache.pulsar.common.api.proto.CommandWatchTopicUpdate; import org.apache.pulsar.common.api.proto.ServerError; @@ -371,6 +373,302 @@ public void testUpdateWatcher() { }); } + /** + * Test that when a lookup request times out, the semaphore is properly released + * so that subsequent lookup requests can still be sent. + */ + @Test + public void testLookupTimeoutReleasesSemaphore() throws Exception { + EventLoopGroup eventLoop = EventLoopUtil.newEventLoopGroup(1, false, + new DefaultThreadFactory("testLookupTimeoutReleasesSemaphore")); + try { + ClientConfigurationData conf = new ClientConfigurationData(); + conf.setOperationTimeoutMs(10); + conf.setKeepAliveIntervalSeconds(0); + ClientCnx cnx = new ClientCnx(InstrumentProvider.NOOP, conf, eventLoop); + ChannelHandlerContext ctx = ClientTestFixtures.mockChannelHandlerContext(); + cnx.channelActive(ctx); + + int initialPermits = cnx.getPendingLookupRequestSemaphore().availablePermits(); + + // Send a lookup request that will time out + CompletableFuture future = + cnx.newLookup(null, 1L); + + // Wait for the timeout to trigger + try { + future.get(2, TimeUnit.SECONDS); + fail("Should have timed out"); + } catch (Exception e) { + assertTrue(e.getCause() instanceof PulsarClientException.TimeoutException); + } + + // Verify semaphore is released back to initial permits + Awaitility.await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { + assertEquals(cnx.getPendingLookupRequestSemaphore().availablePermits(), initialPermits); + }); + } finally { + eventLoop.shutdownGracefully().sync(); + } + } + + /** + * Test that when a lookup request times out, waiting lookup requests in the queue + * are properly dispatched (the waiting queue is driven). + */ + @Test + public void testLookupTimeoutDrivesWaitingQueue() throws Exception { + EventLoopGroup eventLoop = EventLoopUtil.newEventLoopGroup(1, false, + new DefaultThreadFactory("testLookupTimeoutDrivesWaitingQueue")); + try { + ClientConfigurationData conf = new ClientConfigurationData(); + // concurrentLookupRequest=50 by default, maxLookupRequest=50000 by default + conf.setOperationTimeoutMs(50); + conf.setKeepAliveIntervalSeconds(0); + conf.setConcurrentLookupRequest(1); // Only allow 1 concurrent lookup + conf.setMaxLookupRequest(10); // Allow up to 10 total (9 waiting) + ClientCnx cnx = new ClientCnx(InstrumentProvider.NOOP, conf, eventLoop); + ChannelHandlerContext ctx = ClientTestFixtures.mockChannelHandlerContext(); + cnx.channelActive(ctx); + cnx.state = ClientCnx.State.Ready; + + // First lookup occupies the only concurrent slot + CompletableFuture firstFuture = + cnx.newLookup(null, 1L); + + // Second lookup should go into the waiting queue + CompletableFuture secondFuture = + cnx.newLookup(null, 2L); + + // The second future should not be completed yet (it's waiting) + assertFalse(secondFuture.isDone()); + + // Wait for the first request to time out - this should drive the waiting queue + // and dispatch the second request + try { + firstFuture.get(2, TimeUnit.SECONDS); + fail("First future should have timed out"); + } catch (Exception e) { + assertTrue(e.getCause() instanceof PulsarClientException.TimeoutException); + } + + // After the first request times out, the second request should have been + // dispatched from the waiting queue (it will also eventually time out) + try { + secondFuture.get(2, TimeUnit.SECONDS); + fail("Second future should have timed out"); + } catch (Exception e) { + assertTrue(e.getCause() instanceof PulsarClientException.TimeoutException); + } + + // Verify all semaphores are properly released + Awaitility.await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { + assertEquals(cnx.getPendingLookupRequestSemaphore().availablePermits(), 1); + }); + } finally { + eventLoop.shutdownGracefully().sync(); + } + } + + /** + * Test that when multiple lookup requests time out, all waiting requests in the queue + * are eventually dispatched and no semaphore leak occurs. + */ + @Test + public void testMultipleLookupTimeoutsNoSemaphoreLeak() throws Exception { + EventLoopGroup eventLoop = EventLoopUtil.newEventLoopGroup(1, false, + new DefaultThreadFactory("testMultipleLookupTimeoutsNoSemaphoreLeak")); + try { + ClientConfigurationData conf = new ClientConfigurationData(); + conf.setOperationTimeoutMs(30); + conf.setKeepAliveIntervalSeconds(0); + conf.setConcurrentLookupRequest(2); // Allow 2 concurrent lookups + conf.setMaxLookupRequest(10); // Allow up to 10 total (8 waiting) + ClientCnx cnx = new ClientCnx(InstrumentProvider.NOOP, conf, eventLoop); + ChannelHandlerContext ctx = ClientTestFixtures.mockChannelHandlerContext(); + cnx.channelActive(ctx); + cnx.state = ClientCnx.State.Ready; + + // Send 5 lookup requests: 2 will be concurrent, 3 will be in waiting queue + CompletableFuture[] futures = new CompletableFuture[5]; + for (int i = 0; i < 5; i++) { + futures[i] = cnx.newLookup(null, i + 1L); + } + + // Wait for all futures to complete (all should time out eventually) + for (int i = 0; i < 5; i++) { + try { + futures[i].get(5, TimeUnit.SECONDS); + fail("Future " + i + " should have timed out"); + } catch (Exception e) { + assertTrue(e.getCause() instanceof PulsarClientException.TimeoutException, + "Future " + i + " should fail with TimeoutException but got: " + e.getCause()); + } + } + + // Verify all semaphore permits are released (no leak) + Awaitility.await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { + assertEquals(cnx.getPendingLookupRequestSemaphore().availablePermits(), 2); + }); + } finally { + eventLoop.shutdownGracefully().sync(); + } + } + + /** + * Test that when a failed lookup response is received, + * the semaphore permit is properly released via the unified cleanup primitive + * (removePendingRequest) without double-release. + */ + @Test + public void testFailedLookupResponseReleasesSemaphore() throws Exception { + EventLoopGroup eventLoop = EventLoopUtil.newEventLoopGroup(1, false, + new DefaultThreadFactory("testFailedLookupResponseReleasesSemaphore")); + try { + ClientConfigurationData conf = new ClientConfigurationData(); + conf.setOperationTimeoutMs(10_000); + conf.setKeepAliveIntervalSeconds(0); + ClientCnx cnx = new ClientCnx(InstrumentProvider.NOOP, conf, eventLoop); + ChannelHandlerContext ctx = ClientTestFixtures.mockChannelHandlerContext(); + cnx.channelActive(ctx); + cnx.state = ClientCnx.State.Ready; + + int initialPermits = cnx.getPendingLookupRequestSemaphore().availablePermits(); + + // Send a lookup request + CompletableFuture future = + cnx.newLookup(null, 1L); + + // Simulate a failed lookup response (CommandLookupTopicResponse with Failed status) + CommandLookupTopicResponse lookupResponse = new CommandLookupTopicResponse(); + lookupResponse.setRequestId(1L); + lookupResponse.setResponse(CommandLookupTopicResponse.LookupType.Failed); + lookupResponse.setError(ServerError.ServiceNotReady); + lookupResponse.setMessage("Service not ready"); + cnx.handleLookupResponse(lookupResponse); + + // Verify the future completed exceptionally + try { + future.get(2, TimeUnit.SECONDS); + fail("Should have failed"); + } catch (Exception e) { + // expected + } + + // Verify semaphore is released back to initial permits (no leak, no double-release) + // Previously this would cause availablePermits to be initialPermits+1 due to double-release + Awaitility.await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { + assertEquals(cnx.getPendingLookupRequestSemaphore().availablePermits(), initialPermits); + }); + } finally { + eventLoop.shutdownGracefully().sync(); + } + } + + /** + * Test that when the connection is closed while an active lookup request is pending, + * the semaphore permit is properly released. + */ + @Test + public void testActiveRequestConnectionCloseReleasesSemaphore() throws Exception { + EventLoopGroup eventLoop = EventLoopUtil.newEventLoopGroup(1, false, + new DefaultThreadFactory("testActiveRequestConnectionCloseReleasesSemaphore")); + try { + ClientConfigurationData conf = new ClientConfigurationData(); + conf.setOperationTimeoutMs(10_000); + conf.setKeepAliveIntervalSeconds(0); + ClientCnx cnx = new ClientCnx(InstrumentProvider.NOOP, conf, eventLoop); + ChannelHandlerContext ctx = ClientTestFixtures.mockChannelHandlerContext(); + cnx.channelActive(ctx); + + int initialPermits = cnx.getPendingLookupRequestSemaphore().availablePermits(); + + // Send a lookup request + CompletableFuture future = + cnx.newLookup(null, 1L); + + // Simulate connection close + cnx.channelInactive(ctx); + + // Verify the future completed exceptionally with ConnectException + try { + future.get(2, TimeUnit.SECONDS); + fail("Should have failed"); + } catch (Exception e) { + assertTrue(e.getCause() instanceof PulsarClientException.ConnectException); + } + + // Verify semaphore is released back to initial permits + Awaitility.await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { + assertEquals(cnx.getPendingLookupRequestSemaphore().availablePermits(), initialPermits); + }); + } finally { + eventLoop.shutdownGracefully().sync(); + } + } + + /** + * Test that when the connection is closed while a promoted waiting request is active, + * the semaphore permit is properly released and no leak occurs. + */ + @Test + public void testPromotedWaitingRequestConnectionCloseReleasesSemaphore() throws Exception { + EventLoopGroup eventLoop = EventLoopUtil.newEventLoopGroup(1, false, + new DefaultThreadFactory("testPromotedWaitingRequestConnectionCloseReleasesSemaphore")); + try { + ClientConfigurationData conf = new ClientConfigurationData(); + conf.setOperationTimeoutMs(10_000); + conf.setKeepAliveIntervalSeconds(0); + conf.setConcurrentLookupRequest(1); // Only allow 1 concurrent lookup + conf.setMaxLookupRequest(10); + ClientCnx cnx = new ClientCnx(InstrumentProvider.NOOP, conf, eventLoop); + ChannelHandlerContext ctx = ClientTestFixtures.mockChannelHandlerContext(); + cnx.channelActive(ctx); + cnx.state = ClientCnx.State.Ready; + + // First lookup occupies the only concurrent slot + CompletableFuture firstFuture = + cnx.newLookup(null, 1L); + + // Second lookup goes into the waiting queue + CompletableFuture secondFuture = + cnx.newLookup(null, 2L); + + // Simulate a failed lookup response for the first request, which promotes the second + CommandLookupTopicResponse failedResponse = new CommandLookupTopicResponse(); + failedResponse.setRequestId(1L); + failedResponse.setResponse(CommandLookupTopicResponse.LookupType.Failed); + failedResponse.setError(ServerError.ServiceNotReady); + failedResponse.setMessage("Service not ready"); + cnx.handleLookupResponse(failedResponse); + + // Verify the first future completed exceptionally + assertTrue(firstFuture.isCompletedExceptionally()); + + // Wait for the second request to be promoted from the waiting queue + Awaitility.await().atMost(2, TimeUnit.SECONDS) + .until(() -> cnx.pendingRequests.containsKey(2L)); + + // Now close the connection while the promoted request is active + cnx.channelInactive(ctx); + + // Verify the second future completed exceptionally + try { + secondFuture.get(2, TimeUnit.SECONDS); + fail("Second future should have failed"); + } catch (Exception e) { + assertTrue(e.getCause() instanceof PulsarClientException.ConnectException); + } + + // Verify semaphore is released (1 permit for concurrentLookupRequest=1) + Awaitility.await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> { + assertEquals(cnx.getPendingLookupRequestSemaphore().availablePermits(), 1); + }); + } finally { + eventLoop.shutdownGracefully().sync(); + } + } + private void withConnection(String testName, Consumer test) { ThreadFactory threadFactory = new DefaultThreadFactory(testName); EventLoopGroup eventLoop = EventLoopUtil.newEventLoopGroup(1, false, threadFactory); diff --git a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/ClientTestFixtures.java b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/ClientTestFixtures.java index ae0797fa4939a..494dea365d9e6 100644 --- a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/ClientTestFixtures.java +++ b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/ClientTestFixtures.java @@ -147,6 +147,11 @@ public static ChannelHandlerContext mockChannelHandlerContext() { }).when(listenerFuture).addListener(any()); // handle write and writeAndFlush methods so that the input message is released + doAnswer(invocation -> { + Object msg = invocation.getArgument(0); + ReferenceCountUtil.release(msg); + return listenerFuture; + }).when(ctx).write(any()); doAnswer(invocation -> { Object msg = invocation.getArgument(0); ReferenceCountUtil.release(msg); diff --git a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/ConsumerImplTest.java b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/ConsumerImplTest.java index c299e2c72702e..cf47d79178555 100644 --- a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/ConsumerImplTest.java +++ b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/ConsumerImplTest.java @@ -18,6 +18,7 @@ */ package org.apache.pulsar.client.impl; +import static org.apache.pulsar.common.protocol.Commands.DEFAULT_CONSUMER_EPOCH; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.any; @@ -31,6 +32,10 @@ import static org.mockito.Mockito.when; import static org.testng.Assert.assertTrue; import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import java.util.Arrays; +import java.util.BitSet; +import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutorService; @@ -38,17 +43,22 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; +import java.util.stream.Collectors; import lombok.Cleanup; import org.apache.pulsar.client.api.Consumer; import org.apache.pulsar.client.api.Message; +import org.apache.pulsar.client.api.MessageIdAdv; +import org.apache.pulsar.client.api.MessagePayload; import org.apache.pulsar.client.api.Messages; import org.apache.pulsar.client.api.PulsarClient; import org.apache.pulsar.client.api.PulsarClientException; +import org.apache.pulsar.client.api.Schema; import org.apache.pulsar.client.impl.conf.ClientConfigurationData; import org.apache.pulsar.client.impl.conf.ConsumerConfigurationData; import org.apache.pulsar.client.impl.conf.TopicConsumerConfigurationData; import org.apache.pulsar.client.util.ExecutorProvider; import org.apache.pulsar.client.util.ScheduledExecutorProvider; +import org.apache.pulsar.common.api.proto.MessageMetadata; import org.apache.pulsar.common.util.Backoff; import org.awaitility.Awaitility; import org.testng.Assert; @@ -336,4 +346,51 @@ public void testUpdateAutoScaleReceiverQueueHintRaceWithConcurrentDrain() { "Hint must reflect the post-enqueue state (pipeline had >=1 message); " + "a concurrent drain of the just-enqueued message must not clear it."); } + + @Test(invocationTimeOut = 1000) + public void testGetMessageAtSyncsAckSetInMessageIdWithBrokerAckSet() { + // Regression test for MessagePayloadContextImpl#getMessageAt: the BatchMessageIdImpl handed + // back to the caller carries a shared ackSetInMessageId bitset that must be seeded from the + // broker-reported ackSet, not a fresh "all unacked" bitset. Otherwise indices the broker + // already knows are acked would be reported as still-outstanding in the returned MessageId, + // which is the same root cause that let acked batch messages leak into the DLQ (see + // ConsumerImpl#receiveIndividualMessagesFromBatch and its ackSetInMessageId.and(...) fix). + final int batchSize = 3; + MessageMetadata messageMetadata = new MessageMetadata() + .setProducerName("test-producer") + .setSequenceId(0) + .setPublishTime(System.currentTimeMillis()) + .setNumMessagesInBatch(batchSize); + + // Broker reports index 0 as already acked (bit cleared); indices 1 and 2 are still + // outstanding (bits set). This mirrors the ackSet the broker attaches on redelivery. + BitSet brokerAckSet = new BitSet(batchSize); + brokerAckSet.set(1); + brokerAckSet.set(2); + List ackSet = Arrays.stream(brokerAckSet.toLongArray()).boxed().collect(Collectors.toList()); + + MessageIdImpl messageId = new MessageIdImpl(1L, 2L, -1); + MessagePayloadContextImpl context = MessagePayloadContextImpl.get( + null, messageMetadata, messageId, consumer, 0, ackSet, DEFAULT_CONSUMER_EPOCH); + MessagePayload payload0 = MessagePayloadImpl.create(Unpooled.wrappedBuffer(new byte[]{0})); + MessagePayload payload1 = MessagePayloadImpl.create(Unpooled.wrappedBuffer(new byte[]{1})); + try { + // Index 0 is already acked per the broker, so it must not be redelivered to the app. + Assert.assertNull(context.getMessageAt(0, batchSize, payload0, false, Schema.BYTES)); + + Message message1 = context.getMessageAt(1, batchSize, payload1, false, Schema.BYTES); + Assert.assertNotNull(message1); + + BitSet ackSetInMessageId = ((MessageIdAdv) message1.getMessageId()).getAckSet(); + Assert.assertFalse(ackSetInMessageId.get(0), + "index 0 was already acked by the broker, so the returned MessageId's ackSet " + + "must reflect it as acked, not fall back to the default all-unacked state"); + Assert.assertTrue(ackSetInMessageId.get(1), "index 1 is still outstanding"); + Assert.assertTrue(ackSetInMessageId.get(2), "index 2 is still outstanding"); + } finally { + payload0.release(); + payload1.release(); + context.recycle(); + } + } } diff --git a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/ControlledClusterFailoverTest.java b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/ControlledClusterFailoverTest.java index 86b2fa7cb4f9b..cc47841ce0a11 100644 --- a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/ControlledClusterFailoverTest.java +++ b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/ControlledClusterFailoverTest.java @@ -25,6 +25,7 @@ import java.util.concurrent.TimeUnit; import lombok.Cleanup; import org.apache.pulsar.client.api.Authentication; +import org.apache.pulsar.client.api.PulsarClient; import org.apache.pulsar.client.api.ServiceUrlProvider; import org.apache.pulsar.client.impl.conf.ClientConfigurationData; import org.asynchttpclient.Request; @@ -73,6 +74,22 @@ public void testBuildControlledClusterFailoverInstance() throws Exception { Assert.assertEquals(request.getHeaders().get(keyB), valueB); } + @Test + public void testInitializeCanOnlyBeCalledOnce() throws Exception { + String defaultServiceUrl = "pulsar://localhost:6650"; + String urlProvider = "http://localhost:8080/test"; + + ServiceUrlProvider provider = ControlledClusterFailover.builder() + .defaultServiceUrl(defaultServiceUrl) + .urlProvider(urlProvider) + .build(); + + try (PulsarClient client = PulsarClient.builder().serviceUrlProvider(provider).build()) { + Throwable error = Assert.expectThrows(IllegalStateException.class, () -> provider.initialize(client)); + Assert.assertEquals(error.getMessage(), "ServiceUrlProvider has already been initialized"); + } + } + @Test public void testControlledClusterFailoverSwitch() throws Exception { String defaultServiceUrl = "pulsar+ssl://localhost:6651"; diff --git a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/MultiTopicsConsumerImplTest.java b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/MultiTopicsConsumerImplTest.java index 54175613e3bb8..f16702577a30b 100644 --- a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/MultiTopicsConsumerImplTest.java +++ b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/MultiTopicsConsumerImplTest.java @@ -23,6 +23,7 @@ import static org.apache.pulsar.client.impl.ClientTestFixtures.createPulsarClientMockWithMockedClientCnx; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -37,6 +38,7 @@ import io.netty.util.Timer; import io.netty.util.concurrent.DefaultThreadFactory; import java.util.Arrays; +import java.util.Collections; import java.util.HashSet; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; @@ -266,4 +268,88 @@ public void testDontCheckForPartitionsUpdatesOnNonPartitionedTopics() throws Exc verify(clientMock, times(3)).getPartitionedTopicMetadata(any(), anyBoolean(), anyBoolean()); } + @Test + @SuppressWarnings("unchecked") + public void testOnTopicsExtendedRemovedTopicCleansUnackedMessages() { + String topicName = "persistent://public/default/deleted-topic"; + String topicPartition0 = topicName + "-partition-0"; + String topicPartition1 = topicName + "-partition-1"; + String otherTopicPartition = "persistent://public/default/other-topic-partition-0"; + + ConsumerConfigurationData consumerConfData = new ConsumerConfigurationData<>(); + consumerConfData.setSubscriptionName("subscriptionName"); + consumerConfData.setAutoUpdatePartitions(true); + consumerConfData.setAutoUpdatePartitionsIntervalSeconds(60); + consumerConfData.setAckTimeoutMillis(1000); + + MultiTopicsConsumerImpl impl = createMultiTopicsConsumer(consumerConfData); + + impl.partitionedTopics.put(topicName, 2); + impl.allTopicPartitionsNumber.set(2); + + ConsumerImpl partitionConsumer0 = (ConsumerImpl) mock(ConsumerImpl.class); + ConsumerImpl partitionConsumer1 = (ConsumerImpl) mock(ConsumerImpl.class); + when(partitionConsumer0.getTopic()).thenReturn(topicPartition0); + when(partitionConsumer1.getTopic()).thenReturn(topicPartition1); + when(partitionConsumer0.closeAsync()).thenReturn(CompletableFuture.completedFuture(null)); + when(partitionConsumer1.closeAsync()).thenReturn(CompletableFuture.completedFuture(null)); + impl.consumers.put(topicPartition0, partitionConsumer0); + impl.consumers.put(topicPartition1, partitionConsumer1); + + TopicMessageIdImpl removedTopicMessageId = new TopicMessageIdImpl(topicPartition0, new MessageIdImpl(1, 1, 0)); + TopicMessageIdImpl otherTopicMessageId = + new TopicMessageIdImpl(otherTopicPartition, new MessageIdImpl(2, 2, 0)); + impl.getUnAckedMessageTracker().add(removedTopicMessageId); + impl.getUnAckedMessageTracker().add(otherTopicMessageId); + assertEquals(impl.getUnAckedMessageTracker().size(), 2); + + when(impl.client.getPartitionsForTopic(topicName, false)).thenReturn(CompletableFuture.completedFuture( + Collections.emptyList())); + + PartitionsChangedListener listener = impl.topicsPartitionChangedListener; + listener.onTopicsExtended(Collections.singleton(topicName)).join(); + + assertTrue(impl.getConsumers().isEmpty()); + assertEquals(impl.partitionedTopics.get(topicName), Integer.valueOf(0)); + assertEquals(impl.allTopicPartitionsNumber.get(), 0); + assertEquals(impl.getUnAckedMessageTracker().size(), 1); + assertTrue(impl.getUnAckedMessageTracker().remove(otherTopicMessageId)); + verify(partitionConsumer0).closeAsync(); + verify(partitionConsumer1).closeAsync(); + } + + @Test + @SuppressWarnings("unchecked") + public void testBatchAcknowledgeRemovesOnlyAckedMessageIdsFromTracker() { + String topic0 = "persistent://public/default/topic-a-partition-0"; + String topic1 = "persistent://public/default/topic-b-partition-0"; + + ConsumerConfigurationData conf = new ConsumerConfigurationData<>(); + conf.setSubscriptionName("subscriptionName"); + conf.setAckTimeoutMillis(1000); + MultiTopicsConsumerImpl impl = createMultiTopicsConsumer(conf); + impl.setState(HandlerState.State.Ready); + + ConsumerImpl consumer0 = mock(ConsumerImpl.class); + ConsumerImpl consumer1 = mock(ConsumerImpl.class); + CompletableFuture pendingAck = new CompletableFuture<>(); + when(consumer0.getTopic()).thenReturn(topic0); + when(consumer1.getTopic()).thenReturn(topic1); + when(consumer0.doAcknowledgeWithTxn(anyList(), any(), any(), any())).thenReturn(pendingAck); + when(consumer1.doAcknowledgeWithTxn(anyList(), any(), any(), any())).thenReturn(new CompletableFuture<>()); + + impl.consumers.put(topic0, consumer0); + impl.consumers.put(topic1, consumer1); + + TopicMessageIdImpl messageId0 = new TopicMessageIdImpl(topic0, new MessageIdImpl(1, 1, 0)); + TopicMessageIdImpl messageId1 = new TopicMessageIdImpl(topic1, new MessageIdImpl(2, 2, 0)); + impl.getUnAckedMessageTracker().add(messageId0); + impl.getUnAckedMessageTracker().add(messageId1); + + impl.acknowledgeAsync(Arrays.asList(messageId0, messageId1)); + pendingAck.complete(null); + + assertEquals(impl.getUnAckedMessageTracker().size(), 1); + } + } diff --git a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/PartitionedProducerImplTest.java b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/PartitionedProducerImplTest.java index ce2c200344d96..c7de9c7866a54 100644 --- a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/PartitionedProducerImplTest.java +++ b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/PartitionedProducerImplTest.java @@ -300,14 +300,21 @@ public void testMaxPendingQueueSize() throws Exception { clientImpl, topicName, producerConfData, 1, null, null, null); assertEquals(partitionedProducerImpl.getConfiguration().getMaxPendingMessages(), 10); - // Test set MaxPendingMessagesAcrossPartitions=5 - producerConfData.setMaxPendingMessages(ProducerConfigurationData.DEFAULT_MAX_PENDING_MESSAGES); + // Test set MaxPendingMessagesAcrossPartitions=5 with maxPendingMessages left unset. A fresh + // configuration is required to express "unset": setting maxPendingMessages back to 0 would mean + // "no message-count limit", which is a limit of its own and would win over the budget's share. + producerConfData = new ProducerConfigurationData(); + producerConfData.setMessageRoutingMode(MessageRoutingMode.CustomPartition); + producerConfData.setCustomMessageRouter(new CustomMessageRouter()); producerConfData.setMaxPendingMessagesAcrossPartitions(5); partitionedProducerImpl = new PartitionedProducerImpl( clientImpl, topicName, producerConfData, 1, null, null, null); assertEquals(partitionedProducerImpl.getConfiguration().getMaxPendingMessages(), 5); // Test set maxPendingMessage=10 and MaxPendingMessagesAcrossPartitions=10 with 2 partitions + producerConfData = new ProducerConfigurationData(); + producerConfData.setMessageRoutingMode(MessageRoutingMode.CustomPartition); + producerConfData.setCustomMessageRouter(new CustomMessageRouter()); producerConfData.setMaxPendingMessages(10); producerConfData.setMaxPendingMessagesAcrossPartitions(10); partitionedProducerImpl = new PartitionedProducerImpl( diff --git a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/PatternMultiTopicsConsumerImplTest.java b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/PatternMultiTopicsConsumerImplTest.java index dd4bbff757acd..fe3338afa7e1e 100644 --- a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/PatternMultiTopicsConsumerImplTest.java +++ b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/PatternMultiTopicsConsumerImplTest.java @@ -310,6 +310,47 @@ public void testPatternSubscribeAndHashHandlingWithChanges() throws Exception { assertThat(invocationCount.get()).isEqualTo(5); } + @Test + @SuppressWarnings("unchecked") + public void testOnTopicsRemovedCleansUnackedMessagesForRemovedPartitionedTopic() { + String partitionedTopic = "persistent://tenant/namespace/deleted-topic"; + String partition0 = partitionedTopic + "-partition-0"; + String partition1 = partitionedTopic + "-partition-1"; + String otherTopicPartition = "persistent://tenant/namespace/other-topic-partition-0"; + TopicsPattern topicsPattern = + TopicsPatternFactory.create("persistent://tenant/namespace/.*", TopicsPattern.RegexImplementation.JDK); + ConsumerConfigurationData consumerConfData = createConsumerConfigurationData(); + consumerConfData.setAckTimeoutMillis(1000); + + PatternMultiTopicsConsumerImpl consumer = + createPatternMultiTopicsConsumer(consumerConfData, topicsPattern); + + consumer.partitionedTopics.put(partitionedTopic, 2); + + ConsumerImpl partitionConsumer0 = (ConsumerImpl) mock(ConsumerImpl.class); + ConsumerImpl partitionConsumer1 = (ConsumerImpl) mock(ConsumerImpl.class); + when(partitionConsumer0.closeAsync()).thenReturn(CompletableFuture.completedFuture(null)); + when(partitionConsumer1.closeAsync()).thenReturn(CompletableFuture.completedFuture(null)); + consumer.consumers.put(partition0, partitionConsumer0); + consumer.consumers.put(partition1, partitionConsumer1); + + TopicMessageIdImpl removedTopicMessageId = new TopicMessageIdImpl(partition0, new MessageIdImpl(1, 1, 0)); + TopicMessageIdImpl otherTopicMessageId = + new TopicMessageIdImpl(otherTopicPartition, new MessageIdImpl(2, 2, 0)); + consumer.getUnAckedMessageTracker().add(removedTopicMessageId); + consumer.getUnAckedMessageTracker().add(otherTopicMessageId); + assertThat(consumer.getUnAckedMessageTracker().size()).isEqualTo(2); + + consumer.topicsChangeListener.onTopicsRemoved(Arrays.asList(partition0, partition1)).join(); + + assertThat(consumer.partitionedTopics.containsKey(partitionedTopic)).isFalse(); + assertThat(consumer.consumers).doesNotContainKeys(partition0, partition1); + assertThat(consumer.getUnAckedMessageTracker().size()).isEqualTo(1); + assertThat(consumer.getUnAckedMessageTracker().remove(otherTopicMessageId)).isTrue(); + verify(partitionConsumer0).closeAsync(); + verify(partitionConsumer1).closeAsync(); + } + private static void runTimerTasks(Deque tasks) throws Exception { // first drain the queue to a list to avoid an infinite loop List taskList = new ArrayList<>(); diff --git a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/ProducerBuilderImplTest.java b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/ProducerBuilderImplTest.java index a49114d67b58f..92d41a6932325 100644 --- a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/ProducerBuilderImplTest.java +++ b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/ProducerBuilderImplTest.java @@ -22,9 +22,12 @@ import static org.mockito.Mockito.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; import static org.testng.Assert.assertNotNull; import static org.testng.Assert.assertTrue; import static org.testng.Assert.fail; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -60,6 +63,11 @@ public void setup() { producerBuilderImpl = new ProducerBuilderImpl<>(client, Schema.BYTES); when(client.newProducer()).thenReturn(producerBuilderImpl); + // The builder asks the client to fill in the pending-message defaults before creating the + // producer; on a mock that would otherwise hand back a null configuration. + when(client.applyNoMemoryLimitProducerDefaults(any(ProducerConfigurationData.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(client.createProducerAsync( any(ProducerConfigurationData.class), any(Schema.class), eq(null))) .thenReturn(CompletableFuture.completedFuture(producer)); @@ -116,6 +124,23 @@ public void testProducerBuilderImplWhenMessageRoutingModeIsRoundRobinPartition() assertNotNull(producer); } + /** + * {@code loadConf} rebuilds the configuration by replaying every property through the public + * setters, and {@code setMaxPendingMessagesAcrossPartitions} rejects a value below + * {@code maxPendingMessages}. Pins that loading a positive limit does not trip that check on the + * across-partitions property that comes with it, and that the limit is recorded as configured. + */ + @SuppressWarnings("deprecation") + @Test + public void testLoadConfWithAPositiveMaxPendingMessages() { + producerBuilderImpl = new ProducerBuilderImpl<>(client, Schema.BYTES); + producerBuilderImpl.loadConf(Collections.singletonMap("maxPendingMessages", 5000)); + + assertEquals(producerBuilderImpl.getConf().getMaxPendingMessages(), 5000); + assertTrue(producerBuilderImpl.getConf().isMaxPendingMessagesConfigured()); + assertFalse(producerBuilderImpl.getConf().isMaxPendingMessagesAcrossPartitionsConfigured()); + } + @Test public void testProducerBuilderImplWhenMessageRoutingIsSetImplicitly() throws PulsarClientException { producerBuilderImpl = new ProducerBuilderImpl(client, Schema.BYTES); @@ -373,11 +398,25 @@ public void testProducerBuilderImplWhenMaxPendingMessagesAcrossPartitionsPropert } @Test(expectedExceptions = IllegalArgumentException.class, expectedExceptionsMessageRegExp = - "maxPendingMessagesAcrossPartitions needs to be >= maxPendingMessages") + "maxPendingMessagesAcrossPartitions needs to be >= 0") public void testProducerBuilderImplWhenMaxPendingMessagesAcrossPartitionsPropertyIsInvalidErrorMessages() { producerBuilderImpl.maxPendingMessagesAcrossPartitions(-1); } + /** + * The across-partitions budget is allowed to be below {@code maxPendingMessages}: it is a budget + * shared by every partition, and the per-partition limit is lowered to its share where it is used. + * Rejecting it here made the two setters order-dependent. + */ + @SuppressWarnings("deprecation") + @Test + public void testAcrossPartitionsLimitBelowMaxPendingMessagesIsAccepted() { + producerBuilderImpl = new ProducerBuilderImpl<>(client, Schema.BYTES); + producerBuilderImpl.maxPendingMessages(1000).maxPendingMessagesAcrossPartitions(500); + + assertEquals(producerBuilderImpl.getConf().getMaxPendingMessagesAcrossPartitions(), 500); + } + @Test public void testProducerBuilderImplWhenNumericPropertiesAreValid() { producerBuilderImpl.batchingMaxPublishDelay(1, TimeUnit.SECONDS); diff --git a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/SameAuthParamsLookupAutoClusterFailoverTest.java b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/SameAuthParamsLookupAutoClusterFailoverTest.java new file mode 100644 index 0000000000000..959d913dff892 --- /dev/null +++ b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/SameAuthParamsLookupAutoClusterFailoverTest.java @@ -0,0 +1,317 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.client.impl; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import io.netty.channel.EventLoopGroup; +import java.lang.reflect.Method; +import java.net.InetSocketAddress; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import org.apache.commons.lang3.mutable.MutableInt; +import org.apache.commons.lang3.reflect.FieldUtils; +import org.apache.pulsar.client.impl.SameAuthParamsLookupAutoClusterFailover.PulsarServiceState; +import org.apache.pulsar.client.util.ExecutorProvider; +import org.apache.pulsar.common.util.FutureUtil; +import org.apache.pulsar.common.util.netty.EventLoopUtil; +import org.awaitility.Awaitility; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +@Test(groups = "broker-impl") +public class SameAuthParamsLookupAutoClusterFailoverTest { + + private static final String URL0 = "pulsar://broker0:6650"; + private static final String URL1 = "pulsar://broker1:6650"; + private static final String URL2 = "pulsar://broker2:6650"; + + private EventLoopGroup executor; + private PulsarClientImpl mockClient; + private SameAuthParamsLookupAutoClusterFailover failover; + private PulsarServiceState[] stateArray; + private MutableInt[] counterArray; + + @BeforeMethod + public void setup() throws Exception { + executor = EventLoopUtil.newEventLoopGroup(1, false, + new ExecutorProvider.ExtendedThreadFactory("test-failover")); + + String[] urlArray = new String[]{URL0, URL1, URL2}; + failover = SameAuthParamsLookupAutoClusterFailover.builder() + .pulsarServiceUrlArray(urlArray) + .failoverThreshold(1) + .recoverThreshold(2) + .checkHealthyIntervalMs(100) + .testTopic("a/b/c") + .markTopicNotFoundAsAvailable(true) + .build(); + + mockClient = mock(PulsarClientImpl.class); + doNothing().when(mockClient).updateServiceUrl(anyString()); + doNothing().when(mockClient).reloadLookUp(); + + FieldUtils.writeField(failover, "pulsarClient", mockClient, true); + FieldUtils.writeField(failover, "executor", executor, true); + + stateArray = (PulsarServiceState[]) FieldUtils.readField(failover, "pulsarServiceStateArray", true); + counterArray = (MutableInt[]) FieldUtils.readField(failover, "checkCounterArray", true); + } + + @AfterMethod(alwaysRun = true) + public void cleanup() { + if (executor != null) { + executor.shutdownGracefully(0, 0, TimeUnit.MILLISECONDS); + } + } + + private void setLookupResult(String url, boolean available) { + LookupService lookup = mock(LookupService.class); + if (available) { + InetSocketAddress addr = InetSocketAddress.createUnresolved("broker", 6650); + when(lookup.getBroker(any())) + .thenReturn(CompletableFuture.completedFuture( + new LookupTopicResult(addr, addr, false))); + } else { + when(lookup.getBroker(any())) + .thenReturn(FutureUtil.failedFuture( + new RuntimeException("connection refused"))); + } + when(mockClient.getLookup(url)).thenReturn(lookup); + } + + /** + * Reproduces the race condition where findFailoverTo() skips over an unavailable service + * without marking it as Failed. This leaves a stale Healthy state that causes a spurious + * recovery bounce (0 -> 2 -> 1 -> 2) instead of a clean failover (0 -> 2). + * + *

Before the fix, after failover from index 0 to 2, state[1] remained Healthy (stale). + * On the next check cycle, firstHealthyPulsarService() would see state[1]=Healthy and + * immediately "recover" to index 1 — which is actually a broken service. This caused + * unnecessary bouncing and, combined with 3-second probe timeouts on dead services, + * could push the total failover time past the test's awaitility timeout. + */ + @Test(timeOut = 30000) + public void testFindFailoverToMarksSkippedServicesAsFailed() throws Exception { + // url0 is down, url1 is down, url2 is healthy. + setLookupResult(URL0, false); + setLookupResult(URL1, false); + setLookupResult(URL2, true); + + // Pre-set state[0] to Failed (as if checkPulsarServices already detected it), + // then run one check cycle. All on the executor to ensure thread safety. + runOnExecutor(() -> { + stateArray[0] = PulsarServiceState.Failed; + counterArray[0].setValue(0); + }); + runCheckCycle(); + + // After the fix, findFailoverTo marks url1 as Failed when it fails probing. + // Verify on the executor thread where state is owned. + runOnExecutor(() -> { + assertEquals(failover.getCurrentPulsarServiceIndex(), 2, + "Should have failed over to index 2"); + assertEquals(stateArray[1], PulsarServiceState.Failed, + "Service 1 should be marked Failed by findFailoverTo, not remain stale Healthy"); + assertEquals(stateArray[2], PulsarServiceState.Healthy, + "Service 2 should remain Healthy"); + }); + } + + /** + * Verifies no spurious recovery bounce occurs after failover. Without the fix, + * the first check cycle after failover to index 2 would see stale Healthy state[1] + * and immediately switch to index 1. + */ + @Test(timeOut = 30000) + public void testNoSpuriousRecoveryBounceAfterFailover() throws Exception { + // url0 is down, url1 is down, url2 is healthy. + setLookupResult(URL0, false); + setLookupResult(URL1, false); + setLookupResult(URL2, true); + + // Pre-set state[0] to Failed. + runOnExecutor(() -> { + stateArray[0] = PulsarServiceState.Failed; + counterArray[0].setValue(0); + }); + + // Failover: 0 -> 2. + runCheckCycle(); + runOnExecutor(() -> assertEquals(failover.getCurrentPulsarServiceIndex(), 2)); + + // Run another check cycle. Without the fix, state[1] would be stale Healthy, + // and firstHealthyPulsarService would return 1, causing a spurious switch. + runCheckCycle(); + runOnExecutor(() -> assertEquals(failover.getCurrentPulsarServiceIndex(), 2, + "Should stay at index 2, not bounce to index 1")); + } + + /** + * Reproduces the bug fixed by resetting state of higher-priority-than-target indices on + * recovery. The check loop only probes indices 0..currentPulsarServiceIndex, so if a + * higher index was left in a transient state (e.g., PreFail from a single timed-out + * probe) at the moment of recovery, it would stay there forever because no future probe + * ever visits it. + * + *

Scenario: failover 0 -> 2 (state[2]=Healthy). url1 recovers and is about to trigger + * recovery 2 -> 1. On the same check cycle that promotes state[1] to Healthy, url2 sees + * one transient probe failure that flips state[2] from Healthy to PreFail. The recovery + * fires (current 2 -> 1) and from that point on the loop only probes indices 0 and 1. + * + *

Without the fix, state[2] stays at PreFail forever. With the fix, state[2] is + * reset to Healthy when the recovery transition runs. + */ + @Test(timeOut = 30000) + public void testRecoveryResetsHigherIndexStaleState() throws Exception { + // url0 down, url1 down, url2 up. + setLookupResult(URL0, false); + setLookupResult(URL1, false); + setLookupResult(URL2, true); + + // Pre-set state[0]=Failed and trigger failover 0 -> 2. + runOnExecutor(() -> { + stateArray[0] = PulsarServiceState.Failed; + counterArray[0].setValue(0); + }); + runCheckCycle(); + runOnExecutor(() -> { + assertEquals(failover.getCurrentPulsarServiceIndex(), 2); + assertEquals(stateArray[1], PulsarServiceState.Failed); + assertEquals(stateArray[2], PulsarServiceState.Healthy); + }); + + // url1 becomes healthy; first check cycle moves state[1] Failed -> PreRecover. + setLookupResult(URL1, true); + runCheckCycle(); + runOnExecutor(() -> { + assertEquals(failover.getCurrentPulsarServiceIndex(), 2); + assertEquals(stateArray[1], PulsarServiceState.PreRecover); + assertEquals(stateArray[2], PulsarServiceState.Healthy); + }); + + // On the cycle that completes recovery (state[1] PreRecover -> Healthy and triggers + // updateServiceUrl(1)), inject a single failed probe at url2 so state[2] flips + // Healthy -> PreFail just before the index transition. + setLookupResult(URL2, false); + runCheckCycle(); + + // After recovery to index 1: + // - With the fix: state[2] is reset to Healthy on the transition. + // - Without the fix: state[2] is stuck at PreFail forever — the check loop now + // only iterates 0..1 and never visits index 2 again. + runOnExecutor(() -> { + assertEquals(failover.getCurrentPulsarServiceIndex(), 1); + assertEquals(stateArray[1], PulsarServiceState.Healthy); + assertEquals(stateArray[2], PulsarServiceState.Healthy, + "state[2] should be reset to Healthy on recovery, not stuck at PreFail"); + assertEquals(counterArray[2].intValue(), 0, + "counter[2] should be reset to 0 on recovery"); + }); + } + + /** + * Verifies that recovery still works correctly for a service that was marked Failed + * by findFailoverTo, once that service becomes available again. + */ + @Test(timeOut = 30000) + public void testRecoveryAfterFindFailoverToMarksServiceFailed() throws Exception { + // url0 is down, url1 is down, url2 is healthy. + setLookupResult(URL0, false); + setLookupResult(URL1, false); + setLookupResult(URL2, true); + + // Pre-set state[0] to Failed and trigger failover 0 -> 2. + runOnExecutor(() -> { + stateArray[0] = PulsarServiceState.Failed; + counterArray[0].setValue(0); + }); + runCheckCycle(); + runOnExecutor(() -> { + assertEquals(failover.getCurrentPulsarServiceIndex(), 2); + assertEquals(stateArray[1], PulsarServiceState.Failed); + }); + + // Now make url1 healthy (simulating recovery of that service). + setLookupResult(URL1, true); + + // Run check cycles until service 1 recovers. + // Failed -> PreRecover (1 check) -> Healthy (recoverThreshold=2, so 1 more check). + Awaitility.await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + runCheckCycle(); + runOnExecutor(() -> { + assertEquals(failover.getCurrentPulsarServiceIndex(), 1, + "Should recover to index 1 after it becomes healthy"); + assertEquals(stateArray[1], PulsarServiceState.Healthy); + }); + }); + } + + private void runOnExecutor(Runnable task) throws Exception { + try { + executor.submit(task).get(5, TimeUnit.SECONDS); + } catch (java.util.concurrent.ExecutionException e) { + // Unwrap so that AssertionErrors propagate directly to Awaitility. + if (e.getCause() instanceof AssertionError) { + throw (AssertionError) e.getCause(); + } + throw e; + } + } + + private void runCheckCycle() throws Exception { + runOnExecutor(() -> { + try { + Method checkMethod = SameAuthParamsLookupAutoClusterFailover.class + .getDeclaredMethod("checkPulsarServices"); + checkMethod.setAccessible(true); + Method firstHealthyMethod = SameAuthParamsLookupAutoClusterFailover.class + .getDeclaredMethod("firstHealthyPulsarService"); + firstHealthyMethod.setAccessible(true); + Method findFailoverMethod = SameAuthParamsLookupAutoClusterFailover.class + .getDeclaredMethod("findFailoverTo"); + findFailoverMethod.setAccessible(true); + Method updateMethod = SameAuthParamsLookupAutoClusterFailover.class + .getDeclaredMethod("updateServiceUrl", int.class); + updateMethod.setAccessible(true); + + checkMethod.invoke(failover); + int firstHealthy = (int) firstHealthyMethod.invoke(failover); + int currentIndex = failover.getCurrentPulsarServiceIndex(); + if (firstHealthy != currentIndex) { + if (firstHealthy < 0) { + int failoverTo = (int) findFailoverMethod.invoke(failover); + if (failoverTo >= 0) { + updateMethod.invoke(failover, failoverTo); + } + } else { + updateMethod.invoke(failover, firstHealthy); + } + } + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } +} diff --git a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/TopicMessageIdImplTest.java b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/TopicMessageIdImplTest.java index 59f15266c1091..26f4335b75a20 100644 --- a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/TopicMessageIdImplTest.java +++ b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/TopicMessageIdImplTest.java @@ -19,8 +19,10 @@ package org.apache.pulsar.client.impl; import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; import static org.testng.Assert.assertNotEquals; import static org.testng.Assert.assertSame; +import static org.testng.Assert.assertTrue; import org.testng.annotations.Test; public class TopicMessageIdImplTest { @@ -54,6 +56,21 @@ public void equalsTest() { assertNotEquals(topicMsgId1, topicMsgId2); } + @Test + public void testHasSameBasePartitionedTopic() { + MessageIdImpl msgId = new MessageIdImpl(0, 0, 0); + TopicMessageIdImpl partitionMsgId = new TopicMessageIdImpl( + "persistent://public/default/my-topic-partition-0", msgId); + assertTrue(partitionMsgId.hasSameBasePartitionedTopic( + "persistent://public/default/my-topic-partition-1")); + assertTrue(partitionMsgId.hasSameBasePartitionedTopic( + "persistent://public/default/my-topic")); + assertFalse(partitionMsgId.hasSameBasePartitionedTopic( + "persistent://public/default/my-topic-v2")); + assertFalse(partitionMsgId.hasSameBasePartitionedTopic( + "persistent://public/default/my-topic-v2-partition-0")); + } + @Test public void testDeprecatedMethods() { BatchMessageIdImpl msgId = new BatchMessageIdImpl(1, 2, 3, 4); diff --git a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/UnAckedTopicMessageRedeliveryTrackerTest.java b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/UnAckedTopicMessageRedeliveryTrackerTest.java new file mode 100644 index 0000000000000..e0ad1f9111b7e --- /dev/null +++ b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/UnAckedTopicMessageRedeliveryTrackerTest.java @@ -0,0 +1,117 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.client.impl; + +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; +import io.netty.util.HashedWheelTimer; +import io.netty.util.Timer; +import io.netty.util.concurrent.DefaultThreadFactory; +import java.util.concurrent.TimeUnit; +import lombok.Cleanup; +import org.apache.pulsar.client.impl.conf.ConsumerConfigurationData; +import org.apache.pulsar.client.impl.metrics.InstrumentProvider; +import org.testng.annotations.Test; + +public class UnAckedTopicMessageRedeliveryTrackerTest { + + @Test + @SuppressWarnings("unchecked") + public void testRemoveTopicMessages() { + PulsarClientImpl client = mock(PulsarClientImpl.class); + ConnectionPool connectionPool = mock(ConnectionPool.class); + when(client.instrumentProvider()).thenReturn(InstrumentProvider.NOOP); + when(client.getCnxPool()).thenReturn(connectionPool); + @Cleanup("stop") + Timer timer = new HashedWheelTimer( + new DefaultThreadFactory("pulsar-timer", Thread.currentThread().isDaemon()), + 1, TimeUnit.MILLISECONDS); + when(client.timer()).thenReturn(timer); + + ConsumerBase consumer = mock(ConsumerBase.class); + doNothing().when(consumer).onAckTimeoutSend(any()); + doNothing().when(consumer).redeliverUnacknowledgedMessages(any()); + + ConsumerConfigurationData conf = new ConsumerConfigurationData<>(); + conf.setAckTimeoutMillis(1_000_000); + conf.setTickDurationMillis(100_000); + conf.setAckTimeoutRedeliveryBackoff(MultiplierRedeliveryBackoff.builder().build()); + + UnAckedTopicMessageRedeliveryTracker tracker = + new UnAckedTopicMessageRedeliveryTracker(client, consumer, conf); + + String ownerTopic = "persistent://public/default/my-topic-partition-0"; + TopicMessageIdImpl msgInPartition = + new TopicMessageIdImpl(ownerTopic, new MessageIdImpl(1L, 0L, -1)); + TopicMessageIdImpl msgInAckTimeout = + new TopicMessageIdImpl(ownerTopic, new MessageIdImpl(2L, 0L, -1)); + + assertTrue(tracker.add(msgInPartition)); + tracker.ackTimeoutMessages.put(msgInAckTimeout, System.currentTimeMillis() + 1_000_000L); + assertEquals(tracker.size(), 2); + + assertEquals(tracker.removeTopicMessages("persistent://public/default/my-topic-partition-0"), 2); + assertTrue(tracker.isEmpty()); + + tracker.close(); + } + + @Test + @SuppressWarnings("unchecked") + public void testRemoveTopicMessagesDoesNotMatchPrefixTopic() { + PulsarClientImpl client = mock(PulsarClientImpl.class); + ConnectionPool connectionPool = mock(ConnectionPool.class); + when(client.instrumentProvider()).thenReturn(InstrumentProvider.NOOP); + when(client.getCnxPool()).thenReturn(connectionPool); + @Cleanup("stop") + Timer timer = new HashedWheelTimer( + new DefaultThreadFactory("pulsar-timer", Thread.currentThread().isDaemon()), + 1, TimeUnit.MILLISECONDS); + when(client.timer()).thenReturn(timer); + + ConsumerBase consumer = mock(ConsumerBase.class); + doNothing().when(consumer).onAckTimeoutSend(any()); + doNothing().when(consumer).redeliverUnacknowledgedMessages(any()); + + ConsumerConfigurationData conf = new ConsumerConfigurationData<>(); + conf.setAckTimeoutMillis(1_000_000); + conf.setTickDurationMillis(100_000); + conf.setAckTimeoutRedeliveryBackoff(MultiplierRedeliveryBackoff.builder().build()); + + UnAckedTopicMessageRedeliveryTracker tracker = + new UnAckedTopicMessageRedeliveryTracker(client, consumer, conf); + + String ownerTopic = "persistent://public/default/my-topic-v2-partition-0"; + TopicMessageIdImpl msgInPartition = + new TopicMessageIdImpl(ownerTopic, new MessageIdImpl(1L, 0L, -1)); + + assertTrue(tracker.add(msgInPartition)); + assertEquals(tracker.size(), 1); + + assertEquals(tracker.removeTopicMessages("persistent://public/default/my-topic"), 0); + assertEquals(tracker.size(), 1); + + tracker.close(); + } + +} diff --git a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/auth/oauth2/AuthenticationOAuth2Test.java b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/auth/oauth2/AuthenticationOAuth2Test.java index 6b956957d307b..e87f3f5eb86cc 100644 --- a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/auth/oauth2/AuthenticationOAuth2Test.java +++ b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/auth/oauth2/AuthenticationOAuth2Test.java @@ -28,6 +28,10 @@ import static org.testng.Assert.assertNotNull; import static org.testng.Assert.assertThrows; import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; import java.net.MalformedURLException; import java.net.URI; import java.net.URL; @@ -119,6 +123,32 @@ public void testConfigureWithoutOptionalParams() throws Exception { assertNotNull(this.auth.flow); } + @Test + public void testConfiguredAuthRecreatesHttpClientAfterDeserialization() throws Exception { + AuthenticationOAuth2 configuredAuth = new AuthenticationOAuth2(); + configuredAuth.configure(minimalCredentialsJson()); + + byte[] serializedAuth; + try (ByteArrayOutputStream out = new ByteArrayOutputStream(); + ObjectOutputStream objectOutputStream = new ObjectOutputStream(out)) { + objectOutputStream.writeObject(configuredAuth); + serializedAuth = out.toByteArray(); + } finally { + configuredAuth.close(); + } + + AuthenticationOAuth2 deserializedAuth; + try (ObjectInputStream objectInputStream = + new ObjectInputStream(new ByteArrayInputStream(serializedAuth))) { + deserializedAuth = (AuthenticationOAuth2) objectInputStream.readObject(); + } + try { + assertNotNull(((FlowBase) deserializedAuth.flow).getHttpClient()); + } finally { + deserializedAuth.close(); + } + } + @Test public void testConfigureWithTlsClientAuth() throws Exception { Map params = new HashMap<>(); @@ -405,4 +435,4 @@ public void testClose() throws Exception { verify(this.flow).close(); assertThrows(PulsarClientException.AlreadyClosedException.class, () -> this.auth.getAuthData()); } -} \ No newline at end of file +} diff --git a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/conf/ConfigurationDataUtilsTest.java b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/conf/ConfigurationDataUtilsTest.java index a69e58b2eee48..00679aa2d0a1d 100644 --- a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/conf/ConfigurationDataUtilsTest.java +++ b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/conf/ConfigurationDataUtilsTest.java @@ -77,7 +77,15 @@ public void testLoadClientConfigurationData() { assertEquals("v2", confData.getAuthParamMap().get("k2")); assertEquals("0.0.0.0", confData.getDnsLookupBindAddress()); assertEquals(0, confData.getDnsLookupBindPort()); - assertEquals(dnsServerAddresses, confData.getDnsServerAddresses()); + // jackson-databind 2.18.8+/2.22+ defers DNS resolution when deserializing InetSocketAddress + // (CVE-2026-54514 fix), which changes the resolved/unresolved representation. Compare host + // and port — the values that must survive the config round-trip — instead of object equality. + List loadedDnsServerAddresses = confData.getDnsServerAddresses(); + assertEquals(loadedDnsServerAddresses.size(), dnsServerAddresses.size()); + for (int i = 0; i < dnsServerAddresses.size(); i++) { + assertEquals(loadedDnsServerAddresses.get(i).getHostString(), dnsServerAddresses.get(i).getHostString()); + assertEquals(loadedDnsServerAddresses.get(i).getPort(), dnsServerAddresses.get(i).getPort()); + } } @Test diff --git a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/schema/SchemaInfoTest.java b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/schema/SchemaInfoTest.java index dab683beb9624..52b3a897317b5 100644 --- a/pulsar-client/src/test/java/org/apache/pulsar/client/impl/schema/SchemaInfoTest.java +++ b/pulsar-client/src/test/java/org/apache/pulsar/client/impl/schema/SchemaInfoTest.java @@ -44,6 +44,16 @@ public class SchemaInfoTest { + " \"properties\": {}\n" + "}"; + private static final String INT32_SCHEMA_INFO_WITH_NULL_PROPERTY = "{\n" + + " \"name\": \"INT32\",\n" + + " \"schema\": \"\",\n" + + " \"type\": \"INT32\",\n" + + " \"timestamp\": 0,\n" + + " \"properties\": {\n" + + " \"key\": null\n" + + " }\n" + + "}"; + private static final String UTF8_SCHEMA_INFO = "{\n" + " \"name\": \"String\",\n" + " \"schema\": \"\",\n" @@ -95,21 +105,24 @@ public class SchemaInfoTest { + " \"BLUE\"\n" + " ]\n" + " }\n" - + " ]\n" + + " ],\n" + + " \"default\": null\n" + " },\n" + " {\n" + " \"name\": \"field1\",\n" + " \"type\": [\n" + " \"null\",\n" + " \"string\"\n" - + " ]\n" + + " ],\n" + + " \"default\": null\n" + " },\n" + " {\n" + " \"name\": \"field2\",\n" + " \"type\": [\n" + " \"null\",\n" + " \"string\"\n" - + " ]\n" + + " ],\n" + + " \"default\": null\n" + " },\n" + " {\n" + " \"name\": \"field3\",\n" @@ -129,7 +142,8 @@ public class SchemaInfoTest { + " }\n" + " ]\n" + " }\n" - + " ]\n" + + " ],\n" + + " \"default\": null\n" + " },\n" + " {\n" + " \"name\": \"fieldUnableNull\",\n" @@ -171,21 +185,24 @@ public class SchemaInfoTest { + " \"BLUE\"\n" + " ]\n" + " }\n" - + " ]\n" + + " ],\n" + + " \"default\": null\n" + " },\n" + " {\n" + " \"name\": \"field1\",\n" + " \"type\": [\n" + " \"null\",\n" + " \"string\"\n" - + " ]\n" + + " ],\n" + + " \"default\": null\n" + " },\n" + " {\n" + " \"name\": \"field2\",\n" + " \"type\": [\n" + " \"null\",\n" + " \"string\"\n" - + " ]\n" + + " ],\n" + + " \"default\": null\n" + " },\n" + " {\n" + " \"name\": \"field3\",\n" @@ -205,7 +222,8 @@ public class SchemaInfoTest { + " }\n" + " ]\n" + " }\n" - + " ]\n" + + " ],\n" + + " \"default\": null\n" + " },\n" + " {\n" + " \"name\": \"fieldUnableNull\",\n" @@ -341,8 +359,8 @@ public void testNullPropertyValue() throws JSONException { .properties(map) .build(); - // null key will be skipped by Gson when serializing JSON to String - JSONAssert.assertEquals(si.toString(), INT32_SCHEMA_INFO, JSONCompareMode.NON_EXTENSIBLE); + JSONAssert.assertEquals(si.toString(), INT32_SCHEMA_INFO_WITH_NULL_PROPERTY, + JSONCompareMode.NON_EXTENSIBLE); } } } diff --git a/pulsar-common/pom.xml b/pulsar-common/pom.xml index 8e1f4bdf29f77..b01ce5ae2dfd6 100644 --- a/pulsar-common/pom.xml +++ b/pulsar-common/pom.xml @@ -26,7 +26,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-common @@ -219,6 +219,11 @@ completable-futures + + org.roaringbitmap + RoaringBitmap + + org.bouncycastle diff --git a/pulsar-common/src/main/java/org/apache/pulsar/client/impl/schema/SchemaUtils.java b/pulsar-common/src/main/java/org/apache/pulsar/client/impl/schema/SchemaUtils.java index dcc73d1d21fbb..82090936cb493 100644 --- a/pulsar-common/src/main/java/org/apache/pulsar/client/impl/schema/SchemaUtils.java +++ b/pulsar-common/src/main/java/org/apache/pulsar/client/impl/schema/SchemaUtils.java @@ -202,6 +202,7 @@ public static String getStringSchemaVersion(byte[] schemaVersionBytes) { */ public static String jsonifySchemaInfo(SchemaInfo schemaInfo, boolean prettyPrinting) { GsonBuilder gsonBuilder = new GsonBuilder() + .serializeNulls() .registerTypeHierarchyAdapter(byte[].class, new ByteArrayToStringAdapter(schemaInfo)) .registerTypeHierarchyAdapter(Map.class, SCHEMA_PROPERTIES_SERIALIZER); if (prettyPrinting) { @@ -218,6 +219,7 @@ public static String jsonifySchemaInfo(SchemaInfo schemaInfo, boolean prettyPrin */ public static String jsonifySchemaInfoWithVersion(SchemaInfoWithVersion schemaInfoWithVersion) { GsonBuilder gsonBuilder = new GsonBuilder() + .serializeNulls() .setPrettyPrinting() .registerTypeHierarchyAdapter(SchemaInfo.class, SCHEMAINFO_ADAPTER) .registerTypeHierarchyAdapter(Map.class, SCHEMA_PROPERTIES_SERIALIZER); @@ -327,6 +329,7 @@ public JsonElement serialize(SchemaInfo schemaInfo, */ public static String jsonifyKeyValueSchemaInfo(KeyValue kvSchemaInfo) { GsonBuilder gsonBuilder = new GsonBuilder() + .serializeNulls() .registerTypeHierarchyAdapter(SchemaInfo.class, SCHEMAINFO_ADAPTER) .registerTypeHierarchyAdapter(Map.class, SCHEMA_PROPERTIES_SERIALIZER); return gsonBuilder.create().toJson(kvSchemaInfo); diff --git a/pulsar-common/src/main/java/org/apache/pulsar/common/policies/data/OffloadPoliciesImpl.java b/pulsar-common/src/main/java/org/apache/pulsar/common/policies/data/OffloadPoliciesImpl.java index 8c8432644adbf..af47712b98402 100644 --- a/pulsar-common/src/main/java/org/apache/pulsar/common/policies/data/OffloadPoliciesImpl.java +++ b/pulsar-common/src/main/java/org/apache/pulsar/common/policies/data/OffloadPoliciesImpl.java @@ -64,9 +64,15 @@ public class OffloadPoliciesImpl implements Serializable, OffloadPolicies { CONFIGURATION_FIELDS = Collections.unmodifiableList(temp); } - public static final ImmutableList INTERNAL_SUPPORTED_DRIVER = ImmutableList.of("S3", - "aws-s3", "google-cloud-storage", "filesystem", "azureblob", "aliyun-oss"); - public static final ImmutableList DRIVER_NAMES; + public static final String DRIVER_S3 = "S3"; + public static final String DRIVER_AWS_S3 = "aws-s3"; + public static final String DRIVER_GOOGLE_CLOUD_STORAGE = "google-cloud-storage"; + public static final String DRIVER_FILESYSTEM = "filesystem"; + public static final String DRIVER_AZUREBLOB = "azureblob"; + public static final String DRIVER_ALIYUN_OSS = "aliyun-oss"; + public static final List INTERNAL_SUPPORTED_DRIVER = Collections.unmodifiableList(Arrays.asList(DRIVER_S3, + DRIVER_AWS_S3, DRIVER_GOOGLE_CLOUD_STORAGE, DRIVER_FILESYSTEM, DRIVER_AZUREBLOB, DRIVER_ALIYUN_OSS)); + public static final List DRIVER_NAMES; static { String extraDrivers = System.getProperty("pulsar.extra.offload.drivers", ""); if (extraDrivers.trim().isEmpty()) { @@ -222,7 +228,8 @@ public static OffloadPoliciesImpl create(String driver, String region, String bu .managedLedgerOffloadReadBufferSizeInBytes(readBufferSizeInBytes) .managedLedgerOffloadedReadPriority(readPriority); - if (driver.equalsIgnoreCase(DRIVER_NAMES.get(0)) || driver.equalsIgnoreCase(DRIVER_NAMES.get(1))) { + if (driver.equalsIgnoreCase(DRIVER_S3) || driver.equalsIgnoreCase(DRIVER_AWS_S3) + || driver.equalsIgnoreCase(DRIVER_ALIYUN_OSS)) { if (role != null) { builder.s3ManagedLedgerOffloadRole(role); } @@ -241,7 +248,7 @@ public static OffloadPoliciesImpl create(String driver, String region, String bu .s3ManagedLedgerOffloadServiceEndpoint(endpoint) .s3ManagedLedgerOffloadMaxBlockSizeInBytes(maxBlockSizeInBytes) .s3ManagedLedgerOffloadReadBufferSizeInBytes(readBufferSizeInBytes); - } else if (driver.equalsIgnoreCase(DRIVER_NAMES.get(2))) { + } else if (driver.equalsIgnoreCase(DRIVER_GOOGLE_CLOUD_STORAGE)) { builder.gcsManagedLedgerOffloadRegion(region) .gcsManagedLedgerOffloadBucket(bucket) .gcsManagedLedgerOffloadMaxBlockSizeInBytes(maxBlockSizeInBytes) @@ -311,22 +318,23 @@ public boolean isS3Driver() { if (managedLedgerOffloadDriver == null) { return false; } - return managedLedgerOffloadDriver.equalsIgnoreCase(DRIVER_NAMES.get(0)) - || managedLedgerOffloadDriver.equalsIgnoreCase(DRIVER_NAMES.get(1)); + return managedLedgerOffloadDriver.equalsIgnoreCase(DRIVER_S3) + || managedLedgerOffloadDriver.equalsIgnoreCase(DRIVER_AWS_S3) + || managedLedgerOffloadDriver.equalsIgnoreCase(DRIVER_ALIYUN_OSS); } public boolean isGcsDriver() { if (managedLedgerOffloadDriver == null) { return false; } - return managedLedgerOffloadDriver.equalsIgnoreCase(DRIVER_NAMES.get(2)); + return managedLedgerOffloadDriver.equalsIgnoreCase(DRIVER_GOOGLE_CLOUD_STORAGE); } public boolean isFileSystemDriver() { if (managedLedgerOffloadDriver == null) { return false; } - return managedLedgerOffloadDriver.equalsIgnoreCase(DRIVER_NAMES.get(3)); + return managedLedgerOffloadDriver.equalsIgnoreCase(DRIVER_FILESYSTEM); } public boolean bucketValid() { diff --git a/pulsar-common/src/main/java/org/apache/pulsar/common/policies/data/stats/SubscriptionStatsImpl.java b/pulsar-common/src/main/java/org/apache/pulsar/common/policies/data/stats/SubscriptionStatsImpl.java index df91798f48737..ee67c197daae2 100644 --- a/pulsar-common/src/main/java/org/apache/pulsar/common/policies/data/stats/SubscriptionStatsImpl.java +++ b/pulsar-common/src/main/java/org/apache/pulsar/common/policies/data/stats/SubscriptionStatsImpl.java @@ -65,6 +65,16 @@ public class SubscriptionStatsImpl implements SubscriptionStats { /** Get the publish time of the earliest message in the backlog. */ public long earliestMsgPublishTimeInBacklog; + /** + * Age of oldest unacknowledged message for this subscription, in seconds. + *

+ * This is a best-effort cached value from the broker's periodic subscription backlog-age refresh. The value is + * {@code -1} when it is unknown, not applicable, the subscription has no backlog, or the broker has disabled + * subscription backlog-age computation. + *

+ */ + public long oldestBacklogMessageAgeSeconds = -1; + /** Number of entries in the subscription backlog that do not contain the delay messages. */ public long msgBacklogNoDelayed; @@ -220,6 +230,7 @@ public void reset() { nonContiguousDeletedMessagesRanges = 0; nonContiguousDeletedMessagesRangesSerializedSize = 0; earliestMsgPublishTimeInBacklog = 0L; + oldestBacklogMessageAgeSeconds = -1; delayedMessageIndexSizeInBytes = 0; subscriptionProperties.clear(); filterProcessedMsgCount = 0; @@ -285,6 +296,8 @@ public SubscriptionStatsImpl add(SubscriptionStatsImpl stats) { stats.earliestMsgPublishTimeInBacklog ); } + this.oldestBacklogMessageAgeSeconds = Math.max( + this.oldestBacklogMessageAgeSeconds, stats.oldestBacklogMessageAgeSeconds); this.delayedMessageIndexSizeInBytes += stats.delayedMessageIndexSizeInBytes; this.subscriptionProperties.putAll(stats.subscriptionProperties); this.filterProcessedMsgCount += stats.filterProcessedMsgCount; diff --git a/pulsar-common/src/main/java/org/apache/pulsar/common/util/FieldParser.java b/pulsar-common/src/main/java/org/apache/pulsar/common/util/FieldParser.java index 10c1951ab208b..d6aa2c876b1a7 100644 --- a/pulsar-common/src/main/java/org/apache/pulsar/common/util/FieldParser.java +++ b/pulsar-common/src/main/java/org/apache/pulsar/common/util/FieldParser.java @@ -346,7 +346,7 @@ private static Map stringToMap(String strValue, Class keyType, C String[] tokens = trim(strValue).split(","); Map map = new HashMap<>(); for (String token : tokens) { - String[] keyValue = trim(token).split("="); + String[] keyValue = trim(token).split("=", 2); checkArgument(keyValue.length == 2, strValue + " map-value is not in correct format key1=value,key2=value2"); map.put(convert(trim(keyValue[0]), keyType), convert(trim(keyValue[1]), valueType)); diff --git a/pulsar-common/src/main/java/org/apache/pulsar/common/util/LatencyTracer.java b/pulsar-common/src/main/java/org/apache/pulsar/common/util/LatencyTracer.java new file mode 100644 index 0000000000000..afc335a16e4bf --- /dev/null +++ b/pulsar-common/src/main/java/org/apache/pulsar/common/util/LatencyTracer.java @@ -0,0 +1,110 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.common.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.Queue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; + +public class LatencyTracer { + + private final Queue timepoints; + private final NanoTimeSupplier nanoTimeSupplier; + private final long startNs; + + public LatencyTracer(Queue timepoints, NanoTimeSupplier nanoTimeSupplier) { + this.timepoints = timepoints; + this.nanoTimeSupplier = nanoTimeSupplier; + this.startNs = nanoTimeSupplier.getNanos(); + } + + public CompletableFuture trace(String message, CompletableFuture future) { + if (future.isDone()) { + return future; + } + return future.whenComplete((__, ___) -> trace(message)); + } + + public void trace(String action) { + timepoints.add(new Timepoint(action, nanoTimeSupplier.getNanos())); + } + + public Snapshot getLatency() { + final List timepoints = new ArrayList<>(this.timepoints); + if (timepoints.isEmpty()) { + return new Snapshot(startNs, 0, "total: 0 ms"); + } + final StringBuilder sb = new StringBuilder(); + final long totalLatencyNs = TimeUnit.NANOSECONDS.toMillis(timepoints.get(timepoints.size() - 1).timeInNanos + - startNs); + sb.append("total: ").append(totalLatencyNs).append(" ms"); + long prevNs = startNs; + for (final Timepoint tp : timepoints) { + sb.append(", ").append(tp.name).append(": "); + long latencyMs = TimeUnit.NANOSECONDS.toMillis(tp.timeInNanos - prevNs); + if (latencyMs > 0) { + sb.append(latencyMs).append(" ms"); + } else { + sb.append(TimeUnit.NANOSECONDS.toMicros(tp.timeInNanos - prevNs)).append(" us"); + } + prevNs = tp.timeInNanos; + } + return new Snapshot(prevNs, totalLatencyNs, sb.toString()); + } + + public Snapshot traceAndGetLatency(String action) { + trace(action); + return getLatency(); + } + + public interface NanoTimeSupplier { + + long getNanos(); + } + + @Accessors(fluent = true) + @Getter + @RequiredArgsConstructor + public static final class Timepoint { + + private final String name; + private final long timeInNanos; + } + + /** + * A snapshot of the latency tracer at a given moment. + * `endTimeInNanos` the latest traced timestamp in nanoseconds + * `elapsedInMillis` the elapsed time in milliseconds from when the tracer was created + * `description` the detailed description for traced latencies, e.g. "total: 100 ms, A: 60 ms, B: 40 ms" + */ + @Accessors(fluent = true) + @Getter + @RequiredArgsConstructor + public static final class Snapshot { + + private final long endTimeInNanos; + private final long elapsedInMillis; + private final String description; + } +} diff --git a/pulsar-common/src/main/java/org/apache/pulsar/common/util/SecurityUtility.java b/pulsar-common/src/main/java/org/apache/pulsar/common/util/SecurityUtility.java index 80c692c9d6f01..01307d7e5e021 100644 --- a/pulsar-common/src/main/java/org/apache/pulsar/common/util/SecurityUtility.java +++ b/pulsar-common/src/main/java/org/apache/pulsar/common/util/SecurityUtility.java @@ -69,7 +69,6 @@ import javax.net.ssl.TrustManagerFactory; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; -import org.apache.pulsar.common.classification.InterfaceAudience; import org.apache.pulsar.common.tls.TlsHostnameVerifier; /** @@ -162,10 +161,11 @@ private static Provider loadConscryptProvider() { // // more details of Conscrypt's hostname verification: // https://github.com/google/conscrypt/blob/master/IMPLEMENTATION_NOTES.md#hostname-verification - // there's a bug in Conscrypt while setting a custom HostnameVerifier, - // https://github.com/google/conscrypt/issues/1015 and therefore this solution alone - // isn't sufficient to configure Conscrypt's hostname verifier. The method processConscryptTrustManager - // contains the workaround. + // + // Setting the default is sufficient on its own since Conscrypt 2.6.0: TrustManagerImpl used to ignore + // the default verifier (https://github.com/google/conscrypt/issues/1015), which forced Pulsar to copy it + // onto every TrustManager instance, but https://github.com/google/conscrypt/pull/1060 made it fall back + // to the default, so that workaround has been removed. try { HostnameVerifier hostnameVerifier = new TlsHostnameVerifier(); Object wrappedHostnameVerifier = conscryptClazz @@ -397,54 +397,10 @@ private static TrustManager[] setupTrustCerts(KeyStoreHolder ksh, boolean allowI } trustManagers = tmf.getTrustManagers(); - - for (TrustManager trustManager : trustManagers) { - processConscryptTrustManager(trustManager); - } } return trustManagers; } - /*** - * Conscrypt TrustManager instances will be configured to use the Pulsar {@link TlsHostnameVerifier} - * class. - * This method is used as a workaround for https://github.com/google/conscrypt/issues/1015 - * when Conscrypt / OpenSSL is used as the TLS security provider. - * - * @param trustManagers the array of TrustManager instances to process. - * @return same instance passed as parameter - */ - @InterfaceAudience.Private - public static TrustManager[] processConscryptTrustManagers(TrustManager[] trustManagers) { - for (TrustManager trustManager : trustManagers) { - processConscryptTrustManager(trustManager); - } - return trustManagers; - } - - // workaround https://github.com/google/conscrypt/issues/1015 - private static void processConscryptTrustManager(TrustManager trustManager) { - if (trustManager.getClass().getName().equals("org.conscrypt.TrustManagerImpl")) { - try { - Class conscryptClazz = Class.forName("org.conscrypt.Conscrypt"); - Object hostnameVerifier = conscryptClazz.getMethod("getHostnameVerifier", - new Class[]{TrustManager.class}).invoke(null, trustManager); - if (hostnameVerifier == null) { - Object defaultHostnameVerifier = conscryptClazz.getMethod("getDefaultHostnameVerifier", - new Class[]{TrustManager.class}).invoke(null, trustManager); - if (defaultHostnameVerifier != null) { - conscryptClazz.getMethod("setHostnameVerifier", new Class[]{ - TrustManager.class, - Class.forName("org.conscrypt.ConscryptHostnameVerifier") - }).invoke(null, trustManager, defaultHostnameVerifier); - } - } - } catch (ReflectiveOperationException e) { - log.warn("Unable to set hostname verifier for Conscrypt TrustManager implementation", e); - } - } - } - public static X509Certificate[] loadCertificatesFromPemFile(String certFilePath) throws KeyManagementException { X509Certificate[] certificates = null; diff --git a/pulsar-common/src/main/java/org/apache/pulsar/common/util/collections/ConcurrentRoaringBitmap.java b/pulsar-common/src/main/java/org/apache/pulsar/common/util/collections/ConcurrentRoaringBitmap.java new file mode 100644 index 0000000000000..96ed33c2f8047 --- /dev/null +++ b/pulsar-common/src/main/java/org/apache/pulsar/common/util/collections/ConcurrentRoaringBitmap.java @@ -0,0 +1,548 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.common.util.collections; + +import io.netty.buffer.ByteBuf; +import java.io.DataInput; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.concurrent.locks.StampedLock; +import java.util.function.LongConsumer; +import org.roaringbitmap.BitSetUtil; +import org.roaringbitmap.PeekableIntIterator; +import org.roaringbitmap.RoaringBitmap; +import org.roaringbitmap.buffer.MutableRoaringBitmap; + +/** + * {@link LongBitmap} implementation backed by {@link MutableRoaringBitmap} and guarded + * by a {@link StampedLock}. + * + *

Thread-safety basis. RoaringBitmap is not thread-safe by default + * (see pulsar#25991). This + * wrapper relies on the documented contract that {@link MutableRoaringBitmap}'s read + * methods — the {@code ImmutableBitmapDataProvider} surface inherited from + * {@code ImmutableRoaringBitmap} — do not mutate internal state, while methods added by + * {@code BitmapDataProvider} and other {@code MutableRoaringBitmap} mutators + * ({@code andNot}, {@code or}, {@code checkedRemove}, {@code runOptimize}, {@code clone}, + * ...) do. Read methods run under the read lock; mutators under the write lock. + * {@code clone()} is used under the read lock in {@link #forEachLong} and {@link #serialize}; + * its source has been audited to be read-only on the live bitmap. Before upgrading + * the RoaringBitmap dependency or changing the lock split, re-audit these methods + * and run the concurrency regression tests ({@code testConcurrentForEachLongAndMutate}, + * {@code testOrDoesNotMutateInput}). + * + *

Critical sections. Single-value reads take the read lock; mutations take the + * write lock. Bulk mutations that touch two bitmaps ({@link #or}) acquire this bitmap's + * write lock and the other's read lock in {@code identityHashCode} order, so concurrent + * {@code A.or(B)} and {@code B.or(A)} cannot deadlock. Long non-mutating work + * ({@link #serialize}, {@link #forEachLong}) clones under a brief read lock and finishes + * without holding it, so optimize/iterate/runOptimize don't block writers. + * + *

Memory. {@link MutableRoaringBitmap#trim()} fires when removals since the + * last trim reach {@link #TRIM_AFTER_REMOVES}, or whenever the bitmap becomes empty. + * {@link #serialize} runs {@code runOptimize()} on the clone so persisted bytes are compact. + */ +class ConcurrentRoaringBitmap implements LongBitmap { + + private static final long TRIM_AFTER_REMOVES = 10000; + private static final long UINT32_SIZE = 1L << 32; + private static final long MAX_UINT32 = UINT32_SIZE - 1; + + private final MutableRoaringBitmap bitmap; + private final StampedLock lock; + private long removesSinceTrim; + + ConcurrentRoaringBitmap() { + this.bitmap = new MutableRoaringBitmap(); + this.lock = new StampedLock(); + } + + private ConcurrentRoaringBitmap(MutableRoaringBitmap bitmap) { + this.bitmap = bitmap; + this.lock = new StampedLock(); + } + + @Override + public void add(long value) { + validateRange(value); + long stamp = lock.writeLock(); + try { + bitmap.add((int) value); + } finally { + lock.unlockWrite(stamp); + } + } + + @Override + public boolean checkedAdd(long value) { + validateRange(value); + long stamp = lock.writeLock(); + try { + return bitmap.checkedAdd((int) value); + } finally { + lock.unlockWrite(stamp); + } + } + + @Override + public void add(long from, long to) { + if (to <= from) { + return; + } + validateRange(from); + validateRange(to - 1); + long stamp = lock.writeLock(); + try { + bitmap.add(from, to); + } finally { + lock.unlockWrite(stamp); + } + } + + @Override + public boolean remove(long value) { + validateRange(value); + long stamp = lock.writeLock(); + try { + boolean removed = bitmap.checkedRemove((int) value); + if (removed) { + removesSinceTrim++; + maybeTrim(); + } + return removed; + } finally { + lock.unlockWrite(stamp); + } + } + + @Override + public boolean remove(long from, long to) { + if (to <= from) { + return false; + } + validateRange(from); + validateRange(to - 1); + long stamp = lock.writeLock(); + try { + long cardinalityBefore = bitmap.getLongCardinality(); + bitmap.remove(from, to); + long removedCount = cardinalityBefore - bitmap.getLongCardinality(); + if (removedCount > 0) { + removesSinceTrim += removedCount; + maybeTrim(); + } + return removedCount > 0; + } finally { + lock.unlockWrite(stamp); + } + } + + @Override + public boolean contains(long value) { + if (value < 0 || value > MAX_UINT32) { + return false; + } + long stamp = lock.readLock(); + try { + return bitmap.contains((int) value); + } finally { + lock.unlockRead(stamp); + } + } + + @Override + public boolean contains(long from, long to) { + if (from < 0 || from > MAX_UINT32 || to <= from || to > UINT32_SIZE) { + return false; + } + long stamp = lock.readLock(); + try { + return bitmap.contains(from, to); + } finally { + lock.unlockRead(stamp); + } + } + + @Override + public long cardinality() { + long stamp = lock.readLock(); + try { + return bitmap.getLongCardinality(); + } finally { + lock.unlockRead(stamp); + } + } + + @Override + public boolean isEmpty() { + long stamp = lock.readLock(); + try { + return bitmap.isEmpty(); + } finally { + lock.unlockRead(stamp); + } + } + + @Override + public void clear() { + long stamp = lock.writeLock(); + try { + bitmap.clear(); + removesSinceTrim = 0; + } finally { + lock.unlockWrite(stamp); + } + } + + @Override + public long nextAbsentValue(long from) { + if (from < 0 || from > MAX_UINT32) { + return -1; + } + long stamp = lock.readLock(); + try { + return bitmap.nextAbsentValue((int) from); + } finally { + lock.unlockRead(stamp); + } + } + + @Override + public long nextPresentValue(long from) { + if (from < 0 || from > MAX_UINT32) { + return -1; + } + long stamp = lock.readLock(); + try { + return bitmap.nextValue((int) from); + } finally { + lock.unlockRead(stamp); + } + } + + @Override + public long previousAbsentValue(long from) { + if (from < 0) { + return -1; + } + // Clamp to MAX_UINT32 instead of returning -1 for out-of-range input. + // This allows safe usage in PositionRangeSet.lastRange() where + // previousAbsentValue(lastPresentValue()) is called and lastPresentValue + // may be MAX_UINT32. Clamping avoids the need for Math.min() guards at call sites. + if (from > MAX_UINT32) { + from = MAX_UINT32; + } + long stamp = lock.readLock(); + try { + return bitmap.previousAbsentValue((int) from); + } finally { + lock.unlockRead(stamp); + } + } + + @Override + public long lastPresentValue() { + long stamp = lock.readLock(); + try { + if (bitmap.isEmpty()) { + return -1; + } + return bitmap.previousValue(-1); + } finally { + lock.unlockRead(stamp); + } + } + + @Override + public long rank(long value) { + if (value <= 0) { + return 0; + } + // Clamp to UINT32_SIZE instead of MAX_UINT32 to allow rank(UINT32_SIZE) to return + // the total cardinality. This is needed in PositionRangeSet.cardinality() where + // rank(upperValue + 1) is called and upperValue may be MAX_UINT32. The clamping + // ensures rank(0x100000000L) counts all values in the uint32 range. + if (value > UINT32_SIZE) { + value = UINT32_SIZE; + } + long stamp = lock.readLock(); + try { + return bitmap.rank((int) (value - 1)); + } finally { + lock.unlockRead(stamp); + } + } + + @Override + public void or(LongBitmap other) { + if (other == this) { + return; + } + if (!(other instanceof ConcurrentRoaringBitmap)) { + throw new IllegalArgumentException("Unsupported LongBitmap type: " + other.getClass()); + } + ConcurrentRoaringBitmap that = (ConcurrentRoaringBitmap) other; + + // Acquire this.writeLock + that.readLock in identityHashCode order so concurrent + // A.or(B) and B.or(A) don't deadlock. Fall back to inner bitmap identity on collision. + boolean thisFirst; + int outerCmp = Integer.compare( + System.identityHashCode(this), System.identityHashCode(that)); + if (outerCmp != 0) { + thisFirst = outerCmp < 0; + } else { + thisFirst = System.identityHashCode(this.bitmap) < System.identityHashCode(that.bitmap); + } + + if (thisFirst) { + long thisStamp = this.lock.writeLock(); + try { + long thatStamp = that.lock.readLock(); + try { + this.bitmap.or(that.bitmap); + } finally { + that.lock.unlockRead(thatStamp); + } + } finally { + this.lock.unlockWrite(thisStamp); + } + } else { + long thatStamp = that.lock.readLock(); + try { + long thisStamp = this.lock.writeLock(); + try { + this.bitmap.or(that.bitmap); + } finally { + this.lock.unlockWrite(thisStamp); + } + } finally { + that.lock.unlockRead(thatStamp); + } + } + } + + @Override + public void forEachLong(LongConsumer action) { + MutableRoaringBitmap snapshot; + long stamp = lock.readLock(); + try { + snapshot = bitmap.clone(); + } finally { + lock.unlockRead(stamp); + } + snapshot.forEach((org.roaringbitmap.IntConsumer) v -> + action.accept(Integer.toUnsignedLong(v))); + } + + @Override + public long drainTo(long limit, LongConsumer action) { + if (limit <= 0) { + return 0; + } + MutableRoaringBitmap toRemove = new MutableRoaringBitmap(); + long collected; + long writeStamp = lock.writeLock(); + try { + PeekableIntIterator it = bitmap.getIntIterator(); + collected = 0; + while (collected < limit && it.hasNext()) { + toRemove.add(it.next()); + collected++; + } + if (collected == 0) { + return 0; + } + bitmap.andNot(toRemove); + removesSinceTrim = Math.min(removesSinceTrim + collected, TRIM_AFTER_REMOVES); + maybeTrim(); + } finally { + lock.unlockWrite(writeStamp); + } + + toRemove.forEach((org.roaringbitmap.IntConsumer) v -> + action.accept(Integer.toUnsignedLong(v))); + return collected; + } + + @Override + public long serializedSize() { + long stamp = lock.readLock(); + try { + return bitmap.serializedSizeInBytes(); + } finally { + lock.unlockRead(stamp); + } + } + + @Override + public byte[] serialize() { + MutableRoaringBitmap copy; + long stamp = lock.readLock(); + try { + copy = bitmap.clone(); + } finally { + lock.unlockRead(stamp); + } + copy.runOptimize(); + byte[] bytes = new byte[copy.serializedSizeInBytes()]; + copy.serialize(ByteBuffer.wrap(bytes)); + return bytes; + } + + @Override + public long[] serializeToLongArray() { + long stamp = lock.readLock(); + try { + RoaringBitmap immutable = bitmap.toRoaringBitmap(); + return BitSetUtil.toLongArray(immutable); + } finally { + lock.unlockRead(stamp); + } + } + + @Override + public void deserializeFromLongArray(long[] data) { + long stamp = lock.writeLock(); + try { + bitmap.clear(); + RoaringBitmap rb = BitSetUtil.bitmapOf(data); + bitmap.or(rb.toMutableRoaringBitmap()); + removesSinceTrim = 0; + } finally { + lock.unlockWrite(stamp); + } + } + + static ConcurrentRoaringBitmap deserialize(ByteBuf buf) { + try { + ByteBuffer nioBuffer = buf.nioBuffer(buf.readerIndex(), buf.readableBytes()); + int startPosition = nioBuffer.position(); + MutableRoaringBitmap bitmap = new MutableRoaringBitmap(); + bitmap.deserialize(new ByteBufferDataInput(nioBuffer)); + buf.skipBytes(nioBuffer.position() - startPosition); + return new ConcurrentRoaringBitmap(bitmap); + } catch (IOException e) { + throw new RuntimeException("Failed to deserialize LongBitmap", e); + } + } + + /** + * Trims the underlying bitmap if enough removals have accumulated or it's empty. + * Caller must hold the write lock and have already updated {@link #removesSinceTrim}. + */ + private void maybeTrim() { + if (removesSinceTrim >= TRIM_AFTER_REMOVES || bitmap.isEmpty()) { + bitmap.trim(); + removesSinceTrim = 0; + } + } + + private static void validateRange(long value) { + if (value < 0 || value > MAX_UINT32) { + throw new IllegalArgumentException( + "Value out of range [0, " + MAX_UINT32 + "]: " + value); + } + } + + /** Minimal {@link DataInput} over a {@link ByteBuffer} for RoaringBitmap deserialization. */ + private static final class ByteBufferDataInput implements DataInput { + private final ByteBuffer buffer; + + ByteBufferDataInput(ByteBuffer buffer) { + this.buffer = buffer; + } + + @Override + public void readFully(byte[] b) { + buffer.get(b); + } + + @Override + public void readFully(byte[] b, int off, int len) { + buffer.get(b, off, len); + } + + @Override + public int skipBytes(int n) { + int skip = Math.min(n, buffer.remaining()); + buffer.position(buffer.position() + skip); + return skip; + } + + @Override + public boolean readBoolean() { + return buffer.get() != 0; + } + + @Override + public byte readByte() { + return buffer.get(); + } + + @Override + public int readUnsignedByte() { + return Byte.toUnsignedInt(buffer.get()); + } + + @Override + public short readShort() { + return buffer.getShort(); + } + + @Override + public int readUnsignedShort() { + return Short.toUnsignedInt(buffer.getShort()); + } + + @Override + public char readChar() { + return buffer.getChar(); + } + + @Override + public int readInt() { + return buffer.getInt(); + } + + @Override + public long readLong() { + return buffer.getLong(); + } + + @Override + public float readFloat() { + return buffer.getFloat(); + } + + @Override + public double readDouble() { + return buffer.getDouble(); + } + + @Override + public String readLine() { + throw new UnsupportedOperationException(); + } + + @Override + public String readUTF() { + throw new UnsupportedOperationException(); + } + } +} diff --git a/pulsar-common/src/main/java/org/apache/pulsar/common/util/collections/LongBitmap.java b/pulsar-common/src/main/java/org/apache/pulsar/common/util/collections/LongBitmap.java new file mode 100644 index 0000000000000..e08f05f1bc596 --- /dev/null +++ b/pulsar-common/src/main/java/org/apache/pulsar/common/util/collections/LongBitmap.java @@ -0,0 +1,241 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.common.util.collections; + +import java.util.function.LongConsumer; + +/** + * Thread-safe bitmap abstraction for tracking long values. + * + *

The current implementation supports values in the unsigned 32-bit range + * {@code [0, 2^32 - 1]}. Methods that modify the bitmap reject values outside this + * range with {@link IllegalArgumentException}. Query methods return {@code false} + * or {@code -1} for out-of-range values where applicable. + * + *

Supports point and range operations, bulk union, atomic draining, iteration, + * and serialization. All operations are thread-safe. + * + *

This abstraction is used for high-throughput broker metadata tracking, + * including delayed-delivery tracking, consumer-name allocation, and + * draining-hash tracking. + * + *

Example: + *

{@code
+ * LongBitmap bitmap = LongBitmaps.create();
+ * bitmap.add(12345L);
+ * if (bitmap.contains(12345L)) { ... }
+ *
+ * byte[] bytes = bitmap.serialize();
+ * LongBitmap restored = LongBitmaps.deserialize(Unpooled.wrappedBuffer(bytes));
+ * }
+ */ +public interface LongBitmap { + + /** + * Adds a value. + * + * @param value value to add, must be in {@code [0, 2^32 - 1]} + * @throws IllegalArgumentException if value is outside the supported range + */ + void add(long value); + + /** + * Adds a value if it is not already present. + * + *

This operation is atomic. Unlike {@code if (!contains(value)) add(value)}, + * the check and add are performed as a single operation. + * + * @param value value to add, must be in {@code [0, 2^32 - 1]} + * @return {@code true} if the value was added, {@code false} if it already existed + * @throws IllegalArgumentException if value is outside the supported range + */ + boolean checkedAdd(long value); + + /** + * Adds all values in the half-open range {@code [from, to)}. + * + *

No-op if {@code to <= from}. + * + * @param from inclusive lower bound + * @param to exclusive upper bound + * @throws IllegalArgumentException if the range exceeds the supported value range + */ + void add(long from, long to); + + /** + * Removes a value. No-op if absent. + * + * @param value value to remove + * @throws IllegalArgumentException if value is outside the supported range + * @return {@code true} if removed, otherwise {@code false} + */ + boolean remove(long value); + + /** + * Removes all values in the half-open range {@code [from, to)}. + * + *

No-op if {@code to <= from}. + * + * @param from inclusive lower bound + * @param to exclusive upper bound + * @throws IllegalArgumentException if the range exceeds the supported value range + * @return {@code true} if removed, otherwise {@code false} + */ + boolean remove(long from, long to); + + /** + * Returns whether the bitmap contains the given value. + * + * @param value value to check + * @return {@code true} if present, otherwise {@code false} + */ + boolean contains(long value); + + /** + * Returns whether all values in {@code [from, to)} are present. + * + * @param from inclusive lower bound + * @param to exclusive upper bound + * @return {@code true} if all values in the range are present + */ + boolean contains(long from, long to); + + /** Returns the number of values currently stored. */ + long cardinality(); + + /** Returns {@code true} if no values are stored. */ + boolean isEmpty(); + + /** Removes all values from the bitmap. */ + void clear(); + + /** + * Returns the smallest absent value greater than or equal to {@code from}. + * + * @param from inclusive lower bound + * @return next absent value, or {@code -1} if none exists + */ + long nextAbsentValue(long from); + + /** + * Returns the smallest present value greater than or equal to {@code from}. + * + * @param from inclusive lower bound + * @return next present value, or {@code -1} if none exists + */ + long nextPresentValue(long from); + + /** + * Returns the largest absent value less than or equal to {@code from}. + * + *

Boundary behavior: If {@code from > MAX_UINT32 (0xFFFFFFFF)}, it is clamped + * to {@code MAX_UINT32}. This allows safe usage in expressions like + * {@code previousAbsentValue(lastValue)} where {@code lastValue} may be at the boundary, + * avoiding the need for {@code Math.min(result, boundary)} checks at call sites. + * + *

Normal usage (entryId within int range) never triggers this clamping; it only + * applies when computing ranges that may include the uint32 upper boundary. + * + * @param from inclusive upper bound + * @return previous absent value, or {@code -1} if none exists + */ + long previousAbsentValue(long from); + + /** + * Returns the last (highest) present value, or {@code -1} if empty. + * + * @return last present value, or {@code -1} if bitmap is empty + */ + long lastPresentValue(); + + /** + * Returns the number of present values strictly less than {@code value}. + * This is the rank of the value in the sorted sequence of present values. + * + *

Useful for computing range cardinality: + * {@code cardinality(from, to) = rank(to) - rank(from)}. + * + *

Boundary behavior: If {@code value > UINT32_SIZE (0x100000000)}, it is clamped + * to {@code UINT32_SIZE}, effectively returning the total cardinality (all values are less + * than a value beyond the supported range). This allows safe usage in expressions like + * {@code rank(upperValue + 1)} where {@code upperValue} may be {@code MAX_UINT32 (0xFFFFFFFF)}. + * + *

Normal usage never triggers this clamping; it only applies when computing ranges + * that include the uint32 upper boundary. + * + * @param value upper bound (exclusive) + * @return count of present values less than {@code value} + */ + long rank(long value); + + /** + * Adds all values from {@code other} into this bitmap. + * + * @param other bitmap to merge + */ + void or(LongBitmap other); + + /** + * Iterates values in ascending order. + * + *

The iteration observes a stable view of the bitmap. Implementations may + * choose the mechanism used to provide this guarantee. + * + * @param action callback invoked for each value + */ + void forEachLong(LongConsumer action); + + /** + * Atomically removes up to {@code limit} values and invokes {@code action} + * for each removed value. + * + *

Selection and removal are performed atomically. The callback is invoked + * after removal has completed. + * + * @param limit maximum number of values to drain + * @param action callback invoked for each removed value + * @return number of values drained + */ + long drainTo(long limit, LongConsumer action); + + /** + * Returns an upper bound of the serialized size. + */ + long serializedSize(); + + /** + * Serializes the bitmap into a newly allocated byte array. + */ + byte[] serialize(); + + /** + * Serializes the bitmap to long[] format compatible with {@link java.util.BitSet#toLongArray()}. + * This format can be restored using {@link #deserializeFromLongArray(long[])}. + * + * @return long array representing the bitmap + */ + long[] serializeToLongArray(); + + /** + * Deserializes a bitmap from long[] format compatible with {@link java.util.BitSet#valueOf(long[])}. + * + * @param data long array in BitSet format + */ + void deserializeFromLongArray(long[] data); +} diff --git a/pulsar-common/src/main/java/org/apache/pulsar/common/util/collections/LongBitmaps.java b/pulsar-common/src/main/java/org/apache/pulsar/common/util/collections/LongBitmaps.java new file mode 100644 index 0000000000000..ac7b4fadbedd9 --- /dev/null +++ b/pulsar-common/src/main/java/org/apache/pulsar/common/util/collections/LongBitmaps.java @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.common.util.collections; + +import io.netty.buffer.ByteBuf; + +/** + * Factory for creating {@link LongBitmap} instances. + */ +public final class LongBitmaps { + + private LongBitmaps() { + // utility class + } + + /** + * Creates a new empty thread-safe LongBitmap. + * + * @return a new LongBitmap instance + */ + public static LongBitmap create() { + return new ConcurrentRoaringBitmap(); + } + + /** + * Deserializes a LongBitmap from a ByteBuf. + * + *

Advances the buffer's {@code readerIndex} by the number of bytes consumed. The + * buffer may be heap-backed, direct, or a {@link io.netty.buffer.CompositeByteBuf} — + * the implementation reads via {@link ByteBuf#nioBuffer(int, int)} without copying + * when possible. + * + *

The serialized format is the standard 32-bit RoaringBitmap portable format, so + * buffers produced by {@link LongBitmap#serialize()} round-trip exactly. Buffers in + * other formats (e.g. {@code Roaring64Bitmap}) are rejected. + * + * @param buf the input buffer positioned at the start of the serialized bitmap + * @return the deserialized LongBitmap + * @throws RuntimeException if the buffer is malformed, truncated, or in an + * unrecognized format (wraps the underlying {@link java.io.IOException}) + */ + public static LongBitmap deserialize(ByteBuf buf) { + return ConcurrentRoaringBitmap.deserialize(buf); + } + + /** + * Deserializes a LongBitmap from long[] format compatible with {@link java.util.BitSet}. + * + *

Creates a new LongBitmap populated with values from the long array format + * produced by {@link LongBitmap#serializeToLongArray()}. This format is compatible + * with {@link java.util.BitSet#valueOf(long[])}. + * + * @param data long array in BitSet format + * @return a new LongBitmap containing the deserialized values + */ + public static LongBitmap deserializeFromLongArray(long[] data) { + LongBitmap bitmap = create(); + bitmap.deserializeFromLongArray(data); + return bitmap; + } +} diff --git a/pulsar-common/src/main/java/org/apache/pulsar/common/util/collections/SegmentedLongArray.java b/pulsar-common/src/main/java/org/apache/pulsar/common/util/collections/SegmentedLongArray.java index c551895c51a92..f796ccf0c1239 100644 --- a/pulsar-common/src/main/java/org/apache/pulsar/common/util/collections/SegmentedLongArray.java +++ b/pulsar-common/src/main/java/org/apache/pulsar/common/util/collections/SegmentedLongArray.java @@ -18,111 +18,259 @@ */ package org.apache.pulsar.common.util.collections; -import io.netty.buffer.ByteBuf; -import java.util.ArrayList; -import java.util.List; +import static com.google.common.base.Preconditions.checkArgument; +import com.google.common.annotations.VisibleForTesting; +import java.util.Arrays; import javax.annotation.concurrent.NotThreadSafe; import lombok.Getter; -import org.apache.pulsar.common.allocator.PulsarByteBufAllocator; +/** + * A growable array of {@code long} values backed by heap-allocated segments. + * + *

This class provides a logical contiguous {@code long[]} whose size may exceed + * {@link Integer#MAX_VALUE}. Internally, the storage is split into fixed-size + * segments, allowing capacities larger than a single Java array while keeping + * element access in constant time. + * + *

Segment layout invariant: + *

    + *
  • Every segment except the last has length {@code segmentSize}.
  • + *
  • The last segment may be partially filled.
  • + *
  • {@code capacity} always equals the total number of allocated elements + * across all segments.
  • + *
+ * + *

The segment invariant guarantees that the bit based address mapping + * ({@code offset >>> segmentShift}, {@code offset & segmentMask}) remains + * valid for every logical offset. + * + *

Growing and shrinking preserve existing contents while maintaining the + * segment layout invariant. + * + *

This class is not thread-safe. + */ @NotThreadSafe public class SegmentedLongArray implements AutoCloseable { - private static final int SIZE_OF_LONG = 8; + /** + * Default number of {@code long} values in a full segment. Production behavior + * is fixed by this constant; tests may override it via the + * {@link #SegmentedLongArray(long, int) package-private constructor}. + */ + static final int DEFAULT_SEGMENT_SIZE = 2 * 1024 * 1024; + + static { + assert Integer.bitCount(DEFAULT_SEGMENT_SIZE) == 1 + : "DEFAULT_SEGMENT_SIZE must be a power of 2"; + } + + private final int segmentSize; + private final int segmentShift; + private final int segmentMask; - private static final int MAX_SEGMENT_SIZE = 2 * 1024 * 1024; // 2M longs -> 16 MB - private final List buffers = new ArrayList<>(); + private long[][] segments; + private int segmentCount; + /** Minimum capacity to which this array may be shrunk. */ @Getter private final long initialCapacity; + /** Logical capacity, measured in {@code long} elements. */ @Getter private long capacity; + /** Total bytes allocated by all live backing segments. */ + private long allocatedBytes; + + /** + * Creates a segmented array with the specified initial capacity and the default + * segment size ({@link #DEFAULT_SEGMENT_SIZE}). + * + * @param initialCapacity initial capacity in {@code long} elements + * @throws IllegalArgumentException if {@code initialCapacity <= 0} + */ public SegmentedLongArray(long initialCapacity) { - long remainingToAdd = initialCapacity; - - // Add first segment - int sizeToAdd = (int) Math.min(remainingToAdd, MAX_SEGMENT_SIZE); - ByteBuf buffer = PulsarByteBufAllocator.DEFAULT.directBuffer(sizeToAdd * SIZE_OF_LONG); - buffer.writerIndex(sizeToAdd * SIZE_OF_LONG); - buffers.add(buffer); - remainingToAdd -= sizeToAdd; - - // Add the remaining segments, all at full segment size, if necessary - while (remainingToAdd > 0) { - buffer = PulsarByteBufAllocator.DEFAULT.directBuffer(MAX_SEGMENT_SIZE * SIZE_OF_LONG); - buffer.writerIndex(MAX_SEGMENT_SIZE * SIZE_OF_LONG); - buffers.add(buffer); - remainingToAdd -= MAX_SEGMENT_SIZE; - } + this(initialCapacity, DEFAULT_SEGMENT_SIZE); + } + /** + * Creates a segmented array with a custom segment size. + * + *

Intended for unit tests that exercise multi-segment grow/shrink behavior + * without allocating production-sized (16 MiB) backing arrays. Production + * callers should use {@link #SegmentedLongArray(long)}. + * + * @param initialCapacity initial capacity in {@code long} elements + * @param segmentSize number of {@code long} values per full segment; must be + * a positive power of two + * @throws IllegalArgumentException if {@code initialCapacity <= 0} or + * {@code segmentSize} is not a positive power of two + */ + @VisibleForTesting + SegmentedLongArray(long initialCapacity, int segmentSize) { + checkArgument(initialCapacity > 0, "initialCapacity must be positive"); + checkArgument(segmentSize > 0 && Integer.bitCount(segmentSize) == 1, + "segmentSize must be a positive power of two"); + this.segmentSize = segmentSize; + this.segmentShift = Integer.numberOfTrailingZeros(segmentSize); + this.segmentMask = segmentSize - 1; this.initialCapacity = initialCapacity; - this.capacity = this.initialCapacity; + this.capacity = initialCapacity; + allocateSegments(initialCapacity); + } + + /** + * Allocates the initial segment layout. + */ + private void allocateSegments(long longCapacity) { + segmentCount = Math.max(1, (int) ((longCapacity + segmentSize - 1) / segmentSize)); + segments = new long[segmentCount][]; + + long remaining = longCapacity; + long bytes = 0; + + for (int i = 0; i < segmentCount; i++) { + int size = (int) Math.min(segmentSize, remaining); + segments[i] = new long[size]; + bytes += (long) size * Long.BYTES; + remaining -= size; + } + + allocatedBytes = bytes; } public void writeLong(long offset, long value) { - int bufferIdx = (int) (offset / MAX_SEGMENT_SIZE); - int internalIdx = (int) (offset % MAX_SEGMENT_SIZE); - buffers.get(bufferIdx).setLong(internalIdx * SIZE_OF_LONG, value); + long[] segment = segments[(int) (offset >>> segmentShift)]; + segment[(int) (offset & segmentMask)] = value; } public long readLong(long offset) { - int bufferIdx = (int) (offset / MAX_SEGMENT_SIZE); - int internalIdx = (int) (offset % MAX_SEGMENT_SIZE); - return buffers.get(bufferIdx).getLong(internalIdx * SIZE_OF_LONG); + long[] segment = segments[(int) (offset >>> segmentShift)]; + return segment[(int) (offset & segmentMask)]; + } + + /** + * Ensures that the backing storage can hold at least {@code required} + * elements. + * + * @param required minimum required capacity in {@code long} elements + */ + public void ensureCapacity(long required) { + if (required <= capacity) { + return; + } + + long geometric; + if (capacity < segmentSize) { + geometric = Math.min( + capacity + (capacity <= 256 ? capacity : capacity / 2), + segmentSize); + } else { + geometric = capacity + segmentSize; + } + + growTo(Math.max(required, geometric)); } public void increaseCapacity() { - if (capacity < MAX_SEGMENT_SIZE) { - // Resize the current buffer to bigger capacity - capacity += (capacity <= 256 ? capacity : capacity / 2); - capacity = Math.min(capacity, MAX_SEGMENT_SIZE); - buffers.get(0).capacity((int) this.capacity * SIZE_OF_LONG); - buffers.get(0).writerIndex((int) this.capacity * SIZE_OF_LONG); + ensureCapacity(capacity + 1); + } + + /** + * Expands the backing storage to exactly {@code newCapacity}. + */ + private void growTo(long newCapacity) { + if (newCapacity <= capacity) { + return; + } + + int newSegmentCount = (int) ((newCapacity + segmentSize - 1) / segmentSize); + + if (segments.length < newSegmentCount) { + segments = Arrays.copyOf(segments, newSegmentCount); + } + + // If the current last segment becomes an interior segment, + // it must be expanded to preserve the bit-based address mapping. + if (newSegmentCount > segmentCount && segmentCount >= 1) { + int oldLastIdx = segmentCount - 1; + if (segments[oldLastIdx].length < segmentSize) { + resizeLastSegment(oldLastIdx, segmentSize); + } + } + + for (int i = segmentCount; i < newSegmentCount - 1; i++) { + segments[i] = new long[segmentSize]; + allocatedBytes += (long) segmentSize * Long.BYTES; + } + + int newLastIdx = newSegmentCount - 1; + int newLastSize = (int) (newCapacity - (long) newLastIdx * segmentSize); + + if (newLastIdx >= segmentCount) { + segments[newLastIdx] = new long[newLastSize]; + allocatedBytes += (long) newLastSize * Long.BYTES; } else { - // Let's add 1 mode buffer to the list - int bufferSize = MAX_SEGMENT_SIZE * SIZE_OF_LONG; - ByteBuf buffer = PulsarByteBufAllocator.DEFAULT.directBuffer(bufferSize, bufferSize); - buffer.writerIndex(bufferSize); - buffers.add(buffer); - capacity += MAX_SEGMENT_SIZE; + resizeLastSegment(newLastIdx, newLastSize); } + + segmentCount = newSegmentCount; + capacity = newCapacity; } + private void resizeLastSegment(int idx, int newSize) { + long[] old = segments[idx]; + if (old.length == newSize) { + return; + } + + allocatedBytes += (long) (newSize - old.length) * Long.BYTES; + segments[idx] = Arrays.copyOf(old, newSize); + } + + /** + * Shrinks the backing storage to {@code newCapacity}. + * + * @param newCapacity target capacity in {@code long} elements + */ public void shrink(long newCapacity) { if (newCapacity >= capacity || newCapacity < initialCapacity) { return; } - long sizeToReduce = capacity - newCapacity; - while (sizeToReduce >= MAX_SEGMENT_SIZE && buffers.size() > 1) { - ByteBuf b = buffers.remove(buffers.size() - 1); - b.release(); - capacity -= MAX_SEGMENT_SIZE; - sizeToReduce -= MAX_SEGMENT_SIZE; + int newSegmentCount = (int) ((newCapacity + segmentSize - 1) / segmentSize); + int newLastIdx = newSegmentCount - 1; + int newLastSize = (int) (newCapacity - (long) newLastIdx * segmentSize); + + for (int i = newSegmentCount; i < segmentCount; i++) { + allocatedBytes -= (long) segments[i].length * Long.BYTES; + segments[i] = null; } - if (buffers.size() == 1 && sizeToReduce > 0) { - // We should also reduce the capacity of the first buffer - capacity -= sizeToReduce; - ByteBuf oldBuffer = buffers.get(0); - ByteBuf newBuffer = PulsarByteBufAllocator.DEFAULT.directBuffer((int) capacity * SIZE_OF_LONG); - oldBuffer.getBytes(0, newBuffer, (int) capacity * SIZE_OF_LONG); - oldBuffer.release(); - buffers.set(0, newBuffer); + resizeLastSegment(newLastIdx, newLastSize); + + segmentCount = newSegmentCount; + capacity = newCapacity; + + if (segments.length > Math.max(segmentCount * 2L, 16)) { + segments = Arrays.copyOf(segments, segmentCount); } } @Override public void close() { - buffers.forEach(ByteBuf::release); + segments = null; + segmentCount = 0; + capacity = 0; + allocatedBytes = 0; } /** - * The amount of memory used to back the array of longs. + * Returns the physical heap memory reserved by the backing arrays. + * + * @return allocated bytes occupied by all backing segments */ public long bytesCapacity() { - return capacity * SIZE_OF_LONG; + return allocatedBytes; } } diff --git a/pulsar-common/src/main/java/org/apache/pulsar/common/util/collections/TripleLongPriorityQueue.java b/pulsar-common/src/main/java/org/apache/pulsar/common/util/collections/TripleLongPriorityQueue.java index e75ae21ab8518..97fb3b2460d63 100644 --- a/pulsar-common/src/main/java/org/apache/pulsar/common/util/collections/TripleLongPriorityQueue.java +++ b/pulsar-common/src/main/java/org/apache/pulsar/common/util/collections/TripleLongPriorityQueue.java @@ -23,7 +23,26 @@ /** * Provides a priority-queue implementation specialized on items composed by 3 longs. * - *

This class is not thread safe and the items are stored in direct memory. + *

This class is not thread safe. + * + *

Algorithm

+ * + *

This is a binary min-heap stored in a flat array, where each heap node occupies + * 3 consecutive longs (the tuple). The children of the node at index {@code i} are at + * {@code 2i + 1} and {@code 2i + 2}; the parent of node {@code i} is at {@code (i - 1) / 2}. + * + *

Both {@code siftUp} (on insert) and {@code siftDown} (on remove) use the + * hole-based (also called "bottom-up" or "Floyd's") optimization: instead of swapping + * the displaced element with its parent/child at each level, the displaced values are held in + * local variables (registers) and written only once at the final position. This reduces the + * number of array writes per sift layer from 6 (swap: 3 reads + 3 writes on each side) to 3 + * (one directional write), and avoids re-reading the displaced element from the array on every + * comparison. + * + *

Comparison is lexicographic on (n1, n2, n3), using {@code Long.compare} at each level. + * + * @see Bottom-up heapsort + * (Wikipedia) */ public class TripleLongPriorityQueue implements AutoCloseable { private static final int DEFAULT_INITIAL_CAPACITY = 16; @@ -90,12 +109,9 @@ public void close() { */ public void add(long n1, long n2, long n3) { long arrayIdx = tuplesCount * ITEMS_COUNT; - if ((arrayIdx + 2) >= array.getCapacity()) { - array.increaseCapacity(); - } + array.ensureCapacity(arrayIdx + ITEMS_COUNT); - put(tuplesCount, n1, n2, n3); - siftUp(tuplesCount); + siftUp(tuplesCount, n1, n2, n3); ++tuplesCount; } @@ -134,9 +150,17 @@ public long peekN3() { */ public void pop() { checkArgument(tuplesCount != 0); - swap(0, tuplesCount - 1); - tuplesCount--; - siftDown(0); + + if (--tuplesCount == 0) { + return; + } + + long lastBase = tuplesCount * ITEMS_COUNT; + long n1 = array.readLong(lastBase); + long n2 = array.readLong(lastBase + 1); + long n3 = array.readLong(lastBase + 2); + + siftDown(0, n1, n2, n3); shrinkCapacity(); } @@ -188,81 +212,99 @@ private void shrinkCapacity() { } } - private void siftUp(long tupleIdx) { + private void siftUp(long tupleIdx, long n1, long n2, long n3) { + long idx = tupleIdx * ITEMS_COUNT; + while (tupleIdx > 0) { - long parentIdx = (tupleIdx - 1) / 2; - if (compare(tupleIdx, parentIdx) >= 0) { + long parentIdx = (tupleIdx - 1) >>> 1; + long parentBase = parentIdx * ITEMS_COUNT; + + long p0 = array.readLong(parentBase); + long p1 = array.readLong(parentBase + 1); + long p2 = array.readLong(parentBase + 2); + + if (compareTuple(n1, n2, n3, p0, p1, p2) >= 0) { break; } - swap(tupleIdx, parentIdx); + array.writeLong(idx, p0); + array.writeLong(idx + 1, p1); + array.writeLong(idx + 2, p2); + tupleIdx = parentIdx; + idx = parentBase; } + + array.writeLong(idx, n1); + array.writeLong(idx + 1, n2); + array.writeLong(idx + 2, n3); } - private void siftDown(long tupleIdx) { - long half = tuplesCount / 2; + private void siftDown(long tupleIdx, long val0, long val1, long val2) { + long half = tuplesCount >>> 1; + + long idx = tupleIdx * ITEMS_COUNT; + while (tupleIdx < half) { - long left = 2 * tupleIdx + 1; - long right = 2 * tupleIdx + 2; + long left = (tupleIdx << 1) + 1; + long right = left + 1; - long swapIdx = tupleIdx; + long child = left; + long childBase = left * ITEMS_COUNT; - if (compare(tupleIdx, left) > 0) { - swapIdx = left; - } + long child0 = array.readLong(childBase); + long child1 = array.readLong(childBase + 1); + long child2 = array.readLong(childBase + 2); - if (right < tuplesCount && compare(swapIdx, right) > 0) { - swapIdx = right; - } + if (right < tuplesCount) { + long rightBase = right * ITEMS_COUNT; - if (swapIdx == tupleIdx) { - return; - } + long right0 = array.readLong(rightBase); + long right1 = array.readLong(rightBase + 1); + long right2 = array.readLong(rightBase + 2); - swap(tupleIdx, swapIdx); - tupleIdx = swapIdx; - } - } + if (compareTuple(right0, right1, right2, child0, child1, child2) < 0) { - private void put(long tupleIdx, long n1, long n2, long n3) { - long idx = tupleIdx * ITEMS_COUNT; - array.writeLong(idx, n1); - array.writeLong(idx + 1, n2); - array.writeLong(idx + 2, n3); - } + child = right; + childBase = rightBase; - private int compare(long tupleIdx1, long tupleIdx2) { - long idx1 = tupleIdx1 * ITEMS_COUNT; - long idx2 = tupleIdx2 * ITEMS_COUNT; + child0 = right0; + child1 = right1; + child2 = right2; + } + } - int c1 = Long.compare(array.readLong(idx1), array.readLong(idx2)); - if (c1 != 0) { - return c1; - } + if (compareTuple(val0, val1, val2, child0, child1, child2) <= 0) { + break; + } + + array.writeLong(idx, child0); + array.writeLong(idx + 1, child1); + array.writeLong(idx + 2, child2); - int c2 = Long.compare(array.readLong(idx1 + 1), array.readLong(idx2 + 1)); - if (c2 != 0) { - return c2; + tupleIdx = child; + idx = childBase; } - return Long.compare(array.readLong(idx1 + 2), array.readLong(idx2 + 2)); + array.writeLong(idx, val0); + array.writeLong(idx + 1, val1); + array.writeLong(idx + 2, val2); } - private void swap(long tupleIdx1, long tupleIdx2) { - long idx1 = tupleIdx1 * ITEMS_COUNT; - long idx2 = tupleIdx2 * ITEMS_COUNT; + private static int compareTuple( + long a0, long a1, long a2, + long b0, long b1, long b2) { - long tmp1 = array.readLong(idx1); - long tmp2 = array.readLong(idx1 + 1); - long tmp3 = array.readLong(idx1 + 2); + int c = Long.compare(a0, b0); + if (c != 0) { + return c; + } - array.writeLong(idx1, array.readLong(idx2)); - array.writeLong(idx1 + 1, array.readLong(idx2 + 1)); - array.writeLong(idx1 + 2, array.readLong(idx2 + 2)); + c = Long.compare(a1, b1); + if (c != 0) { + return c; + } - array.writeLong(idx2, tmp1); - array.writeLong(idx2 + 1, tmp2); - array.writeLong(idx2 + 2, tmp3); + return Long.compare(a2, b2); } } diff --git a/pulsar-common/src/main/java/org/apache/pulsar/common/util/keystoretls/KeyStoreSSLContext.java b/pulsar-common/src/main/java/org/apache/pulsar/common/util/keystoretls/KeyStoreSSLContext.java index a70857bdf3b5f..f8d05ee9afce9 100644 --- a/pulsar-common/src/main/java/org/apache/pulsar/common/util/keystoretls/KeyStoreSSLContext.java +++ b/pulsar-common/src/main/java/org/apache/pulsar/common/util/keystoretls/KeyStoreSSLContext.java @@ -170,7 +170,7 @@ public SSLContext createSSLContext() throws GeneralSecurityException, IOExceptio TrustManager[] trustManagers = null; if (trustManagerFactory != null) { - trustManagers = SecurityUtility.processConscryptTrustManagers(trustManagerFactory.getTrustManagers()); + trustManagers = trustManagerFactory.getTrustManagers(); } // init diff --git a/pulsar-common/src/test/java/org/apache/pulsar/client/impl/schema/SchemaUtilsTest.java b/pulsar-common/src/test/java/org/apache/pulsar/client/impl/schema/SchemaUtilsTest.java new file mode 100644 index 0000000000000..ea04f87f297d1 --- /dev/null +++ b/pulsar-common/src/test/java/org/apache/pulsar/client/impl/schema/SchemaUtilsTest.java @@ -0,0 +1,82 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.client.impl.schema; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; +import com.fasterxml.jackson.databind.JsonNode; +import java.util.Collections; +import org.apache.pulsar.common.schema.SchemaInfo; +import org.apache.pulsar.common.schema.SchemaInfoWithVersion; +import org.apache.pulsar.common.schema.SchemaType; +import org.apache.pulsar.common.util.ObjectMapperFactory; +import org.testng.annotations.Test; + +public class SchemaUtilsTest { + + private static final String SCHEMA_WITH_NULL_DEFAULTS = "{\n" + + " \"type\": \"record\",\n" + + " \"name\": \"Root\",\n" + + " \"fields\": [\n" + + " {\n" + + " \"name\": \"user_info\",\n" + + " \"type\": {\n" + + " \"type\": \"record\",\n" + + " \"name\": \"UserInfo\",\n" + + " \"fields\": [\n" + + " {\"name\": \"user_id\", \"type\": \"string\", \"default\": \"\"},\n" + + " {\"name\": \"meter_id\", \"type\": [\"null\", \"string\"], \"default\": null}\n" + + " ]\n" + + " },\n" + + " \"default\": {\"user_id\": \"\", \"meter_id\": null}\n" + + " }\n" + + " ]\n" + + "}"; + + @Test + public void testJsonifySchemaInfoPreservesNullDefaults() throws Exception { + SchemaInfo schemaInfo = new SchemaInfoImpl("test-schema", SCHEMA_WITH_NULL_DEFAULTS.getBytes(UTF_8), + SchemaType.AVRO, 0, Collections.emptyMap()); + + assertNullDefaults(SchemaUtils.jsonifySchemaInfo(schemaInfo, true).getBytes(UTF_8)); + + SchemaInfoWithVersion schemaInfoWithVersion = SchemaInfoWithVersion.builder() + .schemaInfo(schemaInfo) + .version(0) + .build(); + assertNullDefaults(SchemaUtils.jsonifySchemaInfoWithVersion(schemaInfoWithVersion).getBytes(UTF_8)); + } + + private static void assertNullDefaults(byte[] json) throws Exception { + JsonNode schema = ObjectMapperFactory.getMapper().reader().readTree(json).at("/schemaInfo/schema"); + if (schema.isMissingNode()) { + schema = ObjectMapperFactory.getMapper().reader().readTree(json).at("/schema"); + } + + JsonNode userInfoField = schema.at("/fields/0"); + assertTrue(userInfoField.get("default").has("meter_id")); + assertTrue(userInfoField.at("/default/meter_id").isNull()); + + JsonNode meterIdField = userInfoField.at("/type/fields/1"); + assertEquals(meterIdField.get("name").asText(), "meter_id"); + assertTrue(meterIdField.has("default")); + assertTrue(meterIdField.get("default").isNull()); + } +} diff --git a/pulsar-common/src/test/java/org/apache/pulsar/common/policies/data/OffloadPoliciesTest.java b/pulsar-common/src/test/java/org/apache/pulsar/common/policies/data/OffloadPoliciesTest.java index 62934343e59e8..76912b9563ad7 100644 --- a/pulsar-common/src/test/java/org/apache/pulsar/common/policies/data/OffloadPoliciesTest.java +++ b/pulsar-common/src/test/java/org/apache/pulsar/common/policies/data/OffloadPoliciesTest.java @@ -97,6 +97,52 @@ public void testS3Configuration() { Assert.assertEquals(offloadPolicies.getManagedLedgerOffloadThresholdInSeconds(), offloadThresholdInSeconds); } + @Test + public void testAliyunOssConfiguration() { + final String driver = "aliyun-oss"; + final String region = "test-region"; + final String bucket = "test-bucket"; + final String role = "test-role"; + final String roleSessionName = "test-role-session-name"; + final String credentialId = "test-credential-id"; + final String credentialSecret = "test-credential-secret"; + final String endPoint = "test-endpoint"; + final Integer maxBlockSizeInBytes = 5 * M; + final Integer readBufferSizeInBytes = 2 * M; + final Long offloadThresholdInBytes = 10L * M; + final Long offloadThresholdInSeconds = 1000L; + final Long offloadDeletionLagInMillis = 5L * MIN; + + OffloadPoliciesImpl offloadPolicies = OffloadPoliciesImpl.create( + driver, + region, + bucket, + endPoint, + role, + roleSessionName, + credentialId, + credentialSecret, + maxBlockSizeInBytes, + readBufferSizeInBytes, + offloadThresholdInBytes, + offloadThresholdInSeconds, + offloadDeletionLagInMillis, + OffloadedReadPriority.TIERED_STORAGE_FIRST + ); + + Assert.assertTrue(offloadPolicies.isS3Driver()); + Assert.assertEquals(offloadPolicies.getManagedLedgerOffloadDriver(), driver); + Assert.assertEquals(offloadPolicies.getS3ManagedLedgerOffloadRegion(), region); + Assert.assertEquals(offloadPolicies.getS3ManagedLedgerOffloadBucket(), bucket); + Assert.assertEquals(offloadPolicies.getS3ManagedLedgerOffloadServiceEndpoint(), endPoint); + // The CLI create() path must carry the credentials under the s3-prefixed keys the + // S3-compatible aliyun-oss offloader reads, not silently drop them. + Assert.assertEquals(offloadPolicies.getS3ManagedLedgerOffloadCredentialId(), credentialId); + Assert.assertEquals(offloadPolicies.getS3ManagedLedgerOffloadCredentialSecret(), credentialSecret); + Assert.assertEquals(offloadPolicies.getS3ManagedLedgerOffloadMaxBlockSizeInBytes(), maxBlockSizeInBytes); + Assert.assertEquals(offloadPolicies.getS3ManagedLedgerOffloadReadBufferSizeInBytes(), readBufferSizeInBytes); + } + @Test public void testGcsConfiguration() { final String driver = "google-cloud-storage"; diff --git a/pulsar-common/src/test/java/org/apache/pulsar/common/policies/data/stats/SubscriptionStatsImplTest.java b/pulsar-common/src/test/java/org/apache/pulsar/common/policies/data/stats/SubscriptionStatsImplTest.java index 8a4b5da9edd20..d6c3222d2b01a 100644 --- a/pulsar-common/src/test/java/org/apache/pulsar/common/policies/data/stats/SubscriptionStatsImplTest.java +++ b/pulsar-common/src/test/java/org/apache/pulsar/common/policies/data/stats/SubscriptionStatsImplTest.java @@ -27,8 +27,10 @@ public class SubscriptionStatsImplTest { public void testReset() { SubscriptionStatsImpl stats = new SubscriptionStatsImpl(); stats.earliestMsgPublishTimeInBacklog = 1L; + stats.oldestBacklogMessageAgeSeconds = 10L; stats.reset(); assertEquals(stats.earliestMsgPublishTimeInBacklog, 0L); + assertEquals(stats.oldestBacklogMessageAgeSeconds, -1L); } @@ -79,4 +81,19 @@ public void testAdd_EarliestMsgPublishTimeInBacklogs_Zero() { SubscriptionStatsImpl aggregate = stats1.add(stats2); assertEquals(aggregate.earliestMsgPublishTimeInBacklog, 0L); } -} \ No newline at end of file + + @Test + public void testAdd_OldestBacklogMessageAgeSeconds() { + SubscriptionStatsImpl stats1 = new SubscriptionStatsImpl(); + stats1.oldestBacklogMessageAgeSeconds = -1L; + + SubscriptionStatsImpl stats2 = new SubscriptionStatsImpl(); + stats2.oldestBacklogMessageAgeSeconds = 20L; + + SubscriptionStatsImpl stats3 = new SubscriptionStatsImpl(); + stats3.oldestBacklogMessageAgeSeconds = 10L; + + SubscriptionStatsImpl aggregate = stats1.add(stats2).add(stats3); + assertEquals(aggregate.oldestBacklogMessageAgeSeconds, 20L); + } +} diff --git a/pulsar-common/src/test/java/org/apache/pulsar/common/util/FieldParserTest.java b/pulsar-common/src/test/java/org/apache/pulsar/common/util/FieldParserTest.java index b22170fa46505..1f9a1e2688267 100644 --- a/pulsar-common/src/test/java/org/apache/pulsar/common/util/FieldParserTest.java +++ b/pulsar-common/src/test/java/org/apache/pulsar/common/util/FieldParserTest.java @@ -95,6 +95,18 @@ public static class MyConfig { public Set stringSet; } + @Test + public void testMapWithEqualsSignAndEmptyValue() { + Map properties = new HashMap<>(); + properties.put("stringStringMap", "key1=value=1,key2="); + + MyConfig config = new MyConfig(); + FieldParser.update(properties, config); + + assertEquals(config.stringStringMap.get("key1"), "value=1"); + assertEquals(config.stringStringMap.get("key2"), ""); + } + @Test public void testNullStrValue() throws Exception { class TestMap { diff --git a/pulsar-common/src/test/java/org/apache/pulsar/common/util/LatencyTracerTest.java b/pulsar-common/src/test/java/org/apache/pulsar/common/util/LatencyTracerTest.java new file mode 100644 index 0000000000000..d3d638d096e57 --- /dev/null +++ b/pulsar-common/src/test/java/org/apache/pulsar/common/util/LatencyTracerTest.java @@ -0,0 +1,143 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.common.util; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNotSame; +import static org.testng.Assert.assertSame; +import static org.testng.Assert.assertTrue; +import static org.testng.Assert.fail; +import java.util.LinkedList; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.testng.annotations.Test; + +@Test(groups = "utils") +public class LatencyTracerTest { + + private static LatencyTracer.NanoTimeSupplier testNanoTimeSupplier(long... nanoTimes) { + return new LatencyTracer.NanoTimeSupplier() { + final LinkedList nanoTimesQueue = new LinkedList<>(); + + { + for (long nanoTime : nanoTimes) { + nanoTimesQueue.add(nanoTime); + } + } + + @Override + public long getNanos() { + final Long nanos = nanoTimesQueue.poll(); + assertNotNull(nanos); + return nanos; + } + }; + } + + @Test + public void testMulti() { + LatencyTracer tracer = new LatencyTracer(new LinkedList<>(), testNanoTimeSupplier(10_000_000L, 30_000_000L, + 70_000_000L, 80_000_000L)); + tracer.trace("A"); + tracer.trace("B"); + LatencyTracer.Snapshot snapshot = tracer.getLatency(); + assertEquals(snapshot.description(), "total: 60 ms, A: 20 ms, B: 40 ms"); + assertEquals(snapshot.elapsedInMillis(), 60); + + tracer.trace("C"); + snapshot = tracer.getLatency(); + assertEquals(snapshot.description(), "total: 70 ms, A: 20 ms, B: 40 ms, C: 10 ms"); + assertEquals(snapshot.elapsedInMillis(), 70); + } + + @Test + public void testEmpty() { + LatencyTracer tracer = new LatencyTracer(new LinkedList<>(), testNanoTimeSupplier(0L, 20_000_000L)); + LatencyTracer.Snapshot snapshot = tracer.getLatency(); + assertEquals(snapshot.description(), "total: 0 ms"); + assertEquals(snapshot.elapsedInMillis(), 0); + } + + @Test + public void testZeroMs() { + LatencyTracer tracer = new LatencyTracer(new LinkedList<>(), testNanoTimeSupplier(0L, 999_999L, 2_000_000L, + 2_100_000L)); + tracer.trace("A"); + tracer.trace("B"); + tracer.trace("C"); + LatencyTracer.Snapshot snapshot = tracer.getLatency(); + assertEquals(snapshot.description(), "total: 2 ms, A: 999 us, B: 1 ms, C: 100 us"); + assertEquals(snapshot.elapsedInMillis(), 2); + } + + @Test + public void testTraceFuture() throws Exception { + LatencyTracer tracer = new LatencyTracer(new LinkedList<>(), System::nanoTime); + final CompletableFuture future = CompletableFuture.completedFuture(100); + assertSame(tracer.trace("A", future), future); + final String latency = tracer.getLatency().description(); + assertTrue(Pattern.compile("total: \\d+ ms").matcher(latency).matches(), latency); + + final CompletableFuture future2 = new CompletableFuture(); + delayedExecutor(500, TimeUnit.MILLISECONDS).execute(() -> future2.complete(1)); + final CompletableFuture tracedFuture = tracer.trace("B", future2); + assertNotSame(tracedFuture, future2); + assertEquals(tracedFuture.get(), 1); + final LatencyTracer.Snapshot snapshot = tracer.getLatency(); + Matcher m = Pattern.compile("total: \\d+ ms, B: (\\d+) ms").matcher(snapshot.description()); + assertTrue(m.matches(), snapshot.description()); + assertEquals(Long.parseLong(m.group(1)), snapshot.elapsedInMillis(), snapshot.description()); + assertTrue(snapshot.elapsedInMillis() >= 500, snapshot.description()); + } + + @Test + public void testTraceFailedFuture() throws Exception { + final LatencyTracer tracer = new LatencyTracer(new LinkedList<>(), testNanoTimeSupplier(1L)); + + final CompletableFuture future = new CompletableFuture(); + delayedExecutor(100, TimeUnit.MILLISECONDS).execute(() -> future.completeExceptionally( + new RuntimeException("failure"))); + + try { + tracer.trace("A", future).get(); + fail(); + } catch (ExecutionException e) { + assertTrue(e.getCause() instanceof RuntimeException); + assertEquals(e.getCause().getMessage(), "failure"); + } + } + + // Replace it with `CompletableFuture.delayedExecutor` after upgraded to Java 9 or later + private static Executor delayedExecutor(long delay, TimeUnit unit) { + + return command -> new Thread(() -> { + try { + Thread.sleep(unit.toMillis(delay)); + command.run(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }).start(); + } +} diff --git a/pulsar-common/src/test/java/org/apache/pulsar/common/util/collections/LongBitmapCompatibilityTest.java b/pulsar-common/src/test/java/org/apache/pulsar/common/util/collections/LongBitmapCompatibilityTest.java new file mode 100644 index 0000000000000..6c9081db44f52 --- /dev/null +++ b/pulsar-common/src/test/java/org/apache/pulsar/common/util/collections/LongBitmapCompatibilityTest.java @@ -0,0 +1,235 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.common.util.collections; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertThrows; +import static org.testng.Assert.assertTrue; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.nio.ByteBuffer; +import org.roaringbitmap.RoaringBitmap; +import org.roaringbitmap.longlong.Roaring64Bitmap; +import org.testng.annotations.Test; + +/** + * Verifies the serialization compatibility characteristics of {@link LongBitmap}. + * + *

Context: the previous implementation used two different RoaringBitmap variants: + *

    + *
  • {@code InMemoryDelayedDeliveryTracker} used {@link Roaring64Bitmap} (in-memory only) + *
  • {@code BucketDelayedDeliveryTracker} used {@link RoaringBitmap} (32-bit, persisted) + *
+ * + *

The new {@link LongBitmap} abstraction always uses the 32-bit {@link RoaringBitmap} + * internally. These tests document and verify: + *

    + *
  1. 32-bit buffer compatibility: {@link LongBitmap} buffers are byte-identical to standard + * {@link RoaringBitmap} buffers (required for {@code BucketDelayedDeliveryTracker} + * backward compatibility — old persisted snapshots must round-trip). + *
  2. 64-bit buffer incompatibility: {@link Roaring64Bitmap} buffers cannot be deserialized + * by {@link LongBitmap} (format includes high-32-bit bucket prefixes). Migration from + * {@code Roaring64Bitmap} is safe only for in-memory state; persisted state would require + * a one-time entry-by-entry rebuild. + *
  3. Behavioral equivalence within {@code uint32} range: {@link LongBitmap} and + * {@link Roaring64Bitmap} produce identical add/remove/contains results for all + * values in {@code [0, 2^32)}. + *
+ */ +public class LongBitmapCompatibilityTest { + + /** + * LongBitmap's serialized bytes must be identical to a 32-bit RoaringBitmap's + * for the same uint32 values. This guarantees persisted snapshots written by + * the old {@code BucketDelayedDeliveryTracker} (using RoaringBitmap) remain + * readable, and vice versa. + */ + @Test + public void testLongBitmapBufferEqualsStandardRoaringBitmap() throws Exception { + LongBitmap longBitmap = LongBitmaps.create(); + longBitmap.add(0); + longBitmap.add(100); + longBitmap.add(1L << 20); + longBitmap.add(0xFFFFFFFFL); // uint32 max + + RoaringBitmap roaring = new RoaringBitmap(); + roaring.add(0); + roaring.add(100); + roaring.add(1 << 20); + roaring.add(0xFFFFFFFF); // -1 as signed int + + byte[] longBitmapBytes = serializeLongBitmap(longBitmap); + byte[] roaringBytes = serializeRoaring32(roaring); + + assertEquals(longBitmapBytes, roaringBytes, + "LongBitmap buffer must be byte-identical to standard 32-bit RoaringBitmap buffer"); + } + + /** + * Round-trip: 32-bit RoaringBitmap buffer -> LongBitmap. Confirms that + * persisted data written by the old code path can be read by LongBitmap. + */ + @Test + public void testDeserializeFromRoaring32Buffer() throws Exception { + RoaringBitmap roaring = new RoaringBitmap(); + for (int i = 0; i < 1000; i += 7) { + roaring.add(i); + } + roaring.add(0xFFFFFFFF); + + byte[] roaringBytes = serializeRoaring32(roaring); + ByteBuf buf = Unpooled.wrappedBuffer(roaringBytes); + try { + LongBitmap longBitmap = LongBitmaps.deserialize(buf); + assertEquals(longBitmap.cardinality(), roaring.getLongCardinality()); + assertTrue(longBitmap.contains(0xFFFFFFFFL)); + for (int i = 0; i < 1000; i += 7) { + assertTrue(longBitmap.contains(i), "missing " + i); + } + for (int i = 1; i < 1000; i += 7) { + assertFalse(longBitmap.contains(i)); + } + } finally { + buf.release(); + } + } + + /** + * LongBitmap buffer cannot be deserialized as a Roaring64Bitmap. + * The 32-bit RoaringBitmap portable format (cookie 12346, ~22 bytes for small sets) + * is shorter than Roaring64Bitmap's bucketed header, so {@code Roaring64Bitmap.deserialize} + * throws {@link java.nio.BufferUnderflowException}. + * + *

Migration implication: persisted 32-bit data cannot be read by old code paths + * that still expect Roaring64Bitmap, and vice versa. + */ + @Test + public void testLongBitmapBufferNotReadableAsRoaring64() throws Exception { + LongBitmap longBitmap = LongBitmaps.create(); + longBitmap.add(1); + longBitmap.add(100); + longBitmap.add(1000); + + byte[] longBitmapBytes = serializeLongBitmap(longBitmap); + + Roaring64Bitmap roaring64 = new Roaring64Bitmap(); + // 32-bit format is shorter than 64-bit header expects — buffer underflow or related I/O error. + assertThrows(Exception.class, + () -> roaring64.deserialize(ByteBuffer.wrap(longBitmapBytes))); + } + + /** + * Roaring64Bitmap buffer cannot be deserialized as a LongBitmap. + * Roaring64Bitmap's serialized format starts with a bucket count, not the + * 32-bit cookie (12346), so {@code MutableRoaringBitmap.deserialize} throws. + */ + @Test + public void testRoaring64BufferNotReadableAsLongBitmap() throws Exception { + Roaring64Bitmap roaring64 = new Roaring64Bitmap(); + roaring64.addLong(1); + roaring64.addLong(100); + roaring64.addLong(1000); + + byte[] roaring64Bytes = serializeRoaring64(roaring64); + + ByteBuf buf = Unpooled.wrappedBuffer(roaring64Bytes); + try { + // MutableRoaringBitmap wraps IOException as RuntimeException. + assertThrows(Exception.class, () -> LongBitmaps.deserialize(buf)); + } finally { + buf.release(); + } + } + + /** + * Behavioral equivalence within uint32 range: LongBitmap and Roaring64Bitmap + * produce identical results for all operations on values in [0, 2^32). + * This is what makes the InMemoryDelayedDeliveryTracker migration safe — + * BookKeeper entry IDs are currently always < 2^32. + */ + @Test + public void testBehavioralEquivalenceWithinUint32() { + LongBitmap longBitmap = LongBitmaps.create(); + Roaring64Bitmap roaring64 = new Roaring64Bitmap(); + + long[] values = {0, 1, 100, 65535, 65536, 1L << 20, 1L << 30, 0xFFFFFFFFL}; + for (long v : values) { + longBitmap.add(v); + roaring64.addLong(v); + } + + assertEquals(longBitmap.cardinality(), roaring64.getLongCardinality()); + for (long v : values) { + assertTrue(longBitmap.contains(v)); + assertTrue(roaring64.contains(v)); + } + + long[] toRemove = {0, 100, 65536, 1L << 30}; + for (long v : toRemove) { + longBitmap.remove(v); + roaring64.removeLong(v); + } + + assertEquals(longBitmap.cardinality(), roaring64.getLongCardinality()); + for (long v : toRemove) { + assertFalse(longBitmap.contains(v)); + assertFalse(roaring64.contains(v)); + } + } + + /** + * LongBitmap accepts the uint32 boundary (2^32 - 1) but rejects 2^32 and above. + * Migration from Roaring64Bitmap must ensure no values >= 2^32 are present; + * otherwise the migration would silently drop or reject those entries. + */ + @Test + public void testUint32Boundary() { + LongBitmap longBitmap = LongBitmaps.create(); + longBitmap.add(0xFFFFFFFFL); + assertTrue(longBitmap.contains(0xFFFFFFFFL)); + assertEquals(longBitmap.cardinality(), 1); + + assertThrows(IllegalArgumentException.class, () -> longBitmap.add(0x100000000L)); + assertThrows(IllegalArgumentException.class, () -> longBitmap.add(-1)); + } + + private static byte[] serializeLongBitmap(LongBitmap bitmap) { + return bitmap.serialize(); + } + + private static byte[] serializeRoaring32(RoaringBitmap bitmap) throws Exception { + bitmap.runOptimize(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + DataOutputStream dos = new DataOutputStream(baos); + bitmap.serialize(dos); + dos.close(); + return baos.toByteArray(); + } + + private static byte[] serializeRoaring64(Roaring64Bitmap bitmap) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + DataOutputStream dos = new DataOutputStream(baos); + bitmap.serialize(dos); + dos.close(); + return baos.toByteArray(); + } +} diff --git a/pulsar-common/src/test/java/org/apache/pulsar/common/util/collections/LongBitmapTest.java b/pulsar-common/src/test/java/org/apache/pulsar/common/util/collections/LongBitmapTest.java new file mode 100644 index 0000000000000..333dda0b24e4e --- /dev/null +++ b/pulsar-common/src/test/java/org/apache/pulsar/common/util/collections/LongBitmapTest.java @@ -0,0 +1,933 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.pulsar.common.util.collections; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertThrows; +import static org.testng.Assert.assertTrue; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.testng.annotations.Test; + +/** + * Unit tests for {@link LongBitmap}. Covers point/range operations, uint32 boundary + * behavior, serialization round-trip, drain-to atomicity, and concurrency contracts + * (or lock ordering, snapshot safety under mutation, or-input immutability). + */ +public class LongBitmapTest { + + @Test + public void testBasicOperations() { + LongBitmap bitmap = LongBitmaps.create(); + + assertEquals(bitmap.cardinality(), 0); + assertFalse(bitmap.contains(1)); + + bitmap.add(1); + bitmap.add(100); + bitmap.add(1000); + + assertEquals(bitmap.cardinality(), 3); + assertTrue(bitmap.contains(1)); + assertTrue(bitmap.contains(100)); + assertTrue(bitmap.contains(1000)); + assertFalse(bitmap.contains(2)); + + bitmap.remove(100); + assertEquals(bitmap.cardinality(), 2); + assertFalse(bitmap.contains(100)); + } + + @Test + public void testRangeValidation() { + LongBitmap bitmap = LongBitmaps.create(); + bitmap.add(0); + bitmap.add(0xFFFFFFFFL); + assertTrue(bitmap.contains(0)); + assertTrue(bitmap.contains(0xFFFFFFFFL)); + + assertThrows(IllegalArgumentException.class, () -> bitmap.add(-1)); + assertThrows(IllegalArgumentException.class, () -> bitmap.add(0x100000000L)); + assertFalse(bitmap.contains(-1)); + assertFalse(bitmap.contains(0x100000000L)); + } + + @Test + public void testSerialization() { + LongBitmap bitmap = LongBitmaps.create(); + for (int i = 0; i < 1000; i += 10) { + bitmap.add(i); + } + + byte[] bytes = bitmap.serialize(); + assertTrue(bytes.length > 0); + + LongBitmap deserialized = LongBitmaps.deserialize(Unpooled.wrappedBuffer(bytes)); + assertEquals(deserialized.cardinality(), 100); + for (int i = 0; i < 1000; i += 10) { + assertTrue(deserialized.contains(i)); + } + assertFalse(deserialized.contains(5)); + } + + @Test + public void testDeserializeFromVariousByteBufTypes() { + LongBitmap bitmap = LongBitmaps.create(); + for (int i = 0; i < 2000; i += 7) { + bitmap.add(i); + } + byte[] bytes = bitmap.serialize(); + + ByteBuf heap = Unpooled.wrappedBuffer(bytes); + try { + LongBitmap r = LongBitmaps.deserialize(heap); + assertEquals(r.cardinality(), bitmap.cardinality()); + assertEquals(heap.readerIndex(), bytes.length); + } finally { + heap.release(); + } + + ByteBuf direct = Unpooled.directBuffer(bytes.length); + try { + direct.writeBytes(bytes); + LongBitmap r = LongBitmaps.deserialize(direct); + assertEquals(r.cardinality(), bitmap.cardinality()); + assertEquals(direct.readerIndex(), bytes.length); + } finally { + direct.release(); + } + + ByteBuf singleComp = Unpooled.wrappedBuffer(new ByteBuf[]{Unpooled.wrappedBuffer(bytes)}); + try { + LongBitmap r = LongBitmaps.deserialize(singleComp); + assertEquals(r.cardinality(), bitmap.cardinality()); + assertEquals(singleComp.readerIndex(), bytes.length); + } finally { + singleComp.release(); + } + + int split = bytes.length / 2; + ByteBuf part1 = Unpooled.wrappedBuffer(bytes, 0, split); + ByteBuf part2 = Unpooled.wrappedBuffer(bytes, split, bytes.length - split); + ByteBuf composite = Unpooled.wrappedBuffer(part1, part2); + try { + LongBitmap r = LongBitmaps.deserialize(composite); + assertEquals(r.cardinality(), bitmap.cardinality()); + assertEquals(composite.readerIndex(), bytes.length); + } finally { + composite.release(); + } + } + + @Test + public void testAddIsIdempotent() { + LongBitmap bitmap = LongBitmaps.create(); + bitmap.add(42); + assertEquals(bitmap.cardinality(), 1); + assertTrue(bitmap.contains(42)); + + bitmap.add(42); + bitmap.add(42); + assertEquals(bitmap.cardinality(), 1); + assertTrue(bitmap.contains(42)); + } + + @Test + public void testCheckedAdd() { + LongBitmap bitmap = LongBitmaps.create(); + + assertTrue(bitmap.checkedAdd(42)); + assertTrue(bitmap.contains(42)); + assertEquals(bitmap.cardinality(), 1); + + assertFalse(bitmap.checkedAdd(42)); + assertEquals(bitmap.cardinality(), 1); + + assertTrue(bitmap.checkedAdd(100)); + assertEquals(bitmap.cardinality(), 2); + + assertThrows(IllegalArgumentException.class, () -> bitmap.checkedAdd(-1)); + assertThrows(IllegalArgumentException.class, () -> bitmap.checkedAdd(0x100000000L)); + assertEquals(bitmap.cardinality(), 2); + } + + @Test + public void testForEach() { + LongBitmap bitmap = LongBitmaps.create(); + bitmap.add(1); + bitmap.add(5); + bitmap.add(10); + + List values = new ArrayList<>(); + bitmap.forEachLong(values::add); + + assertEquals(values.size(), 3); + Collections.sort(values); + assertEquals(values.get(0).longValue(), 1); + assertEquals(values.get(1).longValue(), 5); + assertEquals(values.get(2).longValue(), 10); + } + + @Test + public void testConcurrentReads() throws Exception { + LongBitmap bitmap = LongBitmaps.create(); + for (int i = 0; i < 10000; i++) { + bitmap.add(i); + } + + int numThreads = 10; + ExecutorService executor = Executors.newFixedThreadPool(numThreads); + CountDownLatch latch = new CountDownLatch(numThreads); + AtomicInteger errors = new AtomicInteger(0); + + for (int i = 0; i < numThreads; i++) { + executor.submit(() -> { + try { + for (int j = 0; j < 1000; j++) { + if (!bitmap.contains(j)) { + errors.incrementAndGet(); + } + if (bitmap.cardinality() != 10000) { + errors.incrementAndGet(); + } + } + } finally { + latch.countDown(); + } + }); + } + + assertTrue(latch.await(30, TimeUnit.SECONDS)); + executor.shutdown(); + assertEquals(errors.get(), 0); + } + + @Test + public void testConcurrentWrites() throws Exception { + LongBitmap bitmap = LongBitmaps.create(); + + int numThreads = 10; + int valuesPerThread = 1000; + ExecutorService executor = Executors.newFixedThreadPool(numThreads); + CountDownLatch latch = new CountDownLatch(numThreads); + + for (int t = 0; t < numThreads; t++) { + int threadId = t; + executor.submit(() -> { + try { + for (int i = 0; i < valuesPerThread; i++) { + bitmap.add(threadId * valuesPerThread + i); + } + } finally { + latch.countDown(); + } + }); + } + + assertTrue(latch.await(30, TimeUnit.SECONDS)); + executor.shutdown(); + + assertEquals(bitmap.cardinality(), numThreads * valuesPerThread); + for (int t = 0; t < numThreads; t++) { + for (int i = 0; i < valuesPerThread; i++) { + assertTrue(bitmap.contains(t * valuesPerThread + i)); + } + } + } + + @Test + public void testConcurrentReadWrite() throws Exception { + LongBitmap bitmap = LongBitmaps.create(); + for (int i = 0; i < 5000; i++) { + bitmap.add(i); + } + + int numReaders = 5; + int numWriters = 5; + ExecutorService executor = Executors.newFixedThreadPool(numReaders + numWriters); + CountDownLatch latch = new CountDownLatch(numReaders + numWriters); + AtomicInteger errors = new AtomicInteger(0); + + for (int i = 0; i < numReaders; i++) { + executor.submit(() -> { + try { + for (int j = 0; j < 1000; j++) { + bitmap.contains(j % 5000); + bitmap.cardinality(); + } + } catch (Exception e) { + errors.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + for (int i = 0; i < numWriters; i++) { + int writerId = i; + executor.submit(() -> { + try { + for (int j = 0; j < 1000; j++) { + bitmap.add(5000 + writerId * 1000 + j); + } + } catch (Exception e) { + errors.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + + assertTrue(latch.await(30, TimeUnit.SECONDS)); + executor.shutdown(); + assertEquals(errors.get(), 0); + } + + @Test + public void testConcurrentForEachLongAndMutate() throws Exception { + // forEachLong takes a clone() snapshot under read lock; concurrent mutations on the + // live bitmap must never corrupt the snapshot. Regression guard for pulsar#25991. + LongBitmap bitmap = LongBitmaps.create(); + for (int i = 0; i < 10000; i++) { + bitmap.add(i); + } + + int numReaders = 5; + int numWriters = 5; + ExecutorService executor = Executors.newFixedThreadPool(numReaders + numWriters); + CountDownLatch latch = new CountDownLatch(numReaders + numWriters); + AtomicInteger errors = new AtomicInteger(0); + + for (int i = 0; i < numReaders; i++) { + executor.submit(() -> { + try { + for (int j = 0; j < 100; j++) { + long[] last = {-1}; + bitmap.forEachLong(v -> { + // Snapshot values must arrive in ascending order. + if (v <= last[0]) { + errors.incrementAndGet(); + } + last[0] = v; + }); + } + } catch (Exception e) { + errors.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + + for (int i = 0; i < numWriters; i++) { + int id = i; + executor.submit(() -> { + try { + for (int j = 0; j < 1000; j++) { + long v = 10000 + id * 1000 + (j % 1000); + bitmap.add(v); + bitmap.remove(v); + } + } catch (Exception e) { + errors.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + + assertTrue(latch.await(30, TimeUnit.SECONDS)); + executor.shutdown(); + assertEquals(errors.get(), 0); + assertEquals(bitmap.cardinality(), 10000); + } + + @Test + public void testMemoryTrim() { + LongBitmap bitmap = LongBitmaps.create(); + for (int i = 0; i < 100000; i++) { + bitmap.add(i); + } + for (int i = 0; i < 50000; i++) { + bitmap.remove(i); + } + + assertEquals(bitmap.cardinality(), 50000); + for (int i = 0; i < 50000; i++) { + assertFalse(bitmap.contains(i)); + } + for (int i = 50000; i < 100000; i++) { + assertTrue(bitmap.contains(i)); + } + } + + @Test + public void testIsEmpty() { + LongBitmap bitmap = LongBitmaps.create(); + assertTrue(bitmap.isEmpty()); + + bitmap.add(1); + assertFalse(bitmap.isEmpty()); + + bitmap.remove(1); + assertTrue(bitmap.isEmpty()); + + bitmap.remove(999); // never added + assertTrue(bitmap.isEmpty()); + } + + @Test + public void testOr() { + LongBitmap a = LongBitmaps.create(); + a.add(1); + a.add(100); + a.add(1000); + + LongBitmap b = LongBitmaps.create(); + b.add(100); // overlap + b.add(2000); // unique to b + + a.or(b); + + assertEquals(a.cardinality(), 4); + assertTrue(a.contains(1)); + assertTrue(a.contains(100)); + assertTrue(a.contains(1000)); + assertTrue(a.contains(2000)); + + // b is not modified + assertEquals(b.cardinality(), 2); + } + + @Test + public void testOrEmpty() { + LongBitmap a = LongBitmaps.create(); + a.add(1); + a.add(2); + + LongBitmap empty = LongBitmaps.create(); + a.or(empty); + assertEquals(a.cardinality(), 2); + + LongBitmap target = LongBitmaps.create(); + target.or(a); + assertEquals(target.cardinality(), 2); + } + + @Test + public void testOrSelfIsNoOp() { + LongBitmap a = LongBitmaps.create(); + a.add(1); + a.add(100); + + a.or(a); // should not deadlock + + assertEquals(a.cardinality(), 2); + assertTrue(a.contains(1)); + assertTrue(a.contains(100)); + } + + @Test + public void testRangeAddRemoveContains() { + LongBitmap bitmap = LongBitmaps.create(); + bitmap.add(100, 200); + assertEquals(bitmap.cardinality(), 100); + for (long v = 100; v < 200; v++) { + assertTrue(bitmap.contains(v)); + } + assertFalse(bitmap.contains(99)); + assertFalse(bitmap.contains(200)); + + // Single-value range [x, x+1) is equivalent to add(x). + LongBitmap single = LongBitmaps.create(); + single.add(42, 43); + assertTrue(single.contains(42, 43)); + assertTrue(single.contains(42)); + assertEquals(single.cardinality(), 1); + + // Range contains: true iff EVERY value in [from, to) is set. + assertTrue(bitmap.contains(100, 200)); + assertTrue(bitmap.contains(150, 160)); + assertFalse(bitmap.contains(99, 101)); + assertFalse(bitmap.contains(199, 201)); + assertFalse(bitmap.contains(200, 300)); + + bitmap.remove(100, 150); + assertEquals(bitmap.cardinality(), 50); + for (long v = 100; v < 150; v++) { + assertFalse(bitmap.contains(v)); + } + for (long v = 150; v < 200; v++) { + assertTrue(bitmap.contains(v)); + } + } + + @Test + public void testRangeVsSingleValueEquivalence() { + // For any v, add(v, v+1) / contains(v, v+1) / remove(v, v+1) ≡ add(v) / contains(v) / remove(v). + long[] values = {0, 1, 100, 65535, 65536, 1L << 30, 0xFFFFFFFFL}; + for (long v : values) { + LongBitmap bitmap = LongBitmaps.create(); + bitmap.add(v); + assertTrue(bitmap.contains(v)); + assertEquals(bitmap.cardinality(), 1); + + bitmap.add(v); // idempotent + assertEquals(bitmap.cardinality(), 1); + + bitmap.remove(v); + assertFalse(bitmap.contains(v)); + assertEquals(bitmap.cardinality(), 0); + } + } + + @Test + public void testNextAbsentValue() { + LongBitmap bitmap = LongBitmaps.create(); + // Empty bitmap: first absent is 0 + assertEquals(bitmap.nextAbsentValue(0), 0); + + bitmap.add(0); + bitmap.add(1); + bitmap.add(2); + // [0,1,2] present, next absent from 0 is 3 + assertEquals(bitmap.nextAbsentValue(0), 3); + + bitmap.add(5); + // Gap at 3,4 + assertEquals(bitmap.nextAbsentValue(0), 3); + assertEquals(bitmap.nextAbsentValue(3), 3); + assertEquals(bitmap.nextAbsentValue(5), 6); + + // Out of range returns -1 + assertEquals(bitmap.nextAbsentValue(-1), -1); + assertEquals(bitmap.nextAbsentValue(0x100000000L), -1); + } + + @Test + public void testDrainTo() { + LongBitmap bitmap = LongBitmaps.create(); + for (int i = 0; i < 100; i++) { + bitmap.add(i * 10); // 0, 10, 20, ..., 990 + } + + List drained = new ArrayList<>(); + long count = bitmap.drainTo(5, drained::add); + + assertEquals(count, 5); + assertEquals(drained.size(), 5); + assertEquals(drained.get(0).longValue(), 0); + assertEquals(drained.get(4).longValue(), 40); + assertEquals(bitmap.cardinality(), 95); + assertFalse(bitmap.contains(0)); + assertFalse(bitmap.contains(40)); + assertTrue(bitmap.contains(50)); + + drained.clear(); + count = bitmap.drainTo(1000, drained::add); // drain all remaining + assertEquals(count, 95); + assertTrue(bitmap.isEmpty()); + } + + @Test + public void testDrainToZeroOrNegative() { + LongBitmap bitmap = LongBitmaps.create(); + bitmap.add(1); + bitmap.add(2); + + assertEquals(bitmap.drainTo(0, v -> { + }), 0); + assertEquals(bitmap.cardinality(), 2); + + assertEquals(bitmap.drainTo(-1, v -> { + }), 0); + assertEquals(bitmap.cardinality(), 2); + } + + @Test + public void testEmptyRangeIsNoOp() { + // add(x, x) and remove(x, x) must be no-ops, not throw. + LongBitmap bitmap = LongBitmaps.create(); + bitmap.add(1); + bitmap.add(2); + + bitmap.add(5, 5); + bitmap.add(10, 1); // reversed range + assertEquals(bitmap.cardinality(), 2); + + bitmap.remove(5, 5); + bitmap.remove(10, 1); + assertEquals(bitmap.cardinality(), 2); + } + + @Test + public void testDrainToFollowedByDrainToDoesNotLeak() { + // drainTo must respect the same trim threshold as remove(value). + // Add many values, drain in small batches; if trim never fires, the bitmap + // accumulates unused container capacity across calls. + LongBitmap bitmap = LongBitmaps.create(); + for (int i = 0; i < 50000; i++) { + bitmap.add(i); + } + + // Drain 100 at a time — well under TRIM_AFTER_REMOVES (10000) per call, + // but cumulative trim counter should cross the threshold after several batches. + long totalDrained = 0; + while (!bitmap.isEmpty()) { + totalDrained += bitmap.drainTo(100, v -> { + }); + } + assertEquals(totalDrained, 50000); + assertTrue(bitmap.isEmpty()); + } + + @Test + public void testOrDoesNotMutateInput() { + // A.or(B) must treat B as read-only — required for our lock split + // (this=writeLock, other=readLock). + LongBitmap a = LongBitmaps.create(); + LongBitmap b = LongBitmaps.create(); + for (int i = 0; i < 1000; i++) { + a.add(i * 2); + b.add(i * 2 + 1); + } + long bCardinalityBefore = b.cardinality(); + + a.or(b); + + assertEquals(b.cardinality(), bCardinalityBefore); + for (int i = 0; i < 1000; i++) { + assertTrue(b.contains(i * 2 + 1), "b lost value after a.or(b)"); + assertFalse(b.contains(i * 2), "b gained value after a.or(b)"); + } + assertEquals(a.cardinality(), 2000); + } + + @Test + public void testOrCrossDirectionNoDeadlock() throws Exception { + // Concurrent A.or(B) and B.or(A) must not deadlock. + // Lock ordering by identityHashCode prevents the classic AB-BA deadlock. + int pairs = 10; + ExecutorService executor = Executors.newFixedThreadPool(4); + CountDownLatch latch = new CountDownLatch(pairs * 2); + AtomicInteger errors = new AtomicInteger(0); + + for (int i = 0; i < pairs; i++) { + final LongBitmap a = LongBitmaps.create(); + final LongBitmap b = LongBitmaps.create(); + for (int j = 0; j < 100; j++) { + a.add(j); + b.add(j + 50); // overlap + } + executor.submit(() -> { + try { + a.or(b); + } catch (Exception e) { + errors.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + executor.submit(() -> { + try { + b.or(a); + } catch (Exception e) { + errors.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + + assertTrue(latch.await(30, TimeUnit.SECONDS), + "or() cross-direction should not deadlock"); + executor.shutdown(); + assertEquals(errors.get(), 0); + } + + @Test + public void testDrainToActionDoesNotHoldWriteLock() { + // Verify that the action runs outside the lock: a slow action should not block + // concurrent operations. The select+remove phase holds writeLock briefly, but + // the action invocation happens after unlock. + LongBitmap bitmap = LongBitmaps.create(); + for (int i = 0; i < 1000; i++) { + bitmap.add(i); + } + + // Slow action: sleep briefly per value. drainTo should not hold any lock + // during action execution — concurrent operations should remain responsive. + long start = System.nanoTime(); + long drained = bitmap.drainTo(100, v -> { + try { + Thread.sleep(1); + } catch (InterruptedException e) { + } + }); + long elapsed = System.nanoTime() - start; + assertEquals(drained, 100); + // Total elapsed ~100ms (sleep); the lock was released before action ran. + assertTrue(elapsed >= 100_000_000L); // sanity: drainTo ran the action 100 times + } + + @Test + public void testUint32BoundaryRange() { + // Verify range APIs handle the uint32 upper boundary correctly. + // add(MAX_UINT32, MAX_UINT32+1) should add exactly one value: MAX_UINT32. + // Note: MutableRoaringBitmap.contains(long, long) has a known issue at this + // boundary where it returns false even when the value is present, so we only + // test contains(long) single-value form and cardinality. + LongBitmap bitmap = LongBitmaps.create(); + bitmap.add(0xFFFFFFFFL, 0x100000000L); + assertEquals(bitmap.cardinality(), 1); + assertTrue(bitmap.contains(0xFFFFFFFFL)); + + bitmap.remove(0xFFFFFFFFL, 0x100000000L); + assertEquals(bitmap.cardinality(), 0); + assertFalse(bitmap.contains(0xFFFFFFFFL)); + } + + @Test + public void testRangeReachesMaxUint32WithoutClamp() { + // Validates that add/remove do not need Math.min(to, UINT32_SIZE): + // validateRange(to - 1) already guarantees to <= UINT32_SIZE, and RoaringBitmap + // handles to == UINT32_SIZE correctly at the boundary. If this test ever fails, + // the clamp must be restored. + LongBitmap bitmap = LongBitmaps.create(); + + // Multi-value range that ends exactly at UINT32_SIZE: [MAX-1, MAX+1) = {MAX-1, MAX} + bitmap.add(0xFFFFFFFEL, 0x100000000L); + assertEquals(bitmap.cardinality(), 2); + assertTrue(bitmap.contains(0xFFFFFFFEL)); + assertTrue(bitmap.contains(0xFFFFFFFFL)); + + // Same range on remove + bitmap.remove(0xFFFFFFFEL, 0x100000000L); + assertEquals(bitmap.cardinality(), 0); + assertFalse(bitmap.contains(0xFFFFFFFEL)); + assertFalse(bitmap.contains(0xFFFFFFFFL)); + + // Crossing container boundary (65535/65536) with to on a power-of-2 boundary + bitmap.add(65530L, 65540L); + assertEquals(bitmap.cardinality(), 10); + bitmap.remove(65530L, 65540L); + assertEquals(bitmap.cardinality(), 0); + + // Out-of-range `to` must throw — proving validateRange guards the upper bound. + assertThrows(IllegalArgumentException.class, + () -> bitmap.add(0L, 0x100000001L)); // to = UINT32_SIZE + 1 + } + + @Test + public void testSerializedSizeIsUpperBoundForSerialize() { + // serializedSize() is an upper bound (no runOptimize). serialize() runs + // runOptimize, which only shrinks. So estimated >= actual. + long[] seeds = {0, 100, 65535, 65536, 1L << 30, 0xFFFFFF00L}; + for (int trial = 0; trial < 5; trial++) { + LongBitmap bitmap = LongBitmaps.create(); + java.util.Random rng = new java.util.Random(trial); + int count = 1000 + rng.nextInt(5000); + for (int i = 0; i < count; i++) { + bitmap.add(seeds[rng.nextInt(seeds.length)] + rng.nextInt(100)); + } + long estimated = bitmap.serializedSize(); + byte[] actual = bitmap.serialize(); + assertTrue(estimated >= actual.length, + "trial " + trial + ": estimated " + estimated + " < actual " + actual.length); + } + } + + @Test + public void testDrainToIsAtomic() { + // drainTo must atomically select+remove values. Concurrent add/remove should + // not cause newly added values to be lost between snapshot and removal. + LongBitmap bitmap = LongBitmaps.create(); + for (int i = 0; i < 100; i++) { + bitmap.add(i); + } + + // Drain with a slow action, while concurrently adding/removing values + AtomicInteger errors = new AtomicInteger(0); + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch latch = new CountDownLatch(2); + + // T1: drain slowly + executor.submit(() -> { + try { + bitmap.drainTo(50, v -> { + try { + Thread.sleep(1); + } catch (InterruptedException e) { + } + }); + } catch (Exception e) { + errors.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + + // T2: add/remove while T1 drains + executor.submit(() -> { + try { + Thread.sleep(10); // let T1 start draining + for (int i = 0; i < 50; i++) { + bitmap.remove(i); + bitmap.add(i); + } + } catch (Exception e) { + errors.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + + try { + assertTrue(latch.await(30, TimeUnit.SECONDS)); + executor.shutdown(); + assertEquals(errors.get(), 0); + // After drain(50) and concurrent add/remove, the bitmap should contain + // the values that were re-added by T2, not lost due to racy andNot. + long finalCount = bitmap.cardinality(); + // We drained ~50, then T2 added back some of those. Final count >= 50 + // (the un-drained values) is the key invariant. + assertTrue(finalCount >= 50, + "finalCount=" + finalCount + " should be >= 50 (un-drained values)"); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + @Test + public void testDeserializeFromLongArrayFactory() { + LongBitmap bitmap = LongBitmaps.create(); + bitmap.add(1); + bitmap.add(100); + bitmap.add(1000); + bitmap.add(Integer.MAX_VALUE); + + long[] data = bitmap.serializeToLongArray(); + + LongBitmap restored = LongBitmaps.deserializeFromLongArray(data); + + assertEquals(restored.cardinality(), 4); + assertTrue(restored.contains(1)); + assertTrue(restored.contains(100)); + assertTrue(restored.contains(1000)); + assertTrue(restored.contains(Integer.MAX_VALUE)); + } + + @Test + public void testLastPresentValueWithMaxUint32() { + LongBitmap bitmap = LongBitmaps.create(); + bitmap.add(0xFFFFFFFFL); + + assertEquals(bitmap.lastPresentValue(), 0xFFFFFFFFL); + + bitmap.add(Integer.MAX_VALUE); + assertEquals(bitmap.lastPresentValue(), 0xFFFFFFFFL); + + bitmap.clear(); + assertEquals(bitmap.lastPresentValue(), -1); + } + + @Test + public void testNavigationWithUint32Boundary() { + LongBitmap bitmap = LongBitmaps.create(); + bitmap.add(Integer.MAX_VALUE); + bitmap.add(Integer.MAX_VALUE + 1L); + bitmap.add(0xFFFFFFFFL); + + assertEquals(bitmap.nextPresentValue(Integer.MAX_VALUE), Integer.MAX_VALUE); + assertEquals(bitmap.nextPresentValue(Integer.MAX_VALUE + 1L), Integer.MAX_VALUE + 1L); + assertEquals(bitmap.nextPresentValue(0xFFFFFFFEL), 0xFFFFFFFFL); + + assertEquals(bitmap.nextAbsentValue(0xFFFFFFFFL), -1); + + assertEquals(bitmap.nextPresentValue(0xFFFFFFFFL), 0xFFFFFFFFL); + assertEquals(bitmap.nextPresentValue(0xFFFFFFFFL + 1L), -1); + } + + @Test + public void testRank() { + LongBitmap bitmap = LongBitmaps.create(); + bitmap.add(10); + bitmap.add(20); + bitmap.add(30); + bitmap.add(40); + bitmap.add(50); + + assertEquals(bitmap.rank(0), 0); + assertEquals(bitmap.rank(10), 0); + assertEquals(bitmap.rank(11), 1); + assertEquals(bitmap.rank(20), 1); + assertEquals(bitmap.rank(25), 2); + assertEquals(bitmap.rank(50), 4); + assertEquals(bitmap.rank(51), 5); + assertEquals(bitmap.rank(100), 5); + + bitmap.add(0xFFFFFFFFL); + + assertEquals(bitmap.rank(0xFFFFFFFFL), 5); + assertEquals(bitmap.rank(0x100000000L), 6); + } + + @Test + public void testPreviousAbsentValue() { + LongBitmap bitmap = LongBitmaps.create(); + bitmap.add(5); + bitmap.add(10); + bitmap.add(15); + bitmap.add(20); + + assertEquals(bitmap.previousAbsentValue(25), 25); + assertEquals(bitmap.previousAbsentValue(21), 21); + assertEquals(bitmap.previousAbsentValue(20), 19); + assertEquals(bitmap.previousAbsentValue(16), 16); + assertEquals(bitmap.previousAbsentValue(15), 14); + assertEquals(bitmap.previousAbsentValue(6), 6); + assertEquals(bitmap.previousAbsentValue(5), 4); + assertEquals(bitmap.previousAbsentValue(2), 2); + + bitmap.clear(); + bitmap.add(0xFFFFFFFFL); + + assertEquals(bitmap.previousAbsentValue(0xFFFFFFFFL), 0xFFFFFFFEL); + assertEquals(bitmap.previousAbsentValue(0x100000000L), 0xFFFFFFFEL); + } + + @Test + public void testNextPresentValueAtMaxUint32() { + LongBitmap bitmap = LongBitmaps.create(); + + bitmap.add(0xFFFFFFFFL); + + assertEquals(bitmap.nextPresentValue(0), 0xFFFFFFFFL); + assertEquals(bitmap.nextPresentValue(0xFFFFFFFFL), 0xFFFFFFFFL); + assertEquals(bitmap.nextPresentValue(0x100000000L), -1); + } +} \ No newline at end of file diff --git a/pulsar-common/src/test/java/org/apache/pulsar/common/util/collections/SegmentedLongArrayTest.java b/pulsar-common/src/test/java/org/apache/pulsar/common/util/collections/SegmentedLongArrayTest.java index f6c216c439c21..ac41dd0be79e1 100644 --- a/pulsar-common/src/test/java/org/apache/pulsar/common/util/collections/SegmentedLongArrayTest.java +++ b/pulsar-common/src/test/java/org/apache/pulsar/common/util/collections/SegmentedLongArrayTest.java @@ -19,12 +19,16 @@ package org.apache.pulsar.common.util.collections; import static org.testng.Assert.assertEquals; -import static org.testng.Assert.fail; +import static org.testng.Assert.assertThrows; +import static org.testng.Assert.assertTrue; import lombok.Cleanup; import org.testng.annotations.Test; public class SegmentedLongArrayTest { + /** Small segment size so tests can exercise many-segment behavior without 16 MiB allocations. */ + private static final int TEST_SEGMENT_SIZE = 1024; + @Test public void testArray() { @Cleanup @@ -38,15 +42,9 @@ public void testArray() { a.writeLong(2, 2); a.writeLong(3, Long.MAX_VALUE); - try { - a.writeLong(4, Long.MIN_VALUE); - fail("should have failed"); - } catch (IndexOutOfBoundsException e) { - // Expected - } + assertThrows(IndexOutOfBoundsException.class, () -> a.writeLong(4, Long.MIN_VALUE)); a.increaseCapacity(); - a.writeLong(4, Long.MIN_VALUE); assertEquals(a.getCapacity(), 8); @@ -67,10 +65,10 @@ public void testArray() { @Test public void testLargeArray() { - long initialCap = 3 * 1024 * 1024; + long initialCap = TEST_SEGMENT_SIZE + TEST_SEGMENT_SIZE / 2; @Cleanup - SegmentedLongArray a = new SegmentedLongArray(initialCap); + SegmentedLongArray a = new SegmentedLongArray(initialCap, TEST_SEGMENT_SIZE); assertEquals(a.getCapacity(), initialCap); assertEquals(a.bytesCapacity(), initialCap * 8); assertEquals(a.getInitialCapacity(), initialCap); @@ -85,8 +83,9 @@ public void testLargeArray() { a.increaseCapacity(); - assertEquals(a.getCapacity(), 5 * 1024 * 1024); - assertEquals(a.bytesCapacity(), 5 * 1024 * 1024 * 8); + long expectedCap = initialCap + TEST_SEGMENT_SIZE; + assertEquals(a.getCapacity(), expectedCap); + assertEquals(a.bytesCapacity(), expectedCap * 8); assertEquals(a.getInitialCapacity(), initialCap); assertEquals(a.readLong(baseOffset), 0); @@ -100,4 +99,339 @@ public void testLargeArray() { assertEquals(a.bytesCapacity(), initialCap * 8); assertEquals(a.getInitialCapacity(), initialCap); } + + @Test + public void testIncreaseCapacityGrowthPattern() { + @Cleanup + SegmentedLongArray a = new SegmentedLongArray(4); + + a.increaseCapacity(); + assertEquals(a.getCapacity(), 8); + a.increaseCapacity(); + assertEquals(a.getCapacity(), 16); + a.increaseCapacity(); + assertEquals(a.getCapacity(), 32); + } + + @Test + public void testIncreaseCapacityReachesSegmentBoundary() { + long start = TEST_SEGMENT_SIZE - 100; + @Cleanup + SegmentedLongArray a = new SegmentedLongArray(start, TEST_SEGMENT_SIZE); + assertEquals(a.getCapacity(), start); + + a.increaseCapacity(); + assertEquals(a.getCapacity(), TEST_SEGMENT_SIZE); + + a.increaseCapacity(); + assertEquals(a.getCapacity(), TEST_SEGMENT_SIZE * 2L); + } + + @Test + public void testMultiSegmentIncreaseCapacity() { + long initialCap = TEST_SEGMENT_SIZE * 3; + @Cleanup + SegmentedLongArray a = new SegmentedLongArray(initialCap, TEST_SEGMENT_SIZE); + + for (int i = 0; i < 20; i++) { + a.increaseCapacity(); + } + + long expectedCap = TEST_SEGMENT_SIZE * 23L; + assertEquals(a.getCapacity(), expectedCap); + + for (int i = 0; i < 23; i++) { + long offset = (long) i * TEST_SEGMENT_SIZE + 42; + a.writeLong(offset, i); + assertEquals(a.readLong(offset), i); + } + } + + @Test + public void testShrinkDropsWholeSegments() { + long segSize = TEST_SEGMENT_SIZE; + @Cleanup + SegmentedLongArray a = new SegmentedLongArray(segSize, TEST_SEGMENT_SIZE); + for (int i = 0; i < 4; i++) { + a.increaseCapacity(); + } + assertEquals(a.getCapacity(), segSize * 5); + + for (int i = 0; i < 5; i++) { + a.writeLong((long) i * segSize, 100L + i); + } + + a.shrink(segSize * 3); + assertEquals(a.getCapacity(), segSize * 3); + + for (int i = 0; i < 3; i++) { + assertEquals(a.readLong((long) i * segSize), 100L + i); + } + } + + @Test + public void testShrinkToInitialCapacity() { + @Cleanup + SegmentedLongArray a = new SegmentedLongArray(4); + a.increaseCapacity(); + a.increaseCapacity(); + + a.shrink(4); + assertEquals(a.getCapacity(), 4); + assertEquals(a.getInitialCapacity(), 4); + } + + @Test + public void testShrinkBelowInitialFails() { + @Cleanup + SegmentedLongArray a = new SegmentedLongArray(100); + a.shrink(50); + assertEquals(a.getCapacity(), 100); + } + + @Test + public void testSegmentBoundaryReadWrite() { + long segSize = TEST_SEGMENT_SIZE; + @Cleanup + SegmentedLongArray a = new SegmentedLongArray(segSize * 2, TEST_SEGMENT_SIZE); + + a.writeLong(segSize - 1, 111L); + a.writeLong(segSize, 222L); + + assertEquals(a.readLong(segSize - 1), 111L); + assertEquals(a.readLong(segSize), 222L); + } + + @Test + public void testRoundTripAllValues() { + @Cleanup + SegmentedLongArray a = new SegmentedLongArray(1000); + + long[] testValues = {0, 1, -1, Long.MAX_VALUE, Long.MIN_VALUE, + 255L, 256L, 65535L, 65536L, + Integer.MAX_VALUE, Integer.MIN_VALUE}; + + for (int i = 0; i < testValues.length; i++) { + a.writeLong(i, testValues[i]); + } + for (int i = 0; i < testValues.length; i++) { + assertEquals(a.readLong(i), testValues[i]); + } + } + + @Test + public void testCloseReleasesMemory() { + SegmentedLongArray a = new SegmentedLongArray(100); + a.close(); + assertThrows(NullPointerException.class, () -> a.readLong(0)); + } + + @Test + public void testZeroCapacityRejected() { + assertThrows(IllegalArgumentException.class, () -> { + @Cleanup + SegmentedLongArray ignored = new SegmentedLongArray(0); + }); + } + + @Test + public void testNegativeCapacityRejected() { + assertThrows(IllegalArgumentException.class, () -> { + @Cleanup + SegmentedLongArray ignored = new SegmentedLongArray(-1); + }); + } + + @Test + public void testGrowAfterShrink() { + long segSize = TEST_SEGMENT_SIZE; + @Cleanup + SegmentedLongArray a = new SegmentedLongArray(segSize, TEST_SEGMENT_SIZE); + a.increaseCapacity(); + a.increaseCapacity(); + a.shrink(segSize); + assertEquals(a.getCapacity(), segSize); + + a.writeLong(0, 42L); + + a.increaseCapacity(); + a.writeLong(segSize, 99L); + + assertEquals(a.readLong(0), 42L); + assertEquals(a.readLong(segSize), 99L); + } + + @Test + public void testShrinkNoOpWhenEqual() { + @Cleanup + SegmentedLongArray a = new SegmentedLongArray(100); + a.increaseCapacity(); // 200 + a.shrink(200); // newCapacity == capacity, no-op + assertEquals(a.getCapacity(), 200); + } + + @Test + public void testShrinkNoOpWhenExceeds() { + @Cleanup + SegmentedLongArray a = new SegmentedLongArray(100); + a.increaseCapacity(); // 200 + a.shrink(300); // newCapacity > capacity, no-op + assertEquals(a.getCapacity(), 200); + } + + @Test + public void testNegativeOffset() { + @Cleanup + SegmentedLongArray a = new SegmentedLongArray(10); + assertThrows(IndexOutOfBoundsException.class, () -> a.readLong(-1)); + } + + @Test + public void testSmallInitialCapacityDoesNotAllocateFullSegment() { + @Cleanup + SegmentedLongArray a = new SegmentedLongArray(48, TEST_SEGMENT_SIZE); + assertEquals(a.getCapacity(), 48); + assertEquals(a.bytesCapacity(), 48 * 8); + assertTrue(a.bytesCapacity() < TEST_SEGMENT_SIZE * 8); + } + + @Test + public void testPartialLastSegmentMapping() { + long seg = TEST_SEGMENT_SIZE; + long cap = seg + 1000; + @Cleanup + SegmentedLongArray a = new SegmentedLongArray(cap, TEST_SEGMENT_SIZE); + assertEquals(a.getCapacity(), cap); + assertEquals(a.bytesCapacity(), cap * 8); + a.writeLong(0, 1L); + a.writeLong(seg - 1, 2L); + a.writeLong(seg, 3L); + a.writeLong(cap - 1, 4L); + assertEquals(a.readLong(0), 1L); + assertEquals(a.readLong(seg - 1), 2L); + assertEquals(a.readLong(seg), 3L); + assertEquals(a.readLong(cap - 1), 4L); + } + + @Test + public void testIncreaseCapacityPromotesPartialLastToFull() { + long seg = TEST_SEGMENT_SIZE; + @Cleanup + SegmentedLongArray a = new SegmentedLongArray(seg * 2 + seg / 2, TEST_SEGMENT_SIZE); + long partialBase = seg * 2; + a.writeLong(partialBase, 99L); + a.writeLong(partialBase + seg / 2 - 1, 100L); + long capacityBefore = a.getCapacity(); + a.increaseCapacity(); + assertTrue(a.getCapacity() > capacityBefore); + assertEquals(a.readLong(partialBase), 99L); + assertEquals(a.readLong(partialBase + seg / 2 - 1), 100L); + assertEquals(a.bytesCapacity(), a.getCapacity() * 8); + } + + @Test + public void testBytesCapacityTracksPhysicalThroughGrowAndShrink() { + @Cleanup + SegmentedLongArray a = new SegmentedLongArray(1000); + assertEquals(a.getCapacity(), 1000L); + assertEquals(a.bytesCapacity(), 8000L); + for (int i = 0; i < 5; i++) { + a.increaseCapacity(); + assertEquals(a.bytesCapacity(), a.getCapacity() * 8); + } + a.shrink(2000); + assertEquals(a.bytesCapacity(), a.getCapacity() * 8); + } + + @Test + public void testOffsetMappingAndCapacityAcrossOperations() { + long seg = TEST_SEGMENT_SIZE; + @Cleanup + SegmentedLongArray a = new SegmentedLongArray(50, TEST_SEGMENT_SIZE); + assertEquals(a.bytesCapacity(), a.getCapacity() * 8); + + for (int i = 0; i < 5; i++) { + a.increaseCapacity(); + assertEquals(a.bytesCapacity(), a.getCapacity() * 8); + } + a.ensureCapacity(seg * 3 + 1000); + assertEquals(a.bytesCapacity(), a.getCapacity() * 8); + a.writeLong(0, 1L); + a.writeLong(seg - 1, 2L); + a.writeLong(seg, 3L); + a.writeLong(a.getCapacity() - 1, 4L); + assertEquals(a.readLong(0), 1L); + assertEquals(a.readLong(seg - 1), 2L); + assertEquals(a.readLong(seg), 3L); + assertEquals(a.readLong(a.getCapacity() - 1), 4L); + + a.shrink(seg * 2 + 500); + assertEquals(a.bytesCapacity(), a.getCapacity() * 8); + a.shrink(seg); + assertEquals(a.bytesCapacity(), a.getCapacity() * 8); + a.ensureCapacity(seg * 2 + 100); + assertEquals(a.bytesCapacity(), a.getCapacity() * 8); + a.writeLong(seg, 7L); + assertEquals(a.readLong(seg), 7L); + } + + @Test + public void testAllocatedBytesDeltaAtEveryMutationSite() { + long seg = TEST_SEGMENT_SIZE; + @Cleanup + SegmentedLongArray a = new SegmentedLongArray(100, TEST_SEGMENT_SIZE); + assertEquals(a.bytesCapacity(), a.getCapacity() * 8); + + for (int i = 0; i < 4; i++) { + a.increaseCapacity(); + assertEquals(a.bytesCapacity(), a.getCapacity() * 8); + } + a.ensureCapacity(seg * 3 + 1000); + assertEquals(a.bytesCapacity(), a.getCapacity() * 8); + a.shrink(seg + 500); + assertEquals(a.bytesCapacity(), a.getCapacity() * 8); + a.shrink(seg / 2); + assertEquals(a.bytesCapacity(), a.getCapacity() * 8); + a.ensureCapacity(seg * 2 + 100); + assertEquals(a.bytesCapacity(), a.getCapacity() * 8); + } + + @Test + public void testShrinkTrimsOverallocatedContainer() { + long seg = TEST_SEGMENT_SIZE; + @Cleanup + SegmentedLongArray a = new SegmentedLongArray(50, TEST_SEGMENT_SIZE); + a.ensureCapacity(seg * 30); + assertTrue(a.getCapacity() >= seg * 30); + a.shrink(seg / 2); + assertEquals(a.bytesCapacity(), a.getCapacity() * 8); + a.writeLong(0, 1L); + a.writeLong(a.getCapacity() - 1, 2L); + assertEquals(a.readLong(0), 1L); + assertEquals(a.readLong(a.getCapacity() - 1), 2L); + } + + @Test + public void testCustomSegmentSizeRejectsNonPowerOfTwo() { + assertThrows(IllegalArgumentException.class, () -> { + @Cleanup + SegmentedLongArray ignored = new SegmentedLongArray(100, 1000); + }); + } + + @Test + public void testCustomSegmentSizeRejectsZero() { + assertThrows(IllegalArgumentException.class, () -> { + @Cleanup + SegmentedLongArray ignored = new SegmentedLongArray(100, 0); + }); + } + + @Test + public void testCustomSegmentSizeRejectsNegative() { + assertThrows(IllegalArgumentException.class, () -> { + @Cleanup + SegmentedLongArray ignored = new SegmentedLongArray(100, -1024); + }); + } } diff --git a/pulsar-common/src/test/java/org/apache/pulsar/common/util/collections/TripleLongPriorityQueueTest.java b/pulsar-common/src/test/java/org/apache/pulsar/common/util/collections/TripleLongPriorityQueueTest.java index d3fcc192b573e..737e720894d77 100644 --- a/pulsar-common/src/test/java/org/apache/pulsar/common/util/collections/TripleLongPriorityQueueTest.java +++ b/pulsar-common/src/test/java/org/apache/pulsar/common/util/collections/TripleLongPriorityQueueTest.java @@ -20,8 +20,12 @@ import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; import static org.testng.Assert.assertTrue; import static org.testng.Assert.fail; +import java.util.Comparator; +import java.util.PriorityQueue; +import java.util.Random; import org.testng.annotations.Test; public class TripleLongPriorityQueueTest { @@ -196,4 +200,49 @@ private void triggerScaleOut(int initialCapacity, TripleLongPriorityQueue pq) { pq.add(i, i, i); } } + + @Test + public void testDifferentialRandomPriorityQueue() { + Comparator cmp = Comparator.comparingLong((long[] t) -> t[0]) + .thenComparingLong(t -> t[1]) + .thenComparingLong(t -> t[2]); + + for (int trial = 0; trial < 10; trial++) { + Random rng = new Random(42 + trial); + PriorityQueue oracle = new PriorityQueue<>(cmp); + try (TripleLongPriorityQueue pq = new TripleLongPriorityQueue()) { + int ops = 10_000 + rng.nextInt(10_000); + for (int i = 0; i < ops; i++) { + boolean doAdd = oracle.isEmpty() || rng.nextBoolean(); + if (doAdd) { + // ~10% chance of same-prefix (small n1 range) to exercise tie-breaking + // note: use nextInt instead of RandomGenerator.nextLong(long) because + // this module is compiled with the client compiler release level + long n1 = rng.nextInt(100) < 10 ? rng.nextInt(20) : rng.nextInt(1_000_000); + long n2 = rng.nextInt(100); + long n3 = rng.nextInt(1_000_000); + oracle.add(new long[]{n1, n2, n3}); + pq.add(n1, n2, n3); + } else { + long[] expected = oracle.poll(); + assertNotNull(expected); + assertEquals(pq.peekN1(), expected[0], "n1 mismatch at op " + i); + assertEquals(pq.peekN2(), expected[1], "n2 mismatch at op " + i); + assertEquals(pq.peekN3(), expected[2], "n3 mismatch at op " + i); + pq.pop(); + } + assertEquals(pq.size(), oracle.size(), "size mismatch at op " + i); + } + // drain remaining + while (!oracle.isEmpty()) { + long[] expected = oracle.poll(); + assertEquals(pq.peekN1(), expected[0]); + assertEquals(pq.peekN2(), expected[1]); + assertEquals(pq.peekN3(), expected[2]); + pq.pop(); + } + assertTrue(pq.isEmpty()); + } + } + } } diff --git a/pulsar-config-validation/pom.xml b/pulsar-config-validation/pom.xml index 0c211b3d24845..44656f646186a 100644 --- a/pulsar-config-validation/pom.xml +++ b/pulsar-config-validation/pom.xml @@ -26,7 +26,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-config-validation diff --git a/pulsar-docs-tools/pom.xml b/pulsar-docs-tools/pom.xml index da03777628dcd..ddd311417698c 100644 --- a/pulsar-docs-tools/pom.xml +++ b/pulsar-docs-tools/pom.xml @@ -27,7 +27,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-docs-tools diff --git a/pulsar-function-go/examples/go.mod b/pulsar-function-go/examples/go.mod index 23b89e8e1df9d..6e80ff92e5d64 100644 --- a/pulsar-function-go/examples/go.mod +++ b/pulsar-function-go/examples/go.mod @@ -1,38 +1,38 @@ module github.com/apache/pulsar/pulsar-function-go/examples -go 1.24.0 +go 1.25.0 require ( - github.com/apache/pulsar-client-go v0.14.0 + github.com/apache/pulsar-client-go v0.20.0 github.com/apache/pulsar/pulsar-function-go v0.0.0 ) require ( - github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect - github.com/99designs/keyring v1.2.1 // indirect - github.com/AthenZ/athenz v1.10.39 // indirect + github.com/AthenZ/athenz v1.12.13 // indirect github.com/DataDog/zstd v1.5.0 // indirect + github.com/RoaringBitmap/roaring/v2 v2.8.0 // indirect github.com/ardielle/ardielle-go v1.5.2 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/bits-and-blooms/bitset v1.4.0 // indirect + github.com/bits-and-blooms/bitset v1.12.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/danieljoos/wincred v1.1.2 // indirect - github.com/dvsekhvalnov/jose2go v1.7.0 // indirect - github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect - github.com/hamba/avro/v2 v2.22.2-0.20240625062549-66aad10411d9 // indirect - github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/hamba/avro/v2 v2.29.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.17.9 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/mtibben/percent v0.2.1 // indirect + github.com/mschoch/smat v0.2.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/pierrec/lz4 v2.0.5+incompatible // indirect + github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_golang v1.20.5 // indirect github.com/prometheus/client_model v0.6.1 // indirect @@ -40,17 +40,26 @@ require ( github.com/prometheus/procfs v0.15.1 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect - go.uber.org/atomic v1.7.0 // indirect - golang.org/x/mod v0.30.0 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/term v0.38.0 // indirect - golang.org/x/text v0.32.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect - google.golang.org/grpc v1.79.3 // indirect - google.golang.org/protobuf v1.36.10 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/grpc v1.82.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apimachinery v0.32.3 // indirect + k8s.io/client-go v0.32.3 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e // indirect + sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect ) replace github.com/apache/pulsar/pulsar-function-go => ../ diff --git a/pulsar-function-go/examples/go.sum b/pulsar-function-go/examples/go.sum index 0ccabb4edff78..bbecdf3040d27 100644 --- a/pulsar-function-go/examples/go.sum +++ b/pulsar-function-go/examples/go.sum @@ -1,107 +1,92 @@ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= -github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= -github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= -github.com/99designs/keyring v1.2.1 h1:tYLp1ULvO7i3fI5vE21ReQuj99QFSs7lGm0xWyJo87o= -github.com/99designs/keyring v1.2.1/go.mod h1:fc+wB5KTk9wQ9sDx0kFXB3A0MaeGHM9AwRStKOQ5vOA= -github.com/AthenZ/athenz v1.10.39 h1:mtwHTF/v62ewY2Z5KWhuZgVXftBej1/Tn80zx4DcawY= -github.com/AthenZ/athenz v1.10.39/go.mod h1:3Tg8HLsiQZp81BJY58JBeU2BR6B/H4/0MQGfCwhHNEA= +github.com/AthenZ/athenz v1.12.13 h1:OhZNqZsoBXNrKBJobeUUEirPDnwt0HRo4kQMIO1UwwQ= +github.com/AthenZ/athenz v1.12.13/go.mod h1:XXDXXgaQzXaBXnJX6x/bH4yF6eon2lkyzQZ0z/dxprE= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/DataDog/zstd v1.5.0 h1:+K/VEwIAaPcHiMtQvpLD4lqW7f0Gk3xdYZmI1hD+CXo= github.com/DataDog/zstd v1.5.0/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/Microsoft/hcsshim v0.11.5 h1:haEcLNpj9Ka1gd3B3tAEs9CpE0c+1IhoL59w/exYU38= -github.com/Microsoft/hcsshim v0.11.5/go.mod h1:MV8xMfmECjl5HdO7U/3/hFVnkmSBjAjmA09d4bExKcU= -github.com/apache/pulsar-client-go v0.14.0 h1:P7yfAQhQ52OCAu8yVmtdbNQ81vV8bF54S2MLmCPJC9w= -github.com/apache/pulsar-client-go v0.14.0/go.mod h1:PNUE29x9G1EHMvm41Bs2vcqwgv7N8AEjeej+nEVYbX8= +github.com/RoaringBitmap/roaring/v2 v2.8.0 h1:y1rdtixfXvaITKzkfiKvScI0hlBJHe9sfzJp8cgeM7w= +github.com/RoaringBitmap/roaring/v2 v2.8.0/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0= +github.com/apache/pulsar-client-go v0.20.0 h1:r/jacV7Bf7SR2LiIC3d43vHlPOBUi2vw2yUadot7rV0= +github.com/apache/pulsar-client-go v0.20.0/go.mod h1:/Zf8Q8bSSc6ndEJ8V1muIHf6ZWsMrHoQU+98Ww9pOeI= github.com/ardielle/ardielle-go v1.5.2 h1:TilHTpHIQJ27R1Tl/iITBzMwiUGSlVfiVhwDNGM3Zj4= github.com/ardielle/ardielle-go v1.5.2/go.mod h1:I4hy1n795cUhaVt/ojz83SNVCYIGsAFAONtv2Dr7HUI= -github.com/ardielle/ardielle-tools v1.5.4/go.mod h1:oZN+JRMnqGiIhrzkRN9l26Cej9dEx4jeNG6A+AdkShk= -github.com/aws/aws-sdk-go v1.32.6/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bits-and-blooms/bitset v1.4.0 h1:+YZ8ePm+He2pU3dZlIZiOeAKfrBkXi1lSrXJ/Xzgbu8= -github.com/bits-and-blooms/bitset v1.4.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA= +github.com/bits-and-blooms/bitset v1.12.0 h1:U/q1fAF7xXRhFCrhROzIfffYnu+dlS38vCZtmFVPHmA= +github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/containerd/containerd v1.7.18 h1:jqjZTQNfXGoEaZdW1WwPU0RqSn1Bm2Ay/KJPUuO8nao= -github.com/containerd/containerd v1.7.18/go.mod h1:IYEk9/IO6wAPUz2bCMVUbsfXjzw5UNP5fLz4PsUygQ4= -github.com/containerd/errdefs v0.1.0 h1:m0wCRBiu1WJT/Fr+iOoQHMQS/eP5myQ8lCv4Dz5ZURM= -github.com/containerd/errdefs v0.1.0/go.mod h1:YgWiiHtLmSeBrvpw+UfPijzbLaB77mEG1WwJTDETIV0= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= -github.com/cpuguy83/dockercfg v0.3.1 h1:/FpZ+JaygUR/lZP2NlFI2DVfrOEMAIKP5wWEJdoYe9E= -github.com/cpuguy83/dockercfg v0.3.1/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= -github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0= -github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dimfeld/httptreemux v5.0.1+incompatible h1:Qj3gVcDNoOthBAqftuD596rm4wg/adLLz5xh5CmpiCA= github.com/dimfeld/httptreemux v5.0.1+incompatible/go.mod h1:rbUlSV+CCpv/SuqUTP/8Bk2O3LyUV436/yaRGkhP6Z0= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY= -github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v28.0.0+incompatible h1:Olh0KS820sJ7nPsBKChVhk5pzqcwDR15fumfAd/p9hM= +github.com/docker/docker v28.0.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/dvsekhvalnov/jose2go v1.7.0 h1:bnQc8+GMnidJZA8zc6lLEAb4xNrIqHwO+9TzqvtQZPo= -github.com/dvsekhvalnov/jose2go v1.7.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= -github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= -github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= -github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= -github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= -github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= -github.com/hamba/avro/v2 v2.22.2-0.20240625062549-66aad10411d9 h1:NEoabXt33PDWK4fXryK4e+XX+fSKDmmu9vg3yb9YI2M= -github.com/hamba/avro/v2 v2.22.2-0.20240625062549-66aad10411d9/go.mod h1:fQVdB2mFZBhPW1D5Abej41LMvrErARGrrdjOnKbm5yw= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= -github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/jawher/mow.cli v1.0.4/go.mod h1:5hQj2V8g+qYmLUVWqu4Wuja1pI57M83EChYLVZ0sMKk= -github.com/jawher/mow.cli v1.2.0/go.mod h1:y+pcA3jBAdo/GIZx/0rFjw/K2bVEODP9rfZOfaiq8Ko= -github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik= +github.com/hamba/avro/v2 v2.29.0 h1:fkqoWEPxfygZxrkktgSHEpd0j/P7RKTBTDbcEeMdVEY= +github.com/hamba/avro/v2 v2.29.0/go.mod h1:Pk3T+x74uJoJOFmHrdJ8PRdgSEL/kEKteJ31NytCKxI= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= -github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -110,16 +95,16 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= -github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg= -github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= +github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= +github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -129,27 +114,27 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= -github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= +github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= +github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= -github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= -github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= +github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= -github.com/pierrec/lz4 v2.0.5+incompatible h1:2xWsjqPFWcplujydGg4WmhC/6fZqK42wMM8aXeqhl0I= -github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= +github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= @@ -170,98 +155,113 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/testcontainers/testcontainers-go v0.32.0 h1:ug1aK08L3gCHdhknlTTwWjPHPS+/alvLJU/DRxTD/ME= -github.com/testcontainers/testcontainers-go v0.32.0/go.mod h1:CRHrzHLQhlXUsa5gXjTOfqIEJcrK5+xMDmBr/WMI88E= +github.com/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo= +github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= -go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= -gopkg.in/square/go-jose.v2 v2.4.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U= +k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= +k8s.io/client-go v0.32.3 h1:RKPVltzopkSgHS7aS98QdscAgtgah/+zmpAogooIqVU= +k8s.io/client-go v0.32.3/go.mod h1:3v0+3k4IcT9bXTc4V2rt+d2ZPPG700Xy6Oi0Gdl2PaY= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e h1:KqK5c/ghOm8xkHYhlodbp6i6+r+ChV2vuAuVRdFbLro= +k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= +sigs.k8s.io/structured-merge-diff/v4 v4.4.2 h1:MdmvkGuXi/8io6ixD5wud3vOLwc1rj0aNqRlpuvjmwA= +sigs.k8s.io/structured-merge-diff/v4 v4.4.2/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/pulsar-function-go/go.mod b/pulsar-function-go/go.mod index 4b981c563bacc..53365d37757e9 100644 --- a/pulsar-function-go/go.mod +++ b/pulsar-function-go/go.mod @@ -1,59 +1,68 @@ module github.com/apache/pulsar/pulsar-function-go -go 1.24.0 +go 1.25.0 require ( - github.com/apache/pulsar-client-go v0.14.0 + github.com/apache/pulsar-client-go v0.20.0 github.com/golang/protobuf v1.5.4 github.com/prometheus/client_golang v1.20.5 github.com/prometheus/client_model v0.6.1 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.10.0 - google.golang.org/grpc v1.79.3 - google.golang.org/protobuf v1.36.10 + google.golang.org/grpc v1.82.1 + google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v2 v2.4.0 ) require ( - github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect - github.com/99designs/keyring v1.2.1 // indirect - github.com/AthenZ/athenz v1.10.39 // indirect + github.com/AthenZ/athenz v1.12.13 // indirect github.com/DataDog/zstd v1.5.0 // indirect + github.com/RoaringBitmap/roaring/v2 v2.8.0 // indirect github.com/ardielle/ardielle-go v1.5.2 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/bits-and-blooms/bitset v1.4.0 // indirect + github.com/bits-and-blooms/bitset v1.12.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/danieljoos/wincred v1.1.2 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/dvsekhvalnov/jose2go v1.7.0 // indirect - github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect - github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect - github.com/hamba/avro/v2 v2.22.2-0.20240625062549-66aad10411d9 // indirect - github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/hamba/avro/v2 v2.29.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.17.9 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/kr/text v0.1.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/mtibben/percent v0.2.1 // indirect + github.com/mschoch/smat v0.2.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/pierrec/lz4 v2.0.5+incompatible // indirect + github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect - go.uber.org/atomic v1.7.0 // indirect - golang.org/x/mod v0.30.0 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/term v0.38.0 // indirect - golang.org/x/text v0.32.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apimachinery v0.32.3 // indirect + k8s.io/client-go v0.32.3 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e // indirect + sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect ) replace github.com/apache/pulsar/pulsar-function-go/pf => ./pf diff --git a/pulsar-function-go/go.sum b/pulsar-function-go/go.sum index 0ccabb4edff78..852a5cdd4becb 100644 --- a/pulsar-function-go/go.sum +++ b/pulsar-function-go/go.sum @@ -1,104 +1,90 @@ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= -github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= -github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= -github.com/99designs/keyring v1.2.1 h1:tYLp1ULvO7i3fI5vE21ReQuj99QFSs7lGm0xWyJo87o= -github.com/99designs/keyring v1.2.1/go.mod h1:fc+wB5KTk9wQ9sDx0kFXB3A0MaeGHM9AwRStKOQ5vOA= -github.com/AthenZ/athenz v1.10.39 h1:mtwHTF/v62ewY2Z5KWhuZgVXftBej1/Tn80zx4DcawY= -github.com/AthenZ/athenz v1.10.39/go.mod h1:3Tg8HLsiQZp81BJY58JBeU2BR6B/H4/0MQGfCwhHNEA= +github.com/AthenZ/athenz v1.12.13 h1:OhZNqZsoBXNrKBJobeUUEirPDnwt0HRo4kQMIO1UwwQ= +github.com/AthenZ/athenz v1.12.13/go.mod h1:XXDXXgaQzXaBXnJX6x/bH4yF6eon2lkyzQZ0z/dxprE= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/DataDog/zstd v1.5.0 h1:+K/VEwIAaPcHiMtQvpLD4lqW7f0Gk3xdYZmI1hD+CXo= github.com/DataDog/zstd v1.5.0/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/Microsoft/hcsshim v0.11.5 h1:haEcLNpj9Ka1gd3B3tAEs9CpE0c+1IhoL59w/exYU38= -github.com/Microsoft/hcsshim v0.11.5/go.mod h1:MV8xMfmECjl5HdO7U/3/hFVnkmSBjAjmA09d4bExKcU= -github.com/apache/pulsar-client-go v0.14.0 h1:P7yfAQhQ52OCAu8yVmtdbNQ81vV8bF54S2MLmCPJC9w= -github.com/apache/pulsar-client-go v0.14.0/go.mod h1:PNUE29x9G1EHMvm41Bs2vcqwgv7N8AEjeej+nEVYbX8= +github.com/RoaringBitmap/roaring/v2 v2.8.0 h1:y1rdtixfXvaITKzkfiKvScI0hlBJHe9sfzJp8cgeM7w= +github.com/RoaringBitmap/roaring/v2 v2.8.0/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0= +github.com/apache/pulsar-client-go v0.20.0 h1:r/jacV7Bf7SR2LiIC3d43vHlPOBUi2vw2yUadot7rV0= +github.com/apache/pulsar-client-go v0.20.0/go.mod h1:/Zf8Q8bSSc6ndEJ8V1muIHf6ZWsMrHoQU+98Ww9pOeI= github.com/ardielle/ardielle-go v1.5.2 h1:TilHTpHIQJ27R1Tl/iITBzMwiUGSlVfiVhwDNGM3Zj4= github.com/ardielle/ardielle-go v1.5.2/go.mod h1:I4hy1n795cUhaVt/ojz83SNVCYIGsAFAONtv2Dr7HUI= -github.com/ardielle/ardielle-tools v1.5.4/go.mod h1:oZN+JRMnqGiIhrzkRN9l26Cej9dEx4jeNG6A+AdkShk= -github.com/aws/aws-sdk-go v1.32.6/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bits-and-blooms/bitset v1.4.0 h1:+YZ8ePm+He2pU3dZlIZiOeAKfrBkXi1lSrXJ/Xzgbu8= -github.com/bits-and-blooms/bitset v1.4.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA= +github.com/bits-and-blooms/bitset v1.12.0 h1:U/q1fAF7xXRhFCrhROzIfffYnu+dlS38vCZtmFVPHmA= +github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/containerd/containerd v1.7.18 h1:jqjZTQNfXGoEaZdW1WwPU0RqSn1Bm2Ay/KJPUuO8nao= -github.com/containerd/containerd v1.7.18/go.mod h1:IYEk9/IO6wAPUz2bCMVUbsfXjzw5UNP5fLz4PsUygQ4= -github.com/containerd/errdefs v0.1.0 h1:m0wCRBiu1WJT/Fr+iOoQHMQS/eP5myQ8lCv4Dz5ZURM= -github.com/containerd/errdefs v0.1.0/go.mod h1:YgWiiHtLmSeBrvpw+UfPijzbLaB77mEG1WwJTDETIV0= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= -github.com/cpuguy83/dockercfg v0.3.1 h1:/FpZ+JaygUR/lZP2NlFI2DVfrOEMAIKP5wWEJdoYe9E= -github.com/cpuguy83/dockercfg v0.3.1/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= -github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0= -github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dimfeld/httptreemux v5.0.1+incompatible h1:Qj3gVcDNoOthBAqftuD596rm4wg/adLLz5xh5CmpiCA= github.com/dimfeld/httptreemux v5.0.1+incompatible/go.mod h1:rbUlSV+CCpv/SuqUTP/8Bk2O3LyUV436/yaRGkhP6Z0= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY= -github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v28.0.0+incompatible h1:Olh0KS820sJ7nPsBKChVhk5pzqcwDR15fumfAd/p9hM= +github.com/docker/docker v28.0.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/dvsekhvalnov/jose2go v1.7.0 h1:bnQc8+GMnidJZA8zc6lLEAb4xNrIqHwO+9TzqvtQZPo= -github.com/dvsekhvalnov/jose2go v1.7.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= -github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= -github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= -github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= -github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= -github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= -github.com/hamba/avro/v2 v2.22.2-0.20240625062549-66aad10411d9 h1:NEoabXt33PDWK4fXryK4e+XX+fSKDmmu9vg3yb9YI2M= -github.com/hamba/avro/v2 v2.22.2-0.20240625062549-66aad10411d9/go.mod h1:fQVdB2mFZBhPW1D5Abej41LMvrErARGrrdjOnKbm5yw= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= -github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/jawher/mow.cli v1.0.4/go.mod h1:5hQj2V8g+qYmLUVWqu4Wuja1pI57M83EChYLVZ0sMKk= -github.com/jawher/mow.cli v1.2.0/go.mod h1:y+pcA3jBAdo/GIZx/0rFjw/K2bVEODP9rfZOfaiq8Ko= -github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik= +github.com/hamba/avro/v2 v2.29.0 h1:fkqoWEPxfygZxrkktgSHEpd0j/P7RKTBTDbcEeMdVEY= +github.com/hamba/avro/v2 v2.29.0/go.mod h1:Pk3T+x74uJoJOFmHrdJ8PRdgSEL/kEKteJ31NytCKxI= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= -github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -110,16 +96,16 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= -github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg= -github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= +github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= +github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -129,27 +115,27 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= -github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= +github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= +github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= -github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= -github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= +github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= -github.com/pierrec/lz4 v2.0.5+incompatible h1:2xWsjqPFWcplujydGg4WmhC/6fZqK42wMM8aXeqhl0I= -github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= +github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= @@ -170,98 +156,113 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/testcontainers/testcontainers-go v0.32.0 h1:ug1aK08L3gCHdhknlTTwWjPHPS+/alvLJU/DRxTD/ME= -github.com/testcontainers/testcontainers-go v0.32.0/go.mod h1:CRHrzHLQhlXUsa5gXjTOfqIEJcrK5+xMDmBr/WMI88E= +github.com/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo= +github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= -go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= -gopkg.in/square/go-jose.v2 v2.4.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U= +k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= +k8s.io/client-go v0.32.3 h1:RKPVltzopkSgHS7aS98QdscAgtgah/+zmpAogooIqVU= +k8s.io/client-go v0.32.3/go.mod h1:3v0+3k4IcT9bXTc4V2rt+d2ZPPG700Xy6Oi0Gdl2PaY= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e h1:KqK5c/ghOm8xkHYhlodbp6i6+r+ChV2vuAuVRdFbLro= +k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= +sigs.k8s.io/structured-merge-diff/v4 v4.4.2 h1:MdmvkGuXi/8io6ixD5wud3vOLwc1rj0aNqRlpuvjmwA= +sigs.k8s.io/structured-merge-diff/v4 v4.4.2/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/pulsar-function-go/pb/generate.sh b/pulsar-function-go/pb/generate.sh index 06b61079739ab..a149c73a72272 100755 --- a/pulsar-function-go/pb/generate.sh +++ b/pulsar-function-go/pb/generate.sh @@ -138,12 +138,12 @@ EOF chmod +x "${genScript}" -echo "Running protoc in Docker (golang:1.24-alpine)..." +echo "Running protoc in Docker (golang:1.25-alpine)..." docker run --rm \ -v "${protoDefinitions}:/proto:ro" \ -v "${outDir}:/out" \ -v "${genScript}:/generate_pb.sh:ro" \ - golang:1.24-alpine \ + golang:1.25-alpine \ /generate_pb.sh # Revision of the last commit that touched any .proto file in the proto directory diff --git a/pulsar-function-go/pf/instance.go b/pulsar-function-go/pf/instance.go index af8a4e0157b76..49e156a4177fd 100644 --- a/pulsar-function-go/pf/instance.go +++ b/pulsar-function-go/pf/instance.go @@ -164,7 +164,6 @@ CLOSE: case cm := <-channel: msgInput := cm.Message atMostOnce := gi.context.instanceConf.funcDetails.ProcessingGuarantees == pb.ProcessingGuarantees_ATMOST_ONCE - atLeastOnce := gi.context.instanceConf.funcDetails.ProcessingGuarantees == pb.ProcessingGuarantees_ATLEAST_ONCE autoAck := gi.context.instanceConf.funcDetails.AutoAck //nolint:staticcheck if autoAck && atMostOnce { gi.ackInputMessage(msgInput) @@ -177,12 +176,8 @@ CLOSE: output, err := gi.handlerMsg(msgInput) if err != nil { - log.Errorf("handler message error:%v", err) - if autoAck && atLeastOnce { - gi.nackInputMessage(msgInput) - } - gi.stats.incrTotalUserExceptions(err) - return err + gi.handleUserError(msgInput, err) + continue } gi.stats.processTimeEnd() @@ -308,6 +303,21 @@ func (gi *goInstance) getProducer(topicName string) (pulsar.Producer, error) { return producer, err } +// resolveNackRedeliveryDelay returns the negative-ack redelivery delay to apply, or zero to leave +// the client default in place. +// +// SourceSpec.NegativeAckRedeliveryDelayMs is a proto3 scalar with no presence, so an unset field +// reads as 0. Only a positive value is applied, matching the guard the Java runtime uses in +// JavaInstanceRunnable; a zero left in ConsumerOptions is treated by the client as unset, so the +// default applies either way. +func resolveNackRedeliveryDelay(delayMs uint64) time.Duration { + if delayMs == 0 { + return 0 + } + + return time.Duration(delayMs) * time.Millisecond +} + func (gi *goInstance) setupConsumer() (chan pulsar.ConsumerMessage, error) { subscriptionType := pulsar.Shared if int32(gi.context.instanceConf.funcDetails.Source.SubscriptionType) == pb.SubscriptionType_value["FAILOVER"] { @@ -325,6 +335,8 @@ func (gi *goInstance) setupConsumer() (chan pulsar.ConsumerMessage, error) { funcDetails.Namespace, funcDetails.Name), gi.context.instanceConf.instanceID) + nackRedeliveryDelay := resolveNackRedeliveryDelay(funcDetails.Source.NegativeAckRedeliveryDelayMs) + channel := make(chan pulsar.ConsumerMessage) var ( @@ -343,39 +355,43 @@ func (gi *goInstance) setupConsumer() (chan pulsar.ConsumerMessage, error) { if consumerConf.ReceiverQueueSize != nil { if consumerConf.IsRegexPattern { consumer, err = gi.client.Subscribe(pulsar.ConsumerOptions{ - TopicsPattern: topicName.Name, - ReceiverQueueSize: int(consumerConf.ReceiverQueueSize.Value), - SubscriptionName: subscriptionName, - Properties: properties, - Type: subscriptionType, - MessageChannel: channel, + TopicsPattern: topicName.Name, + ReceiverQueueSize: int(consumerConf.ReceiverQueueSize.Value), + SubscriptionName: subscriptionName, + Properties: properties, + Type: subscriptionType, + MessageChannel: channel, + NackRedeliveryDelay: nackRedeliveryDelay, }) } else { consumer, err = gi.client.Subscribe(pulsar.ConsumerOptions{ - Topic: topicName.Name, - SubscriptionName: subscriptionName, - Properties: properties, - Type: subscriptionType, - ReceiverQueueSize: int(consumerConf.ReceiverQueueSize.Value), - MessageChannel: channel, + Topic: topicName.Name, + SubscriptionName: subscriptionName, + Properties: properties, + Type: subscriptionType, + ReceiverQueueSize: int(consumerConf.ReceiverQueueSize.Value), + MessageChannel: channel, + NackRedeliveryDelay: nackRedeliveryDelay, }) } } else { if consumerConf.IsRegexPattern { consumer, err = gi.client.Subscribe(pulsar.ConsumerOptions{ - TopicsPattern: topicName.Name, - SubscriptionName: subscriptionName, - Properties: properties, - Type: subscriptionType, - MessageChannel: channel, + TopicsPattern: topicName.Name, + SubscriptionName: subscriptionName, + Properties: properties, + Type: subscriptionType, + MessageChannel: channel, + NackRedeliveryDelay: nackRedeliveryDelay, }) } else { consumer, err = gi.client.Subscribe(pulsar.ConsumerOptions{ - Topic: topicName.Name, - SubscriptionName: subscriptionName, - Properties: properties, - Type: subscriptionType, - MessageChannel: channel, + Topic: topicName.Name, + SubscriptionName: subscriptionName, + Properties: properties, + Type: subscriptionType, + MessageChannel: channel, + NackRedeliveryDelay: nackRedeliveryDelay, }) } @@ -391,6 +407,29 @@ func (gi *goInstance) setupConsumer() (chan pulsar.ConsumerMessage, error) { return channel, nil } +func (gi *goInstance) shouldNackInputOnFailure() bool { + guarantee := gi.context.instanceConf.funcDetails.ProcessingGuarantees + return guarantee == pb.ProcessingGuarantees_ATLEAST_ONCE || + guarantee == pb.ProcessingGuarantees_MANUAL +} + +func (gi *goInstance) handleUserError(msgInput pulsar.Message, err error) { + log.Errorf("handler message error:%v", err) + if gi.shouldNackInputOnFailure() { + gi.nackInputMessage(msgInput) + } + gi.stats.incrTotalUserExceptions(err) + gi.stats.processTimeEnd() +} + +func (gi *goInstance) handlePublishError(msgInput pulsar.Message, err error) { + if gi.context.instanceConf.funcDetails.ProcessingGuarantees == pb.ProcessingGuarantees_ATLEAST_ONCE { + gi.nackInputMessage(msgInput) + } + gi.stats.incrTotalSysExceptions(err) + log.Errorf("failed to publish output message: %v", err) +} + func (gi *goInstance) handlerMsg(input pulsar.Message) (output []byte, err error) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -420,11 +459,8 @@ func (gi *goInstance) processResult(msgInput pulsar.Message, output []byte) { // semantics, ensure we nack so someone else can get it, in case we are the only handler. Then mark // exception and fail out. if err != nil { - if autoAck && atLeastOnce { - gi.nackInputMessage(msgInput) - } - gi.stats.incrTotalSysExceptions(err) - log.Fatal(err) + gi.handlePublishError(msgInput, err) + return } // Otherwise the message succeeded. If the SDK is entrusted with responding and we are using // atLeastOnce delivery semantics, ack the message. @@ -437,7 +473,7 @@ func (gi *goInstance) processResult(msgInput pulsar.Message, output []byte) { return } - // No output from the function or no output topic. Ack if we need to and mark the success before rturning. + // No output from the function or no output topic. Ack if we need to and mark the success before returning. if autoAck && atLeastOnce { gi.ackInputMessage(msgInput) } diff --git a/pulsar-function-go/pf/instance_test.go b/pulsar-function-go/pf/instance_test.go index bf45ae3a8917e..447d3a22f8d88 100644 --- a/pulsar-function-go/pf/instance_test.go +++ b/pulsar-function-go/pf/instance_test.go @@ -27,6 +27,8 @@ import ( "time" "github.com/stretchr/testify/assert" + + pb "github.com/apache/pulsar/pulsar-function-go/pb" ) func testProcessSpawnerHealthCheckTimer( @@ -115,3 +117,33 @@ func Test_goInstance_handlerMsg(t *testing.T) { assert.Equal(t, "output", string(output)) assert.Equal(t, message, fc.record) } + +func newTestGoInstance(guarantee pb.ProcessingGuarantees) *goInstance { + return &goInstance{ + context: &FunctionContext{ + instanceConf: &instanceConf{ + funcDetails: pb.FunctionDetails{ + ProcessingGuarantees: guarantee, + }, + }, + }, + } +} + +func TestShouldNackInputOnFailure(t *testing.T) { + tests := []struct { + name string + guarantee pb.ProcessingGuarantees + want bool + }{ + {"atLeastOnce", pb.ProcessingGuarantees_ATLEAST_ONCE, true}, + {"manual", pb.ProcessingGuarantees_MANUAL, true}, + {"atMostOnce", pb.ProcessingGuarantees_ATMOST_ONCE, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + instance := newTestGoInstance(tt.guarantee) + assert.Equal(t, tt.want, instance.shouldNackInputOnFailure()) + }) + } +} diff --git a/pulsar-function-go/pf/mockMessage_test.go b/pulsar-function-go/pf/mockMessage_test.go index d5bfe0961e365..a34c263486347 100644 --- a/pulsar-function-go/pf/mockMessage_test.go +++ b/pulsar-function-go/pf/mockMessage_test.go @@ -48,6 +48,10 @@ func (m *MockMessage) Payload() []byte { return m.payload } +func (m *MockMessage) IsNullValue() bool { + return m.payload == nil +} + func (m *MockMessage) ID() pulsar.MessageID { return m.messageID } diff --git a/pulsar-function-go/pf/nackDelay_test.go b/pulsar-function-go/pf/nackDelay_test.go new file mode 100644 index 0000000000000..0ec704eca7e4c --- /dev/null +++ b/pulsar-function-go/pf/nackDelay_test.go @@ -0,0 +1,66 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +package pf + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// The Go runtime nacks on failure but never configured the delay, so the client default of 60s +// applied regardless of SourceSpec.NegativeAckRedeliveryDelayMs. +func TestResolveNackRedeliveryDelay(t *testing.T) { + tests := []struct { + name string + delayMs uint64 + expected time.Duration + }{ + { + // proto3 scalar with no presence: unset reads as 0. Returning zero leaves + // ConsumerOptions at its zero value, which the client treats as unset. + name: "unset leaves the client default", + delayMs: 0, + expected: 0, + }, + { + name: "milliseconds are converted to a duration", + delayMs: 5000, + expected: 5 * time.Second, + }, + { + name: "sub-second values are preserved", + delayMs: 250, + expected: 250 * time.Millisecond, + }, + { + name: "the client default expressed explicitly still round-trips", + delayMs: 60000, + expected: time.Minute, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.expected, resolveNackRedeliveryDelay(test.delayMs)) + }) + } +} diff --git a/pulsar-functions/api-java/pom.xml b/pulsar-functions/api-java/pom.xml index 1ce7bd27c00f5..0063b1dbdbfa6 100644 --- a/pulsar-functions/api-java/pom.xml +++ b/pulsar-functions/api-java/pom.xml @@ -24,7 +24,7 @@ com.ascentstream.pulsar pulsar-functions - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-functions-api diff --git a/pulsar-functions/instance/pom.xml b/pulsar-functions/instance/pom.xml index 60a7a4ade072a..603bf4c198c8c 100644 --- a/pulsar-functions/instance/pom.xml +++ b/pulsar-functions/instance/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar pulsar-functions - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-functions-instance diff --git a/pulsar-functions/instance/src/main/python/python_instance.py b/pulsar-functions/instance/src/main/python/python_instance.py index 2ab3ccc46171c..5c57dfef79008 100755 --- a/pulsar-functions/instance/src/main/python/python_instance.py +++ b/pulsar-functions/instance/src/main/python/python_instance.py @@ -337,7 +337,10 @@ def process_result(self, output, msg): output_object = self.output_serde.serialize(output) if output_object is not None: - props = {"__pfn_input_topic__" : str(msg.topic), "__pfn_input_msg_id__" : base64ify(msg.message.message_id().serialize())} + props = {} + if self.instance_config.function_details.sink.forwardSourceMessageProperty: + props = msg.message.properties() + props.update({"__pfn_input_topic__" : str(msg.topic), "__pfn_input_msg_id__" : base64ify(msg.message.message_id().serialize())}) if self.effectively_once: self.producer.send_async(output_object, partial(self.done_producing, msg.consumer, msg.message, self.producer.topic()), diff --git a/pulsar-functions/instance/src/main/python/util.py b/pulsar-functions/instance/src/main/python/util.py index f5868093faea4..7b2a2d7b4d172 100755 --- a/pulsar-functions/instance/src/main/python/util.py +++ b/pulsar-functions/instance/src/main/python/util.py @@ -105,8 +105,8 @@ def __init__(self, t, hFunction, name="timer-thread"): self.t = t self.hFunction = hFunction self.thread = Timer(self.t, self.handle_function) - self.thread.setName(name) - self.thread.setDaemon(True) + self.thread.name = name + self.thread.daemon = True def handle_function(self): self.hFunction() diff --git a/pulsar-functions/instance/src/scripts/run_python_instance_tests.sh b/pulsar-functions/instance/src/scripts/run_python_instance_tests.sh index 5cb729686790f..11cec15e14c09 100755 --- a/pulsar-functions/instance/src/scripts/run_python_instance_tests.sh +++ b/pulsar-functions/instance/src/scripts/run_python_instance_tests.sh @@ -21,11 +21,11 @@ # Make sure dependencies are installed pip3 install mock --user -pip3 install protobuf==3.20.1 --user +pip3 install protobuf==6.31.1 --user pip3 install fastavro --user CUR_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" PULSAR_HOME="$( cd "$CUR_DIR/../../../../" >/dev/null && pwd )" # run instance tests -PULSAR_HOME=${PULSAR_HOME} PYTHONPATH=${PULSAR_HOME}/pulsar-functions/instance/target/python-instance python3 -m unittest discover -v ${PULSAR_HOME}/pulsar-functions/instance/target/python-instance/tests +PULSAR_HOME=${PULSAR_HOME} PYTHONPATH=${PULSAR_HOME}/pulsar-functions/instance/src/main/python python3 -m unittest discover -v -s ${PULSAR_HOME}/pulsar-functions/instance/src/test/python diff --git a/pulsar-functions/instance/src/test/java/org/apache/pulsar/functions/instance/ContextImplTest.java b/pulsar-functions/instance/src/test/java/org/apache/pulsar/functions/instance/ContextImplTest.java index cb4c93f153fd9..dbdb4eb1bc91f 100644 --- a/pulsar-functions/instance/src/test/java/org/apache/pulsar/functions/instance/ContextImplTest.java +++ b/pulsar-functions/instance/src/test/java/org/apache/pulsar/functions/instance/ContextImplTest.java @@ -108,6 +108,10 @@ public void setup() throws PulsarClientException { when(client.newProducer()).thenAnswer(invocation -> new ProducerBuilderImpl(client, Schema.BYTES)); when(client.newProducer(any())).thenAnswer( invocation -> new ProducerBuilderImpl(client, invocation.getArgument(0))); + // The builder asks the client to fill in the pending-message defaults before creating the + // producer; on a mock that would otherwise hand back a null configuration. + when(client.applyNoMemoryLimitProducerDefaults(any(ProducerConfigurationData.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); when(client.createProducerAsync(any(ProducerConfigurationData.class), any(), any())) .thenReturn(CompletableFuture.completedFuture(producer)); when(client.getSchema(anyString())).thenReturn(CompletableFuture.completedFuture(Optional.empty())); diff --git a/pulsar-functions/instance/src/test/python/test_python_instance.py b/pulsar-functions/instance/src/test/python/test_python_instance.py index 975ddfd90288d..1e72db8545816 100644 --- a/pulsar-functions/instance/src/test/python/test_python_instance.py +++ b/pulsar-functions/instance/src/test/python/test_python_instance.py @@ -23,6 +23,12 @@ from mock import Mock import sys sys.modules['prometheus_client'] = Mock() +sys.modules['bookkeeper'] = Mock() +sys.modules['bookkeeper.types'] = Mock() +sys.modules['bookkeeper.common'] = Mock() +sys.modules['bookkeeper.common.exceptions'] = Mock() +sys.modules['bookkeeper.proto'] = Mock() +sys.modules['bookkeeper.proto.stream_pb2'] = Mock() from contextimpl import ContextImpl from python_instance import PythonInstance, InstanceConfig @@ -42,7 +48,8 @@ def __eq__(self, other): return Any() def setUp(self): - log.init_logger("INFO", "foo", os.environ.get("PULSAR_HOME") + "/conf/functions-logging/console_logging_config.ini") + if not hasattr(sys.stdout, 'logger'): + log.init_logger("INFO", "foo", os.environ.get("PULSAR_HOME") + "/conf/functions-logging/console_logging_config.ini") def test_context_publish(self): instance_id = 'test_instance_id' @@ -90,3 +97,55 @@ def test_context_ack_partitionedtopic(self): args, kwargs = consumer.acknowledge.call_args self.assertEqual(args[0], "test_message_id") + +class TestPropertiesForwarding(unittest.TestCase): + + def _setup_mock_instance(self, forward_property): + function_details = Function_pb2.FunctionDetails() + function_details.sink.topic = "test_sink_topic" + function_details.sink.forwardSourceMessageProperty = forward_property + + mock_pulsar_client = Mock() + mock_producer = Mock() + mock_pulsar_client.create_producer.return_value = mock_producer + + instance = PythonInstance('test_instance', 'test_func', '1.0', function_details, 100, 30, 'user_code', mock_pulsar_client, Mock(), 'test_cluster', 'test_url', None) + instance.producer = mock_producer + instance.contextimpl = Mock() + instance.contextimpl.get_message_partition_index.return_value = None + instance.output_schema = "DEFAULT_SCHEMA" + instance.output_serde = Mock() + instance.output_serde.serialize.return_value = b'serialized_output' + instance.effectively_once = False + + return instance, mock_producer + + def test_forwards_properties(self): + instance, mock_producer = self._setup_mock_instance(forward_property=True) + + mock_msg = Mock() + mock_msg.topic = "source-topic" + mock_msg.message.message_id().serialize.return_value = b'msg-id' + mock_msg.message.properties.return_value = {"custom-key": "custom-value"} + + instance.process_result("output-data", mock_msg) + + args, kwargs = mock_producer.send_async.call_args + self.assertIn("custom-key", kwargs['properties']) + self.assertEqual(kwargs['properties']["custom-key"], "custom-value") + self.assertIn("__pfn_input_topic__", kwargs['properties']) + + def test_do_not_forward_properties(self): + instance, mock_producer = self._setup_mock_instance(forward_property=False) + + mock_msg = Mock() + mock_msg.topic = "source-topic" + mock_msg.message.message_id().serialize.return_value = b'msg-id' + mock_msg.message.properties.return_value = {"custom-key": "custom-value"} + + instance.process_result("output-data", mock_msg) + + args, kwargs = mock_producer.send_async.call_args + self.assertNotIn("custom-key", kwargs['properties']) + self.assertIn("__pfn_input_topic__", kwargs['properties']) + diff --git a/pulsar-functions/java-examples-builtin/pom.xml b/pulsar-functions/java-examples-builtin/pom.xml index 7804e2ead6014..93035c454a9ab 100644 --- a/pulsar-functions/java-examples-builtin/pom.xml +++ b/pulsar-functions/java-examples-builtin/pom.xml @@ -24,7 +24,7 @@ com.ascentstream.pulsar pulsar-functions - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-functions-api-examples-builtin diff --git a/pulsar-functions/java-examples/pom.xml b/pulsar-functions/java-examples/pom.xml index 01468c7b9c7e8..37b7269aaa9ff 100644 --- a/pulsar-functions/java-examples/pom.xml +++ b/pulsar-functions/java-examples/pom.xml @@ -24,7 +24,7 @@ com.ascentstream.pulsar pulsar-functions - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-functions-api-examples diff --git a/pulsar-functions/localrun-shaded/pom.xml b/pulsar-functions/localrun-shaded/pom.xml index b0175f04fb52d..c94d7e35e4c35 100644 --- a/pulsar-functions/localrun-shaded/pom.xml +++ b/pulsar-functions/localrun-shaded/pom.xml @@ -26,7 +26,7 @@ com.ascentstream.pulsar pulsar-functions - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-functions-local-runner diff --git a/pulsar-functions/localrun/pom.xml b/pulsar-functions/localrun/pom.xml index de026f08fc665..edf2a4bf52de6 100644 --- a/pulsar-functions/localrun/pom.xml +++ b/pulsar-functions/localrun/pom.xml @@ -26,7 +26,7 @@ com.ascentstream.pulsar pulsar-functions - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-functions-local-runner-original diff --git a/pulsar-functions/localrun/src/main/resources/log4j2.xml b/pulsar-functions/localrun/src/main/resources/log4j2.xml index a7714c3f48835..57b6e9e7d33c7 100644 --- a/pulsar-functions/localrun/src/main/resources/log4j2.xml +++ b/pulsar-functions/localrun/src/main/resources/log4j2.xml @@ -22,7 +22,7 @@ - + diff --git a/pulsar-functions/pom.xml b/pulsar-functions/pom.xml index 4ea6ea2c708a0..c6b20cf55ee10 100644 --- a/pulsar-functions/pom.xml +++ b/pulsar-functions/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-functions diff --git a/pulsar-functions/proto/pom.xml b/pulsar-functions/proto/pom.xml index 2c9a7e08e951d..62b07b808008c 100644 --- a/pulsar-functions/proto/pom.xml +++ b/pulsar-functions/proto/pom.xml @@ -27,7 +27,7 @@ com.ascentstream.pulsar pulsar-functions - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-functions-proto diff --git a/pulsar-functions/runtime-all/pom.xml b/pulsar-functions/runtime-all/pom.xml index 880156a7f8fb4..695398e332abf 100644 --- a/pulsar-functions/runtime-all/pom.xml +++ b/pulsar-functions/runtime-all/pom.xml @@ -26,7 +26,7 @@ com.ascentstream.pulsar pulsar-functions - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT - + diff --git a/pulsar-testclient/pom.xml b/pulsar-testclient/pom.xml index b61f2c23b0158..9082247c01a13 100644 --- a/pulsar-testclient/pom.xml +++ b/pulsar-testclient/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-testclient diff --git a/pulsar-testclient/src/main/java/org/apache/pulsar/testclient/PerformanceProducer.java b/pulsar-testclient/src/main/java/org/apache/pulsar/testclient/PerformanceProducer.java index 8860696321a3e..5394838f8a512 100644 --- a/pulsar-testclient/src/main/java/org/apache/pulsar/testclient/PerformanceProducer.java +++ b/pulsar-testclient/src/main/java/org/apache/pulsar/testclient/PerformanceProducer.java @@ -22,8 +22,6 @@ import static java.util.concurrent.TimeUnit.NANOSECONDS; import static org.apache.commons.lang3.StringUtils.isNotBlank; import static org.apache.pulsar.client.impl.conf.ProducerConfigurationData.DEFAULT_BATCHING_MAX_MESSAGES; -import static org.apache.pulsar.client.impl.conf.ProducerConfigurationData.DEFAULT_MAX_PENDING_MESSAGES; -import static org.apache.pulsar.client.impl.conf.ProducerConfigurationData.DEFAULT_MAX_PENDING_MESSAGES_ACROSS_PARTITIONS; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectWriter; import com.google.common.collect.Range; @@ -64,6 +62,7 @@ import org.apache.pulsar.client.api.ProducerBuilder; import org.apache.pulsar.client.api.PulsarClient; import org.apache.pulsar.client.api.PulsarClientException; +import org.apache.pulsar.client.api.Schema; import org.apache.pulsar.client.api.TypedMessageBuilder; import org.apache.pulsar.client.api.transaction.Transaction; import org.apache.pulsar.common.partition.PartitionedTopicMetadata; @@ -134,12 +133,13 @@ public class PerformanceProducer extends PerformanceTopicListArguments{ + "larger than allowed max size") private boolean chunkingAllowed = false; - @Option(names = { "-o", "--max-outstanding" }, description = "Max number of outstanding messages") - public int maxOutstanding = DEFAULT_MAX_PENDING_MESSAGES; + @Option(names = { "-o", "--max-outstanding" }, description = "Max number of outstanding messages. " + + "Left to the client default when unset") + public Integer maxOutstanding; @Option(names = { "-p", "--max-outstanding-across-partitions" }, description = "Max number of outstanding " - + "messages across partitions") - public int maxPendingMessagesAcrossPartitions = DEFAULT_MAX_PENDING_MESSAGES_ACROSS_PARTITIONS; + + "messages across partitions. Left to the client default when unset") + public Integer maxPendingMessagesAcrossPartitions; @Option(names = { "-np", "--partitions" }, description = "Create partitioned topics with the given number " + "of partitions, set 0 to not try to create the topic") @@ -447,14 +447,21 @@ static IMessageFormatter getMessageFormatter(String formatterClass) { } ProducerBuilder createProducerBuilder(PulsarClient client, int producerId) { - ProducerBuilder producerBuilder = client.newProducer() // + // pulsar-perf runs with the client memory limit disabled unless --memory-limit is given, so the + // client's pending-message defaults are the producer's only backpressure. They are applied to + // any producer whose limits are left unset, whichever newProducer() overload is used. + ProducerBuilder producerBuilder = client.newProducer(Schema.BYTES) // .sendTimeout(this.sendTimeout, TimeUnit.SECONDS) // .compressionType(this.compression) // - .maxPendingMessages(this.maxOutstanding) // .accessMode(this.producerAccessMode) // enable round robin message routing if it is a partitioned topic .messageRoutingMode(MessageRoutingMode.RoundRobinPartition); - if (this.maxPendingMessagesAcrossPartitions > 0) { + // Only pass a limit the user actually asked for. Their unset value is 0, which the client reads + // as "no message-count limit", so passing it through would leave the producer unbounded. + if (this.maxOutstanding != null) { + producerBuilder.maxPendingMessages(this.maxOutstanding); + } + if (this.maxPendingMessagesAcrossPartitions != null) { producerBuilder.maxPendingMessagesAcrossPartitions(this.maxPendingMessagesAcrossPartitions); } diff --git a/pulsar-testclient/src/test/java/org/apache/pulsar/testclient/PerformanceProducerTest.java b/pulsar-testclient/src/test/java/org/apache/pulsar/testclient/PerformanceProducerTest.java index 47815ddddf8b1..6bd9ec532134e 100644 --- a/pulsar-testclient/src/test/java/org/apache/pulsar/testclient/PerformanceProducerTest.java +++ b/pulsar-testclient/src/test/java/org/apache/pulsar/testclient/PerformanceProducerTest.java @@ -32,9 +32,12 @@ import org.apache.pulsar.client.api.ClientBuilder; import org.apache.pulsar.client.api.Consumer; import org.apache.pulsar.client.api.Message; +import org.apache.pulsar.client.api.Producer; import org.apache.pulsar.client.api.PulsarClient; import org.apache.pulsar.client.api.SubscriptionType; +import org.apache.pulsar.client.impl.ProducerBase; import org.apache.pulsar.client.impl.ProducerBuilderImpl; +import org.apache.pulsar.client.impl.conf.ProducerConfigurationData; import org.apache.pulsar.common.policies.data.ClusterData; import org.apache.pulsar.common.policies.data.TenantInfoImpl; import org.awaitility.Awaitility; @@ -236,6 +239,58 @@ public void testMaxOutstanding() throws Exception { consumer.close(); } + /** + * pulsar-perf runs with the client memory limit disabled unless {@code --memory-limit} is given, so + * the producer's only backpressure is its pending-message queue. Leaving the options unset has to + * leave the client's own defaults in place; passing their unset value of 0 through would read as an + * explicit "no message-count limit" and leave the producer unbounded, which is what exhausts direct + * memory against a slow broker on a non-partitioned topic. + */ + @Test(timeOut = 20000) + public void testPendingMessageLimitsAreLeftToTheClientWhenUnset() throws Exception { + PerformanceProducer producer = new PerformanceProducer(); + producer.topics = List.of(testTopic + UUID.randomUUID()); + producer.serviceURL = pulsar.getBrokerServiceUrl(); + + @Cleanup + PulsarClient client = PerfClientUtils.createClientBuilderFromArguments(producer).build(); + ProducerBuilderImpl builder = + (ProducerBuilderImpl) producer.createProducerBuilder(client, 0); + + Assert.assertNull(producer.maxOutstanding); + Assert.assertNull(producer.maxPendingMessagesAcrossPartitions); + // pulsar-perf must not configure either limit, so that they stay the client's to decide. + Assert.assertFalse(builder.getConf().isMaxPendingMessagesConfigured()); + Assert.assertFalse(builder.getConf().isMaxPendingMessagesAcrossPartitionsConfigured()); + + // The client resolves its no-memory-limit defaults when the producer is created rather than on + // the builder, so the effective configuration is where they have to show up. + @Cleanup + Producer createdProducer = builder.topic(producer.topics.get(0)).create(); + ProducerConfigurationData conf = ((ProducerBase) createdProducer).getConfiguration(); + Assert.assertTrue(conf.getMaxPendingMessages() > 0); + Assert.assertTrue(conf.getMaxPendingMessagesAcrossPartitions() > 0); + } + + /** + * An option that is given still wins over the client default, and either one can be given on its + * own — the across-partitions budget is allowed to be below the per-producer limit. + */ + @Test(timeOut = 20000) + public void testGivenPendingMessageLimitsAreApplied() throws Exception { + PerformanceProducer producer = new PerformanceProducer(); + producer.topics = List.of(testTopic + UUID.randomUUID()); + producer.serviceURL = pulsar.getBrokerServiceUrl(); + producer.maxPendingMessagesAcrossPartitions = 500; + + @Cleanup + PulsarClient client = PerfClientUtils.createClientBuilderFromArguments(producer).build(); + ProducerBuilderImpl builder = + (ProducerBuilderImpl) producer.createProducerBuilder(client, 0); + + Assert.assertEquals(builder.getConf().getMaxPendingMessagesAcrossPartitions(), 500); + } + @Test public void testRangeConvert() { PerformanceProducer.RangeConvert rangeConvert = new PerformanceProducer.RangeConvert(); diff --git a/pulsar-transaction/common/pom.xml b/pulsar-transaction/common/pom.xml index 5390a724fbcb3..f6092cae658cf 100644 --- a/pulsar-transaction/common/pom.xml +++ b/pulsar-transaction/common/pom.xml @@ -27,7 +27,7 @@ com.ascentstream.pulsar pulsar-transaction-parent - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-transaction-common diff --git a/pulsar-transaction/coordinator/pom.xml b/pulsar-transaction/coordinator/pom.xml index 3ddfb2d5b2dc1..e23d78215fff6 100644 --- a/pulsar-transaction/coordinator/pom.xml +++ b/pulsar-transaction/coordinator/pom.xml @@ -27,7 +27,7 @@ com.ascentstream.pulsar pulsar-transaction-parent - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-transaction-coordinator diff --git a/pulsar-transaction/pom.xml b/pulsar-transaction/pom.xml index 99430633d4755..b675ef54b2d99 100644 --- a/pulsar-transaction/pom.xml +++ b/pulsar-transaction/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-transaction-parent diff --git a/pulsar-websocket/pom.xml b/pulsar-websocket/pom.xml index 55c26d0e86b99..4a637bec07197 100644 --- a/pulsar-websocket/pom.xml +++ b/pulsar-websocket/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-websocket diff --git a/pulsar-websocket/src/main/java/org/apache/pulsar/websocket/ReaderHandler.java b/pulsar-websocket/src/main/java/org/apache/pulsar/websocket/ReaderHandler.java index 7cee6005f05d6..c4bb952c62886 100644 --- a/pulsar-websocket/src/main/java/org/apache/pulsar/websocket/ReaderHandler.java +++ b/pulsar-websocket/src/main/java/org/apache/pulsar/websocket/ReaderHandler.java @@ -121,11 +121,17 @@ public ReaderHandler(WebSocketService service, HttpServletRequest request, Jetty } allowConnect = true; } catch (Exception e) { - log.warn("[{}:{}] Failed in creating reader {} on topic {}", request.getRemoteAddr(), - request.getRemotePort(), subscription, topic, e); + int errorCode = getErrorCode(e); + boolean isKnownError = errorCode != HttpServletResponse.SC_INTERNAL_SERVER_ERROR; + if (isKnownError) { + log.warn("[{}:{}] Failed in creating reader {} on topic {}: {}", request.getRemoteAddr(), + request.getRemotePort(), subscription, topic, e.getMessage()); + } else { + log.error("[{}:{}] Failed in creating reader {} on topic {}", request.getRemoteAddr(), + request.getRemotePort(), subscription, topic, e); + } try { - response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, - "Failed to create reader: " + e.getMessage()); + response.sendError(errorCode, getErrorMessage(e)); } catch (IOException e1) { log.warn("[{}:{}] Failed to send error: {}", request.getRemoteAddr(), request.getRemotePort(), e1.getMessage(), e1); @@ -340,13 +346,25 @@ private int getReceiverQueueSize() { return size; } - private MessageId getMessageId() throws IOException { + private MessageId getMessageId() { MessageId messageId = MessageId.latest; - if (isNotBlank(queryParams.get("messageId"))) { - if (queryParams.get("messageId").equals("earliest")) { + String messageIdParam = queryParams.get("messageId"); + if (isNotBlank(messageIdParam)) { + if (messageIdParam.equals("earliest")) { messageId = MessageId.earliest; - } else if (!queryParams.get("messageId").equals("latest")) { - messageId = MessageIdImpl.fromByteArray(Base64.getDecoder().decode(queryParams.get("messageId"))); + } else if (!messageIdParam.equals("latest")) { + final byte[] decoded; + try { + decoded = Base64.getDecoder().decode(messageIdParam); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid messageId base64 value", e); + } + + try { + messageId = MessageIdImpl.fromByteArray(decoded); + } catch (IOException | RuntimeException e) { + throw new IllegalArgumentException("Invalid messageId value", e); + } } } return messageId; diff --git a/pulsar-websocket/src/test/java/org/apache/pulsar/websocket/ReaderHandlerTest.java b/pulsar-websocket/src/test/java/org/apache/pulsar/websocket/ReaderHandlerTest.java index a79899ab6fac7..9db1561ec5a9a 100644 --- a/pulsar-websocket/src/test/java/org/apache/pulsar/websocket/ReaderHandlerTest.java +++ b/pulsar-websocket/src/test/java/org/apache/pulsar/websocket/ReaderHandlerTest.java @@ -21,17 +21,22 @@ import static org.mockito.Mockito.any; import static org.mockito.Mockito.anyInt; import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.IOException; +import java.util.Base64; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.function.Function; import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; import org.apache.pulsar.client.api.Message; import org.apache.pulsar.client.api.MessageId; import org.apache.pulsar.client.api.PulsarClient; @@ -43,12 +48,98 @@ import org.apache.pulsar.client.impl.MultiTopicsConsumerImpl; import org.apache.pulsar.client.impl.MultiTopicsReaderImpl; import org.apache.pulsar.client.impl.ReaderImpl; +import org.apache.pulsar.common.api.proto.MessageIdData; import org.eclipse.jetty.ee8.websocket.server.JettyServerUpgradeResponse; import org.testng.Assert; import org.testng.annotations.Test; public class ReaderHandlerTest { + @Test + @SuppressWarnings("unchecked") + public void testInvalidMessageIdBase64ReturnsBadRequest() throws IOException { + WebSocketService wss = mock(WebSocketService.class); + PulsarClient mockedClient = mock(PulsarClient.class); + when(wss.getPulsarClient()).thenReturn(mockedClient); + ReaderBuilder mockedReaderBuilder = mock(ReaderBuilder.class); + when(mockedClient.newReader()).thenReturn(mockedReaderBuilder); + when(mockedReaderBuilder.topic(any())).thenReturn(mockedReaderBuilder); + // Ensure the chain doesn't NPE after startMessageId() if parsing unexpectedly succeeds. + when(mockedReaderBuilder.startMessageId(any())).thenReturn(mockedReaderBuilder); + + Map params = new HashMap<>(); + params.put("messageId", new String[] { "invalidMessageId" }); + + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getRequestURI()).thenReturn("/ws/v2/reader/persistent/my-property/my-ns/my-topic"); + when(request.getParameterMap()).thenReturn(params); + + JettyServerUpgradeResponse servletUpgradeResponse = mock(JettyServerUpgradeResponse.class); + new ReaderHandler(wss, request, servletUpgradeResponse); + + verify(servletUpgradeResponse, times(1)) + .sendError(eq(HttpServletResponse.SC_BAD_REQUEST), anyString()); + } + + @Test + @SuppressWarnings("unchecked") + public void testInvalidMessageIdBytesReturnsBadRequest() throws IOException { + WebSocketService wss = mock(WebSocketService.class); + PulsarClient mockedClient = mock(PulsarClient.class); + when(wss.getPulsarClient()).thenReturn(mockedClient); + ReaderBuilder mockedReaderBuilder = mock(ReaderBuilder.class); + when(mockedClient.newReader()).thenReturn(mockedReaderBuilder); + when(mockedReaderBuilder.topic(any())).thenReturn(mockedReaderBuilder); + // Ensure the chain doesn't NPE after startMessageId() if parsing unexpectedly succeeds. + when(mockedReaderBuilder.startMessageId(any())).thenReturn(mockedReaderBuilder); + + // "AQID" is valid Base64, but it doesn't decode into a valid Pulsar MessageId structure. + Map params = new HashMap<>(); + params.put("messageId", new String[] { "AQID" }); + + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getRequestURI()).thenReturn("/ws/v2/reader/persistent/my-property/my-ns/my-topic"); + when(request.getParameterMap()).thenReturn(params); + + JettyServerUpgradeResponse servletUpgradeResponse = mock(JettyServerUpgradeResponse.class); + new ReaderHandler(wss, request, servletUpgradeResponse); + + verify(servletUpgradeResponse, times(1)) + .sendError(eq(HttpServletResponse.SC_BAD_REQUEST), anyString()); + } + + @Test + @SuppressWarnings("unchecked") + public void testInvalidMessageIdRuntimeParseFailureReturnsBadRequest() throws IOException { + WebSocketService wss = mock(WebSocketService.class); + PulsarClient mockedClient = mock(PulsarClient.class); + when(wss.getPulsarClient()).thenReturn(mockedClient); + ReaderBuilder mockedReaderBuilder = mock(ReaderBuilder.class); + when(mockedClient.newReader()).thenReturn(mockedReaderBuilder); + when(mockedReaderBuilder.topic(any())).thenReturn(mockedReaderBuilder); + // Ensure the chain doesn't NPE after startMessageId() if parsing unexpectedly succeeds. + when(mockedReaderBuilder.startMessageId(any())).thenReturn(mockedReaderBuilder); + + MessageIdData invalidBatchMessageId = new MessageIdData() + .setLedgerId(1) + .setEntryId(2) + .setBatchIndex(0) + .setBatchSize(-1); + Map params = new HashMap<>(); + params.put("messageId", new String[] { + Base64.getEncoder().encodeToString(invalidBatchMessageId.toByteArray()) }); + + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getRequestURI()).thenReturn("/ws/v2/reader/persistent/my-property/my-ns/my-topic"); + when(request.getParameterMap()).thenReturn(params); + + JettyServerUpgradeResponse servletUpgradeResponse = mock(JettyServerUpgradeResponse.class); + new ReaderHandler(wss, request, servletUpgradeResponse); + + verify(servletUpgradeResponse, times(1)) + .sendError(eq(HttpServletResponse.SC_BAD_REQUEST), anyString()); + } + @Test @SuppressWarnings("unchecked") public void testCreateReaderImp() throws IOException { @@ -68,7 +159,7 @@ public void testCreateReaderImp() throws IOException { when(consumerImp.getSubscription()).thenReturn(subName); when(mockedReader.getConsumer()).thenReturn(consumerImp); HttpServletRequest request = mock(HttpServletRequest.class); - when(request.getRequestURI()).thenReturn("/ws/v2/producer/persistent/my-property/my-ns/my-topic"); + when(request.getRequestURI()).thenReturn("/ws/v2/reader/persistent/my-property/my-ns/my-topic"); // create reader handler JettyServerUpgradeResponse servletUpgradeResponse = mock(JettyServerUpgradeResponse.class); ReaderHandler readerHandler = new ReaderHandler(wss, request, servletUpgradeResponse); @@ -97,7 +188,7 @@ public void testCreateMultipleTopicReaderImp() throws IOException { when(consumerImp.getSubscription()).thenReturn(subName); when(mockedReader.getMultiTopicsConsumer()).thenReturn(consumerImp); HttpServletRequest request = mock(HttpServletRequest.class); - when(request.getRequestURI()).thenReturn("/ws/v2/producer/persistent/my-property/my-ns/my-topic"); + when(request.getRequestURI()).thenReturn("/ws/v2/reader/persistent/my-property/my-ns/my-topic"); // create reader handler JettyServerUpgradeResponse servletUpgradeResponse = mock(JettyServerUpgradeResponse.class); ReaderHandler readerHandler = new ReaderHandler(wss, request, servletUpgradeResponse); @@ -122,7 +213,7 @@ public void testCreateIllegalReaderImp() throws IOException { IllegalReader illegalReader = new IllegalReader(); when(mockedReaderBuilder.create()).thenReturn(illegalReader); HttpServletRequest request = mock(HttpServletRequest.class); - when(request.getRequestURI()).thenReturn("/ws/v2/producer/persistent/my-property/my-ns/my-topic"); + when(request.getRequestURI()).thenReturn("/ws/v2/reader/persistent/my-property/my-ns/my-topic"); // create reader handler JettyServerUpgradeResponse servletUpgradeResponse = spy(JettyServerUpgradeResponse.class); new ReaderHandler(wss, request, servletUpgradeResponse); diff --git a/src/update_python_protobuf_stubs_with_docker.sh b/src/update_python_protobuf_stubs_with_docker.sh index ece0b5096d7c6..6bc3191f83141 100755 --- a/src/update_python_protobuf_stubs_with_docker.sh +++ b/src/update_python_protobuf_stubs_with_docker.sh @@ -20,6 +20,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" -# Create an inline docker container with alpine:3.21 base image and mount the current directory to it as the +# Create an inline docker container with alpine:3.24 base image and mount the current directory to it as the # working directory and run the script inside the container. -docker run --rm -v "$SCRIPT_DIR/..:/pulsar_src" -w /pulsar_src alpine:3.21 sh -c 'apk add --no-cache bash python3 && /pulsar_src/src/update_python_protobuf_stubs.sh' \ No newline at end of file +docker run --rm -v "$SCRIPT_DIR/..:/pulsar_src" -w /pulsar_src alpine:3.24 sh -c 'apk add --no-cache bash python3 && /pulsar_src/src/update_python_protobuf_stubs.sh' \ No newline at end of file diff --git a/structured-event-log/pom.xml b/structured-event-log/pom.xml index 79bb38001277f..7ab9c2b50691d 100644 --- a/structured-event-log/pom.xml +++ b/structured-event-log/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT structured-event-log diff --git a/testmocks/pom.xml b/testmocks/pom.xml index a51894db67d38..543414cccd427 100644 --- a/testmocks/pom.xml +++ b/testmocks/pom.xml @@ -25,7 +25,7 @@ pulsar com.ascentstream.pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT testmocks diff --git a/testmocks/src/main/java/org/apache/bookkeeper/client/PulsarMockBookKeeper.java b/testmocks/src/main/java/org/apache/bookkeeper/client/PulsarMockBookKeeper.java index 7104ded746029..e888a95fd89cd 100644 --- a/testmocks/src/main/java/org/apache/bookkeeper/client/PulsarMockBookKeeper.java +++ b/testmocks/src/main/java/org/apache/bookkeeper/client/PulsarMockBookKeeper.java @@ -75,6 +75,8 @@ public class PulsarMockBookKeeper extends BookKeeper { final OrderedExecutor orderedExecutor; final ExecutorService executor; final ScheduledExecutorService scheduler; + private volatile long defaultAddEntryDelayMillis = 1L; + private volatile long defaultReadEntriesDelayMillis = 1L; @Override public ClientConfiguration getConf() { @@ -199,8 +201,20 @@ public void asyncOpenLedgerNoRecovery(long lId, DigestType digestType, byte[] pa asyncOpenLedger(lId, digestType, passwd, cb, ctx); } + // Test hook: fail ledger deletions while enabled (used to exercise GC retry semantics). + private volatile java.util.function.BooleanSupplier deleteLedgerFailure; + + public void setDeleteLedgerFailure(java.util.function.BooleanSupplier deleteLedgerFailure) { + this.deleteLedgerFailure = deleteLedgerFailure; + } + @Override public void asyncDeleteLedger(long lId, DeleteCallback cb, Object ctx) { + if (deleteLedgerFailure != null && deleteLedgerFailure.getAsBoolean()) { + executor.execute(() -> + cb.deleteComplete(BKException.Code.NoBookieAvailableException, ctx)); + return; + } getProgrammedFailure().thenComposeAsync((res) -> { if (ledgers.containsKey(lId)) { ledgers.remove(lId); @@ -492,5 +506,33 @@ public MetadataClientDriver getMetadataClientDriver() { return metadataClientDriver; } + public long getReadEntriesDelayMillis() { + return defaultReadEntriesDelayMillis; + } + + public long getNextAddEntryDelayMillis() { + Long delay = addEntryDelaysMillis.poll(); + if (delay != null) { + return delay; + } + return defaultAddEntryDelayMillis; + } + + public long getNextAddEntryResponseDelayMillis() { + Long delay = addEntryResponseDelaysMillis.poll(); + if (delay != null) { + return delay; + } + return 0; + } + + public void setDefaultAddEntryDelayMillis(long defaultAddEntryDelayMillis) { + this.defaultAddEntryDelayMillis = defaultAddEntryDelayMillis; + } + + public void setDefaultReadEntriesDelayMillis(long defaultReadEntriesDelayMillis) { + this.defaultReadEntriesDelayMillis = defaultReadEntriesDelayMillis; + } + private static final Logger log = LoggerFactory.getLogger(PulsarMockBookKeeper.class); } diff --git a/testmocks/src/main/java/org/apache/bookkeeper/client/PulsarMockLedgerHandle.java b/testmocks/src/main/java/org/apache/bookkeeper/client/PulsarMockLedgerHandle.java index 4d1fd1380c807..a540377413346 100644 --- a/testmocks/src/main/java/org/apache/bookkeeper/client/PulsarMockLedgerHandle.java +++ b/testmocks/src/main/java/org/apache/bookkeeper/client/PulsarMockLedgerHandle.java @@ -113,18 +113,26 @@ public void asyncClose(CloseCallback cb, Object ctx) { @Override public void asyncReadEntries(final long firstEntry, final long lastEntry, final ReadCallback cb, final Object ctx) { bk.getProgrammedFailure().thenComposeAsync((res) -> { - log.debug("readEntries: first={} last={} total={}", firstEntry, lastEntry, entries.size()); + if (log.isDebugEnabled()) { + log.debug("readEntries: first={} last={} total={}", firstEntry, lastEntry, entries.size()); + } final Queue seq = new ArrayDeque(); long entryId = firstEntry; while (entryId <= lastEntry && entryId < entries.size()) { seq.add(new LedgerEntry(entries.get((int) entryId++).duplicate())); } - log.debug("Entries read: {}", seq); + if (log.isDebugEnabled()) { + log.debug("Entries read: {}", seq); + } - try { - Thread.sleep(1); - } catch (InterruptedException e) { + long readEntriesDelay = bk.getReadEntriesDelayMillis(); + if (readEntriesDelay > 0) { + try { + Thread.sleep(readEntriesDelay); + } catch (InterruptedException e) { + // ignore + } } Enumeration entries = new Enumeration() { @@ -182,14 +190,12 @@ public void asyncAddEntry(final byte[] data, final int offset, final int length, @Override public void asyncAddEntry(final ByteBuf data, final AddCallback cb, final Object ctx) { bk.getAddEntryFailure().thenComposeAsync((res) -> { - Long delayMillis = bk.addEntryDelaysMillis.poll(); - if (delayMillis == null) { - delayMillis = 1L; - } - - try { - Thread.sleep(delayMillis); - } catch (InterruptedException e) { + long delayMillis = bk.getNextAddEntryDelayMillis(); + if (delayMillis > 0) { + try { + Thread.sleep(delayMillis); + } catch (InterruptedException e) { + } } if (fenced) { @@ -211,8 +217,8 @@ public void asyncAddEntry(final ByteBuf data, final AddCallback cb, final Object cb.addComplete(PulsarMockBookKeeper.getExceptionCode(exception), PulsarMockLedgerHandle.this, LedgerHandle.INVALID_ENTRY_ID, ctx); } else { - Long responseDelayMillis = bk.addEntryResponseDelaysMillis.poll(); - if (responseDelayMillis != null) { + long responseDelayMillis = bk.getNextAddEntryResponseDelayMillis(); + if (responseDelayMillis > 0) { try { Thread.sleep(responseDelayMillis); } catch (InterruptedException e) { diff --git a/tests/bc_2_0_0/pom.xml b/tests/bc_2_0_0/pom.xml index dd4865a2cbec4..550a3525a8c48 100644 --- a/tests/bc_2_0_0/pom.xml +++ b/tests/bc_2_0_0/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar.tests tests-parent - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT bc_2_0_0 diff --git a/tests/bc_2_0_1/pom.xml b/tests/bc_2_0_1/pom.xml index 8f3a095e51750..3616f69be0943 100644 --- a/tests/bc_2_0_1/pom.xml +++ b/tests/bc_2_0_1/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar.tests tests-parent - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT bc_2_0_1 diff --git a/tests/bc_2_6_0/pom.xml b/tests/bc_2_6_0/pom.xml index bc263d8d1995f..ce9f1a2cf90ad 100644 --- a/tests/bc_2_6_0/pom.xml +++ b/tests/bc_2_6_0/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar.tests tests-parent - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT 4.0.0 diff --git a/tests/docker-images/java-test-functions/pom.xml b/tests/docker-images/java-test-functions/pom.xml index ca195fdf56879..a21e47c7756f0 100644 --- a/tests/docker-images/java-test-functions/pom.xml +++ b/tests/docker-images/java-test-functions/pom.xml @@ -23,7 +23,7 @@ com.ascentstream.pulsar.tests docker-images - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT 4.0.0 java-test-functions diff --git a/tests/docker-images/java-test-image/pom.xml b/tests/docker-images/java-test-image/pom.xml index 5fe89c9e1920a..23c2deb613156 100644 --- a/tests/docker-images/java-test-image/pom.xml +++ b/tests/docker-images/java-test-image/pom.xml @@ -23,7 +23,7 @@ com.ascentstream.pulsar.tests docker-images - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT 4.0.0 java-test-image diff --git a/tests/docker-images/java-test-plugins/pom.xml b/tests/docker-images/java-test-plugins/pom.xml index b0fbb87efeedf..bf7f2b7cc1aff 100644 --- a/tests/docker-images/java-test-plugins/pom.xml +++ b/tests/docker-images/java-test-plugins/pom.xml @@ -23,7 +23,7 @@ com.ascentstream.pulsar.tests docker-images - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT 4.0.0 java-test-plugins diff --git a/tests/docker-images/latest-version-image/Dockerfile b/tests/docker-images/latest-version-image/Dockerfile index 07d6a29bb8728..154708bf783b2 100644 --- a/tests/docker-images/latest-version-image/Dockerfile +++ b/tests/docker-images/latest-version-image/Dockerfile @@ -24,6 +24,7 @@ ARG GOLANG_IMAGE FROM $GOLANG_IMAGE as pulsar-function-go COPY target/pulsar-function-go/ /go/src/github.com/apache/pulsar/pulsar-function-go +COPY go-examples/exceptionFunc/ /go/src/github.com/apache/pulsar/pulsar-function-go/examples/exceptionFunc/ RUN cd /go/src/github.com/apache/pulsar/pulsar-function-go && go install ./... RUN cd /go/src/github.com/apache/pulsar/pulsar-function-go/pf && go install RUN cd /go/src/github.com/apache/pulsar/pulsar-function-go/examples && go install ./... diff --git a/tests/docker-images/latest-version-image/go-examples/exceptionFunc/exceptionFunc.go b/tests/docker-images/latest-version-image/go-examples/exceptionFunc/exceptionFunc.go new file mode 100644 index 0000000000000..80ace6e21b9b4 --- /dev/null +++ b/tests/docker-images/latest-version-image/go-examples/exceptionFunc/exceptionFunc.go @@ -0,0 +1,41 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +package main + +import ( + "context" + "errors" + + "github.com/apache/pulsar/pulsar-function-go/pf" +) + +var i int + +func HandleException(ctx context.Context, in []byte) ([]byte, error) { + i++ + if i%10 == 0 { + return nil, errors.New("test") + } + return []byte(string(in) + "!"), nil +} + +func main() { + pf.Start(HandleException) +} diff --git a/tests/docker-images/latest-version-image/pom.xml b/tests/docker-images/latest-version-image/pom.xml index 158ef937374e9..4e4e8480e3554 100644 --- a/tests/docker-images/latest-version-image/pom.xml +++ b/tests/docker-images/latest-version-image/pom.xml @@ -23,7 +23,7 @@ com.ascentstream.pulsar.tests docker-images - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT 4.0.0 latest-version-image @@ -31,7 +31,7 @@ pom - golang:1.24-alpine + golang:1.25-alpine diff --git a/tests/docker-images/pom.xml b/tests/docker-images/pom.xml index 78fc3562cbfdd..715f71205f5e1 100644 --- a/tests/docker-images/pom.xml +++ b/tests/docker-images/pom.xml @@ -27,7 +27,7 @@ com.ascentstream.pulsar.tests tests-parent - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT docker-images Apache Pulsar :: Tests :: Docker Images diff --git a/tests/integration/pom.xml b/tests/integration/pom.xml index 517cdf271f44f..961f33b580341 100644 --- a/tests/integration/pom.xml +++ b/tests/integration/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar.tests tests-parent - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT integration diff --git a/tests/integration/src/test/java/org/apache/pulsar/tests/integration/containers/PulsarContainer.java b/tests/integration/src/test/java/org/apache/pulsar/tests/integration/containers/PulsarContainer.java index 104590045ce86..cd62814cf3527 100644 --- a/tests/integration/src/test/java/org/apache/pulsar/tests/integration/containers/PulsarContainer.java +++ b/tests/integration/src/test/java/org/apache/pulsar/tests/integration/containers/PulsarContainer.java @@ -51,7 +51,7 @@ public abstract class PulsarContainer> exte public static final int BROKER_HTTP_PORT = 8080; public static final int BROKER_HTTPS_PORT = 8081; - public static final String ALPINE_IMAGE_NAME = "alpine:3.20"; + public static final String ALPINE_IMAGE_NAME = "alpine:3.24"; public static final String DEFAULT_IMAGE_NAME = System.getenv().getOrDefault("PULSAR_TEST_IMAGE_NAME", "apachepulsar/pulsar-test-latest-version:latest"); public static final String UPGRADE_TEST_IMAGE_NAME = System.getenv().getOrDefault("PULSAR_UPGRADE_TEST_IMAGE_NAME", diff --git a/tests/integration/src/test/java/org/apache/pulsar/tests/integration/functions/PulsarFunctionsTest.java b/tests/integration/src/test/java/org/apache/pulsar/tests/integration/functions/PulsarFunctionsTest.java index a46f312518a83..dde4f274b2a52 100644 --- a/tests/integration/src/test/java/org/apache/pulsar/tests/integration/functions/PulsarFunctionsTest.java +++ b/tests/integration/src/test/java/org/apache/pulsar/tests/integration/functions/PulsarFunctionsTest.java @@ -400,6 +400,10 @@ protected void testFunctionNegAck(Runtime runtime) throws Exception { submitFunction( runtime, inputTopicName, outputTopicName, functionName, EXCEPTION_FUNCTION_PYTHON_FILE, EXCEPTION_PYTHON_CLASS, schema, null); + } else if (runtime == Runtime.GO) { + submitFunction( + runtime, inputTopicName, outputTopicName, functionName, EXCEPTION_GO_FILE, + null, schema, null); } else { submitFunction( runtime, inputTopicName, outputTopicName, functionName, null, EXCEPTION_JAVA_CLASS, schema, null); diff --git a/tests/integration/src/test/java/org/apache/pulsar/tests/integration/functions/PulsarFunctionsTestBase.java b/tests/integration/src/test/java/org/apache/pulsar/tests/integration/functions/PulsarFunctionsTestBase.java index 288ced63ae5eb..84b31dca9a021 100644 --- a/tests/integration/src/test/java/org/apache/pulsar/tests/integration/functions/PulsarFunctionsTestBase.java +++ b/tests/integration/src/test/java/org/apache/pulsar/tests/integration/functions/PulsarFunctionsTestBase.java @@ -78,6 +78,7 @@ public abstract class PulsarFunctionsTestBase extends PulsarTestSuite { public static final String EXCLAMATION_GO_FILE = "exclamationFunc"; public static final String PUBLISH_FUNCTION_GO_FILE = "exclamationFunc"; + public static final String EXCEPTION_GO_FILE = "exceptionFunc"; public static final String LOGGING_JAVA_CLASS = "org.apache.pulsar.functions.api.examples.LoggingFunction"; diff --git a/tests/integration/src/test/java/org/apache/pulsar/tests/integration/functions/go/PulsarFunctionsGoTest.java b/tests/integration/src/test/java/org/apache/pulsar/tests/integration/functions/go/PulsarFunctionsGoTest.java index 0550fd94ebee2..6e631adafbf7f 100644 --- a/tests/integration/src/test/java/org/apache/pulsar/tests/integration/functions/go/PulsarFunctionsGoTest.java +++ b/tests/integration/src/test/java/org/apache/pulsar/tests/integration/functions/go/PulsarFunctionsGoTest.java @@ -39,4 +39,9 @@ public void testGoExclamationMultiInputsFunction() throws Exception { testExclamationFunction(Runtime.GO, false, false, true, false); } + @Test(groups = {"go_function", "function"}) + public void testGoFunctionNegAck() throws Exception { + testFunctionNegAck(Runtime.GO); + } + } diff --git a/tests/integration/src/test/java/org/apache/pulsar/tests/integration/metrics/OpenTelemetrySanityTest.java b/tests/integration/src/test/java/org/apache/pulsar/tests/integration/metrics/OpenTelemetrySanityTest.java index 4e6847d17235e..6f909c2fd5b7d 100644 --- a/tests/integration/src/test/java/org/apache/pulsar/tests/integration/metrics/OpenTelemetrySanityTest.java +++ b/tests/integration/src/test/java/org/apache/pulsar/tests/integration/metrics/OpenTelemetrySanityTest.java @@ -45,6 +45,7 @@ public class OpenTelemetrySanityTest { // Validate that the OpenTelemetry metrics can be exported to a remote OpenTelemetry collector. @Test(timeOut = 360_000) + @SuppressWarnings("unchecked") public void testOpenTelemetryMetricsOtlpExport() throws Exception { var clusterName = "testOpenTelemetryMetrics-" + UUID.randomUUID(); var openTelemetryCollectorContainer = new OpenTelemetryCollectorContainer(clusterName); @@ -71,7 +72,7 @@ public void testOpenTelemetryMetricsOtlpExport() throws Exception { // TODO: Validate cluster name and service version are present once // https://github.com/open-telemetry/opentelemetry-java/issues/6108 is solved. - var metricName = "queueSize_ratio"; // Sent automatically by the OpenTelemetry SDK. + var metricName = "jvm_cpu_count"; // Configured by the OpenTelemetryService. waitAtMost(90, TimeUnit.SECONDS).ignoreExceptions().pollInterval(1, TimeUnit.SECONDS).until(() -> { var metrics = getMetricsFromPrometheus( openTelemetryCollectorContainer, OpenTelemetryCollectorContainer.PROMETHEUS_EXPORTER_PORT); @@ -95,6 +96,7 @@ public void testOpenTelemetryMetricsOtlpExport() throws Exception { * https://github.com/open-telemetry/opentelemetry-java/blob/main/sdk-extensions/autoconfigure/README.md#prometheus-exporter */ @Test(timeOut = 360_000) + @SuppressWarnings("unchecked") public void testOpenTelemetryMetricsPrometheusExport() throws Exception { var prometheusExporterPort = 9464; var clusterName = "testOpenTelemetryMetrics-" + UUID.randomUUID(); @@ -158,6 +160,7 @@ private static PrometheusMetricsClient.Metrics getMetricsFromPrometheus(ChaosCon return client.getMetrics(); } + @SuppressWarnings("unchecked") private static Map getOpenTelemetryProps(String exporter, Pair ... extraProps) { var defaultProps = Map.of( "OTEL_SDK_DISABLED", "false", diff --git a/tests/pom.xml b/tests/pom.xml index c584f32c2edcf..f23a7efaa3a4c 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -26,7 +26,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT com.ascentstream.pulsar.tests tests-parent diff --git a/tests/pulsar-client-admin-shade-test/pom.xml b/tests/pulsar-client-admin-shade-test/pom.xml index 292b2e06416db..0b8e11c4a50f7 100644 --- a/tests/pulsar-client-admin-shade-test/pom.xml +++ b/tests/pulsar-client-admin-shade-test/pom.xml @@ -26,7 +26,7 @@ com.ascentstream.pulsar.tests tests-parent - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-client-admin-shade-test diff --git a/tests/pulsar-client-all-shade-test/pom.xml b/tests/pulsar-client-all-shade-test/pom.xml index 3f62cb5442a11..56e6978acbc49 100644 --- a/tests/pulsar-client-all-shade-test/pom.xml +++ b/tests/pulsar-client-all-shade-test/pom.xml @@ -26,7 +26,7 @@ com.ascentstream.pulsar.tests tests-parent - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-client-all-shade-test diff --git a/tests/pulsar-client-shade-test/pom.xml b/tests/pulsar-client-shade-test/pom.xml index 93882423a1774..3fece59a94a36 100644 --- a/tests/pulsar-client-shade-test/pom.xml +++ b/tests/pulsar-client-shade-test/pom.xml @@ -27,7 +27,7 @@ com.ascentstream.pulsar.tests tests-parent - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT pulsar-client-shade-test diff --git a/tiered-storage/file-system/pom.xml b/tiered-storage/file-system/pom.xml index 76598f0c33aee..fe126dd6274b1 100644 --- a/tiered-storage/file-system/pom.xml +++ b/tiered-storage/file-system/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar tiered-storage-parent - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT tiered-storage-file-system diff --git a/tiered-storage/jcloud/pom.xml b/tiered-storage/jcloud/pom.xml index 2ce9af08ef6ae..94fba8ee45cfb 100644 --- a/tiered-storage/jcloud/pom.xml +++ b/tiered-storage/jcloud/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar tiered-storage-parent - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT tiered-storage-jcloud diff --git a/tiered-storage/jcloud/src/main/java/org/apache/bookkeeper/mledger/offload/jcloud/provider/JCloudBlobStoreProvider.java b/tiered-storage/jcloud/src/main/java/org/apache/bookkeeper/mledger/offload/jcloud/provider/JCloudBlobStoreProvider.java index f859f2b73284b..b8d30935b8d8a 100644 --- a/tiered-storage/jcloud/src/main/java/org/apache/bookkeeper/mledger/offload/jcloud/provider/JCloudBlobStoreProvider.java +++ b/tiered-storage/jcloud/src/main/java/org/apache/bookkeeper/mledger/offload/jcloud/provider/JCloudBlobStoreProvider.java @@ -429,6 +429,25 @@ public String getAWSSecretKey() { }; static final CredentialBuilder S3_CREDENTIAL_BUILDER = (TieredStorageConfiguration config) -> { + if (config.getCredentials() != null) { + return; + } + // Credentials provided in the tiered storage configuration take + // precedence over the environment variables. Offload policies carry + // credentials under the s3-prefixed keys for every S3-compatible + // driver (see OffloadPoliciesImpl), so those are the keys accepted. + String configId = config.getConfigProperty(S3_ID_FIELD); + String configSecret = config.getConfigProperty(S3_SECRET_FIELD); + if (StringUtils.isNotBlank(configId) || StringUtils.isNotBlank(configSecret)) { + if (StringUtils.isBlank(configId) || StringUtils.isBlank(configSecret)) { + throw new IllegalArgumentException( + "Both " + S3_ID_FIELD + " and " + S3_SECRET_FIELD + + " must be set when providing offload credentials in the configuration"); + } + Credentials credentials = new Credentials(configId, configSecret); + config.setProviderCredentials(() -> credentials); + return; + } String accountName = System.getenv().getOrDefault("ACCESS_KEY_ID", ""); // For forward compatibility if (StringUtils.isEmpty(accountName.trim())) { diff --git a/tiered-storage/jcloud/src/test/java/org/apache/bookkeeper/mledger/offload/jcloud/provider/JCloudBlobStoreProviderTest.java b/tiered-storage/jcloud/src/test/java/org/apache/bookkeeper/mledger/offload/jcloud/provider/JCloudBlobStoreProviderTest.java index f45cb30ea4c2b..7ad2eef99474f 100644 --- a/tiered-storage/jcloud/src/test/java/org/apache/bookkeeper/mledger/offload/jcloud/provider/JCloudBlobStoreProviderTest.java +++ b/tiered-storage/jcloud/src/test/java/org/apache/bookkeeper/mledger/offload/jcloud/provider/JCloudBlobStoreProviderTest.java @@ -19,8 +19,10 @@ package org.apache.bookkeeper.mledger.offload.jcloud.provider; import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; import java.util.HashMap; import java.util.Map; +import org.jclouds.domain.Credentials; import org.testng.annotations.Test; public class JCloudBlobStoreProviderTest { @@ -130,4 +132,48 @@ public void s3ValidationBucketMissed() { TieredStorageConfiguration configuration = new TieredStorageConfiguration(map); configuration.getProvider().validate(configuration); } + + // Offload policies (OffloadPoliciesImpl) carry credentials under the + // s3-prefixed keys for every S3-compatible driver + private TieredStorageConfiguration credentialConfig(String driver, String id, String secret) { + Map map = new HashMap<>(); + map.put("managedLedgerOffloadDriver", driver); + map.put("managedLedgerOffloadServiceEndpoint", "http://storage.service"); + map.put("managedLedgerOffloadBucket", "test-bucket"); + if (id != null) { + map.put("s3ManagedLedgerOffloadCredentialId", id); + } + if (secret != null) { + map.put("s3ManagedLedgerOffloadCredentialSecret", secret); + } + return new TieredStorageConfiguration(map); + } + + private void assertCredentialsFromConfig(String driver) { + TieredStorageConfiguration configuration = + credentialConfig(driver, "config-access-id", "config-access-secret"); + configuration.getProvider().buildCredentials(configuration); + assertNotNull(configuration.getProviderCredentials()); + Credentials credentials = configuration.getProviderCredentials().get(); + assertEquals(credentials.identity, "config-access-id"); + assertEquals(credentials.credential, "config-access-secret"); + } + + @Test + public void s3CredentialsFromConfigTest() { + assertCredentialsFromConfig("S3"); + } + + @Test + public void aliyunOssCredentialsFromConfigTest() { + assertCredentialsFromConfig("aliyun-oss"); + } + + @Test(expectedExceptions = IllegalArgumentException.class, + expectedExceptionsMessageRegExp = "Both s3ManagedLedgerOffloadCredentialId and " + + "s3ManagedLedgerOffloadCredentialSecret must be set.*") + public void s3PartialCredentialsFromConfigTest() { + TieredStorageConfiguration configuration = credentialConfig("S3", "config-access-id", null); + configuration.getProvider().buildCredentials(configuration); + } } diff --git a/tiered-storage/jcloud/src/test/resources/log4j2-test.yml b/tiered-storage/jcloud/src/test/resources/log4j2-test.yml index f5ee5c9a53dd7..7371e668f9210 100644 --- a/tiered-storage/jcloud/src/test/resources/log4j2-test.yml +++ b/tiered-storage/jcloud/src/test/resources/log4j2-test.yml @@ -33,12 +33,12 @@ Configuration: name: STDOUT target: SYSTEM_OUT PatternLayout: - Pattern: "%d{ISO8601_OFFSET_DATE_TIME_HHMM} [%t] %-5level %logger{36} - %msg%n" + Pattern: "%d{ISO8601_OFFSET_DATE_TIME_HHMM} [%t] %-5level %logger{36} - %msg%equals{ %X}{ {}}{}%n" File: name: File fileName: ${filename} PatternLayout: - Pattern: "%d %p %c{1.} [%t] %m%n" + Pattern: "%d %p %c{1.} [%t] %msg%equals{ %X}{ {}}{}%n" Filters: ThresholdFilter: level: error diff --git a/tiered-storage/pom.xml b/tiered-storage/pom.xml index d1923b01870a3..f5126b059fdcf 100644 --- a/tiered-storage/pom.xml +++ b/tiered-storage/pom.xml @@ -25,7 +25,7 @@ com.ascentstream.pulsar pulsar - 4.0.11.1-226281f-20260709031812-SNAPSHOT + 4.0.14-SNAPSHOT tiered-storage-parent