diff --git a/.github/workflows/bp-main.yml b/.github/workflows/bp-main.yml new file mode 100644 index 0000000..d3edea3 --- /dev/null +++ b/.github/workflows/bp-main.yml @@ -0,0 +1,54 @@ +name: Batch Publish Main Snapshot + +on: + push: + branches: + - main + paths: + - 'batch-publish/**' + +jobs: + build: + runs-on: ubuntu-latest + env: + BUILD_EVENT: ${{ github.event_name }} + OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }} + OSSRH_PASSWORD: ${{ secrets.OSSRH_TOKEN }} + SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} + SIGNING_KEY: ${{ secrets.SIGNING_KEY }} + SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} + steps: + - name: Set up JDK + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + - name: Install Nats Server + run: | + pkill -9 nats-server 2>/dev/null || true + mkdir -p ~/.local/bin + cd $GITHUB_WORKSPACE + git clone https://github.com/nats-io/nats-server.git + cd nats-server + go build -o ~/.local/bin/nats-server + nats-server -v + - name: Check out code + uses: actions/checkout@v4 + - name: Compile and Test + run: | + pushd batch-publish + chmod +x gradlew && ./gradlew clean test + popd + - name: Verify Javadoc + run: | + pushd batch-publish + ./gradlew javadoc + popd + - name: Publish Snapshot + run: | + pushd batch-publish + ./gradlew -i publishToSonatype + popd + - name: Clean up + if: always() + run: pkill -9 nats-server 2>/dev/null || true diff --git a/.github/workflows/bp-pr.yml b/.github/workflows/bp-pr.yml new file mode 100644 index 0000000..0e5eb97 --- /dev/null +++ b/.github/workflows/bp-pr.yml @@ -0,0 +1,48 @@ +name: Batch Publish Pull Request + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'batch-publish/**' + +jobs: + build: + runs-on: ubuntu-latest + env: + BUILD_EVENT: ${{ github.event_name }} + OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }} + OSSRH_PASSWORD: ${{ secrets.OSSRH_TOKEN }} + SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} + SIGNING_KEY: ${{ secrets.SIGNING_KEY }} + SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} + steps: + - name: Set up JDK + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + - name: Install Nats Server + run: | + pkill -9 nats-server 2>/dev/null || true + mkdir -p ~/.local/bin + cd $GITHUB_WORKSPACE + git clone https://github.com/nats-io/nats-server.git + cd nats-server + go build -o ~/.local/bin/nats-server + nats-server -v + - name: Check out code + uses: actions/checkout@v4 + - name: Build and Test + run: | + pushd batch-publish + chmod +x gradlew && ./gradlew clean test + popd + - name: Verify Javadoc + run: | + pushd batch-publish + ./gradlew javadoc + popd + - name: Clean up + if: always() + run: pkill -9 nats-server 2>/dev/null || true diff --git a/.github/workflows/bp-release.yml b/.github/workflows/bp-release.yml new file mode 100644 index 0000000..f4d192f --- /dev/null +++ b/.github/workflows/bp-release.yml @@ -0,0 +1,46 @@ +name: Batch Publish Release + +on: + push: + tags: [ 'bp/*' ] + +jobs: + build: + runs-on: ubuntu-latest + env: + BUILD_EVENT: "release" + OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }} + OSSRH_PASSWORD: ${{ secrets.OSSRH_TOKEN }} + SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} + SIGNING_KEY: ${{ secrets.SIGNING_KEY }} + SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} + steps: + - name: Set up JDK + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + - name: Install Nats Server + run: | + pkill -9 nats-server 2>/dev/null || true + mkdir -p ~/.local/bin + cd $GITHUB_WORKSPACE + git clone https://github.com/nats-io/nats-server.git + cd nats-server + go build -o ~/.local/bin/nats-server + nats-server -v + - name: Check out code + uses: actions/checkout@v4 + - name: Compile and Test + run: | + pushd batch-publish + chmod +x gradlew && ./gradlew clean test + popd + - name: Verify, Sign and Publish Release + run: | + pushd batch-publish + ./gradlew -i publishToSonatype closeAndReleaseSonatypeStagingRepository + popd + - name: Clean up + if: always() + run: pkill -9 nats-server 2>/dev/null || true diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 0000000..bf8fd73 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,33 @@ +name: Claude Code + +# GITHUB_TOKEN needs contents:read and actions:read — required by +# claude-code-action for restoring trusted config files from the base branch. +# All other GitHub API access uses the App token. +permissions: + contents: read + actions: read + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + pull_request_target: + types: [opened, reopened, ready_for_review] + +jobs: + claude: + if: github.event_name != 'pull_request_target' || !contains(github.event.pull_request.body, '[skip claude]') + uses: synadia-io/ai-workflows/.github/workflows/claude.yml@v2 + with: + gh_app_id: ${{ vars.CLAUDE_GH_APP_ID }} + checkout_mode: base + review_focus: | + Additionally focus on: + - Thread safety and proper synchronization (concurrent access, connection lifecycle) + - Exception handling patterns (checked vs unchecked, proper resource cleanup with try-with-resources) + - JetStream API correctness (subscription semantics, ack/nak behavior, consumer configuration) + - API compatibility with existing public interfaces + secrets: + claude_oauth_token: ${{ secrets.CLAUDE_OAUTH_TOKEN }} + gh_app_private_key: ${{ secrets.CLAUDE_GH_APP_PRIVATE_KEY }} diff --git a/.github/workflows/counters-main.yml b/.github/workflows/counters-main.yml new file mode 100644 index 0000000..04569db --- /dev/null +++ b/.github/workflows/counters-main.yml @@ -0,0 +1,15 @@ +name: Counter Main Snapshot + +on: + push: + branches: + - main + paths: + - 'counters/**' + +jobs: + build: + uses: synadia-io/workflows/.github/workflows/java-standard-main.yml@main + with: + project-dir: counters + secrets: inherit diff --git a/.github/workflows/counters-pr.yml b/.github/workflows/counters-pr.yml new file mode 100644 index 0000000..be08ff8 --- /dev/null +++ b/.github/workflows/counters-pr.yml @@ -0,0 +1,14 @@ +name: Counter Pull Request + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'counters/**' + +jobs: + build: + uses: synadia-io/workflows/.github/workflows/java-standard-pr.yml@main + with: + project-dir: counters + secrets: inherit diff --git a/.github/workflows/counters-release.yml b/.github/workflows/counters-release.yml new file mode 100644 index 0000000..5e357e2 --- /dev/null +++ b/.github/workflows/counters-release.yml @@ -0,0 +1,12 @@ +name: Counter Release + +on: + push: + tags: [ 'counters/*' ] + +jobs: + build: + uses: synadia-io/workflows/.github/workflows/java-standard-release.yml@main + with: + project-dir: counters + secrets: inherit diff --git a/.github/workflows/cr-main.yml b/.github/workflows/cr-main.yml index a162f89..babbd7f 100644 --- a/.github/workflows/cr-main.yml +++ b/.github/workflows/cr-main.yml @@ -7,9 +7,6 @@ on: paths: - 'chaos-runner/**' -env: - GODEBUG: x509sha1=1 - jobs: build: runs-on: ubuntu-latest @@ -20,7 +17,6 @@ jobs: SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} SIGNING_KEY: ${{ secrets.SIGNING_KEY }} SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} - GODEBUG: x509sha1=1 steps: - name: Set up JDK 8 uses: actions/setup-java@v3 diff --git a/.github/workflows/cr-pr.yml b/.github/workflows/cr-pr.yml index 337c884..9d59f24 100644 --- a/.github/workflows/cr-pr.yml +++ b/.github/workflows/cr-pr.yml @@ -6,9 +6,6 @@ on: paths: - 'chaos-runner/**' -env: - GODEBUG: x509sha1=1 - jobs: build: runs-on: ubuntu-latest @@ -19,7 +16,6 @@ jobs: SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} SIGNING_KEY: ${{ secrets.SIGNING_KEY }} SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} - GODEBUG: x509sha1=1 steps: - name: Set up JDK 8 uses: actions/setup-java@v3 diff --git a/.github/workflows/cr-release.yml b/.github/workflows/cr-release.yml index 47f7cdd..a01b18a 100644 --- a/.github/workflows/cr-release.yml +++ b/.github/workflows/cr-release.yml @@ -4,9 +4,6 @@ on: push: tags: [ 'cr/*' ] -env: - GODEBUG: x509sha1=1 - jobs: build: runs-on: ubuntu-latest @@ -17,7 +14,6 @@ jobs: SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} SIGNING_KEY: ${{ secrets.SIGNING_KEY }} SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} - GODEBUG: x509sha1=1 steps: - name: Set up JDK 8 uses: actions/setup-java@v3 diff --git a/.github/workflows/db-main.yml b/.github/workflows/db-main.yml index 690cbd0..8ba10a6 100644 --- a/.github/workflows/db-main.yml +++ b/.github/workflows/db-main.yml @@ -7,9 +7,6 @@ on: paths: - 'direct-batch/**' -env: - GODEBUG: x509sha1=1 - jobs: build: runs-on: ubuntu-latest @@ -20,7 +17,6 @@ jobs: SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} SIGNING_KEY: ${{ secrets.SIGNING_KEY }} SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} - GODEBUG: x509sha1=1 steps: - name: Set up JDK 8 uses: actions/setup-java@v3 diff --git a/.github/workflows/db-pr.yml b/.github/workflows/db-pr.yml index 19c54c8..92f1b62 100644 --- a/.github/workflows/db-pr.yml +++ b/.github/workflows/db-pr.yml @@ -6,9 +6,6 @@ on: paths: - 'direct-batch/**' -env: - GODEBUG: x509sha1=1 - jobs: build: runs-on: ubuntu-latest @@ -19,7 +16,6 @@ jobs: SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} SIGNING_KEY: ${{ secrets.SIGNING_KEY }} SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} - GODEBUG: x509sha1=1 steps: - name: Set up JDK 8 uses: actions/setup-java@v3 diff --git a/.github/workflows/db-release.yml b/.github/workflows/db-release.yml index 0d2fb92..b1db120 100644 --- a/.github/workflows/db-release.yml +++ b/.github/workflows/db-release.yml @@ -4,9 +4,6 @@ on: push: tags: [ 'db/*' ] -env: - GODEBUG: x509sha1=1 - jobs: build: runs-on: ubuntu-latest @@ -17,7 +14,6 @@ jobs: SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} SIGNING_KEY: ${{ secrets.SIGNING_KEY }} SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} - GODEBUG: x509sha1=1 steps: - name: Set up JDK 8 uses: actions/setup-java@v3 diff --git a/.github/workflows/ekv-main.yml b/.github/workflows/ekv-main.yml index 26b6c59..30c4c98 100644 --- a/.github/workflows/ekv-main.yml +++ b/.github/workflows/ekv-main.yml @@ -7,9 +7,6 @@ on: paths: - 'encoded-kv/**' -env: - GODEBUG: x509sha1=1 - jobs: build: runs-on: ubuntu-latest @@ -20,7 +17,6 @@ jobs: SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} SIGNING_KEY: ${{ secrets.SIGNING_KEY }} SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} - GODEBUG: x509sha1=1 steps: - name: Set up JDK 8 uses: actions/setup-java@v3 diff --git a/.github/workflows/ekv-pr.yml b/.github/workflows/ekv-pr.yml index 15e0e9b..9b87465 100644 --- a/.github/workflows/ekv-pr.yml +++ b/.github/workflows/ekv-pr.yml @@ -6,9 +6,6 @@ on: paths: - 'encoded-kv/**' -env: - GODEBUG: x509sha1=1 - jobs: build: runs-on: ubuntu-latest @@ -19,7 +16,6 @@ jobs: SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} SIGNING_KEY: ${{ secrets.SIGNING_KEY }} SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} - GODEBUG: x509sha1=1 steps: - name: Set up JDK 8 uses: actions/setup-java@v3 diff --git a/.github/workflows/ekv-release.yml b/.github/workflows/ekv-release.yml index 699bee7..dacdddc 100644 --- a/.github/workflows/ekv-release.yml +++ b/.github/workflows/ekv-release.yml @@ -1,12 +1,9 @@ -name: Publish Extensions Release +name: Encoded KV Release on: push: tags: [ 'ekv/*' ] -env: - GODEBUG: x509sha1=1 - jobs: build: runs-on: ubuntu-latest @@ -17,7 +14,6 @@ jobs: SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} SIGNING_KEY: ${{ secrets.SIGNING_KEY }} SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} - GODEBUG: x509sha1=1 steps: - name: Set up JDK 8 uses: actions/setup-java@v3 diff --git a/.github/workflows/pcg-main.yml b/.github/workflows/pcg-main.yml new file mode 100644 index 0000000..0292b94 --- /dev/null +++ b/.github/workflows/pcg-main.yml @@ -0,0 +1,15 @@ +name: Partitioned Consumer Groups Main Snapshot + +on: + push: + branches: + - main + paths: + - 'pcgroups/**' + +jobs: + build: + uses: synadia-io/workflows/.github/workflows/java-standard-main.yml@main + with: + project-dir: pcgroups + secrets: inherit diff --git a/.github/workflows/pcg-pr.yml b/.github/workflows/pcg-pr.yml new file mode 100644 index 0000000..8032301 --- /dev/null +++ b/.github/workflows/pcg-pr.yml @@ -0,0 +1,14 @@ +name: Partitioned Consumer Groups Pull Request + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'pcgroups/**' + +jobs: + build: + uses: synadia-io/workflows/.github/workflows/java-standard-pr.yml@main + with: + project-dir: pcgroups + secrets: inherit diff --git a/.github/workflows/pcg-release.yml b/.github/workflows/pcg-release.yml new file mode 100644 index 0000000..97ea73f --- /dev/null +++ b/.github/workflows/pcg-release.yml @@ -0,0 +1,12 @@ +name: Partitioned Consumer Groups Release + +on: + push: + tags: [ 'pcg/*' ] + +jobs: + build: + uses: synadia-io/workflows/.github/workflows/java-standard-release.yml@main + with: + project-dir: pcgroups + secrets: inherit diff --git a/.github/workflows/pcgcli-release.yml b/.github/workflows/pcgcli-release.yml new file mode 100644 index 0000000..ccbe248 --- /dev/null +++ b/.github/workflows/pcgcli-release.yml @@ -0,0 +1,36 @@ +name: Partitioned Consumer Groups CLI Release + +on: + push: + tags: [ 'pcgcli/*' ] + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + defaults: + run: + working-directory: ./pcgroups + steps: + - name: Set up JDK + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + + - name: Check out code + uses: actions/checkout@v4 + + - name: Build distribution + run: chmod +x gradlew && ./gradlew :pcgroups-cli:clean :pcgroups-cli:dist + + - name: Upload release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + CLI_BUILD="../pcgroups-cli/build" + gh release upload "${{ github.ref_name }}" \ + "$CLI_BUILD/cg.jar" \ + "$CLI_BUILD/cg.tar" \ + --clobber diff --git a/.github/workflows/pcgcli.yml b/.github/workflows/pcgcli.yml new file mode 100644 index 0000000..bf3fdc2 --- /dev/null +++ b/.github/workflows/pcgcli.yml @@ -0,0 +1,38 @@ +name: Partitioned Consumer Groups CLI Build + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'pcgroups-cli/**' + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./pcgroups + steps: + - name: Set up JDK + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + - name: Check out code + uses: actions/checkout@v4 + - name: Build with Gradle + run: chmod +x gradlew && ./gradlew :pcgroups-cli:clean :pcgroups-cli:dist + - name: Validate artifacts were created + run: | + CLI_BUILD="../pcgroups-cli/build" + for f in cg.jar cg.zip cg.tar; do + if [ -f "$CLI_BUILD/$f" ]; then + echo "Validation successful: $CLI_BUILD/$f was created." + else + echo "Validation failed: $CLI_BUILD/$f was not found." + exit 1 + fi + done diff --git a/.github/workflows/pubx-main.yml b/.github/workflows/pubx-main.yml index 4de4f09..dfe93d7 100644 --- a/.github/workflows/pubx-main.yml +++ b/.github/workflows/pubx-main.yml @@ -7,9 +7,6 @@ on: paths: - 'js-publish-extensions/**' -env: - GODEBUG: x509sha1=1 - jobs: build: runs-on: ubuntu-latest @@ -20,7 +17,6 @@ jobs: SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} SIGNING_KEY: ${{ secrets.SIGNING_KEY }} SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} - GODEBUG: x509sha1=1 steps: - name: Set up JDK 8 uses: actions/setup-java@v3 diff --git a/.github/workflows/pubx-pr.yml b/.github/workflows/pubx-pr.yml index d04ccf0..b9c9a82 100644 --- a/.github/workflows/pubx-pr.yml +++ b/.github/workflows/pubx-pr.yml @@ -6,9 +6,6 @@ on: paths: - 'js-publish-extensions/**' -env: - GODEBUG: x509sha1=1 - jobs: build: runs-on: ubuntu-latest @@ -19,7 +16,6 @@ jobs: SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} SIGNING_KEY: ${{ secrets.SIGNING_KEY }} SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} - GODEBUG: x509sha1=1 steps: - name: Set up JDK 8 uses: actions/setup-java@v3 diff --git a/.github/workflows/pubx-release.yml b/.github/workflows/pubx-release.yml index 8f5c08e..efdf855 100644 --- a/.github/workflows/pubx-release.yml +++ b/.github/workflows/pubx-release.yml @@ -4,9 +4,6 @@ on: push: tags: [ 'pubx/*' ] -env: - GODEBUG: x509sha1=1 - jobs: build: runs-on: ubuntu-latest @@ -17,7 +14,6 @@ jobs: SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} SIGNING_KEY: ${{ secrets.SIGNING_KEY }} SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} - GODEBUG: x509sha1=1 steps: - name: Set up JDK 8 uses: actions/setup-java@v3 diff --git a/.github/workflows/retrier-main.yml b/.github/workflows/retrier-main.yml index ef3df39..7d51520 100644 --- a/.github/workflows/retrier-main.yml +++ b/.github/workflows/retrier-main.yml @@ -7,9 +7,6 @@ on: paths: - 'retrier/**' -env: - GODEBUG: x509sha1=1 - jobs: build: runs-on: ubuntu-latest @@ -20,7 +17,6 @@ jobs: SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} SIGNING_KEY: ${{ secrets.SIGNING_KEY }} SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} - GODEBUG: x509sha1=1 steps: - name: Set up JDK 8 uses: actions/setup-java@v3 diff --git a/.github/workflows/retrier-pr.yml b/.github/workflows/retrier-pr.yml index 79b891f..31beed7 100644 --- a/.github/workflows/retrier-pr.yml +++ b/.github/workflows/retrier-pr.yml @@ -6,9 +6,6 @@ on: paths: - 'retrier/**' -env: - GODEBUG: x509sha1=1 - jobs: build: runs-on: ubuntu-latest @@ -19,7 +16,6 @@ jobs: SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} SIGNING_KEY: ${{ secrets.SIGNING_KEY }} SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} - GODEBUG: x509sha1=1 steps: - name: Set up JDK 8 uses: actions/setup-java@v3 diff --git a/.github/workflows/retrier-release.yml b/.github/workflows/retrier-release.yml index 42a54ee..853e04c 100644 --- a/.github/workflows/retrier-release.yml +++ b/.github/workflows/retrier-release.yml @@ -4,9 +4,6 @@ on: push: tags: [ 'retrier/*' ] -env: - GODEBUG: x509sha1=1 - jobs: build: runs-on: ubuntu-latest @@ -17,7 +14,6 @@ jobs: SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} SIGNING_KEY: ${{ secrets.SIGNING_KEY }} SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} - GODEBUG: x509sha1=1 steps: - name: Set up JDK 8 uses: actions/setup-java@v3 diff --git a/.github/workflows/rm-main.yml b/.github/workflows/rm-main.yml index 7d1b0b8..c9e62ea 100644 --- a/.github/workflows/rm-main.yml +++ b/.github/workflows/rm-main.yml @@ -7,9 +7,6 @@ on: paths: - 'request-many/**' -env: - GODEBUG: x509sha1=1 - jobs: build: runs-on: ubuntu-latest @@ -20,7 +17,6 @@ jobs: SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} SIGNING_KEY: ${{ secrets.SIGNING_KEY }} SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} - GODEBUG: x509sha1=1 steps: - name: Set up JDK 8 uses: actions/setup-java@v3 diff --git a/.github/workflows/rm-pr.yml b/.github/workflows/rm-pr.yml index 8527a70..5938636 100644 --- a/.github/workflows/rm-pr.yml +++ b/.github/workflows/rm-pr.yml @@ -6,9 +6,6 @@ on: paths: - 'request-many/**' -env: - GODEBUG: x509sha1=1 - jobs: build: runs-on: ubuntu-latest @@ -19,7 +16,6 @@ jobs: SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} SIGNING_KEY: ${{ secrets.SIGNING_KEY }} SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} - GODEBUG: x509sha1=1 steps: - name: Set up JDK 8 uses: actions/setup-java@v3 diff --git a/.github/workflows/rm-release.yml b/.github/workflows/rm-release.yml index e6ab31c..d186d36 100644 --- a/.github/workflows/rm-release.yml +++ b/.github/workflows/rm-release.yml @@ -4,9 +4,6 @@ on: push: tags: [ 'rm/*' ] -env: - GODEBUG: x509sha1=1 - jobs: build: runs-on: ubuntu-latest @@ -17,7 +14,6 @@ jobs: SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} SIGNING_KEY: ${{ secrets.SIGNING_KEY }} SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} - GODEBUG: x509sha1=1 steps: - name: Set up JDK 8 uses: actions/setup-java@v3 diff --git a/.github/workflows/sm-main.yml b/.github/workflows/sm-main.yml new file mode 100644 index 0000000..ba72d3f --- /dev/null +++ b/.github/workflows/sm-main.yml @@ -0,0 +1,15 @@ +name: Scheduled Message Main Snapshot + +on: + push: + branches: + - main + paths: + - 'schedule-message/**' + +jobs: + build: + uses: synadia-io/workflows/.github/workflows/java-standard-main.yml@main + with: + project-dir: schedule-message + secrets: inherit diff --git a/.github/workflows/sm-pr.yml b/.github/workflows/sm-pr.yml new file mode 100644 index 0000000..bf7779f --- /dev/null +++ b/.github/workflows/sm-pr.yml @@ -0,0 +1,14 @@ +name: Scheduled Message Pull Request + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'schedule-message/**' + +jobs: + build: + uses: synadia-io/workflows/.github/workflows/java-standard-pr.yml@main + with: + project-dir: schedule-message + secrets: inherit diff --git a/.github/workflows/sm-release.yml b/.github/workflows/sm-release.yml new file mode 100644 index 0000000..b342e3d --- /dev/null +++ b/.github/workflows/sm-release.yml @@ -0,0 +1,12 @@ +name: Scheduled Message Release + +on: + push: + tags: [ 'sm/*' ] + +jobs: + build: + uses: synadia-io/workflows/.github/workflows/java-standard-release.yml@main + with: + project-dir: schedule-message + secrets: inherit diff --git a/README.md b/README.md index 77d0de6..538c89d 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,147 @@

- Orbit + Orbit

Orbit.java is a set of independent utilities or extensions around the [JNATS](https://github.com/nats-io/nats.java) ecosystem that aims to boost productivity and provide a higher abstraction layer for the [JNATS](https://github.com/nats-io/nats.java) -client. Note that these libraries will evolve rapidly and API guarantees are -not made until the specific project has a v1.0.0 version. +client. Note that these libraries will evolve rapidly and API guarantees are general not made until the specific project has a v1.0.0 version. # Utilities +| Project | Description | Release Version | Snapshot | +|-------------------------------------------------------|---------------------------------------------------------|-----------------|----------------| +| [Retrier](retrier) | Extension for retrying anything | 0.2.1 | 0.2.2-SNAPSHOT | +| [Jetstream Publish Extensions](js-publish-extensions) | General extensions for Jetstream Publishing | 0.4.4 | 0.4.5-SNAPSHOT | +| [Request Many](request-many) | Get many responses for a single core request. | 0.1.1 | 0.1.2-SNAPSHOT | +| [Encoded KeyValue](encoded-kv) | Allow custom encoding of keys and values. | 0.1.1 | 0.1.2-SNAPSHOT | +| [Direct Batch](direct-batch) | Leverages direct message capabilities in NATS Server | 0.0.4 | 0.0.5-SNAPSHOT | +| [Batch Publish](batch-publish) | Publish an atomic batch | 0.2.2 | 0.2.3-SNAPSHOT | +| [Distributed Counters](counters) | Leverage distributed counters functionality | 0.2.2 | 0.2.3-SNAPSHOT | +| [Scheduled Message](schedule-message) | Leverage ability to schedule a message | 0.0.3 | 0.0.4-SNAPSHOT | +| [Chaos Runner](chaos-runner) | Run some NATS servers and cause chaos | 0.0.8 | 0.0.9-SNAPSHOT | +| [Partitioned Consumer Groups](pcgroups) | Partitioned Consumer Group funcitionality for JetStream | 0.1.1 | 0.1.1-SNAPSHOT | +| [Partitioned Consumer Groups CLI](pcgroups-cli) | Partitioned Consumer Group CLI | 0.1.0 | N/A | ## Retrier -Extension for retrying anything. +Extension for retrying anything. -[![README](https://img.shields.io/badge/README-blue?style=flat&link=retrier/README.md)](retrier/README.md) -![Artifact](https://img.shields.io/badge/Artifact-io.synadia:retrier-00BC8E?labelColor=grey&style=flat) +[Retrier README](retrier/README.md) + +![Artifact](https://img.shields.io/badge/Artifact-io.synadia:retrier-197556?labelColor=grey&style=flat) +![0.2.1](https://img.shields.io/badge/Current_Release-0.2.1-27AAE0) +![0.2.2](https://img.shields.io/badge/Current_Snapshot-0.2.2--SNAPSHOT-27AAE0) [![javadoc](https://javadoc.io/badge2/io.synadia/retrier/javadoc.svg)](https://javadoc.io/doc/io.synadia/retrier) -[![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.synadia/retrier/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.synadia/retrier) +[![Maven Central](https://img.shields.io/maven-central/v/io.synadia/retrier)](https://img.shields.io/maven-central/v/io.synadia/retrier) + +## Jetstream Publish Extensions -## JS Publish Extensions +General extensions for Jetstream Publishing -Extensions around Jetstream Publishing +[Jetstream Publish Extensions README](js-publish-extensions/README.md) -[![README](https://img.shields.io/badge/README-blue?style=flat&link=js-publish-extensions/README.md)](js-publish-extensions/README.md) -![Artifact](https://img.shields.io/badge/Artifact-io.synadia:jnats--js--publish--extensions-00BC8E?labelColor=grey&style=flat) +![Artifact](https://img.shields.io/badge/Artifact-io.synadia:jnats--js--publish--extensions-197556?labelColor=grey&style=flat) +![0.4.4](https://img.shields.io/badge/Current_Release-0.4.4-27AAE0) +![0.4.5](https://img.shields.io/badge/Current_Snapshot-0.4.5--SNAPSHOT-27AAE0) [![javadoc](https://javadoc.io/badge2/io.synadia/jnats-js-publish-extensions/javadoc.svg)](https://javadoc.io/doc/io.synadia/jnats-js-publish-extensions) -[![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.synadia/jnats-js-publish-extensions/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.synadia/jnats-js-publish-extensions) +[![Maven Central](https://img.shields.io/maven-central/v/io.synadia/jnats-js-publish-extensions)](https://img.shields.io/maven-central/v/io.synadia/jnats-js-publish-extensions) ## Request Many Extension to get many responses for a single core request. -[![README](https://img.shields.io/badge/README-blue?style=flat&link=request-many/README.md)](request-many/README.md) -![Artifact](https://img.shields.io/badge/Artifact-io.synadia:request--many-00BC8E?labelColor=grey&style=flat) +[Request Many README](request-many/README.md) + +![Artifact](https://img.shields.io/badge/Artifact-io.synadia:request--many-197556?labelColor=grey&style=flat) +![0.1.1](https://img.shields.io/badge/Current_Release-0.1.1-27AAE0) +![0.1.2](https://img.shields.io/badge/Current_Snapshot-0.1.2--SNAPSHOT-27AAE0) [![javadoc](https://javadoc.io/badge2/io.synadia/request-many/javadoc.svg)](https://javadoc.io/doc/io.synadia/request-many) -[![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.synadia/request-many/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.synadia/request-many) +[![Maven Central](https://img.shields.io/maven-central/v/io.synadia/request-many)](https://img.shields.io/maven-central/v/io.synadia/request-many) ## Encoded KeyValue Extension over Key Value to allow custom encoding of keys and values. -[![README](https://img.shields.io/badge/README-blue?style=flat&link=encoded-kv/README.md)](encoded-kv/README.md) -![Artifact](https://img.shields.io/badge/Artifact-io.synadia:encoded--kv-00BC8E?labelColor=grey&style=flat) +[Encoded KeyValue README](encoded-kv/README.md) + +![Artifact](https://img.shields.io/badge/Artifact-io.synadia:encoded--kv-197556?labelColor=grey&style=flat) +![0.0.4](https://img.shields.io/badge/Current_Release-0.0.4-27AAE0) +![0.0.5](https://img.shields.io/badge/Current_Snapshot-0.0.5--SNAPSHOT-27AAE0) [![javadoc](https://javadoc.io/badge2/io.synadia/encoded-kv/javadoc.svg)](https://javadoc.io/doc/io.synadia/encoded-kv) -[![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.synadia/encoded-kv/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.synadia/encoded-kv) +[![Maven Central](https://img.shields.io/maven-central/v/io.synadia/encoded-kv)](https://img.shields.io/maven-central/v/io.synadia/encoded-kv) ## Direct Batch The direct batch functionality leverages the direct message capabilities introduced in NATS Server v2.11. The functionality is described in [ADR-31](https://github.com/nats-io/nats-architecture-and-design/blob/main/adr/ADR-31.md) -[![README](https://img.shields.io/badge/README-blue?style=flat&link=direct-batch/README.md)](direct-batch/README.md) -![Artifact](https://img.shields.io/badge/Artifact-io.synadia:direct--batch-00BC8E?labelColor=grey&style=flat) +[Direct Batch README](direct-batch/README.md) + +![Artifact](https://img.shields.io/badge/Artifact-io.synadia:direct--batch-197556?labelColor=grey&style=flat) +![0.1.4](https://img.shields.io/badge/Current_Release-0.1.4-27AAE0) +![0.1.5](https://img.shields.io/badge/Current_Snapshot-0.1.5--SNAPSHOT-27AAE0) [![javadoc](https://javadoc.io/badge2/io.synadia/direct-batch/javadoc.svg)](https://javadoc.io/doc/io.synadia/direct-batch) -[![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.synadia/direct-batch/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.synadia/direct-batch) +[![Maven Central](https://img.shields.io/maven-central/v/io.synadia/direct-batch)](https://img.shields.io/maven-central/v/io.synadia/direct-batch) + +### Batch Publish + +Utility to publish an atomic batch, a group of up to 1000 messages + +[Batch Publish README](batch-publish/README.md) + +![Artifact](https://img.shields.io/badge/Artifact-io.synadia:batch--publish-197556?labelColor=grey&style=flat) +![0.2.2](https://img.shields.io/badge/Current_Release-0.2.2-27AAE0) +![0.2.3](https://img.shields.io/badge/Current_Snapshot-0.2.3--SNAPSHOT-27AAE0) +[![javadoc](https://javadoc.io/badge2/io.synadia/batch-publish/javadoc.svg)](https://javadoc.io/doc/io.synadia/batch-publish) +[![Maven Central](https://img.shields.io/maven-central/v/io.synadia/batch-publish)](https://img.shields.io/maven-central/v/io.synadia/batch-publish) + +### Distributed Counters + +Utility to take leverage the distributed counter functionality. + +[Distributed Counters README](counters/README.md) + +![Artifact](https://img.shields.io/badge/Artifact-io.synadia:counters-197556?labelColor=grey&style=flat) +![0.2.2](https://img.shields.io/badge/Current_Release-0.2.2-27AAE0) +![0.2.3](https://img.shields.io/badge/Current_Snapshot-0.2.3--SNAPSHOT-27AAE0) +[![javadoc](https://javadoc.io/badge2/io.synadia/counters/javadoc.svg)](https://javadoc.io/doc/io.synadia/counters) +[![Maven Central](https://img.shields.io/maven-central/v/io.synadia/counters)](https://img.shields.io/maven-central/v/io.synadia/counters) + +### Schedule Message + +Utility to leverage the ability to schedule a message to be published at a later time. +Eventually the ability to schedule a message to publish based on a cron or schedule. + +[Scheduled Message README](schedule-message/README.md) + +![Artifact](https://img.shields.io/badge/Artifact-io.synadia:schedule--message-197556?labelColor=grey&style=flat) +![0.0.3](https://img.shields.io/badge/Current_Release-0.0.3-27AAE0) +![0.0.4](https://img.shields.io/badge/Current_Snapshot-0.0.4--SNAPSHOT-27AAE0) +[![javadoc](https://javadoc.io/badge2/io.synadia/schedule-message/javadoc.svg)](https://javadoc.io/doc/io.synadia/schedule-message) +[![Maven Central](https://img.shields.io/maven-central/v/io.synadia/schedule-message)](https://img.shields.io/maven-central/v/io.synadia/schedule-message) ## Chaos Runner Run some NATS servers and cause chaos by bringing them up and down. -[![README](https://img.shields.io/badge/README-blue?style=flat&link=chaos-runner/README.md)](chaos-runner/README.md) -![Artifact](https://img.shields.io/badge/Artifact-io.synadia:direct--batch-00BC8E?labelColor=grey&style=flat) +[Chaos Runner README](chaos-runner/README.md) + +![Artifact](https://img.shields.io/badge/Artifact-io.synadia:chaos--runner-197556?labelColor=grey&style=flat) +![0.0.8](https://img.shields.io/badge/Current_Release-0.0.8-27AAE0) +![0.0.9](https://img.shields.io/badge/Current_Snapshot-0.0.9--SNAPSHOT-27AAE0) [![javadoc](https://javadoc.io/badge2/io.synadia/chaos-runner/javadoc.svg)](https://javadoc.io/doc/io.synadia/chaos-runner) -[![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.synadia/chaos-runner/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.synadia/chaos-runner) +[![Maven Central](https://img.shields.io/maven-central/v/io.synadia/chaos-runner)](https://img.shields.io/maven-central/v/io.synadia/chaos-runner) + +## Partitioned Consumer Groups + +Implementation of the partitioned Consumer Group functionality, ported from and compatible with the [Golang version](https://github.com/synadia-io/orbit.go/tree/main/pcgroups). + +[Partitioned Consumer Groups README](pcgroups/README.md) + +![Artifact](https://img.shields.io/badge/Artifact-io.synadia:pcgroups-197556?labelColor=grey&style=flat) +![0.1.0](https://img.shields.io/badge/Current_Release-0.1.0-27AAE0) +![0.1.1](https://img.shields.io/badge/Current_Snapshot-0.1.1--SNAPSHOT-27AAE0) +[![javadoc](https://javadoc.io/badge2/io.synadia/pcgroups/javadoc.svg)](https://javadoc.io/doc/io.synadia/pcgroups) +[![Maven Central](https://img.shields.io/maven-central/v/io.synadia/pcgroups)](https://img.shields.io/maven-central/v/io.synadia/pcgroups) # Dependencies ### Gradle diff --git a/batch-publish/.gitignore b/batch-publish/.gitignore new file mode 100644 index 0000000..b3e2ca5 --- /dev/null +++ b/batch-publish/.gitignore @@ -0,0 +1,77 @@ + +# NATS stuff # +############## +gnatsd.log +*.csv + +# Compiled source # +################### +*.com +*.class +*.dll +*.exe +*.o +*.so +/bin + +# Packages # +############ +*.7z +*.dmg +*.gz +*.iso +*.rar +*.tar +*.zip + +# Logs and databases # +###################### +*.log + +# OS generated files # +###################### +.DS_Store* +ehthumbs.db +Icon? +Thumbs.db + +# Editor Files # +################ +*~ +*.swp +.sts4-cache/* + +# Gradle Files # +################ +.gradle +.m2 + +# Build output directies +/target +*/target +/build +*/build + +# IntelliJ specific files/directories +out +.idea +*.ipr +*.iws +*.iml +atlassian-ide-plugin.xml + +# Eclipse specific files/directories +.classpath +.project +.settings +.metadata + +# NetBeans specific files/directories +.nbattrs + +# VSCode +.vscode/ + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +/target/ diff --git a/batch-publish/LICENSE b/batch-publish/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/batch-publish/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed 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. diff --git a/batch-publish/NOTICE b/batch-publish/NOTICE new file mode 100644 index 0000000..ff3c8b4 --- /dev/null +++ b/batch-publish/NOTICE @@ -0,0 +1,5 @@ +Orbit Java +Copyright (c) 2024-2025 Synadia Communications Inc. All Rights Reserved. + +This product includes software developed at +Synadia Communications Inc. \ No newline at end of file diff --git a/batch-publish/README.md b/batch-publish/README.md new file mode 100644 index 0000000..945ba3c --- /dev/null +++ b/batch-publish/README.md @@ -0,0 +1,24 @@ +Orbit + +# Batch Publish + +Utility to publish an atomic batch, a group of up to 1000 messages + +### Important + +* Messages are stored in memory on the server until the commit. +* Batch currently is not about speed, it's about transaction, meaning all the messages must be added to the stream or none of them do. + +https://github.com/nats-io/nats-architecture-and-design/blob/main/adr/ADR-50.md + +![Artifact](https://img.shields.io/badge/Artifact-io.synadia:batch--publish-197556?labelColor=grey&style=flat) +![0.2.2](https://img.shields.io/badge/Current_Release-0.2.2-27AAE0) +![0.2.3](https://img.shields.io/badge/Current_Snapshot-0.2.3--SNAPSHOT-27AAE0) +[![Dependencies Help](https://img.shields.io/badge/Dependencies%20Help-27AAE0)](https://github.com/synadia-io/orbit.java?tab=readme-ov-file#dependencies) +[![javadoc](https://javadoc.io/badge2/io.synadia/batch-publish/javadoc.svg)](https://javadoc.io/doc/io.synadia/batch-publish) +[![Maven Central](https://img.shields.io/maven-central/v/io.synadia/batch-publish)](https://img.shields.io/maven-central/v/io.synadia/batch-publish) + + +--- +Copyright (c) 2024-2025 Synadia Communications Inc. All Rights Reserved. +See [LICENSE](LICENSE) and [NOTICE](NOTICE) file for details. diff --git a/batch-publish/build.gradle b/batch-publish/build.gradle new file mode 100644 index 0000000..64b8a10 --- /dev/null +++ b/batch-publish/build.gradle @@ -0,0 +1,193 @@ +import aQute.bnd.gradle.Bundle + +plugins { + id("java") + id("java-library") + id("maven-publish") + id("jacoco") + id("biz.aQute.bnd.builder") version "7.1.0" + id("org.gradle.test-retry") version "1.6.4" + id("io.github.gradle-nexus.publish-plugin") version "2.0.0" + id("signing") +} + +def jarVersion = "0.2.3" +group = 'io.synadia' + +def isRelease = System.getenv("BUILD_EVENT") == "release" + +def tc = System.getenv("TARGET_COMPATIBILITY"); +def targetCompat = tc == "21" ? JavaVersion.VERSION_21 : (tc == "17" ? JavaVersion.VERSION_17 : JavaVersion.VERSION_1_8) +def jarEnd = tc == "21" ? "-jdk21" : (tc == "17" ? "-jdk17" : "") +def jarAndArtifactName = "batch-publish" + jarEnd + +version = isRelease ? jarVersion : jarVersion + "-SNAPSHOT" // version is the variable the build actually uses. + +System.out.println("Java: " + System.getProperty("java.version")) +System.out.println("Target Compatibility: " + targetCompat) +System.out.println(group + ":" + jarAndArtifactName + ":" + version) + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = targetCompat +} + +repositories { + mavenCentral() + maven { url="https://repo1.maven.org/maven2/" } + maven { url="https://central.sonatype.com/repository/maven-snapshots/" } +} + +dependencies { + implementation 'io.nats:jnats:2.25.1' + implementation 'org.jspecify:jspecify:1.0.0' + + testImplementation 'io.nats:jnats-server-runner:1.2.8' + testImplementation 'com.github.stefanbirkner:system-lambda:1.2.1' + testImplementation 'nl.jqno.equalsverifier:equalsverifier:4.2.3' + + testImplementation 'org.junit.jupiter:junit-jupiter:5.14.1' + testImplementation 'org.junit.platform:junit-platform-launcher:1.14.3' +} + +sourceSets { + main { + java { + srcDirs = ['src/main/java','src/examples/java'] + } + } + test { + java { + srcDirs = ['src/test/java'] + } + } +} + +tasks.register('bundle', Bundle) { + from sourceSets.main.output + exclude("io/synadia/examples/**") +} + +jar { + bundle { + bnd("Bundle-Name": "io.synadia.batch.publish", + "Bundle-Vendor": "synadia.io", + "Bundle-Description": "JetStream Distributed Counters", + "Bundle-DocURL": "https://github.com/synadia-io/orbit.java/tree/main/counters", + "Target-Compatibility": "Java " + targetCompat + ) + } +} + +test { + // Use junit platform for unit tests + useJUnitPlatform() +} + +javadoc { + options.overview = 'src/main/javadoc/overview.html' // relative to source root + source = sourceSets.main.allJava + title = "Synadia Communications Inc. Batch Publish" + classpath = sourceSets.main.runtimeClasspath +} + +tasks.register('examplesJar', Jar) { + archiveClassifier.set('examples') + manifest { + attributes('Implementation-Title': 'Batch Publish Examples', + 'Implementation-Version': jarVersion, + 'Implementation-Vendor': 'synadia.io') + } + from(sourceSets.main.output) { + include "io/synadia/examples/**" + } +} + +tasks.register('javadocJar', Jar) { + archiveClassifier.set('javadoc') + from javadoc +} + +tasks.register('sourcesJar', Jar) { + archiveClassifier.set('sources') + from sourceSets.main.allSource +} + +artifacts { + archives javadocJar, sourcesJar, examplesJar +} + +jacoco { + toolVersion = "0.8.12" +} + +jacocoTestReport { + reports { + xml.required = true // coveralls plugin depends on xml format report + html.required = true + } + afterEvaluate { // only report on main library not examples + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, + exclude: ['**/examples**']) + })) + } +} + +nexusPublishing { + repositories { + sonatype { + nexusUrl.set(uri("https://ossrh-staging-api.central.sonatype.com/service/local/")) + snapshotRepositoryUrl.set(uri("https://central.sonatype.com/repository/maven-snapshots/")) + username = System.getenv('OSSRH_USERNAME') + password = System.getenv('OSSRH_PASSWORD') + } + } +} + +publishing { + publications { + mavenJava(MavenPublication) { + from components.java + artifact sourcesJar + artifact examplesJar + artifact javadocJar + pom { + name = jarAndArtifactName + packaging = 'jar' + groupId = group + artifactId = jarAndArtifactName + description = 'Synadia Communications Inc. Batch Publish' + url = 'https://github.com/synadia-io/orbit.java' + licenses { + license { + name = 'The Apache License, Version 2.0' + url = 'http://www.apache.org/licenses/LICENSE-2.0.txt' + } + } + developers { + developer { + id = "synadia" + name = "Synadia" + email = "info@synadia.com" + url = "https://synadia.io" + } + } + scm { + url = 'https://github.com/synadia-io/orbit.java' + } + } + } + } +} + +if (isRelease) { + signing { + def signingKeyId = System.getenv('SIGNING_KEY_ID') + def signingKey = System.getenv('SIGNING_KEY') + def signingPassword = System.getenv('SIGNING_PASSWORD') + useInMemoryPgpKeys(signingKeyId, signingKey, signingPassword) + sign configurations.archives + sign publishing.publications.mavenJava + } +} diff --git a/batch-publish/gradle/libs.versions.toml b/batch-publish/gradle/libs.versions.toml new file mode 100644 index 0000000..2cfe86a --- /dev/null +++ b/batch-publish/gradle/libs.versions.toml @@ -0,0 +1,12 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format + +[versions] +commons-math3 = "3.6.1" +guava = "33.4.5-jre" +junit-jupiter = "5.12.1" + +[libraries] +commons-math3 = { module = "org.apache.commons:commons-math3", version.ref = "commons-math3" } +guava = { module = "com.google.guava:guava", version.ref = "guava" } +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" } diff --git a/batch-publish/gradle/wrapper/gradle-wrapper.jar b/batch-publish/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/batch-publish/gradle/wrapper/gradle-wrapper.jar differ diff --git a/batch-publish/gradle/wrapper/gradle-wrapper.properties b/batch-publish/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ca025c8 --- /dev/null +++ b/batch-publish/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/batch-publish/gradlew b/batch-publish/gradlew new file mode 100644 index 0000000..23d15a9 --- /dev/null +++ b/batch-publish/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed 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 +# +# https://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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/batch-publish/gradlew.bat b/batch-publish/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/batch-publish/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/batch-publish/settings.gradle b/batch-publish/settings.gradle new file mode 100644 index 0000000..afe3449 --- /dev/null +++ b/batch-publish/settings.gradle @@ -0,0 +1,13 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + maven { url="https://repo1.maven.org/maven2/" } + maven { url="https://central.sonatype.com/repository/maven-snapshots/" } + maven { url="https://plugins.gradle.org/m2/" } + } + plugins { + id("biz.aQute.bnd.builder") version "7.1.0" + } +} +rootProject.name = 'batch-publish' diff --git a/batch-publish/src/examples/java/io/synadia/examples/BasicBatchPublishAsyncExample.java b/batch-publish/src/examples/java/io/synadia/examples/BasicBatchPublishAsyncExample.java new file mode 100644 index 0000000..8b1beae --- /dev/null +++ b/batch-publish/src/examples/java/io/synadia/examples/BasicBatchPublishAsyncExample.java @@ -0,0 +1,66 @@ +// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.examples; + +import io.nats.client.Connection; +import io.nats.client.JetStreamApiException; +import io.nats.client.JetStreamManagement; +import io.nats.client.Nats; +import io.nats.client.api.PublishAck; +import io.nats.client.api.StreamConfiguration; +import io.synadia.bp.BatchPublishOptions; +import io.synadia.bp.BatchPublisher; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +public class BasicBatchPublishAsyncExample { + static final String NATS_URL = "nats://localhost:4222"; + static final String STREAM = "bpa-stream"; + static final String SUBJECT = "bpa-subject"; + static final String BATCH_ID = "bpa-batch-id"; + + public static void main(String[] args) throws Exception { + try (Connection nc = Nats.connect(NATS_URL)) { + JetStreamManagement jsm = nc.jetStreamManagement(); + + // Set up a fresh counter stream + try { jsm.deleteStream(STREAM); } catch (JetStreamApiException ignore) {} + StreamConfiguration config = StreamConfiguration.builder() + .name(STREAM) + .subjects(SUBJECT) + .allowAtomicPublish() + .build(); + jsm.addStream(config); + + BatchPublisher publisher = BatchPublisher.builder() + .connection(nc) + .batchId(BATCH_ID) + .build(); + + publisher.add(SUBJECT, null); + CompletableFuture paf = publisher.commitAsync(SUBJECT, null); + PublishAck pa = paf.get(1, TimeUnit.SECONDS); + System.out.println("Batch [" + pa.getBatchId() + "] Committed " + pa.getJv()); + + publisher = BatchPublisher.builder() + .connection(nc) + .batchId(BATCH_ID + "-batch-error") + .ackFirst(false) // otherwise error will happen on first publish + .build(); + + publisher.add(SUBJECT, null, BatchPublishOptions.builder().expectedLastSequence(1).build()); + paf = publisher.commitAsync(SUBJECT, null); + try { + // this will exception + paf.get(1, TimeUnit.SECONDS); + } + catch (ExecutionException e) { + //noinspection ThrowablePrintedToSystemOut + System.out.println(e); + } + } + } +} diff --git a/batch-publish/src/examples/java/io/synadia/examples/BasicBatchPublishExample.java b/batch-publish/src/examples/java/io/synadia/examples/BasicBatchPublishExample.java new file mode 100644 index 0000000..8238f71 --- /dev/null +++ b/batch-publish/src/examples/java/io/synadia/examples/BasicBatchPublishExample.java @@ -0,0 +1,113 @@ +// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.examples; + +import io.nats.client.*; +import io.nats.client.api.*; +import io.nats.client.impl.Headers; +import io.synadia.bp.BatchPublishException; +import io.synadia.bp.BatchPublishOptions; +import io.synadia.bp.BatchPublisher; + +public class BasicBatchPublishExample { + static final String NATS_URL = "nats://localhost:4222"; + static final String STREAM = "bp-stream"; + static final String SUBJECT = "bp-subject"; + static final String BATCH_ID = "bp-batch-id"; + static final int BATCH_SIZE = 1000; // !!! MAX IS 1000 + static final boolean ACK_FIRST = true; // default is true usually never change this. + static final int AUTO_ACK_EVERY = 100; // 0 or less means no auto ack + + public static void main(String[] args) throws Exception { + try (Connection nc = Nats.connect(NATS_URL)) { + JetStreamManagement jsm = nc.jetStreamManagement(); + + // Set up a fresh counter stream + try { jsm.deleteStream(STREAM); } catch (JetStreamApiException ignore) {} + StreamConfiguration config = StreamConfiguration.builder() + .name(STREAM) + .subjects(SUBJECT) + .allowAtomicPublish() + .build(); + jsm.addStream(config); + + JetStream js = nc.jetStream(); + + BatchPublisher publisher = BatchPublisher.builder() + .connection(nc) + .batchId(BATCH_ID) + .ackFirst(ACK_FIRST) + .ackEvery(AUTO_ACK_EVERY) + .build(); + + for (int i = 1; i <= BATCH_SIZE; i++) { + Headers h = new Headers(); + h.put("my-header", "xyz-" + i); + byte[] data = ("data-" + i).getBytes(); + if (i == BATCH_SIZE) { + PublishAck pa = publisher.commit(SUBJECT, h, data); + assert pa.getJv() != null; + System.out.println("Batch [" + pa.getBatchId() + "] Committed " + pa.getJv().toJson()); + } + else { + publisher.add(SUBJECT, h, data); + } + } + + StreamInfo si = jsm.getStreamInfo(STREAM, StreamInfoOptions.allSubjects()); + long messages = si.getStreamState().getSubjectMap().get(SUBJECT); + System.out.println("Stream State shows '" + SUBJECT + "' has " + messages + " messages."); + + // simple subscription + JetStreamSubscription sub = js.subscribe(SUBJECT, PushSubscribeOptions.builder() + .configuration(ConsumerConfiguration.builder() + .filterSubject(SUBJECT) + .ackPolicy(AckPolicy.None) + .build()) + .build()); + int count = 0; + Message m = sub.nextMessage(500); + while (m != null) { + count++; + m = sub.nextMessage(50); + } + System.out.println("Consumed " + count + " messages from '" + SUBJECT + "'"); + + publisher = BatchPublisher.builder() + .connection(nc) + .batchId(BATCH_ID + "-batch-error") + .ackFirst(false) // otherwise error will happen on first publish + .build(); + publisher.add(SUBJECT, null, BatchPublishOptions.builder().expectedLastSequence(1).build()); + try { + // this will exception + publisher.commit(SUBJECT, null); + } + catch (BatchPublishException e) { + //noinspection ThrowablePrintedToSystemOut + System.out.println(e.getMessage()); + } + } + } + + public static String toString(Message msg) { + StringBuilder sb = new StringBuilder(System.lineSeparator()) + .append(" Subject: ").append(msg.getSubject()); + if (msg.getData() == null || msg.getData().length == 0) { + sb.append(" | No Data"); + } + else { + sb.append(" | Data: ").append(new String(msg.getData())); + } + Headers h = msg.getHeaders(); + if (h != null && !h.isEmpty()) { + sb.append(System.lineSeparator()).append(" Headers:"); + for (String key : h.keySet()) { + sb.append(System.lineSeparator()).append(" "); + sb.append(key).append("=").append(h.get(key)); + } + } + return sb.toString(); + } +} diff --git a/batch-publish/src/examples/java/io/synadia/examples/ExpectationsBatchPublishExample.java b/batch-publish/src/examples/java/io/synadia/examples/ExpectationsBatchPublishExample.java new file mode 100644 index 0000000..958d48d --- /dev/null +++ b/batch-publish/src/examples/java/io/synadia/examples/ExpectationsBatchPublishExample.java @@ -0,0 +1,103 @@ +// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.examples; + +import io.nats.client.*; +import io.nats.client.api.*; +import io.nats.client.impl.Headers; +import io.synadia.bp.BatchPublishOptions; +import io.synadia.bp.BatchPublisher; + +public class ExpectationsBatchPublishExample { + static final String NATS_URL = "nats://localhost:4222"; + static final String STREAM = "expect-batch"; + static final String SUBJECT_PREFIX = "expect."; + static final String SUBJECTS = SUBJECT_PREFIX + ">"; + static final String SUBJECT_A = SUBJECT_PREFIX + "A"; + static final String SUBJECT_B = SUBJECT_PREFIX + "B"; + + public static void main(String[] args) throws Exception { + try (Connection nc = Nats.connect(NATS_URL)) { + JetStreamManagement jsm = nc.jetStreamManagement(); + + // Set up a fresh counter stream + try { jsm.deleteStream(STREAM); } catch (JetStreamApiException ignore) {} + StreamConfiguration config = StreamConfiguration.builder() + .name(STREAM) + .subjects(SUBJECTS) + .allowAtomicPublish() + .build(); + jsm.addStream(config); + + JetStream js = nc.jetStream(); + + PublishAck paA = js.publish(SUBJECT_A, "A1".getBytes()); + System.out.println("Non-Batch Publish to '" + SUBJECT_A + "' --> " + paA); + + PublishAck paB = js.publish(SUBJECT_B, "B1".getBytes()); + System.out.println("Non-Batch Publish to '" + SUBJECT_B + "' --> " + paB); + + BatchPublisher publisher = BatchPublisher.builder() + .connection(nc) + .batchId("4273") + .build(); + + BatchPublishOptions.Builder bpoBuilder = BatchPublishOptions.builder() + .expectedLastSequence(paB.getSeqno()) + .expectedLastSubjectSequence(paA.getSeqno()) + .expectedLastSubjectSequenceSubject(SUBJECT_A); + + BatchPublishOptions bpOpts = bpoBuilder.build(); + System.out.println("Batch Add to '" + SUBJECT_A + "', 'A2'"); + publisher.add(SUBJECT_A, "A2".getBytes(), bpOpts); + + // demonstrates re-use of the builder + bpOpts = bpoBuilder.clearExpected() + .expectedLastSubjectSequence(paB.getSeqno()) + .expectedLastSubjectSequenceSubject(SUBJECT_B) + .build(); + System.out.println("Batch Add to '" + SUBJECT_B + "', 'B2'"); + publisher.add(SUBJECT_B, "B2".getBytes(), bpOpts); + + System.out.println("Batch Commit Add to '" + SUBJECT_A + "', 'A3'"); + PublishAck pa = publisher.commit(SUBJECT_A, "A3".getBytes()); + assert pa.getJv() != null; + System.out.println("Batch [" + pa.getBatchId() + "] Committed " + pa.getJv().toJson()); + + StreamInfo si = jsm.getStreamInfo(STREAM, StreamInfoOptions.allSubjects()); + System.out.println("Stream State"); + for (Subject subject : si.getStreamState().getSubjects()) { + System.out.println(" '" + subject.getName() + "' has " + subject.getCount() + " messages."); + } + + // simple subscription + JetStreamSubscription sub = js.subscribe(SUBJECTS, PushSubscribeOptions.builder() + .configuration(ConsumerConfiguration.builder() + .filterSubject(SUBJECTS) + .ackPolicy(AckPolicy.None) + .build()) + .build()); + System.out.println("Messages:"); + Message m = sub.nextMessage(500); + while (m != null) { + System.out.println(toString(m)); + m = sub.nextMessage(50); + } + } + } + + public static String toString(Message msg) { + StringBuilder sb = new StringBuilder(" '").append(msg.getSubject()); + sb.append("', '").append(new String(msg.getData())).append("'"); + Headers h = msg.getHeaders(); + if (h != null && !h.isEmpty()) { + sb.append(", Headers:"); + for (String key : h.keySet()) { + sb.append(System.lineSeparator()).append(" "); + sb.append(key).append("=").append(h.get(key)); + } + } + return sb.toString(); + } +} diff --git a/batch-publish/src/main/java/io/synadia/bp/BatchPublishException.java b/batch-publish/src/main/java/io/synadia/bp/BatchPublishException.java new file mode 100644 index 0000000..a311ddc --- /dev/null +++ b/batch-publish/src/main/java/io/synadia/bp/BatchPublishException.java @@ -0,0 +1,74 @@ +// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.bp; + +import io.nats.client.JetStreamApiException; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +public class BatchPublishException extends Exception { + private final JetStreamApiException jsApiException; + private final String batchId; + + public BatchPublishException(@NonNull String batchId, @NonNull String message) { + super(message); + this.batchId = batchId; + jsApiException = null; + } + + public BatchPublishException(@NonNull String batchId, @NonNull JetStreamApiException cause) { + super(cause); + this.batchId = batchId; + jsApiException = cause; + } + + public BatchPublishException(@NonNull String batchId, @NonNull Throwable cause) { + super(cause); + this.batchId = batchId; + jsApiException = null; + } + + @Override + public String getMessage() { + return "[" + batchId + "] " + super.getMessage(); + } + + @NonNull + public String getBatchId() { + return batchId; + } + + @Nullable + public JetStreamApiException getJsApiException() { + return jsApiException; + } + + /** + * Get the error code from the response if the exception is a JetStreamApiException + * otherwise will be -1 + * @return the code + */ + public int getErrorCode() { + return jsApiException == null ? -1 : jsApiException.getErrorCode(); + } + + /** + * Get the error code from the response if the exception is a JetStreamApiException + * otherwise will be -1 + * @return the code + */ + public int getApiErrorCode() { + return jsApiException == null ? -1 : jsApiException.getApiErrorCode(); + } + + /** + * Get the description from the response if the exception is a JetStreamApiException + * otherwise will be null + * @return the description + */ + @Nullable + public String getErrorDescription() { + return jsApiException == null ? null : jsApiException.getErrorDescription(); + } +} diff --git a/batch-publish/src/main/java/io/synadia/bp/BatchPublishOptions.java b/batch-publish/src/main/java/io/synadia/bp/BatchPublishOptions.java new file mode 100644 index 0000000..934ba30 --- /dev/null +++ b/batch-publish/src/main/java/io/synadia/bp/BatchPublishOptions.java @@ -0,0 +1,270 @@ +// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.bp; + +import io.nats.client.MessageTtl; + +import java.time.Duration; + +import static io.nats.client.PublishOptions.DEFAULT_TIMEOUT; +import static io.nats.client.PublishOptions.UNSET_LAST_SEQUENCE; +import static io.nats.client.support.Validator.*; + +public class BatchPublishOptions { + public final String expectedStream; + public final long expectedLastSeq; + public final long expectedLastSubSeq; + public final String expectedLastSubSeqSubject; + public final MessageTtl messageTtl; + + private BatchPublishOptions(Builder b) { + this.expectedStream = b.expectedStream; + this.expectedLastSeq = b.expectedLastSeq; + this.expectedLastSubSeq = b.expectedLastSubSeq; + this.expectedLastSubSeqSubject = b.expectedLastSubSeqSubject; + this.messageTtl = b.messageTtl; + } + + @Override + public String toString() { + return "BatchPublishOptions{" + + ", expectedStream='" + expectedStream + '\'' + + ", expectedLastSeq=" + expectedLastSeq + + ", expectedLastSubSeq=" + expectedLastSubSeq + + ", expectedLastSubSeqSub=" + expectedLastSubSeqSubject + + ", messageTtl=" + getMessageTtl() + + '}'; + } + + /** + * Gets the expected stream. + * @return the stream. + */ + public String getExpectedStream() { + return expectedStream; + } + + /** + * Gets the expected last sequence number of the stream. + * @return sequence number + */ + public long getExpectedLastSequence() { + return expectedLastSeq; + } + + /** + * Gets the expected last subject sequence number of the stream. + * @return last subject sequence number + */ + public long getExpectedLastSubjectSequence() { + return expectedLastSubSeq; + } + + /** + * Gets the expected subject to limit last subject sequence number of the stream. + * @return the last subject sequence number's limit subject + */ + public String getExpectedLastSubjectSequenceSubject() { + return expectedLastSubSeqSubject; + } + + /** + * Gets the message ttl string. Might be null. Might be "never". + * 10 seconds would be "10s" for the server + * @return the message ttl string + */ + public String getMessageTtl() { + return messageTtl == null ? null : messageTtl.getTtlString(); + } + + /** + * Creates a builder for the options. + * @return the builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * PublishOptions are created using a Builder. The builder supports chaining and will + * create a default set of options if no methods are calls. The builder can also + * be created from a properties object using the property names defined with the + * prefix PROP_ in this class. + */ + public static class Builder { + Duration ackTimeout = DEFAULT_TIMEOUT; + boolean ackFirst = true; + int ackEvery = 0; + String expectedStream; + long expectedLastSeq = UNSET_LAST_SEQUENCE; + long expectedLastSubSeq = UNSET_LAST_SEQUENCE; + String expectedLastSubSeqSubject; + MessageTtl messageTtl; + + /** + * Constructs a new publish options Builder with the default values. + */ + public Builder() {} + + /** + * Sets the timeout to wait for the acknowledgement for acks when adding or the commit. + * @param ackTimeout the ack timeout. + * @return The Builder + */ + public Builder ackTimeout(Duration ackTimeout) { + this.ackTimeout = validateDurationNotRequiredGtOrEqZero(ackTimeout, DEFAULT_TIMEOUT); + return this; + } + + /** + * Sets the timeout im milliseconds to wait for the acknowledgement for acks when adding or the commit. + * @param ackTimeoutMillis the ack timeout. + * @return The Builder + */ + public Builder ackTimeout(long ackTimeoutMillis) { + this.ackTimeout = ackTimeoutMillis < 1 ? DEFAULT_TIMEOUT : Duration.ofMillis(ackTimeoutMillis); + return this; + } + + /** + * Whether to ack the first message. Defaults to true + * @param ackFirst the flag + * @return The Builder + */ + public Builder ackFirst(boolean ackFirst) { + this.ackFirst = ackFirst; + return this; + } + + /** + * The interval to ack when adding a message, after the first message. Defaults to 0 (never). + * @param ackEvery the ack every value + * @return The Builder + */ + public Builder ackEvery(int ackEvery) { + this.ackEvery = ackEvery < 1 ? 0 : ackEvery; + return this; + } + + /** + * Sets the expected stream for the publish. If the + * stream does not match the server will not save the message. + * @param stream expected stream + * @return The Builder + */ + public Builder expectedStream(String stream) { + expectedStream = validateStreamName(stream, false); + return this; + } + + /** + * Sets the expected message sequence of the publish + * @param sequence the expected last sequence number + * @return The Builder + */ + public Builder expectedLastSequence(long sequence) { + // 0 has NO meaning to expectedLastSequence but we except 0 b/c the sequence is really a ulong + expectedLastSeq = validateGtEqMinus1(sequence, "Last Sequence"); + return this; + } + + /** + * Sets the expected subject message sequence of the publish + * @param sequence the expected last subject sequence number + * @return The Builder + */ + public Builder expectedLastSubjectSequence(long sequence) { + expectedLastSubSeq = validateGtEqMinus1(sequence, "Last Subject Sequence"); + return this; + } + + /** + * Sets the filter subject for the expected last subject sequence + * This can be used for a wildcard since it is used + * in place of the message subject along with expectedLastSubjectSequence + * @param expectedLastSubSeqSubject the filter subject + * @return The Builder + */ + public Builder expectedLastSubjectSequenceSubject(String expectedLastSubSeqSubject) { + this.expectedLastSubSeqSubject = expectedLastSubSeqSubject; + return this; + } + + /** + * Sets the TTL for this specific message to be published. + * Less than 1 has the effect of clearing the message ttl + * @param msgTtlSeconds the ttl in seconds + * @return The Builder + */ + public Builder messageTtlSeconds(int msgTtlSeconds) { + this.messageTtl = msgTtlSeconds < 1 ? null : MessageTtl.seconds(msgTtlSeconds); + return this; + } + + /** + * Sets the TTL for this specific message to be published. Use at your own risk. + * The current specification can be found here @see JetStream Per-Message TTL + * Null or empty has the effect of clearing the message ttl + * @param msgTtlCustom the custom ttl string + * @return The Builder + */ + public Builder messageTtlCustom(String msgTtlCustom) { + this.messageTtl = nullOrEmpty(msgTtlCustom) ? null : MessageTtl.custom(msgTtlCustom); + return this; + } + + /** + * Sets the TTL for this specific message to be published and never be expired + * @return The Builder + */ + public Builder messageTtlNever() { + this.messageTtl = MessageTtl.never(); + return this; + } + + /** + * Sets the TTL for this specific message to be published + * @param messageTtl the message ttl instance + * @return The Builder + */ + public Builder messageTtl(MessageTtl messageTtl) { + this.messageTtl = messageTtl; + return this; + } + + /** + * Clears the expected so the build can be re-used. + * Clears the following fields: + * + * Does not clear the following fields: + * + * @return The Builder + */ + public Builder clearExpected() { + expectedLastSeq = UNSET_LAST_SEQUENCE; + expectedLastSubSeq = UNSET_LAST_SEQUENCE; + expectedLastSubSeqSubject = null; + return this; + } + + /** + * Builds the publish options. + * @return publish options + */ + public BatchPublishOptions build() { + return new BatchPublishOptions(this); + } + } + +} diff --git a/batch-publish/src/main/java/io/synadia/bp/BatchPublisher.java b/batch-publish/src/main/java/io/synadia/bp/BatchPublisher.java new file mode 100644 index 0000000..80d6c3a --- /dev/null +++ b/batch-publish/src/main/java/io/synadia/bp/BatchPublisher.java @@ -0,0 +1,403 @@ +// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.bp; + +import io.nats.client.*; +import io.nats.client.api.PublishAck; +import io.nats.client.impl.Headers; +import io.nats.client.support.NatsJetStreamConstants; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.time.Duration; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static io.nats.client.PublishOptions.DEFAULT_TIMEOUT; +import static io.nats.client.support.NatsJetStreamConstants.*; +import static io.nats.client.support.Validator.*; + +public class BatchPublisher { + enum State { + Open, Closed, Discarded + } + + private final String batchId; + private final Connection conn; + private final Duration ackTimeout; + private final boolean ackFirst; + private final int ackEvery; + private final MessageTtl messageTtl; + + private final Headers headers; // final to be re-used/cleared + private int lastSeq; + private State state; + + private BatchPublisher(BatchPublisher.Builder b) { + batchId = b.batchId; + conn = b.conn; + ackTimeout = b.ackTimeout; + ackFirst = b.ackFirst; + ackEvery = b.ackEvery; + messageTtl = b.messageTtl; + + headers = new Headers(); + lastSeq = 0; + state = State.Open; + } + + @Nullable + public String getBatchId() { + return batchId; + } + + @NonNull + public Duration getAckTimeout() { + return ackTimeout; + } + + public boolean ackFirst() { + return ackFirst; + } + + public int getAckEvery() { + return ackEvery; + } + + /** + * Gets the message ttl string. Might be null. Might be "never". + * 10 seconds would be "10s" for the server + * @return the message ttl string + */ + public String getMessageTtl() { + return messageTtl == null ? null : messageTtl.getTtlString(); + } + + public int size() { + return lastSeq; + } + + public void discard() { + state = State.Discarded; + } + + public boolean isOpen() { + return state == State.Open; + } + + public boolean isDiscarded() { + return state == State.Discarded; + } + + public boolean isClosed() { + return state == State.Closed; + } + + public void add(String subject, byte[] data) throws BatchPublishException { + add(subject, null, data, null); + } + + public void add(String subject, byte[] data, BatchPublishOptions opts) throws BatchPublishException { + add(subject, null, data, opts); + } + + public void add(String subject, Headers userHeaders, byte[] data) throws BatchPublishException { + add(subject, userHeaders, data, null); + } + + public void add(String subject, Headers userHeaders, byte[] data, BatchPublishOptions opts) throws BatchPublishException { + if (state != State.Open) { + throw new BatchPublishException(batchId, "Batch not open: " + state); + } + if ( (++lastSeq == 1 && ackFirst) // first publish + || (ackEvery > 0 && lastSeq % ackEvery == 0)) // or every publish + { + _addAcked(subject, userHeaders, data, opts); + } + else { + updateHeaders(false, userHeaders, opts); + conn.publish(subject, headers, data); + } + } + + public void addAcked(String subject, byte[] data) throws BatchPublishException { + addAcked(subject, null, data, null); + } + + public void addAcked(String subject, byte[] data, BatchPublishOptions opts) throws BatchPublishException { + addAcked(subject, null, data, opts); + } + + public void addAcked(String subject, Headers userHeaders, byte[] data) throws BatchPublishException { + addAcked(subject, userHeaders, data, null); + } + + public void addAcked(String subject, Headers userHeaders, byte[] data, BatchPublishOptions opts) throws BatchPublishException { + if (state != State.Open) { + throw new BatchPublishException(batchId, "Batch not open: " + state); + } + ++lastSeq; + _addAcked(subject, userHeaders, data, opts); + } + + private void _addAcked(String subject, Headers userHeaders, byte[] data, BatchPublishOptions opts) throws BatchPublishException { + Message m = request(subject, userHeaders, data, false, opts); + if (m.getData().length != 0) { + throw new BatchPublishException(batchId, "Invalid ack returned from add with confirm"); + } + } + + public PublishAck commit(String subject, byte[] data) throws BatchPublishException { + return commit(subject, null, data, null); + } + + public PublishAck commit(String subject, byte[] data, BatchPublishOptions opts) throws BatchPublishException { + return commit(subject, null, data, opts); + } + + public PublishAck commit(String subject, Headers userHeaders, byte[] data) throws BatchPublishException { + return commit(subject, userHeaders, data, null); + } + + public PublishAck commit(String subject, Headers userHeaders, byte[] data, BatchPublishOptions opts) throws BatchPublishException { + if (state != State.Open) { + throw new BatchPublishException(batchId, "Batch not open: " + state); + } + try { + ++lastSeq; + Message m = request(subject, userHeaders, data, true, opts); + return new PublishAck(m); + } + catch (IOException e) { + // done this way because PublishAck makes an IOException if the ack is invalid. + // it was done that way because of api backward compatibility + // just no need of the extra layer + throw new BatchPublishException(batchId, e.getMessage()); + } + catch (JetStreamApiException e) { + throw new BatchPublishException(batchId, e); + } + finally { + state = State.Closed; + } + } + + public CompletableFuture commitAsync(String subject, byte[] data) { + return commitAsync(subject, null, data, null); + } + + public CompletableFuture commitAsync(String subject, byte[] data, BatchPublishOptions opts) { + return commitAsync(subject, null, data, opts); + } + + public CompletableFuture commitAsync(String subject, Headers userHeaders, byte[] data) { + return commitAsync(subject, userHeaders, data, null); + } + + public CompletableFuture commitAsync(String subject, Headers userHeaders, byte[] data, BatchPublishOptions opts) { + return CompletableFuture.supplyAsync(() -> { + try { + return commit(subject, userHeaders, data, opts); + } + catch (BatchPublishException e) { + throw new RuntimeException(e); + } + }, conn.getOptions().getExecutor()); + } + + private Message request(String subject, Headers userHeaders, byte[] data, boolean commit, BatchPublishOptions opts) throws BatchPublishException { + try { + updateHeaders(commit, userHeaders, opts); + CompletableFuture f = conn.requestWithTimeout(subject, headers, data, ackTimeout); + return f.get(ackTimeout.toNanos(), TimeUnit.NANOSECONDS); + } + catch (ExecutionException | TimeoutException e) { + throw new BatchPublishException(batchId, e); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new BatchPublishException(batchId, e); + } + } + + private void updateHeaders(boolean commit, Headers userHeaders, BatchPublishOptions bpOpts) { + headers.clear(); + headers.put(NATS_BATCH_ID_HDR, batchId); + headers.put(NatsJetStreamConstants.NATS_BATCH_SEQUENCE_HDR, Integer.toString(lastSeq)); + + if (commit) { + headers.put(NatsJetStreamConstants.NATS_BATCH_COMMIT_HDR, "1"); + } + + if (userHeaders != null && !userHeaders.isEmpty()) { + Set keys = userHeaders.keySet(); + for (String key : keys) { + headers.put(key, userHeaders.get(key)); + } + } + + if (bpOpts != null) { + long value = bpOpts.getExpectedLastSequence(); + if (value > -1) { + headers.put(EXPECTED_LAST_SEQ_HDR, Long.toString(value)); + } + value = bpOpts.getExpectedLastSubjectSequence(); + if (value > -1) { + headers.put(EXPECTED_LAST_SUB_SEQ_HDR, Long.toString(value)); + } + String temp = bpOpts.getExpectedLastSubjectSequenceSubject(); + if (temp != null) { + headers.put(EXPECTED_LAST_SUB_SEQ_SUB_HDR, temp); + } + temp = bpOpts.getExpectedStream(); + if (temp != null) { + headers.put(EXPECTED_STREAM_HDR, temp); + } + + // message ttl can come from the BatchPublishOptions first + // then can come from the BatchPublisher second + temp = bpOpts.getMessageTtl(); + if (temp == null) { + temp = messageTtl == null ? null : messageTtl.getTtlString(); + } + if (temp != null) { + headers.put(MSG_TTL_HDR, temp); + } + } + } + + /** + * Get an instance of the builder, same as new BatchPublisher.Builder(); + * @return The Builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * The builder class for the BatchPublisher + */ + public static class Builder { + private Connection conn; + private Duration ackTimeout; + private String batchId; + private boolean ackFirst = true; + private int ackEvery; + private MessageTtl messageTtl; + + public Builder connection(Connection conn) { + this.conn = conn; + return this; + } + + public Builder batchId(String batchId) { + this.batchId = batchId; + return this; + } + + /** + * Sets the timeout to wait for the acknowledgement for acks when adding or the commit. + * @param ackTimeout the ack timeout. + * @return The Builder + */ + public Builder ackTimeout(Duration ackTimeout) { + this.ackTimeout = validateDurationNotRequiredGtOrEqZero(ackTimeout, DEFAULT_TIMEOUT); + return this; + } + + /** + * Sets the timeout im milliseconds to wait for the acknowledgement for acks when adding or the commit. + * @param ackTimeoutMillis the ack timeout. + * @return The Builder + */ + public Builder ackTimeout(long ackTimeoutMillis) { + this.ackTimeout = ackTimeoutMillis < 1 ? DEFAULT_TIMEOUT : Duration.ofMillis(ackTimeoutMillis); + return this; + } + + /** + * Whether to ack the first message. Defaults to true + * @param ackFirst the flag + * @return The Builder + */ + public Builder ackFirst(boolean ackFirst) { + this.ackFirst = ackFirst; + return this; + } + + /** + * The interval to ack when adding a message, after the first message. Defaults to 0 (never). + * @param ackEvery the ack every value + * @return The Builder + */ + public Builder ackEvery(int ackEvery) { + this.ackEvery = ackEvery < 1 ? 0 : ackEvery; + return this; + } + + /** + * Sets the TTL for this specific message to be published. + * Less than 1 has the effect of clearing the message ttl + * @param msgTtlSeconds the ttl in seconds + * @return The Builder + */ + public Builder messageTtlSeconds(int msgTtlSeconds) { + this.messageTtl = msgTtlSeconds < 1 ? null : MessageTtl.seconds(msgTtlSeconds); + return this; + } + + /** + * Sets the TTL for this specific message to be published. Use at your own risk. + * The current specification can be found here @see JetStream Per-Message TTL + * Null or empty has the effect of clearing the message ttl + * @param msgTtlCustom the custom ttl string + * @return The Builder + */ + public Builder messageTtlCustom(String msgTtlCustom) { + this.messageTtl = nullOrEmpty(msgTtlCustom) ? null : MessageTtl.custom(msgTtlCustom); + return this; + } + + /** + * Sets the TTL for this specific message to be published and never be expired + * @return The Builder + */ + public Builder messageTtlNever() { + this.messageTtl = MessageTtl.never(); + return this; + } + + /** + * Sets the TTL for this specific message to be published + * @param messageTtl the message ttl instance + * @return The Builder + */ + public Builder messageTtl(MessageTtl messageTtl) { + this.messageTtl = messageTtl; + return this; + } + + public BatchPublisher build() { + validateNotNull(conn, "Connection required,"); + if (!conn.getServerInfo().isNewerVersionThan("2.11.99")) { + throw new IllegalArgumentException("Batch direct get not available until server version 2.11.0."); + } + if (ackTimeout == null) { + ackTimeout = conn.getOptions().getConnectionTimeout(); + } + batchId = emptyAsNull(batchId); + if (batchId == null) { + batchId = new NUID().next(); + } + else if (batchId.length() > 64){ + throw new IllegalArgumentException("Batch ID cannot be longer than 64 characters"); + } + return new BatchPublisher(this); + } + } +} diff --git a/batch-publish/src/main/javadoc/images/favicon.ico b/batch-publish/src/main/javadoc/images/favicon.ico new file mode 100644 index 0000000..9464855 Binary files /dev/null and b/batch-publish/src/main/javadoc/images/favicon.ico differ diff --git a/batch-publish/src/main/javadoc/images/large-logo.png b/batch-publish/src/main/javadoc/images/large-logo.png new file mode 100644 index 0000000..33f9483 Binary files /dev/null and b/batch-publish/src/main/javadoc/images/large-logo.png differ diff --git a/batch-publish/src/main/javadoc/images/synadia-logo.png b/batch-publish/src/main/javadoc/images/synadia-logo.png new file mode 100644 index 0000000..1f14bda Binary files /dev/null and b/batch-publish/src/main/javadoc/images/synadia-logo.png differ diff --git a/batch-publish/src/main/javadoc/overview.html b/batch-publish/src/main/javadoc/overview.html new file mode 100644 index 0000000..48f7834 --- /dev/null +++ b/batch-publish/src/main/javadoc/overview.html @@ -0,0 +1,13 @@ + + + + + +Synadia's Batch Publish +

Synadia Logo

+ + + + diff --git a/batch-publish/src/main/resources/placeholder.txt b/batch-publish/src/main/resources/placeholder.txt new file mode 100644 index 0000000..0394bc8 --- /dev/null +++ b/batch-publish/src/main/resources/placeholder.txt @@ -0,0 +1,2 @@ +This is just a placeholder. +Placeholder updated 05/18/2025 to force build \ No newline at end of file diff --git a/batch-publish/src/test/java/io/nats/client/support/Debug.java b/batch-publish/src/test/java/io/nats/client/support/Debug.java new file mode 100644 index 0000000..cada269 --- /dev/null +++ b/batch-publish/src/test/java/io/nats/client/support/Debug.java @@ -0,0 +1,461 @@ +package io.nats.client.support; + +import io.nats.client.Connection; +import io.nats.client.JetStreamApiException; +import io.nats.client.JetStreamManagement; +import io.nats.client.Message; +import io.nats.client.api.ConsumerConfiguration; +import io.nats.client.api.ConsumerInfo; +import io.nats.client.api.SequenceInfo; +import io.nats.client.api.StreamInfo; +import io.nats.client.impl.Headers; +import io.nats.client.impl.NatsJetStreamMetaData; +import io.nats.client.impl.NatsMessage; + +import java.io.IOException; +import java.time.ZonedDateTime; +import java.util.List; + +import static java.nio.charset.StandardCharsets.UTF_8; + +// MODIFIED 2/10/2025 + +@SuppressWarnings("SameParameterValue") +public abstract class Debug { + + public interface DebugPrinter { + void println(String s); + } + + public static final String SEP = " | "; + public static final String DIV = "/"; + public static final String PAD = " "; + public static final String REPLACE = "\\Q%s\\E"; + public static boolean DO_NOT_TRUNCATE = true; + public static boolean PRINT_THREAD_ID = true; + public static boolean PRINT_TIME = true; + public static boolean PAUSE = false; + public static DebugPrinter DEBUG_PRINTER = System.out::println; + public static int MAX_DATA_DISPLAY = 50; + + private Debug() {} /* ensures cannot be constructed */ + + public static void msg(Message msg) { + info(null, msg, true, null); + } + + public static void msg(Message msg, Object... extras) { + info(null, msg, true, stringify(extras, false)); + } + + public static void msg(String label, Message msg, Object... extras) { + info(label, msg, true, stringify(extras, false)); + } + + public static void stackTrace(String label) { + if (PAUSE) { return; } + try { + throw new Exception(); + } + catch (Exception e) { + stackTrace(label, e); + } + } + + public static void stackTrace(String label, Throwable t) { + if (PAUSE) { return; } + String m = t.getMessage(); + if (m == null) { + info(label, "Stack Trace"); + } + else { + info(label, "Stack Trace", t.getMessage()); + } + boolean compress = false; + StackTraceElement[] elements = t.getStackTrace(); + for (int i = 0; i < elements.length; i++) { + String ts = elements[i].toString(); + if (i > 0) { + if (ts.startsWith("io.nats")) { + if (compress) { + info(label, "> ..."); + } + info(label, "> " + ts); + compress = false; + } + else { + compress = true; + } + } + if (ts.startsWith("java")) { + break; + } + } + if (compress) { + info(label, "> ..."); + } + } + + public static void info(String label, Object... extras) { + if (PAUSE) { return; } + if (extras == null || extras.length == 0) { + info(label, null, false, null); + } + else if (extras[0] instanceof NatsMessage) { + info(label, (NatsMessage)extras[0], true, stringify(extras, true)); + } + else { + info(label, null, false, stringify(extras, false)); + } + } + + public static void info(String label, Message msg, boolean forMsg, String extra) { + if (PAUSE) { return; } + String start; + if (PRINT_TIME && PRINT_THREAD_ID) { + start = "[" + Thread.currentThread().getName() + "@" + time() + "] "; + } + else if (PRINT_TIME){ + start = "[" + time() + "] "; + } + else if (PRINT_THREAD_ID){ + start = "[" + Thread.currentThread().getName() + "] "; + } + else { + start = ""; + } + + if (extra == null) { + extra = ""; + } + else { + extra = SEP + extra; + } + + if (label != null) { + label = label.trim(); + } + if (label == null || label.isEmpty()) { + label = start; + } + else { + label = start + label; + } + + if (msg == null) { + if (forMsg) { + DEBUG_PRINTER.println(label + "" + extra); + } + else { + DEBUG_PRINTER.println(label + extra); + } + return; + } + + if (msg.isStatusMessage()) { + DEBUG_PRINTER.println(label + sidString(msg) + msgInfoString(msg) + msg.getStatus() + extra); + } + else if (msg.isJetStream()) { + DEBUG_PRINTER.println(label + sidString(msg) + msgInfoString(msg) + dataString(msg) + replyToString(msg) + extra); + } + else if (msg.getSubject() == null) { + DEBUG_PRINTER.println(label + sidString(msg) + msg + extra); + } + else { + DEBUG_PRINTER.println(label + sidString(msg) + msgInfoString(msg) + dataString(msg) + replyToString(msg) + extra); + } + debugHdr(label.length() + 1, msg); + } + + private static String messageString(Message msg) { + return sidString(msg) + msgInfoString(msg) + dataString(msg) + replyToString(msg); + } + + public static void warn(String label, Object... extras) { + info(label, extras); + } + + public static void warn(String label, Message msg, boolean forMsg, String extra) { + info(label, msg, forMsg, extra); + } + + public static String sidString(Message msg) { + return msg.getSID() == null ? SEP : " sid:" + msg.getSID() + SEP; + } + + public static String msgInfoString(Message msg) { + if (msg.isJetStream()) { + return msg.metaData().streamSequence() + + DIV + msg.metaData().consumerSequence() + + SEP + msg.getSubject() + + SEP; + } + return msg.getSubject() + SEP; + } + + public static String replyToString(Message msg) { + if (msg.isJetStream()) { + NatsJetStreamMetaData meta = msg.metaData(); + return "ss:" + meta.streamSequence() + ' ' + + "cc:" + meta.consumerSequence() + ' ' + + "dlvr:" + meta.deliveredCount() + ' ' + + "pnd:" + meta.pendingCount() + + SEP; + } + if (msg.getReplyTo() == null) { + return ""; + } + return msg.getReplyTo(); + } + + public static String time() { + return "" + System.currentTimeMillis(); + } + + public static String dataString(Message msg) { + byte[] data = msg.getData(); + if (data == null || data.length == 0) { + return "" + SEP; + } + String s = new String(data, UTF_8); + if (DO_NOT_TRUNCATE) { + return s + SEP; + } + + int at = s.indexOf("io.nats.jetstream.api"); + if (at == -1) { + return s.length() > MAX_DATA_DISPLAY ? s.substring(0, MAX_DATA_DISPLAY) + "..." : s; + } + int at2 = s.indexOf('"', at); + return s.substring(at, at2) + SEP; + } + + public static String stringify(Object[] extras, boolean skipFirst) { + if (extras == null || extras.length == 0) { + return null; + } + + if (extras.length == 1) { + return skipFirst ? null : getString(extras[0]); + } + + boolean notFirst = false; + StringBuilder sb = new StringBuilder(); + for (int i = (skipFirst ? 1 : 0); i < extras.length; i++) { + if (notFirst) { + sb.append(SEP); + } + else { + notFirst = true; + } + + String xtra = getString(extras[i]); + while (xtra.contains("%s")) { + xtra = xtra.replaceFirst(REPLACE, getString(extras[++i])); + } + sb.append(xtra); + } + + return sb.length() == 0 ? null : sb.toString(); + } + + public static String getString(Object o) { + if (o == null) { + return "null"; + } + if (o instanceof Message) { + Message msg = (Message)o; + if (msg.getSubject() == null) { + return msg.toString(); + } + return msgInfoString(msg) + dataString(msg) + replyToString(msg); + } +// if (o instanceof ConsumerInfo) { +// o = ((ConsumerInfo)o).getConsumerConfiguration(); +// } + if (o instanceof ConsumerInfo) { + return consumerInfoString((ConsumerInfo)o); + } + if (o instanceof SequenceInfo) { + return sequenceInfoString((SequenceInfo)o); + } + if (o instanceof NatsJetStreamMetaData) { + return metaDataString((NatsJetStreamMetaData)o); + } + if (o instanceof ZonedDateTime) { + return DateTimeUtils.toRfc3339((ZonedDateTime)o); + } +// if (o instanceof ZonedDateTime) { +// return zdtString((ZonedDateTime)o); +// } + if (o instanceof ConsumerConfiguration) { + return formatted((ConsumerConfiguration)o); + } + if (o instanceof Headers) { + Headers h = (Headers)o; + boolean notFirst = false; + StringBuilder sb = new StringBuilder("["); + for (String key : h.keySet()) { + if (notFirst) { + sb.append(','); + } + else { + notFirst = true; + } + sb.append(key).append("=").append(h.get(key)); + } + return sb.append(']').toString(); + } + if (o instanceof JsonSerializable) { + return ((JsonSerializable)o).toJson(); + } + if (o instanceof byte[]) { + byte[] bytes = (byte[])o; + if (bytes.length == 0) { + return ""; + } + return new String((byte[])o); + } + if (o instanceof String[]) { + StringBuilder sb = new StringBuilder(); + boolean first = true; + for (String s : (String[])o) { + if (first) { + first = false; + } + else { + sb.append(", "); + } + sb.append('\'') + .append(s == null ? "" : (s.isEmpty() ? "" : s)) + .append('\''); + } + return sb.toString(); + } + String s = o.toString(); + return s.isEmpty() ? "" : s; + } + + public static void debugHdr(int indent, Message msg) { + Headers h = msg.getHeaders(); + if (h != null && !h.isEmpty()) { + String pad = PAD.substring(0, indent); + for (String key : h.keySet()) { + DEBUG_PRINTER.println(pad + key + "=" + h.get(key)); + } + } + } + + public static void streamAndConsumer(Connection nc, String stream, String conName) throws IOException, JetStreamApiException { + streamAndConsumer(nc.jetStreamManagement(), stream, conName); + } + + public static void streamAndConsumer(JetStreamManagement jsm, String stream, String conName) throws IOException, JetStreamApiException { + printStreamInfo(jsm.getStreamInfo(stream)); + printConsumerInfo(jsm.getConsumerInfo(stream, conName)); + } + + public static void consumer(Connection nc, String stream, String conName) throws IOException, JetStreamApiException { + consumer(nc.jetStreamManagement(), stream, conName); + } + + public static void consumer(JetStreamManagement jsm, String stream, String conName) throws IOException, JetStreamApiException { + ConsumerInfo ci = jsm.getConsumerInfo(stream, conName); + DEBUG_PRINTER.println("Consumer pending=" + ci.getNumPending() + " waiting=" + ci.getNumWaiting() + " ackPending=" + ci.getNumAckPending()); + } + + public static void printStreamInfo(StreamInfo si) { + printObject(si, "StreamConfiguration", "StreamState", "ClusterInfo", "Mirror", "subjects", "sources"); + } + + public static void printStreamInfoList(List list) { + printObject(list, "!StreamInfo", "StreamConfiguration", "StreamState"); + } + + public static void printConsumerInfo(ConsumerInfo ci) { + printObject(ci, "ConsumerConfiguration", "Delivered", "AckFloor"); + } + + public static void printConsumerInfoList(List list) { + printObject(list, "!ConsumerInfo", "ConsumerConfiguration", "Delivered", "AckFloor"); + } + + public static void printObject(Object o, String... subObjectNames) { + String s = o.toString(); + for (String sub : subObjectNames) { + boolean noIndent = sub.startsWith("!"); + String sb = noIndent ? sub.substring(1) : sub; + String rx1 = ", " + sb; + String repl1 = (noIndent ? ",\n": ",\n ") + sb; + s = s.replace(rx1, repl1); + } + DEBUG_PRINTER.println(s); + } + + public static String pad2(int n) { + return n < 10 ? " " + n : "" + n; + } + + public static String pad3(int n) { + return n < 10 ? " " + n : (n < 100 ? " " + n : "" + n); + } + + public static String pad3z(int n) { + return n < 10 ? "00" + n : (n < 100 ? "0" + n : "" + n); + } + + public static String yn(boolean b) { + return b ? "Yes" : "No "; + } + + public static String FN = "\n "; + public static String FBN = "{\n "; + public static String formatted(JsonSerializable j) { + return j.getClass().getSimpleName() + j.toJson() + .replace("{\"", FBN + "\"").replace(",", "," + FN); + } + + public static String formatted(Object o) { + return formatted(o.toString()); + } + + public static String formatted(String s) { + return s.replace("{", FBN).replace(", ", "," + FN); + } + + public static String consumerInfoString(ConsumerInfo ci) { + return ci == null ? "null" : + "Consumer{" + + "pending=" + ci.getNumPending() + + ", waiting=" + ci.getNumWaiting() + + ", ackPending=" + ci.getNumAckPending() + + ", redelivered=" + ci.getRedelivered() + + ", delivered=" + sequenceInfoString(ci.getDelivered()) + + ", ackFloor=" + sequenceInfoString(ci.getAckFloor()) + + "} "; + } + + public static String sequenceInfoString(SequenceInfo si) { + return si == null ? "null" : + "{" + + "consumerSeq=" + si.getConsumerSequence() + + ", streamSeq=" + si.getStreamSequence() + + ", lastActive=" + zdtString(si.getLastActive()) + + '}'; + } + + public static String metaDataString(NatsJetStreamMetaData meta) { + return meta == null ? "null" : + "Meta{" + + "delivered=" + meta.deliveredCount() + + ", streamSeq=" + meta.streamSequence() + + ", consumerSeq=" + meta.consumerSequence() + + ", pending=" + meta.pendingCount() + + ", timestamp=" + zdtString(meta.timestamp()) + + '}'; + } + + public static String zdtString(ZonedDateTime zdt) { + return zdt == null ? "null" : zdt.toLocalTime().toString(); + } +} diff --git a/batch-publish/src/test/resources/placeholder.txt b/batch-publish/src/test/resources/placeholder.txt new file mode 100644 index 0000000..ca5fd64 --- /dev/null +++ b/batch-publish/src/test/resources/placeholder.txt @@ -0,0 +1 @@ +This is just a placeholder. \ No newline at end of file diff --git a/chaos-runner/README.md b/chaos-runner/README.md index 1d62aec..dec33c7 100644 --- a/chaos-runner/README.md +++ b/chaos-runner/README.md @@ -1,51 +1,55 @@ -![Synadia](src/main/javadoc/images/synadia-logo.png)      ![NATS](src/main/javadoc/images/large-logo.png) +Orbit # Chaos Runner A simple java program that can start 1 or more NATS Servers and then add chaos, by taking one of them down on a delay and bringing it back up after a downtime. -**Current Release**: 0.0.2 -  **Current Snapshot**: 0.0.3-SNAPSHOT -  **Gradle and Maven** `io.synadia:chaos-runner` - -[Dependencies Help](https://github.com/synadia-io/orbit.java?tab=readme-ov-file#dependencies) +![Artifact](https://img.shields.io/badge/Artifact-io.synadia:chaos--runner-197556?labelColor=grey&style=flat) +![0.0.8](https://img.shields.io/badge/Current_Release-0.0.8-27AAE0) +![0.0.9](https://img.shields.io/badge/Current_Snapshot-0.0.9--SNAPSHOT-27AAE0) +[![Dependencies Help](https://img.shields.io/badge/Dependencies%20Help-27AAE0)](https://github.com/synadia-io/orbit.java?tab=readme-ov-file#dependencies) +[![javadoc](https://javadoc.io/badge2/io.synadia/chaos-runner/javadoc.svg)](https://javadoc.io/doc/io.synadia/chaos-runner) +[![Maven Central](https://img.shields.io/maven-central/v/io.synadia/chaos-runner)](https://img.shields.io/maven-central/v/io.synadia/chaos-runner) ## Uber Jar The project builds an Uber Jar that contains the compiled code for the Chaos Runner and the Nats Server Runner. You can get this jar in 2 ways. -1. Download the release: [chaos-runner-0.0.2-uber.jar](https://repo1.maven.org/maven2/io/synadia/chaos-runner/0.0.2/chaos-runner-0.0.2-uber.jar) +1. Download the release: [chaos-runner-0.0.3-uber.jar](https://repo1.maven.org/maven2/io/synadia/chaos-runner/0.0.3/chaos-runner-0.0.3-uber.jar) 2. Build from the source. Get the entire chaos-runner source from this Orbit repo, and from the chaos-runner project directory and run `gradle uberJar` - The Uber Jar `chaos-runner-0.0.2-SNAPSHOT-uber.jar` will appear in the `build/libs/` directory + The Uber Jar `chaos-runner-0.0.3-SNAPSHOT-uber.jar` will appear in the `build/libs/` directory (relative to the `chaos-runner` project directory.) ## Command Line Arguments -| Argument | Description | Default | -|----------------------|----------------------------------------------------------------------------|-------------| -| `--servers ` | Number of servers. Accepts 1, 3 or 5 | 3 | -| `--delay ` | Delay to bring down a server, since all servers were up. | 5000 | -| `--initial ` | The first delay. Gives time to start your test program and run setup. | 30000 | -| `--down ` | Delay to bring a server up once it is brought down. | 5000 | -| `--cname ` | Cluster name. Ignored for 1 server. | "cluster" | -| `--prefix ` | Prefix to use for the server name. Used in it's entirety for 1 server | "server" | -| `--dir ` | The working dir. Used as the parent dir for JetStream storage directories. | _temp_ | -| `--nojs` | Do not run the server with JetStream. JetStream is on by default. | JetStream | -| `--random` | Take the servers down randomly. Default is Round Robin. | Round Robin | -| `--port` | The starting server port. | 4220 | -| `--listen` | The starting listen port for clusters. | 4230 | +| Argument | Description | Default | +|-------------------------|----------------------------------------------------------------------------|---------------| +| `--servers <1, 3 or 5>` | Number of servers. Accepts 1, 3 or 5 | 3 | +| `--delay ` | Delay to bring down a server, starting when all servers are up. | 5000 | +| `--initial ` | The first delay. Gives time to start your test program and run setup. | 30000 | +| `--down ` | Delay to bring a server up once it is brought down. | 5000 | +| `--cname ` | Cluster name. Ignored for 1 server. | "cluster" | +| `--prefix ` | Prefix to use for the server name. Used in it's entirety for 1 server | "server" | +| `--dir ` | The working dir. Used as the parent dir for JetStream storage directories. | _system temp_ | +| `--nojs` | Do not run the server with JetStream. JetStream is on by default. | JetStream | +| `--random` | Take the servers down randomly. Default is Round Robin. | Round Robin | +| `--port` | The starting server port. | 4222 | +| `--listen` | The starting listen port for clusters. | 4232 | +| `--monitor` | The starting monitor port. Use 0 for no monitor | 4282 | #### Regarding ports Given any starting port, the system automatically figures the ports for the other nodes. -For example for 3 nodes: -* if the starting server port is 4220, the other ports are 4221 and 4222. -* if the listen port is 4230, the other listen ports are 4231 and 4232 +For 1 node, the port and listen are used directly. For 3 or 5 nodes used for the first server, +each server port is 1 more than the last. Make sure that starting and listen won't overlap. +So for example, for 3 servers... +* If the starting server port is 4222, the other ports are 4223 and 4224. +* If the listen port is 4232, the other listen ports are 4232 and 4233 -![Artifact](https://img.shields.io/badge/Artifact-io.synadia:chaos--runner-00BC8E?labelColor=grey&style=flat) +![Artifact](https://img.shields.io/badge/Artifact-io.synadia:chaos--runner-197556?labelColor=grey&style=flat) [![License Apache 2](https://img.shields.io/badge/License-Apache2-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0) [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.synadia/chaos-runner/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.synadia/chaos-runner) [![javadoc](https://javadoc.io/badge2/io.synadia/chaos-runner/javadoc.svg)](https://javadoc.io/doc/io.synadia/chaos-runner) @@ -61,16 +65,32 @@ java -cp / io.synadia.chaos.ChaosRunner --servers 1 --delay 4 #### Path-To and Jar-Name 1\.If you downloaded the Uber Jar release: * the `` will be wherever you stored the file. -* The `` will be `chaos-runner-0.0.2-uber.jar`. +* The `` will be `chaos-runner-0.0.3-uber.jar`. 2\. If you build it yourself: * the `` will be relative to the `chaos-runner` directory in `build/libs` -* the `` will be `chaos-runner-0.0.2-SNAPSHOT-uber.jar`. +* the `` will be `chaos-runner-0.0.3-SNAPSHOT-uber.jar`. ## Other ways to run... Alternatively you can run a program like the [ChaosRunnerExample](src/examples/java/io/synadia/examples/ChaosRunnerExample.java) from an ide. +### Native image + +You can a download zip file containing a Windows executable `chaos-runner.exe` from the release page, +[chaos-runner-003-windows-exe.zip](https://github.com/synadia-io/orbit.java/releases/download/cr%2F0.0.3/chaos-runner-003-windows-exe.zip) + +-or- + +You can use [GraalVM](https://www.graalvm.org/) native-image to create a native executable for your platform. +This assumes you've installed graalvm. You may need to specify the full path to the native-image.cmd +(or your platform equivalent) if not already in your path. + +``` +> native-image.cmd --install-exit-handlers -cp \chaos-runner-0.0.3-uber.jar io.synadia.chaos.ChaosRunner chaos-runner +> chaos-runner.exe --servers 1 --delay 4000 --initial 10000 +``` + --- Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. See [LICENSE](LICENSE) and [NOTICE](NOTICE) file for details. diff --git a/chaos-runner/build.gradle b/chaos-runner/build.gradle index 54a861d..1719c8e 100644 --- a/chaos-runner/build.gradle +++ b/chaos-runner/build.gradle @@ -13,7 +13,7 @@ plugins { id 'signing' } -def jarVersion = "0.0.3" +def jarVersion = "0.0.9" group = 'io.synadia' def isMerge = System.getenv("BUILD_EVENT") == "push" @@ -38,9 +38,10 @@ repositories { } dependencies { - implementation 'io.nats:jnats-server-runner:2.0.2-SNAPSHOT' + implementation 'io.nats:jnats-server-runner:2.0.2' - implementation 'io.nats:jnats:2.21.4' // this is only for the example + // this is only for the example and the uber jar won't include it + implementation 'io.nats:jnats:2.25.1' testImplementation 'commons-codec:commons-codec:1.18.0' testImplementation 'org.junit.jupiter:junit-jupiter:5.9.0' @@ -68,7 +69,7 @@ tasks.register('bundle', Bundle) { jar { manifest { - attributes('Automatic-Module-Name': 'io.synadia.chaos-runner') + attributes('Automatic-Module-Name': 'io.synadia.chaos.runner') } bnd (['Implementation-Title': 'Chaos Runner', 'Implementation-Version': jarVersion, @@ -140,6 +141,20 @@ tasks.register ('uberJar', Jar) { exclude 'META-INF/*.RSA','META-INF/*.SF','META-INF/*.DSA','**/examples**','placeholder.*' } +tasks.register ('examplesUberJar', Jar) { + archiveClassifier.set('examplesUber') + from sourceSets.main.output + dependsOn configurations.runtimeClasspath + from { + configurations.runtimeClasspath + .findAll { + it.name.contains('jnats') || it.name.contains('nats-server-runner') // examples needs jnats too + } + .collect { zipTree(it) } + } + exclude 'META-INF/*.RSA','META-INF/*.SF','META-INF/*.DSA','placeholder.*' +} + jacoco { toolVersion = "0.8.6" } diff --git a/chaos-runner/make.bat b/chaos-runner/make.bat new file mode 100644 index 0000000..6be1fb9 --- /dev/null +++ b/chaos-runner/make.bat @@ -0,0 +1,2 @@ +call gradle clean uberJar examplesUberJar +native-image.cmd --install-exit-handlers -cp build\libs\chaos-runner-0.0.3-SNAPSHOT-uber.jar io.synadia.chaos.ChaosRunner chaos-runner \ No newline at end of file diff --git a/chaos-runner/src/examples/java/io/synadia/examples/ChaosConnectionListener.java b/chaos-runner/src/examples/java/io/synadia/examples/ChaosConnectionListener.java index cbe926b..0440949 100644 --- a/chaos-runner/src/examples/java/io/synadia/examples/ChaosConnectionListener.java +++ b/chaos-runner/src/examples/java/io/synadia/examples/ChaosConnectionListener.java @@ -6,17 +6,47 @@ import io.nats.client.Connection; import io.nats.client.ConnectionListener; -import static io.synadia.chaos.ChaosUtils.report; +import java.util.concurrent.atomic.AtomicInteger; + +import static io.synadia.chaos.ChaosUtils.out; public class ChaosConnectionListener implements ConnectionListener { - private final String reportLabel; + private static String message(Events event) { + switch (event) { + case CONNECTED: return "Connected"; + case CLOSED: return "Closed"; + case DISCONNECTED: return "Disconnected"; + case RECONNECTED: return "Re-Connected"; + case RESUBSCRIBED: return "Subscriptions Re-Established"; + case DISCOVERED_SERVERS: return "Servers Discovered"; + case LAME_DUCK: return "Entering lame duck mode"; + } + return ""; + }; + + private final String connectionName; + private final AtomicInteger currentPort; public ChaosConnectionListener(String connectionName) { - this.reportLabel = "CL/" + connectionName; + this.connectionName = connectionName; + currentPort = new AtomicInteger(0); } @Override public void connectionEvent(Connection conn, Events type) { - report(reportLabel, type); + int cur; + if (type == Events.CONNECTED) { + cur = conn.getServerInfo().getPort(); + currentPort.set(cur); + } + else { + cur = currentPort.get(); + } + if (cur == 0) { + out("CL", connectionName, message(type)); + } + else { + out("CL", connectionName, message(type), "Port: " + cur); + } } } diff --git a/chaos-runner/src/examples/java/io/synadia/examples/ChaosErrorListener.java b/chaos-runner/src/examples/java/io/synadia/examples/ChaosErrorListener.java index b76cf71..ec8538d 100644 --- a/chaos-runner/src/examples/java/io/synadia/examples/ChaosErrorListener.java +++ b/chaos-runner/src/examples/java/io/synadia/examples/ChaosErrorListener.java @@ -10,23 +10,23 @@ import io.nats.client.impl.ErrorListenerConsoleImpl; import io.nats.client.support.Status; -import static io.synadia.chaos.ChaosUtils.report; +import static io.synadia.chaos.ChaosUtils.out; public class ChaosErrorListener extends ErrorListenerConsoleImpl { - private final String reportLabel; + private final String connectionName; public ChaosErrorListener(String connectionName) { - this.reportLabel = "EL/" + connectionName; + this.connectionName = connectionName; } @Override public void errorOccurred(Connection conn, String error) { - report(reportLabel, supplyMessage("[SEVERE] errorOccurred", conn, null, null, "Error: ", error)); + out("EL", connectionName, "Error", error); } @Override public void exceptionOccurred(Connection conn, Exception exp) { - report(reportLabel, supplyMessage("[SEVERE] exceptionOccurred", conn, null, null, "Exception: ", exp)); + out("EL", connectionName, "Exception", exp); } @Override @@ -39,7 +39,7 @@ public void messageDiscarded(Connection conn, Message msg) { @Override public void heartbeatAlarm(Connection conn, JetStreamSubscription sub, long lastStreamSequence, long lastConsumerSequence) { - report(reportLabel, supplyMessage("[SEVERE] heartbeatAlarm", conn, null, sub, "lastStreamSequence: ", lastStreamSequence, "lastConsumerSequence: ", lastConsumerSequence)); + out("EL", connectionName, "Heartbeat Alarm", "Last Stream Sequence: " + lastStreamSequence, "Last Consumer Sequence: " + lastConsumerSequence); } @Override @@ -60,5 +60,6 @@ public void flowControlProcessed(Connection conn, JetStreamSubscription sub, Str @Override public void socketWriteTimeout(Connection conn) { + out("EL", connectionName, "Socket Write Timeout"); } } diff --git a/chaos-runner/src/examples/java/io/synadia/examples/ChaosRunnerExample.java b/chaos-runner/src/examples/java/io/synadia/examples/ChaosRunnerExample.java index e6dbda1..45b357c 100644 --- a/chaos-runner/src/examples/java/io/synadia/examples/ChaosRunnerExample.java +++ b/chaos-runner/src/examples/java/io/synadia/examples/ChaosRunnerExample.java @@ -9,26 +9,37 @@ import io.synadia.chaos.ChaosArguments; import io.synadia.chaos.ChaosRunner; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URL; import java.util.ArrayList; import java.util.List; -import static io.synadia.chaos.ChaosUtils.report; +import static io.synadia.chaos.ChaosUtils.out; public class ChaosRunnerExample { - static final int NUM_CONNECTIONS = 2; + private static final int SERVER_COUNT = 3; // 1, 3, 5 + private static final long DELAY = 5000; // the delay to bring a server down + private static final long INITIAL_DELAY = 10000; // the delay to bring a server down the first time + private static final long DOWN_TIME = 5000; // how long before bringing the server up + private static final int HEALTH_CHECK_DELAY = 3000; - static final int SERVER_COUNT = 3; // 1, 3, 5 - static final long DELAY = 5000; // the delay to bring a server down - static final long INITIAL_DELAY = 10000; // the delay to bring a server down the first time - static final long DOWN_TIME = 5000; // how long before bringing the server up - static final long STAY_ALIVE = 60_000; // how long to run the example program + private static final int NUM_CONNECTIONS = 5; public static void main(String[] args) throws Exception { ChaosArguments arguments = new ChaosArguments() .servers(SERVER_COUNT) + .workDirectory("C:\\temp\\chaos-runner") + .serverNamePrefix("cr-example-server") + .clusterName("cr-example-cluster") .delay(DELAY) .initialDelay(INITIAL_DELAY) - .downTime(DOWN_TIME); + .downTime(DOWN_TIME) + // this is done last so anything on the command line + // is used over the hard coded items. + .args(args); ChaosRunner runner = ChaosRunner.start(arguments); @@ -36,26 +47,80 @@ public static void main(String[] args) throws Exception { Thread.sleep(1000); String[] urls = runner.getConnectionUrls(); - report("EXAMPLE", "Connection Urls:"); + out("Connection Urls"); for (String url : urls) { - report("EXAMPLE", " " + url); + out(" ", url); } + @SuppressWarnings("MismatchedQueryAndUpdateOfCollection") List connections = new ArrayList<>(urls.length); for (int i = 0; i < NUM_CONNECTIONS; i++) { - String cn = "CONN/" + i; + String connectionName = "Conn" + (i + 1); Options options = Options.builder().servers(urls) - .connectionListener(new ChaosConnectionListener(cn)) - .errorListener(new ChaosErrorListener(cn)) + .connectionListener(new ChaosConnectionListener(connectionName)) + .errorListener(new ChaosErrorListener(connectionName)) .build(); Connection connection = Nats.connect(options); connections.add(connection); - report("EXAMPLE", "Initial connection for " + cn, "Port: " + connection.getServerInfo().getPort()); } - // this just allows time for the runner to work - Thread.sleep(STAY_ALIVE); - System.exit(0); + int[] ports = runner.getConnectionPorts(); + int[] monitorPorts = runner.getMonitorPorts(); + boolean hasMonitor = monitorPorts[0] > 0; + + String[] hzs = new String[ports.length]; + while (true) { + Thread.sleep(HEALTH_CHECK_DELAY); + if (hasMonitor) { + boolean changed = false; + for (int i = 0; i < monitorPorts.length; i++) { + String hz = readHealthz(monitorPorts[i]); + if (!hz.equals(hzs[i])) { + changed = true; + hzs[i] = hz; + } + } + if (changed) { + out("HealthZ"); + for (int i = 0; i < monitorPorts.length; i++) { + int port = ports[i]; + int mport = monitorPorts[i]; + out(" ", port + "/" + mport, hzs[i]); + } + } + } + } + } + + private static String readHealthz(int port) { + return readEndpoint(port, "healthz"); + } + + private static String readEndpoint(int port, String endpoint) { + String sUrl = "http://localhost:" + port + "/" + endpoint; + try { + URL url = new URL(sUrl); + InputStream inputStream = url.openStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); + + boolean first = true; + String line; + StringBuilder content = new StringBuilder(); + while ((line = reader.readLine()) != null) { + if (first) { + first = false; + } + else { + content.append(System.lineSeparator()); + } + content.append(line); + } + reader.close(); + return content.toString().trim(); + } + catch (IOException e) { + return e.getMessage(); + } } } diff --git a/chaos-runner/src/examples/java/io/synadia/examples/ChaosRunnerShutdown.java b/chaos-runner/src/examples/java/io/synadia/examples/ChaosRunnerShutdown.java new file mode 100644 index 0000000..5715a50 --- /dev/null +++ b/chaos-runner/src/examples/java/io/synadia/examples/ChaosRunnerShutdown.java @@ -0,0 +1,94 @@ +// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.examples; + +import io.synadia.chaos.ChaosArguments; +import io.synadia.chaos.ChaosRunner; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URL; + +import static io.synadia.chaos.ChaosUtils.out; + +public class ChaosRunnerShutdown { + + public static void main(String[] args) throws Exception { + ChaosArguments arguments = new ChaosArguments() + .servers(3) + .workDirectory("C:\\temp\\chaos-runner") + .serverNamePrefix("cr-shutdown-server") + .clusterName("cr-shutdown-cluster") + .delay(30_000) + .initialDelay(30_000) + .downTime(30_000); + + ChaosRunner runner = ChaosRunner.start(arguments); + + // just give the servers a little time to be ready be first connect + Thread.sleep(1000); + + String[] urls = runner.getConnectionUrls(); + out("Connection Urls"); + for (String url : urls) { + out(" ", url); + } + + int[] ports = runner.getConnectionPorts(); + int[] monitorPorts = runner.getMonitorPorts(); + + Thread.sleep(1000); + out("H RUNNING", ChaosRunner.isRunning()); + for (int i = 0; i < monitorPorts.length; i++) { + int port = ports[i]; + int mport = monitorPorts[i]; + String hz = readHealthz(monitorPorts[i]); + out("H", port + "/" + mport, hz); + } + + ChaosRunner.shutdown(); + + Thread.sleep(1000); + out("Z RUNNING", ChaosRunner.isRunning()); + for (int i = 0; i < monitorPorts.length; i++) { + int port = ports[i]; + int mport = monitorPorts[i]; + String hz = readHealthz(monitorPorts[i]); + out("Z", port + "/" + mport, hz); + } + } + + private static String readHealthz(int port) { + return readEndpoint(port, "healthz"); + } + + private static String readEndpoint(int port, String endpoint) { + String sUrl = "http://localhost:" + port + "/" + endpoint; + try { + URL url = new URL(sUrl); + InputStream inputStream = url.openStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); + + boolean first = true; + String line; + StringBuilder content = new StringBuilder(); + while ((line = reader.readLine()) != null) { + if (first) { + first = false; + } + else { + content.append(System.lineSeparator()); + } + content.append(line); + } + reader.close(); + return content.toString().trim(); + } + catch (IOException e) { + return e.getMessage(); + } + } +} diff --git a/chaos-runner/src/examples/java/io/synadia/examples/ChaosRunnerSpecificPortExample.java b/chaos-runner/src/examples/java/io/synadia/examples/ChaosRunnerSpecificPortExample.java new file mode 100644 index 0000000..7a8a078 --- /dev/null +++ b/chaos-runner/src/examples/java/io/synadia/examples/ChaosRunnerSpecificPortExample.java @@ -0,0 +1,128 @@ +// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.examples; + +import io.nats.client.Connection; +import io.nats.client.Nats; +import io.nats.client.Options; +import io.synadia.chaos.ChaosArguments; +import io.synadia.chaos.ChaosRunner; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +import static io.synadia.chaos.ChaosUtils.out; + +public class ChaosRunnerSpecificPortExample { + private static final int SPECIFIC_PORT = 4222; + private static final int SERVER_COUNT = 3; // 1, 3, 5 + private static final long DELAY = 3000; // the delay to bring a server down + private static final long INITIAL_DELAY = 3000; // the delay to bring a server down the first time + private static final long DOWN_TIME = 3000; // how long before bringing the server up + private static final int HEALTH_CHECK_DELAY = 1000; + + private static final int NUM_CONNECTIONS = 5; + + public static void main(String[] args) throws Exception { + ChaosArguments arguments = new ChaosArguments() + .servers(SERVER_COUNT) + .specificPort(SPECIFIC_PORT) + .workDirectory("C:\\temp\\chaos-runner") + .serverNamePrefix("cr-example-server") + .clusterName("cr-example-cluster") + .delay(DELAY) + .initialDelay(INITIAL_DELAY) + .downTime(DOWN_TIME) + // this is done last so anything on the command line + // is used over the hard coded items. + .args(args); + + ChaosRunner runner = ChaosRunner.start(arguments); + + // just give the servers a little time to be ready be first connect + Thread.sleep(1000); + + String[] urls = runner.getConnectionUrls(); + out("Connection Urls"); + for (String url : urls) { + out(" ", url); + } + + @SuppressWarnings("MismatchedQueryAndUpdateOfCollection") + List connections = new ArrayList<>(urls.length); + for (int i = 0; i < NUM_CONNECTIONS; i++) { + String connectionName = "Conn" + (i + 1); + Options options = Options.builder().servers(urls) + .connectionListener(new ChaosConnectionListener(connectionName)) + .errorListener(new ChaosErrorListener(connectionName)) + .build(); + + Connection connection = Nats.connect(options); + connections.add(connection); + } + + int[] ports = runner.getConnectionPorts(); + int[] monitorPorts = runner.getMonitorPorts(); + boolean hasMonitor = monitorPorts[0] > 0; + + String[] hzs = new String[ports.length]; + while (true) { + Thread.sleep(HEALTH_CHECK_DELAY); + if (hasMonitor) { + boolean changed = false; + for (int i = 0; i < monitorPorts.length; i++) { + String hz = readHealthz(monitorPorts[i]); + if (!hz.equals(hzs[i])) { + changed = true; + hzs[i] = hz; + } + } + if (changed) { + out("HealthZ"); + for (int i = 0; i < monitorPorts.length; i++) { + int port = ports[i]; + int mport = monitorPorts[i]; + out(" ", port + "/" + mport, hzs[i]); + } + } + } + } + } + + private static String readHealthz(int port) { + return readEndpoint(port, "healthz"); + } + + private static String readEndpoint(int port, String endpoint) { + String sUrl = "http://localhost:" + port + "/" + endpoint; + try { + URL url = new URL(sUrl); + InputStream inputStream = url.openStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); + + boolean first = true; + String line; + StringBuilder content = new StringBuilder(); + while ((line = reader.readLine()) != null) { + if (first) { + first = false; + } + else { + content.append(System.lineSeparator()); + } + content.append(line); + } + reader.close(); + return content.toString().trim(); + } + catch (IOException e) { + return e.getMessage(); + } + } +} diff --git a/chaos-runner/src/main/java/io/synadia/chaos/ChaosArguments.java b/chaos-runner/src/main/java/io/synadia/chaos/ChaosArguments.java index 64ccd51..00053b0 100644 --- a/chaos-runner/src/main/java/io/synadia/chaos/ChaosArguments.java +++ b/chaos-runner/src/main/java/io/synadia/chaos/ChaosArguments.java @@ -3,8 +3,10 @@ import java.io.File; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.Objects; -import static io.nats.NatsRunnerUtils.*; +import static io.nats.NatsRunnerUtils.DEFAULT_CLUSTER_NAME; +import static io.nats.NatsRunnerUtils.DEFAULT_SERVER_NAME_PREFIX; public class ChaosArguments { @@ -17,8 +19,10 @@ public class ChaosArguments { long delay = 5_000; long downTime = 5_000; boolean random = false; - int port = DEFAULT_PORT_START; - int listen = DEFAULT_LISTEN_START; + int specificPort = -1; + int port = 4222; + int listen = 4232; + int monitor = 4282; public ChaosArguments servers(int servers) { this.servers = servers; @@ -41,14 +45,26 @@ public ChaosArguments js(boolean js) { } public ChaosArguments workDirectory(String workDirectory) { + if (workDirectory == null || workDirectory.trim().isEmpty()) { + this.workDirectory = null; + return this; + } return workDirectory(Paths.get(workDirectory)); } public ChaosArguments workDirectory(File workDirectory) { + if (workDirectory == null) { + this.workDirectory = null; + return this; + } return workDirectory(workDirectory.getPath()); } public ChaosArguments workDirectory(Path workDirectory) { + if (workDirectory == null) { + this.workDirectory = null; + return this; + } this.workDirectory = workDirectory; return this; } @@ -78,11 +94,21 @@ public ChaosArguments port(int port) { return this; } + public ChaosArguments specificPort(int port) { + this.specificPort = port; + return this; + } + public ChaosArguments listen(int listen) { this.listen = listen; return this; } + public ChaosArguments monitor(int monitor) { + this.monitor = monitor; + return this; + } + public ChaosArguments args(String[] args) { if (args != null && args.length > 0) { try { @@ -119,9 +145,15 @@ public ChaosArguments args(String[] args) { case "--port": port(Integer.parseInt(args[++x])); break; + case "--sport": + specificPort(Integer.parseInt(args[++x])); + break; case "--listen": listen(Integer.parseInt(args[++x])); break; + case "--monitor": + monitor(Integer.parseInt(args[++x])); + break; case "": break; default: @@ -141,4 +173,93 @@ public void error(String errMsg) { System.err.println("ERROR: " + errMsg); System.exit(-1); } + + public int getServers() { + return servers; + } + + public String getClusterName() { + return clusterName; + } + + public String getServerNamePrefix() { + return serverNamePrefix; + } + + public boolean isJs() { + return js; + } + + public Path getWorkDirectory() { + return workDirectory; + } + + public long getInitialDelay() { + return initialDelay; + } + + public long getDelay() { + return delay; + } + + public long getDownTime() { + return downTime; + } + + public boolean isRandom() { + return random; + } + + public int getPort() { + return port; + } + + public int getListen() { + return listen; + } + + public int getMonitor() { + return monitor; + } + + @Override + public final boolean equals(Object o) { + if (!(o instanceof ChaosArguments)) return false; + if (this == o) { + return true; + } + + ChaosArguments that = (ChaosArguments) o; + return servers == that.servers + && js == that.js + && initialDelay == that.initialDelay + && delay == that.delay + && downTime == that.downTime + && random == that.random + && specificPort == that.specificPort + && port == that.port + && listen == that.listen + && monitor == that.monitor + && Objects.equals(clusterName, that.clusterName) + && Objects.equals(serverNamePrefix, that.serverNamePrefix) + && Objects.equals(workDirectory, that.workDirectory); + } + + @Override + public int hashCode() { + int result = servers; + result = 31 * result + Objects.hashCode(clusterName); + result = 31 * result + Objects.hashCode(serverNamePrefix); + result = 31 * result + Boolean.hashCode(js); + result = 31 * result + Objects.hashCode(workDirectory); + result = 31 * result + Long.hashCode(initialDelay); + result = 31 * result + Long.hashCode(delay); + result = 31 * result + Long.hashCode(downTime); + result = 31 * result + Boolean.hashCode(random); + result = 31 * result + specificPort; + result = 31 * result + port; + result = 31 * result + listen; + result = 31 * result + monitor; + return result; + } } diff --git a/chaos-runner/src/main/java/io/synadia/chaos/ChaosPrinter.java b/chaos-runner/src/main/java/io/synadia/chaos/ChaosPrinter.java new file mode 100644 index 0000000..e0c0a6f --- /dev/null +++ b/chaos-runner/src/main/java/io/synadia/chaos/ChaosPrinter.java @@ -0,0 +1,9 @@ +// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.chaos; + +public interface ChaosPrinter { + void out(Object... objects); + void err(Object... objects); +} diff --git a/chaos-runner/src/main/java/io/synadia/chaos/ChaosRunner.java b/chaos-runner/src/main/java/io/synadia/chaos/ChaosRunner.java index c027033..ce17179 100644 --- a/chaos-runner/src/main/java/io/synadia/chaos/ChaosRunner.java +++ b/chaos-runner/src/main/java/io/synadia/chaos/ChaosRunner.java @@ -16,13 +16,22 @@ import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; import java.util.logging.Level; import static io.nats.NatsRunnerUtils.*; -import static io.synadia.chaos.ChaosUtils.report; +import static io.synadia.chaos.ChaosUtils.getDefaultPrinter; public class ChaosRunner { + private static final String CR_LABEL = "ChaosRunner"; + + private static final ReentrantLock INSTANCE_LOCK = new ReentrantLock(); + private static ChaosRunner INSTANCE; + private static ChaosArguments INSTANCE_ARGUMENTS; + private static Thread APP_SHUTDOWN_HOOK_THREAD; + + public final ChaosPrinter printer; public final int servers; public final String clusterName; public final String serverNamePrefix; @@ -32,26 +41,16 @@ public class ChaosRunner { public final long delay; public final long downTime; public final boolean random; + public final int specificPort; public final int port; public final int listen; + public final int monitor; private final List clusterInserts; private final List natsServerRunners; private final ScheduledThreadPoolExecutor executor; private int downIx = 0; - public void shutdown() { - try { - for (NatsServerRunner runner : natsServerRunners ) { - try { - runner.close(); - } - catch (Exception ignore) {} - } - } - catch (Exception ignore) {} - } - public int[] getConnectionPorts() { int[] ports = new int[servers]; for (int ix = 0; ix < servers; ix++) { @@ -60,6 +59,23 @@ public int[] getConnectionPorts() { return ports; } + public int[] getListenPorts() { + int[] lports = new int[servers]; + for (int ix = 0; ix < servers; ix++) { + lports[ix] = clusterInserts.get(ix).node.listen; + } + return lports; + } + + public int[] getMonitorPorts() { + int[] mports = new int[servers]; + for (int ix = 0; ix < servers; ix++) { + Integer mport = clusterInserts.get(ix).node.monitor; + mports[ix] = mport == null ? 0 : mport; + } + return mports; + } + public String[] getConnectionUrls() { String[] urls = new String[servers]; for (int ix = 0; ix < servers; ix++) { @@ -90,31 +106,39 @@ private void scheduleUp() { private void downTask() { try { - if (random) { + if (specificPort != -1) { + for (int i = 0; i < natsServerRunners.size(); i++) { + NatsServerRunner nsr = natsServerRunners.get(i); + if (nsr.getPort() == specificPort) { + downIx = i; + break; + } + } + } + else if (random) { downIx = ThreadLocalRandom.current().nextInt(servers); } NatsServerRunner runner = natsServerRunners.remove(downIx); - report("DOWN", runner.getPort()); + printer.out(CR_LABEL, "DOWN", runner.getPort()); clusterInserts.add(clusterInserts.remove(downIx)); runner.close(); - scheduleUp(); } catch (Throwable e) { - report("DOWN/EX", e); + printer.out(CR_LABEL, "DOWN/EX", e); } } private void upTask() { try { NatsServerRunner runner = createRunner(servers - 1); - report("UP", runner.getPort()); + printer.out(CR_LABEL, "UP", runner.getPort()); natsServerRunners.add(runner); scheduleDown(delay); } catch (Throwable e) { - report("UP/EX: ", e); + printer.out(CR_LABEL, "UP/EX: ", e); scheduleUp(); } } @@ -146,9 +170,7 @@ public String toString() { return ChaosUtils.toString(this, System.lineSeparator(), "", " ", ""); } - private static ChaosRunner INSTANCE; - - private ChaosRunner(ChaosArguments a) throws IOException { + private ChaosRunner(ChaosArguments a, ChaosPrinter printer) throws IOException { if (a.workDirectory == null) { a.workDirectory = getTemporaryJetStreamStoreDirBase(); } @@ -160,6 +182,7 @@ else if (!a.workDirectory.toFile().exists()) { throw new IllegalArgumentException("Number of servers must be 1, 3 or 5"); } + this.printer = printer; this.servers = a.servers; this.clusterName = a.clusterName; this.serverNamePrefix = a.serverNamePrefix; @@ -169,23 +192,30 @@ else if (!a.workDirectory.toFile().exists()) { this.delay = a.delay; this.downTime = a.downTime; this.random = a.random; + this.specificPort = a.specificPort; this.port = a.port; this.listen = a.listen; + this.monitor = a.monitor; natsServerRunners = new ArrayList<>(); if (servers == 1) { + if (specificPort != -1 && specificPort != port) { + throw new IllegalArgumentException("Invalid specific port"); + } List inserts = new ArrayList<>(); ClusterNode cn; - if (jsStoreDirBase == null) { - cn = null; + Path jsStorePath = Paths.get(jsStoreDirBase.toString(), "" + port); + cn = ClusterNode.builder() + .port(port) + .listen(listen) + .monitor(monitor < 1 ? null : monitor) + .jsStoreDir(jsStorePath) + .build(); + + if (monitor > 0) { + inserts.add("http: " + monitor); } - else { - Path jsStorePath = Paths.get(jsStoreDirBase.toString(), "" + port); - cn = ClusterNode.builder() - .port(port) - .jsStoreDir(jsStorePath) - .build(); - + if (js) { String storeDir = jsStorePath.toString(); if (File.separatorChar == '\\') { storeDir = storeDir.replace("\\", "\\\\").replace("/", "\\\\"); @@ -203,7 +233,20 @@ else if (!a.workDirectory.toFile().exists()) { clusterInserts.add(new ClusterInsert(cn, inserts.toArray(new String[0]))); } else { - List cns = createNodes(servers, clusterName, serverNamePrefix, jsStoreDirBase, DEFAULT_HOST, port, listen, null); + List cns = createNodes(servers, clusterName, serverNamePrefix, jsStoreDirBase, DEFAULT_HOST, port, listen, monitor < 1 ? null : monitor); + if (specificPort != -1) { + boolean found = false; + for (ClusterNode cn : cns) { + if (cn.port == specificPort) { + found = true; + break; + } + } + if (!found) { + throw new IllegalArgumentException("Invalid specific port"); + } + } + clusterInserts = createClusterInserts(cns); } @@ -231,30 +274,107 @@ else if (!a.workDirectory.toFile().exists()) { scheduleDown(initialDelay); } - public static void main(String[] args) { - start(new ChaosArguments().args(args)); + public static ChaosRunner start(ChaosArguments a) throws Exception { + return start(a, null); } - public static ChaosRunner start(ChaosArguments a) { + public static ChaosRunner start(ChaosArguments a, ChaosPrinter printer) throws Exception { NatsServerRunner.setDefaultOutputLevel(Level.SEVERE); + final ChaosPrinter finalPrinter = printer == null ? getDefaultPrinter() : printer; + + INSTANCE_LOCK.lock(); try { - INSTANCE = new ChaosRunner(a); - } - catch (IOException e) { - System.out.println("Failed to start ChaosRunner: " + e.getMessage()); - System.exit(-1); - } - System.out.println(ChaosUtils.toString(INSTANCE, System.lineSeparator(), "[" + System.currentTimeMillis() + "] ", " ", "")); + if (INSTANCE != null) { + if (INSTANCE_ARGUMENTS.equals(a)) { + // same arguments, just return the instance + return INSTANCE; + } + + throw new Exception("Instance already started with different arguments."); + } + + INSTANCE = new ChaosRunner(a, finalPrinter); - Runtime.getRuntime().addShutdownHook( - new Thread("app-shutdown-hook") { + APP_SHUTDOWN_HOOK_THREAD = new Thread("app-shutdown-hook") { @Override public void run() { - INSTANCE.shutdown(); - report("EXIT Chaos Runner"); + shutdownServers(); + shutdownExecutor(); + finalPrinter.out(CR_LABEL, "EXIT"); } - }); + }; + + Runtime.getRuntime().addShutdownHook(APP_SHUTDOWN_HOOK_THREAD); + + } + catch (IOException e) { + finalPrinter.err(CR_LABEL, "Failed to start ChaosRunner", e); + throw e; + } + finally { + INSTANCE_LOCK.unlock(); + } return INSTANCE; } + + public static boolean isRunning() { + INSTANCE_LOCK.lock(); + try { + return INSTANCE != null; + } + finally { + INSTANCE_LOCK.unlock(); + } + } + + public static void shutdown() { + INSTANCE_LOCK.lock(); + try { + removeShutdownHook(); + shutdownExecutor(); + shutdownServers(); + } + finally { + INSTANCE_LOCK.unlock(); + } + } + + public static void shutdownExecutor() { + INSTANCE_LOCK.lock(); + try { + INSTANCE.executor.shutdown(); + } + finally { + INSTANCE_LOCK.unlock(); + } + } + + private static void removeShutdownHook() { + INSTANCE_LOCK.lock(); + try { + if (APP_SHUTDOWN_HOOK_THREAD != null) { + Runtime.getRuntime().removeShutdownHook(APP_SHUTDOWN_HOOK_THREAD); + APP_SHUTDOWN_HOOK_THREAD = null; + } + } + finally { + INSTANCE_LOCK.unlock(); + } + } + + private static void shutdownServers() { + INSTANCE_LOCK.lock(); + try { + if (INSTANCE != null) { + for (NatsServerRunner runner : INSTANCE.natsServerRunners) { + try { runner.close(); } catch (Exception ignore) {} + } + INSTANCE = null; + } + } + finally { + INSTANCE_LOCK.unlock(); + } + } } diff --git a/chaos-runner/src/main/java/io/synadia/chaos/ChaosUtils.java b/chaos-runner/src/main/java/io/synadia/chaos/ChaosUtils.java index 9f3649c..88ccb36 100644 --- a/chaos-runner/src/main/java/io/synadia/chaos/ChaosUtils.java +++ b/chaos-runner/src/main/java/io/synadia/chaos/ChaosUtils.java @@ -3,6 +3,9 @@ package io.synadia.chaos; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; + public class ChaosUtils { public static String toString(ChaosRunner r) { @@ -12,28 +15,83 @@ public static String toString(ChaosRunner r) { public static String toString(ChaosRunner r, String sep, String prefix, String indent, String outdent) { String spi = sep + prefix + indent; StringBuilder sb = new StringBuilder(prefix).append("Chaos Runner:"); - sb.append(spi).append("servers=").append(r.servers).append(outdent) - .append(spi).append("js=").append(r.js).append(outdent); - if (r.servers > 1) { - sb.append(spi).append("clusterName=").append(r.clusterName).append(outdent) - .append(spi).append("serverNamePrefix=").append(r.serverNamePrefix).append(outdent); + sb.append(spi).append("servers=").append(r.servers).append(outdent); + if (r.servers == 1) { + sb.append(spi).append("serverName=").append(r.serverNamePrefix).append(outdent); + sb.append(spi).append("port=").append(r.port).append(outdent); + sb.append(spi).append("listen=").append(r.listen).append(outdent); + sb.append(spi).append("monitor=").append(r.monitor).append(outdent); + sb.append(spi).append("url=").append(r.getConnectionUrls()[0]).append(outdent); + } + else { + sb.append(spi).append("clusterName=").append(r.clusterName).append(outdent); + sb.append(spi).append("serverNamePrefix=").append(r.serverNamePrefix).append(outdent); + sb.append(spi).append("ports=").append(stringify(r.getConnectionPorts())).append(outdent); + sb.append(spi).append("listen=").append(stringify(r.getListenPorts())).append(outdent); + sb.append(spi).append("monitor=").append(stringify(r.getMonitorPorts())).append(outdent); } - sb.append(spi).append("jsStoreDirBase=").append(r.jsStoreDirBase).append(outdent) - .append(spi).append("initialDelay=").append(r.initialDelay).append(outdent) - .append(spi).append("delay=").append(r.delay).append(outdent) - .append(spi).append("downTime=").append(r.downTime).append(outdent) - .append(spi).append("random=").append(r.random); + sb.append(spi).append("js=").append(r.js).append(outdent); + if (r.js) { + sb.append(spi).append("jsStoreDirBase=").append(r.jsStoreDirBase).append(outdent); + } + sb.append(spi).append("initialDelay=").append(r.initialDelay).append(outdent); + sb.append(spi).append("delay=").append(r.delay).append(outdent); + sb.append(spi).append("downTime=").append(r.downTime).append(outdent); + sb.append(spi).append("random=").append(r.random); return sb.toString(); } - public static void report(String label, Object... parts) { - String prefix = "[" + System.currentTimeMillis() + "] " + label; - StringBuilder sb = new StringBuilder(prefix); - for (Object part : parts) { - sb.append(" | "); - sb.append(part); + private static StringBuilder stringify(int[] ints) { + StringBuilder sb = new StringBuilder(); + for (int j = 0, intsLength = ints.length; j < intsLength; j++) { + if (j > 0) { + sb.append(','); + } + sb.append(ints[j]); } - System.out.println(sb); + return sb; + } + + static ChaosPrinter PRINTER; + public static ChaosPrinter getDefaultPrinter() { + if (PRINTER == null) { + PRINTER = new ChaosPrinter() { + @Override + public void out(Object... objects) { + if (objects != null && objects.length > 0) { + System.out.println(join(objects)); + } + } + + @Override + public void err(Object... objects) { + if (objects != null && objects.length > 0) { + System.err.println(join(objects)); + } + } + + private String join(Object[] objects) { + StringBuilder sb = new StringBuilder(TIME_FORMATTER.format(ZonedDateTime.now())); + for (Object object : objects) { + sb.append(" | "); + sb.append(object); + } + return sb.toString(); + } + }; + } + return PRINTER; + } + + public static final DateTimeFormatter TIME_FORMATTER + = DateTimeFormatter.ofPattern("HH:mm:ss.SSS"); + + public static void out(Object... objects) { + getDefaultPrinter().out(objects); + } + + public static void err(Object... objects) { + getDefaultPrinter().err(objects); } } diff --git a/counters/.gitignore b/counters/.gitignore new file mode 100644 index 0000000..b3e2ca5 --- /dev/null +++ b/counters/.gitignore @@ -0,0 +1,77 @@ + +# NATS stuff # +############## +gnatsd.log +*.csv + +# Compiled source # +################### +*.com +*.class +*.dll +*.exe +*.o +*.so +/bin + +# Packages # +############ +*.7z +*.dmg +*.gz +*.iso +*.rar +*.tar +*.zip + +# Logs and databases # +###################### +*.log + +# OS generated files # +###################### +.DS_Store* +ehthumbs.db +Icon? +Thumbs.db + +# Editor Files # +################ +*~ +*.swp +.sts4-cache/* + +# Gradle Files # +################ +.gradle +.m2 + +# Build output directies +/target +*/target +/build +*/build + +# IntelliJ specific files/directories +out +.idea +*.ipr +*.iws +*.iml +atlassian-ide-plugin.xml + +# Eclipse specific files/directories +.classpath +.project +.settings +.metadata + +# NetBeans specific files/directories +.nbattrs + +# VSCode +.vscode/ + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +/target/ diff --git a/counters/LICENSE b/counters/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/counters/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed 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. diff --git a/counters/NOTICE b/counters/NOTICE new file mode 100644 index 0000000..ff3c8b4 --- /dev/null +++ b/counters/NOTICE @@ -0,0 +1,5 @@ +Orbit Java +Copyright (c) 2024-2025 Synadia Communications Inc. All Rights Reserved. + +This product includes software developed at +Synadia Communications Inc. \ No newline at end of file diff --git a/counters/README.md b/counters/README.md new file mode 100644 index 0000000..a292e3d --- /dev/null +++ b/counters/README.md @@ -0,0 +1,90 @@ +Orbit + +# Distributed Counters + +Utility to take advantage of the distributed counter functionality. + +https://github.com/nats-io/nats-architecture-and-design/blob/main/adr/ADR-49.md + +![Artifact](https://img.shields.io/badge/Artifact-io.synadia:counters-197556?labelColor=grey&style=flat) +![0.2.2](https://img.shields.io/badge/Current_Release-0.2.2-27AAE0) +![0.2.3](https://img.shields.io/badge/Current_Snapshot-0.2.3--SNAPSHOT-27AAE0) +[![Dependencies Help](https://img.shields.io/badge/Dependencies%20Help-27AAE0)](https://github.com/synadia-io/orbit.java?tab=readme-ov-file#dependencies) +[![javadoc](https://javadoc.io/badge2/io.synadia/counters/javadoc.svg)](https://javadoc.io/doc/io.synadia/counters) +[![Maven Central](https://img.shields.io/maven-central/v/io.synadia/counters)](https://img.shields.io/maven-central/v/io.synadia/counters) + +## Basic Usage + +```java +Options options = ... +try (Connection nc = Nats.connect(options)) { + JetStreamManagement jsm = nc.jetStreamManagement(); + + // setup the coutner stream + Counters counters = createCountersStream(nc, + StreamConfiguration.builder() + .name("counters-stream") + .subjects("cs.*") + .storageType(StorageType.Memory) + .build()); + + // add + BigInteger bi = counters.add("cs.A", 1); + bi = counters.add("cs.A", 2); + + bi = counters.add("cs.B", 10); + bi = counters.add("cs.B", 20); + + // get + bi = counters.get("cs.A"); + bi = counters.get("cs.B"); +``` + + +## API + +JetStreamOptions are necessary for stream creation and instance construction if your stream needs a prefix or domain. + +### Create Counter Stream +```java +public static Counters createCountersStream(Connection conn, StreamConfiguration userConfig) throws JetStreamApiException, IOException +public static Counters createCountersStream(Connection conn, JetStreamOptions jso, StreamConfiguration userConfig) throws JetStreamApiException, IOException +``` + +### Create Counters Instance +You get a counters instance on construction of the stream as above or by constructing an instance directly. + +```java +public Counters(String streamName, Connection conn) throws IOException, JetStreamApiException +public Counters(String streamName, Connection conn, JetStreamOptions jso) throws IOException, JetStreamApiException +``` + +### Counters instance API +```java +public BigInteger add(String subject, int value) throws JetStreamApiException, IOException +public BigInteger add(String subject, long value) throws JetStreamApiException, IOException +public BigInteger add(String subject, BigInteger value) throws JetStreamApiException, IOException +public BigInteger increment(String subject) throws JetStreamApiException, IOException +public BigInteger decrement(String subject) throws JetStreamApiException, IOException +public BigInteger setViaAdd(String subject, int value) throws JetStreamApiException, IOException +public BigInteger setViaAdd(String subject, long value) throws JetStreamApiException, IOException +public BigInteger setViaAdd(String subject, BigInteger value) throws JetStreamApiException, IOException +public BigInteger get(String subject) throws JetStreamApiException, IOException +public BigInteger getOrElse(String subject, int dflt) throws IOException +public BigInteger getOrElse(String subject, long dflt) throws IOException +public BigInteger getOrElse(String subject, BigInteger dflt) throws IOException +public CounterEntry getEntry(String subject) throws JetStreamApiException, IOException +public LinkedBlockingQueue getEntries(String... subjects) +public LinkedBlockingQueue getEntries(List subjects) +public CounterIterator iterateEntries(String... subjects) +public CounterIterator iterateEntries(List subjects) +public CounterIterator iterateEntries(List subjects, Duration timeoutFirst, Duration timeoutSubsequent) +``` +![Artifact](https://img.shields.io/badge/Artifact-io.synadia:counters-197556?labelColor=grey&style=flat) +[![License Apache 2](https://img.shields.io/badge/License-Apache2-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0) +[![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.synadia/counters/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.synadia/counters) +[![javadoc](https://javadoc.io/badge2/io.synadia/counters/javadoc.svg)](https://javadoc.io/doc/io.synadia/counters) + +--- +Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +See [LICENSE](LICENSE) and [NOTICE](NOTICE) file for details. diff --git a/counters/build.gradle b/counters/build.gradle new file mode 100644 index 0000000..cb00731 --- /dev/null +++ b/counters/build.gradle @@ -0,0 +1,197 @@ +import aQute.bnd.gradle.Bundle + +plugins { + id("java") + id("java-library") + id("maven-publish") + id("jacoco") + id("biz.aQute.bnd.builder") version "7.1.0" + id("org.gradle.test-retry") version "1.6.4" + id("io.github.gradle-nexus.publish-plugin") version "2.0.0" + id("signing") +} + +def jarVersion = "0.2.3" +group = 'io.synadia' + +def isRelease = System.getenv("BUILD_EVENT") == "release" + +def tc = System.getenv("TARGET_COMPATIBILITY"); +def targetCompat = tc == "21" ? JavaVersion.VERSION_21 : (tc == "17" ? JavaVersion.VERSION_17 : JavaVersion.VERSION_1_8) +def jarEnd = tc == "21" ? "-jdk21" : (tc == "17" ? "-jdk17" : "") +def jarAndArtifactName = "counters" + jarEnd + +version = isRelease ? jarVersion : jarVersion + "-SNAPSHOT" // version is the variable the build actually uses. + +System.out.println("Java: " + System.getProperty("java.version")) +System.out.println("Target Compatibility: " + targetCompat) +System.out.println(group + ":" + jarAndArtifactName + ":" + version) + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = targetCompat +} + +repositories { + mavenCentral() + maven { url="https://repo1.maven.org/maven2/" } + maven { url="https://central.sonatype.com/repository/maven-snapshots/" } +} + +dependencies { + implementation 'io.nats:jnats:2.25.1' + implementation 'io.synadia:direct-batch:0.1.4' + implementation 'org.jspecify:jspecify:1.0.0' + + testImplementation 'io.nats:jnats-server-runner:1.2.8' + testImplementation 'commons-codec:commons-codec:1.20.0' + testImplementation 'com.github.stefanbirkner:system-lambda:1.2.1' + testImplementation 'nl.jqno.equalsverifier:equalsverifier:4.2.3' + + testImplementation 'org.junit.jupiter:junit-jupiter:5.14.1' + testImplementation 'org.junit.platform:junit-platform-launcher:1.14.3' +} + +sourceSets { + main { + java { + srcDirs = ['src/main/java','src/examples/java'] + } + resources { + srcDirs = ['src/examples/resources'] + } + } + test { + java { + srcDirs = ['src/test/java'] + } + } +} + +tasks.register('bundle', Bundle) { + from sourceSets.main.output + exclude("io/synadia/examples/**") +} + +jar { + bundle { + bnd("Bundle-Name": "io.synadia.counters", + "Bundle-Vendor": "synadia.io", + "Bundle-Description": "JetStream Distributed Counters", + "Bundle-DocURL": "https://github.com/synadia-io/orbit.java/tree/main/counters", + "Target-Compatibility": "Java " + targetCompat + ) + } +} + +test { + useJUnitPlatform() +} + +javadoc { + options.overview = 'src/main/javadoc/overview.html' // relative to source root + source = sourceSets.main.allJava + title = "Synadia Communications Inc. JetStream Distributed Counters" + classpath = sourceSets.main.runtimeClasspath +} + +tasks.register('examplesJar', Jar) { + archiveClassifier.set('examples') + manifest { + attributes('Implementation-Title': 'JetStream Distributed Counters', + 'Implementation-Version': jarVersion, + 'Implementation-Vendor': 'synadia.io') + } + from(sourceSets.main.output) { + include "io/synadia/examples/**" + } +} + +tasks.register('javadocJar', Jar) { + archiveClassifier.set('javadoc') + from javadoc +} + +tasks.register('sourcesJar', Jar) { + archiveClassifier.set('sources') + from sourceSets.main.allSource +} + +artifacts { + archives javadocJar, sourcesJar, examplesJar +} + +jacoco { + toolVersion = "0.8.12" +} + +jacocoTestReport { + reports { + xml.required = true // coveralls plugin depends on xml format report + html.required = true + } + afterEvaluate { // only report on main library not examples + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, + exclude: ['**/examples**']) + })) + } +} + +nexusPublishing { + repositories { + sonatype { + nexusUrl.set(uri("https://ossrh-staging-api.central.sonatype.com/service/local/")) + snapshotRepositoryUrl.set(uri("https://central.sonatype.com/repository/maven-snapshots/")) + username = System.getenv('OSSRH_USERNAME') + password = System.getenv('OSSRH_PASSWORD') + } + } +} + +publishing { + publications { + mavenJava(MavenPublication) { + from components.java + artifact sourcesJar + artifact examplesJar + artifact javadocJar + pom { + name = jarAndArtifactName + packaging = 'jar' + groupId = group + artifactId = jarAndArtifactName + description = 'Synadia Communications Inc. JetStream Distributed Counters' + url = 'https://github.com/synadia-io/orbit.java' + licenses { + license { + name = 'The Apache License, Version 2.0' + url = 'http://www.apache.org/licenses/LICENSE-2.0.txt' + } + } + developers { + developer { + id = "synadia" + name = "Synadia" + email = "info@synadia.com" + url = "https://synadia.io" + } + } + scm { + url = 'https://github.com/synadia-io/orbit.java' + } + } + } + } +} + +if (isRelease) { + signing { + def signingKeyId = System.getenv('SIGNING_KEY_ID') + def signingKey = System.getenv('SIGNING_KEY') + def signingPassword = System.getenv('SIGNING_PASSWORD') + useInMemoryPgpKeys(signingKeyId, signingKey, signingPassword) + sign configurations.archives + sign publishing.publications.mavenJava + } +} diff --git a/counters/gradle/libs.versions.toml b/counters/gradle/libs.versions.toml new file mode 100644 index 0000000..2cfe86a --- /dev/null +++ b/counters/gradle/libs.versions.toml @@ -0,0 +1,12 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format + +[versions] +commons-math3 = "3.6.1" +guava = "33.4.5-jre" +junit-jupiter = "5.12.1" + +[libraries] +commons-math3 = { module = "org.apache.commons:commons-math3", version.ref = "commons-math3" } +guava = { module = "com.google.guava:guava", version.ref = "guava" } +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" } diff --git a/counters/gradle/wrapper/gradle-wrapper.jar b/counters/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/counters/gradle/wrapper/gradle-wrapper.jar differ diff --git a/counters/gradle/wrapper/gradle-wrapper.properties b/counters/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ca025c8 --- /dev/null +++ b/counters/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/counters/gradlew b/counters/gradlew new file mode 100644 index 0000000..23d15a9 --- /dev/null +++ b/counters/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed 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 +# +# https://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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/counters/gradlew.bat b/counters/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/counters/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/counters/settings.gradle b/counters/settings.gradle new file mode 100644 index 0000000..78be690 --- /dev/null +++ b/counters/settings.gradle @@ -0,0 +1,13 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + maven { url="https://repo1.maven.org/maven2/" } + maven { url="https://central.sonatype.com/repository/maven-snapshots/" } + maven { url="https://plugins.gradle.org/m2/" } + } + plugins { + id("biz.aQute.bnd.builder") version "7.1.0" + } +} +rootProject.name = 'counters' diff --git a/counters/src/examples/java/io/synadia/examples/CounterExample.java b/counters/src/examples/java/io/synadia/examples/CounterExample.java new file mode 100644 index 0000000..43c6679 --- /dev/null +++ b/counters/src/examples/java/io/synadia/examples/CounterExample.java @@ -0,0 +1,194 @@ +// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.examples; + +import io.nats.client.Connection; +import io.nats.client.JetStreamApiException; +import io.nats.client.JetStreamManagement; +import io.nats.client.Nats; +import io.nats.client.api.StorageType; +import io.nats.client.api.StreamConfiguration; +import io.synadia.counters.CounterEntryResponse; +import io.synadia.counters.CounterIterator; +import io.synadia.counters.Counters; + +import java.math.BigInteger; +import java.time.Duration; +import java.util.Collections; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import static io.synadia.counters.Counters.createCountersStream; + +public class CounterExample { + static final String NATS_URL = "nats://localhost:4222"; + + public static void main(String[] args) throws Exception { + try (Connection nc = Nats.connect(NATS_URL)) { + JetStreamManagement jsm = nc.jetStreamManagement(); + + // Set up a fresh counters stream + try { jsm.deleteStream("counters-stream"); } catch (JetStreamApiException ignore) {} + Counters counters = createCountersStream(nc, + StreamConfiguration.builder() + .name("counters-stream") + .subjects("cs.*") + .storageType(StorageType.Memory) + .build()); + + // ---------------------------------------------------------------------------------------------------- + System.out.println("1.1: Add to a subject..."); + System.out.println(" add(\"cs.A\", 1) -> " + counters.add("cs.A", 1)); + System.out.println(" add(\"cs.A\", 2) -> " + counters.add("cs.A", 2)); + System.out.println(" add(\"cs.A\", 3) -> " + counters.add("cs.A", 3)); + System.out.println(" add(\"cs.A\", -1) -> " + counters.add("cs.A", -1)); + + System.out.println(" add(\"cs.B\", 10) -> " + counters.add("cs.B", 10)); + System.out.println(" add(\"cs.B\", 20) -> " + counters.add("cs.B", 20)); + System.out.println(" add(\"cs.B\", 30) -> " + counters.add("cs.B", 30)); + System.out.println(" add(\"cs.B\", -10) -> " + counters.add("cs.B", -10)); + + System.out.println(" add(\"cs.C\", 100) -> " + counters.add("cs.C", 100)); + System.out.println(" add(\"cs.C\", 200) -> " + counters.add("cs.C", 200)); + System.out.println(" add(\"cs.C\", 300) -> " + counters.add("cs.C", 300)); + System.out.println(" add(\"cs.C\", -100) -> " + counters.add("cs.C", -100)); + + // ---------------------------------------------------------------------------------------------------- + System.out.println("\n2.1: get() for existing subjects"); + System.out.println(" get(\"cs.A\") -> " + counters.get("cs.A")); + System.out.println(" get(\"cs.B\") -> " + counters.get("cs.B")); + System.out.println(" get(\"cs.C\") -> " + counters.get("cs.C")); + + System.out.println("\n2.2: get() when the subject is not found"); + try { + counters.get("cs.not-found"); + } + catch (JetStreamApiException e) { + System.out.println(" get(\"cs.not-found\") -> " + e); + } + + System.out.println("\n2.3: get() for a single subject does not allow wildcards"); + try { + counters.get("cs.*"); + } + catch (IllegalArgumentException e) { + System.out.println(" get(\"cs.*\") -> " + e); + } + + System.out.println("\n2.4: getOrElse() with a default when the subject is found"); + System.out.println(" getOrElse(\"cs.C\", BigInteger.ZERO\") -> " + counters.getOrElse("cs.C", BigInteger.ZERO)); + + System.out.println("\n2.5: getOrElse() with a default when the subject is not found"); + try { + counters.get("cs.not-found"); + } + catch (JetStreamApiException e) { + System.out.println(" get(\"cs.not-found\") -> " + e); + } + System.out.println(" getOrElse(\"cs.not-found\", 77777) -> " + counters.getOrElse("cs.not-found", 77777)); + + // ---------------------------------------------------------------------------------------------------- + System.out.println("\n3.1: getEntry() - The full CounterEntry for a subject, notice the last increment..."); + System.out.println(" getEntry(\"cs.A\") -> " + counters.getEntry("cs.A")); + System.out.println(" getEntry(\"cs.B\") -> " + counters.getEntry("cs.B")); + System.out.println(" getEntry(\"cs.C\") -> " + counters.getEntry("cs.C")); + + System.out.println("\n3.2: getEntry() does not allow wildcards"); + try { + counters.getEntry("cs.>"); + } + catch (IllegalArgumentException e) { + System.out.println(" getEntry(\"cs.>\") -> " + e); + } + + // ---------------------------------------------------------------------------------------------------- + System.out.println("\n4.1: getEntries(\"cs.A\", \"cs.B\", \"cs.C\") - Get the CounterEntryResponse objects for multiple subjects."); + LinkedBlockingQueue eResponses = counters.getEntries("cs.A", "cs.B", "cs.C"); + BigInteger total = BigInteger.ZERO; + CounterEntryResponse er = eResponses.poll(1, TimeUnit.SECONDS); + while (er != null && er.isEntry()) { + System.out.println(" " + er); + // the entry response has a method to simplify getting the value + total = total.add(er.getValue()); + er = eResponses.poll(10, TimeUnit.MILLISECONDS); + } + System.out.println(" " + er + " -> No more entries."); + System.out.println(" Values totaled: " + total); + + System.out.println("\n4.2: getEntries(\"cs.*\") - Get CounterEntryResponse objects for wildcard subject(s)."); + eResponses = counters.getEntries("cs.*"); + er = eResponses.poll(1, TimeUnit.SECONDS); + while (er != null && er.isEntry()) { + System.out.println(" " + er); + er = eResponses.poll(10, TimeUnit.MILLISECONDS); + } + System.out.println(" " + er + " -> No more entries."); + + // ---------------------------------------------------------------------------------------------------- + System.out.println("\n5.1: setViaAdd() - Sets the value for a subject by" + + "\n 1) calling getOrElse(subject, BigInteger.ZERO)" + + "\n 2) then calling add with the set value minus the current value."); + System.out.println(" setViaAdd(\"cs.A\", 9) -> " + counters.setViaAdd("cs.A", 9)); + System.out.println(" setViaAdd(\"cs.B\", 99) -> " + counters.setViaAdd("cs.B", 99)); + System.out.println(" setViaAdd(\"cs.C\", 999) -> " + counters.setViaAdd("cs.C", 999)); + + System.out.println("\n5.2: getEntry() - Get the full CounterEntry, notice the last increment after a setViaAdd" + + "\n represents the difference between the entry before the set and the set value."); + System.out.println(" getEntry(\"cs.A\") -> " + counters.getEntry("cs.A")); + System.out.println(" getEntry(\"cs.B\") -> " + counters.getEntry("cs.B")); + System.out.println(" getEntry(\"cs.C\") -> " + counters.getEntry("cs.C")); + + System.out.println("\n5.3: It's safe to call setViaAdd() even if the subject did not exist because it uses getOrElse;"); + try { + counters.get("cs.did-not-exist"); + } + catch (JetStreamApiException e) { + System.out.println(" get(\"cs.did-not-exist\") -> " + e); + } + System.out.println(" setViaAdd(\"cs.did-not-exist\", 99999) -> " + counters.setViaAdd("cs.did-not-exist", 99999)); + System.out.println(" get(\"cs.did-not-exist\") -> " + counters.get("cs.did-not-exist")); + + // ---------------------------------------------------------------------------------------------------- + System.out.println("\n6.1: getEntries(\"cs.no-counters\", \"cs.also-counters\") - getEntries but no subjects have counters."); + eResponses = counters.getEntries("cs.no-counters", "cs.also-counters"); + er = eResponses.poll(1, TimeUnit.SECONDS); + while (er != null && er.isEntry()) { + System.out.println(" " + er); + er = eResponses.poll(10, TimeUnit.MILLISECONDS); + } + System.out.println(" " + er); + + // ---------------------------------------------------------------------------------------------------- + System.out.println("\n7.1: getEntries(\"no-counters\", \"cs.A\", \"cs.B\", \"cs.C\") - getEntries when some subjects have counters."); + eResponses = counters.getEntries("cs.no-counters", "cs.A", "cs.B", "cs.C"); + er = eResponses.poll(1, TimeUnit.SECONDS); + while (er != null && er.isEntry()) { + System.out.println(" " + er); + er = eResponses.poll(10, TimeUnit.MILLISECONDS); + } + System.out.println(" " + er + " -> No more entries."); + + // ---------------------------------------------------------------------------------------------------- + System.out.println("\n8.1: iterateEntries(\"cs.A\", \"cs.B\", \"cs.C\") - Get via CounterIterator for multiple subjects."); + CounterIterator iterator = counters.iterateEntries("cs.A", "cs.B", "cs.C"); + while (iterator.hasNext()) { + System.out.println(" " + iterator.next()); + } + + System.out.println("\n8.2: iterateEntries(\"cs.*\") - Get via CounterIterator for wildcard subject(s)."); + iterator = counters.iterateEntries("cs.*"); + while (iterator.hasNext()) { + System.out.println(" " + iterator.next()); + } + + System.out.println("\n8.3: iterateEntries(\"cs.*\", timeoutFirst, timeoutSubsequent) - Get via CounterIterator with custom timeouts."); + Duration timeoutFirst = Duration.ofMillis(1000); + Duration timeoutSubsequent = Duration.ofMillis(200); + iterator = counters.iterateEntries(Collections.singletonList("cs.*"), timeoutFirst, timeoutSubsequent); + while (iterator.hasNext()) { + System.out.println(" " + iterator.next()); + } + } + } +} diff --git a/counters/src/main/java/io/synadia/counters/CounterEntry.java b/counters/src/main/java/io/synadia/counters/CounterEntry.java new file mode 100644 index 0000000..5880a2d --- /dev/null +++ b/counters/src/main/java/io/synadia/counters/CounterEntry.java @@ -0,0 +1,81 @@ +// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.counters; + +import io.nats.client.api.MessageInfo; +import io.nats.client.impl.Headers; +import org.jspecify.annotations.NonNull; + +import java.math.BigInteger; +import java.util.Map; + +import static io.synadia.counters.CountersUtils.*; + +public class CounterEntry { + private final String subject; + private final BigInteger value; + private final BigInteger lastIncrement; + private final Map> sources; + + CounterEntry(MessageInfo mi) { + if (!mi.isMessage()) { + throw invalidCounterMessage(null); + } + + subject = mi.getSubject(); + try { + value = extractVal(mi.getData()); + } + catch (RuntimeException e) { + throw invalidCounterMessage(e); + } + + Headers h = mi.getHeaders(); + if (h == null) { + throw invalidCounterMessage(null); + } + + String temp = h.getFirst(CountersUtils.INCREMENT_HEADER); + if (temp == null) { + throw invalidCounterMessage(null); + } + lastIncrement = extractLastIncrement(temp); + + sources = extractSources(h.getFirst(CountersUtils.SOURCES_HEADER)); + } + + private static RuntimeException invalidCounterMessage(Exception e) { + return new RuntimeException("Message is not a counter message", e); + } + + @NonNull + public String getSubject() { + return subject; + } + + @NonNull + public BigInteger getValue() { + return value; + } + + @NonNull + public BigInteger getLastIncrement() { + return lastIncrement; + } + + @NonNull + public Map> getSources() { + return sources; + } + + @Override + public String toString() { + return "CounterEntry{" + + "subject=\"" + subject + '\"' + + ", value=" + value + + ", lastIncrement=" + lastIncrement + + ", sources=" + sources + + '}'; + } +} diff --git a/counters/src/main/java/io/synadia/counters/CounterEntryResponse.java b/counters/src/main/java/io/synadia/counters/CounterEntryResponse.java new file mode 100644 index 0000000..238d557 --- /dev/null +++ b/counters/src/main/java/io/synadia/counters/CounterEntryResponse.java @@ -0,0 +1,41 @@ +// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.counters; + +import io.nats.client.api.MessageInfo; +import org.jspecify.annotations.Nullable; + +import java.math.BigInteger; + +import static io.synadia.counters.CountersUtils.extractVal; + +public class CounterEntryResponse extends CounterResponse { + + CounterEntryResponse(MessageInfo mi) { + super(mi); + } + + /** + * Whether this CounterEntry is a regular entry as opposed to an error/status + * @return true if the CounterEntry is a regular entry + */ + public boolean isEntry() { + return mi.isMessage(); + } + + @Nullable + public BigInteger getValue() { + return mi.isMessage() ? extractVal(mi.getData()) : null; + } + + @Nullable + public CounterEntry getEntry() { + return mi.isMessage() ? new CounterEntry(mi) : null; + } + + @Override + public String toString() { + return "CounterEntryResponse{ " + (isEntry() ? getEntry() : getStatus() ) + " }"; + } +} diff --git a/counters/src/main/java/io/synadia/counters/CounterIterator.java b/counters/src/main/java/io/synadia/counters/CounterIterator.java new file mode 100644 index 0000000..cc4c522 --- /dev/null +++ b/counters/src/main/java/io/synadia/counters/CounterIterator.java @@ -0,0 +1,59 @@ +// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.counters; + +import java.time.Duration; +import java.util.Iterator; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +public class CounterIterator implements Iterator { + private final LinkedBlockingQueue queue; + private final Duration timeoutFirst; + private final Duration timeoutSubsequent; + private CounterEntryResponse nextElement; + private boolean first; + private boolean terminated; + + public CounterIterator(LinkedBlockingQueue queue, Duration timeout) { + this(queue, timeout, timeout); + } + + public CounterIterator(LinkedBlockingQueue queue, Duration timeoutFirst, Duration timeoutSubsequent) { + this.queue = queue; + this.timeoutFirst = timeoutFirst; + this.timeoutSubsequent = timeoutSubsequent; + first = true; + terminated = false; + } + + @Override + public boolean hasNext() { + try { + if (terminated) { + return false; + } + if (nextElement == null) { + nextElement = queue.poll(first ? timeoutFirst.toNanos() : timeoutSubsequent.toNanos(), TimeUnit.NANOSECONDS); + first = false; + if (nextElement != null && !nextElement.isEntry()) { + nextElement = null; + terminated = true; + } + } + return nextElement != null; + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + + @Override + public CounterEntryResponse next() { + CounterEntryResponse current = nextElement; + nextElement = null; + return current; + } +} diff --git a/counters/src/main/java/io/synadia/counters/CounterResponse.java b/counters/src/main/java/io/synadia/counters/CounterResponse.java new file mode 100644 index 0000000..5811fce --- /dev/null +++ b/counters/src/main/java/io/synadia/counters/CounterResponse.java @@ -0,0 +1,45 @@ +// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.counters; + +import io.nats.client.api.MessageInfo; +import io.nats.client.support.Status; +import org.jspecify.annotations.Nullable; + +abstract class CounterResponse { + protected final MessageInfo mi; + + CounterResponse(MessageInfo mi) { + this.mi = mi; + } + + @Nullable + public Status getStatus() { + return mi.getStatus(); + } + + /** + * Whether this response is a status message + * @return true if this CounterEntry is a status message + */ + public boolean isStatus() { + return mi.isStatus(); + } + + /** + * Whether this response is a status message and is a direct EOB status + * @return true if this CounterEntry is a status message and is a direct EOB status + */ + public boolean isEobStatus() { + return mi.isEobStatus(); + } + + /** + * Whether this response is a status message and is an error status + * @return true if this CounterEntry is a status message and is an error status + */ + public boolean isErrorStatus() { + return mi.isErrorStatus(); + } +} diff --git a/counters/src/main/java/io/synadia/counters/Counters.java b/counters/src/main/java/io/synadia/counters/Counters.java new file mode 100644 index 0000000..ef450c6 --- /dev/null +++ b/counters/src/main/java/io/synadia/counters/Counters.java @@ -0,0 +1,210 @@ +// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.counters; + +import io.nats.client.*; +import io.nats.client.api.*; +import io.nats.client.impl.Headers; +import io.synadia.direct.DirectBatchContext; +import io.synadia.direct.MessageBatchGetRequest; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.math.BigInteger; +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.LinkedBlockingQueue; + +import static io.nats.client.support.Validator.required; +import static io.synadia.counters.CountersUtils.INCREMENT_HEADER; +import static io.synadia.counters.CountersUtils.extractVal; + +public class Counters { + + public static Counters createCountersStream(Connection conn, StreamConfiguration userConfig) throws JetStreamApiException, IOException { + return createCountersStream(conn, null, userConfig); + } + + public static Counters createCountersStream(Connection conn, JetStreamOptions jso, StreamConfiguration userConfig) throws JetStreamApiException, IOException { + if (userConfig.getRetentionPolicy() != RetentionPolicy.Limits) { + throw new IllegalArgumentException("Retention Policy - Limits is the only allowed limit for counter streams."); + } + if (userConfig.getDiscardPolicy() == DiscardPolicy.New) { + throw new IllegalArgumentException("Discard Policy - New is not allowed for counter streams."); + } + StreamConfiguration config = StreamConfiguration.builder(userConfig) + .allowDirect(true) + .allowMessageCounter(true) + .build(); + + JetStreamManagement jsm = conn.jetStreamManagement(jso); + StreamInfo si = jsm.addStream(config); + + return new Counters(config.getName(), conn, jso, jsm, si); + } + + private final String streamName; + private final Connection conn; + private final Duration timeout; + private final JetStreamManagement jsm; + private final JetStream js; + private final DirectBatchContext dbCtx; + + public Counters(String streamName, Connection conn) throws IOException, JetStreamApiException { + this(streamName, conn, null, null, null); + } + + public Counters(String streamName, Connection conn, JetStreamOptions jso) throws IOException, JetStreamApiException { + this(streamName, conn, jso, null, null); + } + + private Counters(@NonNull String streamName, + @NonNull Connection conn, + @Nullable JetStreamOptions jso, + @Nullable JetStreamManagement jsm, + @Nullable StreamInfo si + ) throws IOException, JetStreamApiException + { + this.conn = conn; + + Duration tempTimeout = null; + if (jso != null) { + tempTimeout = jso.getRequestTimeout(); + } + if (tempTimeout == null) { + tempTimeout = conn.getOptions().getConnectionTimeout(); + } + timeout = tempTimeout; + + this.jsm = jsm == null ? conn.jetStreamManagement(jso) : jsm; + js = this.jsm.jetStream(); + + if (si == null) { + this.streamName = required(streamName, "Stream name required,"); + si = this.jsm.getStreamInfo(streamName); + } + else { + this.streamName = si.getConfiguration().getName(); + } + + if (!si.getConfiguration().getAllowDirect()) { + throw new IllegalArgumentException("Stream must have allow direct set."); + } + + if (!si.getConfiguration().getAllowMessageCounter()) { + throw new IllegalArgumentException("Stream must have allow message counter set."); + } + + dbCtx = new DirectBatchContext(conn, jso, streamName, si); + } + + private BigInteger _add(String subject, String sv) throws IOException, JetStreamApiException { + validateSingleSubject(subject); + Headers h = new Headers(); + h.put(INCREMENT_HEADER, sv); + PublishAck pa = js.publish(subject, h, null); + String val = pa.getVal(); + if (val == null) { + throw new IOException("Publish Failed"); + } + return new BigInteger(val); + } + + public BigInteger add(String subject, int value) throws JetStreamApiException, IOException { + return _add(subject, Integer.toString(value)); + } + + public BigInteger add(String subject, long value) throws JetStreamApiException, IOException { + return _add(subject, Long.toString(value)); + } + + public BigInteger add(String subject, BigInteger value) throws JetStreamApiException, IOException { + return _add(subject, value.toString()); + } + + public BigInteger increment(String subject) throws JetStreamApiException, IOException { + return _add(subject, "1"); + } + + public BigInteger decrement(String subject) throws JetStreamApiException, IOException { + return _add(subject, "-1"); + } + + public BigInteger setViaAdd(String subject, int value) throws JetStreamApiException, IOException { + return setViaAdd(subject, BigInteger.valueOf(value)); + } + + public BigInteger setViaAdd(String subject, long value) throws JetStreamApiException, IOException { + return setViaAdd(subject, BigInteger.valueOf(value)); + } + + public BigInteger setViaAdd(String subject, BigInteger value) throws JetStreamApiException, IOException { + BigInteger bi = getOrElse(subject, BigInteger.ZERO); + return _add(subject, value.subtract(bi).toString()); + } + + public BigInteger get(String subject) throws JetStreamApiException, IOException { + validateSingleSubject(subject); + MessageInfo mi = jsm.getMessage(streamName, MessageGetRequest.lastForSubject(subject)); + return extractVal(mi.getData()); + } + + public BigInteger getOrElse(String subject, int dflt) throws IOException { + return getOrElse(subject, BigInteger.valueOf(dflt)); + } + + public BigInteger getOrElse(String subject, long dflt) throws IOException { + return getOrElse(subject, BigInteger.valueOf(dflt)); + } + + public BigInteger getOrElse(String subject, BigInteger dflt) throws IOException { + try { + return get(subject); + } + catch (JetStreamApiException e) { + return dflt; + } + } + + public CounterEntry getEntry(String subject) throws JetStreamApiException, IOException { + validateSingleSubject(subject); + MessageInfo mi = jsm.getLastMessage(streamName, subject); + return new CounterEntry(mi); + } + + public LinkedBlockingQueue getEntries(String... subjects) { + return getEntries(Arrays.asList(subjects)); + } + + public LinkedBlockingQueue getEntries(List subjects) { + LinkedBlockingQueue queue = new LinkedBlockingQueue<>(); + MessageBatchGetRequest mbgr = MessageBatchGetRequest.multiLastForSubjects(subjects); + conn.getOptions().getExecutor().submit( + () -> dbCtx.requestMessageBatch(mbgr, mi -> queue.add(new CounterEntryResponse(mi)))); + return queue; + } + + public CounterIterator iterateEntries(String... subjects) { + return new CounterIterator(getEntries(Arrays.asList(subjects)), timeout); + } + + public CounterIterator iterateEntries(List subjects) { + return new CounterIterator(getEntries(subjects), timeout); + } + + public CounterIterator iterateEntries(List subjects, Duration timeoutFirst, Duration timeoutSubsequent) { + return new CounterIterator(getEntries(subjects), timeoutFirst, timeoutSubsequent); + } + + private static void validateSingleSubject(String subject) { + if (subject == null || subject.isEmpty()) { + throw new IllegalArgumentException("Subject required."); + } + if (subject.contains("*") || subject.contains(">")) { + throw new IllegalArgumentException("Subject must not contain wildcards '*' or '>'."); + } + } +} diff --git a/counters/src/main/java/io/synadia/counters/CountersUtils.java b/counters/src/main/java/io/synadia/counters/CountersUtils.java new file mode 100644 index 0000000..3ca0514 --- /dev/null +++ b/counters/src/main/java/io/synadia/counters/CountersUtils.java @@ -0,0 +1,63 @@ +// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.counters; + +import io.nats.client.support.JsonParseException; +import io.nats.client.support.JsonParser; +import io.nats.client.support.JsonValue; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.math.BigInteger; +import java.util.HashMap; +import java.util.Map; + +public final class CountersUtils { + + public static final String INCREMENT_HEADER = "Nats-Incr"; + public static final String SOURCES_HEADER = "Nats-Counters-Sources"; + + public static BigInteger extractVal(byte @NonNull [] valBytes) { + String s = new String(valBytes); + // it's just faster to parse this manually + // {"val":"-123"} + // don't want to assume anything about how the json is formatted/spaced + int colonAt = s.indexOf(':'); + int numberStart = s.indexOf('"', colonAt + 1) + 1; + int lastQuote = s.lastIndexOf('"'); + return new BigInteger(s.substring(numberStart, lastQuote).trim()); + } + + public static BigInteger extractLastIncrement(@NonNull String numberString) { + return new BigInteger(numberString); + } + + public static Map> extractSources(@Nullable String json) { + Map> sources = new HashMap<>(); + if (json != null) { + try { + JsonValue v = JsonParser.parse(json); + if (v.map != null) { + for (Map.Entry entry : v.map.entrySet()) { + String key = entry.getKey(); + Map value = entry.getValue().map; + if (value != null) { + Map sourceValue = new HashMap<>(); + sources.put(key, sourceValue); + for (Map.Entry entry2 : value.entrySet()) { + String key2 = entry2.getKey(); + JsonValue value2 = entry2.getValue(); + sourceValue.put(key2, new BigInteger(value2.string)); + } + } + } + } + } + catch (JsonParseException e) { + throw new RuntimeException(e); + } + } + return sources; + } +} diff --git a/counters/src/main/javadoc/images/favicon.ico b/counters/src/main/javadoc/images/favicon.ico new file mode 100644 index 0000000..9464855 Binary files /dev/null and b/counters/src/main/javadoc/images/favicon.ico differ diff --git a/counters/src/main/javadoc/images/large-logo.png b/counters/src/main/javadoc/images/large-logo.png new file mode 100644 index 0000000..33f9483 Binary files /dev/null and b/counters/src/main/javadoc/images/large-logo.png differ diff --git a/counters/src/main/javadoc/images/synadia-logo.png b/counters/src/main/javadoc/images/synadia-logo.png new file mode 100644 index 0000000..1f14bda Binary files /dev/null and b/counters/src/main/javadoc/images/synadia-logo.png differ diff --git a/counters/src/main/javadoc/overview.html b/counters/src/main/javadoc/overview.html new file mode 100644 index 0000000..659b20f --- /dev/null +++ b/counters/src/main/javadoc/overview.html @@ -0,0 +1,13 @@ + + + + + +JetStream Distributed Counter +

Synadia Logo

+ + + + diff --git a/counters/src/main/resources/placeholder.txt b/counters/src/main/resources/placeholder.txt new file mode 100644 index 0000000..ca5fd64 --- /dev/null +++ b/counters/src/main/resources/placeholder.txt @@ -0,0 +1 @@ +This is just a placeholder. \ No newline at end of file diff --git a/counters/src/test/java/io/synadia/counters/CountersTests.java b/counters/src/test/java/io/synadia/counters/CountersTests.java new file mode 100644 index 0000000..c916974 --- /dev/null +++ b/counters/src/test/java/io/synadia/counters/CountersTests.java @@ -0,0 +1,185 @@ +package io.synadia.counters; + +import io.nats.client.*; +import io.nats.client.api.StorageType; +import io.nats.client.api.StreamConfiguration; +import nats.io.NatsServerRunner; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.math.BigInteger; +import java.util.Map; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; + +import static io.synadia.counters.CountersUtils.extractSources; +import static io.synadia.counters.CountersUtils.extractVal; +import static org.junit.jupiter.api.Assertions.*; + +public class CountersTests { + static NatsServerRunner runner; + static Connection nc; + static JetStreamManagement jsm; + static JetStream js; + + @BeforeAll + public static void beforeAll() throws Exception { + NatsServerRunner.setDefaultOutputLevel(Level.WARNING); + runner = new NatsServerRunner(false, true); + Options options = Options.builder() + .server(runner.getURI()) + .errorListener(new ErrorListener() {}) + .build(); + nc = Nats.connect(options); + jsm = nc.jetStreamManagement(); + js = nc.jetStream(); + } + + @AfterAll + public static void afterAll() throws Exception { + runner.close(); + } + + private static Counters createCountersStream(String streamName, String... subjects) throws JetStreamApiException, IOException { + return Counters.createCountersStream(nc, + StreamConfiguration.builder() + .name(streamName) + .subjects(subjects) + .storageType(StorageType.Memory) + .build()); + } + + @Test + public void testCounterExceptions() { + String streamName = NUID.nextGlobalSequence(); + String subjectPrefix = NUID.nextGlobalSequence(); + String wild = subjectPrefix + ".*"; + try { + Counters counters = createCountersStream(streamName, wild); + assertThrows(IllegalArgumentException.class, () -> counters.add(wild, 1)); + assertThrows(IllegalArgumentException.class, () -> counters.get(wild)); + + String streamNameX = NUID.nextGlobalSequence(); + String subjectPrefixX = NUID.nextGlobalSequence(); + String wildX = subjectPrefixX + ".*"; + jsm.addStream(StreamConfiguration.builder() + .name(streamNameX) + .subjects(wildX) + .storageType(StorageType.Memory) + .build()); + assertThrows(IllegalArgumentException.class, () -> new Counters(streamNameX, nc)); + } + catch (JetStreamApiException | IOException e) { + fail(e.getMessage()); + } + } + + @Test + public void testCounterBasics() throws Exception { + String streamName = NUID.nextGlobalSequence(); + String subjectPrefix = NUID.nextGlobalSequence(); + Counters counters = createCountersStream(streamName, subjectPrefix + ".*"); + + String subject1 = subjectPrefix + "." + NUID.nextGlobalSequence(); + String subject2 = subjectPrefix + "." + NUID.nextGlobalSequence(); + String subject3 = subjectPrefix + "." + NUID.nextGlobalSequence(); + + assertEquals(1, counters.add(subject1, 1).intValue()); + assertEquals(1, counters.get(subject1).intValue()); + assertEquals(3, counters.add(subject1, 2).intValue()); + assertEquals(3, counters.get(subject1).intValue()); + assertEquals(6, counters.add(subject1, 3).intValue()); + assertEquals(6, counters.get(subject1).intValue()); + assertEquals(5, counters.add(subject1, -1).intValue()); + assertEquals(5, counters.get(subject1).intValue()); + assertEquals(6, counters.increment(subject1).intValue()); + assertEquals(5, counters.decrement(subject1).intValue()); + + assertEquals(5, counters.getOrElse(subject1, 99).intValue()); + assertEquals(Integer.MAX_VALUE, counters.getOrElse("not-exist", Integer.MAX_VALUE).intValue()); + assertEquals(Long.MAX_VALUE, counters.getOrElse("not-exist", Long.MAX_VALUE).longValue()); + + assertEquals(-1, counters.add(subject2, -1).intValue()); + assertEquals(-1, counters.get(subject2).intValue()); + assertEquals(-3, counters.add(subject2, -2).intValue()); + assertEquals(-3, counters.get(subject2).intValue()); + assertEquals(-6, counters.add(subject2, -3).intValue()); + assertEquals(-6, counters.get(subject2).intValue()); + assertEquals(-5, counters.add(subject2, 1).intValue()); + assertEquals(-5, counters.get(subject2).intValue()); + + assertEquals(Integer.MAX_VALUE, counters.setViaAdd(subject3, Integer.MAX_VALUE).intValue()); + assertEquals(Integer.MAX_VALUE, counters.get(subject3).intValue()); + assertEquals(Long.MAX_VALUE, counters.setViaAdd(subject3, Long.MAX_VALUE).longValue()); + assertEquals(Long.MAX_VALUE, counters.get(subject3).longValue()); + + assertEquals(10, counters.setViaAdd(subject1, 10).intValue()); + assertEquals(100, counters.setViaAdd(subject2, 100).intValue()); + assertEquals(1000, counters.setViaAdd(subject3, 1000).intValue()); + assertEquals(10, counters.get(subject1).intValue()); + assertEquals(100, counters.get(subject2).intValue()); + assertEquals(1000, counters.get(subject3).intValue()); + + BigInteger total = BigInteger.ZERO; + LinkedBlockingQueue eResponses = counters.getEntries(subject1, subject2, subject3); + CounterEntryResponse er = eResponses.poll(1, TimeUnit.SECONDS); + while (er != null && er.isEntry()) { + CounterEntry entry = er.getEntry(); + assertNotNull(entry); + total = total.add(entry.getValue()); + er = eResponses.poll(10, TimeUnit.MILLISECONDS); + } + assertNotNull(er); + assertTrue(er.isEobStatus()); + assertEquals(1110, total.intValue()); + + total = BigInteger.ZERO; + eResponses = counters.getEntries(subjectPrefix + ".*"); + er = eResponses.poll(1, TimeUnit.SECONDS); + while (er != null && er.isEntry()) { + CounterEntry entry = er.getEntry(); + assertNotNull(entry); + total = total.add(entry.getValue()); + er = eResponses.poll(10, TimeUnit.MILLISECONDS); + } + assertNotNull(er); + assertTrue(er.isEobStatus()); + assertEquals(1110, total.intValue()); + } + + @Test + public void testExtractVal() { + assertEquals(10, extractVal("{\"val\":\"10\"}".getBytes()).intValue()); + assertEquals(0, extractVal("{\"val\":\"0\"}".getBytes()).intValue()); + assertEquals(-10, extractVal("{\"val\":\"-10\"}".getBytes()).intValue()); + String l = "" + Long.MAX_VALUE; + assertEquals(Long.MAX_VALUE, extractVal(("{\"val\":\"" + l + "\"}").getBytes()).longValue()); + } + + @Test + public void testExtractSources() { + Map> map = extractSources(null); + assertNotNull(map); + assertTrue(map.isEmpty()); + + map = extractSources(SOURCES_JSON); + assertNotNull(map); + assertFalse(map.isEmpty()); + assertEquals(2, map.size()); + + Map map2 = map.get("source1"); + assertNotNull(map2); + assertFalse(map2.isEmpty()); + assertEquals(1, map2.size()); + + BigInteger value = map2.get("subject1"); + assertNotNull(value); + assertInstanceOf(BigInteger.class, value); + assertEquals(10, value.longValue()); + } + + private final static String SOURCES_JSON = "{\"source1\":{\"subject1\":\"10\"},\"source2\":{\"subject2\":\"20\",\"subject3\":\"90\"}}"; +} diff --git a/counters/src/test/resources/placeholder.txt b/counters/src/test/resources/placeholder.txt new file mode 100644 index 0000000..ca5fd64 --- /dev/null +++ b/counters/src/test/resources/placeholder.txt @@ -0,0 +1 @@ +This is just a placeholder. \ No newline at end of file diff --git a/direct-batch/README.md b/direct-batch/README.md index b252811..4cf9f27 100644 --- a/direct-batch/README.md +++ b/direct-batch/README.md @@ -1,4 +1,4 @@ -![Synadia](src/main/javadoc/images/synadia-logo.png)      ![NATS](src/main/javadoc/images/large-logo.png) +Orbit # Direct batch @@ -8,15 +8,12 @@ It only works with the 2.11.x NATS Server and the JNats 2.20.5.main-2-11-SNAPSHO The direct batch functionality leverages the direct message capabilities introduced in NATS Server 2.11 The functionality is described in [ADR-31](https://github.com/nats-io/nats-architecture-and-design/blob/main/adr/ADR-31.md) -**Current Release**: 0.1.2 - **Current Snapshot**: 0.1.3-SNAPSHOT -  **Gradle and Maven** `io.synadia:direct-batch` -[Dependencies Help](https://github.com/synadia-io/orbit.java?tab=readme-ov-file#dependencies) - -![Artifact](https://img.shields.io/badge/Artifact-io.synadia:direct--batch-00BC8E?labelColor=grey&style=flat) -[![License Apache 2](https://img.shields.io/badge/License-Apache2-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0) -[![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.synadia/direct-batch/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.synadia/direct-batch) +![Artifact](https://img.shields.io/badge/Artifact-io.synadia:direct--batch-197556?labelColor=grey&style=flat) +![0.1.4](https://img.shields.io/badge/Current_Release-0.1.4-27AAE0) +![0.1.5](https://img.shields.io/badge/Current_Snapshot-0.1.5--SNAPSHOT-27AAE0) +[![Dependencies Help](https://img.shields.io/badge/Dependencies%20Help-27AAE0)](https://github.com/synadia-io/orbit.java?tab=readme-ov-file#dependencies) [![javadoc](https://javadoc.io/badge2/io.synadia/direct-batch/javadoc.svg)](https://javadoc.io/doc/io.synadia/direct-batch) +[![Maven Central](https://img.shields.io/maven-central/v/io.synadia/direct-batch)](https://img.shields.io/maven-central/v/io.synadia/direct-batch) ## Message Info diff --git a/direct-batch/build.gradle b/direct-batch/build.gradle index 6fe666d..5602936 100644 --- a/direct-batch/build.gradle +++ b/direct-batch/build.gradle @@ -13,7 +13,7 @@ plugins { id 'signing' } -def jarVersion = "0.1.3" +def jarVersion = "0.1.5" group = 'io.synadia' def isMerge = System.getenv("BUILD_EVENT") == "push" @@ -38,7 +38,8 @@ repositories { } dependencies { - implementation 'io.nats:jnats:2.21.1' + implementation 'io.nats:jnats:2.25.1' + implementation 'org.jspecify:jspecify:1.0.0' testImplementation 'io.nats:jnats-server-runner:1.2.8' testImplementation 'org.junit.jupiter:junit-jupiter:5.9.0' @@ -46,6 +47,10 @@ dependencies { testImplementation 'nl.jqno.equalsverifier:equalsverifier:3.12.3' } +configurations.configureEach { + resolutionStrategy.cacheChangingModulesFor 0, 'seconds' +} + sourceSets { main { java { @@ -66,7 +71,7 @@ tasks.register('bundle', Bundle) { jar { manifest { - attributes('Automatic-Module-Name': 'io.synadia.direct-batch') + attributes('Automatic-Module-Name': 'io.synadia.direct.batch') } bnd (['Implementation-Title': 'Direct Batch', 'Implementation-Version': jarVersion, @@ -201,4 +206,4 @@ if (isRelease) { sign configurations.archives sign publishing.publications.mavenJava } -} \ No newline at end of file +} diff --git a/direct-batch/src/examples/java/io/synadia/examples/ExampleUtils.java b/direct-batch/src/examples/java/io/synadia/examples/ExampleUtils.java index 7da03a0..802de7a 100644 --- a/direct-batch/src/examples/java/io/synadia/examples/ExampleUtils.java +++ b/direct-batch/src/examples/java/io/synadia/examples/ExampleUtils.java @@ -2,6 +2,7 @@ import io.nats.client.NUID; import io.nats.client.api.MessageInfo; +import io.nats.client.impl.Headers; import io.nats.client.support.DateTimeUtils; import java.util.ArrayList; @@ -22,10 +23,14 @@ public static void printMessageInfo(List list) { public static void printMessageInfo(MessageInfo mi, Number listId) { if (mi.isMessage()) { + Headers h = mi.getHeaders(); + String hs = (h == null || h.isEmpty()) ? "none" : h.toString(); System.out.println("[" + listId + "] Message" + " | subject: " + mi.getSubject() + " | sequence: " + mi.getSeq() - + " | time: " + DateTimeUtils.toRfc3339(mi.getTime())); + + " | time: " + (mi.getTime() == null ? "null" : DateTimeUtils.toRfc3339(mi.getTime())) + + " | headers: " + hs + ); } else { if (mi.isEobStatus()) { @@ -34,13 +39,14 @@ public static void printMessageInfo(MessageInfo mi, Number listId) { else if (mi.isErrorStatus()) { System.out.print("[" + listId + "] MI Error"); } - else if (mi.isErrorStatus()) { + else if (mi.isStatus()) { System.out.print("[" + listId + "] MI Status"); } System.out.println(" | isStatus? " + mi.isStatus() + " | isEobStatus? " + mi.isEobStatus() + " | isErrorStatus? " + mi.isErrorStatus() - + " | status code: " + mi.getStatus().getCode()); + + " | status code: " + (mi.getStatus() == null ? "null" : mi.getStatus().getCode()) + ); } } diff --git a/direct-batch/src/main/java/io/synadia/direct/DirectBatchContext.java b/direct-batch/src/main/java/io/synadia/direct/DirectBatchContext.java index bc5d0b1..e51e34b 100644 --- a/direct-batch/src/main/java/io/synadia/direct/DirectBatchContext.java +++ b/direct-batch/src/main/java/io/synadia/direct/DirectBatchContext.java @@ -23,7 +23,7 @@ public class DirectBatchContext { private final Connection conn; private final JetStreamOptions jso; private final String streamName; - final Duration timeout; + private final Duration timeout; /** * Construct a DirectBatchContext instance. @@ -34,7 +34,7 @@ public class DirectBatchContext { * @throws JetStreamApiException the request had an error related to the data */ public DirectBatchContext(Connection conn, String streamName) throws IOException, JetStreamApiException { - this(conn, null, streamName); + this(conn, null, streamName, null); } /** @@ -47,6 +47,10 @@ public DirectBatchContext(Connection conn, String streamName) throws IOException * @throws JetStreamApiException the request had an error related to the data */ public DirectBatchContext(Connection conn, JetStreamOptions jso, String streamName) throws IOException, JetStreamApiException { + this(conn, jso, streamName, null); + } + + public DirectBatchContext(Connection conn, JetStreamOptions jso, String streamName, StreamInfo si) throws IOException, JetStreamApiException { validateNotNull(conn, "Connection required,"); if (!conn.getServerInfo().isNewerVersionThan("2.10.99")) { throw new IllegalArgumentException("Batch direct get not available until server version 2.11.0."); @@ -55,8 +59,14 @@ public DirectBatchContext(Connection conn, JetStreamOptions jso, String streamNa this.jso = jso == null ? DEFAULT_JS_OPTIONS : jso; JetStreamManagement jsm = conn.jetStreamManagement(this.jso); - this.streamName = required(streamName, "Stream name required,"); - StreamInfo si = jsm.getStreamInfo(streamName); + if (si == null) { + this.streamName = required(streamName, "Stream name required,"); + si = jsm.getStreamInfo(streamName); + } + else { + this.streamName = si.getConfiguration().getName(); + } + if (!si.getConfiguration().getAllowDirect()) { throw new IllegalArgumentException("Stream must have allow direct set."); } diff --git a/direct-batch/src/main/java/io/synadia/direct/MessageBatchGetRequest.java b/direct-batch/src/main/java/io/synadia/direct/MessageBatchGetRequest.java index 8f8dec8..7fceaa2 100644 --- a/direct-batch/src/main/java/io/synadia/direct/MessageBatchGetRequest.java +++ b/direct-batch/src/main/java/io/synadia/direct/MessageBatchGetRequest.java @@ -2,6 +2,7 @@ import io.nats.client.support.JsonSerializable; import io.nats.client.support.Validator; +import org.jspecify.annotations.NonNull; import java.time.ZonedDateTime; import java.util.List; @@ -37,10 +38,24 @@ private MessageBatchGetRequest(String subject, this.multiLastBySubjects = null; this.upToSequence = -1; this.upToTime = null; - this.minSequence = startTime == null && minSequence < 1 ? 1 : minSequence; } + // multi last for constructor + private MessageBatchGetRequest(List subjects, long upToSequence, ZonedDateTime upToTime, int batch) { + if (subjects == null || subjects.isEmpty()) { + throw new IllegalArgumentException("Subjects are required."); + } + this.batch = batch; + nextBySubject = null; + this.maxBytes = -1; + this.minSequence = -1; + this.startTime = null; + this.multiLastBySubjects = subjects; + this.upToSequence = upToSequence; + this.upToTime = upToTime; + } + /** * Get up to batch number of messages where the message sequence is >= 1 and for the specified subject * @param subject the subject @@ -108,21 +123,6 @@ public static MessageBatchGetRequest batchBytes(String subject, int batch, int m return new MessageBatchGetRequest(subject, batch, maxBytes, -1, startTime); } - // multi last for constructor - private MessageBatchGetRequest(List subjects, long upToSequence, ZonedDateTime upToTime, int batch) { - if (subjects == null || subjects.isEmpty()) { - throw new IllegalArgumentException("Subjects are required."); - } - this.batch = batch; - nextBySubject = null; - this.maxBytes = -1; - this.minSequence = -1; - this.startTime = null; - this.multiLastBySubjects = subjects; - this.upToSequence = upToSequence; - this.upToTime = upToTime; - } - /** * Get the last messages for the subjects specified subject * @param subjects the subjects, may include wildcards. @@ -253,6 +253,7 @@ public ZonedDateTime getUpToTime() { } @Override + @NonNull public String toJson() { StringBuilder sb = beginJson(); addField(sb, BATCH, batch); diff --git a/direct-batch/src/main/java/io/synadia/direct/MessageInfoHandler.java b/direct-batch/src/main/java/io/synadia/direct/MessageInfoHandler.java deleted file mode 100644 index 9a56a4d..0000000 --- a/direct-batch/src/main/java/io/synadia/direct/MessageInfoHandler.java +++ /dev/null @@ -1,15 +0,0 @@ -package io.synadia.direct; - -import io.nats.client.api.MessageInfo; - -/** - * Handler for {@link MessageInfo}. - */ -public interface MessageInfoHandler { - /** - * Called to deliver a {@link MessageInfo} to the handler. - * - * @param messageInfo the received {@link MessageInfo} - */ - void onMessageInfo(MessageInfo messageInfo); -} diff --git a/direct-batch/src/test/java/io/synadia/direct/DirectBatchTests.java b/direct-batch/src/test/java/io/synadia/direct/DirectBatchTests.java index 12efb56..21b92f0 100644 --- a/direct-batch/src/test/java/io/synadia/direct/DirectBatchTests.java +++ b/direct-batch/src/test/java/io/synadia/direct/DirectBatchTests.java @@ -420,18 +420,18 @@ private static void doQueue(DirectBatchContext db, MessageBatchGetRequest mbgr, @SuppressWarnings("DuplicateBranchesInSwitch") private static void _verify(List list, String label, boolean lastIsEob) { switch (label) { - case "1" : _verify(list, 1, 23, lastIsEob, 1, 2, 3); break; - case "1A" : _verify(list, 1, 3, lastIsEob, 1, 6, 11); break; - case "2" : _verify(list, 4, 20, lastIsEob, 4, 5, 6); break; - case "2A" : _verify(list, 6, 2, lastIsEob, 6, 11, 16); break; - case "3" : _verify(list, 6, 18, lastIsEob, 6, 7, 8); break; - case "3A" : _verify(list, 6, 2, lastIsEob, 6, 11, 16); break; - case "4" : _verify(list, 1, 23, lastIsEob, 1, 2); break; - case "4A" : _verify(list, 1, 3, lastIsEob, 1, 6); break; - case "5" : _verify(list, 4, 20, lastIsEob, 4, 5); break; - case "5A" : _verify(list, 6, 2, lastIsEob, 6, 11); break; - case "6" : _verify(list, 6, 18, lastIsEob, 6, 7); break; - case "6A" : _verify(list, 6, 2, lastIsEob, 6, 11); break; + case "1" : _verify(list, 1, 22, lastIsEob, 1, 2, 3); break; + case "1A" : _verify(list, 1, 2, lastIsEob, 1, 6, 11); break; + case "2" : _verify(list, 4, 19, lastIsEob, 4, 5, 6); break; + case "2A" : _verify(list, 6, 1, lastIsEob, 6, 11, 16); break; + case "3" : _verify(list, 6, 17, lastIsEob, 6, 7, 8); break; + case "3A" : _verify(list, 6, 1, lastIsEob, 6, 11, 16); break; + case "4" : _verify(list, 1, 22, lastIsEob, 1, 2); break; + case "4A" : _verify(list, 1, 2, lastIsEob, 1, 6); break; + case "5" : _verify(list, 4, 19, lastIsEob, 4, 5); break; + case "5A" : _verify(list, 6, 1, lastIsEob, 6, 11); break; + case "6" : _verify(list, 6, 17, lastIsEob, 6, 7); break; + case "6A" : _verify(list, 6, 1, lastIsEob, 6, 11); break; case "M1" : _verify(list, 21, 0, lastIsEob, 21, 23, 25); break; case "M1A" : _verify(list, 21, 2, lastIsEob, 21, 22, 23, 24, 25); break; case "M2" : _verify(list, 18, 0, lastIsEob, 18, 20, 21); break; diff --git a/encoded-kv/README.md b/encoded-kv/README.md index fb074df..d4d2961 100644 --- a/encoded-kv/README.md +++ b/encoded-kv/README.md @@ -1,4 +1,4 @@ -![Synadia](src/main/javadoc/images/synadia-logo.png)      ![NATS](src/main/javadoc/images/large-logo.png) +Orbit # Encoded Key Value @@ -14,15 +14,12 @@ It requires a _codec_, which * decodes the encoded key back to the key object * decodes the encoded data bytes back into the value object. -**Current Release**: N/A -  **Current Snapshot**: 0.1.0-SNAPSHOT -  **Gradle and Maven** `io.synadia:encoded-kv` -[Dependencies Help](https://github.com/synadia-io/orbit.java?tab=readme-ov-file#dependencies) - -![Artifact](https://img.shields.io/badge/Artifact-io.synadia:encoded--kv-00BC8E?labelColor=grey&style=flat) -[![License Apache 2](https://img.shields.io/badge/License-Apache2-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0) -[![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.synadia/encoded-kv/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.synadia/encoded-kv) +![Artifact](https://img.shields.io/badge/Artifact-io.synadia:encoded--kv-197556?labelColor=grey&style=flat) +![0.0.4](https://img.shields.io/badge/Current_Release-0.0.4-27AAE0) +![0.0.5](https://img.shields.io/badge/Current_Snapshot-0.0.5--SNAPSHOT-27AAE0) +[![Dependencies Help](https://img.shields.io/badge/Dependencies%20Help-27AAE0)](https://github.com/synadia-io/orbit.java?tab=readme-ov-file#dependencies) [![javadoc](https://javadoc.io/badge2/io.synadia/encoded-kv/javadoc.svg)](https://javadoc.io/doc/io.synadia/encoded-kv) +[![Maven Central](https://img.shields.io/maven-central/v/io.synadia/encoded-kv)](https://img.shields.io/maven-central/v/io.synadia/encoded-kv) --- Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. diff --git a/encoded-kv/build.gradle b/encoded-kv/build.gradle index 92b15ef..a508fd4 100644 --- a/encoded-kv/build.gradle +++ b/encoded-kv/build.gradle @@ -13,7 +13,7 @@ plugins { id 'signing' } -def jarVersion = "0.0.2" +def jarVersion = "0.0.5" group = 'io.synadia' def isMerge = System.getenv("BUILD_EVENT") == "push" @@ -38,7 +38,7 @@ repositories { } dependencies { - implementation 'io.nats:jnats:2.21.1' + implementation 'io.nats:jnats:2.25.1' testImplementation 'commons-codec:commons-codec:1.18.0' testImplementation 'io.nats:jnats-server-runner:1.2.8' @@ -67,7 +67,7 @@ tasks.register('bundle', Bundle) { jar { manifest { - attributes('Automatic-Module-Name': 'io.synadia.encoded-kv') + attributes('Automatic-Module-Name': 'io.synadia.encoded.kv') } bnd (['Implementation-Title': 'KV Encoding', 'Implementation-Version': jarVersion, diff --git a/encoded-kv/src/main/java/io/synadia/ekv/EncodedKeyResult.java b/encoded-kv/src/main/java/io/synadia/ekv/EncodedKeyResult.java index 6f994b7..66812a2 100644 --- a/encoded-kv/src/main/java/io/synadia/ekv/EncodedKeyResult.java +++ b/encoded-kv/src/main/java/io/synadia/ekv/EncodedKeyResult.java @@ -16,7 +16,7 @@ public EncodedKeyResult(KeyResult keyResult, KeyCodec keyCodec) { this.keyCodec = keyCodec; } - public KeyType getKey() throws Exception { + public KeyType getKey() { String key = keyResult.getKey(); return key == null ? null : keyCodec.decode(key); } diff --git a/encoded-kv/src/main/java/io/synadia/ekv/KvEncodedKeyEncodedValue.java b/encoded-kv/src/main/java/io/synadia/ekv/KvEncoded.java similarity index 94% rename from encoded-kv/src/main/java/io/synadia/ekv/KvEncodedKeyEncodedValue.java rename to encoded-kv/src/main/java/io/synadia/ekv/KvEncoded.java index 93fadec..ac2959b 100644 --- a/encoded-kv/src/main/java/io/synadia/ekv/KvEncodedKeyEncodedValue.java +++ b/encoded-kv/src/main/java/io/synadia/ekv/KvEncoded.java @@ -18,22 +18,22 @@ import java.util.ArrayList; import java.util.List; -public class KvEncodedKeyEncodedValue { +public class KvEncoded { private final KeyValue kv; private final KeyCodec keyCodec; private final ValueCodec valueCodec; - public KvEncodedKeyEncodedValue(Connection connection, String bucketName, KeyCodec keyCodec, ValueCodec valueCodec) throws IOException { + public KvEncoded(Connection connection, String bucketName, KeyCodec keyCodec, ValueCodec valueCodec) throws IOException { this(connection, bucketName, keyCodec, valueCodec, null); } - public KvEncodedKeyEncodedValue(Connection connection, String bucketName, KeyCodec keyCodec, ValueCodec valueCodec, KeyValueOptions kvo) throws IOException { + public KvEncoded(Connection connection, String bucketName, KeyCodec keyCodec, ValueCodec valueCodec, KeyValueOptions kvo) throws IOException { kv = connection.keyValue(bucketName, kvo); this.keyCodec = keyCodec; this.valueCodec = valueCodec; } - public KvEncodedKeyEncodedValue(KeyValue kv, KeyCodec keyCodec, ValueCodec valueCodec) { + public KvEncoded(KeyValue kv, KeyCodec keyCodec, ValueCodec valueCodec) { this.kv = kv; this.keyCodec = keyCodec; this.valueCodec = valueCodec; diff --git a/encoded-kv/src/main/java/io/synadia/ekv/KvEncodedKey.java b/encoded-kv/src/main/java/io/synadia/ekv/KvEncodedKey.java new file mode 100644 index 0000000..d217cb1 --- /dev/null +++ b/encoded-kv/src/main/java/io/synadia/ekv/KvEncodedKey.java @@ -0,0 +1,26 @@ +// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.ekv; + +import io.nats.client.Connection; +import io.nats.client.KeyValue; +import io.nats.client.KeyValueOptions; +import io.synadia.ekv.codec.ByteValueCodec; +import io.synadia.ekv.codec.KeyCodec; + +import java.io.IOException; + +public class KvEncodedKey extends KvEncoded { + public KvEncodedKey(Connection connection, String bucketName, KeyCodec keyCodec) throws IOException { + super(connection, bucketName, keyCodec, new ByteValueCodec()); + } + + public KvEncodedKey(Connection connection, String bucketName, KeyCodec keyCodec, KeyValueOptions kvo) throws IOException { + super(connection, bucketName, keyCodec, new ByteValueCodec(), kvo); + } + + public KvEncodedKey(KeyValue kv, KeyCodec keyCodec) { + super(kv, keyCodec, new ByteValueCodec()); + } +} diff --git a/encoded-kv/src/main/java/io/synadia/ekv/KvStringKeyEncodedValue.java b/encoded-kv/src/main/java/io/synadia/ekv/KvEncodedValue.java similarity index 55% rename from encoded-kv/src/main/java/io/synadia/ekv/KvStringKeyEncodedValue.java rename to encoded-kv/src/main/java/io/synadia/ekv/KvEncodedValue.java index e79c9f9..6ec96af 100644 --- a/encoded-kv/src/main/java/io/synadia/ekv/KvStringKeyEncodedValue.java +++ b/encoded-kv/src/main/java/io/synadia/ekv/KvEncodedValue.java @@ -11,16 +11,16 @@ import java.io.IOException; -public class KvStringKeyEncodedValue extends KvEncodedKeyEncodedValue { - public KvStringKeyEncodedValue(Connection connection, String bucketName, ValueCodec valueCodec) throws IOException { +public class KvEncodedValue extends KvEncoded { + public KvEncodedValue(Connection connection, String bucketName, ValueCodec valueCodec) throws IOException { super(connection, bucketName, new StringKeyCodec(), valueCodec); } - public KvStringKeyEncodedValue(Connection connection, String bucketName, ValueCodec valueCodec, KeyValueOptions kvo) throws IOException { + public KvEncodedValue(Connection connection, String bucketName, ValueCodec valueCodec, KeyValueOptions kvo) throws IOException { super(connection, bucketName, new StringKeyCodec(), valueCodec, kvo); } - public KvStringKeyEncodedValue(KeyValue kv, ValueCodec valueCodec) { + public KvEncodedValue(KeyValue kv, ValueCodec valueCodec) { super(kv, new StringKeyCodec(), valueCodec); } } diff --git a/encoded-kv/src/main/java/io/synadia/ekv/codec/ByteValueCodec.java b/encoded-kv/src/main/java/io/synadia/ekv/codec/ByteValueCodec.java new file mode 100644 index 0000000..6c74c74 --- /dev/null +++ b/encoded-kv/src/main/java/io/synadia/ekv/codec/ByteValueCodec.java @@ -0,0 +1,17 @@ +// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.ekv.codec; + +public class ByteValueCodec implements ValueCodec { + + @Override + public byte[] encode(byte[] value) { + return value; + } + + @Override + public byte[] decode(byte[] encodedValue) { + return encodedValue; + } +} diff --git a/encoded-kv/src/main/javadoc/overview.html b/encoded-kv/src/main/javadoc/overview.html index 4cb8395..201bd87 100644 --- a/encoded-kv/src/main/javadoc/overview.html +++ b/encoded-kv/src/main/javadoc/overview.html @@ -1,5 +1,5 @@ diff --git a/encoded-kv/src/test/java/io/synadia/ekv/CodecTests.java b/encoded-kv/src/test/java/io/synadia/ekv/CodecTests.java index cb85e32..69e08d8 100644 --- a/encoded-kv/src/test/java/io/synadia/ekv/CodecTests.java +++ b/encoded-kv/src/test/java/io/synadia/ekv/CodecTests.java @@ -3,10 +3,7 @@ package io.synadia.ekv; -import io.synadia.ekv.codec.DataValueCodec; -import io.synadia.ekv.codec.GeneralStringKeyCodec; -import io.synadia.ekv.codec.PathKeyCodec; -import io.synadia.ekv.codec.StringKeyCodec; +import io.synadia.ekv.codec.*; import io.synadia.ekv.misc.Data; import io.synadia.ekv.misc.GeneralType; import nats.io.NatsServerRunner; @@ -140,4 +137,21 @@ private void validatePathCodec(String path, String encoded, String decoded, bool assertEquals(path, pkcTrailingPath.decode(decoded + PathKeyCodec.TRAILING_SUFFIX)); } } + + @Test + public void testByteValueCodec() { + byte[] data = "data".getBytes(); + ByteValueCodec bvc = new ByteValueCodec(); + byte[] bytes = bvc.encode(data); + assertArrayEquals(data, bytes); + bytes = bvc.decode(data); + assertArrayEquals(data, bytes); + } + + @Test + public void testFilteringNotSupported() { + DataKeyCodec dkc = new DataKeyCodec(); + assertFalse(dkc.allowsFiltering()); + assertThrows(UnsupportedOperationException.class, () -> dkc.encodeFilter(null)); + } } diff --git a/encoded-kv/src/test/java/io/synadia/ekv/MiscTests.java b/encoded-kv/src/test/java/io/synadia/ekv/MiscTests.java new file mode 100644 index 0000000..cb76651 --- /dev/null +++ b/encoded-kv/src/test/java/io/synadia/ekv/MiscTests.java @@ -0,0 +1,42 @@ +// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.ekv; + +import io.nats.client.api.KeyResult; +import io.synadia.ekv.codec.StringKeyCodec; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class MiscTests { + + @Test + public void testEncodedKeyResult() { + KeyResult kr = new KeyResult(); + EncodedKeyResult ekr = new EncodedKeyResult<>(kr, new StringKeyCodec()); + assertNull(ekr.getKey()); + assertNull(ekr.getException()); + assertFalse(ekr.isKey()); + assertFalse(ekr.isException()); + assertTrue(ekr.isDone()); + + kr = new KeyResult("key"); + ekr = new EncodedKeyResult<>(kr, new StringKeyCodec()); + assertNotNull(ekr.getKey()); + assertEquals(kr.getKey(), ekr.getKey()); + assertNull(ekr.getException()); + assertTrue(ekr.isKey()); + assertFalse(ekr.isException()); + assertFalse(ekr.isDone()); + + kr = new KeyResult(new Exception("message")); + ekr = new EncodedKeyResult<>(kr, new StringKeyCodec()); + assertNull(ekr.getKey()); + assertNotNull(ekr.getException()); + assertTrue(ekr.getException().getMessage().contains("message")); + assertFalse(ekr.isKey()); + assertTrue(ekr.isException()); + assertTrue(ekr.isDone()); + } +} diff --git a/encoded-kv/src/test/java/io/synadia/ekv/WorkflowTests.java b/encoded-kv/src/test/java/io/synadia/ekv/WorkflowTests.java index e83be4c..6c812f6 100644 --- a/encoded-kv/src/test/java/io/synadia/ekv/WorkflowTests.java +++ b/encoded-kv/src/test/java/io/synadia/ekv/WorkflowTests.java @@ -18,6 +18,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.EnumSource; import java.nio.charset.StandardCharsets; @@ -41,27 +42,48 @@ public static void beforeAll() { } @ParameterizedTest - @EnumSource(GeneralType.class) - public void testStringKeyWorkflow(GeneralType gt) throws Exception { + @CsvSource({"PLAIN,N/A", "PLAIN,EVC", "PLAIN,EVC-NC", "PLAIN,EVC-KVO", "BASE64,N/A", "HEX,N/A"}) + public void testStringKeyWorkflow(String gtName, String variant) throws Exception { + GeneralType gt = gtName.equals("PLAIN") ? GeneralType.PLAIN : (gtName.equals("BASE64") ? GeneralType.BASE64 : GeneralType.HEX); try (NatsServerRunner runner = new NatsServerRunner(false, true)) { try (Connection nc = Nats.connect(runner.getURI())) { GeneralStringKeyCodec keyCodec = new GeneralStringKeyCodec(gt); DataValueCodec dvc = new DataValueCodec(gt); - String bucketName = NUID.nextGlobalSequence(); + String bucket = NUID.nextGlobalSequence(); KeyValueManagement kvm = nc.keyValueManagement(); - kvm.create(KeyValueConfiguration.builder().name(bucketName).build()); + kvm.create(KeyValueConfiguration.builder().name(bucket) + .maxHistoryPerKey(3) + .build()); - // this is just for coverage of constructors. - KvEncodedKeyEncodedValue ekv; + // this is just for coverage of constructors and KvEncoded classes + KvEncoded ekv; if (gt == GeneralType.PLAIN) { - KeyValue kv = nc.keyValue(bucketName); - ekv = new KvEncodedKeyEncodedValue<>(kv, keyCodec, dvc); + switch (variant) { + case "EVC": + KeyValue kv = nc.keyValue(bucket); + ekv = new KvEncodedValue<>(kv, dvc); + break; + case "EVC-NC": + ekv = new KvEncodedValue<>(nc, bucket, dvc); + break; + case "EVC-KVO": + ekv = new KvEncodedValue<>(nc, bucket, dvc, null); // COVERAGE + break; + default: + ekv = new KvEncoded<>(nc, bucket, keyCodec, dvc); + } + } + else if (gt == GeneralType.BASE64) { + KeyValue kv = nc.keyValue(bucket); + ekv = new KvEncoded<>(kv, keyCodec, dvc); } else { - ekv = new KvEncodedKeyEncodedValue<>(nc, bucketName, keyCodec, dvc); + ekv = new KvEncoded<>(nc, bucket, keyCodec, dvc); } + assertEquals(bucket, ekv.getBucketName()); + String key1 = "key.1"; String key2 = "key.2"; List keyList = new ArrayList<>(); @@ -71,12 +93,16 @@ public void testStringKeyWorkflow(GeneralType gt) throws Exception { Data v1 = new Data("v1", "foo", false); Data v2 = new Data("v2", "bar", false); - validatePutRevision(1, ekv.put(key1, v1)); - validatePutRevision(2, ekv.put(key2, v2)); + validateRevision(1, ekv.put(key1, v1)); + validateRevision(2, ekv.put(key2, v2)); - validateGet(key1, v1, ekv.get(key1)); - validateGet(key2, v2, ekv.get(key2)); + validateGet(bucket, key1, v1, ekv.get(key1)); + validateGet(bucket, key1, v1, ekv.get(key1, 1)); // COVERAGE for revision + validateGet(bucket, key2, v2, ekv.get(key2)); + validateGet(bucket, key2, v2, ekv.get(key2, 2)); // COVERAGE for revision + assertNull(ekv.get(key1, 99)); + assertNull(ekv.get(key2, 99)); assertNull(ekv.get("not-found")); validateKeys(key1, key2, ekv.keys()); @@ -91,23 +117,23 @@ public void testStringKeyWorkflow(GeneralType gt) throws Exception { validateKeys(null, key2, getFromQueue(ekv.consumeKeys(key2))); validateKeys(key1, key2, getFromQueue(ekv.consumeKeys(keyList))); - String stream = "KV_" + bucketName; + String stream = "KV_" + bucket; JetStreamSubscription sub = nc.jetStream().subscribe(">", PushSubscribeOptions.builder().stream(stream).build()); Message m1 = sub.nextMessage(Duration.ofSeconds(1)); Message m2 = sub.nextMessage(Duration.ofSeconds(1)); switch (gt) { case PLAIN: - assertEquals("$KV." + bucketName + ".key.1", m1.getSubject()); - assertEquals("$KV." + bucketName + ".key.2", m2.getSubject()); + assertEquals("$KV." + bucket + ".key.1", m1.getSubject()); + assertEquals("$KV." + bucket + ".key.2", m2.getSubject()); assertArrayEquals(v1.serialize(), m1.getData()); assertArrayEquals(v2.serialize(), m2.getData()); break; case BASE64: String encKey1 = keyCodec.encode(key1); String encKey2 = keyCodec.encode(key2); - assertEquals("$KV." + bucketName + "." + encKey1, m1.getSubject()); - assertEquals("$KV." + bucketName + "." + encKey2, m2.getSubject()); + assertEquals("$KV." + bucket + "." + encKey1, m1.getSubject()); + assertEquals("$KV." + bucket + "." + encKey2, m2.getSubject()); Base64 base64 = new Base64(); assertArrayEquals(base64.encode(v1.serialize()), m1.getData()); assertArrayEquals(base64.encode(v2.serialize()), m2.getData()); @@ -115,14 +141,87 @@ public void testStringKeyWorkflow(GeneralType gt) throws Exception { case HEX: String encKeyH1 = keyCodec.encode(key1); String encKeyH2 = keyCodec.encode(key2); - assertEquals("$KV." + bucketName + "." + encKeyH1, m1.getSubject()); - assertEquals("$KV." + bucketName + "." + encKeyH2, m2.getSubject()); + assertEquals("$KV." + bucket + "." + encKeyH1, m1.getSubject()); + assertEquals("$KV." + bucket + "." + encKeyH2, m2.getSubject()); Hex hex = new Hex(); assertArrayEquals(hex.encode(v1.serialize()), m1.getData()); assertArrayEquals(hex.encode(v2.serialize()), m2.getData()); } + + String key3 = "key.3"; + Data v3a = new Data("v3", "aaa", false); + Data v3b = new Data("v3", "bbb", false); + Data v3c = new Data("v3", "ccc", false); + Data v3d = new Data("v3", "ddd", false); + validateRevision(3, ekv.create(key3, v3a)); + validateGet(bucket, key3, v3a, ekv.get(key3)); + + List dataHistory = new ArrayList<>(); + dataHistory.add(v3a); + assertHistory(dataHistory, ekv.history(key3)); + + assertThrows(JetStreamApiException.class, () -> ekv.create(key3, v3a)); + + validateRevision(4, ekv.update(key3, v3b, 3)); + validateGet(bucket, key3, v3b, ekv.get(key3)); + dataHistory.add(v3b); + assertHistory(dataHistory, ekv.history(key3)); + + assertThrows(JetStreamApiException.class, () -> ekv.delete(key3, 3)); // COVERAGE + assertThrows(JetStreamApiException.class, () -> ekv.purge(key3, 3)); // COVERAGE + + ekv.delete(key3); + assertNull(ekv.get(key3)); + + dataHistory.add(KeyValueOperation.DELETE); + assertHistory(dataHistory, ekv.history(key3)); + + // revision is 6 b/c delete + validateRevision(6, ekv.put(key3, v3c)); + validateGet(bucket, key3, v3c, ekv.get(key3)); + + dataHistory.clear(); + dataHistory.add(v3b); + dataHistory.add(KeyValueOperation.DELETE); + dataHistory.add(v3c); + assertHistory(dataHistory, ekv.history(key3)); + + ekv.delete(key3, 6); + assertNull(ekv.get(key3)); + dataHistory.clear(); + dataHistory.add(KeyValueOperation.DELETE); + dataHistory.add(v3c); + dataHistory.add(KeyValueOperation.DELETE); + assertHistory(dataHistory, ekv.history(key3)); + + // revision is 8 b/c delete + validateRevision(8, ekv.put(key3, v3d)); + validateGet(bucket, key3, v3d, ekv.get(key3)); + dataHistory.remove(0); // delete is replaced + dataHistory.add(v3d); + + assertHistory(dataHistory, ekv.history(key3)); + ekv.purge(key3, 8); + assertNull(ekv.get(key3)); + dataHistory.clear(); + dataHistory.add(KeyValueOperation.PURGE); + assertHistory(dataHistory, ekv.history(key3)); + } + } + } + + private void assertHistory(List expected, List> apiHistory) { + System.out.println(); + for (int x = 0; x < apiHistory.size(); x++) { + Object o = expected.get(x); + if (o instanceof KeyValueOperation) { + assertEquals(o, apiHistory.get(x).getOperation()); + } + else { + assertEquals(o, apiHistory.get(x).getValue()); } } + assertEquals(apiHistory.size(), expected.size()); } @Test @@ -132,23 +231,27 @@ public void testKeyWorkflow() throws Exception { DataKeyCodec dkc = new DataKeyCodec(); DataValueCodec dvc = new DataValueCodec(GeneralType.BASE64); - String bucketName = NUID.nextGlobalSequence(); + String bucket = NUID.nextGlobalSequence(); KeyValueManagement kvm = nc.keyValueManagement(); - kvm.create(KeyValueConfiguration.builder().name(bucketName).build()); + kvm.create(KeyValueConfiguration.builder().name(bucket).build()); - KvEncodedKeyEncodedValue ekv = new KvEncodedKeyEncodedValue<>(nc, bucketName, dkc, dvc); + KvEncoded ekv = new KvEncoded<>(nc, bucket, dkc, dvc); Data key1 = new Data("foo1", null, true); Data key2 = new Data("foo2", null, true); Data v1 = new Data("bar1", "baz1", false); Data v2 = new Data("bar2", "baz2", false); - validatePutRevision(1, ekv.put(key1, v1)); - validatePutRevision(2, ekv.put(key2, v2)); + validateRevision(1, ekv.put(key1, v1)); + validateRevision(2, ekv.put(key2, v2)); - validateGet(key1, v1, ekv.get(key1)); - validateGet(key2, v2, ekv.get(key2)); + validateGet(bucket, key1, v1, ekv.get(key1)); + validateGet(bucket, key1, v1, ekv.get(key1, 1)); // COVERAGE for revision + validateGet(bucket, key2, v2, ekv.get(key2)); + validateGet(bucket, key2, v2, ekv.get(key2, 2)); // COVERAGE for revision + assertNull(ekv.get(key1, 99)); + assertNull(ekv.get(key2, 99)); assertNull(ekv.get(new Data("not-found", null, true))); validateKeys(key1, key2, ekv.keys()); @@ -166,23 +269,23 @@ public void testKeyWorkflow() throws Exception { } } - private static void validatePutRevision(long expectedRev, long actualRev) { + private static void validateRevision(long expectedRev, long actualRev) { assertEquals(expectedRev, actualRev); } - private static void validateGet(T key, Data value, EncodedKeyValueEntry entry) throws Exception { + private static void validateGet(String bucket, T key, Data value, EncodedKeyValueEntry entry) { assertNotNull(entry); + assertEquals(bucket, entry.getBucket()); assertEquals(key, entry.getKey()); assertEquals(value, entry.getValue()); + assertTrue(entry.getRevision() >= 0); // COVERAGE + assertTrue(entry.getDelta() >= 0); // COVERAGE } private static void validateKeys(T key1, T key2, List keys) { int count = 0; if (key1 != null) { count++; - if (!keys.contains(key1)) { - int x = 0; - } assertTrue(keys.contains(key1)); } else { @@ -274,7 +377,7 @@ public void endOfData() { static String TEST_WATCH_KEY_2 = "key.2"; interface TestWatchSubSupplier { - NatsKeyValueWatchSubscription get(KvEncodedKeyEncodedValue kv) throws Exception; + NatsKeyValueWatchSubscription get(KvEncoded kv) throws Exception; } @ParameterizedTest @@ -388,7 +491,7 @@ private void _testWatch(Connection nc, GeneralType gt, TestKeyValueWatcher watch .storageType(StorageType.Memory) .build()); - KvEncodedKeyEncodedValue kv = new KvEncodedKeyEncodedValue<>(nc.keyValue(bucket), watcher.keyCodec, watcher.valueCodec); + KvEncoded kv = new KvEncoded<>(nc.keyValue(bucket), watcher.keyCodec, watcher.valueCodec); NatsKeyValueWatchSubscription sub = null; diff --git a/encoded-kv/src/test/java/io/synadia/ekv/codec/GeneralByteValueCodec.java b/encoded-kv/src/test/java/io/synadia/ekv/codec/GeneralByteValueCodec.java index a678f98..08431fc 100644 --- a/encoded-kv/src/test/java/io/synadia/ekv/codec/GeneralByteValueCodec.java +++ b/encoded-kv/src/test/java/io/synadia/ekv/codec/GeneralByteValueCodec.java @@ -19,36 +19,31 @@ public GeneralByteValueCodec(GeneralType gt) { @Override public byte[] encode(byte[] value) { - if (value == null) { - return null; - } - - switch (gt) { - case BASE64: - return Base64.encodeBase64(value); - case HEX: - return new String(Hex.encodeHex(value)).getBytes(StandardCharsets.US_ASCII); + if (value != null) { + switch (gt) { + case BASE64: + return Base64.encodeBase64(value); + case HEX: + return new String(Hex.encodeHex(value)).getBytes(StandardCharsets.US_ASCII); + } } return value; } @Override public byte[] decode(byte[] encodedValue) { - if (encodedValue == null || encodedValue.length == 0) { - return null; + if (encodedValue != null && encodedValue.length > 0) { + switch (gt) { + case BASE64: return Base64.decodeBase64(encodedValue); + case HEX: + try { + return Hex.decodeHex(new String(encodedValue, StandardCharsets.US_ASCII)); + } + catch (DecoderException e) { + throw new RuntimeException(e); + } + } } - - switch (gt) { - case BASE64: return Base64.decodeBase64(encodedValue); - case HEX: - try { - return Hex.decodeHex(new String(encodedValue, StandardCharsets.US_ASCII)); - } - catch (DecoderException e) { - throw new RuntimeException(e); - } - } - return encodedValue; } } diff --git a/js-publish-extensions/README.md b/js-publish-extensions/README.md index 6eb30a4..e8e9909 100644 --- a/js-publish-extensions/README.md +++ b/js-publish-extensions/README.md @@ -1,18 +1,15 @@ -![Synadia](src/main/javadoc/images/synadia-logo.png)      ![NATS](src/main/javadoc/images/large-logo.png) +Orbit # JNATS JetStream Publisher Extensions Extensions specific to JetStream publishing. -**Current Release**: 0.4.2 -  **Current Snapshot**: 0.4.3-SNAPSHOT -  **Gradle and Maven** `io.synadia:jnats-js-publish-extensions` -[Dependencies Help](https://github.com/synadia-io/orbit.java?tab=readme-ov-file#dependencies) - -![Artifact](https://img.shields.io/badge/Artifact-io.synadia:jnats--js--publish--extensions-00BC8E?labelColor=grey&style=flat) -[![License Apache 2](https://img.shields.io/badge/License-Apache2-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0) -[![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.synadia/jnats-js-publish-extensions/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.synadia/jnats-js-publish-extensions) +![Artifact](https://img.shields.io/badge/Artifact-io.synadia:jnats--js--publish--extensions-197556?labelColor=grey&style=flat) +![0.4.4](https://img.shields.io/badge/Current_Release-0.4.4-27AAE0) +![0.4.5](https://img.shields.io/badge/Current_Snapshot-0.4.5--SNAPSHOT-27AAE0) +[![Dependencies Help](https://img.shields.io/badge/Dependencies%20Help-27AAE0)](https://github.com/synadia-io/orbit.java?tab=readme-ov-file#dependencies) [![javadoc](https://javadoc.io/badge2/io.synadia/jnats-js-publish-extensions/javadoc.svg)](https://javadoc.io/doc/io.synadia/jnats-js-publish-extensions) +[![Maven Central](https://img.shields.io/maven-central/v/io.synadia/jnats-js-publish-extensions)](https://img.shields.io/maven-central/v/io.synadia/jnats-js-publish-extensions) ### PublishRetrier diff --git a/js-publish-extensions/build.gradle b/js-publish-extensions/build.gradle index ff76be2..f194363 100644 --- a/js-publish-extensions/build.gradle +++ b/js-publish-extensions/build.gradle @@ -13,7 +13,7 @@ plugins { id 'signing' } -def jarVersion = "0.4.3" +def jarVersion = "0.4.5" group = 'io.synadia' def isMerge = System.getenv("BUILD_EVENT") == "push" @@ -45,7 +45,7 @@ repositories { } dependencies { - implementation 'io.nats:jnats:2.21.1' + implementation 'io.nats:jnats:2.25.1' implementation 'io.synadia:retrier:0.2.1' testImplementation 'io.nats:jnats-server-runner:1.2.8' @@ -74,7 +74,7 @@ tasks.register('bundle', Bundle) { jar { manifest { - attributes('Automatic-Module-Name': 'io.synadia.jnats-js-publish-extensions') + attributes('Automatic-Module-Name': 'io.synadia.jnats.js.publish.extensions') } bnd (['Implementation-Title': 'JNATS JetStream Publish Extensions', 'Implementation-Version': jarVersion, diff --git a/js-publish-extensions/src/main/javadoc/overview.html b/js-publish-extensions/src/main/javadoc/overview.html index de17c46..9426c9d 100644 --- a/js-publish-extensions/src/main/javadoc/overview.html +++ b/js-publish-extensions/src/main/javadoc/overview.html @@ -1,5 +1,5 @@ diff --git a/orbit_shorter.png b/orbit_shorter.png new file mode 100644 index 0000000..45c43b4 Binary files /dev/null and b/orbit_shorter.png differ diff --git a/pcgroups-cli/README.md b/pcgroups-cli/README.md new file mode 100644 index 0000000..4f2c02b --- /dev/null +++ b/pcgroups-cli/README.md @@ -0,0 +1,86 @@ +Orbit + +# Partitioned Consumer Groups CLI + +The Partitioned Consumer Groups CLI is a command line tool. In the usage, + +[![0.1.0](https://img.shields.io/badge/Current_Release-0.1.0-27AAE0)](https://github.com/synadia-io/orbit.java/releases/tag/pcgcli%2F0.1.0) + +#### Downloads + +Archives containing executable jar file (from the [Release Page](https://github.com/synadia-io/orbit.java/releases/tag/pcgcli%2F0.1.0)) + +| Asset | Size | SHA | +|------------------------------------------------------------------------------------------------|---------|---------------------------------------------------------------------------| +| **[cg.tar](https://github.com/synadia-io/orbit.java/releases/download/pcgcli%2F0.1.0/cg.tar)** | 6.56 MB | `sha256:7d02bfb4246872929613a029ade1ac1f2edc25abdd60140a9fd8a36452977f1e` | +| **[cg.zip](https://github.com/synadia-io/orbit.java/releases/download/pcgcli%2F0.1.0/cg.zip)** | 5.65 MB | `sha256:dff6e5b85377cac79635e6d77ecd65de35fe4031687ecefd0c2302b6fd520be2` | + +## Usage + +`cg` stands for `java -jar /cg.jar` + +``` +Usage: cg [options] + +Commands: + static Static consumer groups mode + elastic Elastic consumer groups mode + +Use 'cg --help' for more information about a command. +``` + +``` +Usage: cg static [COMMAND] +Static consumer groups mode +Commands: + ls, list List static consumer groups for a stream + info Get static consumer group info + create Create a static partitioned consumer group + delete, rm Delete a static partitioned consumer group + member-info, memberinfo, minfo Get static consumer group member info + step-down, stepdown, sd Initiate a step down for a member + consume, join Join a static partitioned consumer group + prompt Interactive prompt mode +``` + +``` +Usage: cg elastic [COMMAND] +Elastic consumer groups mode +Commands: + ls, list List elastic consumer groups for a stream + info Get elastic consumer group info + create Create an elastic partitioned consumer + group + delete, rm Delete an elastic partitioned consumer + group + add Add members to an elastic consumer group + drop Drop members from an elastic consumer group + create-mapping, cm, createmapping Create member mappings for an elastic + consumer group + delete-mapping, dm, deletemapping Delete member mappings for an elastic + consumer group + member-info, memberinfo, minfo Get elastic consumer group member info + step-down, stepdown, sd Initiate a step down for a member + consume, join Join an elastic partitioned consumer group + prompt Interactive prompt mode +``` + +## Building from Source + +### Gradle +``` +gradle clean package +``` +will build the `cg.jar` in the `build` folder + +## Running + + +``` +java -jar target/cg.jar ... +java -jar build/cg.jar ... +``` + +--- +Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +See [LICENSE](LICENSE) and [NOTICE](NOTICE) file for details. diff --git a/pcgroups-cli/build.gradle b/pcgroups-cli/build.gradle new file mode 100644 index 0000000..9946add --- /dev/null +++ b/pcgroups-cli/build.gradle @@ -0,0 +1,63 @@ +plugins { + id("java") + id 'com.github.johnrengelman.shadow' version '8.1.1' // Apply the shadow plugin +} + +version = "0.2.0" +def originalShadow = 'pcgroups-cli-' + version + '-all.jar' + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 +} + +repositories { + mavenCentral() + maven { url="https://repo1.maven.org/maven2/" } + maven { url="https://central.sonatype.com/repository/maven-snapshots" } +} + +dependencies { + implementation project(':') // ':' means root project which is 'pcgroups' + implementation 'info.picocli:picocli:4.7.5' + + testImplementation 'io.nats:jnats-server-runner:3.1.0' + testImplementation 'org.junit.jupiter:junit-jupiter:5.14.1' + testImplementation 'org.junit.platform:junit-platform-launcher:1.14.3' +} + +shadowJar { + manifest { + attributes 'Main-Class': 'io.synadia.pcg.cli.CgCommand' + } +} + +tasks.register('package', Copy) { + dependsOn shadowJar + + // we want the file to be called cg.jar + from ('build/libs') + include originalShadow + destinationDir file('build/') + rename originalShadow, "cg.jar" +} + +tasks.register('createZip', Zip) { + dependsOn 'package' + + archiveFileName = "cg.zip" + destinationDirectory = file('build/') + from (files('README.md', 'build/cg.jar')) +} + +tasks.register('createTar', Tar) { + dependsOn 'package' + + archiveFileName = "cg.tar" + destinationDirectory = file('build/') + from (files('README.md', 'build/cg.jar')) +} + +tasks.register('dist') { + dependsOn createZip + dependsOn createTar +} diff --git a/pcgroups-cli/gradle/wrapper/gradle-wrapper.jar b/pcgroups-cli/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/pcgroups-cli/gradle/wrapper/gradle-wrapper.jar differ diff --git a/pcgroups-cli/gradle/wrapper/gradle-wrapper.properties b/pcgroups-cli/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2a84e18 --- /dev/null +++ b/pcgroups-cli/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/pcgroups-cli/src/main/java/io/synadia/pcg/cli/CgCommand.java b/pcgroups-cli/src/main/java/io/synadia/pcg/cli/CgCommand.java new file mode 100644 index 0000000..bea105b --- /dev/null +++ b/pcgroups-cli/src/main/java/io/synadia/pcg/cli/CgCommand.java @@ -0,0 +1,59 @@ +// Copyright 2024-2025 Synadia Communications Inc. +// Licensed 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 io.synadia.pcg.cli; + +import picocli.CommandLine; +import picocli.CommandLine.*; + +import java.util.concurrent.Callable; + +/** + * Main CLI entry point for the Partitioned Consumer Group CLI tool. + * Run with: java -jar cg.jar [command] [options] + */ +@Command(name = "cg", + version = "0.1.0", + description = "Partitioned Consumer Group CLI tool", + mixinStandardHelpOptions = true, + subcommands = { + StaticCommands.class, + ElasticCommands.class + }) +public class CgCommand implements Callable { + + @Option(names = "--context", description = "NATS CLI context to use") + String context; + + @Override + public Integer call() { + System.out.println("Partitioned Consumer Group CLI tool v0.1.0"); + System.out.println(); + System.out.println("Usage: cg [options]"); + System.out.println(); + System.out.println("Commands:"); + System.out.println(" static Static consumer groups mode"); + System.out.println(" elastic Elastic consumer groups mode"); + System.out.println(); + System.out.println("Use 'cg --help' for more information about a command."); + return 0; + } + + public static void main(String[] args) { + int exitCode = new CommandLine(new CgCommand()) + .setAbbreviatedOptionsAllowed(true) + .setAbbreviatedSubcommandsAllowed(true) + .execute(args); + System.exit(exitCode); + } +} diff --git a/pcgroups-cli/src/main/java/io/synadia/pcg/cli/CliUtils.java b/pcgroups-cli/src/main/java/io/synadia/pcg/cli/CliUtils.java new file mode 100644 index 0000000..f0811ac --- /dev/null +++ b/pcgroups-cli/src/main/java/io/synadia/pcg/cli/CliUtils.java @@ -0,0 +1,310 @@ +// Copyright 2024-2025 Synadia Communications Inc. +// Licensed 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 io.synadia.pcg.cli; + +import io.nats.client.Connection; +import io.nats.client.JetStream; +import io.nats.client.JetStreamOptions; +import io.nats.client.Nats; +import io.nats.client.Options; +import io.synadia.pcg.MemberMapping; +import io.synadia.pcg.PartitioningFilter; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; + +/** + * CLI helper utilities. + */ +public class CliUtils { + + private CliUtils() { + // Utility class + } + + /** + * Connects to NATS using the specified context or defaults. + */ + public static Connection connect(String contextName) throws IOException, InterruptedException { + Options.Builder builder = Options.builder(); + + if (contextName != null && !contextName.isEmpty()) { + // Try to load context from NATS CLI context file + NatsContext ctx = loadNatsContext(contextName); + if (ctx != null) { + if (ctx.url != null && !ctx.url.isEmpty()) { + builder.server(ctx.url); + } + if (ctx.user != null && !ctx.user.isEmpty() && ctx.password != null) { + builder.userInfo(ctx.user, ctx.password); + } + if (ctx.creds != null && !ctx.creds.isEmpty()) { + builder.credentialPath(ctx.creds); + } + if (ctx.nkey != null && !ctx.nkey.isEmpty()) { + builder.authHandler(Nats.staticCredentials(null, ctx.nkey.toCharArray())); + } + if (ctx.token != null && !ctx.token.isEmpty()) { + builder.token(ctx.token.toCharArray()); + } + } + } else { + // Default connection + String natsUrl = System.getenv("NATS_URL"); + if (natsUrl == null || natsUrl.isEmpty()) { + natsUrl = Options.DEFAULT_URL; + } + builder.server(natsUrl); + } + + return Nats.connect(builder.build()); + } + + /** + * Creates a JetStream context with optional domain. + */ + public static JetStream getJetStream(Connection nc, String contextName) throws IOException { + JetStreamOptions.Builder jsoBuilder = JetStreamOptions.builder(); + + if (contextName != null && !contextName.isEmpty()) { + NatsContext ctx = loadNatsContext(contextName); + if (ctx != null && ctx.jsDomain != null && !ctx.jsDomain.isEmpty()) { + jsoBuilder.domain(ctx.jsDomain); + } + } + + return nc.jetStream(jsoBuilder.build()); + } + + /** + * Parses member mapping arguments in the format "member:partition1,partition2,...". + */ + public static List parseMemberMappings(List mappingArgs) throws IllegalArgumentException { + List mappings = new ArrayList<>(); + + if (mappingArgs == null || mappingArgs.isEmpty()) { + return mappings; + } + + for (String mapping : mappingArgs) { + int colonIndex = mapping.indexOf(':'); + if (colonIndex < 0) { + throw new IllegalArgumentException("can't parse member mapping '" + mapping + "': missing ':'"); + } + + String memberName = mapping.substring(0, colonIndex); + String partitionsInput = mapping.substring(colonIndex + 1); + String[] partitionArgs = partitionsInput.split(","); + + int[] partitions = new int[partitionArgs.length]; + for (int i = 0; i < partitionArgs.length; i++) { + try { + partitions[i] = Integer.parseInt(partitionArgs[i].trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("can't parse partition '" + partitionArgs[i] + "': not a valid integer"); + } + } + + mappings.add(new MemberMapping(memberName, partitions)); + } + + return mappings; + } + + /** + * Parses partitioning filter arguments in the format "filter:wildcard1,wildcard2" or just "filter". + * Each string in the list represents one PartitioningFilter. + */ + public static List parsePartitioningFilters(List filterArgs) throws IllegalArgumentException { + List filters = new ArrayList<>(); + + if (filterArgs == null || filterArgs.isEmpty()) { + return filters; + } + + for (String arg : filterArgs) { + int colonIndex = arg.indexOf(':'); + if (colonIndex < 0) { + // No wildcards specified + filters.add(new PartitioningFilter(arg, new int[0])); + } else { + String filter = arg.substring(0, colonIndex); + String wildcardsInput = arg.substring(colonIndex + 1); + int[] wildcards = parsePartitioningWildcardsString(wildcardsInput); + filters.add(new PartitioningFilter(filter, wildcards)); + } + } + + return filters; + } + + private static int[] parsePartitioningWildcardsString(String input) throws IllegalArgumentException { + if (input == null || input.isEmpty()) { + return new int[0]; + } + String[] parts = input.split(","); + int[] wildcards = new int[parts.length]; + for (int i = 0; i < parts.length; i++) { + try { + wildcards[i] = Integer.parseInt(parts[i].trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("can't parse wildcard index '" + parts[i] + "': not a valid integer"); + } + } + return wildcards; + } + + /** + * Parses partitioning wildcard indexes from a list of strings. + */ + public static int[] parsePartitioningWildcards(List wildcardArgs) throws IllegalArgumentException { + if (wildcardArgs == null || wildcardArgs.isEmpty()) { + return new int[0]; + } + + int[] wildcards = new int[wildcardArgs.size()]; + for (int i = 0; i < wildcardArgs.size(); i++) { + try { + wildcards[i] = Integer.parseInt(wildcardArgs.get(i).trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("can't parse wildcard index '" + wildcardArgs.get(i) + "': not a valid integer"); + } + } + + return wildcards; + } + + /** + * Simple NATS context structure. + */ + static class NatsContext { + String url; + String user; + String password; + String creds; + String nkey; + String token; + String jsDomain; + } + + /** + * Loads a NATS CLI context from the standard location. + */ + private static NatsContext loadNatsContext(String contextName) { + // Try to load from ~/.config/nats/context/.json + String configDir = System.getenv("XDG_CONFIG_HOME"); + if (configDir == null || configDir.isEmpty()) { + configDir = System.getProperty("user.home") + "/.config"; + } + + Path contextFile = Paths.get(configDir, "nats", "context", contextName + ".json"); + if (!Files.exists(contextFile)) { + return null; + } + + try { + String content = Files.readString(contextFile); + return parseContextJson(content); + } catch (IOException e) { + return null; + } + } + + /** + * Parses a simple JSON context file. + */ + private static NatsContext parseContextJson(String json) { + NatsContext ctx = new NatsContext(); + + // Simple JSON parsing without external dependencies + ctx.url = extractJsonValue(json, "url"); + ctx.user = extractJsonValue(json, "user"); + ctx.password = extractJsonValue(json, "password"); + ctx.creds = extractJsonValue(json, "creds"); + ctx.nkey = extractJsonValue(json, "nkey"); + ctx.token = extractJsonValue(json, "token"); + ctx.jsDomain = extractJsonValue(json, "jetstream_domain"); + + return ctx; + } + + private static String extractJsonValue(String json, String key) { + String pattern = "\"" + key + "\""; + int keyIndex = json.indexOf(pattern); + if (keyIndex < 0) { + return null; + } + + int colonIndex = json.indexOf(':', keyIndex + pattern.length()); + if (colonIndex < 0) { + return null; + } + + int valueStart = colonIndex + 1; + while (valueStart < json.length() && Character.isWhitespace(json.charAt(valueStart))) { + valueStart++; + } + + if (valueStart >= json.length()) { + return null; + } + + if (json.charAt(valueStart) == '"') { + int valueEnd = json.indexOf('"', valueStart + 1); + if (valueEnd > valueStart) { + return json.substring(valueStart + 1, valueEnd); + } + } + + return null; + } + + /** + * Formats a Duration as a human-readable string (e.g., "20ms", "5s", "1m30s"). + */ + public static String formatDuration(java.time.Duration duration) { + long totalNanos = duration.toNanos(); + if (totalNanos == 0) return "0s"; + + long hours = duration.toHours(); + long minutes = duration.toMinutesPart(); + long seconds = duration.toSecondsPart(); + long millis = duration.toMillisPart(); + + StringBuilder sb = new StringBuilder(); + if (hours > 0) sb.append(hours).append("h"); + if (minutes > 0) sb.append(minutes).append("m"); + if (seconds > 0) sb.append(seconds).append("s"); + if (millis > 0 && hours == 0 && minutes == 0 && seconds == 0) sb.append(millis).append("ms"); + + return sb.length() > 0 ? sb.toString() : "0s"; + } + + /** + * Prompts for user confirmation. + */ + public static boolean confirm(String message) { + System.out.print(message + " (y/n): "); + try { + BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); + String response = reader.readLine(); + return response != null && response.trim().equalsIgnoreCase("y"); + } catch (IOException e) { + return false; + } + } +} diff --git a/pcgroups-cli/src/main/java/io/synadia/pcg/cli/DurationConverter.java b/pcgroups-cli/src/main/java/io/synadia/pcg/cli/DurationConverter.java new file mode 100644 index 0000000..30fa70d --- /dev/null +++ b/pcgroups-cli/src/main/java/io/synadia/pcg/cli/DurationConverter.java @@ -0,0 +1,110 @@ +// Copyright 2024-2025 Synadia Communications Inc. +// Licensed 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 io.synadia.pcg.cli; + +import picocli.CommandLine.ITypeConverter; + +import java.time.Duration; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Converts human-readable duration strings to Duration objects. + * Supports formats like: 20ms, 5s, 1m, 2h, 1h30m, 500us, 100ns + */ +public class DurationConverter implements ITypeConverter { + + private static final Pattern DURATION_PATTERN = Pattern.compile( + "(?:(\\d+)h)?(?:(\\d+)m(?!s))?(?:(\\d+)s)?(?:(\\d+)ms)?(?:(\\d+)us)?(?:(\\d+)ns)?", + Pattern.CASE_INSENSITIVE + ); + + @Override + public Duration convert(String value) throws Exception { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException("Duration cannot be empty"); + } + + value = value.trim().toLowerCase(); + + // Try parsing as a simple number with unit suffix + Matcher matcher = DURATION_PATTERN.matcher(value); + if (matcher.matches()) { + long totalNanos = 0; + + String hours = matcher.group(1); + String minutes = matcher.group(2); + String seconds = matcher.group(3); + String millis = matcher.group(4); + String micros = matcher.group(5); + String nanos = matcher.group(6); + + if (hours != null) { + totalNanos += Long.parseLong(hours) * 3600_000_000_000L; + } + if (minutes != null) { + totalNanos += Long.parseLong(minutes) * 60_000_000_000L; + } + if (seconds != null) { + totalNanos += Long.parseLong(seconds) * 1_000_000_000L; + } + if (millis != null) { + totalNanos += Long.parseLong(millis) * 1_000_000L; + } + if (micros != null) { + totalNanos += Long.parseLong(micros) * 1_000L; + } + if (nanos != null) { + totalNanos += Long.parseLong(nanos); + } + + if (totalNanos > 0) { + return Duration.ofNanos(totalNanos); + } + } + + // Try single unit formats (e.g., "20ms", "5s") + Pattern singleUnit = Pattern.compile("(\\d+)(ns|us|ms|s|m|h)", Pattern.CASE_INSENSITIVE); + Matcher singleMatcher = singleUnit.matcher(value); + if (singleMatcher.matches()) { + long amount = Long.parseLong(singleMatcher.group(1)); + String unit = singleMatcher.group(2).toLowerCase(); + + switch (unit) { + case "ns": + return Duration.ofNanos(amount); + case "us": + return Duration.ofNanos(amount * 1000); + case "ms": + return Duration.ofMillis(amount); + case "s": + return Duration.ofSeconds(amount); + case "m": + return Duration.ofMinutes(amount); + case "h": + return Duration.ofHours(amount); + } + } + + throw new IllegalArgumentException( + "Invalid duration format: '" + value + "'. Use formats like: 20ms, 5s, 1m, 2h, 1h30m"); + } + + /** + * Static convenience method for parsing duration strings. + */ + public static Duration parseDuration(String value) throws Exception { + return new DurationConverter().convert(value); + } +} diff --git a/pcgroups-cli/src/main/java/io/synadia/pcg/cli/ElasticCommands.java b/pcgroups-cli/src/main/java/io/synadia/pcg/cli/ElasticCommands.java new file mode 100644 index 0000000..b4fe1f9 --- /dev/null +++ b/pcgroups-cli/src/main/java/io/synadia/pcg/cli/ElasticCommands.java @@ -0,0 +1,441 @@ +// Copyright 2024-2025 Synadia Communications Inc. +// Licensed 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 io.synadia.pcg.cli; + +import io.nats.client.Connection; +import io.nats.client.api.AckPolicy; +import io.nats.client.api.ConsumerConfiguration; +import io.synadia.pcg.*; +import picocli.CommandLine.*; + +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.Callable; + +/** + * Elastic consumer group CLI commands. + */ +@Command(name = "elastic", description = "Elastic consumer groups mode", + mixinStandardHelpOptions = true, + subcommands = { + ElasticCommands.Ls.class, + ElasticCommands.Info.class, + ElasticCommands.Create.class, + ElasticCommands.Delete.class, + ElasticCommands.Add.class, + ElasticCommands.Drop.class, + ElasticCommands.CreateMapping.class, + ElasticCommands.DeleteMapping.class, + ElasticCommands.MemberInfo.class, + ElasticCommands.StepDown.class, + ElasticCommands.Consume.class, + ElasticCommands.Prompt.class + }) +public class ElasticCommands implements Callable { + + @ParentCommand + CgCommand parent; + + @Override + public Integer call() { + System.out.println("Use 'cg elastic --help' for available subcommands"); + return 0; + } + + @Command(name = "ls", aliases = {"list"}, description = "List elastic consumer groups for a stream", mixinStandardHelpOptions = true) + static class Ls implements Callable { + @ParentCommand + private ElasticCommands parent; + + @Parameters(index = "0", description = "Stream name") + String streamName; + + @Override + public Integer call() { + try (Connection nc = CliUtils.connect(parent.parent.context)) { + List groups = ElasticConsumerGroup.list(nc, streamName); + System.out.println("elastic consumer groups: " + groups); + return 0; + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + return 1; + } + } + } + + @Command(name = "info", description = "Get elastic consumer group info", mixinStandardHelpOptions = true) + static class Info implements Callable { + @ParentCommand + private ElasticCommands parent; + + @Parameters(index = "0", description = "Stream name") + String streamName; + + @Parameters(index = "1", description = "Consumer group name") + String consumerGroupName; + + @Override + public Integer call() { + try (Connection nc = CliUtils.connect(parent.parent.context)) { + ElasticConsumerGroupConfig config = ElasticConsumerGroup.getConfig(nc, streamName, consumerGroupName); + + System.out.printf("config: max members=%d, max buffered msgs=%d, max buffered bytes=%d%n", config.getMaxMembers(), config.getMaxBufferedMessages(), config.getMaxBufferedBytes()); + + if (config.getPartitioningFilters().isEmpty()) { + System.out.printf("no partitioning filters defined (whole subject used)%n"); + } else { + for (PartitioningFilter pf : config.getPartitioningFilters()) { + System.out.printf("filter=%s, partitioning wildcards %s%n", + pf.getFilter(), Arrays.toString(pf.getPartitioningWildcards())); + } + } + + if (!config.getMembers().isEmpty()) { + System.out.printf("members: %s%n", config.getMembers()); + } else if (!config.getMemberMappings().isEmpty()) { + System.out.printf("Member mappings: %s%n", config.getMemberMappings()); + } else { + System.out.println("no members or mappings defined"); + } + + List activeMembers = ElasticConsumerGroup.listActiveMembers(nc, streamName, consumerGroupName); + System.out.printf("currently active members: %s%n", activeMembers); + return 0; + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + return 1; + } + } + } + + @Command(name = "create", description = "Create an elastic partitioned consumer group", mixinStandardHelpOptions = true) + static class Create implements Callable { + @ParentCommand + private ElasticCommands parent; + + @Parameters(index = "0", description = "Stream name") + String streamName; + + @Parameters(index = "1", description = "Consumer group name") + String consumerGroupName; + + @Parameters(index = "2", description = "Max number of members") + int maxMembers; + + @Option(names = "--filter", description = "Partitioning filter in format 'subject:wildcard1,wildcard2' or just 'subject' (repeatable, omit to use whole subject)", split = "\\|") + List filterArgs; + + @Option(names = "--max-buffered-msgs", description = "Max number of buffered messages", defaultValue = "0") + long maxBufferedMsgs; + + @Option(names = "--max-buffered-bytes", description = "Max number of buffered bytes", defaultValue = "0") + long maxBufferedBytes; + + @Override + public Integer call() { + try (Connection nc = CliUtils.connect(parent.parent.context)) { + List partitioningFilters = CliUtils.parsePartitioningFilters(filterArgs); + ElasticConsumerGroup.create(nc, streamName, consumerGroupName, maxMembers, partitioningFilters, maxBufferedMsgs, maxBufferedBytes); + System.out.println("elastic partitioned consumer group created"); + return 0; + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + return 1; + } + } + } + + @Command(name = "delete", aliases = {"rm"}, description = "Delete an elastic partitioned consumer group", mixinStandardHelpOptions = true) + static class Delete implements Callable { + @ParentCommand + private ElasticCommands parent; + + @Parameters(index = "0", description = "Stream name") + String streamName; + + @Parameters(index = "1", description = "Consumer group name") + String consumerGroupName; + + @Option(names = {"-f", "--force"}, description = "Force delete without confirmation") + boolean force; + + @Override + public Integer call() { + if (!force) { + if (!CliUtils.confirm("WARNING: this operation will cause all existing consumer members to terminate consuming. Are you sure?")) { + System.out.println("Operation canceled"); + return 1; + } + } + + try (Connection nc = CliUtils.connect(parent.parent.context)) { + ElasticConsumerGroup.delete(nc, streamName, consumerGroupName); + System.out.println("elastic consumer group deleted"); + return 0; + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + return 1; + } + } + } + + @Command(name = "add", description = "Add members to an elastic consumer group", mixinStandardHelpOptions = true) + static class Add implements Callable { + @ParentCommand + private ElasticCommands parent; + + @Parameters(index = "0", description = "Stream name") + String streamName; + + @Parameters(index = "1", description = "Consumer group name") + String consumerGroupName; + + @Parameters(index = "2..*", description = "Member names") + List memberNames; + + @Override + public Integer call() { + try (Connection nc = CliUtils.connect(parent.parent.context)) { + List members = ElasticConsumerGroup.addMembers(nc, streamName, consumerGroupName, memberNames); + System.out.println("added members: " + members); + return 0; + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + return 1; + } + } + } + + @Command(name = "drop", description = "Drop members from an elastic consumer group", mixinStandardHelpOptions = true) + static class Drop implements Callable { + @ParentCommand + private ElasticCommands parent; + + @Parameters(index = "0", description = "Stream name") + String streamName; + + @Parameters(index = "1", description = "Consumer group name") + String consumerGroupName; + + @Parameters(index = "2..*", description = "Member names") + List memberNames; + + @Override + public Integer call() { + try (Connection nc = CliUtils.connect(parent.parent.context)) { + List members = ElasticConsumerGroup.deleteMembers(nc, streamName, consumerGroupName, memberNames); + System.out.println("dropped members: " + members); + return 0; + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + return 1; + } + } + } + + @Command(name = "create-mapping", aliases = {"cm", "createmapping"}, description = "Create member mappings for an elastic consumer group", mixinStandardHelpOptions = true) + static class CreateMapping implements Callable { + @ParentCommand + private ElasticCommands parent; + + @Parameters(index = "0", description = "Stream name") + String streamName; + + @Parameters(index = "1", description = "Consumer group name") + String consumerGroupName; + + @Parameters(index = "2..*", description = "Mappings in format member:partition1,partition2,...") + List mappingArgs; + + @Override + public Integer call() { + try (Connection nc = CliUtils.connect(parent.parent.context)) { + List memberMappings = CliUtils.parseMemberMappings(mappingArgs); + ElasticConsumerGroup.setMemberMappings(nc, streamName, consumerGroupName, memberMappings); + System.out.println("member mapping: " + memberMappings); + return 0; + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + return 1; + } + } + } + + @Command(name = "delete-mapping", aliases = {"dm", "deletemapping"}, description = "Delete member mappings for an elastic consumer group", mixinStandardHelpOptions = true) + static class DeleteMapping implements Callable { + @ParentCommand + private ElasticCommands parent; + + @Parameters(index = "0", description = "Stream name") + String streamName; + + @Parameters(index = "1", description = "Consumer group name") + String consumerGroupName; + + @Override + public Integer call() { + try (Connection nc = CliUtils.connect(parent.parent.context)) { + ElasticConsumerGroup.deleteMemberMappings(nc, streamName, consumerGroupName); + System.out.println("member mappings deleted"); + return 0; + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + return 1; + } + } + } + + @Command(name = "member-info", aliases = {"memberinfo", "minfo"}, description = "Get elastic consumer group member info", mixinStandardHelpOptions = true) + static class MemberInfo implements Callable { + @ParentCommand + private ElasticCommands parent; + + @Parameters(index = "0", description = "Stream name") + String streamName; + + @Parameters(index = "1", description = "Consumer group name") + String consumerGroupName; + + @Parameters(index = "2", description = "Member name") + String memberName; + + @Override + public Integer call() { + try (Connection nc = CliUtils.connect(parent.parent.context)) { + boolean[] status = ElasticConsumerGroup.isInMembershipAndActive(nc, streamName, consumerGroupName, memberName); + boolean inMembership = status[0]; + boolean isActive = status[1]; + + if (inMembership) { + if (isActive) { + System.out.printf("member %s is part of the consumer group membership and is active%n", memberName); + } else { + System.out.printf("member %s is part of the consumer group membership%n", memberName); + System.out.printf("***Warning*** member %s is part of the consumer group membership but has NO active instance%n", memberName); + } + } else { + System.out.printf("member %s is not currently part of the consumer group membership%n", memberName); + } + return 0; + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + return 1; + } + } + } + + @Command(name = "step-down", aliases = {"stepdown", "sd"}, description = "Initiate a step down for a member", mixinStandardHelpOptions = true) + static class StepDown implements Callable { + @ParentCommand + private ElasticCommands parent; + + @Parameters(index = "0", description = "Stream name") + String streamName; + + @Parameters(index = "1", description = "Consumer group name") + String consumerGroupName; + + @Parameters(index = "2", description = "Member name") + String memberName; + + @Override + public Integer call() { + try (Connection nc = CliUtils.connect(parent.parent.context)) { + ElasticConsumerGroup.memberStepDown(nc, streamName, consumerGroupName, memberName); + System.out.printf("member %s step down initiated%n", memberName); + return 0; + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + return 1; + } + } + } + + @Command(name = "consume", aliases = {"join"}, description = "Join an elastic partitioned consumer group", mixinStandardHelpOptions = true) + static class Consume implements Callable { + @ParentCommand + private ElasticCommands parent; + + @Parameters(index = "0", description = "Stream name") + String streamName; + + @Parameters(index = "1", description = "Consumer group name") + String consumerGroupName; + + @Parameters(index = "2", description = "Member name") + String memberName; + + @Option(names = "--sleep", description = "Sleep to simulate processing time (e.g., 20ms, 5s, 1m)", defaultValue = "20ms", converter = DurationConverter.class) + Duration processingDuration = Duration.ofMillis(20); + + @Override + public Integer call() { + try (Connection nc = CliUtils.connect(parent.parent.context)) { + Duration processingTime = processingDuration; + + System.out.println("consuming..."); + ConsumerConfiguration consumerConfig = ConsumerConfiguration.builder() + .maxAckPending(1) + .ackWait(Duration.ofSeconds(2)) + .ackPolicy(AckPolicy.Explicit) + .build(); + ConsumerGroupConsumeContext ctx = ElasticConsumerGroup.consume(nc, streamName, consumerGroupName, memberName, + msg -> { + String pid = msg.getPinnedId(); + try { + long seqNumber = msg.getMessage().metaData().streamSequence(); + System.out.printf("[%s] subject=%s, seq=%d, pinnedID=%s. Processing for %s ... ", + memberName, msg.getSubject(), seqNumber, pid, CliUtils.formatDuration(processingTime)); + Thread.sleep(processingTime.toMillis()); + msg.ackSync(Duration.ofSeconds(5)); + System.out.println("acked"); + } catch (Exception e) { + System.out.println("message could not be acked! (it will be or may already have been re-delivered): " + e.getMessage()); + } + }, + consumerConfig); + + // Wait for completion + try { + ctx.done().get(); + System.out.println("instance returned with no error"); + } catch (Exception e) { + System.out.println("instance returned with an error: " + e.getMessage()); + } + + return 0; + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + return 1; + } + } + } + + @Command(name = "prompt", description = "Interactive prompt mode", mixinStandardHelpOptions = true) + static class Prompt implements Callable { + @ParentCommand + private ElasticCommands parent; + + @Override + public Integer call() { + try (Connection nc = CliUtils.connect(parent.parent.context)) { + return new PromptHandler(false, parent.parent.context, nc).run(); + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + return 1; + } + } + } +} diff --git a/pcgroups-cli/src/main/java/io/synadia/pcg/cli/PromptHandler.java b/pcgroups-cli/src/main/java/io/synadia/pcg/cli/PromptHandler.java new file mode 100644 index 0000000..7dd7450 --- /dev/null +++ b/pcgroups-cli/src/main/java/io/synadia/pcg/cli/PromptHandler.java @@ -0,0 +1,717 @@ +// Copyright 2024-2025 Synadia Communications Inc. +// Licensed 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 io.synadia.pcg.cli; + +import io.nats.client.Connection; +import io.nats.client.api.AckPolicy; +import io.nats.client.api.ConsumerConfiguration; +import io.synadia.pcg.*; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Shared interactive prompt handler for both static and elastic modes. + * Mirrors the Go prompt() function behavior. + */ +class PromptHandler { + + private boolean isStatic; + private final String context; + private final Connection nc; + private Duration processingDuration = Duration.ofMillis(20); + private boolean consuming = false; + private String currentStream; + private String currentGroup; + private String currentMember; + + PromptHandler(boolean isStatic, String context, Connection nc) { + this.isStatic = isStatic; + this.context = context; + this.nc = nc; + } + + int run() { + System.out.println("Interactive prompt mode - type 'help' for commands, 'exit' to quit"); + BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); + + while (true) { + try { + if (isStatic) { + System.out.print("[static]"); + } else { + System.out.print("[elastic]"); + } + if (consuming) { + System.out.printf("[%s/%s/%s]> ", currentStream, currentGroup, currentMember); + } else { + System.out.print("> "); + } + + String line = reader.readLine(); + if (line == null) break; + + line = line.trim(); + if (line.isEmpty()) continue; + + String command; + String argsString = null; + String[] args = null; + int spaceIndex = line.indexOf(' '); + if (spaceIndex >= 0) { + command = line.substring(0, spaceIndex); + argsString = line.substring(spaceIndex + 1).trim(); + args = argsString.split("\\s+"); + } else { + command = line; + args = new String[0]; + } + + switch (command) { + case "exit": + case "quit": + System.out.println("Exiting..."); + return 0; + case "help": + case "?": + printHelp(); + break; + case "static": + isStatic = true; + break; + case "elastic": + isStatic = false; + break; + case "processing-time": + handleProcessingTime(reader, args, argsString); + break; + case "list": + case "ls": + handleList(reader, args, argsString); + break; + case "info": + handleInfo(reader, args); + break; + case "create": + handleCreate(reader); + break; + case "delete": + case "rm": + handleDelete(reader, args); + break; + case "add": + handleAdd(reader, args); + break; + case "drop": + handleDrop(reader, args); + break; + case "createmapping": + case "create-mapping": + case "cm": + handleCreateMapping(reader, args); + break; + case "deletemapping": + case "delete-mapping": + case "dm": + handleDeleteMapping(reader, args); + break; + case "memberinfo": + case "member-info": + case "minfo": + handleMemberInfo(reader, args); + break; + case "stepdown": + case "step-down": + case "sd": + handleStepDown(reader, args); + break; + case "consume": + case "join": + handleConsume(reader, args); + break; + default: + System.out.println("Unknown command: " + command + ". Type 'help' for available commands."); + } + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + } + } + return 0; + } + + private void printHelp() { + System.out.println("Available commands:"); + System.out.println(" exit/quit - exit the program"); + System.out.println(" list/ls - list partitioned consumer groups"); + System.out.println(" info - get partitioned consumer group info"); + System.out.println(" create - create a partitioned consumer group (interactive, filter and wildcard indexes are optional)"); + System.out.println(" delete/rm - delete a partitioned consumer group"); + System.out.println(" memberinfo/minfo - get partitioned consumer group member info"); + System.out.println(" add [...] - add a member to a partitioned consumer group"); + System.out.println(" drop [...] - remove a member from a partitioned consumer group"); + System.out.println(" deletemapping - delete all member mappings for a partitioned consumer group"); + System.out.println(" createmapping - create member mappings for a partitioned consumer group"); + System.out.println(" stepdown/sd - initiate a step down for a member"); + System.out.println(" consume/join - join a partitioned consumer group"); + System.out.println(" processing-time - set message processing time (e.g., 20ms, 5s, 1m)"); + System.out.println(" static - static consumer groups mode"); + System.out.println(" elastic - elastic consumer groups mode"); + } + + private void handleProcessingTime(BufferedReader reader, String[] args, String argsString) { + try { + String input; + if (args.length != 1) { + System.out.print("processing time: "); + input = reader.readLine(); + if (input == null) return; + input = input.trim(); + } else { + input = argsString; + } + processingDuration = DurationConverter.parseDuration(input); + System.out.printf("processing time set to %s%n", CliUtils.formatDuration(processingDuration)); + } catch (Exception e) { + System.out.printf("error: can't parse processing time: %s%n", e.getMessage()); + } + } + + private void handleList(BufferedReader reader, String[] args, String argsString) { + try { + if (args.length != 1) { + System.out.print("stream name: "); + String input = reader.readLine(); + if (input == null) return; + currentStream = input.trim(); + } else { + currentStream = argsString; + } + + List groups; + if (isStatic) { + groups = StaticConsumerGroup.list(nc, currentStream); + System.out.println("static consumer groups: " + groups); + } else { + groups = ElasticConsumerGroup.list(nc, currentStream); + System.out.println("elastic consumer groups: " + groups); + } + } catch (Exception e) { + System.out.printf("error: can't list partitioned consumer groups: %s%n", e.getMessage()); + } + } + + private void handleInfo(BufferedReader reader, String[] args) { + try { + if (args.length != 2) { + System.out.print("stream name: "); + String input = reader.readLine(); + if (input == null) return; + currentStream = input.trim(); + System.out.print("consumer group name: "); + input = reader.readLine(); + if (input == null) return; + currentGroup = input.trim(); + } else { + currentStream = args[0]; + currentGroup = args[1]; + } + + if (isStatic) { + StaticConsumerGroupConfig config = StaticConsumerGroup.getConfig(nc, currentStream, currentGroup); + System.out.printf("config: max members=%d, filter=%s%n", config.getMaxMembers(), config.getFilter()); + if (!config.getMembers().isEmpty()) { + System.out.printf("members: %s%n", config.getMembers()); + } else if (!config.getMemberMappings().isEmpty()) { + System.out.printf("Member mappings: %s%n", config.getMemberMappings()); + } else { + System.out.println("no members or mappings defined"); + } + List activeMembers = StaticConsumerGroup.listActiveMembers(nc, currentStream, currentGroup); + System.out.printf("currently active members: %s%n", activeMembers); + } else { + ElasticConsumerGroupConfig config = ElasticConsumerGroup.getConfig(nc, currentStream, currentGroup); + if (config.getPartitioningFilters().isEmpty()) { + System.out.printf("config: max members=%d, no partitioning filters (whole subject used)%n", + config.getMaxMembers()); + } else { + for (PartitioningFilter pf : config.getPartitioningFilters()) { + System.out.printf("config: max members=%d, filter=%s, partitioning wildcards %s%n", + config.getMaxMembers(), pf.getFilter(), Arrays.toString(pf.getPartitioningWildcards())); + } + } + if (!config.getMembers().isEmpty()) { + System.out.printf("members: %s%n", config.getMembers()); + } else if (!config.getMemberMappings().isEmpty()) { + System.out.printf("Member mappings: %s%n", config.getMemberMappings()); + } else { + System.out.println("no members or mappings defined"); + } + List activeMembers = ElasticConsumerGroup.listActiveMembers(nc, currentStream, currentGroup); + System.out.printf("currently active members: %s%n", activeMembers); + } + } catch (Exception e) { + if (isStatic) { + System.out.printf("can't get static partitioned consumer group config: %s%n", e.getMessage()); + } else { + System.out.printf("can't get elastic partitioned consumer group config: %s%n", e.getMessage()); + } + } + } + + private void handleCreate(BufferedReader reader) { + try { + System.out.print("stream name: "); + String input = reader.readLine(); + if (input == null) return; + currentStream = input.trim(); + + System.out.print("consumer group name: "); + input = reader.readLine(); + if (input == null) return; + currentGroup = input.trim(); + + System.out.print("max members: "); + input = reader.readLine(); + if (input == null) return; + int maxMembers = Integer.parseInt(input.trim()); + + if (isStatic) { + System.out.print("filter (empty for whole subject partitioning): "); + input = reader.readLine(); + if (input == null) return; + String filter = input.trim().isEmpty() ? null : input.trim(); + System.out.print("space separated set of members (hit return to set member mappings instead): "); + input = reader.readLine(); + if (input == null) return; + input = input.trim(); + + if (input.isEmpty()) { + System.out.println("enter the member mappings"); + List mappingArgs = inputMemberMappings(reader); + if (mappingArgs.isEmpty()) { + System.out.println("member mappings not defined, can't create the partitioned consumer group"); + return; + } + List memberMappings = CliUtils.parseMemberMappings(mappingArgs); + StaticConsumerGroup.create(nc, currentStream, currentGroup, maxMembers, filter, new ArrayList<>(), memberMappings); + System.out.println("static partitioned consumer group created"); + } else { + List memberNames = Arrays.asList(input.split("\\s+")); + StaticConsumerGroup.create(nc, currentStream, currentGroup, maxMembers, filter, memberNames, new ArrayList<>()); + System.out.println("static partitioned consumer group created"); + } + } else { + List partitioningFilters = new ArrayList<>(); + System.out.println("enter partitioning filters (empty filter to finish, or empty first filter for whole subject partitioning):"); + while (true) { + System.out.print(" filter: "); + input = reader.readLine(); + if (input == null) return; + String filter = input.trim(); + if (filter.isEmpty()) break; + + System.out.print(" space separated partitioning wildcard indexes (empty for none): "); + input = reader.readLine(); + if (input == null) return; + int[] wildcards; + if (input.trim().isEmpty()) { + wildcards = new int[0]; + } else { + String[] pwciArgs = input.trim().split("\\s+"); + wildcards = new int[pwciArgs.length]; + for (int i = 0; i < pwciArgs.length; i++) { + wildcards[i] = Integer.parseInt(pwciArgs[i]); + } + } + partitioningFilters.add(new PartitioningFilter(filter, wildcards)); + } + + System.out.print("max buffered messages (0 for no limit): "); + input = reader.readLine(); + if (input == null) return; + long maxBufferedMsgs = input.trim().isEmpty() ? 0 : Long.parseLong(input.trim()); + + System.out.print("max buffered bytes (0 for no limit): "); + input = reader.readLine(); + if (input == null) return; + long maxBufferedBytes = input.trim().isEmpty() ? 0 : Long.parseLong(input.trim()); + + ElasticConsumerGroup.create(nc, currentStream, currentGroup, maxMembers, partitioningFilters, maxBufferedMsgs, maxBufferedBytes); + System.out.println("elastic partitioned consumer group created"); + } + } catch (Exception e) { + System.out.printf("can't create partitioned consumer group: %s%n", e.getMessage()); + } + } + + private void handleDelete(BufferedReader reader, String[] args) { + try { + if (args.length != 2) { + System.out.print("stream name: "); + String input = reader.readLine(); + if (input == null) return; + currentStream = input.trim(); + System.out.print("consumer group name: "); + input = reader.readLine(); + if (input == null) return; + currentGroup = input.trim(); + } else { + currentStream = args[0]; + currentGroup = args[1]; + } + + System.out.print("WARNING: this operation will cause all existing consumer members to terminate consuming. Are you sure? (y/n): "); + String confirm = reader.readLine(); + if (confirm == null || !confirm.trim().equalsIgnoreCase("y")) { + System.out.println("Operation canceled"); + return; + } + + if (isStatic) { + StaticConsumerGroup.delete(nc, currentStream, currentGroup); + System.out.println("static consumer group deleted"); + } else { + ElasticConsumerGroup.delete(nc, currentStream, currentGroup); + System.out.println("elastic consumer group deleted"); + } + } catch (Exception e) { + System.out.printf("can't delete partitioned consumer group: %s%n", e.getMessage()); + } + } + + private void handleAdd(BufferedReader reader, String[] args) { + try { + if (isStatic) { + System.out.println("can not add members to a static partitioned consumer groups, you must delete and recreate them"); + return; + } + + List memberNames; + if (args.length < 3) { + System.out.print("stream name: "); + String input = reader.readLine(); + if (input == null) return; + currentStream = input.trim(); + System.out.print("consumer group name: "); + input = reader.readLine(); + if (input == null) return; + currentGroup = input.trim(); + System.out.print("member name (or space separated list of names): "); + input = reader.readLine(); + if (input == null) return; + memberNames = Arrays.asList(input.trim().split("\\s+")); + } else { + currentStream = args[0]; + currentGroup = args[1]; + memberNames = Arrays.asList(Arrays.copyOfRange(args, 2, args.length)); + } + + List members = ElasticConsumerGroup.addMembers(nc, currentStream, currentGroup, memberNames); + System.out.println("added members: " + members); + } catch (Exception e) { + System.out.printf("can't add members: %s%n", e.getMessage()); + } + } + + private void handleDrop(BufferedReader reader, String[] args) { + try { + if (isStatic) { + System.out.println("can not drop members from a static partitioned consumer groups, you must delete and recreate them"); + return; + } + + List memberNames; + if (args.length < 3) { + System.out.print("stream name: "); + String input = reader.readLine(); + if (input == null) return; + currentStream = input.trim(); + System.out.print("consumer group name: "); + input = reader.readLine(); + if (input == null) return; + currentGroup = input.trim(); + System.out.print("member name (or space separated list of names): "); + input = reader.readLine(); + if (input == null) return; + memberNames = Arrays.asList(input.trim().split("\\s+")); + } else { + currentStream = args[0]; + currentGroup = args[1]; + memberNames = Arrays.asList(Arrays.copyOfRange(args, 2, args.length)); + } + + List members = ElasticConsumerGroup.deleteMembers(nc, currentStream, currentGroup, memberNames); + System.out.println("dropped members: " + members); + } catch (Exception e) { + System.out.printf("can't drop members: %s%n", e.getMessage()); + } + } + + private void handleCreateMapping(BufferedReader reader, String[] args) { + try { + if (args.length != 2) { + System.out.print("stream name: "); + String input = reader.readLine(); + if (input == null) return; + currentStream = input.trim(); + System.out.print("consumer group name: "); + input = reader.readLine(); + if (input == null) return; + currentGroup = input.trim(); + } else { + currentStream = args[0]; + currentGroup = args[1]; + } + + List mappingArgs = inputMemberMappings(reader); + List memberMappings = CliUtils.parseMemberMappings(mappingArgs); + + if (isStatic) { + // For static, we'd need to recreate - not directly supported + System.out.println("can not set member mappings on a static partitioned consumer group, you must delete and recreate it"); + } else { + ElasticConsumerGroup.setMemberMappings(nc, currentStream, currentGroup, memberMappings); + System.out.printf("member mappings set: %s%n", memberMappings); + } + } catch (Exception e) { + System.out.printf("can't set member mappings: %s%n", e.getMessage()); + } + } + + private void handleDeleteMapping(BufferedReader reader, String[] args) { + try { + if (args.length != 2) { + System.out.print("stream name: "); + String input = reader.readLine(); + if (input == null) return; + currentStream = input.trim(); + System.out.print("consumer group name: "); + input = reader.readLine(); + if (input == null) return; + currentGroup = input.trim(); + } else { + currentStream = args[0]; + currentGroup = args[1]; + } + + if (!CliUtils.confirm("WARNING: this operation will cause all existing consumer members to terminate consuming are you sure?")) { + return; + } + + if (isStatic) { + System.out.println("can not delete member mappings on a static partitioned consumer group, you must delete and recreate it"); + } else { + ElasticConsumerGroup.deleteMemberMappings(nc, currentStream, currentGroup); + System.out.println("member mappings deleted"); + } + } catch (Exception e) { + System.out.printf("can't delete member mappings: %s%n", e.getMessage()); + } + } + + private void handleMemberInfo(BufferedReader reader, String[] args) { + try { + if (args.length != 3) { + System.out.print("stream name: "); + String input = reader.readLine(); + if (input == null) return; + currentStream = input.trim(); + System.out.print("consumer group name: "); + input = reader.readLine(); + if (input == null) return; + currentGroup = input.trim(); + System.out.print("member name: "); + input = reader.readLine(); + if (input == null) return; + currentMember = input.trim(); + } else { + currentStream = args[0]; + currentGroup = args[1]; + currentMember = args[2]; + } + + if (isStatic) { + StaticConsumerGroupConfig config = StaticConsumerGroup.getConfig(nc, currentStream, currentGroup); + List activeMembers = StaticConsumerGroup.listActiveMembers(nc, currentStream, currentGroup); + + if (config.isInMembership(currentMember)) { + System.out.printf("member %s is part of the consumer group membership%n", currentMember); + if (activeMembers.contains(currentMember)) { + System.out.printf("member %s is active%n", currentMember); + } else { + System.out.printf("***Warning*** member %s is part of the consumer group membership but has NO active instance%n", currentMember); + } + } else { + System.out.printf("member %s is not part of the consumer group membership%n", currentMember); + } + } else { + boolean[] status = ElasticConsumerGroup.isInMembershipAndActive(nc, currentStream, currentGroup, currentMember); + boolean inMembership = status[0]; + boolean isActive = status[1]; + + if (inMembership) { + if (isActive) { + System.out.printf("member %s is part of the consumer group membership and is active%n", currentMember); + } else { + System.out.printf("member %s is part of the consumer group membership%n", currentMember); + System.out.printf("***Warning*** member %s is part of the consumer group membership but has NO active instance%n", currentMember); + } + } else { + System.out.printf("member %s is not currently part of the consumer group membership%n", currentMember); + } + } + } catch (Exception e) { + System.out.printf("can't get partitioned consumer group member info: %s%n", e.getMessage()); + } + } + + private void handleStepDown(BufferedReader reader, String[] args) { + try { + if (args.length != 3) { + System.out.print("stream name: "); + String input = reader.readLine(); + if (input == null) return; + currentStream = input.trim(); + System.out.print("consumer group name: "); + input = reader.readLine(); + if (input == null) return; + currentGroup = input.trim(); + System.out.print("member name: "); + input = reader.readLine(); + if (input == null) return; + currentMember = input.trim(); + } else { + currentStream = args[0]; + currentGroup = args[1]; + currentMember = args[2]; + } + + if (isStatic) { + StaticConsumerGroup.memberStepDown(nc, currentStream, currentGroup, currentMember); + } else { + ElasticConsumerGroup.memberStepDown(nc, currentStream, currentGroup, currentMember); + } + System.out.printf("member %s step down initiated%n", currentMember); + } catch (Exception e) { + System.out.printf("can't step down member: %s%n", e.getMessage()); + } + } + + private void handleConsume(BufferedReader reader, String[] args) { + try { + if (consuming) { + System.out.println("already consuming"); + return; + } + + if (args.length != 3) { + System.out.print("stream name: "); + String input = reader.readLine(); + if (input == null) return; + currentStream = input.trim(); + System.out.print("consumer group name: "); + input = reader.readLine(); + if (input == null) return; + currentGroup = input.trim(); + System.out.print("member name: "); + input = reader.readLine(); + if (input == null) return; + currentMember = input.trim(); + } else { + currentStream = args[0]; + currentGroup = args[1]; + currentMember = args[2].trim(); + } + + Duration processingTime = processingDuration; + String memberName = currentMember; + + System.out.println("consuming..."); + ConsumerConfiguration consumerConfig = ConsumerConfiguration.builder() + .maxAckPending(1) + .ackWait(Duration.ofSeconds(2)) + .ackPolicy(AckPolicy.Explicit) + .build(); + + ConsumerGroupConsumeContext ctx; + if (isStatic) { + ctx = StaticConsumerGroup.consume(nc, currentStream, currentGroup, memberName, + msg -> { + String pid = msg.getPinnedId(); + try { + long seqNumber = msg.getMessage().metaData().streamSequence(); + System.out.printf("[%s] subject=%s, seq=%d, pinnedID=%s. Processing for %s ... ", + memberName, msg.getSubject(), seqNumber, pid, CliUtils.formatDuration(processingTime)); + Thread.sleep(processingTime.toMillis()); + msg.ackSync(Duration.ofSeconds(5)); + System.out.println("acked"); + } catch (Exception e) { + System.out.println("message could not be acked! (it will be or may already have been re-delivered): " + e.getMessage()); + } + }, + consumerConfig); + } else { + ctx = ElasticConsumerGroup.consume(nc, currentStream, currentGroup, memberName, + msg -> { + String pid = msg.getPinnedId(); + try { + long seqNumber = msg.getMessage().metaData().streamSequence(); + System.out.printf("[%s] subject=%s, seq=%d, pinnedID=%s. Processing for %s ... ", + memberName, msg.getSubject(), seqNumber, pid, CliUtils.formatDuration(processingTime)); + Thread.sleep(processingTime.toMillis()); + msg.ackSync(Duration.ofSeconds(5)); + System.out.println("acked"); + } catch (Exception e) { + System.out.println("message could not be acked! (it will be or may already have been re-delivered): " + e.getMessage()); + } + }, + consumerConfig); + } + + consuming = true; + + // Wait for completion + try { + ctx.done().get(); + System.out.println("instance returned with no error"); + } catch (Exception e) { + System.out.println("instance returned with an error: " + e.getMessage()); + } + consuming = false; + } catch (Exception e) { + System.out.printf("can't join the partitioned consumer group: %s%n", e.getMessage()); + consuming = false; + } + } + + private List inputMemberMappings(BufferedReader reader) { + List mappings = new ArrayList<>(); + System.out.println("enter member mappings in format member:partition1,partition2,... (empty line to finish)"); + try { + while (true) { + System.out.print("mapping (or empty to finish): "); + String input = reader.readLine(); + if (input == null || input.trim().isEmpty()) break; + mappings.add(input.trim()); + } + } catch (Exception e) { + System.err.println("Error reading input: " + e.getMessage()); + } + return mappings; + } +} diff --git a/pcgroups-cli/src/main/java/io/synadia/pcg/cli/StaticCommands.java b/pcgroups-cli/src/main/java/io/synadia/pcg/cli/StaticCommands.java new file mode 100644 index 0000000..6756357 --- /dev/null +++ b/pcgroups-cli/src/main/java/io/synadia/pcg/cli/StaticCommands.java @@ -0,0 +1,365 @@ +// Copyright 2024-2025 Synadia Communications Inc. +// Licensed 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 io.synadia.pcg.cli; + +import io.nats.client.Connection; +import io.nats.client.api.AckPolicy; +import io.nats.client.api.ConsumerConfiguration; +import io.synadia.pcg.*; +import picocli.CommandLine.*; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; + +/** + * Static consumer group CLI commands. + */ +@Command(name = "static", description = "Static consumer groups mode", + mixinStandardHelpOptions = true, + subcommands = { + StaticCommands.Ls.class, + StaticCommands.Info.class, + StaticCommands.Create.class, + StaticCommands.Delete.class, + StaticCommands.MemberInfo.class, + StaticCommands.StepDown.class, + StaticCommands.Consume.class, + StaticCommands.Prompt.class + }) +public class StaticCommands implements Callable { + + @ParentCommand + CgCommand parent; + + @Override + public Integer call() { + System.out.println("Use 'cg static --help' for available subcommands"); + return 0; + } + + @Command(name = "ls", aliases = {"list"}, description = "List static consumer groups for a stream", mixinStandardHelpOptions = true) + static class Ls implements Callable { + @ParentCommand + private StaticCommands parent; + + @Parameters(index = "0", description = "Stream name") + String streamName; + + @Override + public Integer call() { + try (Connection nc = CliUtils.connect(parent.parent.context)) { + List groups = StaticConsumerGroup.list(nc, streamName); + System.out.println("static consumer groups: " + groups); + return 0; + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + return 1; + } + } + } + + @Command(name = "info", description = "Get static consumer group info", mixinStandardHelpOptions = true) + static class Info implements Callable { + @ParentCommand + private StaticCommands parent; + + @Parameters(index = "0", description = "Stream name") + String streamName; + + @Parameters(index = "1", description = "Consumer group name") + String consumerGroupName; + + @Override + public Integer call() { + try (Connection nc = CliUtils.connect(parent.parent.context)) { + StaticConsumerGroupConfig config = StaticConsumerGroup.getConfig(nc, streamName, consumerGroupName); + + System.out.printf("config: max members=%d, filter=%s%n", config.getMaxMembers(), config.getFilter()); + + if (!config.getMembers().isEmpty()) { + System.out.printf("members: %s%n", config.getMembers()); + } else if (!config.getMemberMappings().isEmpty()) { + System.out.printf("Member mappings: %s%n", config.getMemberMappings()); + } else { + System.out.println("no members or mappings defined"); + } + + List activeMembers = StaticConsumerGroup.listActiveMembers(nc, streamName, consumerGroupName); + System.out.printf("currently active members: %s%n", activeMembers); + return 0; + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + return 1; + } + } + } + + @Command(name = "create", description = "Create a static partitioned consumer group", + mixinStandardHelpOptions = true, + subcommands = {StaticCommands.CreateBalanced.class, StaticCommands.CreateMapped.class}) + static class Create implements Callable { + @ParentCommand + private StaticCommands parent; + + @Override + public Integer call() { + System.out.println("Use 'cg static create balanced' or 'cg static create mapped'"); + return 0; + } + } + + @Command(name = "balanced", description = "Create a static consumer group with balanced members", mixinStandardHelpOptions = true) + static class CreateBalanced implements Callable { + @ParentCommand + private Create createParent; + + @Parameters(index = "0", description = "Stream name") + String streamName; + + @Parameters(index = "1", description = "Consumer group name") + String consumerGroupName; + + @Parameters(index = "2", description = "Max number of members") + int maxMembers; + + @Parameters(index = "3", description = "Filter") + String filter; + + @Parameters(index = "4..*", arity = "1..*", description = "Member names") + List memberNames; + + @Override + public Integer call() { + try (Connection nc = CliUtils.connect(createParent.parent.parent.context)) { + StaticConsumerGroup.create(nc, streamName, consumerGroupName, maxMembers, filter, memberNames, new ArrayList<>()); + System.out.println("static partitioned consumer group created"); + return 0; + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + return 1; + } + } + } + + @Command(name = "mapped", description = "Create a static consumer group with member mappings", mixinStandardHelpOptions = true) + static class CreateMapped implements Callable { + @ParentCommand + private Create createParent; + + @Parameters(index = "0", description = "Stream name") + String streamName; + + @Parameters(index = "1", description = "Consumer group name") + String consumerGroupName; + + @Parameters(index = "2", description = "Max number of members") + int maxMembers; + + @Parameters(index = "3", description = "Filter") + String filter; + + @Parameters(index = "4..*", description = "Mappings in format member:partition1,partition2,...") + List mappingArgs; + + @Override + public Integer call() { + try (Connection nc = CliUtils.connect(createParent.parent.parent.context)) { + List memberMappings = CliUtils.parseMemberMappings(mappingArgs); + StaticConsumerGroup.create(nc, streamName, consumerGroupName, maxMembers, filter, new ArrayList<>(), memberMappings); + System.out.println("static partitioned consumer group created"); + return 0; + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + return 1; + } + } + } + + @Command(name = "delete", aliases = {"rm"}, description = "Delete a static partitioned consumer group", mixinStandardHelpOptions = true) + static class Delete implements Callable { + @ParentCommand + private StaticCommands parent; + + @Parameters(index = "0", description = "Stream name") + String streamName; + + @Parameters(index = "1", description = "Consumer group name") + String consumerGroupName; + + @Option(names = {"-f", "--force"}, description = "Force delete without confirmation") + boolean force; + + @Override + public Integer call() { + if (!force) { + if (!CliUtils.confirm("WARNING: this operation will cause all existing consumer members to terminate consuming. Are you sure?")) { + System.out.println("Operation canceled"); + return 1; + } + } + + try (Connection nc = CliUtils.connect(parent.parent.context)) { + StaticConsumerGroup.delete(nc, streamName, consumerGroupName); + System.out.println("static consumer group deleted"); + return 0; + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + return 1; + } + } + } + + @Command(name = "member-info", aliases = {"memberinfo", "minfo"}, description = "Get static consumer group member info", mixinStandardHelpOptions = true) + static class MemberInfo implements Callable { + @ParentCommand + private StaticCommands parent; + + @Parameters(index = "0", description = "Stream name") + String streamName; + + @Parameters(index = "1", description = "Consumer group name") + String consumerGroupName; + + @Parameters(index = "2", description = "Member name") + String memberName; + + @Override + public Integer call() { + try (Connection nc = CliUtils.connect(parent.parent.context)) { + StaticConsumerGroupConfig config = StaticConsumerGroup.getConfig(nc, streamName, consumerGroupName); + List activeMembers = StaticConsumerGroup.listActiveMembers(nc, streamName, consumerGroupName); + + if (config.isInMembership(memberName)) { + System.out.printf("member %s is part of the consumer group membership%n", memberName); + if (activeMembers.contains(memberName)) { + System.out.printf("member %s is active%n", memberName); + } else { + System.out.printf("***Warning*** member %s is part of the consumer group membership but has NO active instance%n", memberName); + } + } else { + System.out.printf("member %s is not part of the consumer group membership%n", memberName); + } + return 0; + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + return 1; + } + } + } + + @Command(name = "step-down", aliases = {"stepdown", "sd"}, description = "Initiate a step down for a member", mixinStandardHelpOptions = true) + static class StepDown implements Callable { + @ParentCommand + private StaticCommands parent; + + @Parameters(index = "0", description = "Stream name") + String streamName; + + @Parameters(index = "1", description = "Consumer group name") + String consumerGroupName; + + @Parameters(index = "2", description = "Member name") + String memberName; + + @Override + public Integer call() { + try (Connection nc = CliUtils.connect(parent.parent.context)) { + StaticConsumerGroup.memberStepDown(nc, streamName, consumerGroupName, memberName); + System.out.printf("member %s step down initiated%n", memberName); + return 0; + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + return 1; + } + } + } + + @Command(name = "consume", aliases = {"join"}, description = "Join a static partitioned consumer group", mixinStandardHelpOptions = true) + static class Consume implements Callable { + @ParentCommand + private StaticCommands parent; + + @Parameters(index = "0", description = "Stream name") + String streamName; + + @Parameters(index = "1", description = "Consumer group name") + String consumerGroupName; + + @Parameters(index = "2", description = "Member name") + String memberName; + + @Option(names = "--sleep", description = "Sleep to simulate processing time (e.g., 20ms, 5s, 1m)", defaultValue = "20ms", converter = DurationConverter.class) + Duration processingDuration = Duration.ofMillis(20); + + @Override + public Integer call() { + try (Connection nc = CliUtils.connect(parent.parent.context)) { + Duration processingTime = processingDuration; + + System.out.println("consuming..."); + ConsumerConfiguration consumerConfig = ConsumerConfiguration.builder() + .maxAckPending(1) + .ackWait(Duration.ofSeconds(2)) + .ackPolicy(AckPolicy.Explicit) + .build(); + ConsumerGroupConsumeContext ctx = StaticConsumerGroup.consume(nc, streamName, consumerGroupName, memberName, + msg -> { + String pid = msg.getPinnedId(); + try { + long seqNumber = msg.getMessage().metaData().streamSequence(); + System.out.printf("[%s] subject=%s, seq=%d, pinnedID=%s. Processing for %s ... ", + memberName, msg.getSubject(), seqNumber, pid, CliUtils.formatDuration(processingTime)); + Thread.sleep(processingTime.toMillis()); + msg.ackSync(Duration.ofSeconds(5)); + System.out.println("acked"); + } catch (Exception e) { + System.out.println("message could not be acked! (it will be or may already have been re-delivered): " + e.getMessage()); + } + }, + consumerConfig); + + // Wait for completion + try { + ctx.done().get(); + System.out.println("instance returned with no error"); + } catch (Exception e) { + System.out.println("instance returned with an error: " + e.getMessage()); + } + + return 0; + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + return 1; + } + } + } + + @Command(name = "prompt", description = "Interactive prompt mode", mixinStandardHelpOptions = true) + static class Prompt implements Callable { + @ParentCommand + private StaticCommands parent; + + @Override + public Integer call() { + try (Connection nc = CliUtils.connect(parent.parent.context)) { + return new PromptHandler(true, parent.parent.context, nc).run(); + } catch (Exception e) { + System.err.println("Error: " + e.getMessage()); + return 1; + } + } + } +} diff --git a/pcgroups/LICENSE b/pcgroups/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/pcgroups/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed 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. diff --git a/pcgroups/NOTICE b/pcgroups/NOTICE new file mode 100644 index 0000000..bd2b8ad --- /dev/null +++ b/pcgroups/NOTICE @@ -0,0 +1,5 @@ +Orbit Java +Copyright (c) 2024-2025 Synadia Communications Inc. All Rights Reserved. + +This product includes software developed at +Synadia Communications Inc. diff --git a/pcgroups/README.md b/pcgroups/README.md new file mode 100644 index 0000000..f175860 --- /dev/null +++ b/pcgroups/README.md @@ -0,0 +1,120 @@ +Orbit + +# Partitioned Consumer Groups + +![Artifact](https://img.shields.io/badge/Artifact-io.synadia:pcgroups-197556?labelColor=grey&style=flat) +![0.1.0](https://img.shields.io/badge/Current_Release-0.1.0-27AAE0) +![0.1.1](https://img.shields.io/badge/Current_Snapshot-0.1.1--SNAPSHOT-27AAE0) +[![Dependencies Help](https://img.shields.io/badge/Dependencies%20Help-27AAE0)](https://github.com/synadia-io/orbit.java?tab=readme-ov-file#dependencies) +[![javadoc](https://javadoc.io/badge2/io.synadia/pcgroups/javadoc.svg)](https://javadoc.io/doc/io.synadia/pcgroups) +[![Maven Central](https://img.shields.io/maven-central/v/io.synadia/pcgroups)](https://img.shields.io/maven-central/v/io.synadia/pcgroups) + +Initial implementation of a client-side partitioned consumer group feature for NATS streams leveraging some of the new features introduced in `nats-server` version 2.11. + +Note that post 2.11 versions of `nats-server` may include new features related to the consumer group use case that could render this client-side library unneeded (or make much smaller) + +# Overview + +This library enables the parallelization through partitioning of the consumption of messages from a stream while ensuring a strict order of not just delivery but also successful consumption of the messages using all or parts of the message's subject as a partitioning key. + +In JetStream terms, strictly ordered consumption is achieved when you set the consumer's 'max acks pending' value to 1. However, setting this on a JetStream consumer has the very unfortunate side effect of being very low throughput (limited by the network latency and processing speed) and not being horizontally scalable: only one message is being delivered and processed synchronously at a time from that JetStream consumer, no matter how many instances of the consuming application are deployed. + +The library allows the creation of 'consumer groups' on Stream, where each 'member' of the consumer group can consume from the group in parallel (with max acks pending 1 if needed), with the guarantee that in no way more than one message for a particular key can be consumed at the same time. Client applications wanting to consume messages from the group simply do so using a 'member name' and providing a callback. Even if more than one instance of a member is deployed, only one of those instances will be delivered messages at a time. + +The library takes care of the partitioning and the mapping of the partitions between the members of the group, the idea being that it is mostly transparent to the consuming application's developers who only need to join a consumer group, providing a member name and a callback to process and acknowledge the message when successfully processed. + +NATS Partitioned consumer groups come in two flavors: *elastic* and *static*. + +***Static*** partitioned consumer groups assume that the stream already has a partition number present as the first token of the message's subjects (something that can be done automatically when messages are stored into to the stream by setting a subject transform for the stream). You can only create and delete static consumer groups. Any change to the consumer group's config in the KV bucket will cause all the member instances for all members of the group to stop consuming. + +***Elastic*** partitioned consumer groups on the other hand are implemented differently: the stream doesn't need to already contain a partition number subject token and you can administratively add and drop members from the consumer group's config whenever you want without having to delete and re-create the consumer (like you have to with static consumer groups). You have the option of specifying a subject filter for the consumer group and calculating the partition number from the subject name using a consistent hashing algorithm. Either through the use of `*` wildcards in the partitioning filter(s) and then specifying in the partitioning wildcards array the indexes of the `*` wildcards in the filter that you want to use for computing the partition number (you can specify between one index and all of the indexes), or by leaving that array of wildcard indexes empty (or not specifying a partitioning filter at all) in which case the partition number is calculated using the entirety of the message's subject. + +***In both cases*** +In both cases you must specify when creating the consumer group the maximum number of members for the group (which is actually the number of partitions used when partitioning the messages), plus a list of "members" (named instances of the consuming application). The library takes care of distributing the members over the list of partitions using either a 'balanced' distribution (the partitions are evenly distributed between the members) or 'mappings' (where you assign administratively the mappings of partitions to the members). The membership list or mappings must be specified once at consumer group creation time for static consumer groups, but can be changed at any time for elastic consumer groups. + +Each consumer groups has a configuration which is stored in a KV bucket (named `static-consumer-groups` or `elastic-consumer-groups`). + +## Static + +Static consumer groups operate on a stream where the partition number has already been inserted in the subject as the first token of the messages. In this mode of operation, the library creates JetStream consumers (one per member of the group) directly on the stream. This is not elastic: you create the consumer with a list of members once, and you can not adjust that membership list or mapping for the life of the consumer group (if you want to change the mapping, up to you to delete and re-create the static partitioned consumer group, and to figure out which sequence number you may want this new static partitioned consumer group to start from). + +## Elastic + +Elastic consumer groups operate on any stream, the messages in the stream do not have the partition number present in their subjects. The membership list (or mapping) for the consumer can be adjusted administratively at any time and up to the max number of members defined initially. The consumer group library in this case creates a new work-queue stream that sources from the stream, inserting the partition number subject token on the way. The consumer group library takes care of creating this sourced stream and managing all the consumers on this stream according to the current membership, the developer only needs to provide a stream name, consumer group name and a member name and callback and make sure to ack the messages. You can specify (at creation time) a maximum size (in number of messages or bytes) for this working queue stream, but be aware that once this stream has reached its limit, it will pause the sourcing for at least 1 second (expecting messages to be consumed from the consumer group, thereby making room for more messages to be sourced) so you will want to set this value to more than 1 second's worth of message consumption by the clients of the consumer group or this could result in small delays in the consumption of messages from the consumer group. + +## High availability + +You can deploy and run multiple instances of the consuming application using the same member name, in that case only one of the running instances of the member will be 'pinned' and have messages delivered to it (thereby the other instances are effectively in hot standby). There are functions (`ElasticMemberStepDown()` and `StaticMemberStepDown`) to force a change of the currently pinned member instance. + +### The importance of AckWait for reactivity to faults + +The timers related to how quickly consumer group member instances react to faults (for example the currently pinned instance getting killed or suspended for an extended period of time) are related and derived from the AckWait value passed when joining the consumer group to consume messages, due to the limit of nats.go current implementation of `Consume`, if the AckWait value passed is less than 2 seconds _and_ the consumer group is caught up with the head of the stream, then you may see some (slow and harmless) flapping of the active instance for the members in the consumer group. If the AckWait value passed is 0 then the default AckWait value of 5 seconds is used. Note that in the case of static consumer groups without acknowledgements, you can adjust the Pinned TTL value by specifying an AckWait value in the ConsumerConfig you pass to static consume. + +## Using Partitioned Consumer Groups + +For the client application programmer, there is one basic functionality exposed by both static and elastic partitioned consumer groups: join and consume messages (when selected) from a named consumer group on a stream by specifying a _member name_, a regular JetStream consumer config, and a _callback_. The library takes care of stripping the partition number token from the subject such that you can use any existing callback code you may already have as is. + +There are also administrative functions to create and delete consumer groups, plus, in the case of elastic consumer groups only, the ability to add/drop members or to change the custom member to partition mappings on an existing elastic consumer group. + +## CLI + +Included is a small command line interface tool, named `cg` and located in the `cli` directory, that allows you to manage consumer groups, as well as test or demonstrate the functionality. + +This `cg` CLI tool can be used by passing it commands and arguments directly, or with an interactive prompt using the `prompt` command (e.g. `cg static prompt`). + +For more details on the CLI visit the [Partitioned Consumer Groups CLI Project](https://github.com/synadia-io/orbit.java/tree/main/pcgroups-cli) + +### Binaries +You can download the latest `cg.jar` archived in a tar file +[cg.tar](https://github.com/synadia-io/orbit.java/releases/download/pcgcli%2F0.1.0/cg.tar) +or zip file +[cg.zip](https://github.com/synadia-io/orbit.java/releases/download/pcgcli%2F0.1.0/cg.zip) + +## Demo walkthrough + +### Static + +Create a stream "foo" that automatically partitions over 10 partitions using `static_stream_setup.sh`, then generate some traffic (a new message every 10ms) for that stream using `generate_traffic.sh`. + +Create a static consumer group named "cg" on the stream in question, with two members defined called "m1" and "m2": `java -jar cg.jar static create balanced foo cg 10 '>' m1 m2` + +Start consuming messages with a simulated processing time of 20ms from an instance of member "m1": `java -jar cg.jar static consume foo cg m1 --sleep 25ms`. Run in another window cg again to consume as member m2 a second, run multiple instances of members m1 and m2, kill the active one (the one receiving messages) and watch as one of the other instances takes over. + +### Elastic + +Create a stream 'foo' that captures messages on the subjects `foo.*`, then generate some traffic (a new message every 10ms) for that stream using `generate_traffic.sh`. + +Create an elastic consumer group named "cg", partitioning over 10 partitions using the second token (first `*` wildcard in the filter "foo.*") in the subject as the partitioning key: `java -jar cg.jar elastic create foo cg 10`. + +At this point the elastic consumer group is created, but no members have been added to it yet. But you can start instances of your consuming members already (e.g. `java -jar cg.jar elastic consume foo cg m1` for an instance of a member "m1"), for example start instances of members "m1", "m2" and "m3". At this point none of those members are receiving messages. + +Add "m1" and "m2" to the membership: `java -jar cg.jar elastic add foo cg m1 m2`, see how they start receiving messages. Then drop "m1" from the membership `java -jar cg.jar elastic drop foo cg m1`, add it again, and each time watch as the consumer starts and stops receiving messages, run another consumer "m3" and add/drop it from the membership, etc... + +As soon as the elastic consumer group is created, you can start instances of consuming clients (e.g. `java -jar cg.jar elastic consume foo cg m1`), and they will start to consume messages as soon as (and as long as) they are in the group's membership. + +### Example + +To start consuming from a static consumer group, you call `StaticConsumerGroup.consume`. To start consuming from an elastic consumer group you call `ElasticConsumerGroup.consume`. These calls will return a `ConsumerGroupConsumeContext`. Assuming no exception is thrown,this will create a few threads to handle handles consumption and monitoring for changes in the consumer group's config. + +e.g. for static +```java +ConsumerGroupConsumeContext ctx = StaticConsumerGroup.consume(nc, streamName, consumerGroupName, memberName, messageHandler, consumerConfig); +``` +The arguments are: +- `nc` is a NATS connection object. +- `streamName` is the name of the Stream on which the consumer group has been created. +- `consumerGroupName` is the name of the consumer group that has been created on the stream. +- `memberName` is the name of the member you want to join the consumer group as. +- `messageHandler` is a callback function that gets invoked and passed the messages for consumption. Note that if you are using an elastic consumer group you _must_ explicitly acknowledge (positively or negatively) the message in your callback. +- `config` is a regular JetStream consumer config to use by the library as a template when actually creating the JetStream consumers. For elastic consumers the acknowledgement policy must be explicit. For static consumer groups, it doesn't have to, but if you want to do strictly one at a time processing, you will need to use explicit acks in order for max acks pending 1 to apply. Note that this configuration being used as template means that some of the values will be overwritten and can be left empty (e.g. names and durable names, filters, priority groups) or will be overwritten. Note that there is a relationship that must be maintained between the ack wait time, the consumer fetch time out, and the pinned TTL values to avoid 'flapping' of the pinned client so those timeout and TTL values are computed from the value of AckWait passed in the ConsumerConfig, and as such the failover time between instances of the same member are affected by the AckWait value. + +- The `ConsumerGroupConsumeContext` returned lets you stop the consumption and get a future that completes when the consumer stops (e.g. when explicitly stopped, or when the consumer group gets deleted) or an error occurs. + +You can look at the `cg` CLI tool's source code for examples of how to create and consume for both static and elastic consumer groups. +# Requirements + +Partitioned consumer groups require NATS server version 2.11 or above. + +--- +Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +See [LICENSE](LICENSE) and [NOTICE](NOTICE) file for details. \ No newline at end of file diff --git a/pcgroups/build.gradle b/pcgroups/build.gradle new file mode 100644 index 0000000..d672d1e --- /dev/null +++ b/pcgroups/build.gradle @@ -0,0 +1,158 @@ +import aQute.bnd.gradle.Bundle + +plugins { + id("java") + id("java-library") + id("maven-publish") + id("jacoco") + id("biz.aQute.bnd.builder") version "7.1.0" + id("org.gradle.test-retry") version "1.6.4" + id("io.github.gradle-nexus.publish-plugin") version "2.0.0" + id("signing") +} + +def jarVersion = "0.2.0" +group = 'io.synadia' + +def isRelease = System.getenv("BUILD_EVENT") == "release" + +def tc = System.getenv("TARGET_COMPATIBILITY"); +def targetCompat = tc == "21" ? JavaVersion.VERSION_21 : (tc == "17" ? JavaVersion.VERSION_17 : JavaVersion.VERSION_1_8) +def jarEnd = tc == "21" ? "-jdk21" : (tc == "17" ? "-jdk17" : "") +def jarAndArtifactName = "pcgroups" + jarEnd + +version = isRelease ? jarVersion : jarVersion + "-SNAPSHOT" // version is the variable the build actually uses. + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = targetCompat +} + +repositories { + mavenCentral() + maven { url="https://repo1.maven.org/maven2/" } + maven { url="https://central.sonatype.com/repository/maven-snapshots" } +} + +dependencies { + api 'io.nats:jnats:2.25.1' + api 'org.jspecify:jspecify:1.0.0' + + testImplementation 'io.nats:jnats-server-runner:3.1.0' + testImplementation 'org.junit.jupiter:junit-jupiter:5.14.1' + testImplementation 'org.junit.platform:junit-platform-launcher:1.14.3' +} + +tasks.register('bundle', Bundle) { + from sourceSets.main.output +} + +jar { + bundle { + bnd("Bundle-Name": "io.synadia.partitioned.consumer.groups", + "Bundle-Vendor": "synadia.io", + "Bundle-Description": "NATS JetStream Partitioned Consumer Groups Library for Java", + "Bundle-DocURL": "https://github.com/synadia-io/orbit.java/tree/main/pcgroups", + "Target-Compatibility": "Java " + targetCompat + ) + } +} + +test { + useJUnitPlatform() +} + +javadoc { + options.overview = 'src/main/javadoc/overview.html' // relative to source root + source = sourceSets.main.allJava + title = "Synadia Communications Inc. NATS JetStream Partitioned Consumer Groups" + classpath = sourceSets.main.runtimeClasspath +} + +tasks.register('javadocJar', Jar) { + archiveClassifier.set('javadoc') + from javadoc +} + +tasks.register('sourcesJar', Jar) { + archiveClassifier.set('sources') + from sourceSets.main.allSource +} + +artifacts { + archives javadocJar, sourcesJar +} + +jacoco { + toolVersion = "0.8.12" +} + +jacocoTestReport { + reports { + xml.required = true // coveralls plugin depends on xml format report + html.required = true + } + afterEvaluate { // only report on main library not examples + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, + exclude: ['**/examples**','**/Debug**']) + })) + } +} + +nexusPublishing { + repositories { + sonatype { + nexusUrl.set(uri("https://ossrh-staging-api.central.sonatype.com/service/local/")) + snapshotRepositoryUrl.set(uri("https://central.sonatype.com/repository/maven-snapshots/")) + username = System.getenv('OSSRH_USERNAME') + password = System.getenv('OSSRH_PASSWORD') + } + } +} + +publishing { + publications { + mavenJava(MavenPublication) { + from components.java + artifact sourcesJar + artifact javadocJar + pom { + name = jarAndArtifactName + packaging = 'jar' + groupId = group + artifactId = jarAndArtifactName + description = "Synadia Communications Inc. NATS JetStream Partitioned Consumer Groups" + url = "https://github.com/synadia-io/orbit.java/tree/main/pcgroups" + licenses { + license { + name = 'The Apache License, Version 2.0' + url = 'http://www.apache.org/licenses/LICENSE-2.0.txt' + } + } + developers { + developer { + id = "synadia" + name = "Synadia" + email = "info@synadia.com" + url = "https://synadia.io" + } + } + scm { + url = "https://github.com/synadia-io/orbit.java" + } + } + } + } +} + +if (isRelease) { + signing { + def signingKeyId = System.getenv('SIGNING_KEY_ID') + def signingKey = System.getenv('SIGNING_KEY') + def signingPassword = System.getenv('SIGNING_PASSWORD') + useInMemoryPgpKeys(signingKeyId, signingKey, signingPassword) + sign configurations.archives + sign publishing.publications.mavenJava + } +} diff --git a/pcgroups/gradle/libs.versions.toml b/pcgroups/gradle/libs.versions.toml new file mode 100644 index 0000000..2cfe86a --- /dev/null +++ b/pcgroups/gradle/libs.versions.toml @@ -0,0 +1,12 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format + +[versions] +commons-math3 = "3.6.1" +guava = "33.4.5-jre" +junit-jupiter = "5.12.1" + +[libraries] +commons-math3 = { module = "org.apache.commons:commons-math3", version.ref = "commons-math3" } +guava = { module = "com.google.guava:guava", version.ref = "guava" } +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" } diff --git a/pcgroups/gradle/wrapper/gradle-wrapper.jar b/pcgroups/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/pcgroups/gradle/wrapper/gradle-wrapper.jar differ diff --git a/pcgroups/gradle/wrapper/gradle-wrapper.properties b/pcgroups/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ca025c8 --- /dev/null +++ b/pcgroups/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/pcgroups/gradlew b/pcgroups/gradlew new file mode 100644 index 0000000..23d15a9 --- /dev/null +++ b/pcgroups/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed 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 +# +# https://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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/pcgroups/gradlew.bat b/pcgroups/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/pcgroups/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/pcgroups/scripts/elastic_stream_setup.sh b/pcgroups/scripts/elastic_stream_setup.sh new file mode 100755 index 0000000..618a003 --- /dev/null +++ b/pcgroups/scripts/elastic_stream_setup.sh @@ -0,0 +1,2 @@ +#!/bin/bash +nats stream add foo --subjects="foo.*" --defaults \ No newline at end of file diff --git a/pcgroups/scripts/generate_traffic.sh b/pcgroups/scripts/generate_traffic.sh new file mode 100755 index 0000000..fd2a0c8 --- /dev/null +++ b/pcgroups/scripts/generate_traffic.sh @@ -0,0 +1,2 @@ +#!/bin/bash +nats bench js pub async foo --multisubject --sleep 10ms --multisubjectmax 100 --stream foo diff --git a/pcgroups/scripts/static_stream_setup.sh b/pcgroups/scripts/static_stream_setup.sh new file mode 100755 index 0000000..bf558c5 --- /dev/null +++ b/pcgroups/scripts/static_stream_setup.sh @@ -0,0 +1,2 @@ +#!/bin/bash +nats stream add foo --subjects="foo.*" --transform-source="foo.*" --transform-destination="{{partition(10,1)}}.foo.{{wildcard(1)}}" --defaults \ No newline at end of file diff --git a/pcgroups/settings.gradle b/pcgroups/settings.gradle new file mode 100644 index 0000000..6d41f45 --- /dev/null +++ b/pcgroups/settings.gradle @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + maven { url="https://repo1.maven.org/maven2/" } + maven { url="https://central.sonatype.com/repository/maven-snapshots/" } + maven { url="https://plugins.gradle.org/m2/" } + } + plugins { + id("biz.aQute.bnd.builder") version "7.1.0" + } +} +rootProject.name = 'pcgroups' + +include 'pcgroups-cli' +project(':pcgroups-cli').projectDir = file('../pcgroups-cli') diff --git a/pcgroups/src/main/java/io/synadia/pcg/ConsumerGroupConsumeContext.java b/pcgroups/src/main/java/io/synadia/pcg/ConsumerGroupConsumeContext.java new file mode 100644 index 0000000..05339db --- /dev/null +++ b/pcgroups/src/main/java/io/synadia/pcg/ConsumerGroupConsumeContext.java @@ -0,0 +1,33 @@ +// Copyright 2024-2025 Synadia Communications Inc. +// Licensed 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 io.synadia.pcg; + +import java.util.concurrent.CompletableFuture; + +/** + * Interface for controlling the consume lifecycle of a consumer group member. + */ +public interface ConsumerGroupConsumeContext { + + /** + * Stops consuming messages. + */ + void stop(); + + /** + * Returns a future that completes when the consumer stops or an error occurs. + * The future completes with an exception if an error occurred. + */ + CompletableFuture done(); +} diff --git a/pcgroups/src/main/java/io/synadia/pcg/ConsumerGroupMsg.java b/pcgroups/src/main/java/io/synadia/pcg/ConsumerGroupMsg.java new file mode 100644 index 0000000..e3501a6 --- /dev/null +++ b/pcgroups/src/main/java/io/synadia/pcg/ConsumerGroupMsg.java @@ -0,0 +1,140 @@ +// Copyright 2024-2025 Synadia Communications Inc. +// Licensed 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 io.synadia.pcg; + +import io.nats.client.Message; +import io.nats.client.impl.Headers; + +import java.io.IOException; +import java.time.Duration; + +/** + * Wrapper for JetStream messages that strips the partition number from the subject. + */ +public class ConsumerGroupMsg { + + private final Message msg; + + public ConsumerGroupMsg(Message msg) { + this.msg = msg; + } + + /** + * Returns the message body data. + */ + public byte[] getData() { + return msg.getData(); + } + + /** + * Returns the message headers. + */ + public Headers getHeaders() { + return msg.getHeaders(); + } + + /** + * Returns the subject with the partition number prefix stripped. + * The original subject format is "{partitionNumber}.{originalSubject}". + */ + public String getSubject() { + String subject = msg.getSubject(); + if (subject == null) { + return null; + } + int dotIndex = subject.indexOf('.'); + if (dotIndex >= 0 && dotIndex < subject.length() - 1) { + return subject.substring(dotIndex + 1); + } + return subject; + } + + /** + * Returns the original subject including partition prefix. + */ + public String getRawSubject() { + return msg.getSubject(); + } + + /** + * Returns the reply subject for the message. + */ + public String getReplyTo() { + return msg.getReplyTo(); + } + + /** + * Returns the underlying message for metadata access. + */ + public Message getMessage() { + return msg; + } + + /** + * Acknowledges the message. + * This tells the server that the message was successfully processed. + */ + public void ack() throws IOException, InterruptedException { + msg.ack(); + } + + /** + * Acknowledges the message and waits for acknowledgment from server. + */ + public void ackSync(Duration timeout) throws IOException, InterruptedException, java.util.concurrent.TimeoutException { + msg.ackSync(timeout); + } + + /** + * Negatively acknowledges the message. + * This tells the server to redeliver the message. + */ + public void nak() throws IOException, InterruptedException { + msg.nak(); + } + + /** + * Negatively acknowledges the message with a delay. + * This tells the server to redeliver the message after the specified delay. + */ + public void nakWithDelay(Duration delay) throws IOException, InterruptedException { + msg.nakWithDelay(delay); + } + + /** + * Tells the server that this message is being worked on. + * Resets the redelivery timer on the server. + */ + public void inProgress() throws IOException, InterruptedException { + msg.inProgress(); + } + + /** + * Tells the server to not redeliver this message, regardless of MaxDeliver setting. + */ + public void term() throws IOException, InterruptedException { + msg.term(); + } + + /** + * Returns the pinned ID from message headers if present. + */ + public String getPinnedId() { + Headers headers = msg.getHeaders(); + if (headers != null) { + return headers.getFirst("Nats-Pin-Id"); + } + return null; + } +} diff --git a/pcgroups/src/main/java/io/synadia/pcg/ElasticConsumerGroup.java b/pcgroups/src/main/java/io/synadia/pcg/ElasticConsumerGroup.java new file mode 100644 index 0000000..5ef23ca --- /dev/null +++ b/pcgroups/src/main/java/io/synadia/pcg/ElasticConsumerGroup.java @@ -0,0 +1,944 @@ +// Copyright 2024-2025 Synadia Communications Inc. +// Licensed 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 io.synadia.pcg; + +import io.nats.client.*; +import io.nats.client.api.*; +import io.nats.client.impl.Headers; +import io.synadia.pcg.exceptions.ConsumerGroupException; + +import java.io.IOException; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.logging.Logger; + +import static io.synadia.pcg.PartitionUtils.*; +import static io.synadia.pcg.PartitioningFilter.EVERYTHING; + +/** + * Elastic consumer group implementation. + * Provides dynamic partition assignment for consumer groups with a work queue stream. + */ +public class ElasticConsumerGroup { + + private static final Logger LOGGER = Logger.getLogger(ElasticConsumerGroup.class.getName()); + + private ElasticConsumerGroup() { + // Utility class + } + + /** + * Creates an elastic consumer group. + * + * @param nc NATS connection + * @param streamName Name of the source stream + * @param consumerGroupName Name of the consumer group + * @param maxMembers Maximum number of members (partitions) + * @param partitioningFilters List of partitioning filters + * @param maxBufferedMessages Max messages in work queue (0 for unlimited) + * @param maxBufferedBytes Max bytes in work queue (0 for unlimited) + * @return The created configuration + */ + public static ElasticConsumerGroupConfig create(Connection nc, String streamName, String consumerGroupName, + int maxMembers, List partitioningFilters, + long maxBufferedMessages, long maxBufferedBytes) + throws ConsumerGroupException, IOException, JetStreamApiException, InterruptedException { + + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( + maxMembers, partitioningFilters, maxBufferedMessages, maxBufferedBytes, + new ArrayList<>(), new ArrayList<>()); + config.validate(); + + JetStreamManagement jsm = nc.jetStreamManagement(); + + // Get stream info to determine replicas and storage type + StreamInfo streamInfo = jsm.getStreamInfo(streamName); + int replicas = streamInfo.getConfiguration().getReplicas(); + StorageType storage = streamInfo.getConfiguration().getStorageType(); + + // Get or create the KV bucket + KeyValueManagement kvm = nc.keyValueManagement(); + KeyValue kv; + try { + kv = nc.keyValue(KV_ELASTIC_BUCKET_NAME); + } catch (Exception e) { + // Create the bucket if it doesn't exist + KeyValueConfiguration kvConfig = KeyValueConfiguration.builder() + .name(KV_ELASTIC_BUCKET_NAME) + .replicas(replicas) + .storageType(StorageType.File) + .build(); + kvm.create(kvConfig); + kv = nc.keyValue(KV_ELASTIC_BUCKET_NAME); + } + + String key = composeKey(streamName, consumerGroupName); + + // Check if config already exists + ElasticConsumerGroupConfig existingConfig = ElasticConsumerGroupConfig.instance(kv.get(key)); + if (existingConfig != null) { + if (existingConfig.getMaxMembers() != maxMembers || + !Objects.equals(existingConfig.getPartitioningFilters(), partitioningFilters) || + existingConfig.getMaxBufferedMessages() != maxBufferedMessages || + existingConfig.getMaxBufferedBytes() != maxBufferedBytes) { + throw new ConsumerGroupException( + "the existing elastic consumer group config can not be updated to the requested one, " + + "please delete the existing elastic consumer group and create a new one"); + } + return existingConfig; + } + + // Create the config entry + kv.put(key, config.serialize()); + + // Create the work queue stream with subject transform + String workQueueStreamName = composeCGSName(streamName, consumerGroupName); + + StreamConfiguration.Builder scBuilder = StreamConfiguration.builder() + .name(workQueueStreamName) + .retentionPolicy(RetentionPolicy.WorkQueue) + .replicas(replicas) + .storageType(storage) + .discardPolicy(DiscardPolicy.New) + .allowDirect(true); + + if (maxBufferedMessages > 0) { + scBuilder.maxMessages(maxBufferedMessages); + } + if (maxBufferedBytes > 0) { + scBuilder.maxBytes(maxBufferedBytes); + } + + // Add source with subject transforms + List subjectTransforms = new ArrayList<>(); + if (partitioningFilters != null && !partitioningFilters.isEmpty()) { + for (PartitioningFilter pf : partitioningFilters) { + subjectTransforms.add(SubjectTransform.builder() + .source(pf.getFilter()) + .destination(pf.getPartitioningTransformDest(maxMembers)) + .build()); + } + } else { + subjectTransforms.add(SubjectTransform.builder() + .source(">") + .destination(EVERYTHING.getPartitioningTransformDest(maxMembers)) + .build()); + } + + scBuilder.addSource(Source.builder() + .sourceName(streamName) + .startSeq(0) + .subjectTransforms(subjectTransforms.toArray(new SubjectTransform[0])) + .build()); + + try { + jsm.addStream(scBuilder.build()); + } catch (JetStreamApiException e) { + throw new ConsumerGroupException("can't create the elastic consumer group's stream: " + e.getMessage(), e); + } + + return config; + } + + /** + * Starts consuming messages from an elastic consumer group. + * + * @param nc NATS connection + * @param streamName Name of the source stream + * @param consumerGroupName Name of the consumer group + * @param memberName Name of this member + * @param handler Message handler callback + * @param consumerConfig Consumer configuration (null for defaults) + * @return A consume context for controlling the consumption lifecycle + */ + public static ConsumerGroupConsumeContext consume(Connection nc, String streamName, String consumerGroupName, + String memberName, Consumer handler, + ConsumerConfiguration consumerConfig) + throws ConsumerGroupException, IOException, JetStreamApiException, InterruptedException { + if (handler == null) { + throw new ConsumerGroupException("a message handler must be provided"); + } + + if (consumerConfig == null) { + consumerConfig = ConsumerConfiguration.builder() + .ackWait(DEFAULT_ACK_WAIT) + .ackPolicy(AckPolicy.Explicit) + .build(); + } + + if (consumerConfig.getAckPolicy() != null && consumerConfig.getAckPolicy() != AckPolicy.Explicit) { + throw new ConsumerGroupException("the ack policy when consuming from elastic consumer groups must be explicit"); + } + + if (consumerConfig.getAckWait() == null || consumerConfig.getAckWait().isZero() || consumerConfig.getAckWait().isNegative()) { + consumerConfig = ConsumerConfiguration.builder(consumerConfig).ackWait(DEFAULT_ACK_WAIT).build(); + } + + consumerConfig = ConsumerConfiguration.builder(consumerConfig) + .inactiveThreshold(Duration.ofMillis(consumerConfig.getAckWait().toMillis() * CONSUMER_IDLE_TIMEOUT_FACTOR)) + .build(); + + // Verify the work queue stream exists + JetStreamManagement jsm = nc.jetStreamManagement(); + String workQueueStreamName = composeCGSName(streamName, consumerGroupName); + try { + jsm.getStreamInfo(workQueueStreamName); + } catch (JetStreamApiException e) { + throw new ConsumerGroupException("the elastic consumer group's stream does not exist", e); + } + + // Get the KV bucket + KeyValue kv; + try { + kv = nc.keyValue(KV_ELASTIC_BUCKET_NAME); + } catch (Exception e) { + throw new ConsumerGroupException("the elastic consumer group KV bucket doesn't exist", e); + } + + // Get the config + ElasticConsumerGroupConfig config = getConfigFromKV(kv, streamName, consumerGroupName); + + return new ElasticConsumeContextImpl(nc, kv, streamName, consumerGroupName, memberName, config, handler, consumerConfig); + } + + /** + * Deletes an elastic consumer group. + */ + public static void delete(Connection nc, String streamName, String consumerGroupName) + throws IOException, JetStreamApiException, InterruptedException, ConsumerGroupException { + JetStreamManagement jsm = nc.jetStreamManagement(); + + // Get the KV bucket and delete the config entry + try { + KeyValue kv = nc.keyValue(KV_ELASTIC_BUCKET_NAME); + String key = composeKey(streamName, consumerGroupName); + kv.delete(key); + } catch (Exception e) { + // Ignore if bucket or key doesn't exist + } + + // Delete the work queue stream + String workQueueStreamName = composeCGSName(streamName, consumerGroupName); + try { + jsm.deleteStream(workQueueStreamName); + } catch (JetStreamApiException e) { + throw new ConsumerGroupException("could not delete the elastic consumer group's stream: " + e.getMessage(), e); + } + } + + /** + * Lists elastic consumer groups for a stream. + */ + public static List list(Connection nc, String streamName) + throws IOException, JetStreamApiException, InterruptedException { + KeyValue kv; + try { + kv = nc.keyValue(KV_ELASTIC_BUCKET_NAME); + } catch (Exception e) { + return new ArrayList<>(); + } + + List keys = kv.keys(); + List consumerGroupNames = new ArrayList<>(); + + for (String key : keys) { + String[] parts = key.split("\\."); + if (parts.length >= 2 && parts[0].equals(streamName)) { + consumerGroupNames.add(parts[1]); + } + } + + return consumerGroupNames; + } + + /** + * Adds members to an elastic consumer group. + * + * @return The updated list of members + */ + public static List addMembers(Connection nc, String streamName, String consumerGroupName, + List memberNamesToAdd) + throws IOException, JetStreamApiException, InterruptedException, ConsumerGroupException { + if (streamName == null || streamName.isEmpty() || + consumerGroupName == null || consumerGroupName.isEmpty() || + memberNamesToAdd == null || memberNamesToAdd.isEmpty()) { + throw new ConsumerGroupException("invalid stream name or elastic consumer group name or no member names"); + } + + KeyValue kv; + try { + kv = nc.keyValue(KV_ELASTIC_BUCKET_NAME); + } catch (Exception e) { + throw new ConsumerGroupException("the elastic consumer group KV bucket doesn't exist", e); + } + + ElasticConsumerGroupConfig config = getConfigFromKV(kv, streamName, consumerGroupName); + + if (!config.getMemberMappings().isEmpty()) { + throw new ConsumerGroupException("can't add members to an elastic consumer group that uses member mappings"); + } + + Set existingMembers = new LinkedHashSet<>(config.getMembers()); + for (String memberName : memberNamesToAdd) { + if (memberName != null && !memberName.isEmpty()) { + existingMembers.add(memberName); + } + } + + List newMembers = new ArrayList<>(existingMembers); + config.setMembers(newMembers); + + String key = composeKey(streamName, consumerGroupName); + kv.update(key, config.serialize(), config.getRevision()); + + return newMembers; + } + + /** + * Removes members from an elastic consumer group. + * + * @return The updated list of members + */ + public static List deleteMembers(Connection nc, String streamName, String consumerGroupName, + List memberNamesToDrop) + throws IOException, JetStreamApiException, InterruptedException, ConsumerGroupException { + if (streamName == null || streamName.isEmpty() || + consumerGroupName == null || consumerGroupName.isEmpty() || + memberNamesToDrop == null || memberNamesToDrop.isEmpty()) { + throw new ConsumerGroupException("invalid stream name or elastic consumer group name or no member names"); + } + + KeyValue kv; + try { + kv = nc.keyValue(KV_ELASTIC_BUCKET_NAME); + } catch (Exception e) { + throw new ConsumerGroupException("the elastic consumer group KV bucket doesn't exist", e); + } + + ElasticConsumerGroupConfig config = getConfigFromKV(kv, streamName, consumerGroupName); + + if (!config.getMemberMappings().isEmpty()) { + throw new ConsumerGroupException("can't drop members from an elastic consumer group that uses member mappings"); + } + + Set droppingMembers = new HashSet<>(memberNamesToDrop); + List newMembers = new ArrayList<>(); + + for (String existingMember : config.getMembers()) { + if (!droppingMembers.contains(existingMember)) { + newMembers.add(existingMember); + } + } + + config.setMembers(newMembers); + + String key = composeKey(streamName, consumerGroupName); + kv.update(key, config.serialize(), config.getRevision()); + + return newMembers; + } + + /** + * Sets member mappings for an elastic consumer group. + */ + public static void setMemberMappings(Connection nc, String streamName, String consumerGroupName, + List memberMappings) + throws IOException, JetStreamApiException, InterruptedException, ConsumerGroupException { + if (streamName == null || streamName.isEmpty() || + consumerGroupName == null || consumerGroupName.isEmpty() || + memberMappings == null || memberMappings.isEmpty()) { + throw new ConsumerGroupException("invalid stream name or elastic consumer group name or member mappings"); + } + + KeyValue kv; + try { + kv = nc.keyValue(KV_ELASTIC_BUCKET_NAME); + } catch (Exception e) { + throw new ConsumerGroupException("the elastic consumer group KV bucket doesn't exist", e); + } + + ElasticConsumerGroupConfig config = getConfigFromKV(kv, streamName, consumerGroupName); + + config.setMembers(new ArrayList<>()); + config.setMemberMappings(memberMappings); + config.validate(); + + String key = composeKey(streamName, consumerGroupName); + kv.put(key, config.serialize()); + } + + /** + * Deletes member mappings for an elastic consumer group. + */ + public static void deleteMemberMappings(Connection nc, String streamName, String consumerGroupName) + throws IOException, JetStreamApiException, InterruptedException, ConsumerGroupException { + if (streamName == null || streamName.isEmpty() || + consumerGroupName == null || consumerGroupName.isEmpty()) { + throw new ConsumerGroupException("invalid stream name or elastic consumer group name"); + } + + KeyValue kv; + try { + kv = nc.keyValue(KV_ELASTIC_BUCKET_NAME); + } catch (Exception e) { + throw new ConsumerGroupException("the elastic consumer group KV bucket doesn't exist", e); + } + + ElasticConsumerGroupConfig config = getConfigFromKV(kv, streamName, consumerGroupName); + + config.setMemberMappings(new ArrayList<>()); + + String key = composeKey(streamName, consumerGroupName); + kv.put(key, config.serialize()); + } + + /** + * Lists active members of an elastic consumer group. + */ + public static List listActiveMembers(Connection nc, String streamName, String consumerGroupName) + throws IOException, JetStreamApiException, InterruptedException, ConsumerGroupException { + KeyValue kv; + try { + kv = nc.keyValue(KV_ELASTIC_BUCKET_NAME); + } catch (Exception e) { + throw new ConsumerGroupException("the elastic consumer group KV bucket doesn't exist", e); + } + + ElasticConsumerGroupConfig config = getConfigFromKV(kv, streamName, consumerGroupName); + + if (config.getMembers().isEmpty() && config.getMemberMappings().isEmpty()) { + return new ArrayList<>(); + } + + JetStreamManagement jsm = nc.jetStreamManagement(); + String workQueueStreamName = composeCGSName(streamName, consumerGroupName); + + List activeMembers = new ArrayList<>(); + List consumers = jsm.getConsumers(workQueueStreamName); + + List memberList = config.getMembers(); + List mappings = config.getMemberMappings(); + + for (ConsumerInfo cInfo : consumers) { + if (!memberList.isEmpty()) { + for (String m : memberList) { + if (cInfo.getName().equals(m)) { + activeMembers.add(m); + break; + } + } + } else if (!mappings.isEmpty()) { + for (MemberMapping mapping : mappings) { + if (cInfo.getName().equals(mapping.getMember())) { + activeMembers.add(mapping.getMember()); + break; + } + } + } + } + + return activeMembers; + } + + /** + * Checks if a member is included in the elastic consumer group and is active. + * + * @return A boolean array where [0] = inMembership, [1] = isActive + */ + public static boolean[] isInMembershipAndActive(Connection nc, String streamName, String consumerGroupName, + String memberName) + throws IOException, JetStreamApiException, InterruptedException, ConsumerGroupException { + KeyValue kv; + try { + kv = nc.keyValue(KV_ELASTIC_BUCKET_NAME); + } catch (Exception e) { + throw new ConsumerGroupException("the elastic consumer group KV bucket doesn't exist", e); + } + + ElasticConsumerGroupConfig config = getConfigFromKV(kv, streamName, consumerGroupName); + + boolean inMembership = config.isInMembership(memberName); + + JetStreamManagement jsm = nc.jetStreamManagement(); + String workQueueStreamName = composeCGSName(streamName, consumerGroupName); + + boolean isActive = false; + List consumers = jsm.getConsumers(workQueueStreamName); + + for (ConsumerInfo cInfo : consumers) { + if (cInfo.getName().equals(memberName)) { + isActive = true; + break; + } + } + + return new boolean[]{inMembership, isActive}; + } + + /** + * Forces the current active (pinned) instance for a member to step down. + * This requires NATS server 2.11+ with priority consumer support. + */ + public static void memberStepDown(Connection nc, String streamName, String consumerGroupName, String memberName) + throws IOException, JetStreamApiException, InterruptedException { + JetStreamManagement jsm = nc.jetStreamManagement(); + String workQueueStreamName = composeCGSName(streamName, consumerGroupName); + jsm.unpinConsumer(workQueueStreamName, memberName, PRIORITY_GROUP_NAME); + } + + /** + * Gets the elastic consumer group configuration. + */ + public static ElasticConsumerGroupConfig getConfig(Connection nc, String streamName, String consumerGroupName) + throws IOException, JetStreamApiException, InterruptedException, ConsumerGroupException { + KeyValue kv; + try { + kv = nc.keyValue(KV_ELASTIC_BUCKET_NAME); + } catch (Exception e) { + throw new ConsumerGroupException("the elastic consumer group KV bucket doesn't exist", e); + } + + return getConfigFromKV(kv, streamName, consumerGroupName); + } + + /** + * Returns the list of partition filters for a given member based on the config. + */ + public static List getPartitionFilters(ElasticConsumerGroupConfig config, String memberName) { + List filters = new ArrayList<>(); + if (!config.getPartitioningFilters().isEmpty()) { + for (PartitioningFilter pf : config.getPartitioningFilters()) { + filters.addAll(PartitionUtils.generatePartitionFilters( + config.getMembers(), config.getMaxMembers(), config.getMemberMappings(), memberName, pf.getFilter())); + } + } else { + filters.addAll(PartitionUtils.generatePartitionFilters( + config.getMembers(), config.getMaxMembers(), config.getMemberMappings(), memberName)); + } + return filters; + } + + private static ElasticConsumerGroupConfig getConfigFromKV(KeyValue kv, String streamName, String consumerGroupName) + throws IOException, JetStreamApiException, InterruptedException, ConsumerGroupException { + if (streamName == null || streamName.isEmpty() || + consumerGroupName == null || consumerGroupName.isEmpty()) { + throw new ConsumerGroupException("invalid stream name or elastic consumer group name"); + } + + String key = composeKey(streamName, consumerGroupName); + ElasticConsumerGroupConfig config = ElasticConsumerGroupConfig.instance(kv.get(key)); + if (config == null) { + throw new ConsumerGroupException("error getting the elastic consumer group's config: not found"); + } + config.validate(); + return config; + } + + /** + * Composes the Consumer Group Stream Name. + */ + private static String composeCGSName(String streamName, String consumerGroupName) { + return streamName + "-" + consumerGroupName; + } + + /** + * Internal implementation of the consume context for elastic consumer groups. + * Uses a single event-processing thread (matching Go's instanceRoutine pattern) + * to serialize watcher updates and self-correction, avoiding race conditions. + */ + private static class ElasticConsumeContextImpl implements ConsumerGroupConsumeContext { + // NATS API error code for "filtered consumer not unique on workqueue stream" + private static final int JS_CONSUMER_WQ_CONSUMER_NOT_UNIQUE_ERR = 10100; + + private final Connection nc; + private final KeyValue kv; + private final String streamName; + private final String consumerGroupName; + private final String memberName; + private ElasticConsumerGroupConfig config; + private final Consumer handler; + private final ConsumerConfiguration consumerUserConfig; + private final CompletableFuture doneFuture; + private final AtomicBoolean stopped; + private final AtomicReference currentPinnedId; + private final long selfCorrectionIntervalMs; + + // Event queue for serializing watcher updates (like Go's keyWatcher.Updates() channel) + private final LinkedBlockingQueue eventQueue; + + // Single thread that processes all events (like Go's instanceRoutine goroutine) + private Thread instanceThread; + + // Consumer state - only accessed from instanceThread (no synchronization needed) + private MessageConsumer messageConsumer; + private io.nats.client.impl.NatsKeyValueWatchSubscription watchSubscription; + + ElasticConsumeContextImpl(Connection nc, KeyValue kv, String streamName, String consumerGroupName, + String memberName, ElasticConsumerGroupConfig config, + Consumer handler, ConsumerConfiguration consumerUserConfig) + throws IOException, JetStreamApiException, InterruptedException, ConsumerGroupException { + this.nc = nc; + this.kv = kv; + this.streamName = streamName; + this.consumerGroupName = consumerGroupName; + this.memberName = memberName; + this.config = config; + this.handler = handler; + this.consumerUserConfig = consumerUserConfig; + this.doneFuture = new CompletableFuture<>(); + this.stopped = new AtomicBoolean(false); + this.currentPinnedId = new AtomicReference<>(""); + this.eventQueue = new LinkedBlockingQueue<>(); + this.selfCorrectionIntervalMs = consumerUserConfig.getAckWait().toMillis() * CONSUMER_IDLE_TIMEOUT_FACTOR + 500; + + // Join if already in membership (before starting the routine, matching Go) + if (config.isInMembership(memberName)) { + joinMemberConsumer(); + } + + startWatcher(); + startInstanceRoutine(); + } + + /** + * Sets up the KV watcher that enqueues events to the event queue. + * The watcher callback does NO processing - it just enqueues, keeping the + * NATS dispatch thread unblocked. + */ + private void startWatcher() { + Thread watcherThread = new Thread(() -> { + try { + String key = composeKey(streamName, consumerGroupName); + KeyValueWatcher watcher = new KeyValueWatcher() { + @Override + public void watch(KeyValueEntry entry) { + if (!stopped.get()) { + eventQueue.offer(entry); + } + } + + @Override + public void endOfData() { + // Initial data load complete + } + }; + + watchSubscription = kv.watch(key, watcher); + + } catch (Exception e) { + if (!stopped.get()) { + doneFuture.completeExceptionally(e); + } + } + }); + watcherThread.setDaemon(true); + watcherThread.start(); + } + + /** + * Starts the single instance routine thread that processes all events. + * This matches Go's instanceRoutine goroutine with its select loop: + * - Watcher updates from the event queue + * - Self-correction via queue poll timeout + * - Stop via the stopped flag + */ + private void startInstanceRoutine() { + instanceThread = new Thread(() -> { + while (!stopped.get()) { + try { + // Poll with timeout - timeout acts as self-correction timer + // (like Go's time.After case in the select loop) + KeyValueEntry entry = eventQueue.poll(selfCorrectionIntervalMs, TimeUnit.MILLISECONDS); + + if (stopped.get()) { + break; + } + + if (entry == null) { + // Timeout = self-correction (like Go's time.After case) + if (messageConsumer == null && config.isInMembership(memberName)) { + joinMemberConsumer(); + } + } else { + // Watcher update (like Go's keyWatcher.Updates() case) + processWatcherUpdate(entry); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + + // Cleanup on exit + stopConsuming(); + doneFuture.complete(null); + }); + instanceThread.setDaemon(true); + instanceThread.start(); + } + + /** + * Processes a single watcher update event. + * Matches the watcher update case in Go's instanceRoutine select loop. + */ + private void processWatcherUpdate(KeyValueEntry entry) { + if (entry.getOperation() == KeyValueOperation.DELETE || + entry.getOperation() == KeyValueOperation.PURGE) { + stopConsuming(); + stopped.set(true); + doneFuture.complete(null); + return; + } + + try { + ElasticConsumerGroupConfig newConfig = ElasticConsumerGroupConfig.instance(entry); + newConfig.validate(); + + // Check if critical config changed (immutable fields) + if (newConfig.getMaxMembers() != config.getMaxMembers() || + !Objects.equals(newConfig.getPartitioningFilters(), config.getPartitioningFilters()) || + newConfig.getMaxBufferedMessages() != config.getMaxBufferedMessages() || + newConfig.getMaxBufferedBytes() != config.getMaxBufferedBytes()) { + stopConsuming(); + stopped.set(true); + doneFuture.completeExceptionally( + new ConsumerGroupException("elastic consumer group config watcher received a bad change in the configuration")); + return; + } + + // Optimization: if nothing changed and already have the consumer, skip + // (matches Go's optimization at instanceRoutine line 203-206) + if (messageConsumer != null && + Objects.equals(newConfig.getMembers(), config.getMembers()) && + Objects.equals(newConfig.getMemberMappings(), config.getMemberMappings())) { + return; + } + + // Check if members or mappings changed + if (!Objects.equals(newConfig.getMembers(), config.getMembers()) || + !Objects.equals(newConfig.getMemberMappings(), config.getMemberMappings())) { + config = newConfig; + processMembershipChange(); + } + + } catch (ConsumerGroupException e) { + stopConsuming(); + stopped.set(true); + doneFuture.completeExceptionally(e); + } catch (Exception e) { + LOGGER.warning("Error processing watcher update: " + e.getMessage()); + } + } + + /** + * Processes membership changes. Matches Go's processMembershipChange. + * Only called from instanceThread - no synchronization needed. + */ + private void processMembershipChange() { + // Check if we are the pinned member before stopping + boolean isPinned = false; + if (messageConsumer != null) { + try { + JetStreamManagement jsm = nc.jetStreamManagement(); + String workQueueStreamName = composeCGSName(streamName, consumerGroupName); + ConsumerInfo ci = jsm.getConsumerInfo(workQueueStreamName, memberName); + List pgStates = ci.getPriorityGroupStates(); + if (pgStates != null) { + String myPinnedId = currentPinnedId.get(); + for (PriorityGroupState pg : pgStates) { + if (PRIORITY_GROUP_NAME.equals(pg.getGroup()) && + myPinnedId != null && myPinnedId.equals(pg.getPinnedClientId())) { + isPinned = true; + break; + } + } + } + } catch (Exception e) { + // Ignore - consumer may not exist yet + } + // Stop the message consumer (matching Go's stopConsuming) + stopMessageConsumer(); + } + + // Only the pinned member should delete the consumer + if (isPinned) { + try { + JetStreamManagement jsm = nc.jetStreamManagement(); + String workQueueStreamName = composeCGSName(streamName, consumerGroupName); + jsm.deleteConsumer(workQueueStreamName, memberName); + } catch (Exception e) { + // Ignore - consumer may not exist + } + } else { + // Backoff to let the pinned member handle the delete and recreate first + try { + Thread.sleep(400 + (long) (Math.random() * 100)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + // Rejoin (matching Go which always calls joinMemberConsumer unconditionally) + joinMemberConsumer(); + } + + /** + * Creates or recreates the JetStream consumer and starts consuming. + * Matches Go's joinMemberConsumer. Only called from instanceThread. + */ + private void joinMemberConsumer() { + try { + List filters = ElasticConsumerGroup.getPartitionFilters(config, memberName); + + // If we are no longer in the membership list, nothing to do + if (filters.isEmpty()) { + return; + } + + JetStreamManagement jsm = nc.jetStreamManagement(); + String workQueueStreamName = composeCGSName(streamName, consumerGroupName); + + // Build consumer configuration from user config, overriding internal fields + Duration pinnedTTL = calculatePinnedTTL(consumerUserConfig.getAckWait()); + ConsumerConfiguration cc = ConsumerConfiguration.builder(consumerUserConfig) + .durable(null) + .name(memberName) + .filterSubjects(filters) + .priorityGroups(PRIORITY_GROUP_NAME) + .priorityPolicy(PriorityPolicy.PinnedClient) + .priorityTimeout(pinnedTTL) + .build(); + + // Try to create consumer (matching Go's tryCreateConsumer pattern) + try { + jsm.createConsumer(workQueueStreamName, cc); + } catch (JetStreamApiException e) { + // Check for "filtered consumer not unique on workqueue" - silently ignore + // (normal during concurrent membership changes, self-correction will retry) + if (e.getApiErrorCode() == JS_CONSUMER_WQ_CONSUMER_NOT_UNIQUE_ERR) { + return; + } + // Consumer exists with different config - delete and recreate + // (matching Go's ErrConsumerExists handling) + try { + jsm.deleteConsumer(workQueueStreamName, memberName); + } catch (Exception deleteEx) { + LOGGER.warning("Error trying to delete consumer for member \"" + memberName + "\": " + deleteEx.getMessage()); + return; + } + try { + jsm.createConsumer(workQueueStreamName, cc); + } catch (JetStreamApiException retryEx) { + if (retryEx.getApiErrorCode() == JS_CONSUMER_WQ_CONSUMER_NOT_UNIQUE_ERR) { + return; + } + LOGGER.warning("Error trying to create consumer for member \"" + memberName + "\": " + retryEx.getMessage()); + return; + } catch (Exception retryEx) { + LOGGER.warning("Error trying to create consumer for member \"" + memberName + "\": " + retryEx.getMessage()); + return; + } + } + + // Start consuming (matching Go's startConsuming) + StreamContext sc = nc.getStreamContext(workQueueStreamName); + ConsumerContext consumerCtx = sc.getConsumerContext(memberName); + + Duration pullExpiry = calculatePullExpiry(consumerUserConfig.getAckWait()); + ConsumeOptions co = ConsumeOptions.builder() + .expiresIn(pullExpiry.toMillis()) + .group(PRIORITY_GROUP_NAME) + .build(); + + messageConsumer = consumerCtx.consume(co, msg -> { + String pid = null; + Headers headers = msg.getHeaders(); + if (headers != null) { + pid = headers.getFirst("Nats-Pin-Id"); + } + + if (pid != null && !pid.isEmpty()) { + String current = currentPinnedId.get(); + if (current.isEmpty() || !current.equals(pid)) { + currentPinnedId.set(pid); + } + } + + ConsumerGroupMsg cgMsg = new ConsumerGroupMsg(msg); + handler.accept(cgMsg); + }); + + } catch (Exception e) { + LOGGER.warning("Error joining member consumer: " + e.getMessage()); + } + } + + @Override + public void stop() { + if (stopped.compareAndSet(false, true)) { + // Interrupt the instance thread to wake it from queue.poll() + if (instanceThread != null) { + instanceThread.interrupt(); + } + } + } + + @Override + public CompletableFuture done() { + return doneFuture; + } + + /** + * Stops the JetStream message consumer by closing it (unsubscribing the pull subscription). + * Uses close() instead of stop() because: + * - stop() only sets a flag but leaves the subscription active + * - close() sets the flag AND unsubscribes the subscription from the dispatcher + * This matches Go's consumeContext.Stop() which cancels the consume loop and waits + * for exit, ensuring no pending pull requests remain when the consumer is deleted. + */ + private void stopMessageConsumer() { + if (messageConsumer != null) { + try { + messageConsumer.close(); + } catch (Exception e) { + // Ignore + } + messageConsumer = null; + } + } + + /** + * Stops everything: message consumer and KV watcher. + * Used for full shutdown only (called from instanceThread on exit). + * Matches Go's stopConsuming() + watcher cleanup. + */ + private void stopConsuming() { + stopMessageConsumer(); + if (watchSubscription != null) { + try { + watchSubscription.unsubscribe(); + } catch (Exception e) { + // Ignore + } + watchSubscription = null; + } + } + } +} diff --git a/pcgroups/src/main/java/io/synadia/pcg/ElasticConsumerGroupConfig.java b/pcgroups/src/main/java/io/synadia/pcg/ElasticConsumerGroupConfig.java new file mode 100644 index 0000000..16c2e9e --- /dev/null +++ b/pcgroups/src/main/java/io/synadia/pcg/ElasticConsumerGroupConfig.java @@ -0,0 +1,295 @@ +// Copyright 2024-2025 Synadia Communications Inc. +// Licensed 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 io.synadia.pcg; + +import io.nats.client.api.KeyValueEntry; +import io.nats.client.support.*; +import io.synadia.pcg.exceptions.ConsumerGroupException; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.*; + +import static io.nats.client.support.JsonUtils.*; +import static io.nats.client.support.JsonValueUtils.*; + +/** + * Configuration for an elastic consumer group. + * JSON structure must be compatible with the Go version. + */ +public class ElasticConsumerGroupConfig implements JsonSerializable { + static final String MAX_MEMBERS = "max_members"; + static final String PARTITIONING_FILTERS = "partitioning_filters"; + static final String MAX_BUFFERED_MSG = "max_buffered_msg"; + static final String MAX_BUFFERED_BYTES = "max_buffered_bytes"; + static final String MEMBERS = "members"; + static final String MEMBER_MAPPINGS = "member_mappings"; + + private int maxMembers; + private List partitioningFilters; + private long maxBufferedMessages; + private long maxBufferedBytes; + private List members; + private List memberMappings; + + // Internal revision number, not serialized + private transient long revision; + + @Nullable + public static ElasticConsumerGroupConfig instance(KeyValueEntry entry) throws JsonParseException { + if (entry != null) { + byte[] json = entry.getValue(); + if (json != null) { + ElasticConsumerGroupConfig config = instance(json); + config.setRevision(entry.getRevision()); + return config; + } + } + return null; + } + + @NonNull + public static ElasticConsumerGroupConfig instance(byte @NonNull[] json) throws JsonParseException { + return new ElasticConsumerGroupConfig(JsonParser.parse(json)); + } + + public ElasticConsumerGroupConfig() { + this.partitioningFilters = new ArrayList<>(); + this.members = new ArrayList<>(); + this.memberMappings = new ArrayList<>(); + } + + public ElasticConsumerGroupConfig(int maxMembers, List partitioningFilters, + long maxBufferedMessages, long maxBufferedBytes, + List members, List memberMappings) { + this.maxMembers = maxMembers; + this.partitioningFilters = partitioningFilters == null ? new ArrayList<>() : new ArrayList<>(partitioningFilters); + this.maxBufferedMessages = maxBufferedMessages; + this.maxBufferedBytes = maxBufferedBytes; + this.members = members == null ? new ArrayList<>() : new ArrayList<>(members); + this.memberMappings = memberMappings == null ? new ArrayList<>() : new ArrayList<>(memberMappings); + } + + public ElasticConsumerGroupConfig(JsonValue jv) { + this.maxMembers = JsonValueUtils.readInteger(jv, MAX_MEMBERS, 0); + this.partitioningFilters = PartitioningFilter.listOfOrEmptyList(readValue(jv, PARTITIONING_FILTERS)); + this.maxBufferedMessages = JsonValueUtils.readLong(jv, MAX_BUFFERED_MSG, 0); + this.maxBufferedBytes = JsonValueUtils.readLong(jv, MAX_BUFFERED_BYTES, 0); + this.members = JsonValueUtils.readStringList(jv, MEMBERS); + this.memberMappings = MemberMapping.listOfOrEmptyList(readValue(jv, MEMBER_MAPPINGS)); + } + + public int getMaxMembers() { + return maxMembers; + } + + public void setMaxMembers(int maxMembers) { + this.maxMembers = maxMembers; + } + + public List getPartitioningFilters() { + return new ArrayList<>(partitioningFilters); + } + + public void setPartitioningFilters(List partitioningFilters) { + this.partitioningFilters = partitioningFilters == null ? new ArrayList<>() : new ArrayList<>(partitioningFilters); + } + + public long getMaxBufferedMessages() { + return maxBufferedMessages; + } + + public void setMaxBufferedMessages(long maxBufferedMessages) { + this.maxBufferedMessages = maxBufferedMessages; + } + + public long getMaxBufferedBytes() { + return maxBufferedBytes; + } + + public void setMaxBufferedBytes(long maxBufferedBytes) { + this.maxBufferedBytes = maxBufferedBytes; + } + + public List getMembers() { + return members != null ? new ArrayList<>(members) : new ArrayList<>(); + } + + public void setMembers(List members) { + this.members = members == null ? new ArrayList<>() : new ArrayList<>(members); + } + + public List getMemberMappings() { + return new ArrayList<>(memberMappings); + } + + public void setMemberMappings(List memberMappings) { + this.memberMappings = memberMappings == null ? new ArrayList<>() : new ArrayList<>(memberMappings); + } + + public long getRevision() { + return revision; + } + + public void setRevision(long revision) { + this.revision = revision; + } + + /** + * Checks if the given member name is in the current membership. + */ + public boolean isInMembership(String name) { + if (!memberMappings.isEmpty()) { + for (MemberMapping mapping : memberMappings) { + if (mapping.getMember().equals(name)) { + return true; + } + } + } + return members.contains(name); + } + + /** + * Validates the elastic consumer group configuration. + * + * @throws ConsumerGroupException if the configuration is invalid + */ + public void validate() throws ConsumerGroupException { + // Validate max members + if (maxMembers < 1) { + throw new ConsumerGroupException("the max number of members must be >= 1"); + } + + // Validate partitioning filters + for (PartitioningFilter pf : partitioningFilters) { + if (pf.getFilter() == null || pf.getFilter().isEmpty()) { + throw new ConsumerGroupException("partitioning filters must have a non-empty filter"); + } + + String[] filterTokens = pf.getFilter().split("\\."); + int numWildcards = 0; + for (String token : filterTokens) { + if ("*".equals(token)) { + numWildcards++; + } + } + + if (numWildcards == 0 && !">".equals(filterTokens[filterTokens.length - 1])) { + throw new ConsumerGroupException("partitioning filters must have at least one * wildcard or end with > wildcard"); + } + + int[] wildcards = pf.getPartitioningWildcards(); + if (wildcards != null && wildcards.length > numWildcards) { + throw new ConsumerGroupException("the number of partitioning wildcards must not be larger than the total number of * wildcards in the filter"); + } + + Set seenWildcards = new HashSet<>(); + if (wildcards != null) { + for (int pwc : wildcards) { + if (seenWildcards.contains(pwc)) { + throw new ConsumerGroupException("partitioning wildcard indexes must be unique"); + } + seenWildcards.add(pwc); + + if (pwc > numWildcards || pwc < 1) { + throw new ConsumerGroupException("partitioning wildcard indexes must be between 1 and the number of * wildcards in the filter"); + } + } + } + } + + // Validate that only one of members or member mappings is provided + boolean hasMembers = !members.isEmpty(); + boolean hasMemberMappings = !memberMappings.isEmpty(); + + if (hasMembers && hasMemberMappings) { + throw new ConsumerGroupException("either members or member mappings must be provided, not both"); + } + + // Validate member mappings + if (hasMemberMappings) { + if (memberMappings.size() > maxMembers) { + throw new ConsumerGroupException("the number of member mappings must be between 1 and the max number of members"); + } + + Set seenMembers = new HashSet<>(); + Set seenPartitions = new HashSet<>(); + + for (MemberMapping mm : memberMappings) { + if (seenMembers.contains(mm.getMember())) { + throw new ConsumerGroupException("member names must be unique"); + } + seenMembers.add(mm.getMember()); + + for (int p : mm.getPartitions()) { + if (seenPartitions.contains(p)) { + throw new ConsumerGroupException("partition numbers must be used only once"); + } + seenPartitions.add(p); + + if (p < 0 || p >= maxMembers) { + throw new ConsumerGroupException("partition numbers must be between 0 and one less than the max number of members"); + } + } + } + + if (seenPartitions.size() != maxMembers) { + throw new ConsumerGroupException("the number of unique partition numbers must be equal to the max number of members"); + } + } + } + + @Override + @NonNull + public String toJson() { + StringBuilder sb = beginJson(); + addField(sb, MAX_MEMBERS, maxMembers); + addJsons(sb, PARTITIONING_FILTERS, partitioningFilters); + addField(sb, MAX_BUFFERED_MSG, maxBufferedMessages); + addField(sb, MAX_BUFFERED_BYTES, maxBufferedBytes); + addStrings(sb, MEMBERS, members); + addJsons(sb, MEMBER_MAPPINGS, memberMappings); + return endJson(sb).toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ElasticConsumerGroupConfig that = (ElasticConsumerGroupConfig) o; + return maxMembers == that.maxMembers && + maxBufferedMessages == that.maxBufferedMessages && + maxBufferedBytes == that.maxBufferedBytes && + Objects.equals(partitioningFilters, that.partitioningFilters) && + Objects.equals(members, that.members) && + Objects.equals(memberMappings, that.memberMappings); + } + + @Override + public int hashCode() { + return Objects.hash(maxMembers, partitioningFilters, maxBufferedMessages, maxBufferedBytes, members, memberMappings); + } + + @Override + public String toString() { + return "ElasticConsumerGroupConfig{" + + "maxMembers=" + maxMembers + + ", partitioningFilters=" + partitioningFilters + + ", maxBufferedMessages=" + maxBufferedMessages + + ", maxBufferedBytes=" + maxBufferedBytes + + ", members=" + members + + ", memberMappings=" + memberMappings + + '}'; + } +} diff --git a/pcgroups/src/main/java/io/synadia/pcg/MemberMapping.java b/pcgroups/src/main/java/io/synadia/pcg/MemberMapping.java new file mode 100644 index 0000000..e3b6652 --- /dev/null +++ b/pcgroups/src/main/java/io/synadia/pcg/MemberMapping.java @@ -0,0 +1,115 @@ +// Copyright 2024-2025 Synadia Communications Inc. +// Licensed 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 io.synadia.pcg; + +import io.nats.client.support.JsonSerializable; +import io.nats.client.support.JsonValue; +import io.nats.client.support.JsonValueUtils; +import org.jspecify.annotations.NonNull; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +import static io.nats.client.support.JsonUtils.*; +import static io.nats.client.support.JsonValueUtils.*; +import static io.nats.client.support.JsonValueUtils.readString; + +/** + * Represents a mapping between a member name and its assigned partitions. + * JSON structure must be compatible with the Go version. + */ +public class MemberMapping implements JsonSerializable { + static final String MEMBER = "member"; + static final String PARTITIONS = "partitions"; + + private String member; + private int[] partitions; + + static List listOfOrEmptyList(JsonValue jv) { + return JsonValueUtils.listOf(jv, MemberMapping::new); + } + + public MemberMapping() {} + + public MemberMapping(String member, int[] partitions) { + this.member = member; + this.partitions = partitions != null ? partitions.clone() : new int[0]; + } + + public MemberMapping(JsonValue jv) { + this.member = readString(jv, MEMBER); + List integers = read(jv, PARTITIONS, v -> listOf(v, JsonValueUtils::getInteger)); + this.partitions = new int[integers.size()]; + for (int x = 0; x < integers.size(); x++) { + Integer i = integers.get(x); + this.partitions[x] = i == null ? 0 : i; + } + } + + @Override + @NonNull + public String toJson() { + StringBuilder sb = beginJson(); + addField(sb, MEMBER, member); + if (partitions.length > 0) { + List integers = new ArrayList<>(partitions.length); + for (int i : partitions) { + integers.add(i); + } + _addList(sb, PARTITIONS, integers, StringBuilder::append); + } + return endJson(sb).toString(); + } + + public String getMember() { + return member; + } + + public void setMember(String member) { + this.member = member; + } + + public int[] getPartitions() { + return partitions != null ? partitions.clone() : new int[0]; + } + + public void setPartitions(int[] partitions) { + this.partitions = partitions != null ? partitions.clone() : new int[0]; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + MemberMapping that = (MemberMapping) o; + return Objects.equals(member, that.member) && Arrays.equals(partitions, that.partitions); + } + + @Override + public int hashCode() { + int result = Objects.hash(member); + result = 31 * result + Arrays.hashCode(partitions); + return result; + } + + @Override + public String toString() { + return "MemberMapping{" + + "member='" + member + '\'' + + ", partitions=" + Arrays.toString(partitions) + + '}'; + } +} diff --git a/pcgroups/src/main/java/io/synadia/pcg/PartitionUtils.java b/pcgroups/src/main/java/io/synadia/pcg/PartitionUtils.java new file mode 100644 index 0000000..dddf95a --- /dev/null +++ b/pcgroups/src/main/java/io/synadia/pcg/PartitionUtils.java @@ -0,0 +1,176 @@ +// Copyright 2024-2025 Synadia Communications Inc. +// Licensed 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 io.synadia.pcg; + +import java.time.Duration; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Utility functions shared by both static and elastic consumer groups. + * Contains constants and the partition distribution algorithm. + */ +public final class PartitionUtils { + + // Constants matching the Go implementation + public static final int PULL_TIMEOUT_DIVIDER = 2; + public static final int CONSUMER_IDLE_TIMEOUT_FACTOR = 1; + public static final Duration DEFAULT_ACK_WAIT = Duration.ofSeconds(5); + public static final Duration MIN_PULL_EXPIRY_PINNED_TTL = Duration.ofSeconds(1); + public static final String PRIORITY_GROUP_NAME = "PCG"; + + // KV bucket names (must match Go implementation) + public static final String KV_STATIC_BUCKET_NAME = "static-consumer-groups"; + public static final String KV_ELASTIC_BUCKET_NAME = "elastic-consumer-groups"; + + private PartitionUtils() { + // Utility class + } + + /** + * Compose the consumer group's config key name. + * Format: "{streamName}.{consumerGroupName}" + */ + public static String composeKey(String streamName, String consumerGroupName) { + return streamName + "." + consumerGroupName; + } + + /** + * Compose the consumer group stream name for elastic consumer groups. + * Format: "{streamName}-{consumerGroupName}" + */ + public static String composeCGSName(String streamName, String consumerGroupName) { + return streamName + "-" + consumerGroupName; + } + + /** + * Compose the static consumer name. + * Format: "{consumerGroupName}-{memberName}" + */ + public static String composeStaticConsumerName(String consumerGroupName, String memberName) { + return consumerGroupName + "-" + memberName; + } + + /** + * Generates the partition filters for a particular member of a consumer group, + * according to the provided max number of members and the membership. + * This algorithm must match the Go implementation exactly for wire compatibility. + * + * @param members List of member names (for balanced distribution) + * @param maxMembers Maximum number of members/partitions + * @param memberMappings Explicit member-to-partition mappings (alternative to members) + * @param memberName The member to generate filters for + * @return List of partition filters (e.g., "0.>", "1.>", "2.>") + */ + public static List generatePartitionFilters(List members, int maxMembers, + List memberMappings, String memberName) { + return generatePartitionFilters(members, maxMembers, memberMappings, memberName, ">"); + } + + public static List generatePartitionFilters(List members, int maxMembers, + List memberMappings, String memberName, + String filter) { + String effectiveFilter = (filter != null && !filter.isEmpty()) ? filter : ">"; + if (members != null && !members.isEmpty()) { + // Deduplicate and sort members + List sortedMembers = members.stream() + .distinct() + .sorted() + .collect(Collectors.toList()); + + // Cap to maxMembers if necessary + if (sortedMembers.size() > maxMembers) { + sortedMembers = sortedMembers.subList(0, maxMembers); + } + + int numMembers = sortedMembers.size(); + + if (numMembers > 0) { + // Rounded number of partitions per member + int numPer = maxMembers / numMembers; + List myFilters = new ArrayList<>(); + + for (int i = 0; i < maxMembers; i++) { + int memberIndex = i / numPer; + + if (i < (numMembers * numPer)) { + if (sortedMembers.get(memberIndex % numMembers).equals(memberName)) { + myFilters.add(i + "." + effectiveFilter); + } + } else { + // Remainder if the number of partitions is not a multiple of the number of members + if (sortedMembers.get((i - (numMembers * numPer)) % numMembers).equals(memberName)) { + myFilters.add(i + "." + effectiveFilter); + } + } + } + + return myFilters; + } + return Collections.emptyList(); + } else if (memberMappings != null && !memberMappings.isEmpty()) { + List myFilters = new ArrayList<>(); + + for (MemberMapping mapping : memberMappings) { + if (mapping.getMember().equals(memberName)) { + for (int pn : mapping.getPartitions()) { + myFilters.add(pn + "." + effectiveFilter); + } + } + } + + return myFilters; + } + return Collections.emptyList(); + } + + /** + * Deduplicate a list of strings while preserving order. + */ + public static List deduplicateStringList(List list) { + if (list == null) { + return Collections.emptyList(); + } + Set seen = new LinkedHashSet<>(); + List result = new ArrayList<>(); + for (String s : list) { + if (seen.add(s)) { + result.add(s); + } + } + return result; + } + + /** + * Calculate the pull expiry duration based on ack wait. + */ + public static Duration calculatePullExpiry(Duration ackWait) { + Duration calculated = ackWait.dividedBy(PULL_TIMEOUT_DIVIDER); + return calculated.compareTo(MIN_PULL_EXPIRY_PINNED_TTL) > 0 ? calculated : MIN_PULL_EXPIRY_PINNED_TTL; + } + + /** + * Calculate the pinned TTL based on ack wait. + */ + public static Duration calculatePinnedTTL(Duration ackWait) { + return ackWait.compareTo(MIN_PULL_EXPIRY_PINNED_TTL) > 0 ? ackWait : MIN_PULL_EXPIRY_PINNED_TTL; + } + + /** + * Calculate the inactive threshold based on ack wait. + */ + public static Duration calculateInactiveThreshold(Duration ackWait) { + return ackWait.multipliedBy(CONSUMER_IDLE_TIMEOUT_FACTOR); + } +} diff --git a/pcgroups/src/main/java/io/synadia/pcg/PartitioningFilter.java b/pcgroups/src/main/java/io/synadia/pcg/PartitioningFilter.java new file mode 100644 index 0000000..8e9fbb7 --- /dev/null +++ b/pcgroups/src/main/java/io/synadia/pcg/PartitioningFilter.java @@ -0,0 +1,156 @@ +// Copyright 2024-2025 Synadia Communications Inc. +// Licensed 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 io.synadia.pcg; + +import io.nats.client.support.JsonSerializable; +import io.nats.client.support.JsonValue; +import io.nats.client.support.JsonValueUtils; +import org.jspecify.annotations.NonNull; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +import static io.nats.client.support.JsonUtils.*; +import static io.nats.client.support.JsonValueUtils.*; +import static io.nats.client.support.JsonValueUtils.readString; + +/** + * Represents a partitioning filter with its associated wildcard indexes. + * JSON structure must be compatible with the Go version. + */ +public class PartitioningFilter implements JsonSerializable { + static final String FILTER = "filter"; + static final String PARTITIONING_WILDCARDS = "partitioning_wildcards"; + + public static PartitioningFilter EVERYTHING = new PartitioningFilter(">", new int[0]); + + private String filter; + private int[] partitioningWildcards; + + static List listOfOrEmptyList(JsonValue jv) { + return JsonValueUtils.listOf(jv, PartitioningFilter::new); + } + + public PartitioningFilter() { + this.partitioningWildcards = new int[0]; + } + + public PartitioningFilter(String filter) { + this(filter, new int[0]); + } + + public PartitioningFilter(String filter, int[] partitioningWildcards) { + this.filter = filter; + this.partitioningWildcards = partitioningWildcards != null ? partitioningWildcards.clone() : new int[0]; + } + + public PartitioningFilter(JsonValue jv) { + this.filter = readString(jv, FILTER); + List integers = read(jv, PARTITIONING_WILDCARDS, v -> listOf(v, JsonValueUtils::getInteger)); + if (integers == null || integers.isEmpty()) { + this.partitioningWildcards = new int[0]; + } else { + this.partitioningWildcards = new int[integers.size()]; + for (int x = 0; x < integers.size(); x++) { + Integer i = integers.get(x); + this.partitioningWildcards[x] = i == null ? 0 : i; + } + } + } + + public String getFilter() { + return filter; + } + + public void setFilter(String filter) { + this.filter = filter; + } + + public int[] getPartitioningWildcards() { + return partitioningWildcards != null ? partitioningWildcards.clone() : new int[0]; + } + + public void setPartitioningWildcards(int[] partitioningWildcards) { + this.partitioningWildcards = partitioningWildcards != null ? partitioningWildcards.clone() : new int[0]; + } + + public String getPartitioningTransformDest(int maxMembers) { + String effectiveFilter = (getFilter() != null && !getFilter().isEmpty()) ? getFilter() : ">"; + int[] wildcards = getPartitioningWildcards(); + + StringBuilder wildcardList = new StringBuilder(); + for (int i = 0; i < wildcards.length; i++) { + if (i > 0) wildcardList.append(","); + wildcardList.append(wildcards[i]); + } + + String[] filterTokens = effectiveFilter.split("\\."); + int cwIndex = 1; + for (int i = 0; i < filterTokens.length; i++) { + if (filterTokens[i].equals("*")) { + filterTokens[i] = "{{Wildcard(" + cwIndex + ")}}"; + cwIndex++; + } + } + + String destFromFilter = String.join(".", filterTokens); + + if (wildcards.length == 0) { + return "{{Partition(" + maxMembers + ")}}." + destFromFilter; + } + + return "{{Partition(" + maxMembers + "," + wildcardList + ")}}." + destFromFilter; + } + + @Override + @NonNull + public String toJson() { + StringBuilder sb = beginJson(); + addField(sb, FILTER, filter); + if (partitioningWildcards.length > 0) { + List integers = new ArrayList<>(partitioningWildcards.length); + for (int i : partitioningWildcards) { + integers.add(i); + } + _addList(sb, PARTITIONING_WILDCARDS, integers, StringBuilder::append); + } + return endJson(sb).toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + PartitioningFilter that = (PartitioningFilter) o; + return Objects.equals(filter, that.filter) && + Arrays.equals(partitioningWildcards, that.partitioningWildcards); + } + + @Override + public int hashCode() { + int result = Objects.hash(filter); + result = 31 * result + Arrays.hashCode(partitioningWildcards); + return result; + } + + @Override + public String toString() { + return "PartitioningFilter{" + + "filter='" + filter + '\'' + + ", partitioningWildcards=" + Arrays.toString(partitioningWildcards) + + '}'; + } +} diff --git a/pcgroups/src/main/java/io/synadia/pcg/StaticConsumerGroup.java b/pcgroups/src/main/java/io/synadia/pcg/StaticConsumerGroup.java new file mode 100644 index 0000000..2948370 --- /dev/null +++ b/pcgroups/src/main/java/io/synadia/pcg/StaticConsumerGroup.java @@ -0,0 +1,498 @@ +// Copyright 2024-2025 Synadia Communications Inc. +// Licensed 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 io.synadia.pcg; + +import io.nats.client.*; +import io.nats.client.api.*; +import io.nats.client.impl.Headers; +import io.synadia.pcg.exceptions.ConsumerGroupException; + +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.logging.Logger; + +import static io.synadia.pcg.PartitionUtils.*; + +/** + * Static consumer group implementation. + * Provides static partition assignment for consumer groups. + */ +public class StaticConsumerGroup { + + private static final Logger LOGGER = Logger.getLogger(StaticConsumerGroup.class.getName()); + + private StaticConsumerGroup() { + // Utility class + } + + /** + * Creates a static consumer group. + * + * @param nc NATS connection + * @param streamName Name of the stream + * @param consumerGroupName Name of the consumer group + * @param maxMembers Maximum number of members (partitions) + * @param filter Subject filter + * @param members List of member names (for balanced distribution) + * @param memberMappings Explicit member-to-partition mappings + * @return The created configuration + */ + public static StaticConsumerGroupConfig create(Connection nc, String streamName, String consumerGroupName, + int maxMembers, String filter, List members, + List memberMappings) throws ConsumerGroupException, IOException, JetStreamApiException, InterruptedException { + StaticConsumerGroupConfig config = new StaticConsumerGroupConfig(maxMembers, filter, members, memberMappings); + config.validate(); + + JetStreamManagement jsm = nc.jetStreamManagement(); + + // Get stream info to determine replicas + StreamInfo streamInfo = jsm.getStreamInfo(streamName); + int replicas = streamInfo.getConfiguration().getReplicas(); + + // Get or create the KV bucket + KeyValueManagement kvm = nc.keyValueManagement(); + KeyValue kv; + try { + kv = nc.keyValue(KV_STATIC_BUCKET_NAME); + } catch (Exception e) { + // Create the bucket if it doesn't exist + KeyValueConfiguration kvConfig = KeyValueConfiguration.builder() + .name(KV_STATIC_BUCKET_NAME) + .replicas(replicas) + .storageType(StorageType.File) + .build(); + kvm.create(kvConfig); + kv = nc.keyValue(KV_STATIC_BUCKET_NAME); + } + + String key = composeKey(streamName, consumerGroupName); + + // Check if config already exists + StaticConsumerGroupConfig existingConfig = StaticConsumerGroupConfig.instance(kv.get(key)); + if (existingConfig != null) { + + // Verify the config matches + if (!configsMatch(existingConfig, config)) { + throw new ConsumerGroupException("the existing static consumer group config doesn't match ours"); + } + return existingConfig; + } + + // Create the config entry + kv.put(key, config.serialize()); + + return config; + } + + /** + * Starts consuming messages from a static consumer group. + * + * @param nc NATS connection + * @param streamName Name of the stream + * @param consumerGroupName Name of the consumer group + * @param memberName Name of this member + * @param handler Message handler callback + * @param consumerConfig Consumer configuration (null for defaults) + * @return A consume context for controlling the consumption lifecycle + */ + public static ConsumerGroupConsumeContext consume(Connection nc, String streamName, String consumerGroupName, + String memberName, Consumer handler, + ConsumerConfiguration consumerConfig) throws ConsumerGroupException, IOException, JetStreamApiException, InterruptedException { + if (handler == null) { + throw new ConsumerGroupException("a message handler must be provided"); + } + + if (consumerConfig == null) { + consumerConfig = ConsumerConfiguration.builder().ackWait(DEFAULT_ACK_WAIT).build(); + } else if (consumerConfig.getAckWait() == null || consumerConfig.getAckWait().isZero() || consumerConfig.getAckWait().isNegative()) { + consumerConfig = ConsumerConfiguration.builder(consumerConfig).ackWait(DEFAULT_ACK_WAIT).build(); + } + + // Get the KV bucket + KeyValue kv; + try { + kv = nc.keyValue(KV_STATIC_BUCKET_NAME); + } catch (Exception e) { + throw new ConsumerGroupException("the static consumer group KV bucket doesn't exist", e); + } + + // Get the config + StaticConsumerGroupConfig config = getConfigFromKV(kv, streamName, consumerGroupName); + + if (!config.isInMembership(memberName)) { + throw new ConsumerGroupException("the member name is not in the current static consumer group membership"); + } + + // Verify stream exists + JetStreamManagement jsm = nc.jetStreamManagement(); + try { + jsm.getStreamInfo(streamName); + } catch (JetStreamApiException e) { + throw new ConsumerGroupException("the static consumer group's stream does not exist", e); + } + + return new StaticConsumeContextImpl(nc, kv, streamName, consumerGroupName, memberName, config, handler, consumerConfig); + } + + /** + * Deletes a static consumer group. + */ + public static void delete(Connection nc, String streamName, String consumerGroupName) throws IOException, JetStreamApiException, InterruptedException { + JetStreamManagement jsm = nc.jetStreamManagement(); + + // Get the KV bucket + KeyValue kv; + try { + kv = nc.keyValue(KV_STATIC_BUCKET_NAME); + } catch (Exception e) { + return; // Bucket doesn't exist + } + + // Delete the config entry + String key = composeKey(streamName, consumerGroupName); + try { + kv.delete(key); + } catch (Exception e) { + // Ignore if key doesn't exist + } + + // Delete consumers that match the pattern + List consumerNames = jsm.getConsumerNames(streamName); + + for (String consumerName : consumerNames) { + if (consumerName.startsWith(consumerGroupName + "-")) { + try { + jsm.deleteConsumer(streamName, consumerName); + } catch (JetStreamApiException e) { + // Ignore if consumer doesn't exist + } + } + } + } + + /** + * Lists static consumer groups for a stream. + */ + public static List list(Connection nc, String streamName) throws IOException, JetStreamApiException, InterruptedException { + KeyValue kv; + try { + kv = nc.keyValue(KV_STATIC_BUCKET_NAME); + } catch (Exception e) { + return new ArrayList<>(); + } + + List keys = kv.keys(); + List consumerGroupNames = new ArrayList<>(); + + for (String key : keys) { + String[] parts = key.split("\\."); + if (parts.length >= 2 && parts[0].equals(streamName)) { + consumerGroupNames.add(parts[1]); + } + } + + return consumerGroupNames; + } + + /** + * Lists active members of a static consumer group. + */ + public static List listActiveMembers(Connection nc, String streamName, String consumerGroupName) + throws IOException, JetStreamApiException, InterruptedException, ConsumerGroupException { + KeyValue kv; + try { + kv = nc.keyValue(KV_STATIC_BUCKET_NAME); + } catch (Exception e) { + throw new ConsumerGroupException("the static consumer group KV bucket doesn't exist", e); + } + + StaticConsumerGroupConfig config = getConfigFromKV(kv, streamName, consumerGroupName); + JetStreamManagement jsm = nc.jetStreamManagement(); + + List activeMembers = new ArrayList<>(); + List consumers = jsm.getConsumers(streamName); + + List memberList = config.getMembers(); + List mappings = config.getMemberMappings(); + + for (ConsumerInfo cInfo : consumers) { + if (!memberList.isEmpty()) { + for (String m : memberList) { + if (cInfo.getName().equals(composeStaticConsumerName(consumerGroupName, m)) && cInfo.getNumWaiting() > 0) { + activeMembers.add(m); + break; + } + } + } else if (!mappings.isEmpty()) { + for (MemberMapping mapping : mappings) { + if (cInfo.getName().equals(composeStaticConsumerName(consumerGroupName, mapping.getMember())) && cInfo.getNumWaiting() > 0) { + activeMembers.add(mapping.getMember()); + break; + } + } + } + } + + return activeMembers; + } + + /** + * Forces the current active (pinned) instance for a member to step down. + * This requires NATS server 2.11+ with priority consumer support. + */ + public static void memberStepDown(Connection nc, String streamName, String consumerGroupName, String memberName) + throws IOException, JetStreamApiException, InterruptedException { + JetStreamManagement jsm = nc.jetStreamManagement(); + String consumerName = composeStaticConsumerName(consumerGroupName, memberName); + jsm.unpinConsumer(streamName, consumerName, PRIORITY_GROUP_NAME); + } + + /** + * Gets the static consumer group configuration. + */ + public static StaticConsumerGroupConfig getConfig(Connection nc, String streamName, String consumerGroupName) + throws IOException, JetStreamApiException, InterruptedException, ConsumerGroupException { + KeyValue kv; + try { + kv = nc.keyValue(KV_STATIC_BUCKET_NAME); + } catch (Exception e) { + throw new ConsumerGroupException("the static consumer group KV bucket doesn't exist", e); + } + + return getConfigFromKV(kv, streamName, consumerGroupName); + } + + private static StaticConsumerGroupConfig getConfigFromKV(KeyValue kv, String streamName, String consumerGroupName) + throws IOException, JetStreamApiException, InterruptedException, ConsumerGroupException { + if (streamName == null || streamName.isEmpty() || consumerGroupName == null || consumerGroupName.isEmpty()) { + throw new ConsumerGroupException("invalid stream name or consumer group name"); + } + + String key = composeKey(streamName, consumerGroupName); + KeyValueEntry entry = kv.get(key); + + if (entry == null) { + throw new ConsumerGroupException("error getting the static consumer group's config: not found"); + } + + StaticConsumerGroupConfig config = StaticConsumerGroupConfig.instance(entry); + config.validate(); + + return config; + } + + private static boolean configsMatch(StaticConsumerGroupConfig a, StaticConsumerGroupConfig b) { + return a.getMaxMembers() == b.getMaxMembers() && + java.util.Objects.equals(a.getFilter(), b.getFilter()) && + java.util.Objects.equals(a.getMembers(), b.getMembers()) && + java.util.Objects.equals(a.getMemberMappings(), b.getMemberMappings()); + } + + /** + * Internal implementation of the consume context for static consumer groups. + */ + private static class StaticConsumeContextImpl implements ConsumerGroupConsumeContext { + private final Connection nc; + private final KeyValue kv; + private final String streamName; + private final String consumerGroupName; + private final String memberName; + private final StaticConsumerGroupConfig config; + private final Consumer handler; + private final ConsumerConfiguration consumerUserConfig; + private final CompletableFuture doneFuture; + private final AtomicBoolean stopped; + private final AtomicReference currentPinnedId; + private MessageConsumer messageConsumer; + private io.nats.client.impl.NatsKeyValueWatchSubscription watchSubscription; + + StaticConsumeContextImpl(Connection nc, KeyValue kv, String streamName, String consumerGroupName, + String memberName, StaticConsumerGroupConfig config, + Consumer handler, ConsumerConfiguration consumerUserConfig) + throws IOException, JetStreamApiException, InterruptedException, ConsumerGroupException { + this.nc = nc; + this.kv = kv; + this.streamName = streamName; + this.consumerGroupName = consumerGroupName; + this.memberName = memberName; + this.config = config; + this.handler = handler; + this.consumerUserConfig = consumerUserConfig; + this.doneFuture = new CompletableFuture<>(); + this.stopped = new AtomicBoolean(false); + this.currentPinnedId = new AtomicReference<>(""); + + joinMemberConsumer(); + startWatcher(); + } + + private void joinMemberConsumer() throws IOException, JetStreamApiException, InterruptedException, ConsumerGroupException { + List filters = PartitionUtils.generatePartitionFilters( + config.getMembers(), config.getMaxMembers(), config.getMemberMappings(), memberName); + + if (filters.isEmpty()) { + return; + } + + String consumerName = composeStaticConsumerName(consumerGroupName, memberName); + + // Build consumer configuration from user config, overriding internal fields + Duration pinnedTTL = calculatePinnedTTL(consumerUserConfig.getAckWait()); + ConsumerConfiguration cc = ConsumerConfiguration.builder(consumerUserConfig) + .durable(consumerName) + .name(consumerName) // Needed for cross-compatibility with Go client which creates the consumer using another JS API call + .filterSubjects(filters) + .priorityGroups(PRIORITY_GROUP_NAME) + .priorityPolicy(PriorityPolicy.PinnedClient) + .priorityTimeout(pinnedTTL) + .build(); + + // Create the durable consumer explicitly (matching Go's js.CreateConsumer) + JetStreamManagement jsm = nc.jetStreamManagement(); + jsm.createConsumer(streamName, cc); + + // Get consumer context and start consuming + StreamContext sc = nc.getStreamContext(streamName); + ConsumerContext consumerCtx = sc.getConsumerContext(consumerName); + + Duration pullExpiry = calculatePullExpiry(consumerUserConfig.getAckWait()); + ConsumeOptions co = ConsumeOptions.builder() + .expiresIn(pullExpiry.toMillis()) + .group(PRIORITY_GROUP_NAME) + .build(); + + messageConsumer = consumerCtx.consume(co, msg -> { + String pid = null; + Headers headers = msg.getHeaders(); + if (headers != null) { + pid = headers.getFirst("Nats-Pin-Id"); + } + + if (pid != null && !pid.isEmpty()) { + String current = currentPinnedId.get(); + if (current.isEmpty() || !current.equals(pid)) { + currentPinnedId.set(pid); + } + } + + ConsumerGroupMsg cgMsg = new ConsumerGroupMsg(msg); + handler.accept(cgMsg); + }); + } + + private void startWatcher() { + Thread watcherThread = new Thread(() -> { + try { + String key = composeKey(streamName, consumerGroupName); + KeyValueWatcher watcher = new KeyValueWatcher() { + @Override + public void watch(KeyValueEntry entry) { + if (stopped.get()) { + return; + } + + if (entry.getOperation() == KeyValueOperation.DELETE || + entry.getOperation() == KeyValueOperation.PURGE) { + stopAndDeleteMemberConsumer(); + doneFuture.complete(null); + return; + } + + try { + StaticConsumerGroupConfig newConfig = StaticConsumerGroupConfig.instance(entry); + newConfig.validate(); + + // Check if critical config changed + if (newConfig.getMaxMembers() != config.getMaxMembers() || + !java.util.Objects.equals(newConfig.getFilter(), config.getFilter()) || + !java.util.Objects.equals(newConfig.getMembers(), config.getMembers()) || + !java.util.Objects.equals(newConfig.getMemberMappings(), config.getMemberMappings())) { + stopAndDeleteMemberConsumer(); + doneFuture.completeExceptionally( + new ConsumerGroupException("static consumer group config watcher received a change in the configuration, terminating")); + } + } catch (Exception e) { + stopAndDeleteMemberConsumer(); + doneFuture.completeExceptionally(e); + } + } + + @Override + public void endOfData() { + // Initial data load complete + } + }; + + watchSubscription = kv.watch(key, watcher); + + } catch (Exception e) { + if (!stopped.get()) { + doneFuture.completeExceptionally(e); + } + } + }); + watcherThread.setDaemon(true); + watcherThread.start(); + } + + @Override + public void stop() { + if (stopped.compareAndSet(false, true)) { + stopConsuming(); + doneFuture.complete(null); + } + } + + @Override + public CompletableFuture done() { + return doneFuture; + } + + private void stopConsuming() { + if (messageConsumer != null) { + try { + messageConsumer.close(); + } catch (Exception e) { + // Ignore + } + messageConsumer = null; + } + if (watchSubscription != null) { + try { + watchSubscription.unsubscribe(); + } catch (Exception e) { + // Ignore + } + watchSubscription = null; + } + } + + private void stopAndDeleteMemberConsumer() { + stopConsuming(); + try { + JetStreamManagement jsm = nc.jetStreamManagement(); + String consumerName = composeStaticConsumerName(consumerGroupName, memberName); + jsm.deleteConsumer(streamName, consumerName); + } catch (Exception e) { + // Ignore - consumer may not exist + } + } + } +} diff --git a/pcgroups/src/main/java/io/synadia/pcg/StaticConsumerGroupConfig.java b/pcgroups/src/main/java/io/synadia/pcg/StaticConsumerGroupConfig.java new file mode 100644 index 0000000..da46472 --- /dev/null +++ b/pcgroups/src/main/java/io/synadia/pcg/StaticConsumerGroupConfig.java @@ -0,0 +1,211 @@ +// Copyright 2024-2025 Synadia Communications Inc. +// Licensed 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 io.synadia.pcg; + +import io.nats.client.api.KeyValueEntry; +import io.nats.client.support.*; +import io.synadia.pcg.exceptions.ConsumerGroupException; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.*; + +import static io.nats.client.support.JsonUtils.*; +import static io.nats.client.support.JsonValueUtils.readValue; + +/** + * Configuration for a static consumer group. + * JSON structure must be compatible with the Go version. + */ +public class StaticConsumerGroupConfig implements JsonSerializable { + static final String MAX_MEMBERS = "max_members"; + static final String FILTER = "filter"; + static final String MEMBERS = "members"; + static final String MEMBER_MAPPINGS = "member_mappings"; + + private int maxMembers; + private String filter; + private List members; + private List memberMappings; + + @Nullable + public static StaticConsumerGroupConfig instance(KeyValueEntry entry) throws JsonParseException { + if (entry != null) { + byte[] json = entry.getValue(); + if (json != null) { + return instance(json); + } + } + return null; + } + + @NonNull + public static StaticConsumerGroupConfig instance(byte @NonNull[] json) throws JsonParseException { + return new StaticConsumerGroupConfig(JsonParser.parse(json)); + } + + public StaticConsumerGroupConfig() { + this.members = new ArrayList<>(); + this.memberMappings = new ArrayList<>(); + } + + public StaticConsumerGroupConfig(int maxMembers, String filter, List members, List memberMappings) { + this.maxMembers = maxMembers; + this.filter = filter; + this.members = members == null ? new ArrayList<>() : new ArrayList<>(members); + this.memberMappings = memberMappings == null ? new ArrayList<>() : new ArrayList<>(memberMappings); + } + + public StaticConsumerGroupConfig(JsonValue jv) { + this.maxMembers = JsonValueUtils.readInteger(jv, MAX_MEMBERS, 0); + this.filter = JsonValueUtils.readString(jv, FILTER); + this.members = JsonValueUtils.readStringList(jv, MEMBERS); + this.memberMappings = MemberMapping.listOfOrEmptyList(readValue(jv, MEMBER_MAPPINGS)); + } + + @Override + @NonNull + public String toJson() { + StringBuilder sb = beginJson(); + addField(sb, MAX_MEMBERS, maxMembers); + addField(sb, FILTER, filter); + addStrings(sb, MEMBERS, members); + addJsons(sb, MEMBER_MAPPINGS, memberMappings); + return endJson(sb).toString(); + } + + public int getMaxMembers() { + return maxMembers; + } + + public void setMaxMembers(int maxMembers) { + this.maxMembers = maxMembers; + } + + public String getFilter() { + return filter; + } + + public void setFilter(String filter) { + this.filter = filter; + } + + public List getMembers() { + return new ArrayList<>(members); + } + + public void setMembers(List members) { + this.members = members == null ? new ArrayList<>() : new ArrayList<>(members); + } + + public List getMemberMappings() { + return new ArrayList<>(memberMappings); + } + + public void setMemberMappings(List memberMappings) { + this.memberMappings = memberMappings == null ? new ArrayList<>() : new ArrayList<>(memberMappings); + } + + /** + * Checks if the given member name is in the current membership. + */ + public boolean isInMembership(String name) { + if (!memberMappings.isEmpty()) { + for (MemberMapping mapping : memberMappings) { + if (mapping.getMember().equals(name)) { + return true; + } + } + } + return members.contains(name); + } + + /** + * Validates the static consumer group configuration. + * + * @throws ConsumerGroupException if the configuration is invalid + */ + public void validate() throws ConsumerGroupException { + // Validate max members + if (maxMembers < 1) { + throw new ConsumerGroupException("the max number of members must be >= 1"); + } + + // Validate that only one of members or member mappings is provided + boolean hasMembers = !members.isEmpty(); + boolean hasMemberMappings = !memberMappings.isEmpty(); + + if (hasMembers && hasMemberMappings) { + throw new ConsumerGroupException("either members or member mappings must be provided, not both"); + } + + // Validate member mappings + if (hasMemberMappings) { + if (memberMappings.size() > maxMembers) { + throw new ConsumerGroupException("the number of member mappings must be between 1 and the max number of members"); + } + + Set seenMembers = new HashSet<>(); + Set seenPartitions = new HashSet<>(); + + for (MemberMapping mm : memberMappings) { + if (seenMembers.contains(mm.getMember())) { + throw new ConsumerGroupException("member names must be unique"); + } + seenMembers.add(mm.getMember()); + + for (int p : mm.getPartitions()) { + if (seenPartitions.contains(p)) { + throw new ConsumerGroupException("partition numbers must be used only once"); + } + seenPartitions.add(p); + + if (p < 0 || p >= maxMembers) { + throw new ConsumerGroupException("partition numbers must be between 0 and one less than the max number of members"); + } + } + } + + if (seenPartitions.size() != maxMembers) { + throw new ConsumerGroupException("the number of unique partition numbers must be equal to the max number of members"); + } + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + StaticConsumerGroupConfig that = (StaticConsumerGroupConfig) o; + return maxMembers == that.maxMembers && + Objects.equals(filter, that.filter) && + Objects.equals(members, that.members) && + Objects.equals(memberMappings, that.memberMappings); + } + + @Override + public int hashCode() { + return Objects.hash(maxMembers, filter, members, memberMappings); + } + + @Override + public String toString() { + return "StaticConsumerGroupConfig{" + + "maxMembers=" + maxMembers + + ", filter='" + filter + '\'' + + ", members=" + members + + ", memberMappings=" + memberMappings + + '}'; + } +} diff --git a/pcgroups/src/main/java/io/synadia/pcg/exceptions/ConsumerGroupException.java b/pcgroups/src/main/java/io/synadia/pcg/exceptions/ConsumerGroupException.java new file mode 100644 index 0000000..58953ef --- /dev/null +++ b/pcgroups/src/main/java/io/synadia/pcg/exceptions/ConsumerGroupException.java @@ -0,0 +1,32 @@ +// Copyright 2024-2025 Synadia Communications Inc. +// Licensed 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 io.synadia.pcg.exceptions; + +/** + * Exception thrown when a consumer group operation fails. + */ +public class ConsumerGroupException extends Exception { + + public ConsumerGroupException(String message) { + super(message); + } + + public ConsumerGroupException(String message, Throwable cause) { + super(message, cause); + } + + public ConsumerGroupException(Throwable cause) { + super(cause); + } +} diff --git a/pcgroups/src/main/javadoc/images/favicon.ico b/pcgroups/src/main/javadoc/images/favicon.ico new file mode 100644 index 0000000..9464855 Binary files /dev/null and b/pcgroups/src/main/javadoc/images/favicon.ico differ diff --git a/pcgroups/src/main/javadoc/images/large-logo.png b/pcgroups/src/main/javadoc/images/large-logo.png new file mode 100644 index 0000000..33f9483 Binary files /dev/null and b/pcgroups/src/main/javadoc/images/large-logo.png differ diff --git a/pcgroups/src/main/javadoc/images/synadia-logo.png b/pcgroups/src/main/javadoc/images/synadia-logo.png new file mode 100644 index 0000000..1f14bda Binary files /dev/null and b/pcgroups/src/main/javadoc/images/synadia-logo.png differ diff --git a/pcgroups/src/main/javadoc/overview.html b/pcgroups/src/main/javadoc/overview.html new file mode 100644 index 0000000..1bfaaa5 --- /dev/null +++ b/pcgroups/src/main/javadoc/overview.html @@ -0,0 +1,13 @@ + + + + + +NATS JetStream Partitioned Consumer Groups Library for Java +

Synadia Logo

+ + + + diff --git a/pcgroups/src/test/java/io/synadia/pcg/ElasticConsumerGroupTest.java b/pcgroups/src/test/java/io/synadia/pcg/ElasticConsumerGroupTest.java new file mode 100644 index 0000000..f683c6d --- /dev/null +++ b/pcgroups/src/test/java/io/synadia/pcg/ElasticConsumerGroupTest.java @@ -0,0 +1,491 @@ +// Copyright 2024-2025 Synadia Communications Inc. +// Licensed 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 io.synadia.pcg; + +import io.nats.NatsRunnerUtils; +import io.nats.client.support.JsonParseException; +import io.synadia.pcg.exceptions.ConsumerGroupException; +import org.jspecify.annotations.NonNull; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.logging.Level; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for ElasticConsumerGroupConfig. + * Tests config validation, JSON serialization, and partition transform destination generation. + */ +class ElasticConsumerGroupTest { + + static { + NatsRunnerUtils.setDefaultOutputLevel(Level.SEVERE); + } + + private static @NonNull List getPartitioningFilters() { + return Collections.singletonList(new PartitioningFilter()); + } + + private static @NonNull List getPartitioningFilters(String filter, int... partitioningWildcards) { + return Collections.singletonList(new PartitioningFilter(filter, partitioningWildcards)); + } + + private static @NonNull List getPartitioningFilters(String filter) { + return Collections.singletonList(new PartitioningFilter(filter)); + } + + @Test + void testConfigBasic() { + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters("foo.*", 1), 1000, 10000, + Arrays.asList("m1", "m2"), new ArrayList<>() + ); + + assertEquals(4, config.getMaxMembers()); + assertEquals("foo.*", config.getPartitioningFilters().get(0).getFilter()); + assertArrayEquals(new int[]{1}, config.getPartitioningFilters().get(0).getPartitioningWildcards()); + assertEquals(1000, config.getMaxBufferedMessages()); + assertEquals(10000, config.getMaxBufferedBytes()); + assertEquals(2, config.getMembers().size()); + assertTrue(config.getMemberMappings().isEmpty()); + } + + @Test + void testIsInMembership() { + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters("foo.*", 1), 0, 0, + Arrays.asList("m1", "m2", "m3"), new ArrayList<>() + ); + + assertTrue(config.isInMembership("m1")); + assertTrue(config.isInMembership("m2")); + assertTrue(config.isInMembership("m3")); + assertFalse(config.isInMembership("m4")); + } + + @Test + void testIsInMembershipWithMappings() { + List mappings = Arrays.asList( + new MemberMapping("alice", new int[]{0, 1}), + new MemberMapping("bob", new int[]{2, 3}) + ); + + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters("foo.*", 1), 0, 0, + new ArrayList<>(), mappings + ); + + assertTrue(config.isInMembership("alice")); + assertTrue(config.isInMembership("bob")); + assertFalse(config.isInMembership("charlie")); + } + + @Test + void testValidationMaxMembersZero() { + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( + 0, getPartitioningFilters("foo.*", 1), 0, 0, + Arrays.asList("m1", "m2"), new ArrayList<>() + ); + + ConsumerGroupException exception = assertThrows(ConsumerGroupException.class, config::validate); + assertTrue(exception.getMessage().contains("max number of members must be >= 1")); + } + + @Test + void testValidationFilterNoWildcardNoGt() { + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters("foo.bar"), 0, 0, + Arrays.asList("m1", "m2"), new ArrayList<>() + ); + + ConsumerGroupException exception = assertThrows(ConsumerGroupException.class, config::validate); + assertTrue(exception.getMessage().contains("partitioning filters must have at least one * wildcard or end with > wildcard")); + } + + @Test + void testValidationFilterEndingWithGtIsValid() { + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters("foo.>"), 0, 0, + Arrays.asList("m1", "m2"), new ArrayList<>() + ); + + assertDoesNotThrow(config::validate); + } + + @Test + void testValidationFilterNoWildcardWithWildcardsSpecified() { + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters("foo.bar", 1), 0, 0, + Arrays.asList("m1", "m2"), new ArrayList<>() + ); + + ConsumerGroupException exception = assertThrows(ConsumerGroupException.class, config::validate); + assertTrue(exception.getMessage().contains("partitioning filters must have at least one * wildcard or end with > wildcard")); + } + + @Test + void testValidationPartitioningWildcardsEmptyIsValid() { + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters("foo.*"), 0, 0, + Arrays.asList("m1", "m2"), new ArrayList<>() + ); + + assertDoesNotThrow(config::validate); + } + + @Test + void testValidationNoFilterIsNotValid() { + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters(null), 0, 0, + Arrays.asList("m1", "m2"), new ArrayList<>() + ); + + assertThrows(ConsumerGroupException.class, config::validate); + } + + @Test + void testValidationEmptyFilterIsNotValid() { + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters(""), 0, 0, + Arrays.asList("m1", "m2"), new ArrayList<>() + ); + + assertThrows(ConsumerGroupException.class, config::validate); + } + + @Test + void testValidationPartitioningWildcardsTooMany() { + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters("foo.*", 1, 2), 0, 0, // Only 1 wildcard in filter + Arrays.asList("m1", "m2"), new ArrayList<>() + ); + + ConsumerGroupException exception = assertThrows(ConsumerGroupException.class, config::validate); + assertTrue(exception.getMessage().contains("number of partitioning wildcards must not be larger than")); + } + + @Test + void testValidationPartitioningWildcardsOutOfRange() { + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters("foo.*", 2), 0, 0, // Index 2 is out of range (only 1 wildcard) + Arrays.asList("m1", "m2"), new ArrayList<>() + ); + + ConsumerGroupException exception = assertThrows(ConsumerGroupException.class, config::validate); + assertTrue(exception.getMessage().contains("partitioning wildcard indexes must be between 1 and")); + } + + @Test + void testValidationPartitioningWildcardsZeroIndex() { + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters("foo.*", 0), 0, 0, + Arrays.asList("m1", "m2"), new ArrayList<>() + ); + + ConsumerGroupException exception = assertThrows(ConsumerGroupException.class, config::validate); + assertTrue(exception.getMessage().contains("partitioning wildcard indexes must be between 1 and")); + } + + @Test + void testValidationPartitioningWildcardsDuplicate() { + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters("foo.*.bar.*", 1, 1), 0, 0, + Arrays.asList("m1", "m2"), new ArrayList<>() + ); + + ConsumerGroupException exception = assertThrows(ConsumerGroupException.class, config::validate); + assertTrue(exception.getMessage().contains("partitioning wildcard indexes must be unique")); + } + + @Test + void testValidationBothMembersAndMappings() { + List mappings = Collections.singletonList( + new MemberMapping("alice", new int[]{0, 1, 2, 3}) + ); + + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters("foo.*", 1), 0, 0, + Arrays.asList("m1", "m2"), mappings + ); + + ConsumerGroupException exception = assertThrows(ConsumerGroupException.class, config::validate); + assertTrue(exception.getMessage().contains("either members or member mappings must be provided, not both")); + } + + @Test + void testValidationSuccess() { + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters("foo.*", 1), 0, 0, + Arrays.asList("m1", "m2"), new ArrayList<>() + ); + + assertDoesNotThrow(config::validate); + } + + @Test + void testValidationSuccessWithMappings() { + List mappings = Arrays.asList( + new MemberMapping("alice", new int[]{0, 1}), + new MemberMapping("bob", new int[]{2, 3}) + ); + + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters("foo.*", 1), 0, 0, + new ArrayList<>(), mappings + ); + + assertDoesNotThrow(config::validate); + } + + @Test + void testValidationSuccessMultipleWildcards() { + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters("foo.*.bar.*", 1, 2), 0, 0, + Arrays.asList("m1", "m2"), new ArrayList<>() + ); + + assertDoesNotThrow(config::validate); + } + + @Test + void testGetPartitioningTransformDestSingle() { + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters("foo.*", 1), 0, 0, + new ArrayList<>(), new ArrayList<>() + ); + + String dest = config.getPartitioningFilters().get(0).getPartitioningTransformDest(4); + assertEquals("{{Partition(4,1)}}.foo.{{Wildcard(1)}}", dest); + } + + @Test + void testGetPartitioningTransformDestMultiple() { + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( + 6, getPartitioningFilters("foo.*.bar.*", 1, 2), 0, 0, + new ArrayList<>(), new ArrayList<>() + ); + + String dest = config.getPartitioningFilters().get(0).getPartitioningTransformDest(6); + assertEquals("{{Partition(6,1,2)}}.foo.{{Wildcard(1)}}.bar.{{Wildcard(2)}}", dest); + } + + @Test + void testGetPartitioningTransformDestPartialWildcards() { + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( + 8, getPartitioningFilters("a.*.b.*.c.*", 2), 0, 0, + new ArrayList<>(), new ArrayList<>() + ); + + String dest = config.getPartitioningFilters().get(0).getPartitioningTransformDest(8); + assertEquals("{{Partition(8,2)}}.a.{{Wildcard(1)}}.b.{{Wildcard(2)}}.c.{{Wildcard(3)}}", dest); + } + + @Test + void testGetPartitioningTransformDestNoWildcards() { + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters("foo.*"), 0, 0, + new ArrayList<>(), new ArrayList<>() + ); + + String dest = config.getPartitioningFilters().get(0).getPartitioningTransformDest(4); + assertEquals("{{Partition(4)}}.foo.{{Wildcard(1)}}", dest); + } + + @Test + void testGetPartitioningTransformDestNoFilter() { + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters(null), 0, 0, + new ArrayList<>(), new ArrayList<>() + ); + + String dest = config.getPartitioningFilters().get(0).getPartitioningTransformDest(4); + assertEquals("{{Partition(4)}}.>", dest); + } + + @Test + void testGetPartitioningTransformDestEmptyFilter() { + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters(""), 0, 0, + new ArrayList<>(), new ArrayList<>() + ); + + String dest = config.getPartitioningFilters().get(0).getPartitioningTransformDest(4); + assertEquals("{{Partition(4)}}.>", dest); + } + + @Test + void testJsonSerializationWithMembers() throws JsonParseException { + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters("foo.*", 1), 1000, 10000, + Arrays.asList("m1", "m2"), new ArrayList<>() + ); + + String json = config.toJson(); + + // Verify JSON structure matches Go format + assertTrue(json.contains("\"max_members\":4")); + assertTrue(json.contains("\"filter\":\"foo.*\"")); + assertTrue(json.contains("\"partitioning_wildcards\":[1]")); + assertTrue(json.contains("\"max_buffered_msg\":1000")); + assertTrue(json.contains("\"max_buffered_bytes\":10000")); + assertTrue(json.contains("\"members\":[\"m1\",\"m2\"]")); + + // Deserialize and verify + ElasticConsumerGroupConfig deserialized = ElasticConsumerGroupConfig.instance(config.serialize()); + assertEquals(config.getMaxMembers(), deserialized.getMaxMembers()); + assertEquals(config.getPartitioningFilters().get(0).getFilter(), deserialized.getPartitioningFilters().get(0).getFilter()); + assertArrayEquals(config.getPartitioningFilters().get(0).getPartitioningWildcards(), deserialized.getPartitioningFilters().get(0).getPartitioningWildcards()); + assertEquals(config.getMaxBufferedMessages(), deserialized.getMaxBufferedMessages()); + assertEquals(config.getMaxBufferedBytes(), deserialized.getMaxBufferedBytes()); + assertEquals(config.getMembers(), deserialized.getMembers()); + } + + @Test + void testJsonSerializationWithMappings() throws JsonParseException { + List mappings = Arrays.asList( + new MemberMapping("alice", new int[]{0, 1}), + new MemberMapping("bob", new int[]{2, 3}) + ); + + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters("foo.*", 1), 0, 0, + new ArrayList<>(), mappings + ); + + String json = config.toJson(); + + assertTrue(json.contains("\"member_mappings\"")); + assertTrue(json.contains("\"member\":\"alice\"")); + assertTrue(json.contains("\"partitions\":[0,1]")); + + // Deserialize and verify + ElasticConsumerGroupConfig deserialized = ElasticConsumerGroupConfig.instance(config.serialize()); + assertEquals(2, deserialized.getMemberMappings().size()); + assertEquals("alice", deserialized.getMemberMappings().get(0).getMember()); + assertArrayEquals(new int[]{0, 1}, deserialized.getMemberMappings().get(0).getPartitions()); + } + + @Test + void testJsonDeserializationFromGo() throws JsonParseException { + // This JSON is in the format produced by the Go implementation + String goJson = "{\"max_members\":4,\"partitioning_filters\":[{\"filter\":\"foo.*\",\"partitioning_wildcards\":[1]}],\"max_buffered_msg\":1000,\"max_buffered_bytes\":10000,\"members\":[\"m1\",\"m2\"]}"; + + ElasticConsumerGroupConfig config = ElasticConsumerGroupConfig.instance(goJson.getBytes(StandardCharsets.UTF_8)); + + assertEquals(4, config.getMaxMembers()); + assertEquals("foo.*", config.getPartitioningFilters().get(0).getFilter()); + assertArrayEquals(new int[]{1}, config.getPartitioningFilters().get(0).getPartitioningWildcards()); + assertEquals(1000, config.getMaxBufferedMessages()); + assertEquals(10000, config.getMaxBufferedBytes()); + assertEquals(2, config.getMembers().size()); + assertEquals("m1", config.getMembers().get(0)); + assertEquals("m2", config.getMembers().get(1)); + } + + @Test + void testJsonDeserializationWithMappingsFromGo() throws JsonParseException { + // This JSON is in the format produced by the Go implementation + String goJson = "{\"max_members\":4,\"partitioning_filters\":[{\"filter\":\"bar.*\",\"partitioning_wildcards\":[1]}],\"member_mappings\":[{\"member\":\"alice\",\"partitions\":[0,1]},{\"member\":\"bob\",\"partitions\":[2,3]}]}"; + + ElasticConsumerGroupConfig config = ElasticConsumerGroupConfig.instance(goJson.getBytes(StandardCharsets.UTF_8)); + + assertEquals(4, config.getMaxMembers()); + assertEquals("bar.*", config.getPartitioningFilters().get(0).getFilter()); + assertArrayEquals(new int[]{1}, config.getPartitioningFilters().get(0).getPartitioningWildcards()); + assertEquals(2, config.getMemberMappings().size()); + assertEquals("alice", config.getMemberMappings().get(0).getMember()); + assertArrayEquals(new int[]{0, 1}, config.getMemberMappings().get(0).getPartitions()); + assertEquals("bob", config.getMemberMappings().get(1).getMember()); + assertArrayEquals(new int[]{2, 3}, config.getMemberMappings().get(1).getPartitions()); + } + + @Test + void testEquals() { + ElasticConsumerGroupConfig config1 = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters("foo.*", 1), 1000, 10000, + Arrays.asList("m1", "m2"), new ArrayList<>() + ); + + ElasticConsumerGroupConfig config2 = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters("foo.*", 1), 1000, 10000, + Arrays.asList("m1", "m2"), new ArrayList<>() + ); + + ElasticConsumerGroupConfig config3 = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters("foo.*", 1), 1000, 10000, + Arrays.asList("m1", "m3"), new ArrayList<>() + ); + + assertEquals(config1, config2); + assertNotEquals(config1, config3); + } + + @Test + void testHashCode() { + ElasticConsumerGroupConfig config1 = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters("foo.*", 1), 1000, 10000, + Arrays.asList("m1", "m2"), new ArrayList<>() + ); + + ElasticConsumerGroupConfig config2 = new ElasticConsumerGroupConfig( + 4, getPartitioningFilters("foo.*", 1), 1000, 10000, + Arrays.asList("m1", "m2"), new ArrayList<>() + ); + + assertEquals(config1.hashCode(), config2.hashCode()); + } + + @Test + void testRevision() { + ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig(); + assertEquals(0, config.getRevision()); + + config.setRevision(42); + assertEquals(42, config.getRevision()); + + // Verify revision is not serialized + String json = config.toJson(); + assertFalse(json.contains("revision")); + } + +// @Test +// void testGetPartitionFilters() { +// ElasticConsumerGroupConfig config = new ElasticConsumerGroupConfig( +// 6, getPartitioningFilters("foo.*", 1), 0, 0, +// Arrays.asList("m1", "m2", "m3"), new ArrayList<>() +// ); +// +// List m1Filters = ElasticConsumerGroup.getPartitionFilters(config, "m1"); +// List m2Filters = ElasticConsumerGroup.getPartitionFilters(config, "m2"); +// List m3Filters = ElasticConsumerGroup.getPartitionFilters(config, "m3"); +// +// // Each member should get 2 partitions +// assertEquals(2, m1Filters.size()); +// assertEquals(2, m2Filters.size()); +// assertEquals(2, m3Filters.size()); +// +// // Verify distribution +// assertTrue(m1Filters.contains("0.>")); +// assertTrue(m1Filters.contains("1.>")); +// assertTrue(m2Filters.contains("2.>")); +// assertTrue(m2Filters.contains("3.>")); +// assertTrue(m3Filters.contains("4.>")); +// assertTrue(m3Filters.contains("5.>")); +// } +} diff --git a/pcgroups/src/test/java/io/synadia/pcg/IntegrationTest.java b/pcgroups/src/test/java/io/synadia/pcg/IntegrationTest.java new file mode 100644 index 0000000..30b5134 --- /dev/null +++ b/pcgroups/src/test/java/io/synadia/pcg/IntegrationTest.java @@ -0,0 +1,299 @@ +// Copyright 2024-2025 Synadia Communications Inc. +// Licensed 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 io.synadia.pcg; + +import io.nats.NatsRunnerUtils; +import io.nats.NatsServerRunner; +import io.nats.client.Connection; +import io.nats.client.ErrorListener; +import io.nats.client.Nats; +import io.nats.client.Options; +import io.nats.client.api.ConsumerConfiguration; +import io.nats.client.api.StorageType; +import io.nats.client.api.StreamConfiguration; +import io.nats.client.api.SubjectTransform; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * Integration tests for Static and Elastic consumer groups. + * Ported from Go: golang/test/stream_consumer_group_test.go + */ +class IntegrationTest { + + static { + NatsRunnerUtils.setDefaultOutputLevel(Level.SEVERE); + } + + /** + * Ported from Go TestStatic. + * Creates a stream with subject transform, publishes 10 messages, + * and verifies 2 static members consume all messages. + */ + @Test + void testStatic() throws Exception { + try (NatsServerRunner server = NatsServerRunner.builder().jetstream(true).build()) { + Options options = Options.builder().server(server.getNatsLocalhostUri()).errorListener(new ErrorListener() {}).build(); + Connection nc = Nats.connect(options); + + String streamName = "test"; + String cgName = "group"; + AtomicInteger c1 = new AtomicInteger(0); + AtomicInteger c2 = new AtomicInteger(0); + + // Create a stream with subject transform (like the Go test) + nc.jetStreamManagement().addStream(StreamConfiguration.builder() + .name(streamName) + .subjects("bar.*") + .subjectTransform(SubjectTransform.builder() + .source("bar.*") + .destination("{{partition(2,1)}}.bar.{{wildcard(1)}}") + .build()) + .storageType(StorageType.Memory) + .build()); + + // Publish 10 messages + for (int i = 0; i < 10; i++) { + nc.jetStream().publish("bar." + i, "payload".getBytes()); + } + + ConsumerConfiguration config = ConsumerConfiguration.builder() + .maxAckPending(1) + .ackWait(Duration.ofSeconds(1)) + .build(); + + // Create static consumer group + StaticConsumerGroup.create(nc, streamName, cgName, 2, "bar.*", + Arrays.asList("m1", "m2"), new ArrayList<>()); + + // Start consuming on both members + ConsumerGroupConsumeContext cc1 = StaticConsumerGroup.consume(nc, streamName, cgName, "m1", msg -> { + c1.incrementAndGet(); + try { + msg.ack(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }, config); + + ConsumerGroupConsumeContext cc2 = StaticConsumerGroup.consume(nc, streamName, cgName, "m2", msg -> { + c2.incrementAndGet(); + try { + msg.ack(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }, config); + + // Wait for all 10 messages to be consumed + long deadline = System.currentTimeMillis() + 5000; + while (c1.get() + c2.get() < 10) { + Thread.sleep(100); + if (System.currentTimeMillis() > deadline) { + fail("timeout: c1=" + c1.get() + " c2=" + c2.get() + " expected total=10"); + } + } + + assertEquals(10, c1.get() + c2.get()); + + cc1.stop(); + cc2.stop(); + + // Delete static consumer group + StaticConsumerGroup.delete(nc, streamName, cgName); + nc.close(); + } + } + + /** + * Ported from Go TestElastic. + * Creates a stream, tests elastic consume with member add/delete. + * Verifies partition redistribution when members are added and removed. + */ + @Test + void testElastic() throws Exception { + try (NatsServerRunner server = NatsServerRunner.builder().jetstream(true).build()) { + Options options = Options.builder().server(server.getNatsLocalhostUri()).errorListener(new ErrorListener() {}).build(); + Connection nc = Nats.connect(options); + + String streamName = "test"; + String cgName = "group"; + AtomicInteger c1 = new AtomicInteger(0); + AtomicInteger c2 = new AtomicInteger(0); + + // Create a stream (no subject transform needed for elastic) + nc.jetStreamManagement().addStream(StreamConfiguration.builder() + .name(streamName) + .subjects("bar.*") + .storageType(StorageType.Memory) + .build()); + + // Publish 10 messages + for (int i = 0; i < 10; i++) { + nc.jetStream().publish("bar." + i, "payload".getBytes()); + } + + ConsumerConfiguration config = ConsumerConfiguration.builder() + .maxAckPending(1) + .ackWait(Duration.ofSeconds(1)) + .build(); + + PartitioningFilter pf = new PartitioningFilter("bar.*", new int[]{1}); + // Create elastic consumer group + ElasticConsumerGroup.create(nc, streamName, cgName, 2, + Collections.singletonList(pf), -1, -1); + + // Start consuming on both members + ConsumerGroupConsumeContext cc1 = ElasticConsumerGroup.consume(nc, streamName, cgName, "m1", msg -> { + c1.incrementAndGet(); + try { + msg.ack(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }, config); + + ConsumerGroupConsumeContext cc2 = ElasticConsumerGroup.consume(nc, streamName, cgName, "m2", msg -> { + c2.incrementAndGet(); + try { + msg.ack(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }, config); + + // Wait for KV watchers to be established on the instance threads + Thread.sleep(200); + + // Add only m1 first + ElasticConsumerGroup.addMembers(nc, streamName, cgName, Collections.singletonList("m1")); + + // Wait for m1 to consume all 10 messages (only m1 is a member) + long deadline = System.currentTimeMillis() + 5000; + while (c1.get() != 10 || c2.get() != 0) { + Thread.sleep(100); + if (System.currentTimeMillis() > deadline) { + fail("timeout phase 1: c1=" + c1.get() + " c2=" + c2.get() + " expected c1=10 c2=0"); + } + } + assertEquals(10, c1.get()); + assertEquals(0, c2.get()); + + // Add m2 + ElasticConsumerGroup.addMembers(nc, streamName, cgName, Collections.singletonList("m2")); + + // Wait for consumers to be recreated + Thread.sleep(50); + + // Publish 10 more messages + for (int i = 0; i < 10; i++) { + nc.jetStream().publish("bar." + i, "payload".getBytes()); + } + + // Wait for split consumption (m1=15, m2=5) + deadline = System.currentTimeMillis() + 10000; + while (c1.get() != 15 || c2.get() != 5) { + Thread.sleep(100); + if (System.currentTimeMillis() > deadline) { + fail("timeout phase 2: c1=" + c1.get() + " c2=" + c2.get() + " expected c1=15 c2=5"); + } + } + + // Delete m1 + ElasticConsumerGroup.deleteMembers(nc, streamName, cgName, Collections.singletonList("m1")); + + // Wait for consumers to be recreated + Thread.sleep(50); + + // Publish 10 more messages + for (int i = 0; i < 10; i++) { + nc.jetStream().publish("bar." + i, "payload".getBytes()); + } + + // Wait for m2 to consume all (m1=15 stays, m2=15) + deadline = System.currentTimeMillis() + 10000; + while (c1.get() != 15 || c2.get() != 15) { + Thread.sleep(100); + if (System.currentTimeMillis() > deadline) { + fail("timeout phase 3: c1=" + c1.get() + " c2=" + c2.get() + " expected c1=15 c2=15"); + } + } + + cc1.stop(); + cc2.stop(); + + // Delete elastic consumer group + ElasticConsumerGroup.delete(nc, streamName, cgName); + + // --- Test elastic with no partitioning filters (partition on whole subject) --- + String cgName2 = "group2"; + AtomicInteger c3 = new AtomicInteger(0); + AtomicInteger c4 = new AtomicInteger(0); + + // Create elastic consumer group with no filter (null) and empty wildcards + ElasticConsumerGroup.create(nc, streamName, cgName2, 2, null, -1, -1); + + // Start consuming on both members + ConsumerGroupConsumeContext cc3 = ElasticConsumerGroup.consume(nc, streamName, cgName2, "m1", msg -> { + c3.incrementAndGet(); + try { + msg.ack(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }, config); + + ConsumerGroupConsumeContext cc4 = ElasticConsumerGroup.consume(nc, streamName, cgName2, "m2", msg -> { + c4.incrementAndGet(); + try { + msg.ack(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }, config); + + // Add members + ElasticConsumerGroup.addMembers(nc, streamName, cgName2, Arrays.asList("m1", "m2")); + + // Wait for all 30 messages (from previous publishes) to be consumed, split between the 2 members + // The stream has 30 messages total (10 + 10 + 10 from the three publish phases above) + deadline = System.currentTimeMillis() + 10000; + while (c3.get() + c4.get() < 30) { + Thread.sleep(100); + if (System.currentTimeMillis() > deadline) { + fail("timeout no-filter elastic: c3=" + c3.get() + " c4=" + c4.get() + " expected total=30"); + } + } + + assertEquals(30, c3.get() + c4.get()); + + cc3.stop(); + cc4.stop(); + + // Delete elastic consumer group + ElasticConsumerGroup.delete(nc, streamName, cgName2); + + nc.close(); + } + } +} diff --git a/pcgroups/src/test/java/io/synadia/pcg/PartitionUtilsTest.java b/pcgroups/src/test/java/io/synadia/pcg/PartitionUtilsTest.java new file mode 100644 index 0000000..c11673c --- /dev/null +++ b/pcgroups/src/test/java/io/synadia/pcg/PartitionUtilsTest.java @@ -0,0 +1,279 @@ +// Copyright 2024-2025 Synadia Communications Inc. +// Licensed 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 io.synadia.pcg; + +import io.nats.NatsRunnerUtils; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.logging.Level; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for PartitionUtils. + * These tests verify the partition distribution algorithm matches the Go implementation. + */ +class PartitionUtilsTest { + + static { + NatsRunnerUtils.setDefaultOutputLevel(Level.SEVERE); + } + + @Test + void testComposeKey() { + assertEquals("mystream.mycg", PartitionUtils.composeKey("mystream", "mycg")); + assertEquals("stream1.cg2", PartitionUtils.composeKey("stream1", "cg2")); + } + + @Test + void testComposeCGSName() { + assertEquals("mystream-mycg", PartitionUtils.composeCGSName("mystream", "mycg")); + assertEquals("stream1-cg2", PartitionUtils.composeCGSName("stream1", "cg2")); + } + + @Test + void testComposeStaticConsumerName() { + assertEquals("mycg-member1", PartitionUtils.composeStaticConsumerName("mycg", "member1")); + assertEquals("cg-m2", PartitionUtils.composeStaticConsumerName("cg", "m2")); + } + + @Test + void testGeneratePartitionFiltersBalanced() { + // Test with 6 partitions and 3 members - should distribute evenly + List members = Arrays.asList("m1", "m2", "m3"); + int maxMembers = 6; + + List m1Filters = PartitionUtils.generatePartitionFilters(members, maxMembers, null, "m1"); + List m2Filters = PartitionUtils.generatePartitionFilters(members, maxMembers, null, "m2"); + List m3Filters = PartitionUtils.generatePartitionFilters(members, maxMembers, null, "m3"); + + // Each member should get 2 partitions + assertEquals(2, m1Filters.size()); + assertEquals(2, m2Filters.size()); + assertEquals(2, m3Filters.size()); + + // m1 gets partitions 0, 1 + assertTrue(m1Filters.contains("0.>")); + assertTrue(m1Filters.contains("1.>")); + + // m2 gets partitions 2, 3 + assertTrue(m2Filters.contains("2.>")); + assertTrue(m2Filters.contains("3.>")); + + // m3 gets partitions 4, 5 + assertTrue(m3Filters.contains("4.>")); + assertTrue(m3Filters.contains("5.>")); + } + + @Test + void testGeneratePartitionFiltersUneven() { + // Test with 7 partitions and 3 members - remainder goes to first member + List members = Arrays.asList("m1", "m2", "m3"); + int maxMembers = 7; + + List m1Filters = PartitionUtils.generatePartitionFilters(members, maxMembers, null, "m1"); + List m2Filters = PartitionUtils.generatePartitionFilters(members, maxMembers, null, "m2"); + List m3Filters = PartitionUtils.generatePartitionFilters(members, maxMembers, null, "m3"); + + // m1 gets 3 partitions (0, 1, 6) + assertEquals(3, m1Filters.size()); + assertTrue(m1Filters.contains("0.>")); + assertTrue(m1Filters.contains("1.>")); + assertTrue(m1Filters.contains("6.>")); + + // m2 gets 2 partitions (2, 3) + assertEquals(2, m2Filters.size()); + assertTrue(m2Filters.contains("2.>")); + assertTrue(m2Filters.contains("3.>")); + + // m3 gets 2 partitions (4, 5) + assertEquals(2, m3Filters.size()); + assertTrue(m3Filters.contains("4.>")); + assertTrue(m3Filters.contains("5.>")); + } + + @Test + void testGeneratePartitionFiltersMemberMappings() { + // Test with explicit member mappings + List mappings = Arrays.asList( + new MemberMapping("alice", new int[]{0, 2, 4}), + new MemberMapping("bob", new int[]{1, 3, 5}) + ); + + List aliceFilters = PartitionUtils.generatePartitionFilters(null, 6, mappings, "alice"); + List bobFilters = PartitionUtils.generatePartitionFilters(null, 6, mappings, "bob"); + List charlieFilters = PartitionUtils.generatePartitionFilters(null, 6, mappings, "charlie"); + + assertEquals(3, aliceFilters.size()); + assertTrue(aliceFilters.contains("0.>")); + assertTrue(aliceFilters.contains("2.>")); + assertTrue(aliceFilters.contains("4.>")); + + assertEquals(3, bobFilters.size()); + assertTrue(bobFilters.contains("1.>")); + assertTrue(bobFilters.contains("3.>")); + assertTrue(bobFilters.contains("5.>")); + + // Non-member gets no partitions + assertTrue(charlieFilters.isEmpty()); + } + + @Test + void testGeneratePartitionFiltersDeduplicates() { + // Test that duplicate members are deduplicated + List members = Arrays.asList("m1", "m2", "m1", "m2", "m3"); + int maxMembers = 6; + + List m1Filters = PartitionUtils.generatePartitionFilters(members, maxMembers, null, "m1"); + List m2Filters = PartitionUtils.generatePartitionFilters(members, maxMembers, null, "m2"); + List m3Filters = PartitionUtils.generatePartitionFilters(members, maxMembers, null, "m3"); + + // After deduplication, should be same as 3 unique members + assertEquals(2, m1Filters.size()); + assertEquals(2, m2Filters.size()); + assertEquals(2, m3Filters.size()); + } + + @Test + void testGeneratePartitionFiltersSortsMembers() { + // Test that members are sorted before distribution + // Even if passed in different order, same member should get same partitions + List members1 = Arrays.asList("m3", "m1", "m2"); + List members2 = Arrays.asList("m1", "m2", "m3"); + int maxMembers = 6; + + List m1Filters1 = PartitionUtils.generatePartitionFilters(members1, maxMembers, null, "m1"); + List m1Filters2 = PartitionUtils.generatePartitionFilters(members2, maxMembers, null, "m1"); + + // Should get same partitions regardless of input order + assertEquals(m1Filters1, m1Filters2); + } + + @Test + void testGeneratePartitionFiltersCapToMaxMembers() { + // Test that members are capped to maxMembers + List members = Arrays.asList("m1", "m2", "m3", "m4", "m5"); + int maxMembers = 3; + + // After sorting: m1, m2, m3, m4, m5 + // Capped to first 3: m1, m2, m3 + // m4 and m5 should not get any partitions + + List m1Filters = PartitionUtils.generatePartitionFilters(members, maxMembers, null, "m1"); + List m4Filters = PartitionUtils.generatePartitionFilters(members, maxMembers, null, "m4"); + List m5Filters = PartitionUtils.generatePartitionFilters(members, maxMembers, null, "m5"); + + assertEquals(1, m1Filters.size()); + assertTrue(m4Filters.isEmpty()); + assertTrue(m5Filters.isEmpty()); + } + + @Test + void testGeneratePartitionFiltersEmptyMembers() { + List filters = PartitionUtils.generatePartitionFilters(Collections.emptyList(), 6, null, "m1"); + assertTrue(filters.isEmpty()); + } + + @Test + void testGeneratePartitionFiltersNullMembers() { + List filters = PartitionUtils.generatePartitionFilters(null, 6, null, "m1"); + assertTrue(filters.isEmpty()); + } + + @Test + void testGeneratePartitionFiltersSingleMember() { + List members = Collections.singletonList("m1"); + int maxMembers = 4; + + List m1Filters = PartitionUtils.generatePartitionFilters(members, maxMembers, null, "m1"); + + // Single member should get all partitions + assertEquals(4, m1Filters.size()); + assertTrue(m1Filters.contains("0.>")); + assertTrue(m1Filters.contains("1.>")); + assertTrue(m1Filters.contains("2.>")); + assertTrue(m1Filters.contains("3.>")); + } + + @Test + void testDeduplicateStringList() { + List input = Arrays.asList("a", "b", "a", "c", "b"); + List result = PartitionUtils.deduplicateStringList(input); + + assertEquals(3, result.size()); + assertEquals("a", result.get(0)); + assertEquals("b", result.get(1)); + assertEquals("c", result.get(2)); + } + + @Test + void testDeduplicateStringListNull() { + List result = PartitionUtils.deduplicateStringList(null); + assertTrue(result.isEmpty()); + } + + @Test + void testCalculatePullExpiry() { + // 10 second ack wait -> 5 second pull expiry + Duration ackWait = Duration.ofSeconds(10); + Duration pullExpiry = PartitionUtils.calculatePullExpiry(ackWait); + assertEquals(Duration.ofSeconds(5), pullExpiry); + + // 1 second ack wait -> 1 second pull expiry (min is 1 second) + ackWait = Duration.ofSeconds(1); + pullExpiry = PartitionUtils.calculatePullExpiry(ackWait); + assertEquals(Duration.ofSeconds(1), pullExpiry); + + // 500ms ack wait -> 1 second pull expiry (min is 1 second) + ackWait = Duration.ofMillis(500); + pullExpiry = PartitionUtils.calculatePullExpiry(ackWait); + assertEquals(Duration.ofSeconds(1), pullExpiry); + } + + @Test + void testCalculatePinnedTTL() { + // 10 second ack wait -> 10 second pinned TTL + Duration ackWait = Duration.ofSeconds(10); + Duration pinnedTTL = PartitionUtils.calculatePinnedTTL(ackWait); + assertEquals(Duration.ofSeconds(10), pinnedTTL); + + // 500ms ack wait -> 1 second pinned TTL (min is 1 second) + ackWait = Duration.ofMillis(500); + pinnedTTL = PartitionUtils.calculatePinnedTTL(ackWait); + assertEquals(Duration.ofSeconds(1), pinnedTTL); + } + + @Test + void testCalculateInactiveThreshold() { + Duration ackWait = Duration.ofSeconds(5); + Duration threshold = PartitionUtils.calculateInactiveThreshold(ackWait); + assertEquals(Duration.ofSeconds(5), threshold); + } + + @Test + void testConstants() { + assertEquals(2, PartitionUtils.PULL_TIMEOUT_DIVIDER); + assertEquals(1, PartitionUtils.CONSUMER_IDLE_TIMEOUT_FACTOR); + assertEquals(Duration.ofSeconds(5), PartitionUtils.DEFAULT_ACK_WAIT); + assertEquals(Duration.ofSeconds(1), PartitionUtils.MIN_PULL_EXPIRY_PINNED_TTL); + assertEquals("PCG", PartitionUtils.PRIORITY_GROUP_NAME); + assertEquals("static-consumer-groups", PartitionUtils.KV_STATIC_BUCKET_NAME); + assertEquals("elastic-consumer-groups", PartitionUtils.KV_ELASTIC_BUCKET_NAME); + } +} diff --git a/pcgroups/src/test/java/io/synadia/pcg/StaticConsumerGroupTest.java b/pcgroups/src/test/java/io/synadia/pcg/StaticConsumerGroupTest.java new file mode 100644 index 0000000..073d4ae --- /dev/null +++ b/pcgroups/src/test/java/io/synadia/pcg/StaticConsumerGroupTest.java @@ -0,0 +1,309 @@ +// Copyright 2024-2025 Synadia Communications Inc. +// Licensed 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 io.synadia.pcg; + +import io.nats.NatsRunnerUtils; +import io.nats.client.support.JsonParseException; +import io.synadia.pcg.exceptions.ConsumerGroupException; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.logging.Level; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for StaticConsumerGroupConfig. + * Tests config validation and JSON serialization compatibility with Go. + */ +class StaticConsumerGroupTest { + + static { + NatsRunnerUtils.setDefaultOutputLevel(Level.SEVERE); + } + + @Test + void testConfigWithMembers() { + StaticConsumerGroupConfig config = new StaticConsumerGroupConfig( + 4, "foo.>", + Arrays.asList("m1", "m2", "m3", "m4"), + new ArrayList<>() + ); + + assertEquals(4, config.getMaxMembers()); + assertEquals("foo.>", config.getFilter()); + assertEquals(4, config.getMembers().size()); + assertTrue(config.getMemberMappings().isEmpty()); + + assertTrue(config.isInMembership("m1")); + assertTrue(config.isInMembership("m4")); + assertFalse(config.isInMembership("m5")); + } + + @Test + void testConfigWithMemberMappings() { + List mappings = Arrays.asList( + new MemberMapping("alice", new int[]{0, 1}), + new MemberMapping("bob", new int[]{2, 3}) + ); + + StaticConsumerGroupConfig config = new StaticConsumerGroupConfig( + 4, "foo.>", new ArrayList<>(), mappings + ); + + assertEquals(4, config.getMaxMembers()); + assertTrue(config.getMembers().isEmpty()); + assertEquals(2, config.getMemberMappings().size()); + + assertTrue(config.isInMembership("alice")); + assertTrue(config.isInMembership("bob")); + assertFalse(config.isInMembership("charlie")); + } + + @Test + void testValidationMaxMembersZero() { + StaticConsumerGroupConfig config = new StaticConsumerGroupConfig( + 0, "foo.>", + Arrays.asList("m1", "m2"), + new ArrayList<>() + ); + + ConsumerGroupException exception = assertThrows(ConsumerGroupException.class, config::validate); + assertTrue(exception.getMessage().contains("max number of members must be >= 1")); + } + + @Test + void testValidationBothMembersAndMappings() { + List mappings = Collections.singletonList( + new MemberMapping("alice", new int[]{0, 1}) + ); + + StaticConsumerGroupConfig config = new StaticConsumerGroupConfig( + 2, "foo.>", + Arrays.asList("m1", "m2"), + mappings + ); + + ConsumerGroupException exception = assertThrows(ConsumerGroupException.class, config::validate); + assertTrue(exception.getMessage().contains("either members or member mappings must be provided, not both")); + } + + @Test + void testValidationDuplicateMemberNames() { + List mappings = Arrays.asList( + new MemberMapping("alice", new int[]{0, 1}), + new MemberMapping("alice", new int[]{2, 3}) + ); + + StaticConsumerGroupConfig config = new StaticConsumerGroupConfig( + 4, "foo.>", new ArrayList<>(), mappings + ); + + ConsumerGroupException exception = assertThrows(ConsumerGroupException.class, config::validate); + assertTrue(exception.getMessage().contains("member names must be unique")); + } + + @Test + void testValidationDuplicatePartitions() { + List mappings = Arrays.asList( + new MemberMapping("alice", new int[]{0, 1}), + new MemberMapping("bob", new int[]{1, 2}) + ); + + StaticConsumerGroupConfig config = new StaticConsumerGroupConfig( + 3, "foo.>", new ArrayList<>(), mappings + ); + + ConsumerGroupException exception = assertThrows(ConsumerGroupException.class, config::validate); + assertTrue(exception.getMessage().contains("partition numbers must be used only once")); + } + + @Test + void testValidationPartitionOutOfRange() { + List mappings = Arrays.asList( + new MemberMapping("alice", new int[]{0, 1}), + new MemberMapping("bob", new int[]{2, 5}) // 5 is out of range for maxMembers=4 + ); + + StaticConsumerGroupConfig config = new StaticConsumerGroupConfig( + 4, "foo.>", new ArrayList<>(), mappings + ); + + ConsumerGroupException exception = assertThrows(ConsumerGroupException.class, config::validate); + assertTrue(exception.getMessage().contains("partition numbers must be between 0 and one less")); + } + + @Test + void testValidationNotAllPartitionsCovered() { + List mappings = Arrays.asList( + new MemberMapping("alice", new int[]{0, 1}), + new MemberMapping("bob", new int[]{2}) // Missing partition 3 + ); + + StaticConsumerGroupConfig config = new StaticConsumerGroupConfig( + 4, "foo.>", new ArrayList<>(), mappings + ); + + ConsumerGroupException exception = assertThrows(ConsumerGroupException.class, config::validate); + assertTrue(exception.getMessage().contains("number of unique partition numbers must be equal")); + } + + @Test + void testValidationSuccess() throws ConsumerGroupException { + List mappings = Arrays.asList( + new MemberMapping("alice", new int[]{0, 1}), + new MemberMapping("bob", new int[]{2, 3}) + ); + + StaticConsumerGroupConfig config = new StaticConsumerGroupConfig( + 4, "foo.>", new ArrayList<>(), mappings + ); + + assertDoesNotThrow(config::validate); + } + + @Test + void testValidationWithMembersSuccess() throws ConsumerGroupException { + StaticConsumerGroupConfig config = new StaticConsumerGroupConfig( + 4, "foo.>", + Arrays.asList("m1", "m2", "m3", "m4"), + new ArrayList<>() + ); + + assertDoesNotThrow(config::validate); + } + + @Test + void testJsonSerializationWithMembers() throws JsonParseException { + StaticConsumerGroupConfig config = new StaticConsumerGroupConfig( + 4, "foo.>", + Arrays.asList("m1", "m2"), + new ArrayList<>() + ); + + String json = config.toJson(); + + // Verify JSON structure matches Go format + assertTrue(json.contains("\"max_members\":4")); + assertTrue(json.contains("\"filter\":\"foo.>\"")); + assertTrue(json.contains("\"members\":[\"m1\",\"m2\"]")); + + // Deserialize and verify + StaticConsumerGroupConfig deserialized = StaticConsumerGroupConfig.instance(json.getBytes(StandardCharsets.UTF_8)); + assertEquals(config.getMaxMembers(), deserialized.getMaxMembers()); + assertEquals(config.getFilter(), deserialized.getFilter()); + assertEquals(config.getMembers(), deserialized.getMembers()); + } + + @Test + void testJsonSerializationWithMappings() throws JsonParseException { + List mappings = Arrays.asList( + new MemberMapping("alice", new int[]{0, 1}), + new MemberMapping("bob", new int[]{2, 3}) + ); + + StaticConsumerGroupConfig config = new StaticConsumerGroupConfig( + 4, "foo.>", new ArrayList<>(), mappings + ); + + String json = config.toJson(); + + // Verify JSON structure matches Go format + assertTrue(json.contains("\"max_members\":4")); + assertTrue(json.contains("\"member_mappings\"")); + assertTrue(json.contains("\"member\":\"alice\"")); + assertTrue(json.contains("\"partitions\":[0,1]")); + + // Deserialize and verify + StaticConsumerGroupConfig deserialized = StaticConsumerGroupConfig.instance(json.getBytes(StandardCharsets.UTF_8)); + assertEquals(config.getMaxMembers(), deserialized.getMaxMembers()); + assertEquals(2, deserialized.getMemberMappings().size()); + assertEquals("alice", deserialized.getMemberMappings().get(0).getMember()); + assertArrayEquals(new int[]{0, 1}, deserialized.getMemberMappings().get(0).getPartitions()); + } + + @Test + void testJsonDeserializationFromGo() throws JsonParseException { + // This JSON is in the format produced by the Go implementation + String goJson = "{\"max_members\":4,\"filter\":\"foo.>\",\"members\":[\"m1\",\"m2\",\"m3\",\"m4\"]}"; + + StaticConsumerGroupConfig config = StaticConsumerGroupConfig.instance(goJson.getBytes(StandardCharsets.UTF_8)); + + assertEquals(4, config.getMaxMembers()); + assertEquals("foo.>", config.getFilter()); + assertEquals(4, config.getMembers().size()); + assertEquals("m1", config.getMembers().get(0)); + } + + @Test + void testJsonDeserializationWithMappingsFromGo() throws JsonParseException { + // This JSON is in the format produced by the Go implementation + String goJson = "{\"max_members\":4,\"filter\":\"bar.>\",\"member_mappings\":[{\"member\":\"alice\",\"partitions\":[0,1]},{\"member\":\"bob\",\"partitions\":[2,3]}]}"; + + StaticConsumerGroupConfig config = StaticConsumerGroupConfig.instance(goJson.getBytes(StandardCharsets.UTF_8)); + + assertEquals(4, config.getMaxMembers()); + assertEquals("bar.>", config.getFilter()); + assertEquals(2, config.getMemberMappings().size()); + assertEquals("alice", config.getMemberMappings().get(0).getMember()); + assertArrayEquals(new int[]{0, 1}, config.getMemberMappings().get(0).getPartitions()); + assertEquals("bob", config.getMemberMappings().get(1).getMember()); + assertArrayEquals(new int[]{2, 3}, config.getMemberMappings().get(1).getPartitions()); + } + + @Test + void testEquals() { + StaticConsumerGroupConfig config1 = new StaticConsumerGroupConfig( + 4, "foo.>", + Arrays.asList("m1", "m2"), + new ArrayList<>() + ); + + StaticConsumerGroupConfig config2 = new StaticConsumerGroupConfig( + 4, "foo.>", + Arrays.asList("m1", "m2"), + new ArrayList<>() + ); + + StaticConsumerGroupConfig config3 = new StaticConsumerGroupConfig( + 4, "foo.>", + Arrays.asList("m1", "m3"), + new ArrayList<>() + ); + + assertEquals(config1, config2); + assertNotEquals(config1, config3); + } + + @Test + void testHashCode() { + StaticConsumerGroupConfig config1 = new StaticConsumerGroupConfig( + 4, "foo.>", + Arrays.asList("m1", "m2"), + new ArrayList<>() + ); + + StaticConsumerGroupConfig config2 = new StaticConsumerGroupConfig( + 4, "foo.>", + Arrays.asList("m1", "m2"), + new ArrayList<>() + ); + + assertEquals(config1.hashCode(), config2.hashCode()); + } +} diff --git a/request-many/README.md b/request-many/README.md index f1f9d9a..91f2815 100644 --- a/request-many/README.md +++ b/request-many/README.md @@ -1,4 +1,4 @@ -![Synadia](src/main/javadoc/images/synadia-logo.png)      ![NATS](src/main/javadoc/images/large-logo.png) +Orbit # Request-Many Utility @@ -9,15 +9,12 @@ instead of the usual first response for a request. This allows you to implement * Scatter-gather pattern, getting responses from many workers. * Many responses, for instance, a multipart payload, from a single worker. -**Current Release**: 0.1.0 -  **Current Snapshot**: 0.1.1-SNAPSHOT -  **Gradle and Maven** `io.synadia:request-many` -[Dependencies Help](https://github.com/synadia-io/orbit.java?tab=readme-ov-file#dependencies) - -![Artifact](https://img.shields.io/badge/Artifact-io.synadia:retrier-00BC8E?labelColor=grey&style=flat) -[![License Apache 2](https://img.shields.io/badge/License-Apache2-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0) -[![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.synadia/retrier/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.synadia/retrier) -[![javadoc](https://javadoc.io/badge2/io.synadia/retrier/javadoc.svg)](https://javadoc.io/doc/io.synadia/retrier) +![Artifact](https://img.shields.io/badge/Artifact-io.synadia:request--many-197556?labelColor=grey&style=flat) +![0.1.1](https://img.shields.io/badge/Current_Release-0.1.1-27AAE0) +![0.1.2](https://img.shields.io/badge/Current_Snapshot-0.1.2--SNAPSHOT-27AAE0) +[![Dependencies Help](https://img.shields.io/badge/Dependencies%20Help-27AAE0)](https://github.com/synadia-io/orbit.java?tab=readme-ov-file#dependencies) +[![javadoc](https://javadoc.io/badge2/io.synadia/request-many/javadoc.svg)](https://javadoc.io/doc/io.synadia/request-many) +[![Maven Central](https://img.shields.io/maven-central/v/io.synadia/request-many)](https://img.shields.io/maven-central/v/io.synadia/request-many) ### Request Many Usage diff --git a/request-many/build.gradle b/request-many/build.gradle index 94f7aa6..f3c0597 100644 --- a/request-many/build.gradle +++ b/request-many/build.gradle @@ -13,7 +13,7 @@ plugins { id 'signing' } -def jarVersion = "0.1.1" +def jarVersion = "0.1.2" group = 'io.synadia' def isMerge = System.getenv("BUILD_EVENT") == "push" @@ -38,7 +38,7 @@ repositories { } dependencies { - implementation 'io.nats:jnats:2.21.1' + implementation 'io.nats:jnats:2.25.1' testImplementation 'io.nats:jnats-server-runner:1.2.8' testImplementation 'org.junit.jupiter:junit-jupiter:5.9.0' @@ -66,7 +66,7 @@ tasks.register('bundle', Bundle) { jar { manifest { - attributes('Automatic-Module-Name': 'io.synadia.request-many') + attributes('Automatic-Module-Name': 'io.synadia.request.many') } bnd (['Implementation-Title': 'Request Many Utility', 'Implementation-Version': jarVersion, diff --git a/request-many/src/main/javadoc/overview.html b/request-many/src/main/javadoc/overview.html index c14e2f6..f42bdc7 100644 --- a/request-many/src/main/javadoc/overview.html +++ b/request-many/src/main/javadoc/overview.html @@ -1,5 +1,5 @@ diff --git a/retrier/README.md b/retrier/README.md index 1054051..6ab93bc 100644 --- a/retrier/README.md +++ b/retrier/README.md @@ -1,18 +1,15 @@ -![Synadia](src/main/javadoc/images/synadia-logo.png)      ![NATS](src/main/javadoc/images/large-logo.png) +Orbit # Retrier Utility The retrier is a general purpose Java utility that can execute any action (function) in a retriable manner. -**Current Release**: 0.2.1 -  **Current Snapshot**: 0.2.2-SNAPSHOT -  **Gradle and Maven** `io.synadia:retrier` -[Dependencies Help](https://github.com/synadia-io/orbit.java?tab=readme-ov-file#dependencies) - -![Artifact](https://img.shields.io/badge/Artifact-io.synadia:retrier-00BC8E?labelColor=grey&style=flat) -[![License Apache 2](https://img.shields.io/badge/License-Apache2-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0) -[![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.synadia/retrier/badge.svg)](https://maven-badges.herokuapp.com/maven-central/io.synadia/retrier) +![Artifact](https://img.shields.io/badge/Artifact-io.synadia:retrier-197556?labelColor=grey&style=flat) +![0.2.1](https://img.shields.io/badge/Current_Release-0.2.1-27AAE0) +![0.2.2](https://img.shields.io/badge/Current_Snapshot-0.2.2--SNAPSHOT-27AAE0) +[![Dependencies Help](https://img.shields.io/badge/Dependencies%20Help-27AAE0)](https://github.com/synadia-io/orbit.java?tab=readme-ov-file#dependencies) [![javadoc](https://javadoc.io/badge2/io.synadia/retrier/javadoc.svg)](https://javadoc.io/doc/io.synadia/retrier) +[![Maven Central](https://img.shields.io/maven-central/v/io.synadia/retrier)](https://img.shields.io/maven-central/v/io.synadia/retrier) ### Retrier Usage diff --git a/retrier/build.gradle b/retrier/build.gradle index 5d7e45b..fe663de 100644 --- a/retrier/build.gradle +++ b/retrier/build.gradle @@ -38,7 +38,7 @@ repositories { } dependencies { - implementation 'io.nats:jnats:2.21.1' + implementation 'io.nats:jnats:2.25.1' testImplementation 'io.nats:jnats-server-runner:1.2.8' testImplementation 'org.junit.jupiter:junit-jupiter:5.9.0' diff --git a/retrier/src/main/java/io/synadia/retrier/Retrier.java b/retrier/src/main/java/io/synadia/retrier/Retrier.java index 2fdc49d..dc3249a 100644 --- a/retrier/src/main/java/io/synadia/retrier/Retrier.java +++ b/retrier/src/main/java/io/synadia/retrier/Retrier.java @@ -33,7 +33,7 @@ public static T execute(RetryConfig config, RetryAction action) throws Ex * or the observer declines to retry. */ public static T execute(RetryConfig config, RetryAction action, RetryObserver observer) throws Exception { - long[] backoffPolicy = config.getBackoffPolicy();; + long[] backoffPolicy = config.getBackoffPolicy(); int plen = backoffPolicy.length; int retries = 0; long deadlineExpiresAt = System.currentTimeMillis() + config.getDeadline(); diff --git a/retrier/src/main/javadoc/overview.html b/retrier/src/main/javadoc/overview.html index cff357e..0a0f7ab 100644 --- a/retrier/src/main/javadoc/overview.html +++ b/retrier/src/main/javadoc/overview.html @@ -1,5 +1,5 @@ diff --git a/schedule-message/.gitignore b/schedule-message/.gitignore new file mode 100644 index 0000000..b3e2ca5 --- /dev/null +++ b/schedule-message/.gitignore @@ -0,0 +1,77 @@ + +# NATS stuff # +############## +gnatsd.log +*.csv + +# Compiled source # +################### +*.com +*.class +*.dll +*.exe +*.o +*.so +/bin + +# Packages # +############ +*.7z +*.dmg +*.gz +*.iso +*.rar +*.tar +*.zip + +# Logs and databases # +###################### +*.log + +# OS generated files # +###################### +.DS_Store* +ehthumbs.db +Icon? +Thumbs.db + +# Editor Files # +################ +*~ +*.swp +.sts4-cache/* + +# Gradle Files # +################ +.gradle +.m2 + +# Build output directies +/target +*/target +/build +*/build + +# IntelliJ specific files/directories +out +.idea +*.ipr +*.iws +*.iml +atlassian-ide-plugin.xml + +# Eclipse specific files/directories +.classpath +.project +.settings +.metadata + +# NetBeans specific files/directories +.nbattrs + +# VSCode +.vscode/ + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +/target/ diff --git a/schedule-message/LICENSE b/schedule-message/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/schedule-message/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed 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. diff --git a/schedule-message/NOTICE b/schedule-message/NOTICE new file mode 100644 index 0000000..ff3c8b4 --- /dev/null +++ b/schedule-message/NOTICE @@ -0,0 +1,5 @@ +Orbit Java +Copyright (c) 2024-2025 Synadia Communications Inc. All Rights Reserved. + +This product includes software developed at +Synadia Communications Inc. \ No newline at end of file diff --git a/schedule-message/README.md b/schedule-message/README.md new file mode 100644 index 0000000..90c7fba --- /dev/null +++ b/schedule-message/README.md @@ -0,0 +1,107 @@ +Orbit + +# Scheduled Message + +Utility to leverage the ability to schedule a message to be published at a later time. +Eventually the ability to schedule a message to publish based on a cron or schedule. + +https://github.com/nats-io/nats-architecture-and-design/blob/main/adr/ADR-51.md + +![Artifact](https://img.shields.io/badge/Artifact-io.synadia:schedule--message-197556?labelColor=grey&style=flat) +![0.0.3](https://img.shields.io/badge/Current_Release-0.0.3-27AAE0) +![0.0.4](https://img.shields.io/badge/Current_Snapshot-0.0.4--SNAPSHOT-27AAE0) +[![Dependencies Help](https://img.shields.io/badge/Dependencies%20Help-27AAE0)](https://github.com/synadia-io/orbit.java?tab=readme-ov-file#dependencies) +[![javadoc](https://javadoc.io/badge2/io.synadia/schedule-message/javadoc.svg)](https://javadoc.io/doc/io.synadia/schedule-message) +[![Maven Central](https://img.shields.io/maven-central/v/io.synadia/schedule-message)](https://img.shields.io/maven-central/v/io.synadia/schedule-message) + +### Building a Scheduled Message + +A scheduled message is just a normal message with some extra headers. + +It consists of a subject that holds the schedule message, +a subject that is the target subject for the schedule, +the scheduling information which can be a specific time or a standard cron based schedule. + +The `ScheduledMessageBuilder` makes it easy to create this using a builder pattern. + +### Basic message content + +You can add message data and custom headers like a normal message with these builder methods: + +``` +scheduleSubject(String scheduleSubject) // set the primary subject +targetSubject(String targetSubject) // set the subject that is the target of the schedule +data(byte[] data) // set the data from a byte array +data(String data) // set the data from a UTF-8 string +data(String data, Charset charset) // set the data from a string +headers(Headers headers) // set user headers +copy(Message message) // copy the subject, data and headers from an existing message +``` + +### Scheduling variations + +There are several scheduling variations. Only the last one given to the builder is used. + +``` +scheduleAt(ZonedDateTime zdt) +scheduleImmediate() +schedule(Predefined predefined) +scheduleEvery(String every) +scheduleCron(String cron) +``` + +### TTL + +You can set a scheduled message to have a TTL + +``` +messageTtl(MessageTtl messageTtl) +``` + +### Predefined Schedules + +There is an enum that pre-defines some repeating schedule behavior. +``` +/** + * Run once a year, midnight, Jan. 1st. Same as Yearly. Equivalent to cron string 0 0 0 1 1 * + */ +Annually("@annually"), + +/** + * Run once a year, midnight, Jan. 1st. Same as Annually. Equivalent to cron string 0 0 0 1 1 * + */ +Yearly("@yearly"), + +/** + * Run once a month, midnight, first of month. Same as cron format 0 0 0 1 * * + */ +Monthly("@monthly"), + +/** + * Run once a week, midnight between Sat/Sun. Equivalent to cron string 0 0 0 * * 0 + */ +Weekly("@weekly"), + +/** + * Run once a day, midnight. Same as Daily. Equivalent to cron string 0 0 0 * * * + */ +Midnight("@midnight"), + +/** + * Run once a day, midnight. Same as Midnight. Equivalent to cron string 0 0 0 * * * + */ +Daily("@daily"), + +/** + * Run once an hour, beginning of hour. Equivalent to cron string 0 0 * * * * + */ +Hourly("@hourly"); +``` + +### ADR + +The original feature design document: [JetStream Message Scheduler ADR-51](https://github.com/nats-io/nats-architecture-and-design/blob/main/adr/ADR-51.md) + +--- +Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +See [LICENSE](LICENSE) and [NOTICE](NOTICE) file for details. diff --git a/schedule-message/build.gradle b/schedule-message/build.gradle new file mode 100644 index 0000000..406e1f3 --- /dev/null +++ b/schedule-message/build.gradle @@ -0,0 +1,193 @@ +import aQute.bnd.gradle.Bundle + +plugins { + id("java") + id("java-library") + id("maven-publish") + id("jacoco") + id("biz.aQute.bnd.builder") version "7.1.0" + id("org.gradle.test-retry") version "1.6.4" + id("io.github.gradle-nexus.publish-plugin") version "2.0.0" + id("signing") +} + +def jarVersion = "0.0.4" +group = 'io.synadia' + +def isRelease = System.getenv("BUILD_EVENT") == "release" + +def tc = System.getenv("TARGET_COMPATIBILITY"); +def targetCompat = tc == "21" ? JavaVersion.VERSION_21 : (tc == "17" ? JavaVersion.VERSION_17 : JavaVersion.VERSION_1_8) +def jarEnd = tc == "21" ? "-jdk21" : (tc == "17" ? "-jdk17" : "") +def jarAndArtifactName = "schedule-message" + jarEnd + +version = isRelease ? jarVersion : jarVersion + "-SNAPSHOT" // version is the variable the build actually uses. + +System.out.println("Java: " + System.getProperty("java.version")) +System.out.println("Target Compatibility: " + targetCompat) +System.out.println(group + ":" + jarAndArtifactName + ":" + version) + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = targetCompat +} + +repositories { + mavenLocal() + mavenCentral() + maven { url="https://repo1.maven.org/maven2/" } + maven { url="https://central.sonatype.com/repository/maven-snapshots/" } +} + +dependencies { + implementation 'io.nats:jnats:2.25.3' + implementation 'org.jspecify:jspecify:1.0.0' + implementation 'io.synadia:counters:0.2.2' + + testImplementation 'io.nats:jnats-server-runner:3.1.0' + testImplementation 'org.junit.jupiter:junit-jupiter:5.14.1' + testImplementation 'org.junit.platform:junit-platform-launcher:1.14.3' +} + +sourceSets { + main { + java { + srcDirs = ['src/main/java','src/examples/java'] + } + } + test { + java { + srcDirs = ['src/test/java'] + } + } +} + +tasks.register('bundle', Bundle) { + from sourceSets.main.output + exclude("io/synadia/examples/**") +} + +jar { + bundle { + bnd("Bundle-Name": "io.synadia.schedule.message", + "Bundle-Vendor": "synadia.io", + "Bundle-Description": "JetStream Scheduled Messages", + "Bundle-DocURL": "https://github.com/synadia-io/orbit.java/tree/main/schedule-message", + "Target-Compatibility": "Java " + targetCompat + ) + } +} + +test { + // Use junit platform for unit tests + useJUnitPlatform() +} + +javadoc { + options.overview = 'src/main/javadoc/overview.html' // relative to source root + source = sourceSets.main.allJava + title = "Synadia Communications Inc. JetStream Scheduled Messages" + excludes ['**/examples/**'] + classpath = sourceSets.main.runtimeClasspath +} + +tasks.register('examplesJar', Jar) { + archiveClassifier.set('examples') + manifest { + attributes('Implementation-Title': 'JetStream Scheduled Messages', + 'Implementation-Version': jarVersion, + 'Implementation-Vendor': 'synadia.io') + } + from(sourceSets.main.output) { + include "io/synadia/examples/**" + } +} + +tasks.register('javadocJar', Jar) { + archiveClassifier.set('javadoc') + from javadoc +} + +tasks.register('sourcesJar', Jar) { + archiveClassifier.set('sources') + from sourceSets.main.allSource +} + +artifacts { + archives javadocJar, sourcesJar, examplesJar +} + +jacoco { + toolVersion = "0.8.12" +} + +jacocoTestReport { + reports { + xml.required = true // coveralls plugin depends on xml format report + html.required = true + } + afterEvaluate { // only report on main library not examples + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, + exclude: ['**/examples**','**/Debug**']) + })) + } +} + +nexusPublishing { + repositories { + sonatype { + nexusUrl.set(uri("https://ossrh-staging-api.central.sonatype.com/service/local/")) + snapshotRepositoryUrl.set(uri("https://central.sonatype.com/repository/maven-snapshots/")) + username = System.getenv('OSSRH_USERNAME') + password = System.getenv('OSSRH_PASSWORD') + } + } +} + +publishing { + publications { + mavenJava(MavenPublication) { + from components.java + artifact sourcesJar + artifact examplesJar + artifact javadocJar + pom { + name = jarAndArtifactName + packaging = 'jar' + groupId = group + artifactId = jarAndArtifactName + description = "Synadia Communications Inc. JetStream Scheduled Messages" + url = "https://github.com/synadia-io/orbit.java/tree/main/schedule-message" + licenses { + license { + name = 'The Apache License, Version 2.0' + url = 'http://www.apache.org/licenses/LICENSE-2.0.txt' + } + } + developers { + developer { + id = "synadia" + name = "Synadia" + email = "info@synadia.com" + url = "https://synadia.io" + } + } + scm { + url = 'https://github.com/synadia-io/orbit.java' + } + } + } + } +} + +if (isRelease) { + signing { + def signingKeyId = System.getenv('SIGNING_KEY_ID') + def signingKey = System.getenv('SIGNING_KEY') + def signingPassword = System.getenv('SIGNING_PASSWORD') + useInMemoryPgpKeys(signingKeyId, signingKey, signingPassword) + sign configurations.archives + sign publishing.publications.mavenJava + } +} diff --git a/schedule-message/gradle/wrapper/gradle-wrapper.jar b/schedule-message/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/schedule-message/gradle/wrapper/gradle-wrapper.jar differ diff --git a/schedule-message/gradle/wrapper/gradle-wrapper.properties b/schedule-message/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ca025c8 --- /dev/null +++ b/schedule-message/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/schedule-message/gradlew b/schedule-message/gradlew new file mode 100644 index 0000000..23d15a9 --- /dev/null +++ b/schedule-message/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed 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 +# +# https://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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/schedule-message/gradlew.bat b/schedule-message/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/schedule-message/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/schedule-message/settings.gradle b/schedule-message/settings.gradle new file mode 100644 index 0000000..d37f9a3 --- /dev/null +++ b/schedule-message/settings.gradle @@ -0,0 +1,13 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + maven { url="https://repo1.maven.org/maven2/" } + maven { url="https://central.sonatype.com/repository/maven-snapshots/" } + maven { url="https://plugins.gradle.org/m2/" } + } + plugins { + id("biz.aQute.bnd.builder") version "7.1.0" + } +} +rootProject.name = 'schedule-message' diff --git a/schedule-message/src/examples/java/io/synadia/examples/ScheduleBasics.java b/schedule-message/src/examples/java/io/synadia/examples/ScheduleBasics.java new file mode 100644 index 0000000..f6d4ba6 --- /dev/null +++ b/schedule-message/src/examples/java/io/synadia/examples/ScheduleBasics.java @@ -0,0 +1,112 @@ +// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.examples; + +import io.nats.client.*; +import io.nats.client.api.StorageType; +import io.nats.client.support.DateTimeUtils; +import io.synadia.sm.ScheduleManagement; +import io.synadia.sm.ScheduledMessageBuilder; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static io.synadia.examples.ScheduleExampleUtils.report; + +/** + * Example: build and publish a few scheduled messages using + * {@link io.synadia.sm.ScheduledMessageBuilder#scheduleMessage(io.nats.client.JetStream)}. + */ +public class ScheduleBasics { + + /** Stream name used by this example. */ + public static final String STREAM = "schedules-enabled"; + + /** Prefix for all schedule subjects in this example. */ + public static final String SCHEDULE_PREFIX = "schedule."; + + /** Prefix for all target subjects in this example. */ + public static final String TARGET_PREFIX = "target."; + + private static final String SCHEDULES = SCHEDULE_PREFIX + ">"; + private static final String TARGETS = TARGET_PREFIX + "*"; + + /** Subject patterns the example stream accepts. */ + public static final String[] STREAM_SUBJECTS = new String[]{SCHEDULES, TARGETS}; + + private ScheduleBasics() {} + + /** + * Example entry point. + * @param args ignored + */ + public static void main(String[] args) { + try { + Options options = new Options.Builder() + .server("nats://localhost:4222") + .errorListener(new ErrorListener() {}) + .build(); + + try (Connection connection = Nats.connect(options)) { + JetStreamManagement jsm = connection.jetStreamManagement(); + JetStream js = connection.jetStream(); + + // delete the stream in case it existed, just for a fresh example + try { jsm.deleteStream(STREAM); } catch (Exception ignore) {} + + // Use the utility to properly create a schedulable stream + ScheduleManagement.createSchedulableStream(jsm, STREAM, StorageType.Memory, STREAM_SUBJECTS); + + CountDownLatch latch = new CountDownLatch(4); + Dispatcher d = connection.createDispatcher(); + + // subscribe to the subject that receives the schedule message + js.subscribe(SCHEDULES, d, m -> { + report("MONITORING via '" + SCHEDULES + "'", m); + m.ack(); + }, false); + + // subscribe to the target subject + js.subscribe(TARGETS, d, m -> { + report("TARGETED via '" + TARGETS + "'", m); + m.ack(); + latch.countDown(); + }, false); + + report("SCHEDULING " + SCHEDULE_PREFIX + "now"); + new ScheduledMessageBuilder() + .scheduleSubject(SCHEDULE_PREFIX + "now") + .targetSubject(TARGET_PREFIX + "now") + .scheduleImmediate() + .data("Schedule-Now") + .scheduleMessage(js); + + report("SCHEDULING " + SCHEDULE_PREFIX + "at"); + new ScheduledMessageBuilder() + .scheduleSubject(SCHEDULE_PREFIX + "at") + .targetSubject(TARGET_PREFIX + "at") + .scheduleAt(DateTimeUtils.gmtNow().plusSeconds(5)) + .data("Scheduled-At") + .scheduleMessage(js); + + report("SCHEDULING " + SCHEDULE_PREFIX + "every"); + new ScheduledMessageBuilder() + .scheduleSubject(SCHEDULE_PREFIX + "every") + .targetSubject(TARGET_PREFIX + "every") + .scheduleEvery(1, TimeUnit.SECONDS) + .data("Every Second") + .scheduleMessage(js); + + latch.await(); + + // The "every" schedule keeps firing until it is removed. + report("CANCEL " + SCHEDULE_PREFIX + "every", + ScheduleManagement.cancelSchedule(jsm, SCHEDULE_PREFIX + "every", STREAM)); + } + } + catch (Exception e) { + e.printStackTrace(); + } + } +} diff --git a/schedule-message/src/examples/java/io/synadia/examples/ScheduleBasicsAlternate.java b/schedule-message/src/examples/java/io/synadia/examples/ScheduleBasicsAlternate.java new file mode 100644 index 0000000..b7fc51a --- /dev/null +++ b/schedule-message/src/examples/java/io/synadia/examples/ScheduleBasicsAlternate.java @@ -0,0 +1,120 @@ +// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.examples; + +import io.nats.client.*; +import io.nats.client.api.StorageType; +import io.nats.client.api.StreamInfo; +import io.nats.client.support.DateTimeUtils; +import io.synadia.sm.ScheduleManagement; +import io.synadia.sm.ScheduledMessageBuilder; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static io.synadia.examples.ScheduleExampleUtils.report; + +/** + * Example: same scenario as {@link ScheduleBasics}, but built using + * {@link io.synadia.sm.ScheduledMessageBuilder#build()} and then published + * via {@link io.nats.client.JetStream#publish(io.nats.client.Message)}. + * There is really no reason to do this unless you specifically want to log the + * actual message. + */ +public class ScheduleBasicsAlternate { + + /** Stream name used by this example. */ + public static final String STREAM = "schedules-enabled"; + + /** Prefix for all schedule subjects in this example. */ + public static final String SCHEDULE_PREFIX = "schedule."; + + /** Prefix for all target subjects in this example. */ + public static final String TARGET_PREFIX = "target."; + + private static final String SCHEDULES = SCHEDULE_PREFIX + ">"; + private static final String TARGETS = TARGET_PREFIX + "*"; + + /** Subject patterns the example stream accepts. */ + public static final String[] STREAM_SUBJECTS = new String[]{SCHEDULES, TARGETS}; + + private ScheduleBasicsAlternate() {} + + /** + * Example entry point. + * @param args ignored + */ + public static void main(String[] args) { + try { + Options options = new Options.Builder() + .server("nats://localhost:4222") + .errorListener(new ErrorListener() {}) + .build(); + + try (Connection connection = Nats.connect(options)) { + JetStreamManagement jsm = connection.jetStreamManagement(); + JetStream js = connection.jetStream(); + + // delete the stream in case it existed, just for a fresh example + try { jsm.deleteStream(STREAM); } catch (Exception ignore) {} + + // Use the utility to properly create a schedulable stream + StreamInfo si = ScheduleManagement.createSchedulableStream(jsm, STREAM, StorageType.Memory, STREAM_SUBJECTS); + report("Created stream", si.getConfiguration()); + + CountDownLatch latch = new CountDownLatch(4); + Dispatcher d = connection.createDispatcher(); + + // subscribe to the subject that receives the schedule message + js.subscribe(SCHEDULES, d, m -> { + report("MONITORING via '" + SCHEDULES + "'", m); + m.ack(); + }, false); + + // subscribe to the target subject + js.subscribe(TARGETS, d, m -> { + report("TARGETED via '" + TARGETS + "'", m); + m.ack(); + latch.countDown(); + }, false); + + Message m = new ScheduledMessageBuilder() + .scheduleSubject(SCHEDULE_PREFIX + "now") + .targetSubject(TARGET_PREFIX + "now") + .scheduleImmediate() + .data("Schedule-Now") + .build(); + report("SCHEDULING " + SCHEDULE_PREFIX + "now", m); + js.publish(m); + + m = new ScheduledMessageBuilder() + .scheduleSubject(SCHEDULE_PREFIX + "at") + .targetSubject(TARGET_PREFIX + "at") + .scheduleAt(DateTimeUtils.gmtNow().plusSeconds(5)) + .data("Scheduled-At") + .build(); + report("SCHEDULING " + SCHEDULE_PREFIX + "at", m); + js.publish(m); + + m = new ScheduledMessageBuilder() + .scheduleSubject(SCHEDULE_PREFIX + "every") + .targetSubject(TARGET_PREFIX + "every") + .scheduleEvery(1, TimeUnit.SECONDS) + .data("Every Second") + .build(); + report("SCHEDULING " + SCHEDULE_PREFIX + "every", m); + js.publish(m); + + latch.await(); + + // The "every" schedule keeps firing until it is removed. + report("CANCEL " + SCHEDULE_PREFIX + "every", + ScheduleManagement.cancelSchedule(jsm, SCHEDULE_PREFIX + "every", STREAM)); + } + } + catch (Exception e) { + e.printStackTrace(); + } + } +} diff --git a/schedule-message/src/examples/java/io/synadia/examples/ScheduleCancel.java b/schedule-message/src/examples/java/io/synadia/examples/ScheduleCancel.java new file mode 100644 index 0000000..866fef7 --- /dev/null +++ b/schedule-message/src/examples/java/io/synadia/examples/ScheduleCancel.java @@ -0,0 +1,117 @@ +// Copyright (c) 2025-2026 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.examples; + +import io.nats.client.*; +import io.nats.client.api.StorageType; +import io.synadia.sm.ScheduleManagement; +import io.synadia.sm.ScheduleManagement.Result; +import io.synadia.sm.ScheduledMessageBuilder; + +import java.time.Duration; + +import static io.synadia.examples.ScheduleExampleUtils.report; + +/** + * Example: stop a running schedule with each + * {@link ScheduleManagement#cancelSchedule cancelSchedule} overload. + *

+ *

    + *
  • by stream + sequence — the lowest-level call
  • + *
  • by stream + subject — looks up the sequence on a known stream
  • + *
  • by subject only — the helper locates the stream too
  • + *
+ * The example also shows a {@link Result#NOT_FOUND} outcome by cancelling a + * subject that no longer has a schedule. + */ +public class ScheduleCancel { + + /** Stream name used by this example. */ + public static final String STREAM = "schedules-enabled"; + + /** Prefix for all schedule subjects in this example. */ + public static final String SCHEDULE_PREFIX = "schedule."; + + /** Prefix for all target subjects in this example. */ + public static final String TARGET_PREFIX = "target."; + + private static final String SCHEDULES = SCHEDULE_PREFIX + ">"; + private static final String TARGETS = TARGET_PREFIX + "*"; + + /** Subject patterns the example stream accepts. */ + public static final String[] STREAM_SUBJECTS = new String[]{SCHEDULES, TARGETS}; + + private ScheduleCancel() {} + + /** + * Example entry point. + * @param args ignored + */ + public static void main(String[] args) { + try { + Options options = new Options.Builder() + .server("nats://localhost:4222") + .errorListener(new ErrorListener() {}) + .build(); + + try (Connection connection = Nats.connect(options)) { + JetStreamManagement jsm = connection.jetStreamManagement(); + JetStream js = connection.jetStream(); + + // delete the stream in case it existed, just for a fresh example + try { jsm.deleteStream(STREAM); } catch (Exception ignore) {} + + ScheduleManagement.createSchedulableStream(jsm, STREAM, StorageType.Memory, STREAM_SUBJECTS); + + String bySeqSubject = SCHEDULE_PREFIX + "by-seq"; + String bySubjectSubject = SCHEDULE_PREFIX + "by-subject"; + String byLookupSubject = SCHEDULE_PREFIX + "by-lookup"; + + // Schedule each one an hour out so it can't fire while the example runs. + long bySeq = new ScheduledMessageBuilder() + .scheduleSubject(bySeqSubject) + .targetSubject(TARGET_PREFIX + "by-seq") + .scheduleIn(Duration.ofHours(1)) + .data("By-Seq") + .scheduleMessage(js); + report("SCHEDULED " + bySeqSubject + " at sequence " + bySeq); + + new ScheduledMessageBuilder() + .scheduleSubject(bySubjectSubject) + .targetSubject(TARGET_PREFIX + "by-subject") + .scheduleIn(Duration.ofHours(1)) + .data("By-Subject") + .scheduleMessage(js); + report("SCHEDULED " + bySubjectSubject); + + new ScheduledMessageBuilder() + .scheduleSubject(byLookupSubject) + .targetSubject(TARGET_PREFIX + "by-lookup") + .scheduleIn(Duration.ofHours(1)) + .data("By-Lookup") + .scheduleMessage(js); + report("SCHEDULED " + byLookupSubject); + + // 1) Sequence-based cancel. + Result r1 = ScheduleManagement.cancelSchedule(jsm, STREAM, bySeq); + report("CANCEL by sequence", r1); + + // 2) Subject + stream cancel. + Result r2 = ScheduleManagement.cancelSchedule(jsm, bySubjectSubject, STREAM); + report("CANCEL by subject + stream", r2); + + // 3) Subject-only cancel; the helper locates the stream. + Result r3 = ScheduleManagement.cancelSchedule(jsm, byLookupSubject); + report("CANCEL by subject (auto-find)", r3); + + // 4) Cancelling something that isn't there returns NOT_FOUND. + Result r4 = ScheduleManagement.cancelSchedule(jsm, bySeqSubject, STREAM); + report("CANCEL already-cancelled", r4); + } + } + catch (Exception e) { + e.printStackTrace(); + } + } +} diff --git a/schedule-message/src/examples/java/io/synadia/examples/ScheduleCron.java b/schedule-message/src/examples/java/io/synadia/examples/ScheduleCron.java new file mode 100644 index 0000000..55a1b39 --- /dev/null +++ b/schedule-message/src/examples/java/io/synadia/examples/ScheduleCron.java @@ -0,0 +1,107 @@ +// Copyright (c) 2025-2026 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.examples; + +import io.nats.client.*; +import io.nats.client.api.StorageType; +import io.synadia.sm.ScheduleManagement; +import io.synadia.sm.ScheduledMessageBuilder; + +import java.util.concurrent.CountDownLatch; + +import static io.synadia.examples.ScheduleExampleUtils.report; + +/** + * Example: schedule using cron expressions, including the + * {@link io.synadia.sm.ScheduledMessageBuilder#scheduleCron(String, String)} + * variant that takes an IANA time zone. + *

+ * NATS schedules use a six-field cron form (second minute hour day month + * day-of-week) per ADR-51. The expressions below fire on short intervals + * so the example completes quickly; the time-zone-bound expression behaves + * identically here because it does not pin a time of day, but the call shape + * is the same one you would use for {@code "0 30 9 * * *"} ("9:30 every day + * in New York"). + */ +public class ScheduleCron { + + /** Stream name used by this example. */ + public static final String STREAM = "schedules-enabled"; + + /** Prefix for all schedule subjects in this example. */ + public static final String SCHEDULE_PREFIX = "schedule."; + + /** Prefix for all target subjects in this example. */ + public static final String TARGET_PREFIX = "target."; + + private static final String SCHEDULES = SCHEDULE_PREFIX + ">"; + private static final String TARGETS = TARGET_PREFIX + "*"; + + /** Subject patterns the example stream accepts. */ + public static final String[] STREAM_SUBJECTS = new String[]{SCHEDULES, TARGETS}; + + private ScheduleCron() {} + + /** + * Example entry point. + * @param args ignored + */ + public static void main(String[] args) { + try { + Options options = new Options.Builder() + .server("nats://localhost:4222") + .errorListener(new ErrorListener() {}) + .build(); + + try (Connection connection = Nats.connect(options)) { + JetStreamManagement jsm = connection.jetStreamManagement(); + JetStream js = connection.jetStream(); + + // delete the stream in case it existed, just for a fresh example + try { jsm.deleteStream(STREAM); } catch (Exception ignore) {} + + ScheduleManagement.createSchedulableStream(jsm, STREAM, StorageType.Memory, STREAM_SUBJECTS); + + CountDownLatch latch = new CountDownLatch(4); + Dispatcher d = connection.createDispatcher(); + + js.subscribe(TARGETS, d, m -> { + report("TARGETED via '" + TARGETS + "'", m); + m.ack(); + latch.countDown(); + }, false); + + String cronSubject = SCHEDULE_PREFIX + "cron"; + String cronTzSubject = SCHEDULE_PREFIX + "cron-tz"; + + // Six-field cron: every two seconds. + report("SCHEDULING " + cronSubject + " with cron '*/2 * * * * *'"); + new ScheduledMessageBuilder() + .scheduleSubject(cronSubject) + .targetSubject(TARGET_PREFIX + "cron") + .scheduleCron("*/2 * * * * *") + .data("Cron-Every-2s") + .scheduleMessage(js); + + // Same expression, evaluated in a specific IANA time zone. + report("SCHEDULING " + cronTzSubject + " with cron '*/3 * * * * *' (America/New_York)"); + new ScheduledMessageBuilder() + .scheduleSubject(cronTzSubject) + .targetSubject(TARGET_PREFIX + "cron-tz") + .scheduleCron("*/3 * * * * *", "America/New_York") + .data("Cron-Every-3s-NY") + .scheduleMessage(js); + + latch.await(); + + // Cron schedules keep firing until they are removed. + report("CANCEL " + cronSubject, ScheduleManagement.cancelSchedule(jsm, cronSubject, STREAM)); + report("CANCEL " + cronTzSubject, ScheduleManagement.cancelSchedule(jsm, cronTzSubject, STREAM)); + } + } + catch (Exception e) { + e.printStackTrace(); + } + } +} diff --git a/schedule-message/src/examples/java/io/synadia/examples/ScheduleEveryAndIn.java b/schedule-message/src/examples/java/io/synadia/examples/ScheduleEveryAndIn.java new file mode 100644 index 0000000..195abf4 --- /dev/null +++ b/schedule-message/src/examples/java/io/synadia/examples/ScheduleEveryAndIn.java @@ -0,0 +1,128 @@ +// Copyright (c) 2025-2026 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.examples; + +import io.nats.client.*; +import io.nats.client.api.StorageType; +import io.synadia.sm.ScheduleManagement; +import io.synadia.sm.ScheduledMessageBuilder; + +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static io.synadia.examples.ScheduleExampleUtils.report; + +/** + * Example: the remaining {@code scheduleEvery(...)} and {@code scheduleIn(...)} + * overloads on {@link ScheduledMessageBuilder} that the {@link ScheduleBasics} + * example does not exercise: + *

    + *
  • {@link ScheduledMessageBuilder#scheduleEvery(String)} with a Go + * {@code time.ParseDuration} string ({@code "2s"}, {@code "1m30s"})
  • + *
  • {@link ScheduledMessageBuilder#scheduleEvery(Duration)}
  • + *
  • {@link ScheduledMessageBuilder#scheduleIn(Duration)} (one-shot)
  • + *
  • {@link ScheduledMessageBuilder#scheduleIn(long, TimeUnit)} (one-shot)
  • + *
+ */ +public class ScheduleEveryAndIn { + + /** Stream name used by this example. */ + public static final String STREAM = "schedules-enabled"; + + /** Prefix for all schedule subjects in this example. */ + public static final String SCHEDULE_PREFIX = "schedule."; + + /** Prefix for all target subjects in this example. */ + public static final String TARGET_PREFIX = "target."; + + private static final String SCHEDULES = SCHEDULE_PREFIX + ">"; + private static final String TARGETS = TARGET_PREFIX + "*"; + + /** Subject patterns the example stream accepts. */ + public static final String[] STREAM_SUBJECTS = new String[]{SCHEDULES, TARGETS}; + + private ScheduleEveryAndIn() {} + + /** + * Example entry point. + * @param args ignored + */ + public static void main(String[] args) { + try { + Options options = new Options.Builder() + .server("nats://localhost:4222") + .errorListener(new ErrorListener() {}) + .build(); + + try (Connection connection = Nats.connect(options)) { + JetStreamManagement jsm = connection.jetStreamManagement(); + JetStream js = connection.jetStream(); + + // delete the stream in case it existed, just for a fresh example + try { jsm.deleteStream(STREAM); } catch (Exception ignore) {} + + ScheduleManagement.createSchedulableStream(jsm, STREAM, StorageType.Memory, STREAM_SUBJECTS); + + CountDownLatch latch = new CountDownLatch(6); + Dispatcher d = connection.createDispatcher(); + + js.subscribe(TARGETS, d, m -> { + report("TARGETED via '" + TARGETS + "'", m); + m.ack(); + latch.countDown(); + }, false); + + // @every using Go's time.ParseDuration syntax. + String everyStringSubject = SCHEDULE_PREFIX + "every-string"; + report("SCHEDULING " + everyStringSubject + " with scheduleEvery(\"2s\")"); + new ScheduledMessageBuilder() + .scheduleSubject(everyStringSubject) + .targetSubject(TARGET_PREFIX + "every-string") + .scheduleEvery("2s") + .data("Every-2s-String") + .scheduleMessage(js); + + // @every using a java.time.Duration. + String everyDurationSubject = SCHEDULE_PREFIX + "every-duration"; + report("SCHEDULING " + everyDurationSubject + " with scheduleEvery(Duration.ofSeconds(3))"); + new ScheduledMessageBuilder() + .scheduleSubject(everyDurationSubject) + .targetSubject(TARGET_PREFIX + "every-duration") + .scheduleEvery(Duration.ofSeconds(3)) + .data("Every-3s-Duration") + .scheduleMessage(js); + + // One-shot via Duration. + String inDurationSubject = SCHEDULE_PREFIX + "in-duration"; + report("SCHEDULING " + inDurationSubject + " with scheduleIn(Duration.ofSeconds(2))"); + new ScheduledMessageBuilder() + .scheduleSubject(inDurationSubject) + .targetSubject(TARGET_PREFIX + "in-duration") + .scheduleIn(Duration.ofSeconds(2)) + .data("In-2s-Duration") + .scheduleMessage(js); + + // One-shot via (long, TimeUnit). + String inUnitSubject = SCHEDULE_PREFIX + "in-unit"; + report("SCHEDULING " + inUnitSubject + " with scheduleIn(4, TimeUnit.SECONDS)"); + new ScheduledMessageBuilder() + .scheduleSubject(inUnitSubject) + .targetSubject(TARGET_PREFIX + "in-unit") + .scheduleIn(4, TimeUnit.SECONDS) + .data("In-4s-Unit") + .scheduleMessage(js); + + latch.await(); + + // The two recurring schedules will keep firing if not cancelled. + report("CANCEL " + everyStringSubject, ScheduleManagement.cancelSchedule(jsm, everyStringSubject, STREAM)); + report("CANCEL " + everyDurationSubject, ScheduleManagement.cancelSchedule(jsm, everyDurationSubject, STREAM)); + } + } + catch (Exception e) { + e.printStackTrace(); + } + } +} diff --git a/schedule-message/src/examples/java/io/synadia/examples/ScheduleExampleUtils.java b/schedule-message/src/examples/java/io/synadia/examples/ScheduleExampleUtils.java new file mode 100644 index 0000000..9eef648 --- /dev/null +++ b/schedule-message/src/examples/java/io/synadia/examples/ScheduleExampleUtils.java @@ -0,0 +1,67 @@ +// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.examples; + +import io.nats.client.Message; +import io.nats.client.impl.Headers; + +/** + * Small console-logging helpers shared by the example apps. + */ +public class ScheduleExampleUtils { + + private ScheduleExampleUtils() {} + + /** + * Print a pipe-separated, timestamped line to {@code System.out}. Each object is + * rendered with {@link Object#toString()}, except {@link Message}, which is rendered + * via {@link #toString(Message)}. + * @param objects the values to render + */ + public static void report(Object... objects) { + StringBuilder sb = new StringBuilder(); + boolean first = true; + for (Object o : objects) { + if (first) { + first = false; + } + else { + sb.append(" | "); + } + if (o instanceof Message) { + sb.append(toString((Message)o)); + } + else { + sb.append(o.toString()); + } + } + System.out.println("[" + System.currentTimeMillis() + "] " + sb); + } + + /** + * Format a {@link Message} as a short multi-line string showing the subject, data + * (when present), and headers (when any). + * @param msg the message to format + * @return a human-readable representation + */ + public static String toString(Message msg) { + StringBuilder sb = new StringBuilder(System.lineSeparator()) + .append(" Subject: ").append(msg.getSubject()); + if (msg.getData() == null || msg.getData().length == 0) { + sb.append(" | No Data"); + } + else { + sb.append(" | Data: ").append(new String(msg.getData())); + } + Headers h = msg.getHeaders(); + if (h != null && !h.isEmpty()) { + sb.append(System.lineSeparator()).append(" Headers:"); + for (String key : h.keySet()) { + sb.append(System.lineSeparator()).append(" "); + sb.append(key).append("=").append(h.get(key)); + } + } + return sb.toString(); + } +} diff --git a/schedule-message/src/examples/java/io/synadia/examples/ScheduleFromSource.java b/schedule-message/src/examples/java/io/synadia/examples/ScheduleFromSource.java new file mode 100644 index 0000000..fab67f4 --- /dev/null +++ b/schedule-message/src/examples/java/io/synadia/examples/ScheduleFromSource.java @@ -0,0 +1,120 @@ +// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.examples; + +import io.nats.client.*; +import io.nats.client.api.StorageType; +import io.nats.client.impl.Headers; +import io.nats.client.impl.NatsMessage; +import io.synadia.sm.ScheduleManagement; +import io.synadia.sm.ScheduledMessageBuilder; + +import java.time.Duration; +import java.util.concurrent.CountDownLatch; + +import static io.synadia.examples.ScheduleExampleUtils.report; + +/** + * Example: schedule a message whose body and headers are taken from the last + * message published on a separate source subject (the + * {@code Nats-Schedule-Source} feature in ADR-51). + */ +public class ScheduleFromSource { + + /** Stream name used by this example. */ + public static final String STREAM = "schedules-enabled"; + + private static final String SCHEDULES = "schedules"; + private static final String TARGET = "target"; + private static final String SOURCE = "source"; + + /** Subject patterns the example stream accepts. */ + public static final String[] STREAM_SUBJECTS = new String[]{SCHEDULES, TARGET, SOURCE}; + + private ScheduleFromSource() {} + + /** + * Example entry point. + * @param args ignored + */ + public static void main(String[] args) { + try { + Options options = new Options.Builder() + .server("nats://localhost:4222") + .errorListener(new ErrorListener() {}) + .build(); + + try (Connection connection = Nats.connect(options)) { + JetStreamManagement jsm = connection.jetStreamManagement(); + JetStream js = connection.jetStream(); + + // delete the stream in case it existed, just for a fresh example + try { jsm.deleteStream(STREAM); } catch (Exception ignore) {} + + // Use the utility to properly create a schedulable stream + ScheduleManagement.createSchedulableStream(jsm, STREAM, StorageType.Memory, STREAM_SUBJECTS); + + CountDownLatch latch1 = new CountDownLatch(1); + CountDownLatch latch2 = new CountDownLatch(2); + Dispatcher d = connection.createDispatcher(); + + // subscribe to the subject that receives the schedule message + js.subscribe(SCHEDULES, d, m -> { + report("SCHEDULED (received)", m); + m.ack(); + }, false); + + // subscribe to the target subject + js.subscribe(SOURCE, d, m -> { + report("SOURCED (received)", m); + m.ack(); + }, false); + + // subscribe to the target subject + js.subscribe(TARGET, d, m -> { + report("TARGETED (received)", m); + m.ack(); + latch1.countDown(); + latch2.countDown(); + }, false); + + // Publish Data to the Source subject + String sourceData = "data1"; + Headers sourceHeaders = new Headers(); + sourceHeaders.put("foo1", "bar1"); + Message sourceMessage = new NatsMessage(SOURCE, null, sourceHeaders, sourceData.getBytes()); + report("SOURCE 1 (publishing)", sourceMessage); + js.publish(sourceMessage); + connection.flush(Duration.ofSeconds(1)); + + Message scheduleMessage = new ScheduledMessageBuilder() + .scheduleSubject(SCHEDULES) + .targetSubject(TARGET) + .scheduleImmediate() + .sources(SOURCE) + .build(); + report("SCHEDULE 1 (publishing)", scheduleMessage); + js.publish(scheduleMessage); + + latch1.await(); + + sourceData = "data2"; + sourceHeaders = new Headers(); + sourceHeaders.put("foo2", "bar2"); + sourceMessage = new NatsMessage(SOURCE, null, sourceHeaders, sourceData.getBytes()); + report("SOURCE 2 (publishing)", sourceMessage); + js.publish(sourceMessage); + connection.flush(Duration.ofSeconds(1)); + + report("SCHEDULE 2 (publishing)", scheduleMessage); + js.publish(scheduleMessage); + + latch2.await(); + } + } + catch (Exception e) { + e.printStackTrace(); + } + } +} diff --git a/schedule-message/src/examples/java/io/synadia/examples/ScheduleHeadersAndCopy.java b/schedule-message/src/examples/java/io/synadia/examples/ScheduleHeadersAndCopy.java new file mode 100644 index 0000000..49fad3c --- /dev/null +++ b/schedule-message/src/examples/java/io/synadia/examples/ScheduleHeadersAndCopy.java @@ -0,0 +1,112 @@ +// Copyright (c) 2025-2026 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.examples; + +import io.nats.client.*; +import io.nats.client.api.StorageType; +import io.nats.client.impl.Headers; +import io.nats.client.impl.NatsMessage; +import io.synadia.sm.ScheduleManagement; +import io.synadia.sm.ScheduledMessageBuilder; + +import java.util.concurrent.CountDownLatch; + +import static io.synadia.examples.ScheduleExampleUtils.report; + +/** + * Example: attaching user headers to a schedule and seeding a schedule from + * an existing {@link Message} via {@link ScheduledMessageBuilder#copy(Message)}. + *

+ * The builder writes its own {@code Nats-Schedule-*} headers after copying + * user headers, so user headers are preserved on the message that the schedule + * publishes to the target subject. + */ +public class ScheduleHeadersAndCopy { + + /** Stream name used by this example. */ + public static final String STREAM = "schedules-enabled"; + + /** Prefix for all schedule subjects in this example. */ + public static final String SCHEDULE_PREFIX = "schedule."; + + /** Prefix for all target subjects in this example. */ + public static final String TARGET_PREFIX = "target."; + + private static final String SCHEDULES = SCHEDULE_PREFIX + ">"; + private static final String TARGETS = TARGET_PREFIX + "*"; + + /** Subject patterns the example stream accepts. */ + public static final String[] STREAM_SUBJECTS = new String[]{SCHEDULES, TARGETS}; + + private ScheduleHeadersAndCopy() {} + + /** + * Example entry point. + * @param args ignored + */ + public static void main(String[] args) { + try { + Options options = new Options.Builder() + .server("nats://localhost:4222") + .errorListener(new ErrorListener() {}) + .build(); + + try (Connection connection = Nats.connect(options)) { + JetStreamManagement jsm = connection.jetStreamManagement(); + JetStream js = connection.jetStream(); + + // delete the stream in case it existed, just for a fresh example + try { jsm.deleteStream(STREAM); } catch (Exception ignore) {} + + ScheduleManagement.createSchedulableStream(jsm, STREAM, StorageType.Memory, STREAM_SUBJECTS); + + CountDownLatch latch = new CountDownLatch(2); + Dispatcher d = connection.createDispatcher(); + + js.subscribe(TARGETS, d, m -> { + report("TARGETED via '" + TARGETS + "'", m); + m.ack(); + latch.countDown(); + }, false); + + // 1) Attach custom headers to a scheduled message. + Headers userHeaders = new Headers(); + userHeaders.put("X-Trace-Id", "abc-123"); + userHeaders.put("X-Origin", "ScheduleHeadersAndCopy"); + + String withHeadersSubject = SCHEDULE_PREFIX + "with-headers"; + report("SCHEDULING " + withHeadersSubject + " with custom headers"); + new ScheduledMessageBuilder() + .scheduleSubject(withHeadersSubject) + .targetSubject(TARGET_PREFIX + "with-headers") + .scheduleImmediate() + .headers(userHeaders) + .data("Has-User-Headers") + .scheduleMessage(js); + + // 2) Seed a schedule from an existing message via copy(). + // copy() pulls subject, data, and headers; the schedule subject + // here is taken from the source message, while the target subject + // is set explicitly. + Headers seedHeaders = new Headers(); + seedHeaders.put("X-Seeded-From", "template"); + Message seed = new NatsMessage(SCHEDULE_PREFIX + "copied", null, seedHeaders, "Seed-Data".getBytes()); + report("SEED message (template for copy())", seed); + + String copiedSubject = SCHEDULE_PREFIX + "copied"; + report("SCHEDULING " + copiedSubject + " built via copy(seed)"); + new ScheduledMessageBuilder() + .copy(seed) + .targetSubject(TARGET_PREFIX + "copied") + .scheduleImmediate() + .scheduleMessage(js); + + latch.await(); + } + } + catch (Exception e) { + e.printStackTrace(); + } + } +} diff --git a/schedule-message/src/examples/java/io/synadia/examples/SchedulePredefined.java b/schedule-message/src/examples/java/io/synadia/examples/SchedulePredefined.java new file mode 100644 index 0000000..46d2b00 --- /dev/null +++ b/schedule-message/src/examples/java/io/synadia/examples/SchedulePredefined.java @@ -0,0 +1,86 @@ +// Copyright (c) 2025-2026 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.examples; + +import io.nats.client.*; +import io.nats.client.api.StorageType; +import io.synadia.sm.PredefinedSchedules; +import io.synadia.sm.ScheduleManagement; +import io.synadia.sm.ScheduledMessageBuilder; + +import static io.synadia.examples.ScheduleExampleUtils.report; + +/** + * Example: build a schedule for every entry of {@link PredefinedSchedules} + * ({@code @hourly}, {@code @daily}, {@code @weekly}, ...). The shortest + * predefined interval is {@code @hourly}, so this example would not fire + * within a reasonable test run; instead it publishes each schedule, reports + * what got stored on the stream, then cancels it. + */ +public class SchedulePredefined { + + /** Stream name used by this example. */ + public static final String STREAM = "schedules-enabled"; + + /** Prefix for all schedule subjects in this example. */ + public static final String SCHEDULE_PREFIX = "schedule."; + + /** Prefix for all target subjects in this example. */ + public static final String TARGET_PREFIX = "target."; + + private static final String SCHEDULES = SCHEDULE_PREFIX + ">"; + private static final String TARGETS = TARGET_PREFIX + "*"; + + /** Subject patterns the example stream accepts. */ + public static final String[] STREAM_SUBJECTS = new String[]{SCHEDULES, TARGETS}; + + private SchedulePredefined() {} + + /** + * Example entry point. + * @param args ignored + */ + public static void main(String[] args) { + try { + Options options = new Options.Builder() + .server("nats://localhost:4222") + .errorListener(new ErrorListener() {}) + .build(); + + try (Connection connection = Nats.connect(options)) { + JetStreamManagement jsm = connection.jetStreamManagement(); + JetStream js = connection.jetStream(); + + // delete the stream in case it existed, just for a fresh example + try { jsm.deleteStream(STREAM); } catch (Exception ignore) {} + + ScheduleManagement.createSchedulableStream(jsm, STREAM, StorageType.Memory, STREAM_SUBJECTS); + + for (PredefinedSchedules p : PredefinedSchedules.values()) { + String scheduleSubject = SCHEDULE_PREFIX + p.name().toLowerCase(); + String targetSubject = TARGET_PREFIX + p.name().toLowerCase(); + + // Show what is being sent to the schedule subject. + Message m = new ScheduledMessageBuilder() + .scheduleSubject(scheduleSubject) + .targetSubject(targetSubject) + .schedule(p) + .data("Predefined-" + p.name()) + .build(); + report("SCHEDULING " + p.name(), m); + + js.publish(m); + + // Predefined schedules fire no more often than hourly, + // so cancel each one immediately to keep the stream tidy. + report("CANCEL " + scheduleSubject, + ScheduleManagement.cancelSchedule(jsm, scheduleSubject, STREAM)); + } + } + } + catch (Exception e) { + e.printStackTrace(); + } + } +} diff --git a/schedule-message/src/examples/java/io/synadia/examples/SchedulePublishAndCancel.java b/schedule-message/src/examples/java/io/synadia/examples/SchedulePublishAndCancel.java new file mode 100644 index 0000000..8fb6099 --- /dev/null +++ b/schedule-message/src/examples/java/io/synadia/examples/SchedulePublishAndCancel.java @@ -0,0 +1,169 @@ +// Copyright (c) 2025-2026 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.examples; + +import io.nats.client.*; +import io.nats.client.api.PublishAck; +import io.nats.client.api.StorageType; +import io.nats.client.impl.Headers; +import io.synadia.sm.ScheduleManagement; +import io.synadia.sm.ScheduledMessageBuilder; + +import java.time.Duration; +import java.util.concurrent.CountDownLatch; + +import static io.synadia.examples.ScheduleExampleUtils.report; + +/** + * Example: the three atomic publish-and-stop calls on {@link ScheduleManagement}. + * Each one publishes a message to the {@code targetSubject} and stops the named + * schedule as a single atomic step. + *

    + *
  1. {@link ScheduleManagement#publishAndCancelSchedule(JetStreamManagement, String, String, byte[], Headers)} + * — unguarded; always publishes and returns a non-null ack
  2. + *
  3. {@link ScheduleManagement#publishAndCancelScheduleIfExists(JetStreamManagement, String, String, byte[], Headers)} + * — guarded; returns {@code null} when the schedule is no longer present
  4. + *
  5. {@link ScheduleManagement#publishAndCancelSchedule(JetStreamManagement, String, long, String, byte[], Headers)} + * — explicit-sequence overload, uses + * {@code Nats-Expected-Last-Subject-Sequence} so the publish only succeeds + * while the schedule message is still at the named sequence
  6. + *
+ * The example also calls the guarded form against an already-cancelled subject + * to show the {@code null} return. + */ +public class SchedulePublishAndCancel { + + /** Stream name used by this example. */ + public static final String STREAM = "schedules-enabled"; + + /** Prefix for all schedule subjects in this example. */ + public static final String SCHEDULE_PREFIX = "schedule."; + + /** Prefix for all target subjects in this example. */ + public static final String TARGET_PREFIX = "target."; + + private static final String SCHEDULES = SCHEDULE_PREFIX + ">"; + private static final String TARGETS = TARGET_PREFIX + "*"; + + /** Subject patterns the example stream accepts. */ + public static final String[] STREAM_SUBJECTS = new String[]{SCHEDULES, TARGETS}; + + private SchedulePublishAndCancel() {} + + /** + * Example entry point. + * @param args ignored + */ + public static void main(String[] args) { + try { + Options options = new Options.Builder() + .server("nats://localhost:4222") + .errorListener(new ErrorListener() {}) + .build(); + + try (Connection connection = Nats.connect(options)) { + JetStreamManagement jsm = connection.jetStreamManagement(); + JetStream js = connection.jetStream(); + + // delete the stream in case it existed, just for a fresh example + try { jsm.deleteStream(STREAM); } catch (Exception ignore) {} + + ScheduleManagement.createSchedulableStream(jsm, STREAM, StorageType.Memory, STREAM_SUBJECTS); + + CountDownLatch latch = new CountDownLatch(3); + Dispatcher d = connection.createDispatcher(); + + js.subscribe(TARGETS, d, m -> { + report("TARGETED via '" + TARGETS + "'", m); + m.ack(); + latch.countDown(); + }, false); + + String unguardedSubject = SCHEDULE_PREFIX + "unguarded"; + String guardedSubject = SCHEDULE_PREFIX + "guarded"; + String bySeqSubject = SCHEDULE_PREFIX + "by-seq"; + + // Plant three schedules an hour out so none fire during the example. + new ScheduledMessageBuilder() + .scheduleSubject(unguardedSubject) + .targetSubject(TARGET_PREFIX + "unguarded") + .scheduleIn(Duration.ofHours(1)) + .data("placeholder") + .scheduleMessage(js); + + new ScheduledMessageBuilder() + .scheduleSubject(guardedSubject) + .targetSubject(TARGET_PREFIX + "guarded") + .scheduleIn(Duration.ofHours(1)) + .data("placeholder") + .scheduleMessage(js); + + long bySeq = new ScheduledMessageBuilder() + .scheduleSubject(bySeqSubject) + .targetSubject(TARGET_PREFIX + "by-seq") + .scheduleIn(Duration.ofHours(1)) + .data("placeholder") + .scheduleMessage(js); + + // 1) Unguarded form: publish + stop, no existence check. + // Always publishes and returns a non-null PublishAck (the call + // throws on publish failure), so no null check is needed. + PublishAck ack1 = ScheduleManagement.publishAndCancelSchedule( + jsm, + unguardedSubject, + TARGET_PREFIX + "unguarded", + "unguarded-payload".getBytes(), + null); + report("publishAndCancelSchedule (unguarded) seq=" + ack1.getSeqno()); + + // 2) Guarded form: looks up the schedule first. + // Returns null if the schedule is no longer present (or if the + // stream lookup is ambiguous), in which case nothing was published. + PublishAck ack2 = ScheduleManagement.publishAndCancelScheduleIfExists( + jsm, + guardedSubject, + TARGET_PREFIX + "guarded", + "guarded-payload".getBytes(), + null); + if (ack2 == null) { + report("publishAndCancelScheduleIfExists (present) - schedule not found, nothing published"); + } + else { + report("publishAndCancelScheduleIfExists (present) seq=" + ack2.getSeqno()); + } + + // 3) Explicit-sequence overload: uses Nats-Expected-Last-Subject-Sequence. + // Return type is non-nullable PublishAck; the call throws on a + // precondition or publish failure, so no null check is needed. + PublishAck ack3 = ScheduleManagement.publishAndCancelSchedule( + jsm, + bySeqSubject, + bySeq, + TARGET_PREFIX + "by-seq", + "by-seq-payload".getBytes(), + null); + report("publishAndCancelSchedule (by sequence) seq=" + ack3.getSeqno()); + + // 4) Guarded form against an already-cancelled subject -> null, no publish. + PublishAck ack4 = ScheduleManagement.publishAndCancelScheduleIfExists( + jsm, + unguardedSubject, + TARGET_PREFIX + "unguarded", + "should-not-be-sent".getBytes(), + null); + if (ack4 == null) { + report("publishAndCancelScheduleIfExists (gone) - null as expected, nothing published"); + } + else { + report("publishAndCancelScheduleIfExists (gone) unexpectedly published seq=" + ack4.getSeqno()); + } + + latch.await(); + } + } + catch (Exception e) { + e.printStackTrace(); + } + } +} diff --git a/schedule-message/src/examples/java/io/synadia/examples/ScheduleTtlAndRollup.java b/schedule-message/src/examples/java/io/synadia/examples/ScheduleTtlAndRollup.java new file mode 100644 index 0000000..b72b2f7 --- /dev/null +++ b/schedule-message/src/examples/java/io/synadia/examples/ScheduleTtlAndRollup.java @@ -0,0 +1,133 @@ +// Copyright (c) 2025-2026 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.examples; + +import io.nats.client.*; +import io.nats.client.api.StorageType; +import io.synadia.sm.ScheduleManagement; +import io.synadia.sm.ScheduledMessageBuilder; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static io.synadia.examples.ScheduleExampleUtils.report; + +/** + * Example: the {@code Nats-Schedule-TTL} and {@code Nats-Schedule-Rollup} + * headers, set via {@link ScheduledMessageBuilder#messageTtl(MessageTtl)} and + * {@link ScheduledMessageBuilder#rollup()}. + *

+ *

    + *
  • TTL applies a per-message TTL to each message the schedule + * publishes; the stream must allow per-message TTLs (the schedulable + * stream helper turns this on for you).
  • + *
  • Rollup sets {@code Nats-Schedule-Rollup: sub}, which causes the + * target subject to be rolled up on each fire — only the latest + * message remains on the target subject.
  • + *
+ * After a few fires this example reports the per-subject stream counts so the + * difference between TTL'd and rolled-up targets is visible. + */ +public class ScheduleTtlAndRollup { + + /** Stream name used by this example. */ + public static final String STREAM = "schedules-enabled"; + + /** Prefix for all schedule subjects in this example. */ + public static final String SCHEDULE_PREFIX = "schedule."; + + /** Prefix for all target subjects in this example. */ + public static final String TARGET_PREFIX = "target."; + + private static final String SCHEDULES = SCHEDULE_PREFIX + ">"; + private static final String TARGETS = TARGET_PREFIX + "*"; + + /** Subject patterns the example stream accepts. */ + public static final String[] STREAM_SUBJECTS = new String[]{SCHEDULES, TARGETS}; + + private ScheduleTtlAndRollup() {} + + /** + * Example entry point. + * @param args ignored + */ + public static void main(String[] args) { + try { + Options options = new Options.Builder() + .server("nats://localhost:4222") + .errorListener(new ErrorListener() {}) + .build(); + + try (Connection connection = Nats.connect(options)) { + JetStreamManagement jsm = connection.jetStreamManagement(); + JetStream js = connection.jetStream(); + + // delete the stream in case it existed, just for a fresh example + try { jsm.deleteStream(STREAM); } catch (Exception ignore) {} + + ScheduleManagement.createSchedulableStream(jsm, STREAM, StorageType.Memory, STREAM_SUBJECTS); + + String ttlSubject = SCHEDULE_PREFIX + "ttl"; + String rollupSubject = SCHEDULE_PREFIX + "rollup"; + String ttlTarget = TARGET_PREFIX + "ttl"; + String rollupTarget = TARGET_PREFIX + "rollup"; + + AtomicInteger ttlHits = new AtomicInteger(); + AtomicInteger rollupHits = new AtomicInteger(); + CountDownLatch latch = new CountDownLatch(6); + Dispatcher d = connection.createDispatcher(); + + js.subscribe(TARGETS, d, m -> { + if (m.getSubject().equals(ttlTarget)) ttlHits.incrementAndGet(); + if (m.getSubject().equals(rollupTarget)) rollupHits.incrementAndGet(); + report("TARGETED via '" + TARGETS + "'", m); + m.ack(); + latch.countDown(); + }, false); + + // Each published message gets a 3-second TTL on the stream. + report("SCHEDULING " + ttlSubject + " with messageTtl(3s)"); + new ScheduledMessageBuilder() + .scheduleSubject(ttlSubject) + .targetSubject(ttlTarget) + .scheduleEvery(2, TimeUnit.SECONDS) + .messageTtl(MessageTtl.seconds(3)) + .data("TTL-3s") + .scheduleMessage(js); + + // Each fire replaces any prior message on the target subject. + report("SCHEDULING " + rollupSubject + " with rollup()"); + new ScheduledMessageBuilder() + .scheduleSubject(rollupSubject) + .targetSubject(rollupTarget) + .scheduleEvery(2, TimeUnit.SECONDS) + .rollup() + .data("Rollup") + .scheduleMessage(js); + + latch.await(); + + report("CANCEL " + ttlSubject, ScheduleManagement.cancelSchedule(jsm, ttlSubject, STREAM)); + report("CANCEL " + rollupSubject, ScheduleManagement.cancelSchedule(jsm, rollupSubject, STREAM)); + + report("DELIVERED to " + ttlTarget, ttlHits.get()); + report("DELIVERED to " + rollupTarget, rollupHits.get()); + + // Both targets received the same number of fires, but the rollup + // target retains only the latest message; the TTL target retains + // each published message until it expires after 3s. + report("STREAM count for " + ttlTarget, + jsm.getStreamInfo(STREAM, io.nats.client.api.StreamInfoOptions.filterSubjects(ttlTarget)) + .getStreamState().getSubjectCount()); + report("STREAM count for " + rollupTarget, + jsm.getStreamInfo(STREAM, io.nats.client.api.StreamInfoOptions.filterSubjects(rollupTarget)) + .getStreamState().getSubjectCount()); + } + } + catch (Exception e) { + e.printStackTrace(); + } + } +} diff --git a/schedule-message/src/main/java/io/synadia/sm/PredefinedSchedules.java b/schedule-message/src/main/java/io/synadia/sm/PredefinedSchedules.java new file mode 100644 index 0000000..3bae59d --- /dev/null +++ b/schedule-message/src/main/java/io/synadia/sm/PredefinedSchedules.java @@ -0,0 +1,52 @@ +// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.sm; + +/** + * Predefined cron-like schedule shortcuts supported by NATS message schedules + * (per ADR-51). Pass one to + * {@link ScheduledMessageBuilder#schedule(PredefinedSchedules)}. + */ +public enum PredefinedSchedules { + /** + * Run once a year, midnight, Jan. 1st. Same as Yearly. Equivalent to cron string 0 0 0 1 1 * + */ + Annually("@annually"), + + /** + * Run once a year, midnight, Jan. 1st. Same as Annually. Equivalent to cron string 0 0 0 1 1 * + */ + Yearly("@yearly"), + + /** + * Run once a month, midnight, first of month. Same as cron format 0 0 0 1 * * + */ + Monthly("@monthly"), + + /** + * Run once a week, midnight between Sat/Sun. Equivalent to cron string 0 0 0 * * 0 + */ + Weekly("@weekly"), + + /** + * Run once a day, midnight. Same as Daily. Equivalent to cron string 0 0 0 * * * + */ + Midnight("@midnight"), + + /** + * Run once a day, midnight. Same as Midnight. Equivalent to cron string 0 0 0 * * * + */ + Daily("@daily"), + + /** + * Run once an hour, beginning of hour. Equivalent to cron string 0 0 * * * * + */ + Hourly("@hourly"); + + final String value; + + PredefinedSchedules(String value) { + this.value = value; + } +} diff --git a/schedule-message/src/main/java/io/synadia/sm/ScheduleManagement.java b/schedule-message/src/main/java/io/synadia/sm/ScheduleManagement.java new file mode 100644 index 0000000..5613e09 --- /dev/null +++ b/schedule-message/src/main/java/io/synadia/sm/ScheduleManagement.java @@ -0,0 +1,354 @@ +package io.synadia.sm; + +import io.nats.client.*; +import io.nats.client.api.*; +import io.nats.client.impl.Headers; +import io.nats.client.impl.NatsMessage; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.util.List; + +import static io.nats.client.support.NatsJetStreamConstants.*; +import static io.nats.client.support.Validator.notPrintableOrHasWildGt; + +/** + * Helper utilities for stopping NATS message schedules early, per + * ADR-51 + * (section Ending/stopping schedules early), plus a couple of convenience helpers for + * creating schedule-capable streams. + *

+ * The class exposes two families of operations: + *

    + *
  • Basic stop — remove the schedule message from its stream so it can no + * longer fire. {@link #cancelSchedule(JetStreamManagement, String, long)} deletes by + * stream sequence; the subject-based overloads look the sequence up first.
  • + *
  • Atomic publish-and-stop — publish a message to a different subject and stop + * the schedule as a single atomic step. The unconditional form + * {@link #publishAndCancelSchedule(JetStreamManagement, String, String, byte[], Headers)} + * publishes without checking that the schedule still exists; + * {@link #publishAndCancelScheduleIfExists(JetStreamManagement, String, String, byte[], Headers)} + * guards the publish with an existence check, returning {@code null} if the schedule + * is no longer present. The sequence-bound variant + * {@link #publishAndCancelSchedule(JetStreamManagement, String, long, String, byte[], Headers)} + * uses {@code Nats-Expected-Last-Subject-Sequence} so the publish only succeeds while + * the schedule message is still at the named sequence.
  • + *
+ * Per the ADR the publish subject of the atomic variants must not equal the schedule + * subject; the server rejects such publishes with error code {@code 10212}. + *

+ * All methods are static; the class is {@code abstract} purely to prevent instantiation. + */ +@NullMarked +public abstract class ScheduleManagement { + + /** Utility class — not intended to be instantiated. */ + private ScheduleManagement() {} + + /** + * Outcome of a {@code cancelSchedule(...)} call. + */ + public enum Result { + /** The schedule message was found and successfully deleted. */ + SUCCESS, + /** The server-side delete returned {@code false}. */ + FAILURE, + /** No schedule message was found for the given subject / sequence. */ + NOT_FOUND + } + + /** + * Add a new stream with message scheduling enabled. + * Both {@code AllowMsgSchedules} and {@code AllowMsgTTL} are set on the stream — the + * latter is required for the {@code Nats-Schedule-TTL} header to take effect on + * messages produced by schedules. + * + * @param jsm the JetStream management context + * @param streamName the stream name + * @param storageType the storage type ({@code File} or {@code Memory}) + * @param subjects the subjects the stream will accept; must cover both the + * schedule subjects and any target subjects schedules publish to + * @return the created {@link StreamInfo} + * @throws JetStreamApiException if the server returned an error + * @throws IOException if the request could not be sent + */ + public static StreamInfo createSchedulableStream(JetStreamManagement jsm, String streamName, StorageType storageType, String... subjects) throws JetStreamApiException, IOException { + StreamConfiguration sc = StreamConfiguration.builder() + .name(streamName) + .storageType(storageType) + .subjects(subjects) + .allowMessageSchedules() + .allowMessageTtl() + .build(); + return jsm.addStream(sc); + } + + /** + * Add a new stream with message scheduling enabled, derived from an existing + * {@link StreamConfiguration}. The supplied configuration is copied and + * {@code AllowMsgSchedules} / {@code AllowMsgTTL} are turned on; all other settings + * are preserved. + * + * @param jsm the JetStream management context + * @param startingStreamConfig the base configuration to copy from + * @return the created {@link StreamInfo} + * @throws JetStreamApiException if the server returned an error + * @throws IOException if the request could not be sent + */ + public static StreamInfo createSchedulableStream(JetStreamManagement jsm, StreamConfiguration startingStreamConfig) throws JetStreamApiException, IOException { + StreamConfiguration sc = StreamConfiguration.builder(startingStreamConfig) + .allowMessageSchedules() + .allowMessageTtl() + .build(); + return jsm.addStream(sc); + } + + /** + * Stop a schedule by deleting its message at a specific stream sequence (ADR-51 + * mechanism: delete by stream sequence). The schedule stops firing as soon as + * its message is removed. + * + * @param jsm the JetStream management context + * @param stream the stream that holds the schedule message + * @param scheduleStreamSequence the stream sequence of the schedule message + * @return {@link Result#SUCCESS} on a successful delete, {@link Result#FAILURE} if + * the server reported the delete as unsuccessful, or {@link Result#NOT_FOUND} if + * no message exists at that sequence (server error {@code 10043}). Any other + * server error is rethrown. + * @throws JetStreamApiException if the server returned an error other than + * "message not found" + * @throws IOException if the request could not be sent + */ + public static Result cancelSchedule(JetStreamManagement jsm, String stream, long scheduleStreamSequence) throws JetStreamApiException, IOException { + try { + return jsm.deleteMessage(stream, scheduleStreamSequence) ? Result.SUCCESS : Result.FAILURE; + } + catch (JetStreamApiException e) { + if (e.getApiErrorCode() == 10043) { + return Result.NOT_FOUND; + } + throw e; + } + } + + /** + * Convenience overload that locates the stream that owns the schedule subject before + * delegating to {@link #cancelSchedule(JetStreamManagement, String, String)}. + *

+ * The lookup is strict: it fails if zero streams match the subject, and refuses to + * pick one when more than one stream matches. Pass the stream name explicitly to the + * three-argument overload if you need to disambiguate. + * + * @param jsm the JetStream management context + * @param scheduleSubject the schedule subject + * @return see {@link #cancelSchedule(JetStreamManagement, String, long)} + * @throws IllegalStateException if no stream — or more than one — covers the subject + * @throws JetStreamApiException if the server returned an error + * @throws IOException if the request could not be sent + */ + public static Result cancelSchedule(JetStreamManagement jsm, String scheduleSubject) throws JetStreamApiException, IOException { + return cancelSchedule(jsm, scheduleSubject, findStream(jsm, scheduleSubject)); + } + + /** + * Stop a schedule identified by its subject in the given stream. Looks up the last + * message on the subject, verifies it is a schedule message (has the + * {@code Nats-Schedule} header), and deletes it by its stream sequence. + * + * @param jsm the JetStream management context + * @param scheduleSubject the exact schedule subject (wildcards are not supported) + * @param scheduleStream the name of the stream that holds the schedule message + * @return {@link Result#NOT_FOUND} if no schedule message exists on the subject; + * otherwise the result of the underlying sequence-based delete + * @throws JetStreamApiException if the server returned an error + * @throws IOException if the request could not be sent + */ + public static Result cancelSchedule(JetStreamManagement jsm, String scheduleSubject, String scheduleStream) throws JetStreamApiException, IOException { + if (notPrintableOrHasWildGt(scheduleSubject)) { + // this is a wildcard subject so we must use purge + PurgeResponse response = jsm.purgeStream(scheduleStream, PurgeOptions.builder().subject(scheduleSubject).build()); + if (response.isSuccess()) { + return response.getPurged() > 0 ? Result.SUCCESS : Result.NOT_FOUND; + } + return Result.FAILURE; + } + + long seq = getScheduleSequence(jsm, scheduleStream, scheduleSubject); + if (seq == -1) { + return Result.NOT_FOUND; + } + return cancelSchedule(jsm, scheduleStream, seq); + } + + /** + * Atomically publish a message and stop the named schedule, per ADR-51's + * atomic stop mechanism. The published message carries: + *

    + *
  • {@code Nats-Scheduler}: {@code scheduleSubject}
  • + *
  • {@code Nats-Schedule-Next}: {@code purge}
  • + *
+ * The publish is sent unconditionally; the schedule is stopped as a side effect of + * the server processing the headers. Use + * {@link #publishAndCancelScheduleIfExists(JetStreamManagement, String, String, byte[], Headers)} + * if you need to skip the publish when the schedule is no longer present. + *

+ * The {@code targetSubject} must not equal {@code scheduleSubject}. This constraint + * is enforced by the server, not by this method, so passing equal subjects surfaces + * as a {@link JetStreamApiException} with error code {@code 10212} from the publish + * call. + * + * @param jsm the JetStream management context (its {@code jetStream()} + * context is used to publish) + * @param scheduleSubject the schedule subject to stop + * @param targetSubject the subject to publish to; this may be the original + * schedule's target subject (to publish early) or any other + * subject. Must not equal {@code scheduleSubject} — see above + * @param data the message body; may be {@code null} + * @param userHeaders extra headers to include on the published message; may be + * {@code null}. The {@code Nats-Scheduler} and + * {@code Nats-Schedule-Next} headers are always set by this + * method and override any conflicting keys from + * {@code userHeaders} + * @return the {@link PublishAck} from the server + * @throws JetStreamApiException if the server returned an error + * @throws IOException if the request could not be sent + */ + public static PublishAck publishAndCancelSchedule(JetStreamManagement jsm, String scheduleSubject, String targetSubject, + byte @Nullable[] data, @Nullable Headers userHeaders) throws JetStreamApiException, IOException { + Headers h = makeHeaders(scheduleSubject, userHeaders); + return jsm.jetStream().publish(targetSubject, h, data); + } + + /** + * Guarded variant of + * {@link #publishAndCancelSchedule(JetStreamManagement, String, String, byte[], Headers)}: + * looks up the schedule message first and only publishes if it is still present, using + * the {@code Nats-Expected-Last-Subject-Sequence} precondition so the operation is + * atomic with any concurrent fire. + *

+ * The lookup requires exactly one stream to cover {@code scheduleSubject}; if + * zero or more than one stream matches, the method returns {@code null} without + * publishing. + * + * @param jsm the JetStream management context + * @param scheduleSubject the schedule subject to stop + * @param targetSubject the subject to publish to; must not equal + * {@code scheduleSubject} (server rejects with code + * {@code 10212}) + * @param data the message body; may be {@code null} + * @param userHeaders extra headers to include on the published message; may be + * {@code null}. The {@code Nats-Scheduler} and + * {@code Nats-Schedule-Next} headers are always set by this + * method and override any conflicting keys from + * {@code userHeaders} + * @return the {@link PublishAck} from the server, or {@code null} if the schedule + * for {@code scheduleSubject} could not be located (no schedule message present, + * or the stream lookup was ambiguous) + * @throws JetStreamApiException if the server returned an error + * @throws IOException if the request could not be sent + */ + public static @Nullable PublishAck publishAndCancelScheduleIfExists(JetStreamManagement jsm, String scheduleSubject, String targetSubject, + byte @Nullable[] data, @Nullable Headers userHeaders) throws JetStreamApiException, IOException { + String streamName = findStreamLenient(jsm, scheduleSubject); + if (streamName != null) { + long seq = getScheduleSequence(jsm, streamName, scheduleSubject); + if (seq != -1) { + return publishAndCancelSchedule(jsm, scheduleSubject, seq, targetSubject, data, userHeaders); + } + } + return null; + } + + /** + * Atomic publish-and-stop guarded by an explicit existence check. Same headers as + * the simpler overload, but additionally sets: + *

    + *
  • {@code Nats-Expected-Last-Subject-Sequence}: {@code scheduleStreamSequence}
  • + *
  • {@code Nats-Expected-Last-Subject-Sequence-Subject}: {@code scheduleSubject}
  • + *
+ * The publish — and therefore the stop — only succeeds if the schedule message is + * still present at the given sequence on its subject. Useful for stopping a schedule + * and publishing in one atomic step without risk of duplicating the message if the + * schedule fires concurrently. + * + * @param jsm the JetStream management context + * @param scheduleSubject the schedule subject to stop + * @param scheduleStreamSequence the expected stream sequence of the schedule message + * on {@code scheduleSubject} + * @param targetSubject the subject to publish to. Must not equal + * {@code scheduleSubject}; the server enforces this + * constraint and rejects with error code {@code 10212} + * if the two are equal + * @param data the message body; may be {@code null} + * @param userHeaders extra headers to include on the published message; + * may be {@code null}. The {@code Nats-Scheduler} and + * {@code Nats-Schedule-Next} headers are always set by + * this method and override any conflicting keys from + * {@code userHeaders} + * @return the {@link PublishAck} from the server + * @throws JetStreamApiException if the precondition fails, the server returned error + * {@code 10212} (target subject equals schedule subject), or any other server + * error occurred + * @throws IOException if the request could not be sent + */ + public static PublishAck publishAndCancelSchedule(JetStreamManagement jsm, String scheduleSubject, long scheduleStreamSequence, String targetSubject, + byte @Nullable[] data, @Nullable Headers userHeaders) throws JetStreamApiException, IOException { + Headers h = makeHeaders(scheduleSubject, userHeaders); + PublishOptions opts = PublishOptions.builder() + .expectedLastSubjectSequenceSubject(scheduleSubject) + .expectedLastSubjectSequence(scheduleStreamSequence) + .build(); + Message m = new NatsMessage(targetSubject, null, h, data); + return jsm.jetStream().publish(m , opts); + } + + private static Headers makeHeaders(String scheduleSubject, @Nullable Headers userHeaders) { + Headers h = new Headers(); + if (userHeaders != null) { + for (String key : userHeaders.keySet()) { + h.put(key, userHeaders.get(key)); + } + } + h.put(NATS_SCHEDULE_NEXT_HDR, "purge"); + h.put(NATS_SCHEDULER_HDR, scheduleSubject); + return h; + } + + private static @Nullable String findStreamLenient(JetStreamManagement jsm, String scheduleSubject) throws JetStreamApiException, IOException { + List streams = jsm.getStreamNames(scheduleSubject); + if (streams == null || streams.size() != 1) { + return null; + } + return streams.get(0); + } + + private static String findStream(JetStreamManagement jsm, String scheduleSubject) throws JetStreamApiException, IOException { + List streams = jsm.getStreamNames(scheduleSubject); + if (streams == null || streams.isEmpty()) { + throw new IllegalStateException("No stream found for subject [" + scheduleSubject + "]"); + } + if (streams.size() != 1) { + throw new IllegalStateException("Subject matches more than 1 stream [" + scheduleSubject + "]"); + } + return streams.get(0); + } + + private static long getScheduleSequence(JetStreamManagement jsm, String streamName, String scheduleSubject) throws IOException, JetStreamApiException { + try { + MessageInfo mi = jsm.getLastMessage(streamName, scheduleSubject); + if (mi != null) { + Headers headers = mi.getHeaders(); + if (headers != null && headers.containsKey(NATS_SCHEDULE_HDR)) { + return mi.getSeq(); + } + } + } + catch (JetStreamApiException e) { + if (e.getApiErrorCode() != 10037) { + throw e; + } + } + return -1; + } +} diff --git a/schedule-message/src/main/java/io/synadia/sm/ScheduledMessageBuilder.java b/schedule-message/src/main/java/io/synadia/sm/ScheduledMessageBuilder.java new file mode 100644 index 0000000..feda85f --- /dev/null +++ b/schedule-message/src/main/java/io/synadia/sm/ScheduledMessageBuilder.java @@ -0,0 +1,442 @@ +// Copyright (c) 2025-2026 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.sm; + +import io.nats.client.JetStream; +import io.nats.client.JetStreamApiException; +import io.nats.client.Message; +import io.nats.client.MessageTtl; +import io.nats.client.api.PublishAck; +import io.nats.client.impl.Headers; +import io.nats.client.impl.NatsMessage; +import io.nats.client.support.DateTimeUtils; +import io.nats.client.support.NatsJetStreamConstants; +import io.nats.client.support.Validator; +import org.jspecify.annotations.NonNull; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Builder for constructing a NATS JetStream {@link Message} that carries a schedule per ADR-51. + *

+ * The resulting message is published to a schedule subject on a stream that supports message + * scheduling. The message itself does not get delivered to subscribers directly; instead the + * server interprets the {@code Nats-Schedule-*} headers and produces published messages on the + * configured target subject according to the schedule. + *

+ * Three scheduling modes are supported: + *

    + *
  • {@code @at} - a one-shot schedule at a specific point in time + * (see {@link #scheduleAt}, {@link #scheduleIn}, {@link #scheduleImmediate}).
  • + *
  • {@code @every} - a fixed interval, with a minimum supported interval of 1 second + * (see {@link #scheduleEvery}).
  • + *
  • Cron / predefined - a standard cron expression or one of the predefined entries + * such as {@code @hourly}, {@code @daily}, etc. + * (see {@link #scheduleCron}, {@link #schedule(PredefinedSchedules)}).
  • + *
+ */ +public class ScheduledMessageBuilder { + + /** Number of nanoseconds in one second. */ + public static final long NANOS_PER_SECOND = 1_000_000_000L; + + private String scheduleString; + private String timezone; + private String scheduleSubject; + private String targetSubject; + private Headers headers; + private byte[] data; + private MessageTtl messageTtl; + private boolean rollup; + private final List sources = new ArrayList<>(); + + /** + * Construct an empty builder. Configure the schedule and target subjects, the + * schedule (one of the {@code schedule*} methods), and any data / headers before + * calling {@link #build()} or {@link #scheduleMessage(JetStream)}. + */ + public ScheduledMessageBuilder() {} + + /** + * Set the schedule subject + * @param scheduleSubject the schedule subject + * @return the builder + */ + public ScheduledMessageBuilder scheduleSubject(String scheduleSubject) { + this.scheduleSubject = scheduleSubject; + return this; + } + + /** + * Set the target subject + * @param targetSubject the target subject + * @return the builder + */ + public ScheduledMessageBuilder targetSubject(String targetSubject) { + this.targetSubject = targetSubject; + return this; + } + + /** + * Set the data from a byte array. null data changed to empty byte array + * @param data the data + * @return the builder + */ + public ScheduledMessageBuilder data(byte[] data) { + this.data = data; + return this; + } + + /** + * Set the data from a string converting using the + * charset StandardCharsets.UTF_8 + * @param data the data string + * @return the builder + */ + public ScheduledMessageBuilder data(String data) { + if (data != null) { + this.data = data.getBytes(StandardCharsets.UTF_8); + } + return this; + } + + /** + * Set the data from a string + * @param data the data string + * @param charset the charset, for example {@code StandardCharsets.UTF_8} + * @return the builder + */ + public ScheduledMessageBuilder data(String data, final Charset charset) { + this.data = data.getBytes(charset); + return this; + } + + /** + * Set message headers + * @param headers the headers + * @return the builder + */ + public ScheduledMessageBuilder headers(Headers headers) { + this.headers = headers; + return this; + } + + /** + * Copy the subject, data and headers from an existing message + * @param message the message + * @return the builder + */ + public ScheduledMessageBuilder copy(Message message) { + scheduleSubject(message.getSubject()); + headers(message.getHeaders()); + return data(message.getData()); + } + + /** + * Schedule for an amount of time from now. + * This is not absolute since it takes time to build and send the message. + * @param fromNow how long from now to schedule + * @return a ScheduledMessageBuilder object + */ + public ScheduledMessageBuilder scheduleIn(Duration fromNow) { + return scheduleAt(ZonedDateTime.now().plus(fromNow)); + } + + /** + * Schedule for an amount of time from now. + * This is not absolute since it takes time to build and send the message. + * @param fromNow how long from now to schedule, expressed in {@code timeUnit}s + * @param timeUnit the unit for {@code fromNow} + * @return a ScheduledMessageBuilder object + */ + public ScheduledMessageBuilder scheduleIn(long fromNow, TimeUnit timeUnit) { + return scheduleAt(ZonedDateTime.now().plusNanos(timeUnit.toNanos(fromNow))); + } + + /** + * Schedule for a specific time + * @param zdt the time to schedule + * @return a ScheduledMessageBuilder object + */ + public ScheduledMessageBuilder scheduleAt(ZonedDateTime zdt) { + scheduleString = zdt == null ? null : "@at " + DateTimeUtils.toRfc3339(zdt); + return this; + } + + /** + * Schedule to run immediately. This is like scheduleAt with time of "now" minus 1 second, + * which will be in the past by the time it gets to the server, + * so the scheduled message will be published immediately. + * @return a ScheduledMessageBuilder object + */ + public ScheduledMessageBuilder scheduleImmediate() { + return scheduleAt(DateTimeUtils.gmtNow().minusSeconds(1)); + } + + /** + * Schedule with one of the predefined enum values + * @param predefined One of the predefined enum values + * @return a ScheduledMessageBuilder object + */ + public ScheduledMessageBuilder schedule(PredefinedSchedules predefined) { + scheduleString = predefined == null ? null : predefined.value; + return this; + } + + /** + * Schedule an interval + * @param every A time specification that complies with Golang's time.ParseDuration() format. + * @return a ScheduledMessageBuilder object + */ + public ScheduledMessageBuilder scheduleEvery(String every) { + every = Validator.emptyAsNull(every); + if (every == null) { + scheduleString = null; + } + else { + scheduleString = "@every " + every; + } + return this; + } + + /** + * Schedule an interval + * @param every a duration, validated to be at least 1 second + * @return a ScheduledMessageBuilder object + */ + public ScheduledMessageBuilder scheduleEvery(Duration every) { + if (every == null) { + scheduleString = null; + } + else { + if (every.toNanos() < NANOS_PER_SECOND) { + throw new IllegalArgumentException("Expiry cannot be less than 1 second."); + } + scheduleString = "@every " + toGoDuration(every); + } + return this; + } + + /** + * Schedule an interval + * @param duration a duration, validated to be at least 1 second + * @param timeUnit the unit for the duration + * @return a ScheduledMessageBuilder object + */ + public ScheduledMessageBuilder scheduleEvery(int duration, TimeUnit timeUnit) { + return scheduleEvery(Duration.ofNanos(timeUnit.toNanos(duration))); + } + + /** + * Schedule based on standard cron + * @param cron A valid cron string + * @return a ScheduledMessageBuilder object + */ + public ScheduledMessageBuilder scheduleCron(String cron) { + scheduleString = Validator.emptyAsNull(cron); + return this; + } + + /** + * Schedule based on standard cron + * @param cron A valid cron string + * @param timezone a valid IANA time zone + * @return a ScheduledMessageBuilder object + */ + public ScheduledMessageBuilder scheduleCron(String cron, String timezone) { + scheduleString = Validator.emptyAsNull(cron); + this.timezone = timezone; + return this; + } + + /** + * Set the {@code Nats-Schedule-TTL} header. This TTL is applied to each message + * the schedule publishes to the target subject (when the stream supports per-message TTLs); + * it is not a TTL on the schedule itself. + * @param messageTtl the per-published-message TTL + * @return the builder + */ + public ScheduledMessageBuilder messageTtl(MessageTtl messageTtl) { + this.messageTtl = messageTtl; + return this; + } + + /** + * Set the {@code Nats-Schedule-Source} header. When set, the schedule reads the last + * message on the source subject and publishes it to the target subject; if no message + * exists on the source subject, the schedule's own body and headers are used as a + * fallback. Wildcards are not supported. Per ADR-51 conventions the header supports + * a list of source subjects. + * @param sources the source subjects + * @return the builder + */ + public ScheduledMessageBuilder sources(List sources) { + this.sources.clear(); + if (sources != null) { + this.sources.addAll(sources); + } + return this; + } + + /** + * Set the {@code Nats-Schedule-Source} header. When set, the schedule reads the last + * message on the source subject and publishes it to the target subject; if no message + * exists on the source subject, the schedule's own body and headers are used as a + * fallback. Wildcards are not supported. Per ADR-51 conventions the header supports + * a list of source subjects. + * @param sources the source subjects + * @return the builder + */ + public ScheduledMessageBuilder sources(String... sources) { + this.sources.clear(); + if (sources != null) { + Collections.addAll(this.sources, sources); + } + return this; + } + + /** + * Set the {@code Nats-Schedule-Rollup} header to {@code sub}, which is the only + * valid value per ADR-51. This causes published messages to roll up the target subject. + * @return the builder + */ + public ScheduledMessageBuilder rollup() { + rollup = true; + return this; + } + + /** + * Build the scheduled message and publish it to JetStream. + * @param js the JetStream context used to publish + * @return the sequence number of the stored schedule message from the {@link PublishAck} + * @throws JetStreamApiException if the server returns an error + * @throws IOException if there is a communication problem with the server + */ + public long scheduleMessage(JetStream js) throws JetStreamApiException, IOException { + return js.publish(build()).getSeqno(); + } + + /** + * Build the fully constructed message ready to be published to the schedule subject. + *

+ * Validates that {@code scheduleSubject} and {@code targetSubject} are both supplied + * and printable without {@code *} or {@code >} wildcards, and that a schedule string + * has been set via one of the {@code scheduleXxx}/{@code schedule} methods. + *

+ * Sets the following headers as applicable: + * {@code Nats-Schedule}, {@code Nats-Schedule-Target}, + * {@code Nats-Schedule-TTL}, {@code Nats-Schedule-Time-Zone}, + * {@code Nats-Schedule-Source}, {@code Nats-Schedule-Rollup}. + * @return the constructed {@link Message} + */ + public Message build() { + Validator.required(scheduleSubject, "Publish Subject is required."); + Validator.required(targetSubject, "Target Subject is required."); + if (Validator.notPrintableOrHasWildGt(scheduleSubject)) { + Validator.required(scheduleSubject, "Publish Subject cannot contain '*' or '>'."); + } + if (Validator.notPrintableOrHasWildGt(targetSubject)) { + Validator.required(targetSubject, "Target Subject cannot contain '*' or '>'."); + } + Validator.required(scheduleString, "Schedule is required."); + + if (headers == null) { + headers = new Headers(); + } + headers.put(NatsJetStreamConstants.NATS_SCHEDULE_TARGET_HDR, targetSubject); + headers.put(NatsJetStreamConstants.NATS_SCHEDULE_HDR, scheduleString); + if (messageTtl != null) { + headers.put(NatsJetStreamConstants.NATS_SCHEDULE_TTL_HDR, messageTtl.getTtlString()); + } + if (timezone != null) { + headers.put(NatsJetStreamConstants.NATS_SCHEDULE_TIME_ZONE_HDR, timezone); + } + if (sources.size() > 0) { + headers.put(NatsJetStreamConstants.NATS_SCHEDULE_SOURCE_HDR, sources); + } + if (rollup) { + headers.put(NatsJetStreamConstants.NATS_SCHEDULE_ROLLUP_HDR, "sub"); + } + + return NatsMessage.builder() + .subject(scheduleSubject) + .headers(headers) + .data(data) + .build(); + } + + /** + * Format a {@link Duration} as a Go {@code time.ParseDuration()} string + * (for example {@code "1h30m5s"}). Used to render {@code @every} interval values. + * @param duration the duration to format + * @return the Go-formatted duration string + */ + public static String toGoDuration(Duration duration) { + long left = duration.toNanos(); + long nanos = left % 1_000_000L; + left = (left - nanos) / 1_000_000L; + long millis = left % 1_000L; + left = (left - millis) / 1_000L; + long seconds = left % 60L; + left = (left - seconds) / 60L; + long minutes = left % 60L; + long hours = (left - minutes) / 60L; + + StringBuilder sb = new StringBuilder(); + if (hours > 0) sb.append(hours).append('h'); + if (minutes > 0) sb.append(minutes).append('m'); + if (seconds > 0) sb.append(seconds).append('s'); + if (millis > 0) sb.append(millis).append("ms"); + if (nanos > 0) sb.append(nanos).append("ns"); + + return sb.toString(); + } + + /** + * Validate that a Go {@code time.ParseDuration()} formatted string represents a + * duration of at least one second, matching ADR-51's minimum supported interval + * rule for {@code @every} schedules. + * @param s the Go-formatted duration string + * @return {@code true} if the string parses successfully and is at least 1 second + */ + public static boolean isAtLeastOneSecond(@NonNull String s) { + long totalNanos = 0; + int i = 0; + + try { + while (i < s.length()) { + int start = i; + while (i < s.length() && Character.isDigit(s.charAt(i))) i++; + if (i == start) return false; + long value = Long.parseLong(s.substring(start, i)); + + int unitStart = i; + while (i < s.length() && Character.isLetter(s.charAt(i))) i++; + String unit = s.substring(unitStart, i); + + switch (unit) { + case "h": totalNanos += value * 3_600_000_000_000L; break; + case "m": totalNanos += value * 60_000_000_000L; break; + case "s": totalNanos += value * NANOS_PER_SECOND; break; + case "ms": totalNanos += value * 1_000_000L; break; + case "us": totalNanos += value * 1_000L; break; + case "ns": totalNanos += value; break; + default: return false; + } + } + } catch (Exception e) { + return false; + } + + return totalNanos >= NANOS_PER_SECOND; + } +} diff --git a/schedule-message/src/main/javadoc/images/favicon.ico b/schedule-message/src/main/javadoc/images/favicon.ico new file mode 100644 index 0000000..9464855 Binary files /dev/null and b/schedule-message/src/main/javadoc/images/favicon.ico differ diff --git a/schedule-message/src/main/javadoc/images/large-logo.png b/schedule-message/src/main/javadoc/images/large-logo.png new file mode 100644 index 0000000..33f9483 Binary files /dev/null and b/schedule-message/src/main/javadoc/images/large-logo.png differ diff --git a/schedule-message/src/main/javadoc/images/synadia-logo.png b/schedule-message/src/main/javadoc/images/synadia-logo.png new file mode 100644 index 0000000..1f14bda Binary files /dev/null and b/schedule-message/src/main/javadoc/images/synadia-logo.png differ diff --git a/schedule-message/src/main/javadoc/overview.html b/schedule-message/src/main/javadoc/overview.html new file mode 100644 index 0000000..e3b147a --- /dev/null +++ b/schedule-message/src/main/javadoc/overview.html @@ -0,0 +1,13 @@ + + + + + +JetStream Scheduled Message +

Synadia Logo

+ + + + diff --git a/schedule-message/src/main/resources/placeholder.txt b/schedule-message/src/main/resources/placeholder.txt new file mode 100644 index 0000000..ca5fd64 --- /dev/null +++ b/schedule-message/src/main/resources/placeholder.txt @@ -0,0 +1 @@ +This is just a placeholder. \ No newline at end of file diff --git a/schedule-message/src/test/java/io/synadia/sm/ScheduleManagementTests.java b/schedule-message/src/test/java/io/synadia/sm/ScheduleManagementTests.java new file mode 100644 index 0000000..fc630bd --- /dev/null +++ b/schedule-message/src/test/java/io/synadia/sm/ScheduleManagementTests.java @@ -0,0 +1,312 @@ +// Copyright (c) 2025-2026 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.sm; + +import io.nats.NatsRunnerUtils; +import io.nats.NatsServerRunner; +import io.nats.client.*; +import io.nats.client.api.MessageInfo; +import io.nats.client.api.PublishAck; +import io.nats.client.api.StorageType; +import io.nats.client.impl.Headers; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.logging.Level; + +import static io.nats.client.support.NatsJetStreamConstants.*; +import static org.junit.jupiter.api.Assertions.*; + +public class ScheduleManagementTests { + + static NatsServerRunner runner; + static Connection nc; + static JetStreamManagement jsm; + static JetStream js; + + @BeforeAll + public static void beforeAll() throws Exception { + NatsRunnerUtils.setDefaultOutputLevel(Level.WARNING); + runner = new NatsServerRunner(false, true); + Options options = Options.builder() + .server(runner.getNatsLocalhostUri()) + .errorListener(new ErrorListener() {}) + .build(); + nc = Nats.connect(options); + jsm = nc.jetStreamManagement(); + js = nc.jetStream(); + } + + @AfterAll + public static void afterAll() throws Exception { + if (nc != null) nc.close(); + if (runner != null) runner.close(); + } + + // -- helpers --------------------------------------------------------------- + + /** Holder for a freshly created stream and the schedule / target subject prefixes scoped to it. */ + private static class Fixture { + final String stream; + final String schedPrefix; // e.g. "sched." + final String tgtPrefix; // e.g. "tgt." + Fixture(String stream, String schedPrefix, String tgtPrefix) { + this.stream = stream; + this.schedPrefix = schedPrefix; + this.tgtPrefix = tgtPrefix; + } + String sched(String leaf) { return schedPrefix + "." + leaf; } + String tgt(String leaf) { return tgtPrefix + "." + leaf; } + } + + /** Create a schedulable stream with unique schedule and target subject patterns. */ + private static Fixture newFixture() throws Exception { + String id = NUID.nextGlobalSequence(); + String stream = "stream_" + id; + String schedPrefix = "sched_" + id; + String tgtPrefix = "tgt_" + id; + ScheduleManagement.createSchedulableStream( + jsm, stream, StorageType.Memory, + schedPrefix + ".>", + tgtPrefix + ".>"); + return new Fixture(stream, schedPrefix, tgtPrefix); + } + + /** + * Schedule a single delayed message far enough in the future that it cannot fire + * during the test. Returns the stream sequence the schedule message landed at. + */ + private static long scheduleInTheFuture(String schedSubject, String targetSubject, String data) throws Exception { + return new ScheduledMessageBuilder() + .scheduleSubject(schedSubject) + .targetSubject(targetSubject) + .scheduleIn(Duration.ofHours(1)) + .data(data) + .scheduleMessage(js); + } + + /** True iff a schedule message still exists on the subject (carries the Nats-Schedule header). */ + private static boolean scheduleExists(String streamName, String schedSubject) throws Exception { + try { + MessageInfo mi = jsm.getLastMessage(streamName, schedSubject); + return mi != null + && mi.getHeaders() != null + && mi.getHeaders().containsKey(NATS_SCHEDULE_HDR); + } + catch (JetStreamApiException e) { + if (e.getApiErrorCode() == 10037) { + return false; + } + throw e; + } + } + + // -- cancelSchedule(jsm, stream, scheduleStreamSequence) ------------------- + + @Test + public void testCancelBySequence() throws Exception { + Fixture f = newFixture(); + String schedSubject = f.sched("a"); + long seq = scheduleInTheFuture(schedSubject, f.tgt("a"), "body"); + + assertTrue(scheduleExists(f.stream, schedSubject)); + assertEquals(ScheduleManagement.Result.SUCCESS, ScheduleManagement.cancelSchedule(jsm, f.stream, seq)); + assertFalse(scheduleExists(f.stream, schedSubject)); + + assertEquals(ScheduleManagement.Result.NOT_FOUND, + ScheduleManagement.cancelSchedule(jsm, f.stream, 99_999L)); + } + + // -- cancelSchedule(jsm, scheduleSubject) ---------------------------------- + + @Test + public void testCancelBySubject_success() throws Exception { + Fixture f = newFixture(); + String schedSubject = f.sched("b"); + scheduleInTheFuture(schedSubject, f.tgt("b"), "body"); + + assertTrue(scheduleExists(f.stream, schedSubject)); + assertEquals(ScheduleManagement.Result.SUCCESS, + ScheduleManagement.cancelSchedule(jsm, schedSubject)); + assertFalse(scheduleExists(f.stream, schedSubject)); + } + + @Test + public void testCancelBySubject_noStreamThrows() { + String orphan = "no_such_subject_" + NUID.nextGlobalSequence(); + assertThrows(IllegalStateException.class, + () -> ScheduleManagement.cancelSchedule(jsm, orphan)); + } + + // -- cancelSchedule(jsm, scheduleSubject, scheduleStream) ------------------ + + @Test + public void testCancelBySubjectInStream_exact_subject() throws Exception { + Fixture f = newFixture(); + String schedSubject = f.sched("c"); + scheduleInTheFuture(schedSubject, f.tgt("c"), "body"); + + assertTrue(scheduleExists(f.stream, schedSubject)); + assertEquals(ScheduleManagement.Result.SUCCESS, + ScheduleManagement.cancelSchedule(jsm, schedSubject, f.stream)); + assertFalse(scheduleExists(f.stream, schedSubject)); + + assertEquals(ScheduleManagement.Result.NOT_FOUND, + ScheduleManagement.cancelSchedule(jsm, f.sched("nope"), f.stream)); + } + + @Test + public void testCancelBySubjectInStream_wildcard_purgesAll() throws Exception { + Fixture f = newFixture(); + String s1 = f.sched("w1"); + String s2 = f.sched("w2"); + String s3 = f.sched("w3"); + scheduleInTheFuture(s1, f.tgt("w1"), "1"); + scheduleInTheFuture(s2, f.tgt("w2"), "2"); + scheduleInTheFuture(s3, f.tgt("w3"), "3"); + + assertEquals(ScheduleManagement.Result.SUCCESS, + ScheduleManagement.cancelSchedule(jsm, f.schedPrefix + ".*", f.stream)); + assertFalse(scheduleExists(f.stream, s1)); + assertFalse(scheduleExists(f.stream, s2)); + assertFalse(scheduleExists(f.stream, s3)); + } + + @Test + public void testCancelBySubjectInStream_wildcard_noMatches() throws Exception { + Fixture f = newFixture(); + assertEquals(ScheduleManagement.Result.NOT_FOUND, + ScheduleManagement.cancelSchedule(jsm, f.schedPrefix + ".*", f.stream)); + } + + // -- publishAndCancelSchedule(jsm, sched, tgt, data, userHeaders) ---------- + // -- publishAndCancelScheduleIfExists(jsm, sched, tgt, data, userHeaders) -- + + @Test + public void testPublishAndCancel_unconditional_success() throws Exception { + Fixture f = newFixture(); + String schedSubject = f.sched("p1"); + String tgtSubject = f.tgt("p1"); + scheduleInTheFuture(schedSubject, tgtSubject, "body"); + + PublishAck ack = ScheduleManagement.publishAndCancelSchedule( + jsm, schedSubject, tgtSubject, "cancel-now".getBytes(), null); + + assertNotNull(ack); + assertFalse(scheduleExists(f.stream, schedSubject)); + } + + @Test + public void testPublishAndCancel_ifExists_whenPresent() throws Exception { + Fixture f = newFixture(); + String schedSubject = f.sched("p2"); + String tgtSubject = f.tgt("p2"); + scheduleInTheFuture(schedSubject, tgtSubject, "body"); + + PublishAck ack = ScheduleManagement.publishAndCancelScheduleIfExists( + jsm, schedSubject, tgtSubject, "cancel-now".getBytes(), null); + + assertNotNull(ack); + assertFalse(scheduleExists(f.stream, schedSubject)); + } + + @Test + public void testPublishAndCancel_ifExists_whenMissing() throws Exception { + Fixture f = newFixture(); + String schedSubject = f.sched("p3"); + String tgtSubject = f.tgt("p3"); + + PublishAck ack = ScheduleManagement.publishAndCancelScheduleIfExists( + jsm, schedSubject, tgtSubject, "cancel-now".getBytes(), null); + + assertNull(ack); + } + + // -- publishAndCancelSchedule(jsm, sched, seq, tgt, data) ------------------ + + @Test + public void testPublishAndCancel_withSequence_success() throws Exception { + Fixture f = newFixture(); + String schedSubject = f.sched("p4"); + String tgtSubject = f.tgt("p4"); + long seq = scheduleInTheFuture(schedSubject, tgtSubject, "body"); + + PublishAck ack = ScheduleManagement.publishAndCancelSchedule( + jsm, schedSubject, seq, tgtSubject, "cancel-now".getBytes(), null); + + assertNotNull(ack); + assertFalse(scheduleExists(f.stream, schedSubject)); + } + + @Test + public void testPublishAndCancel_withSequence_wrongSequenceFails() throws Exception { + Fixture f = newFixture(); + String schedSubject = f.sched("p5"); + String tgtSubject = f.tgt("p5"); + long seq = scheduleInTheFuture(schedSubject, tgtSubject, "body"); + + assertThrows(JetStreamApiException.class, () -> + ScheduleManagement.publishAndCancelSchedule( + jsm, schedSubject, seq + 999, tgtSubject, "cancel-now".getBytes(), null)); + } + + // -- ADR-51 constraint: targetSubject must not equal scheduleSubject ------- + + /** + * The server rejects an atomic stop publish whose target subject equals the schedule + * subject (ADR-51 error 10212). The client does not pre-check this — it lets the + * server surface the rejection. + */ + @Test + public void testPublishAndCancel_targetEqualsScheduleSubject_serverRejects() throws Exception { + Fixture f = newFixture(); + String sameSubject = f.sched("same"); + scheduleInTheFuture(sameSubject, f.tgt("same"), "body"); + + JetStreamApiException ex = assertThrows(JetStreamApiException.class, () -> + ScheduleManagement.publishAndCancelSchedule( + jsm, sameSubject, sameSubject, "x".getBytes(), null)); + assertEquals(10212, ex.getApiErrorCode()); + } + + // -- userHeaders cannot override the required Nats-Scheduler / Nats-Schedule-Next -- + + /** + * If the caller passes the protected headers ({@code Nats-Scheduler} or + * {@code Nats-Schedule-Next}) in {@code userHeaders}, the values set by the method + * must win — otherwise the atomic stop signal would be corrupted and the schedule + * would not be purged. Unrelated user headers must still be carried through to the + * published message. + */ + @Test + public void testPublishAndCancel_userHeadersCannotOverrideRequired() throws Exception { + Fixture f = newFixture(); + String schedSubject = f.sched("hdr"); + String tgtSubject = f.tgt("hdr"); + scheduleInTheFuture(schedSubject, tgtSubject, "body"); + + Headers userHeaders = new Headers(); + userHeaders.put(NATS_SCHEDULE_NEXT_HDR, "not-purge"); + userHeaders.put(NATS_SCHEDULER_HDR, "wrong-subject"); + userHeaders.put("X-Custom", "carry-me"); + + PublishAck ack = ScheduleManagement.publishAndCancelSchedule( + jsm, schedSubject, tgtSubject, "cancel-now".getBytes(), userHeaders); + assertNotNull(ack); + + // The schedule was actually cancelled — proves the required headers won. + assertFalse(scheduleExists(f.stream, schedSubject)); + + // The published target message carries the correct required values plus the user header. + MessageInfo tgtMsg = jsm.getLastMessage(f.stream, tgtSubject); + assertNotNull(tgtMsg); + Headers tgtHeaders = tgtMsg.getHeaders(); + assertNotNull(tgtHeaders); + assertEquals("purge", tgtHeaders.getFirst(NATS_SCHEDULE_NEXT_HDR)); + assertEquals(schedSubject, tgtHeaders.getFirst(NATS_SCHEDULER_HDR)); + assertEquals("carry-me", tgtHeaders.getFirst("X-Custom")); + } +} diff --git a/schedule-message/src/test/resources/placeholder.txt b/schedule-message/src/test/resources/placeholder.txt new file mode 100644 index 0000000..ca5fd64 --- /dev/null +++ b/schedule-message/src/test/resources/placeholder.txt @@ -0,0 +1 @@ +This is just a placeholder. \ No newline at end of file diff --git a/schedule-message/test.bat b/schedule-message/test.bat new file mode 100644 index 0000000..22c2cd1 --- /dev/null +++ b/schedule-message/test.bat @@ -0,0 +1,5 @@ +call gradlew clean build jacocoTestReport +taskkill /F /IM nats-server.exe +start chrome file:///C:/nats/orbit.java/counter/build/reports/jacoco/test/html/index.html +start chrome file:///C:/nats/orbit.java/counter/build/reports/tests/test/index.html + diff --git a/utils/.gitignore b/utils/.gitignore new file mode 100644 index 0000000..b3e2ca5 --- /dev/null +++ b/utils/.gitignore @@ -0,0 +1,77 @@ + +# NATS stuff # +############## +gnatsd.log +*.csv + +# Compiled source # +################### +*.com +*.class +*.dll +*.exe +*.o +*.so +/bin + +# Packages # +############ +*.7z +*.dmg +*.gz +*.iso +*.rar +*.tar +*.zip + +# Logs and databases # +###################### +*.log + +# OS generated files # +###################### +.DS_Store* +ehthumbs.db +Icon? +Thumbs.db + +# Editor Files # +################ +*~ +*.swp +.sts4-cache/* + +# Gradle Files # +################ +.gradle +.m2 + +# Build output directies +/target +*/target +/build +*/build + +# IntelliJ specific files/directories +out +.idea +*.ipr +*.iws +*.iml +atlassian-ide-plugin.xml + +# Eclipse specific files/directories +.classpath +.project +.settings +.metadata + +# NetBeans specific files/directories +.nbattrs + +# VSCode +.vscode/ + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +/target/ diff --git a/utils/LICENSE b/utils/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/utils/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed 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. diff --git a/utils/NOTICE b/utils/NOTICE new file mode 100644 index 0000000..ff3c8b4 --- /dev/null +++ b/utils/NOTICE @@ -0,0 +1,5 @@ +Orbit Java +Copyright (c) 2024-2025 Synadia Communications Inc. All Rights Reserved. + +This product includes software developed at +Synadia Communications Inc. \ No newline at end of file diff --git a/utils/README.md b/utils/README.md new file mode 100644 index 0000000..936128e --- /dev/null +++ b/utils/README.md @@ -0,0 +1,16 @@ +Orbit + +# Utilities + +Miscellaneous Helper Code + +### GenerateServerErrorConstants +`GenerateServerErrorConstants` will download the latest errors.json file and generate a java file like `ServerErrorConstants` + +You can use whatever class name you want, +but the generated class be kept in package `io.nats.client.api;` +otherwise it cannot access the packaged scoped `io.nats.client.api.Error` class + +--- +Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +See [LICENSE](LICENSE) and [NOTICE](NOTICE) file for details. diff --git a/utils/build.gradle b/utils/build.gradle new file mode 100644 index 0000000..ce382a0 --- /dev/null +++ b/utils/build.gradle @@ -0,0 +1,28 @@ +plugins { + id 'java' + id 'java-library' +} + +group = 'io.synadia' +version = "0.0.0" + +def tc = System.getenv("TARGET_COMPATIBILITY"); +def targetCompat = tc == "21" ? JavaVersion.VERSION_21 : (tc == "17" ? JavaVersion.VERSION_17 : JavaVersion.VERSION_1_8) + +System.out.println("Java: " + System.getProperty("java.version")) +System.out.println("Target Compatibility: " + targetCompat) + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = targetCompat +} + +repositories { + mavenCentral() + maven { url="https://repo1.maven.org/maven2/" } + maven { url="https://central.sonatype.com/repository/maven-snapshots/" } +} + +dependencies { + implementation 'io.nats:jnats:2.25.1' +} diff --git a/utils/gradle/libs.versions.toml b/utils/gradle/libs.versions.toml new file mode 100644 index 0000000..2cfe86a --- /dev/null +++ b/utils/gradle/libs.versions.toml @@ -0,0 +1,12 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format + +[versions] +commons-math3 = "3.6.1" +guava = "33.4.5-jre" +junit-jupiter = "5.12.1" + +[libraries] +commons-math3 = { module = "org.apache.commons:commons-math3", version.ref = "commons-math3" } +guava = { module = "com.google.guava:guava", version.ref = "guava" } +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" } diff --git a/utils/gradle/wrapper/gradle-wrapper.jar b/utils/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/utils/gradle/wrapper/gradle-wrapper.jar differ diff --git a/utils/gradle/wrapper/gradle-wrapper.properties b/utils/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ca025c8 --- /dev/null +++ b/utils/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/utils/gradlew b/utils/gradlew new file mode 100644 index 0000000..23d15a9 --- /dev/null +++ b/utils/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed 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 +# +# https://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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/utils/gradlew.bat b/utils/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/utils/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/utils/settings.gradle b/utils/settings.gradle new file mode 100644 index 0000000..f9dbf60 --- /dev/null +++ b/utils/settings.gradle @@ -0,0 +1,13 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + maven { url="https://repo1.maven.org/maven2/" } + maven { url="https://central.sonatype.com/repository/maven-snapshots/" } + maven { url="https://plugins.gradle.org/m2/" } + } + plugins { + id("biz.aQute.bnd.builder") version "7.1.0" + } +} +rootProject.name = 'utils' diff --git a/utils/src/main/java/io/nats/client/api/GenerateServerErrorConstants.java b/utils/src/main/java/io/nats/client/api/GenerateServerErrorConstants.java new file mode 100644 index 0000000..19b48d7 --- /dev/null +++ b/utils/src/main/java/io/nats/client/api/GenerateServerErrorConstants.java @@ -0,0 +1,120 @@ +// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.nats.client.api; + +import io.nats.client.support.JsonParser; +import io.nats.client.support.JsonValue; + +import java.io.ByteArrayOutputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +public final class GenerateServerErrorConstants { + public static final Comparator FILE_ORDER = null; + public static final Comparator CONSTANTS_ALPHA = Comparator.comparing(x -> x.constant); + public static final Comparator ERROR_CODE_ASCENDING = Comparator.comparing(x -> x.errorCode); + + private static final String TARGET_CLASS_NAME = "ServerErrorConstants"; + private static final String TARGET_CLASS_FILE = "C:\\temp\\" + TARGET_CLASS_NAME + ".java"; + private static final String ERRORS_JSON = "https://raw.githubusercontent.com/nats-io/nats-server/refs/heads/main/server/errors.json"; + private static final Comparator COMPARATOR = ERROR_CODE_ASCENDING; + + public static byte[] getJson() throws Exception { + URL url = new URL(ERRORS_JSON); + try (InputStream inputStream = url.openStream(); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + + byte[] buffer = new byte[4096]; + int bytesRead; + + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + + return outputStream.toByteArray(); + } + } + + final String constant; + final int code; + final int errorCode; + final String description; + + public GenerateServerErrorConstants(JsonValue item) { + constant = item.map.get("constant").string; + code = item.map.get("code").i; + errorCode = item.map.get("error_code").i; + description = item.map.get("description").string; + } + + public String getConstantLine() { + return String.format(" public static final Error %s = new Error(%d, %d, \"%s\");", + constant, code, errorCode, description); + } + + public String getStaticLine() { + return String.format(" temp.put(%d, %s);", errorCode, constant); + } + + @Override + public String toString() { + return getConstantLine(); + } + + public static void main(String[] args) { + try { + JsonValue jv = JsonParser.parse(getJson()); + + List list = new ArrayList<>(); + for (JsonValue item : jv.array) { + list.add(new GenerateServerErrorConstants(item)); + } + + //noinspection ConstantValue + if (COMPARATOR != null) { + list.sort(COMPARATOR); + } + + try (FileOutputStream out = new FileOutputStream(TARGET_CLASS_FILE)) { + + write(out, "package io.nats.client.api;\n\n" + + "import java.util.Collections;\n" + + "import java.util.HashMap;\n" + + "import java.util.Map;\n\n" + + "public final class "); + write(out, TARGET_CLASS_NAME); + writeln(out, " {\n" + + " public static final Map ERROR_BY_API_ERROR_CODE;\n"); + + for (GenerateServerErrorConstants constant : list) { + System.out.println(constant); + writeln(out, constant.getConstantLine()); + } + writeln(out, "\n static {\n" + + " Map temp = new HashMap<>();"); + for (GenerateServerErrorConstants constant : list) { + writeln(out, constant.getStaticLine()); + } + writeln(out, " ERROR_BY_API_ERROR_CODE = Collections.unmodifiableMap(temp);\n }\n}"); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + private static void write(FileOutputStream out, String s) throws IOException { + out.write(s.replace("\\n", System.lineSeparator()).getBytes(StandardCharsets.UTF_8)); + } + + private static void writeln(FileOutputStream out, String s) throws IOException { + write(out, s); + out.write(System.lineSeparator().getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/utils/src/main/java/io/nats/client/api/ServerErrorConstants.java b/utils/src/main/java/io/nats/client/api/ServerErrorConstants.java new file mode 100644 index 0000000..8351e4a --- /dev/null +++ b/utils/src/main/java/io/nats/client/api/ServerErrorConstants.java @@ -0,0 +1,420 @@ +// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +// GENERATED 10/10/2025 + +package io.nats.client.api; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public final class ServerErrorConstants { + public static final Map ERROR_BY_API_ERROR_CODE; + + public static final Error JSAccountResourcesExceededErr = new Error(400, 10002, "resource limits exceeded for account"); + public static final Error JSBadRequestErr = new Error(400, 10003, "bad request"); + public static final Error JSClusterIncompleteErr = new Error(503, 10004, "incomplete results"); + public static final Error JSClusterNoPeersErrF = new Error(400, 10005, "{err}"); + public static final Error JSClusterNotActiveErr = new Error(500, 10006, "JetStream not in clustered mode"); + public static final Error JSClusterNotAssignedErr = new Error(500, 10007, "JetStream cluster not assigned to this server"); + public static final Error JSClusterNotAvailErr = new Error(503, 10008, "JetStream system temporarily unavailable"); + public static final Error JSClusterNotLeaderErr = new Error(500, 10009, "JetStream cluster can not handle request"); + public static final Error JSClusterRequiredErr = new Error(503, 10010, "JetStream clustering support required"); + public static final Error JSClusterTagsErr = new Error(400, 10011, "tags placement not supported for operation"); + public static final Error JSConsumerCreateErrF = new Error(500, 10012, "{err}"); + public static final Error JSConsumerNameExistErr = new Error(400, 10013, "consumer name already in use"); + public static final Error JSConsumerNotFoundErr = new Error(404, 10014, "consumer not found"); + public static final Error JSSnapshotDeliverSubjectInvalidErr = new Error(400, 10015, "deliver subject not valid"); + public static final Error JSConsumerDurableNameNotInSubjectErr = new Error(400, 10016, "consumer expected to be durable but no durable name set in subject"); + public static final Error JSConsumerDurableNameNotMatchSubjectErr = new Error(400, 10017, "consumer name in subject does not match durable name in request"); + public static final Error JSConsumerDurableNameNotSetErr = new Error(400, 10018, "consumer expected to be durable but a durable name was not set"); + public static final Error JSConsumerEphemeralWithDurableInSubjectErr = new Error(400, 10019, "consumer expected to be ephemeral but detected a durable name set in subject"); + public static final Error JSConsumerEphemeralWithDurableNameErr = new Error(400, 10020, "consumer expected to be ephemeral but a durable name was set in request"); + public static final Error JSStreamExternalApiOverlapErrF = new Error(400, 10021, "stream external api prefix {prefix} must not overlap with {subject}"); + public static final Error JSStreamExternalDelPrefixOverlapsErrF = new Error(400, 10022, "stream external delivery prefix {prefix} overlaps with stream subject {subject}"); + public static final Error JSInsufficientResourcesErr = new Error(503, 10023, "insufficient resources"); + public static final Error JSStreamInvalidExternalDeliverySubjErrF = new Error(400, 10024, "stream external delivery prefix {prefix} must not contain wildcards"); + public static final Error JSInvalidJSONErr = new Error(400, 10025, "invalid JSON: {err}"); + public static final Error JSMaximumConsumersLimitErr = new Error(400, 10026, "maximum consumers limit reached"); + public static final Error JSMaximumStreamsLimitErr = new Error(400, 10027, "maximum number of streams reached"); + public static final Error JSMemoryResourcesExceededErr = new Error(500, 10028, "insufficient memory resources available"); + public static final Error JSMirrorConsumerSetupFailedErrF = new Error(500, 10029, "{err}"); + public static final Error JSMirrorMaxMessageSizeTooBigErr = new Error(400, 10030, "stream mirror must have max message size >= source"); + public static final Error JSMirrorWithSourcesErr = new Error(400, 10031, "stream mirrors can not also contain other sources"); + public static final Error JSMirrorWithStartSeqAndTimeErr = new Error(400, 10032, "stream mirrors can not have both start seq and start time configured"); + public static final Error JSMirrorWithSubjectFiltersErr = new Error(400, 10033, "stream mirrors can not contain filtered subjects"); + public static final Error JSMirrorWithSubjectsErr = new Error(400, 10034, "stream mirrors can not contain subjects"); + public static final Error JSNoAccountErr = new Error(503, 10035, "account not found"); + public static final Error JSClusterUnSupportFeatureErr = new Error(503, 10036, "not currently supported in clustered mode"); + public static final Error JSNoMessageFoundErr = new Error(404, 10037, "no message found"); + public static final Error JSNotEmptyRequestErr = new Error(400, 10038, "expected an empty request payload"); + public static final Error JSNotEnabledForAccountErr = new Error(503, 10039, "JetStream not enabled for account"); + public static final Error JSClusterPeerNotMemberErr = new Error(400, 10040, "peer not a member"); + public static final Error JSRaftGeneralErrF = new Error(500, 10041, "{err}"); + public static final Error JSRestoreSubscribeFailedErrF = new Error(500, 10042, "JetStream unable to subscribe to restore snapshot {subject}: {err}"); + public static final Error JSSequenceNotFoundErrF = new Error(400, 10043, "sequence {seq} not found"); + public static final Error JSClusterServerNotMemberErr = new Error(400, 10044, "server is not a member of the cluster"); + public static final Error JSSourceConsumerSetupFailedErrF = new Error(500, 10045, "{err}"); + public static final Error JSSourceMaxMessageSizeTooBigErr = new Error(400, 10046, "stream source must have max message size >= target"); + public static final Error JSStorageResourcesExceededErr = new Error(500, 10047, "insufficient storage resources available"); + public static final Error JSStreamAssignmentErrF = new Error(500, 10048, "{err}"); + public static final Error JSStreamCreateErrF = new Error(500, 10049, "{err}"); + public static final Error JSStreamDeleteErrF = new Error(500, 10050, "{err}"); + public static final Error JSStreamGeneralErrorF = new Error(500, 10051, "{err}"); + public static final Error JSStreamInvalidConfigF = new Error(500, 10052, "{err}"); + public static final Error JSStreamLimitsErrF = new Error(500, 10053, "{err}"); + public static final Error JSStreamMessageExceedsMaximumErr = new Error(400, 10054, "message size exceeds maximum allowed"); + public static final Error JSStreamMirrorNotUpdatableErr = new Error(400, 10055, "stream mirror configuration can not be updated"); + public static final Error JSStreamMismatchErr = new Error(400, 10056, "stream name in subject does not match request"); + public static final Error JSStreamMsgDeleteFailedF = new Error(500, 10057, "{err}"); + public static final Error JSStreamNameExistErr = new Error(400, 10058, "stream name already in use with a different configuration"); + public static final Error JSStreamNotFoundErr = new Error(404, 10059, "stream not found"); + public static final Error JSStreamNotMatchErr = new Error(400, 10060, "expected stream does not match"); + public static final Error JSStreamReplicasNotUpdatableErr = new Error(400, 10061, "Replicas configuration can not be updated"); + public static final Error JSStreamRestoreErrF = new Error(500, 10062, "restore failed: {err}"); + public static final Error JSStreamSequenceNotMatchErr = new Error(503, 10063, "expected stream sequence does not match"); + public static final Error JSStreamSnapshotErrF = new Error(500, 10064, "snapshot failed: {err}"); + public static final Error JSStreamSubjectOverlapErr = new Error(400, 10065, "subjects overlap with an existing stream"); + public static final Error JSStreamTemplateCreateErrF = new Error(500, 10066, "{err}"); + public static final Error JSStreamTemplateDeleteErrF = new Error(500, 10067, "{err}"); + public static final Error JSStreamTemplateNotFoundErr = new Error(404, 10068, "template not found"); + public static final Error JSStreamUpdateErrF = new Error(500, 10069, "{err}"); + public static final Error JSStreamWrongLastMsgIDErrF = new Error(400, 10070, "wrong last msg ID: {id}"); + public static final Error JSStreamWrongLastSequenceErrF = new Error(400, 10071, "wrong last sequence: {seq}"); + public static final Error JSTempStorageFailedErr = new Error(500, 10072, "JetStream unable to open temp storage for restore"); + public static final Error JSTemplateNameNotMatchSubjectErr = new Error(400, 10073, "template name in subject does not match request"); + public static final Error JSStreamReplicasNotSupportedErr = new Error(500, 10074, "replicas > 1 not supported in non-clustered mode"); + public static final Error JSPeerRemapErr = new Error(503, 10075, "peer remap failed"); + public static final Error JSNotEnabledErr = new Error(503, 10076, "JetStream not enabled"); + public static final Error JSStreamStoreFailedF = new Error(503, 10077, "{err}"); + public static final Error JSConsumerConfigRequiredErr = new Error(400, 10078, "consumer config required"); + public static final Error JSConsumerDeliverToWildcardsErr = new Error(400, 10079, "consumer deliver subject has wildcards"); + public static final Error JSConsumerPushMaxWaitingErr = new Error(400, 10080, "consumer in push mode can not set max waiting"); + public static final Error JSConsumerDeliverCycleErr = new Error(400, 10081, "consumer deliver subject forms a cycle"); + public static final Error JSConsumerMaxPendingAckPolicyRequiredErr = new Error(400, 10082, "consumer requires ack policy for max ack pending"); + public static final Error JSConsumerSmallHeartbeatErr = new Error(400, 10083, "consumer idle heartbeat needs to be >= 100ms"); + public static final Error JSConsumerPullRequiresAckErr = new Error(400, 10084, "consumer in pull mode requires explicit ack policy on workqueue stream"); + public static final Error JSConsumerPullNotDurableErr = new Error(400, 10085, "consumer in pull mode requires a durable name"); + public static final Error JSConsumerPullWithRateLimitErr = new Error(400, 10086, "consumer in pull mode can not have rate limit set"); + public static final Error JSConsumerMaxWaitingNegativeErr = new Error(400, 10087, "consumer max waiting needs to be positive"); + public static final Error JSConsumerHBRequiresPushErr = new Error(400, 10088, "consumer idle heartbeat requires a push based consumer"); + public static final Error JSConsumerFCRequiresPushErr = new Error(400, 10089, "consumer flow control requires a push based consumer"); + public static final Error JSConsumerDirectRequiresPushErr = new Error(400, 10090, "consumer direct requires a push based consumer"); + public static final Error JSConsumerDirectRequiresEphemeralErr = new Error(400, 10091, "consumer direct requires an ephemeral consumer"); + public static final Error JSConsumerOnMappedErr = new Error(400, 10092, "consumer direct on a mapped consumer"); + public static final Error JSConsumerFilterNotSubsetErr = new Error(400, 10093, "consumer filter subject is not a valid subset of the interest subjects"); + public static final Error JSConsumerInvalidPolicyErrF = new Error(400, 10094, "{err}"); + public static final Error JSConsumerInvalidSamplingErrF = new Error(400, 10095, "failed to parse consumer sampling configuration: {err}"); + public static final Error JSStreamInvalidErr = new Error(500, 10096, "stream not valid"); + public static final Error JSStreamHeaderExceedsMaximumErr = new Error(400, 10097, "header size exceeds maximum allowed of 64k"); + public static final Error JSConsumerWQRequiresExplicitAckErr = new Error(400, 10098, "workqueue stream requires explicit ack"); + public static final Error JSConsumerWQMultipleUnfilteredErr = new Error(400, 10099, "multiple non-filtered consumers not allowed on workqueue stream"); + public static final Error JSConsumerWQConsumerNotUniqueErr = new Error(400, 10100, "filtered consumer not unique on workqueue stream"); + public static final Error JSConsumerWQConsumerNotDeliverAllErr = new Error(400, 10101, "consumer must be deliver all on workqueue stream"); + public static final Error JSConsumerNameTooLongErrF = new Error(400, 10102, "consumer name is too long, maximum allowed is {max}"); + public static final Error JSConsumerBadDurableNameErr = new Error(400, 10103, "durable name can not contain '.', '*', '>'"); + public static final Error JSConsumerStoreFailedErrF = new Error(500, 10104, "error creating store for consumer: {err}"); + public static final Error JSConsumerExistingActiveErr = new Error(400, 10105, "consumer already exists and is still active"); + public static final Error JSConsumerReplacementWithDifferentNameErr = new Error(400, 10106, "consumer replacement durable config not the same"); + public static final Error JSConsumerDescriptionTooLongErrF = new Error(400, 10107, "consumer description is too long, maximum allowed is {max}"); + public static final Error JSConsumerWithFlowControlNeedsHeartbeats = new Error(400, 10108, "consumer with flow control also needs heartbeats"); + public static final Error JSStreamSealedErr = new Error(400, 10109, "invalid operation on sealed stream"); + public static final Error JSStreamPurgeFailedF = new Error(500, 10110, "{err}"); + public static final Error JSStreamRollupFailedF = new Error(500, 10111, "{err}"); + public static final Error JSConsumerInvalidDeliverSubject = new Error(400, 10112, "invalid push consumer deliver subject"); + public static final Error JSStreamMaxBytesRequired = new Error(400, 10113, "account requires a stream config to have max bytes set"); + public static final Error JSConsumerMaxRequestBatchNegativeErr = new Error(400, 10114, "consumer max request batch needs to be > 0"); + public static final Error JSConsumerMaxRequestExpiresTooSmall = new Error(400, 10115, "consumer max request expires needs to be >= 1ms"); + public static final Error JSConsumerMaxDeliverBackoffErr = new Error(400, 10116, "max deliver is required to be > length of backoff values"); + public static final Error JSStreamInfoMaxSubjectsErr = new Error(500, 10117, "subject details would exceed maximum allowed"); + public static final Error JSStreamOfflineErr = new Error(500, 10118, "stream is offline"); + public static final Error JSConsumerOfflineErr = new Error(500, 10119, "consumer is offline"); + public static final Error JSNoLimitsErr = new Error(400, 10120, "no JetStream default or applicable tiered limit present"); + public static final Error JSConsumerMaxPendingAckExcessErrF = new Error(400, 10121, "consumer max ack pending exceeds system limit of {limit}"); + public static final Error JSStreamMaxStreamBytesExceeded = new Error(400, 10122, "stream max bytes exceeds account limit max stream bytes"); + public static final Error JSStreamMoveAndScaleErr = new Error(400, 10123, "can not move and scale a stream in a single update"); + public static final Error JSStreamMoveInProgressF = new Error(400, 10124, "stream move already in progress: {msg}"); + public static final Error JSConsumerMaxRequestBatchExceededF = new Error(400, 10125, "consumer max request batch exceeds server limit of {limit}"); + public static final Error JSConsumerReplicasExceedsStream = new Error(400, 10126, "consumer config replica count exceeds parent stream"); + public static final Error JSConsumerNameContainsPathSeparatorsErr = new Error(400, 10127, "Consumer name can not contain path separators"); + public static final Error JSStreamNameContainsPathSeparatorsErr = new Error(400, 10128, "Stream name can not contain path separators"); + public static final Error JSStreamMoveNotInProgress = new Error(400, 10129, "stream move not in progress"); + public static final Error JSStreamNameExistRestoreFailedErr = new Error(400, 10130, "stream name already in use, cannot restore"); + public static final Error JSConsumerCreateFilterSubjectMismatchErr = new Error(400, 10131, "Consumer create request did not match filtered subject from create subject"); + public static final Error JSConsumerCreateDurableAndNameMismatch = new Error(400, 10132, "Consumer Durable and Name have to be equal if both are provided"); + public static final Error JSReplicasCountCannotBeNegative = new Error(400, 10133, "replicas count cannot be negative"); + public static final Error JSConsumerReplicasShouldMatchStream = new Error(400, 10134, "consumer config replicas must match interest retention stream's replicas"); + public static final Error JSConsumerMetadataLengthErrF = new Error(400, 10135, "consumer metadata exceeds maximum size of {limit}"); + public static final Error JSConsumerDuplicateFilterSubjects = new Error(400, 10136, "consumer cannot have both FilterSubject and FilterSubjects specified"); + public static final Error JSConsumerMultipleFiltersNotAllowed = new Error(400, 10137, "consumer with multiple subject filters cannot use subject based API"); + public static final Error JSConsumerOverlappingSubjectFilters = new Error(400, 10138, "consumer subject filters cannot overlap"); + public static final Error JSConsumerEmptyFilter = new Error(400, 10139, "consumer filter in FilterSubjects cannot be empty"); + public static final Error JSSourceDuplicateDetected = new Error(400, 10140, "duplicate source configuration detected"); + public static final Error JSSourceInvalidStreamName = new Error(400, 10141, "sourced stream name is invalid"); + public static final Error JSMirrorInvalidStreamName = new Error(400, 10142, "mirrored stream name is invalid"); + public static final Error JSMirrorWithFirstSeqErr = new Error(400, 10143, "stream mirrors can not have first sequence configured"); + public static final Error JSSourceMultipleFiltersNotAllowed = new Error(400, 10144, "source with multiple subject transforms cannot also have a single subject filter"); + public static final Error JSSourceInvalidSubjectFilter = new Error(400, 10145, "source transform source: {err}"); + public static final Error JSSourceInvalidTransformDestination = new Error(400, 10146, "source transform: {err}"); + public static final Error JSSourceOverlappingSubjectFilters = new Error(400, 10147, "source filters can not overlap"); + public static final Error JSConsumerAlreadyExists = new Error(400, 10148, "consumer already exists"); + public static final Error JSConsumerDoesNotExist = new Error(400, 10149, "consumer does not exist"); + public static final Error JSMirrorMultipleFiltersNotAllowed = new Error(400, 10150, "mirror with multiple subject transforms cannot also have a single subject filter"); + public static final Error JSMirrorInvalidSubjectFilter = new Error(400, 10151, "mirror transform source: {err}"); + public static final Error JSMirrorOverlappingSubjectFilters = new Error(400, 10152, "mirror subject filters can not overlap"); + public static final Error JSConsumerInactiveThresholdExcess = new Error(400, 10153, "consumer inactive threshold exceeds system limit of {limit}"); + public static final Error JSMirrorInvalidTransformDestination = new Error(400, 10154, "mirror transform: {err}"); + public static final Error JSStreamTransformInvalidSource = new Error(400, 10155, "stream transform source: {err}"); + public static final Error JSStreamTransformInvalidDestination = new Error(400, 10156, "stream transform: {err}"); + public static final Error JSPedanticErrF = new Error(400, 10157, "pedantic mode: {err}"); + public static final Error JSStreamDuplicateMessageConflict = new Error(409, 10158, "duplicate message id is in process"); + public static final Error JSConsumerPriorityPolicyWithoutGroup = new Error(400, 10159, "Setting PriorityPolicy requires at least one PriorityGroup to be set"); + public static final Error JSConsumerInvalidPriorityGroupErr = new Error(400, 10160, "Provided priority group does not exist for this consumer"); + public static final Error JSConsumerEmptyGroupName = new Error(400, 10161, "Group name cannot be an empty string"); + public static final Error JSConsumerInvalidGroupNameErr = new Error(400, 10162, "Valid priority group name must match A-Z, a-z, 0-9, -_/=)+ and may not exceed 16 characters"); + public static final Error JSStreamExpectedLastSeqPerSubjectNotReady = new Error(503, 10163, "expected last sequence per subject temporarily unavailable"); + public static final Error JSStreamWrongLastSequenceConstantErr = new Error(400, 10164, "wrong last sequence"); + public static final Error JSMessageTTLInvalidErr = new Error(400, 10165, "invalid per-message TTL"); + public static final Error JSMessageTTLDisabledErr = new Error(400, 10166, "per-message TTL is disabled"); + public static final Error JSStreamTooManyRequests = new Error(429, 10167, "too many requests"); + public static final Error JSMessageIncrDisabledErr = new Error(400, 10168, "message counters is disabled"); + public static final Error JSMessageIncrMissingErr = new Error(400, 10169, "message counter increment is missing"); + public static final Error JSMessageIncrPayloadErr = new Error(400, 10170, "message counter has payload"); + public static final Error JSMessageIncrInvalidErr = new Error(400, 10171, "message counter increment is invalid"); + public static final Error JSMessageCounterBrokenErr = new Error(400, 10172, "message counter is broken"); + public static final Error JSMirrorWithCountersErr = new Error(400, 10173, "stream mirrors can not also calculate counters"); + public static final Error JSAtomicPublishDisabledErr = new Error(400, 10174, "atomic publish is disabled"); + public static final Error JSAtomicPublishMissingSeqErr = new Error(400, 10175, "atomic publish sequence is missing"); + public static final Error JSAtomicPublishIncompleteBatchErr = new Error(400, 10176, "atomic publish batch is incomplete"); + public static final Error JSAtomicPublishUnsupportedHeaderBatchErr = new Error(400, 10177, "atomic publish unsupported header used: {header}"); + public static final Error JSConsumerPushWithPriorityGroupErr = new Error(400, 10178, "priority groups can not be used with push consumers"); + public static final Error JSAtomicPublishInvalidBatchIDErr = new Error(400, 10179, "atomic publish batch ID is invalid"); + public static final Error JSStreamMinLastSeqErr = new Error(412, 10180, "min last sequence"); + public static final Error JSConsumerAckPolicyInvalidErr = new Error(400, 10181, "consumer ack policy invalid"); + public static final Error JSConsumerReplayPolicyInvalidErr = new Error(400, 10182, "consumer replay policy invalid"); + public static final Error JSConsumerAckWaitNegativeErr = new Error(400, 10183, "consumer ack wait needs to be positive"); + public static final Error JSConsumerBackOffNegativeErr = new Error(400, 10184, "consumer backoff needs to be positive"); + public static final Error JSRequiredApiLevelErr = new Error(412, 10185, "JetStream minimum api level required"); + public static final Error JSMirrorWithMsgSchedulesErr = new Error(400, 10186, "stream mirrors can not also schedule messages"); + public static final Error JSSourceWithMsgSchedulesErr = new Error(400, 10187, "stream source can not also schedule messages"); + public static final Error JSMessageSchedulesDisabledErr = new Error(400, 10188, "message schedules is disabled"); + public static final Error JSMessageSchedulesPatternInvalidErr = new Error(400, 10189, "message schedules pattern is invalid"); + public static final Error JSMessageSchedulesTargetInvalidErr = new Error(400, 10190, "message schedules target is invalid"); + public static final Error JSMessageSchedulesTTLInvalidErr = new Error(400, 10191, "message schedules invalid per-message TTL"); + public static final Error JSMessageSchedulesRollupInvalidErr = new Error(400, 10192, "message schedules invalid rollup"); + public static final Error JSStreamExpectedLastSeqPerSubjectInvalid = new Error(400, 10193, "missing sequence for expected last sequence per subject"); + public static final Error JSStreamOfflineReasonErrF = new Error(500, 10194, "stream is offline: {err}"); + public static final Error JSConsumerOfflineReasonErrF = new Error(500, 10195, "consumer is offline: {err}"); + public static final Error JSConsumerPriorityGroupWithPolicyNone = new Error(400, 10196, "consumer can not have priority groups when policy is none"); + public static final Error JSConsumerPinnedTTLWithoutPriorityPolicyNone = new Error(400, 10197, "PinnedTTL cannot be set when PriorityPolicy is none"); + public static final Error JSMirrorWithAtomicPublishErr = new Error(400, 10198, "stream mirrors can not also use atomic publishing"); + public static final Error JSAtomicPublishTooLargeBatchErrF = new Error(400, 10199, "atomic publish batch is too large: {size}"); + public static final Error JSAtomicPublishInvalidBatchCommitErr = new Error(400, 10200, "atomic publish batch commit is invalid"); + public static final Error JSAtomicPublishContainsDuplicateMessageErr = new Error(400, 10201, "atomic publish batch contains duplicate message id"); + + static { + Map temp = new HashMap<>(); + temp.put(10002, JSAccountResourcesExceededErr); + temp.put(10003, JSBadRequestErr); + temp.put(10004, JSClusterIncompleteErr); + temp.put(10005, JSClusterNoPeersErrF); + temp.put(10006, JSClusterNotActiveErr); + temp.put(10007, JSClusterNotAssignedErr); + temp.put(10008, JSClusterNotAvailErr); + temp.put(10009, JSClusterNotLeaderErr); + temp.put(10010, JSClusterRequiredErr); + temp.put(10011, JSClusterTagsErr); + temp.put(10012, JSConsumerCreateErrF); + temp.put(10013, JSConsumerNameExistErr); + temp.put(10014, JSConsumerNotFoundErr); + temp.put(10015, JSSnapshotDeliverSubjectInvalidErr); + temp.put(10016, JSConsumerDurableNameNotInSubjectErr); + temp.put(10017, JSConsumerDurableNameNotMatchSubjectErr); + temp.put(10018, JSConsumerDurableNameNotSetErr); + temp.put(10019, JSConsumerEphemeralWithDurableInSubjectErr); + temp.put(10020, JSConsumerEphemeralWithDurableNameErr); + temp.put(10021, JSStreamExternalApiOverlapErrF); + temp.put(10022, JSStreamExternalDelPrefixOverlapsErrF); + temp.put(10023, JSInsufficientResourcesErr); + temp.put(10024, JSStreamInvalidExternalDeliverySubjErrF); + temp.put(10025, JSInvalidJSONErr); + temp.put(10026, JSMaximumConsumersLimitErr); + temp.put(10027, JSMaximumStreamsLimitErr); + temp.put(10028, JSMemoryResourcesExceededErr); + temp.put(10029, JSMirrorConsumerSetupFailedErrF); + temp.put(10030, JSMirrorMaxMessageSizeTooBigErr); + temp.put(10031, JSMirrorWithSourcesErr); + temp.put(10032, JSMirrorWithStartSeqAndTimeErr); + temp.put(10033, JSMirrorWithSubjectFiltersErr); + temp.put(10034, JSMirrorWithSubjectsErr); + temp.put(10035, JSNoAccountErr); + temp.put(10036, JSClusterUnSupportFeatureErr); + temp.put(10037, JSNoMessageFoundErr); + temp.put(10038, JSNotEmptyRequestErr); + temp.put(10039, JSNotEnabledForAccountErr); + temp.put(10040, JSClusterPeerNotMemberErr); + temp.put(10041, JSRaftGeneralErrF); + temp.put(10042, JSRestoreSubscribeFailedErrF); + temp.put(10043, JSSequenceNotFoundErrF); + temp.put(10044, JSClusterServerNotMemberErr); + temp.put(10045, JSSourceConsumerSetupFailedErrF); + temp.put(10046, JSSourceMaxMessageSizeTooBigErr); + temp.put(10047, JSStorageResourcesExceededErr); + temp.put(10048, JSStreamAssignmentErrF); + temp.put(10049, JSStreamCreateErrF); + temp.put(10050, JSStreamDeleteErrF); + temp.put(10051, JSStreamGeneralErrorF); + temp.put(10052, JSStreamInvalidConfigF); + temp.put(10053, JSStreamLimitsErrF); + temp.put(10054, JSStreamMessageExceedsMaximumErr); + temp.put(10055, JSStreamMirrorNotUpdatableErr); + temp.put(10056, JSStreamMismatchErr); + temp.put(10057, JSStreamMsgDeleteFailedF); + temp.put(10058, JSStreamNameExistErr); + temp.put(10059, JSStreamNotFoundErr); + temp.put(10060, JSStreamNotMatchErr); + temp.put(10061, JSStreamReplicasNotUpdatableErr); + temp.put(10062, JSStreamRestoreErrF); + temp.put(10063, JSStreamSequenceNotMatchErr); + temp.put(10064, JSStreamSnapshotErrF); + temp.put(10065, JSStreamSubjectOverlapErr); + temp.put(10066, JSStreamTemplateCreateErrF); + temp.put(10067, JSStreamTemplateDeleteErrF); + temp.put(10068, JSStreamTemplateNotFoundErr); + temp.put(10069, JSStreamUpdateErrF); + temp.put(10070, JSStreamWrongLastMsgIDErrF); + temp.put(10071, JSStreamWrongLastSequenceErrF); + temp.put(10072, JSTempStorageFailedErr); + temp.put(10073, JSTemplateNameNotMatchSubjectErr); + temp.put(10074, JSStreamReplicasNotSupportedErr); + temp.put(10075, JSPeerRemapErr); + temp.put(10076, JSNotEnabledErr); + temp.put(10077, JSStreamStoreFailedF); + temp.put(10078, JSConsumerConfigRequiredErr); + temp.put(10079, JSConsumerDeliverToWildcardsErr); + temp.put(10080, JSConsumerPushMaxWaitingErr); + temp.put(10081, JSConsumerDeliverCycleErr); + temp.put(10082, JSConsumerMaxPendingAckPolicyRequiredErr); + temp.put(10083, JSConsumerSmallHeartbeatErr); + temp.put(10084, JSConsumerPullRequiresAckErr); + temp.put(10085, JSConsumerPullNotDurableErr); + temp.put(10086, JSConsumerPullWithRateLimitErr); + temp.put(10087, JSConsumerMaxWaitingNegativeErr); + temp.put(10088, JSConsumerHBRequiresPushErr); + temp.put(10089, JSConsumerFCRequiresPushErr); + temp.put(10090, JSConsumerDirectRequiresPushErr); + temp.put(10091, JSConsumerDirectRequiresEphemeralErr); + temp.put(10092, JSConsumerOnMappedErr); + temp.put(10093, JSConsumerFilterNotSubsetErr); + temp.put(10094, JSConsumerInvalidPolicyErrF); + temp.put(10095, JSConsumerInvalidSamplingErrF); + temp.put(10096, JSStreamInvalidErr); + temp.put(10097, JSStreamHeaderExceedsMaximumErr); + temp.put(10098, JSConsumerWQRequiresExplicitAckErr); + temp.put(10099, JSConsumerWQMultipleUnfilteredErr); + temp.put(10100, JSConsumerWQConsumerNotUniqueErr); + temp.put(10101, JSConsumerWQConsumerNotDeliverAllErr); + temp.put(10102, JSConsumerNameTooLongErrF); + temp.put(10103, JSConsumerBadDurableNameErr); + temp.put(10104, JSConsumerStoreFailedErrF); + temp.put(10105, JSConsumerExistingActiveErr); + temp.put(10106, JSConsumerReplacementWithDifferentNameErr); + temp.put(10107, JSConsumerDescriptionTooLongErrF); + temp.put(10108, JSConsumerWithFlowControlNeedsHeartbeats); + temp.put(10109, JSStreamSealedErr); + temp.put(10110, JSStreamPurgeFailedF); + temp.put(10111, JSStreamRollupFailedF); + temp.put(10112, JSConsumerInvalidDeliverSubject); + temp.put(10113, JSStreamMaxBytesRequired); + temp.put(10114, JSConsumerMaxRequestBatchNegativeErr); + temp.put(10115, JSConsumerMaxRequestExpiresTooSmall); + temp.put(10116, JSConsumerMaxDeliverBackoffErr); + temp.put(10117, JSStreamInfoMaxSubjectsErr); + temp.put(10118, JSStreamOfflineErr); + temp.put(10119, JSConsumerOfflineErr); + temp.put(10120, JSNoLimitsErr); + temp.put(10121, JSConsumerMaxPendingAckExcessErrF); + temp.put(10122, JSStreamMaxStreamBytesExceeded); + temp.put(10123, JSStreamMoveAndScaleErr); + temp.put(10124, JSStreamMoveInProgressF); + temp.put(10125, JSConsumerMaxRequestBatchExceededF); + temp.put(10126, JSConsumerReplicasExceedsStream); + temp.put(10127, JSConsumerNameContainsPathSeparatorsErr); + temp.put(10128, JSStreamNameContainsPathSeparatorsErr); + temp.put(10129, JSStreamMoveNotInProgress); + temp.put(10130, JSStreamNameExistRestoreFailedErr); + temp.put(10131, JSConsumerCreateFilterSubjectMismatchErr); + temp.put(10132, JSConsumerCreateDurableAndNameMismatch); + temp.put(10133, JSReplicasCountCannotBeNegative); + temp.put(10134, JSConsumerReplicasShouldMatchStream); + temp.put(10135, JSConsumerMetadataLengthErrF); + temp.put(10136, JSConsumerDuplicateFilterSubjects); + temp.put(10137, JSConsumerMultipleFiltersNotAllowed); + temp.put(10138, JSConsumerOverlappingSubjectFilters); + temp.put(10139, JSConsumerEmptyFilter); + temp.put(10140, JSSourceDuplicateDetected); + temp.put(10141, JSSourceInvalidStreamName); + temp.put(10142, JSMirrorInvalidStreamName); + temp.put(10143, JSMirrorWithFirstSeqErr); + temp.put(10144, JSSourceMultipleFiltersNotAllowed); + temp.put(10145, JSSourceInvalidSubjectFilter); + temp.put(10146, JSSourceInvalidTransformDestination); + temp.put(10147, JSSourceOverlappingSubjectFilters); + temp.put(10148, JSConsumerAlreadyExists); + temp.put(10149, JSConsumerDoesNotExist); + temp.put(10150, JSMirrorMultipleFiltersNotAllowed); + temp.put(10151, JSMirrorInvalidSubjectFilter); + temp.put(10152, JSMirrorOverlappingSubjectFilters); + temp.put(10153, JSConsumerInactiveThresholdExcess); + temp.put(10154, JSMirrorInvalidTransformDestination); + temp.put(10155, JSStreamTransformInvalidSource); + temp.put(10156, JSStreamTransformInvalidDestination); + temp.put(10157, JSPedanticErrF); + temp.put(10158, JSStreamDuplicateMessageConflict); + temp.put(10159, JSConsumerPriorityPolicyWithoutGroup); + temp.put(10160, JSConsumerInvalidPriorityGroupErr); + temp.put(10161, JSConsumerEmptyGroupName); + temp.put(10162, JSConsumerInvalidGroupNameErr); + temp.put(10163, JSStreamExpectedLastSeqPerSubjectNotReady); + temp.put(10164, JSStreamWrongLastSequenceConstantErr); + temp.put(10165, JSMessageTTLInvalidErr); + temp.put(10166, JSMessageTTLDisabledErr); + temp.put(10167, JSStreamTooManyRequests); + temp.put(10168, JSMessageIncrDisabledErr); + temp.put(10169, JSMessageIncrMissingErr); + temp.put(10170, JSMessageIncrPayloadErr); + temp.put(10171, JSMessageIncrInvalidErr); + temp.put(10172, JSMessageCounterBrokenErr); + temp.put(10173, JSMirrorWithCountersErr); + temp.put(10174, JSAtomicPublishDisabledErr); + temp.put(10175, JSAtomicPublishMissingSeqErr); + temp.put(10176, JSAtomicPublishIncompleteBatchErr); + temp.put(10177, JSAtomicPublishUnsupportedHeaderBatchErr); + temp.put(10178, JSConsumerPushWithPriorityGroupErr); + temp.put(10179, JSAtomicPublishInvalidBatchIDErr); + temp.put(10180, JSStreamMinLastSeqErr); + temp.put(10181, JSConsumerAckPolicyInvalidErr); + temp.put(10182, JSConsumerReplayPolicyInvalidErr); + temp.put(10183, JSConsumerAckWaitNegativeErr); + temp.put(10184, JSConsumerBackOffNegativeErr); + temp.put(10185, JSRequiredApiLevelErr); + temp.put(10186, JSMirrorWithMsgSchedulesErr); + temp.put(10187, JSSourceWithMsgSchedulesErr); + temp.put(10188, JSMessageSchedulesDisabledErr); + temp.put(10189, JSMessageSchedulesPatternInvalidErr); + temp.put(10190, JSMessageSchedulesTargetInvalidErr); + temp.put(10191, JSMessageSchedulesTTLInvalidErr); + temp.put(10192, JSMessageSchedulesRollupInvalidErr); + temp.put(10193, JSStreamExpectedLastSeqPerSubjectInvalid); + temp.put(10194, JSStreamOfflineReasonErrF); + temp.put(10195, JSConsumerOfflineReasonErrF); + temp.put(10196, JSConsumerPriorityGroupWithPolicyNone); + temp.put(10197, JSConsumerPinnedTTLWithoutPriorityPolicyNone); + temp.put(10198, JSMirrorWithAtomicPublishErr); + temp.put(10199, JSAtomicPublishTooLargeBatchErrF); + temp.put(10200, JSAtomicPublishInvalidBatchCommitErr); + temp.put(10201, JSAtomicPublishContainsDuplicateMessageErr); + ERROR_BY_API_ERROR_CODE = Collections.unmodifiableMap(temp); + } +} diff --git a/utils/src/main/resources/placeholder.txt b/utils/src/main/resources/placeholder.txt new file mode 100644 index 0000000..ca5fd64 --- /dev/null +++ b/utils/src/main/resources/placeholder.txt @@ -0,0 +1 @@ +This is just a placeholder. \ No newline at end of file diff --git a/utils/src/test/resources/placeholder.txt b/utils/src/test/resources/placeholder.txt new file mode 100644 index 0000000..ca5fd64 --- /dev/null +++ b/utils/src/test/resources/placeholder.txt @@ -0,0 +1 @@ +This is just a placeholder. \ No newline at end of file