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.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.
-[](retrier/README.md)
-
+[Retrier README](retrier/README.md)
+
+
+
+
[](https://javadoc.io/doc/io.synadia/retrier)
-[](https://maven-badges.herokuapp.com/maven-central/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)
-[](js-publish-extensions/README.md)
-
+
+
+
[](https://javadoc.io/doc/io.synadia/jnats-js-publish-extensions)
-[](https://maven-badges.herokuapp.com/maven-central/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.
-[](request-many/README.md)
-
+[Request Many README](request-many/README.md)
+
+
+
+
[](https://javadoc.io/doc/io.synadia/request-many)
-[](https://maven-badges.herokuapp.com/maven-central/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.
-[](encoded-kv/README.md)
-
+[Encoded KeyValue README](encoded-kv/README.md)
+
+
+
+
[](https://javadoc.io/doc/io.synadia/encoded-kv)
-[](https://maven-badges.herokuapp.com/maven-central/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)
-[](direct-batch/README.md)
-
+[Direct Batch README](direct-batch/README.md)
+
+
+
+
[](https://javadoc.io/doc/io.synadia/direct-batch)
-[](https://maven-badges.herokuapp.com/maven-central/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)
+
+
+
+
+[](https://javadoc.io/doc/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)
+
+
+
+
+[](https://javadoc.io/doc/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)
+
+
+
+
+[](https://javadoc.io/doc/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.
-[](chaos-runner/README.md)
-
+[Chaos Runner README](chaos-runner/README.md)
+
+
+
+
[](https://javadoc.io/doc/io.synadia/chaos-runner)
-[](https://maven-badges.herokuapp.com/maven-central/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)
+
+
+
+
+[](https://javadoc.io/doc/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 @@
+
+
+# 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
+
+
+
+
+[](https://github.com/synadia-io/orbit.java?tab=readme-ov-file#dependencies)
+[](https://javadoc.io/doc/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:
+ *
+ * - expectedLastSequence
+ * - expectedLastSubSeq
+ * - expectedLastSubSeqSubject
+ *
+ * Does not clear the following fields:
+ *
+ * - ackTimeout
+ * - ackFirst
+ * - ackEvery
+ * - expectedStream
+ * - messageTtl
+ *
+ * @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
+
+
+
+
+
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 @@
- 
+
# 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)
+
+
+
+[](https://github.com/synadia-io/orbit.java?tab=readme-ov-file#dependencies)
+[](https://javadoc.io/doc/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
-
+
[](https://www.apache.org/licenses/LICENSE-2.0)
[](https://maven-badges.herokuapp.com/maven-central/io.synadia/chaos-runner)
[](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 @@
+
+
+# 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
+
+
+
+
+[](https://github.com/synadia-io/orbit.java?tab=readme-ov-file#dependencies)
+[](https://javadoc.io/doc/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)
+```
+
+[](https://www.apache.org/licenses/LICENSE-2.0)
+[](https://maven-badges.herokuapp.com/maven-central/io.synadia/counters)
+[](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
+
+
+
+
+
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 @@
- 
+
# 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)
-
-
-[](https://www.apache.org/licenses/LICENSE-2.0)
-[](https://maven-badges.herokuapp.com/maven-central/io.synadia/direct-batch)
+
+
+
+[](https://github.com/synadia-io/orbit.java?tab=readme-ov-file#dependencies)
[](https://javadoc.io/doc/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 @@
- 
+
# 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)
-
-
-[](https://www.apache.org/licenses/LICENSE-2.0)
-[](https://maven-badges.herokuapp.com/maven-central/io.synadia/encoded-kv)
+
+
+
+[](https://github.com/synadia-io/orbit.java?tab=readme-ov-file#dependencies)
[](https://javadoc.io/doc/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