diff --git a/README.md b/README.md index 538c89d..3793216 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ client. Note that these libraries will evolve rapidly and API guarantees are gen | [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 | +| [Batch Publish](batch-publish) | Publish an atomic batch or a fast-ingest batch | 0.2.2 | 0.3.0-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 | @@ -84,13 +84,13 @@ The functionality is described in [ADR-31](https://github.com/nats-io/nats-archi ### Batch Publish -Utility to publish an atomic batch, a group of up to 1000 messages +Utility to publish a group of messages as one unit, either as an atomic batch of up to 1000 messages where all are stored or none are, or as a fast-ingest batch with no size limit and server driven flow control. [Batch Publish README](batch-publish/README.md) ![Artifact](https://img.shields.io/badge/Artifact-io.synadia:batch--publish-197556?labelColor=grey&style=flat) ![0.2.2](https://img.shields.io/badge/Current_Release-0.2.2-27AAE0) -![0.2.3](https://img.shields.io/badge/Current_Snapshot-0.2.3--SNAPSHOT-27AAE0) +![0.3.0](https://img.shields.io/badge/Current_Snapshot-0.3.0--SNAPSHOT-27AAE0) [![javadoc](https://javadoc.io/badge2/io.synadia/batch-publish/javadoc.svg)](https://javadoc.io/doc/io.synadia/batch-publish) [![Maven Central](https://img.shields.io/maven-central/v/io.synadia/batch-publish)](https://img.shields.io/maven-central/v/io.synadia/batch-publish) diff --git a/batch-publish/README.md b/batch-publish/README.md index 945ba3c..0c85265 100644 --- a/batch-publish/README.md +++ b/batch-publish/README.md @@ -2,23 +2,118 @@ # Batch Publish -Utility to publish an atomic batch, a group of up to 1000 messages +Publish a group of messages as one unit. [ADR-50](https://github.com/nats-io/nats-architecture-and-design/blob/main/adr/ADR-50.md) defines two ways to do this, and they have opposite goals. They share a vocabulary — a batch id, a batch sequence, and a `PublishAck` carrying `batch` and `count` — but they are separate wire protocols, so pick the one that matches what you need. -### Important +| | Atomic Batch Publish | Fast-Ingest Publish | +|---|---|---| +| Guarantee | All messages stored, or none | None; messages are stored as they arrive | +| Size limit | 1000 messages | No limit | +| Messages can be lost | No | Yes, and you choose how that is handled | +| Flow control | No | Yes, server driven | +| Stream config | `allow_atomic` | `allow_batched` | +| Server | 2.12.0+ | 2.14.0+ | +| Java types | `BatchPublisher`, `EobBatchPublisher` | `FastPublisher`, `EobFastPublisher` | -* 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. +## Atomic Batch Publish -https://github.com/nats-io/nats-architecture-and-design/blob/main/adr/ADR-50.md +A group of up to 1000 messages that all get added to the stream or none do. + +* Messages are staged in memory on the server until the commit; nothing is stored before then. +* This is about transactions, not speed. + +### Ending a batch + +There are two ways to end an atomic batch, one per type. Both add messages identically — same `add`/`addAcked`, same headers, same options. + +`BatchPublisher.commit(subject, data)` sends a final real message and stores it along with the rest of the batch. Use it when the last thing you have to publish is genuinely the last message of your transaction. + +`EobBatchPublisher.commit()` takes no message and ends the batch **without storing one**. The server discards the sentinel's payload, rewrites the header of the previously received last message so the batch commits normally, and reports a batch size that excludes the sentinel. Use it when your transaction is exactly the messages you already published — otherwise you would have to hold one message back just to carry the commit, or invent a filler message and permanently store a piece of junk in the stream. + +```java +EobBatchPublisher bp = EobBatchPublisher.builder().connection(nc).build(); +bp.add(subject, data); +bp.add(subject, data); +PublishAck pa = bp.commit(); // no message, no subject; pa.getBatchSize() is 2 +``` + +The sentinel is published on the subject of the **first** message added. + +A batch cannot consist of only a sentinel. `size()` always reports the number of *stored* messages, so it agrees with `PublishAck.getBatchSize()` in both cases, and the publisher checks that agreement on the ack rather than assuming it. + +### What the publisher refuses + +Four things are rejected locally rather than sent for the server to reject, because each of them costs the whole batch: + +* An expected **last sequence** on any message but the first. ADR-50 allows it only on the first message, and the server rejects the entire batch when a later one carries it. The expected last *subject* sequence and the expected stream are not restricted this way and can go on any message. +* The batch protocol headers — `Nats-Batch-Id`, `Nats-Batch-Sequence`, `Nats-Batch-Commit` — in your own headers. The publisher writes those itself. +* `Nats-Expected-Last-Msg-Id`, which the server refuses inside a batch. `Nats-Msg-Id` is fine: batch de-duplication is supported from server 2.12.1. +* A batch id that is not a single subject token, or is longer than 64 characters. Fast ingest carries the id in the reply subject, so a dot in it would silently become a different batch id on the server. + +**`EobBatchPublisher` requires a server at 2.14.0 or later**, checked at `build()`. `BatchPublisher` needs only 2.12.0. + +## Fast-Ingest Publish + +**This is not atomic.** There is no staging and no all-or-nothing guarantee: messages are persisted as they arrive, the batch has no size limit, and messages can be dropped by the server's overload protection or lost across a stream leader change. What you get in exchange is a control channel over which the server continuously tells you how fast you are allowed to go, which is what keeps many concurrent producers from burying a stream. + +Because messages can be lost, you choose up front what a gap means to you: + +| `GapMode` | Behavior | Use when | +|---|---|---| +| `Fail` (default) | Any gap abandons the batch. The server stops accepting messages and sends a final `PublishAck` reporting how far it got. | A gap is a hole in your data — the ObjectStore-shaped case. | +| `Ok` | Gaps are reported to your listener and the batch continues from the received sequence. | You are shipping a firehose where a lost message is survivable, such as metrics. | + +The same choice governs per-message header check failures such as `Nats-Expected-Last-Sequence`: in `Fail` they stop the batch, in `Ok` they are reported and the batch continues. + +Fast ingest ends the same two ways as an atomic batch, and uses the same two types of publisher: + +```java +EobFastPublisher fp = EobFastPublisher.builder() + .connection(nc) + .gapMode(GapMode.Fail) + .maxFlow(100) // most messages the server may go between acks + .maxOutstandingAcks(2) // how far ahead you are willing to run + .listener(myListener) + .build(); // local only, no server contact yet + +fp.add(subject, data); // blocks only when flow control says so +PublishAck pa = fp.commit(); // ends the batch, stores no final message +``` + +Use `FastPublisher` instead when you do want the message that ends the batch stored; it ends with `commit(subject, data)`. Everything else — `add`, `ping`, `abandon`, flow control, gap handling — is identical and shared. + +Notes: + +* `build()` does not contact the server. Feature detection happens on the first `add`. +* The fast publishers are **not thread safe** and should be owned by one producer thread. Multiple concurrent producers should each hold their own. +* The control channel is read on a dispatcher of the publisher's own, so a batch the server abandons is known to be over immediately — `isTerminal()` and `getEndReason()` are current even while the application is between publishes, with no `ping()` needed for that. +* Listener callbacks still run on the thread that called `add`, `commit` or `ping`, in arrival order, and so do the counters. That thread does the accounting; the dispatcher only classifies. Call `ping()` if you want the callbacks and the flow state brought up to date without publishing. +* `abandon()` gives up without committing, and `close()` is the same thing, so try-with-resources releases the control channel — the dispatcher and its thread — without ever committing a batch whose assembly threw. The atomic publishers hold no such resource and use `discard()`. +* `getEndReason()` says why a batch ended — `Open`, `Committed`, `Gap`, `Error` or `Abandoned` — which `isTerminal()` alone cannot. +* When a gap or a per message error ends a `Fail` batch, the server abandons it and sends a final `PublishAck` saying how far it actually got. Committing such a batch does not publish anything; it throws, carrying that ack: `catch (FastPublishException e) { e.getPublishAck(); }`. It is the only authoritative statement of what was stored — a gap report explicitly is not — and it may be absent, since these acks are best effort. +* `ping()` goes to the subject of the first message in the batch, and a batch with no messages cannot be pinged. + +## Changes in 0.3.0 + +Fast-ingest publishing is new in this release, including a control channel read asynchronously on the publisher's own dispatcher: `FastPublisher`, `EobFastPublisher`, `GapMode`, `FastPublishListener` and the `FastFlowGap` / `FastFlowError` / `FastPubAck` reports. `EobBatchPublisher` is new too, so atomic batches can now end without storing a message. + +Changes to what was already there: + +* **Ack timeouts are milliseconds.** `ackTimeout(long millis)` on both builders; below 1 means the default. The `Duration` overload still compiles and converts, and is deprecated. A `Duration` under a millisecond used to mean *wait forever* on one path and *time out immediately* on the other. +* **The per message ack settings are gone from `BatchPublishOptions`.** `ackTimeout`, `ackFirst` and `ackEvery` were accepted there and never read. They belong to the batch, not to one message, and they have always worked on the publisher's builder. +* **A connection level rejection is now a `BatchPublishException`.** An invalid subject, a closed or draining connection, or a full reconnect buffer used to escape `add` as an unchecked exception with no batch id attached. +* **An `add` that is acknowledged now reports the server's error.** It used to say only "Invalid ack returned from add with confirm", dropping the reason — `atomic publish is disabled`, for instance — on the floor. +* **`commitAsync` no longer buries the cause.** `ExecutionException.getCause()` is now the `BatchPublishException` itself rather than a `RuntimeException` wrapping it. +* **A publisher level message TTL now applies to every message.** It was silently ignored unless that message also carried a `BatchPublishOptions`. +* The publisher rejects the four things listed under [What the publisher refuses](#what-the-publisher-refuses), and validates the commit's `PublishAck` against its own count and batch id. ![Artifact](https://img.shields.io/badge/Artifact-io.synadia:batch--publish-197556?labelColor=grey&style=flat) ![0.2.2](https://img.shields.io/badge/Current_Release-0.2.2-27AAE0) -![0.2.3](https://img.shields.io/badge/Current_Snapshot-0.2.3--SNAPSHOT-27AAE0) +![0.3.0](https://img.shields.io/badge/Current_Snapshot-0.3.0--SNAPSHOT-27AAE0) [![Dependencies Help](https://img.shields.io/badge/Dependencies%20Help-27AAE0)](https://github.com/synadia-io/orbit.java?tab=readme-ov-file#dependencies) [![javadoc](https://javadoc.io/badge2/io.synadia/batch-publish/javadoc.svg)](https://javadoc.io/doc/io.synadia/batch-publish) [![Maven Central](https://img.shields.io/maven-central/v/io.synadia/batch-publish)](https://img.shields.io/maven-central/v/io.synadia/batch-publish) --- -Copyright (c) 2024-2025 Synadia Communications Inc. All Rights Reserved. +Copyright (c) 2024-2026 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 index c9e3284..3b0e262 100644 --- a/batch-publish/build.gradle +++ b/batch-publish/build.gradle @@ -36,7 +36,7 @@ repositories { } dependencies { - implementation 'io.nats:jnats:2.26.3-SNAPSHOT' + implementation 'io.nats:jnats:2.26.3' implementation 'org.jspecify:jspecify:1.0.0' testImplementation 'io.nats:jnats-server-runner:4.0.2' diff --git a/batch-publish/src/examples/java/io/synadia/examples/AtomicBatchDocExample.java b/batch-publish/src/examples/java/io/synadia/examples/AtomicBatchDocExample.java index 1e2b3ab..b8b5fb8 100644 --- a/batch-publish/src/examples/java/io/synadia/examples/AtomicBatchDocExample.java +++ b/batch-publish/src/examples/java/io/synadia/examples/AtomicBatchDocExample.java @@ -1,4 +1,4 @@ -// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// Copyright (c) 2025-2026 Synadia Communications Inc. All Rights Reserved. // See LICENSE and NOTICE file for details. package io.synadia.examples; @@ -11,13 +11,28 @@ import io.nats.client.api.StreamConfiguration; import io.synadia.bp.BatchPublisher; +/** + * The atomic batch snippet for the NATS documentation. Only the lines between the + * NATS-DOC-START and NATS-DOC-END markers are pulled into the docs; everything around + * them is the setup needed to make the file runnable. + * Requires a server at 2.12.0 or later. + */ public class AtomicBatchDocExample { - static final String NATS_URL = "nats://localhost:4222"; + // a main class, never instantiated + private AtomicBatchDocExample() {} + + static final String NATS_URL = System.getenv("NATS_URL") != null + ? System.getenv("NATS_URL") : "nats://localhost:4222"; static final String STREAM = "ORDERS"; static final String SUBJECTS = "orders.>"; static final String SUBJECT = "orders.created"; static final String BATCH_ID = "order-4273"; + /** + * Run the example. + * @param args unused + * @throws Exception if anything the example does fails + */ public static void main(String[] args) throws Exception { try (Connection nc = Nats.connect(NATS_URL)) { JetStreamManagement jsm = nc.jetStreamManagement(); diff --git a/batch-publish/src/examples/java/io/synadia/examples/BasicBatchPublishAsyncExample.java b/batch-publish/src/examples/java/io/synadia/examples/BasicBatchPublishAsyncExample.java index 8b1beae..2d5248f 100644 --- a/batch-publish/src/examples/java/io/synadia/examples/BasicBatchPublishAsyncExample.java +++ b/batch-publish/src/examples/java/io/synadia/examples/BasicBatchPublishAsyncExample.java @@ -1,4 +1,4 @@ -// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// Copyright (c) 2025-2026 Synadia Communications Inc. All Rights Reserved. // See LICENSE and NOTICE file for details. package io.synadia.examples; @@ -16,17 +16,30 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +/** + * Commit an atomic batch asynchronously, taking the commit's PublishAck from a future + * instead of blocking on it. + * Requires a server at 2.12.0 or later. + */ public class BasicBatchPublishAsyncExample { + // a main class, never instantiated + private 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"; + /** + * Run the example. + * @param args unused + * @throws Exception if anything the example does fails + */ 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 + // Set up a fresh stream that allows atomic batch publish try { jsm.deleteStream(STREAM); } catch (JetStreamApiException ignore) {} StreamConfiguration config = StreamConfiguration.builder() .name(STREAM) @@ -58,8 +71,7 @@ public static void main(String[] args) throws Exception { paf.get(1, TimeUnit.SECONDS); } catch (ExecutionException e) { - //noinspection ThrowablePrintedToSystemOut - System.out.println(e); + System.out.println(e.getMessage()); } } } diff --git a/batch-publish/src/examples/java/io/synadia/examples/BasicBatchPublishExample.java b/batch-publish/src/examples/java/io/synadia/examples/BasicBatchPublishExample.java index 8238f71..5d32a5a 100644 --- a/batch-publish/src/examples/java/io/synadia/examples/BasicBatchPublishExample.java +++ b/batch-publish/src/examples/java/io/synadia/examples/BasicBatchPublishExample.java @@ -1,4 +1,4 @@ -// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// Copyright (c) 2025-2026 Synadia Communications Inc. All Rights Reserved. // See LICENSE and NOTICE file for details. package io.synadia.examples; @@ -10,7 +10,14 @@ import io.synadia.bp.BatchPublishOptions; import io.synadia.bp.BatchPublisher; +/** + * Commit an atomic batch by sending a final real message that is stored with the rest. + * Requires a server at 2.12.0 or later. + */ public class BasicBatchPublishExample { + // a main class, never instantiated + private BasicBatchPublishExample() {} + static final String NATS_URL = "nats://localhost:4222"; static final String STREAM = "bp-stream"; static final String SUBJECT = "bp-subject"; @@ -18,12 +25,18 @@ public class BasicBatchPublishExample { 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 + static final int ACK_THIS_ONE = 250; // the one message this example acks by hand + /** + * Run the example. + * @param args unused + * @throws Exception if anything the example does fails + */ 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 + // Set up a fresh stream that allows atomic batch publish try { jsm.deleteStream(STREAM); } catch (JetStreamApiException ignore) {} StreamConfiguration config = StreamConfiguration.builder() .name(STREAM) @@ -41,20 +54,36 @@ public static void main(String[] args) throws Exception { .ackEvery(AUTO_ACK_EVERY) .build(); + // Every message you actually have is a real message, + // but the last one is also the commit message. + // The EobBatchPublisher does it differently 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) { + // commit() takes a subject and a message, and that message is stored like + // any other. It carries the commit rather than being extra, so the count is + // BATCH_SIZE and not BATCH_SIZE + 1. PublishAck pa = publisher.commit(SUBJECT, h, data); - assert pa.getJv() != null; System.out.println("Batch [" + pa.getBatchId() + "] Committed " + pa.getJv().toJson()); } + else if (i == ACK_THIS_ONE) { + // addAcked asks the server to confirm this particular message and blocks + // until it does, whatever ackFirst and ackEvery are set to. Use it when one + // message in the batch is worth waiting on. + publisher.addAcked(SUBJECT, h, data); + } else { publisher.add(SUBJECT, h, data); } } + // committing closes the publisher, and size() is its own count of what the batch + // stored: BATCH_SIZE, the adds plus the commit message, which is stored like any other. + System.out.println("Publisher size " + publisher.size() + + ", isOpen " + publisher.isOpen() + ", isClosed " + publisher.isClosed()); + StreamInfo si = jsm.getStreamInfo(STREAM, StreamInfoOptions.allSubjects()); long messages = si.getStreamState().getSubjectMap().get(SUBJECT); System.out.println("Stream State shows '" + SUBJECT + "' has " + messages + " messages."); @@ -74,6 +103,17 @@ public static void main(String[] args) throws Exception { } System.out.println("Consumed " + count + " messages from '" + SUBJECT + "'"); + // Everything from here on is SUPPOSED to fail. It demonstrates that the expectations + // set in BatchPublishOptions are enforced, and that a failed expectation takes the + // whole batch with it. + // + // The batch above left the stream at sequence 1000, but this one claims + // expectedLastSequence(1). The server checks that under the lock at commit time, + // sees 1000 instead of 1, and rejects the batch - so none of these messages are + // stored, not just the one carrying the expectation. + // + // So the JetStreamApiException printed below ("wrong last sequence: 1000 [10071]") + // is the expected output of a successful run, not a bug. It is caught and printed. publisher = BatchPublisher.builder() .connection(nc) .batchId(BATCH_ID + "-batch-error") @@ -85,29 +125,25 @@ public static void main(String[] args) throws 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)); - } + // A batch that is started and then given up on. discard() ends it on the client + // without committing, so nothing it added is ever stored - the stream count below + // is unchanged. The server drops a batch it stops hearing from after 10 seconds. + publisher = BatchPublisher.builder() + .connection(nc) + .batchId(BATCH_ID + "-discarded") + .build(); + System.out.println("New publisher isOpen " + publisher.isOpen() + ", size " + publisher.size()); + publisher.add(SUBJECT, "never stored".getBytes()); + publisher.discard(); + System.out.println("After discard isOpen " + publisher.isOpen() + + ", isDiscarded " + publisher.isDiscarded() + ", size " + publisher.size()); + + si = jsm.getStreamInfo(STREAM, StreamInfoOptions.allSubjects()); + System.out.println("Stream State still shows '" + SUBJECT + "' has " + + si.getStreamState().getSubjectMap().get(SUBJECT) + " messages."); } - return sb.toString(); } } diff --git a/batch-publish/src/examples/java/io/synadia/examples/BasicEobBatchPublishAsyncExample.java b/batch-publish/src/examples/java/io/synadia/examples/BasicEobBatchPublishAsyncExample.java new file mode 100644 index 0000000..f3ac118 --- /dev/null +++ b/batch-publish/src/examples/java/io/synadia/examples/BasicEobBatchPublishAsyncExample.java @@ -0,0 +1,87 @@ +// Copyright (c) 2026 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.EobBatchPublisher; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +/** + * Commit an atomic batch without storing a final message, asynchronously. + * Requires a server at 2.14.0 or later. + */ +public class BasicEobBatchPublishAsyncExample { + // a main class, never instantiated + private BasicEobBatchPublishAsyncExample() {} + + static final String NATS_URL = "nats://localhost:4222"; + static final String STREAM = "eoba-stream"; + static final String SUBJECT = "eoba-subject"; + static final String BATCH_ID = "eoba-batch-id"; + + /** + * Run the example. + * @param args unused + * @throws Exception if anything the example does fails + */ + public static void main(String[] args) throws Exception { + try (Connection nc = Nats.connect(NATS_URL)) { + JetStreamManagement jsm = nc.jetStreamManagement(); + + // Set up a fresh stream that allows atomic batch publish + try { jsm.deleteStream(STREAM); } catch (JetStreamApiException ignore) {} + StreamConfiguration config = StreamConfiguration.builder() + .name(STREAM) + .subjects(SUBJECT) + .allowAtomicPublish() + .build(); + jsm.addStream(config); + + EobBatchPublisher publisher = EobBatchPublisher.builder() + .connection(nc) + .batchId(BATCH_ID) + .build(); + + publisher.add(SUBJECT, null); + publisher.add(SUBJECT, null); + // commitAsync() takes no message and no subject. The sentinel always goes to the + // subject of the first message added. It consumed a batch sequence but was never + // stored, so the batch size is 2, the messages actually added. + int sizeBeforeCommit = publisher.size(); + CompletableFuture paf = publisher.commitAsync(); + PublishAck pa = paf.get(1, TimeUnit.SECONDS); + // size() counts what the batch stores, the same thing BatchSize counts, so the + // sentinel is in neither and the number does not move across the commit. + System.out.println("Batch [" + pa.getBatchId() + "] Committed " + pa.getBatchSize() + " messages." + + " Publisher size was " + sizeBeforeCommit + " before the commit and " + publisher.size() + " after."); + + publisher = EobBatchPublisher.builder() + .connection(nc) + .batchId(BATCH_ID + "-batch-error") + .ackFirst(false) // otherwise error will happen on first publish + .build(); + + // The batch above left the stream at sequence 2, so this expectation cannot be met. + // The server checks it at commit time and rejects the whole batch. + publisher.add(SUBJECT, null, BatchPublishOptions.builder().expectedLastSequence(1).build()); + paf = publisher.commitAsync(); + try { + // this will exception + paf.get(1, TimeUnit.SECONDS); + } + catch (ExecutionException e) { + System.out.println(e.getMessage()); + } + } + } +} diff --git a/batch-publish/src/examples/java/io/synadia/examples/BasicEobBatchPublishExample.java b/batch-publish/src/examples/java/io/synadia/examples/BasicEobBatchPublishExample.java new file mode 100644 index 0000000..e89b5f6 --- /dev/null +++ b/batch-publish/src/examples/java/io/synadia/examples/BasicEobBatchPublishExample.java @@ -0,0 +1,122 @@ +// Copyright (c) 2026 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.examples; + +import io.nats.client.*; +import io.nats.client.api.*; +import io.nats.client.impl.Headers; +import io.synadia.bp.BatchPublishException; +import io.synadia.bp.BatchPublishOptions; +import io.synadia.bp.EobBatchPublisher; + +/** + * Commit an atomic batch without storing a final message. + * Requires a server at 2.14.0 or later. + */ +public class BasicEobBatchPublishExample { + // a main class, never instantiated + private BasicEobBatchPublishExample() {} + + static final String NATS_URL = "nats://localhost:4222"; + static final String STREAM = "eob-stream"; + static final String SUBJECT = "eob-subject"; + static final String BATCH_ID = "eob-batch-id"; + static final int BATCH_SIZE = 5; + 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 + + /** + * Run the example. + * @param args unused + * @throws Exception if anything the example does fails + */ + public static void main(String[] args) throws Exception { + try (Connection nc = Nats.connect(NATS_URL)) { + JetStreamManagement jsm = nc.jetStreamManagement(); + + // Set up a fresh stream that allows atomic batch publish + try { jsm.deleteStream(STREAM); } catch (JetStreamApiException ignore) {} + StreamConfiguration config = StreamConfiguration.builder() + .name(STREAM) + .subjects(SUBJECT) + .allowAtomicPublish() + .build(); + jsm.addStream(config); + + JetStream js = nc.jetStream(); + + EobBatchPublisher publisher = EobBatchPublisher.builder() + .connection(nc) + .batchId(BATCH_ID) + .ackFirst(ACK_FIRST) + .ackEvery(AUTO_ACK_EVERY) + .build(); + + // The point of EOB: every message you actually have is a real message. + // There is no filler message held back just to carry the commit. + for (int i = 1; i <= BATCH_SIZE; i++) { + Headers h = new Headers(); + h.put("my-header", "xyz-" + i); + byte[] data = ("data-" + i).getBytes(); + publisher.add(SUBJECT, h, data); + } + + // size() is the client's own count of what the batch will store. It is the same + // thing the ack's BatchSize reports, so the two are directly comparable. + int sizeBeforeCommit = publisher.size(); + + // commit() takes no message and no subject. The sentinel always goes to the subject + // of the first message added. It consumed a batch sequence but was never stored, + // so the count is BATCH_SIZE and not BATCH_SIZE + 1, and size() does not move. + PublishAck pa = publisher.commit(); + System.out.println("Batch [" + pa.getBatchId() + "] Committed " + pa.getBatchSize() + " messages." + + " Publisher size was " + sizeBeforeCommit + " before the commit and " + publisher.size() + " after," + + " isClosed " + publisher.isClosed() + "."); + + 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 + "'"); + + // Everything from here on is SUPPOSED to fail. It demonstrates that the expectations + // set in BatchPublishOptions are enforced, and that a failed expectation takes the + // whole batch with it. + // + // The batch above left the stream at sequence BATCH_SIZE, but this one claims + // expectedLastSequence(1). The server checks that under the lock at commit time, + // sees BATCH_SIZE instead of 1, and rejects the batch - so none of these messages are + // stored, not just the one carrying the expectation. + // + // So the JetStreamApiException printed below ("wrong last sequence: 5 [10071]") + // is the expected output of a successful run, not a bug. It is caught and printed. + publisher = EobBatchPublisher.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(); + } + catch (BatchPublishException e) { + System.out.println(e.getMessage()); + } + } + } +} diff --git a/batch-publish/src/examples/java/io/synadia/examples/ExpectationsBatchPublishExample.java b/batch-publish/src/examples/java/io/synadia/examples/ExpectationsBatchPublishExample.java index 958d48d..23ed9ca 100644 --- a/batch-publish/src/examples/java/io/synadia/examples/ExpectationsBatchPublishExample.java +++ b/batch-publish/src/examples/java/io/synadia/examples/ExpectationsBatchPublishExample.java @@ -1,4 +1,4 @@ -// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// Copyright (c) 2025-2026 Synadia Communications Inc. All Rights Reserved. // See LICENSE and NOTICE file for details. package io.synadia.examples; @@ -9,7 +9,16 @@ import io.synadia.bp.BatchPublishOptions; import io.synadia.bp.BatchPublisher; +/** + * Set per message expectations on a batch - expected last sequence, expected last subject + * sequence and the subject that applies to - and re-use one options builder across messages. + * The server checks all of them at commit time, under lock. + * Requires a server at 2.12.0 or later. + */ public class ExpectationsBatchPublishExample { + // a main class, never instantiated + private ExpectationsBatchPublishExample() {} + static final String NATS_URL = "nats://localhost:4222"; static final String STREAM = "expect-batch"; static final String SUBJECT_PREFIX = "expect."; @@ -17,11 +26,16 @@ public class ExpectationsBatchPublishExample { static final String SUBJECT_A = SUBJECT_PREFIX + "A"; static final String SUBJECT_B = SUBJECT_PREFIX + "B"; + /** + * Run the example. + * @param args unused + * @throws Exception if anything the example does fails + */ 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 + // Set up a fresh stream that allows atomic batch publish try { jsm.deleteStream(STREAM); } catch (JetStreamApiException ignore) {} StreamConfiguration config = StreamConfiguration.builder() .name(STREAM) @@ -62,7 +76,6 @@ public static void main(String[] args) throws Exception { 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()); @@ -87,6 +100,11 @@ public static void main(String[] args) throws Exception { } } + /** + * Render a message, its data and its headers, for printing. + * @param msg the message + * @return the rendering + */ public static String toString(Message msg) { StringBuilder sb = new StringBuilder(" '").append(msg.getSubject()); sb.append("', '").append(new String(msg.getData())).append("'"); diff --git a/batch-publish/src/examples/java/io/synadia/examples/FastIngestExample.java b/batch-publish/src/examples/java/io/synadia/examples/FastIngestExample.java new file mode 100644 index 0000000..7402ef4 --- /dev/null +++ b/batch-publish/src/examples/java/io/synadia/examples/FastIngestExample.java @@ -0,0 +1,94 @@ +// Copyright (c) 2026 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.examples; + +import io.nats.client.*; +import io.nats.client.api.*; +import io.synadia.bp.*; + +import static io.synadia.bp.AbstractFastPublisher.DEFAULT_MAX_FLOW; +import static io.synadia.bp.AbstractFastPublisher.DEFAULT_MAX_OUTSTANDING_ACKS; + +/** + * A fast ingest firehose in GapMode.Ok, where a dropped message is reported but does not + * abandon the batch. Requires a server at 2.14.0 or later. + *

+ * This is not atomic. Messages are persisted as they arrive, so unlike an atomic batch there + * is no point at which nothing has been stored yet. + */ +public class FastIngestExample { + // a main class, never instantiated + private FastIngestExample() {} + + static final String NATS_URL = "nats://localhost:4222"; + static final String STREAM = "fi-stream"; + static final String SUBJECT = "fi-subject"; + static final int COUNT = 10_000; + + /** + * Run the example. + * @param args unused + * @throws Exception if anything the example does fails + */ + public static void main(String[] args) throws Exception { + try (Connection nc = Nats.connect(NATS_URL)) { + JetStreamManagement jsm = nc.jetStreamManagement(); + + // allowBatched is what opts the stream in to fast ingest + try { jsm.deleteStream(STREAM); } catch (JetStreamApiException ignore) {} + jsm.addStream(StreamConfiguration.builder() + .name(STREAM) + .subjects(SUBJECT) + .allowBatched(true) + .build()); + + EobFastPublisher fp = EobFastPublisher.builder() + .connection(nc) + .gapMode(GapMode.Ok) + // both of these are the defaults, named rather than written as 100 and 2 so the + // example says where they come from. MAX_FLOW_CEILING and MAX_OUTSTANDING_ACKS + // on the same class are the upper bounds, not values to reach for. + .maxFlow(DEFAULT_MAX_FLOW) // the most messages the server may go between acks + .maxOutstandingAcks(DEFAULT_MAX_OUTSTANDING_ACKS) // how far ahead we are willing to run + .listener(new FastPublishListener() { + @Override + public void onFlowChange(long ackEvery) { + // this always fires once for the server's opening rate, which may be + // lower than the maxFlow asked for, then again on any later change + System.out.println("Flow rate is now every " + ackEvery + " messages."); + } + + @Override + public void onGap(FastFlowGap gap) { + System.out.println("Gap reported, continuing because this is GapMode.Ok: " + gap); + } + }) + .build(); + + long start = System.currentTimeMillis(); + for (int i = 1; i <= COUNT; i++) { + // add blocks only when flow control says we are too far ahead + FastPubAck a = fp.add(SUBJECT, ("data-" + i).getBytes()); + if (i % 2500 == 0) { + System.out.println(" sent " + a.getBatchSequence() + ", server acked " + a.getAckSequence()); + } + } + + // the no-arg commit ends the batch without storing a filler message + PublishAck pa = fp.commit(); + long elapsed = System.currentTimeMillis() - start; + + System.out.println("Batch [" + pa.getBatchId() + "] stored " + pa.getBatchSize() + " messages in " + elapsed + "ms."); + + // flow() is the rate the server last dictated, which is not necessarily the maxFlow + // that was asked for. gapCount() is how many gaps were reported across the batch; + // in GapMode.Ok a non-zero count means messages were dropped and the batch went on. + System.out.println("Client size " + fp.size() + ", final flow every " + fp.flow() + + " messages, " + fp.gapCount() + " gaps reported."); + + StreamInfo si = jsm.getStreamInfo(STREAM); + System.out.println("Stream has " + si.getStreamState().getMsgCount() + " messages."); + } + } +} diff --git a/batch-publish/src/examples/java/io/synadia/examples/FastIngestGapFailExample.java b/batch-publish/src/examples/java/io/synadia/examples/FastIngestGapFailExample.java new file mode 100644 index 0000000..aff384c --- /dev/null +++ b/batch-publish/src/examples/java/io/synadia/examples/FastIngestGapFailExample.java @@ -0,0 +1,90 @@ +// Copyright (c) 2026 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.examples; + +import io.nats.client.*; +import io.nats.client.api.*; +import io.synadia.bp.*; + +/** + * Fast ingest in GapMode.Fail, the ObjectStore shaped case where a gap is a hole in a file and + * so must abandon the batch. Requires a server at 2.14.0 or later. + *

+ * In this mode a gap or a per message header check failure stops the batch. The final PublishAck + * is still authoritative about what was persisted, which is how you learn where to resume. + */ +public class FastIngestGapFailExample { + // a main class, never instantiated + private FastIngestGapFailExample() {} + + static final String NATS_URL = "nats://localhost:4222"; + static final String STREAM = "fi-fail-stream"; + static final String SUBJECT = "fi-fail-subject"; + static final int COUNT = 1000; + + // a field rather than a local so the listener below can query the publisher it belongs to + static EobFastPublisher fp; + + /** + * Run the example. + * @param args unused + * @throws Exception if anything the example does fails + */ + public static void main(String[] args) throws Exception { + try (Connection nc = Nats.connect(NATS_URL)) { + JetStreamManagement jsm = nc.jetStreamManagement(); + + try { jsm.deleteStream(STREAM); } catch (JetStreamApiException ignore) {} + jsm.addStream(StreamConfiguration.builder() + .name(STREAM) + .subjects(SUBJECT) + .allowBatched(true) + .build()); + + fp = EobFastPublisher.builder() + .connection(nc) + .gapMode(GapMode.Fail) // this is the default, shown here for clarity + .listener(new FastPublishListener() { + @Override + public void onGap(FastFlowGap gap) { + // in Fail mode the batch is over. The PublishAck is still coming and + // reports how far the server actually got. + // + // gapCount() and getLastGap() are recorded before this callback runs, so + // a listener can ask the publisher rather than only reading the gap it + // was handed. getLastGap() here is the same object as gap. + System.out.println("Gap " + fp.gapCount() + " - batch abandoned: " + gap + + ", publisher reports " + fp.getLastGap() + ", mode " + fp.getGapMode()); + } + + @Override + public void onError(FastFlowError error) { + System.out.println("Message " + error.getSequence() + " failed: " + error.getDescription()); + } + }) + .build(); + + try { + for (int i = 1; i <= COUNT; i++) { + fp.add(SUBJECT, ("data-" + i).getBytes()); + if (fp.isTerminal()) { + System.out.println("Batch stopped early at " + fp.size()); + break; + } + } + PublishAck pa = fp.commit(); + System.out.println("Batch [" + pa.getBatchId() + "] stored " + pa.getBatchSize() + " messages."); + } + catch (FastPublishException e) { + // whatever was persisted before the failure stays persisted + System.out.println(e.getMessage()); + System.out.println("Acked through sequence " + fp.ackedSequence()); + fp.abandon(); + } + + StreamInfo si = jsm.getStreamInfo(STREAM); + System.out.println("Stream has " + si.getStreamState().getMsgCount() + " messages."); + } + } +} diff --git a/batch-publish/src/main/java/io/synadia/bp/AbstractBatchPublisher.java b/batch-publish/src/main/java/io/synadia/bp/AbstractBatchPublisher.java new file mode 100644 index 0000000..f14c248 --- /dev/null +++ b/batch-publish/src/main/java/io/synadia/bp/AbstractBatchPublisher.java @@ -0,0 +1,766 @@ +// Copyright (c) 2026 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 org.jspecify.annotations.NonNull; + +import java.io.IOException; +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.*; + +import static io.nats.client.PublishOptions.DEFAULT_TIMEOUT; +import static io.nats.client.PublishOptions.UNSET_LAST_SEQUENCE; +import static io.nats.client.support.NatsJetStreamConstants.*; +import static io.nats.client.support.Validator.*; + +/** + * Everything an atomic batch publisher does apart from how the batch is ended. + *

+ * The two ways to end a batch are different enough to be different types: + * {@link BatchPublisher} sends a final real message and stores it, while + * {@link EobBatchPublisher} ends the batch without storing anything. + * Both stage messages the same way, which is what lives here. + */ +public abstract class AbstractBatchPublisher { + /** The state of the batch. Private: it is an implementation detail, not part of the api. */ + private enum State { + Open, Closed, Discarded + } + + /** The id of this batch. */ + protected final String batchId; + + /** The connection to publish on. */ + protected final Connection conn; + + /** How long to wait for an acknowledgement. */ + protected final Duration ackTimeout; + + /** Whether the first added message is acknowledged. */ + protected final boolean ackFirst; + + /** How often an added message is acknowledged, after the first. */ + protected final int ackEvery; + + /** The publisher level message ttl, if any. */ + protected final MessageTtl messageTtl; + + /** Re-used and cleared for every publish rather than allocated per message. */ + protected final Headers headers; + + /** The batch sequence of the most recently sent message. */ + protected int lastSeq; + + /** Where the batch stands. Subclasses read it through isOpen/requireOpen and close it + * through markClosed, so the private State type never leaks out of this class. */ + private State state; + + /** The subject of the first message added, which is where an EOB sentinel is addressed. */ + protected String firstSubject; + + /** + * Construct from a builder. + * @param b the builder + */ + protected AbstractBatchPublisher(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; + firstSubject = null; + } + + /** + * The id of this batch. + * @return the batch id + */ + @NonNull + public String getBatchId() { + return batchId; + } + + /** + * How long this publisher waits for an acknowledgement. + * @return the ack timeout + */ + @NonNull + public Duration getAckTimeout() { + return ackTimeout; + } + + /** + * Whether the first added message is acknowledged. + * @return the flag + */ + public boolean ackFirst() { + return ackFirst; + } + + /** + * How often an added message is acknowledged, after the first. 0 means never. + * @return the ack every value + */ + 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(); + } + + /** + * The number of messages the batch will store. + * @return the number of stored messages + */ + public int size() { + return lastSeq; + } + + /** + * Give up on the batch. Nothing that was staged is stored. + */ + public void discard() { + state = State.Discarded; + } + + /** + * Whether the batch is still accepting messages. + * @return true if open + */ + public boolean isOpen() { + return state == State.Open; + } + + /** + * Whether the batch was discarded. + * @return true if discarded + */ + public boolean isDiscarded() { + return state == State.Discarded; + } + + /** + * Whether the batch was committed. + * @return true if closed + */ + public boolean isClosed() { + return state == State.Closed; + } + + /** + * Add a message to the batch. + * @param subject the subject + * @param data the payload + * @throws BatchPublishException if the batch is not open + */ + public void add(@NonNull String subject, byte[] data) throws BatchPublishException { + add(subject, null, data, null); + } + + /** + * Add a message to the batch. + * @param subject the subject + * @param data the payload + * @param opts per message options + * @throws BatchPublishException if the batch is not open + */ + public void add(@NonNull String subject, byte[] data, BatchPublishOptions opts) throws BatchPublishException { + add(subject, null, data, opts); + } + + /** + * Add a message to the batch. + * @param subject the subject + * @param userHeaders headers for this message + * @param data the payload + * @throws BatchPublishException if the batch is not open + */ + public void add(@NonNull String subject, Headers userHeaders, byte[] data) throws BatchPublishException { + add(subject, userHeaders, data, null); + } + + /** + * Add a message to the batch. + * @param subject the subject + * @param userHeaders headers for this message + * @param data the payload + * @param opts per message options + * @throws BatchPublishException if the batch is not open + */ + public void add(@NonNull String subject, Headers userHeaders, byte[] data, BatchPublishOptions opts) throws BatchPublishException { + int seq = lastSeq + 1; + if ( (seq == 1 && ackFirst) // first publish + || (ackEvery > 0 && seq % ackEvery == 0)) // or every publish + { + _addAcked(subject, userHeaders, data, opts); + } + else { + _add(subject, userHeaders, data, opts); + } + } + + /** + * Add a message to the batch and wait for the server to acknowledge it. + * @param subject the subject + * @param data the payload + * @throws BatchPublishException if the batch is not open or the ack is invalid + */ + public void addAcked(@NonNull String subject, byte[] data) throws BatchPublishException { + _addAcked(subject, null, data, null); + } + + /** + * Add a message to the batch and wait for the server to acknowledge it. + * @param subject the subject + * @param data the payload + * @param opts per message options + * @throws BatchPublishException if the batch is not open or the ack is invalid + */ + public void addAcked(@NonNull String subject, byte[] data, BatchPublishOptions opts) throws BatchPublishException { + _addAcked(subject, null, data, opts); + } + + /** + * Add a message to the batch and wait for the server to acknowledge it. + * @param subject the subject + * @param userHeaders headers for this message + * @param data the payload + * @throws BatchPublishException if the batch is not open or the ack is invalid + */ + public void addAcked(@NonNull String subject, Headers userHeaders, byte[] data) throws BatchPublishException { + _addAcked(subject, userHeaders, data, null); + } + + /** + * Add a message to the batch and wait for the server to acknowledge it. + * @param subject the subject + * @param userHeaders headers for this message + * @param data the payload + * @param opts per message options + * @throws BatchPublishException if the batch is not open or the ack is invalid + */ + public void addAcked(@NonNull String subject, Headers userHeaders, byte[] data, BatchPublishOptions opts) throws BatchPublishException { + _addAcked(subject, userHeaders, data, opts); + } + + private void _add(String subject, Headers userHeaders, byte[] data, BatchPublishOptions opts) throws BatchPublishException { + _send(subject, userHeaders, data, opts, this::publish); + } + + private void _addAcked(String subject, Headers userHeaders, byte[] data, BatchPublishOptions opts) throws BatchPublishException { + _send(subject, userHeaders, data, opts, (s, h, d, o) -> { + Message m = request(s, h, d, null, o); + if (m.getData().length != 0) { + throw ackError(m); + } + }); + } + + /** + * Make the exception for a non-empty reply to a message inside the batch. The server answers + * a message in a batch with zero bytes, so a body means it rejected the message and sent a + * full error ack, exactly as it would on the commit. Parsing it is what keeps the server's + * reason, "atomic publish is disabled" for instance, instead of reporting only that the + * reply was not empty. + * @param m the reply message + * @return the exception to throw + */ + private BatchPublishException ackError(Message m) { + // never a NotSent: a reply body at all is proof the server received the message and + // rejected it, so the batch sequence was used. + try { + // PublishAck's constructor is the parser the commit already relies on. An error ack + // comes back out of it as a JetStreamApiException. + new PublishAck(m); + } + catch (JetStreamApiException e) { + return new BatchPublishException(batchId, e); + } + catch (IOException e) { + // PublishAck makes an IOException when the body is not a readable ack at all, which + // is the case the message below was written for. Fall through to it. + } + return new BatchPublishException(batchId, "Invalid ack returned from add with confirm"); + } + + /** + * Thrown by a send path when the connection refused the publish before anything was queued, + * so the batch sequence was never used and has to be given back. + *

+ * Package private and never thrown out of a public method as itself: callers catch it as the + * {@link BatchPublishException} it is. The type exists only so {@code _send} can tell "this + * message did not leave the client" from every other failure, which it cannot do by + * inspecting the cause, because {@link java.util.concurrent.CancellationException} is an + * {@link IllegalStateException} and a cancellation means the message probably did leave. + */ + static class NotSent extends BatchPublishException { + NotSent(String batchId, Throwable cause) { + super(batchId, cause); + } + } + + /** How a message is put on the wire. The only thing that differs between the two adds. */ + private interface Sender { + void send(@NonNull String subject, Headers userHeaders, byte[] data, BatchPublishOptions opts) throws BatchPublishException; + } + + /** + * The three headers this publisher writes itself. A user header of the same name is copied + * over the top of the protocol value by {@code updateHeaders}, which corrupts the batch, so + * they are refused rather than silently overwritten or silently dropped. + */ + private static final Set MANAGED_HEADERS = new HashSet<>(Arrays.asList( + NATS_BATCH_ID_HDR.toLowerCase(), NATS_BATCH_SEQUENCE_HDR.toLowerCase(), NATS_BATCH_COMMIT_HDR.toLowerCase())); + + /** + * Headers the server refuses anywhere inside a batch. {@code Nats-Expected-Last-Msg-Id} is + * answered with 10177 for any message, including the first. {@code Nats-Msg-Id} is + * deliberately absent: the server supports de-duplication in batches from 2.12.1 and only + * rejects a duplicate within one batch, so refusing it outright, as the Rust client still + * does, would block a supported feature. + */ + private static final Set UNSUPPORTED_HEADERS = new HashSet<>(Collections.singletonList( + EXPECTED_LAST_MSG_ID_HDR.toLowerCase())); + + /** + * Refuse user headers the batch protocol does not allow. Checked before the sequence + * advances, so a rejected call leaves no hole. + * @param userHeaders the caller's headers, may be null + * @throws BatchPublishException if a header is managed by the publisher, refused by the + * server inside a batch, or an expected last sequence after the first message + */ + protected void requireUserHeadersAllowed(Headers userHeaders) throws BatchPublishException { + if (userHeaders == null || userHeaders.isEmpty()) { + return; + } + for (String key : userHeaders.keySet()) { + String lower = key.toLowerCase(); + if (MANAGED_HEADERS.contains(lower)) { + throw new BatchPublishException(batchId, + "The batch publisher sets the " + key + " header itself."); + } + if (UNSUPPORTED_HEADERS.contains(lower)) { + throw new BatchPublishException(batchId, + "The server does not allow the " + key + " header inside a batch."); + } + if (lastSeq > 0 && EXPECTED_LAST_SEQ_HDR.toLowerCase().equals(lower)) { + throw new BatchPublishException(batchId, + "Only the first message of a batch may set an expected last sequence."); + } + } + } + + /** + * ADR-50: "Only the first message of the batch may contain {@code Nats-Expected-Last-Sequence}." + * The server enforces that by rejecting the whole batch at commit time - 10071 when the value + * does not match the sequence it has reached, 10164 when it does - so sending it on a later + * message can only ever lose the batch. Checked before the sequence advances, so a rejected + * call leaves no hole. + *

+ * The restriction is narrow, and the other expectations are deliberately not included: + * {@code Nats-Expected-Last-Subject-Sequence} is legal on any message unless an earlier + * message in the batch wrote that same subject, which only the server can know, and + * {@code Nats-Expected-Stream} is legal on every message. + * @param opts the per message options, may be null + * @throws BatchPublishException if a message after the first carries an expected last sequence + */ + protected void requireExpectedLastSequenceOnlyOnFirst(BatchPublishOptions opts) throws BatchPublishException { + if (lastSeq > 0 && opts != null && opts.getExpectedLastSequence() > UNSET_LAST_SEQUENCE) { + throw new BatchPublishException(batchId, + "Only the first message of a batch may set an expected last sequence."); + } + } + + /** + * Everything an add does apart from the communication itself. The sequence advances before + * the send because the header block carries it, and the first subject is recorded after, + * so a non-null firstSubject means at least one message actually reached the server. + */ + private void _send(String subject, Headers userHeaders, byte[] data, BatchPublishOptions opts, Sender sender) throws BatchPublishException { + requireOpen(); + requireUserHeadersAllowed(userHeaders); + requireExpectedLastSequenceOnlyOnFirst(opts); + ++lastSeq; + try { + sender.send(subject, userHeaders, data, opts); + } + catch (NotSent e) { + // nothing left the client, so the sequence was never spent. Only this one failure + // gives it back: every other, an ack error included, leaves it spent because the + // server may already have the message. + --lastSeq; + throw e; + } + rememberFirstSubject(subject); + } + + /** + * Mark the batch committed, so nothing more can be added to it. Call from a commit's + * finally block so the batch closes whether or not the server accepted it. + */ + protected void markClosed() { + state = State.Closed; + } + + /** + * Throw unless the batch is still open. + * @throws BatchPublishException if the batch is not open + */ + protected void requireOpen() throws BatchPublishException { + if (state != State.Open) { + throw new BatchPublishException(batchId, "Batch not open: " + state); + } + } + + /** + * Record the subject of the first message that was actually published. Called only after a + * successful send, so a non-null firstSubject means at least one message reached the server, + * which is what the EOB commit's empty-batch check relies on. + * @param subject the subject just published to + */ + private void rememberFirstSubject(@NonNull String subject) { + if (firstSubject == null) { + firstSubject = subject; + } + } + + /** + * Check the server's account of the batch against the client's own. ADR-50 defines + * {@code BatchSize} as the messages the batch stored, which is what {@link #size()} counts, + * so on an atomic batch the two must agree exactly: the batch either stored everything or + * nothing, so there is no case where the client's count is merely an upper bound. + * @param pa the PublishAck from the commit + * @throws BatchPublishException if the server's account disagrees with the client's + */ + protected void validateAck(PublishAck pa) throws BatchPublishException { + if (pa.getBatchSize() != lastSeq) { + throw new BatchPublishException(batchId, + "The server reported " + pa.getBatchSize() + " messages in the batch, the client sent " + lastSeq + "."); + } + if (!batchId.equals(pa.getBatchId())) { + throw new BatchPublishException(batchId, + "The server reported batch id " + pa.getBatchId() + "."); + } + } + + /** + * Build the header block and publish, without waiting for a reply. The sibling of + * {@link #request}; there is no commitValue parameter because a commit always waits for its + * PublishAck and so always goes through request. + * @param subject the subject + * @param userHeaders headers for this message + * @param data the payload + * @param opts per message options + * @throws BatchPublishException if the connection rejects the publish + */ + protected void publish(@NonNull String subject, Headers userHeaders, byte[] data, BatchPublishOptions opts) throws BatchPublishException { + updateHeaders(null, userHeaders, opts); + try { + conn.publish(subject, headers, data); + } + catch (IllegalArgumentException | IllegalStateException e) { + // jnats rejects the publish itself for an invalid subject, a closed or draining + // connection, or a full reconnect buffer. Those are unchecked, and an add that + // fails must fail the same way whatever rejected it. NotSent because none of them + // reaches the outgoing queue, so nothing left the client. + throw new NotSent(batchId, e); + } + } + + /** + * Build the header block, publish, and wait for the reply. + * @param subject the subject + * @param userHeaders headers for this message + * @param data the payload + * @param commitValue null when this is not a commit, otherwise the commit header value + * @param opts per message options + * @return the reply message + * @throws BatchPublishException if the request fails or times out + */ + protected Message request(@NonNull String subject, Headers userHeaders, byte[] data, String commitValue, BatchPublishOptions opts) throws BatchPublishException { + try { + updateHeaders(commitValue, 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 (CancellationException e) { + // requestWithTimeout cancels its future when nothing answers, so this is the shape a + // timeout actually arrives in. It is unchecked, so without this it escapes raw. + throw new BatchPublishException(batchId, e); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new BatchPublishException(batchId, e); + } + catch (IllegalArgumentException | IllegalStateException e) { + // Same connection level rejections as publish, and the same conclusion: nothing + // was queued. This catch must stay below the CancellationException one, which is + // itself an IllegalStateException and means the opposite - the message probably did + // go out, so its sequence stays spent. + throw new NotSent(batchId, e); + } + } + + /** + * Build the header block for one message. + * @param commitValue null when this is not a commit, otherwise {@code NATS_BATCH_COMMIT_STORE} + * to commit and store the message or {@code NATS_BATCH_COMMIT_EOB} to commit + * without storing it. + * @param userHeaders headers for this message + * @param bpOpts per message options + */ + private void updateHeaders(String commitValue, Headers userHeaders, BatchPublishOptions bpOpts) { + headers.clear(); + headers.put(NATS_BATCH_ID_HDR, batchId); + // The EOB sentinel is the next message in the batch on the wire but is never stored, so + // it takes the next sequence without lastSeq advancing. That keeps lastSeq meaning one + // thing - the messages the batch will store - which is what size() reports and what the + // pub ack's BatchSize counts. + headers.put(NATS_BATCH_SEQUENCE_HDR, + Integer.toString(NATS_BATCH_COMMIT_EOB.equals(commitValue) ? lastSeq + 1 : lastSeq)); + + if (commitValue != null) { + headers.put(NATS_BATCH_COMMIT_HDR, commitValue); + } + + 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); + } + } + + // The ttl is resolved outside the options block on purpose. It is the one setting that + // exists in both places: the options value wins, and the publisher value applies to + // every message, including the ones sent with no options at all. + String ttl = bpOpts == null ? null : bpOpts.getMessageTtl(); + if (ttl == null) { + ttl = messageTtl == null ? null : messageTtl.getTtlString(); + } + if (ttl != null) { + headers.put(MSG_TTL_HDR, ttl); + } + } + + /** + * The settings both publishers share. Self typed so the setters return the concrete builder. + * @param the concrete builder type + * @param the publisher the builder makes + */ + public abstract static class Builder, T extends AbstractBatchPublisher> { + /** + * Construct a builder with the default settings. + */ + protected Builder() {} + + Connection conn; + Duration ackTimeout; + String batchId; + boolean ackFirst = true; + int ackEvery; + MessageTtl messageTtl; + + /** + * Return this, typed as the concrete builder. + * @return this builder + */ + protected abstract B self(); + + /** + * The version the server must be newer than, in the form isNewerVersionThan expects, + * so "2.11.99" to require 2.12.0. + * @return the version to compare against + */ + protected abstract String newerThanVersion(); + + /** + * The message used when the server is too old. + * @return the message + */ + protected abstract String tooOldMessage(); + + /** + * Sets the connection. Required. + * @param conn the connection + * @return The Builder + */ + public B connection(Connection conn) { + this.conn = conn; + return self(); + } + + /** + * Sets the batch id. Generated when not supplied. Cannot be longer than 64 characters. + * @param batchId the batch id + * @return The Builder + */ + public B batchId(String batchId) { + this.batchId = batchId; + return self(); + } + + /** + * Sets the timeout, in milliseconds, to wait for an acknowledgement when adding or + * committing. Less than 1 means use the default. Milliseconds rather than a Duration + * because no timeout below a millisecond is reasonable, and a zero Duration is read as + * an immediate timeout on this path and as wait forever on the fast ingest one. + * @param ackTimeoutMillis the ack timeout in milliseconds + * @return The Builder + */ + public B ackTimeout(long ackTimeoutMillis) { + this.ackTimeout = ackTimeoutMillis < 1 ? DEFAULT_TIMEOUT : Duration.ofMillis(ackTimeoutMillis); + return self(); + } + + /** + * Sets the timeout to wait for an acknowledgement when adding or committing. + *

+ * Kept only so code built against 0.2.2 still compiles and still links. A Duration + * invites sub-millisecond values, which are never a reasonable timeout and which jnats + * reads as wait forever; this converts to milliseconds, so anything under a millisecond + * becomes the default rather than an unbounded wait. + * @param ackTimeout the ack timeout + * @return The Builder + * @deprecated use {@link #ackTimeout(long)} and pass milliseconds + */ + @Deprecated + public B ackTimeout(Duration ackTimeout) { + return ackTimeout(ackTimeout == null ? 0 : ackTimeout.toMillis()); + } + + /** + * Whether to ack the first message. Defaults to true + * @param ackFirst the flag + * @return The Builder + */ + public B ackFirst(boolean ackFirst) { + this.ackFirst = ackFirst; + return self(); + } + + /** + * 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 B ackEvery(int ackEvery) { + this.ackEvery = ackEvery < 1 ? 0 : ackEvery; + return self(); + } + + /** + * 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 B messageTtlSeconds(int msgTtlSeconds) { + this.messageTtl = msgTtlSeconds < 1 ? null : MessageTtl.seconds(msgTtlSeconds); + return self(); + } + + /** + * 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 B messageTtlCustom(String msgTtlCustom) { + this.messageTtl = nullOrEmpty(msgTtlCustom) ? null : MessageTtl.custom(msgTtlCustom); + return self(); + } + + /** + * Sets the TTL for this specific message to be published and never be expired + * @return The Builder + */ + public B messageTtlNever() { + this.messageTtl = MessageTtl.never(); + return self(); + } + + /** + * Sets the TTL for this specific message to be published + * @param messageTtl the message ttl instance + * @return The Builder + */ + public B messageTtl(MessageTtl messageTtl) { + this.messageTtl = messageTtl; + return self(); + } + + /** + * Validate the shared settings and fill in defaults. Call from build(). + */ + protected void validateAndDefault() { + validateNotNull(conn, "Connection required,"); + if (!conn.getServerInfo().isNewerVersionThan(newerThanVersion())) { + throw new IllegalArgumentException(tooOldMessage()); + } + 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"); + } + else { + // The fast publishers carry the id as one token of the reply subject, where a + // dot would leave the server reading only the last segment as the id. The same + // rule is applied here so one id means the same thing to both families. + validatePrintableExceptWildDotGt(batchId, "Batch ID", true); + } + } + + /** + * Build the publisher. + * @return the publisher + */ + public abstract T build(); + } +} diff --git a/batch-publish/src/main/java/io/synadia/bp/AbstractFastPublisher.java b/batch-publish/src/main/java/io/synadia/bp/AbstractFastPublisher.java new file mode 100644 index 0000000..0af0a6c --- /dev/null +++ b/batch-publish/src/main/java/io/synadia/bp/AbstractFastPublisher.java @@ -0,0 +1,965 @@ +// Copyright (c) 2026 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.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.io.IOException; +import java.time.Duration; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static io.nats.client.support.ApiConstants.*; +import static io.nats.client.support.JsonValueUtils.readLong; +import static io.nats.client.support.JsonValueUtils.readString; +import static io.nats.client.support.NatsJetStreamConstants.*; +import static io.nats.client.support.Validator.*; + +/** + * Everything a fast ingest publisher does apart from how the batch is ended. + *

+ * The two ways to end a batch are different enough to be different types: + * {@link FastPublisher} sends a final real message and stores it, while + * {@link EobFastPublisher} ends the batch without storing anything. + * Both add messages the same way, which is what lives here. + *

+ * This is not atomic. Unlike the atomic publishers there is no staging and no all or + * nothing guarantee. Messages are persisted as they arrive, the batch has no size limit, and + * messages can be dropped by the server's overload protection or lost across a stream leader + * change. What you get in exchange is a control channel over which the server continuously tells + * you how fast you are allowed to go. Choose {@link GapMode} to say whether a gap should abandon + * the batch or merely be reported. + *

+ * These publishers are not thread safe and are meant to be owned by one producer thread. + * Multiple concurrent producers should each hold their own, which is exactly the scenario the + * server's flow control is designed around. + *

+ * The control channel is read on a dispatcher of this publisher's own, so the work is split in + * two. That thread does one thing: decide whether an arriving message means the server has ended + * the batch, and record it. Everything else - the flow accounting, the gap and error counters, + * the terminal ack, and every listener callback - runs on the thread that called + * {@code add}, {@code commit} or {@code ping}, in arrival order. So a batch the server abandons + * while the application is between publishes is known to be over immediately, through + * {@link #isTerminal()} and {@link #getEndReason()}, while nothing a listener does can run on a + * thread the application did not expect. + *

+ * These implement {@link AutoCloseable} so try-with-resources releases the control channel, + * which is the one resource a fast publisher owns: its dispatcher, and the thread that comes + * with it. {@link #close()} is exactly + * {@link #abandon()} and never commits: try-with-resources runs it on the exception path too, + * where committing a batch whose assembly had just thrown would be precisely backwards. + * Use {@code commit} to end a batch deliberately. + */ +public abstract class AbstractFastPublisher implements AutoCloseable { + /** + * The default upper bound on how many messages the server may go between flow + * acknowledgements, when the user does not choose one. ADR-50 does not mandate a number; + * this is the value its worked example uses. + */ + public static final int DEFAULT_MAX_FLOW = 100; + + /** The largest max flow the subject grammar can carry, since the server reads it as a uint16. */ + public static final int MAX_FLOW_CEILING = 65535; + + /** + * The default number of acknowledgements the client will let go outstanding before it + * blocks. ADR-50 recommends 1 or 2. + */ + public static final int DEFAULT_MAX_OUTSTANDING_ACKS = 2; + + /** The most outstanding acknowledgements ADR-50 suggests exposing. */ + public static final int MAX_OUTSTANDING_ACKS = 3; + + /** The id of this batch. */ + protected final String batchId; + + /** The connection to publish on. */ + protected final Connection conn; + + private final Duration ackTimeout; + private final GapMode gapMode; + private final int maxOutstandingAcks; + private final FastPublishListener listener; + private final Dispatcher dispatcher; + private final BlockingQueue control = new LinkedBlockingQueue<>(); + private final String replyPrefix; // everything up to the per message seq + + /** The batch sequence of the most recently sent message. */ + protected long batchSeq; + + /** The subject of the first message added, which is where an EOB sentinel is addressed. */ + protected String firstSubject; + + private long lastAckSeq; + private long ackEvery; // server dictated, 0 until the server tells us + private volatile boolean terminal; // written by the dispatcher thread too + private final AtomicReference endReason = new AtomicReference<>(EndReason.Open); + private boolean abandoned; + private PublishAck finalAck; // the batch's terminal PublishAck, kept once it is seen + private FastFlowError pendingError; + private long gapCount; // proposal, see gapCount() + private FastFlowGap lastGap; // proposal, see getLastGap() + + /** + * Construct from a builder. + * @param b the builder + */ + protected AbstractFastPublisher(Builder b) { + batchId = b.batchId; + conn = b.conn; + ackTimeout = b.ackTimeout; + gapMode = b.gapMode; + maxOutstandingAcks = b.maxOutstandingAcks; + listener = b.listener; + + // A fresh inbox, never the request/mux inbox. ADR-50 calls this out: on an error the + // server may have thousands of queued acks to send, and dropping interest entirely lets + // it short circuit them. + // + // The subscription is owned by a dispatcher of this publisher's own rather than read + // synchronously, so the control channel is processed the moment something arrives even + // if the application has gone quiet. A dedicated dispatcher rather than a shared one: + // it is a thread of its own, so classifying this batch's control messages never contends + // with anything else on the connection, and closing it releases that thread. + String prefix = conn.createInbox(); + dispatcher = conn.createDispatcher(); + dispatcher.subscribe(prefix + "." + batchId + ".>", this::onControlMessage); + + // Cached because only seq and op change per message, and this is the hot path. + // maxFlow is stated to the server once, here, and never needed again. + replyPrefix = prefix + "." + batchId + "." + b.maxFlow + "." + gapMode + "."; + + batchSeq = 0; + lastAckSeq = 0; + ackEvery = 0; + gapCount = 0; + terminal = false; + abandoned = false; + firstSubject = null; + } + + // ------------------------------------------------------------------------------------ + // accessors + // ------------------------------------------------------------------------------------ + + /** + * The id of this batch. + * @return the batch id + */ + @NonNull + public String getBatchId() { + return batchId; + } + + /** + * The gap mode this batch was started with. + * @return the gap mode + */ + @NonNull + public GapMode getGapMode() { + return gapMode; + } + + /** + * The number of messages the batch will store. + * @return the number of stored messages + */ + public long size() { + return batchSeq; + } + + /** + * The highest batch sequence the server has acknowledged. Acks are cumulative. + * @return the acknowledged batch sequence + */ + public long ackedSequence() { + return lastAckSeq; + } + + /** + * The current server dictated flow rate, meaning the server acknowledges every this many + * messages. Zero until the server answers the first message. + * @return the flow rate + */ + public long flow() { + return ackEvery; + } + + /** + * Whether this batch is finished, either committed or stopped by a gap or error in + * {@link GapMode#Fail}. + * @return true if the batch can no longer accept messages + */ + public boolean isTerminal() { + return terminal || abandoned; + } + + /** + * Why the batch ended, or {@link EndReason#Open} while it is still running. + *

+ * {@link #isTerminal()} answers whether the batch is over; this answers what ended it, which + * a caller needs to tell a committed batch from one the server abandoned under it. The + * guarantee between them is one directional and is the direction callers use: once + * {@code isTerminal()} is true this is never {@code Open}. It can be set an instant before + * the batch reads as terminal, which is harmless. + * @return the reason + */ + @NonNull + public EndReason getEndReason() { + return endReason.get(); + } + + /** + * The last error the server reported for a message in this batch, if any. + * @return the error or null + */ + @Nullable + public FastFlowError getPendingError() { + return pendingError; + } + + // ------------------------------------------------------------------------------------ + // gap insight. Proposals, not part of ADR-50. + // ------------------------------------------------------------------------------------ + + /** + * How many gaps the server has reported for this batch. + *

+ * Proposal. ADR-50 defines the gap report but says nothing about a client counting + * them. The C, Go, .NET, Python and Rust clients each hand a gap to a callback and keep + * nothing but a fatal flag, so none of them can answer this after the fact. In + * {@link GapMode#Fail} the count is 0 or 1, since the first gap ends the batch. In + * {@link GapMode#Ok} it is the number of times the batch was interrupted, which is what + * the user of an Ok mode batch is actually asking when they ask how the batch went. + *

+ * This counts reports, not lost messages. ADR-50 states that a gap report MUST NOT be used + * to determine what was persisted, so no count of lost messages is derived from it here. + * Only the final {@link io.nats.client.api.PublishAck} is authoritative about that. + * @return the number of gaps reported + */ + public long gapCount() { + return gapCount; + } + + /** + * The most recent gap the server reported for this batch, if any. + *

+ * Proposal. ADR-50 delivers gaps to the client but does not say whether a client + * should retain them, and no other client does. Retaining the last one lets code that + * registered no {@link FastPublishListener} still see that the batch was interrupted and + * where. Only the last is kept, because a {@link GapMode#Ok} batch has no bound on how + * many there may be. + * @return the last gap reported, or null if none was + */ + @Nullable + public FastFlowGap getLastGap() { + return lastGap; + } + + // ------------------------------------------------------------------------------------ + // publishing + // ------------------------------------------------------------------------------------ + + /** + * Add a message to the batch. Blocks only when flow control says the client is too far ahead. + * @param subject the subject + * @param data the payload, may be null + * @return where the batch stands after this message + * @throws FastPublishException if the batch is finished or the server rejects the batch + */ + public FastPubAck add(@NonNull String subject, byte[] data) throws FastPublishException { + return add(subject, null, data); + } + + /** + * Add a message to the batch. Blocks only when flow control says the client is too far ahead. + * @param subject the subject + * @param userHeaders headers for this message, may be null + * @param data the payload, may be null + * @return where the batch stands after this message + * @throws FastPublishException if the batch is finished or the server rejects the batch + */ + public FastPubAck add(@NonNull String subject, Headers userHeaders, byte[] data) throws FastPublishException { + // Read whatever is already queued before spending a batch sequence. A gap or error that + // arrived since the last call ends the batch here, so _send's requireUsable throws + // instead of one more message going out into a batch the server has already dropped. + // On the first add this is necessarily empty - the inbox is fresh and nothing has been + // published to it - so it costs one counter read. + drain(); + + boolean first = _send(subject, userHeaders, data, + batchSeq == 0 ? FAST_BATCH_OP_START : FAST_BATCH_OP_APPEND) == 1; + + if (first) { + awaitFirstReply(); + } + + drain(); + awaitFlowWindow(); + + return new FastPubAck(batchSeq, lastAckSeq); + } + + /** + * Wait for the server's answer to the first message of the batch, which is mandatory: it is + * how the client learns the feature exists on this server and this stream, and what flow + * rate it may start at. A server that predates fast ingest silently ignores the {@code $FI} + * reply subject and never answers, which is why the absence of a reply is itself the signal + * and has to be bounded by the ack timeout. + * @throws FastPublishException if nothing answers, or the answer is a server error + */ + private void awaitFirstReply() throws FastPublishException { + Message m = nextMessage(); + if (m == null) { + // The batch never started, and the message that would have started it is already on + // the wire, so the next add would append to a batch the server does not have. Give + // up on it here rather than leaving a publisher that looks usable and is not, and + // release the control channel with it: nothing is coming. + abandon(); + throw new FastPublishException(batchId, + "No response to the first message of the batch. The server may not support fast ingest publish."); + } + process(m); + } + + /** + * Block while the client is further ahead of the server than the number of acknowledgements + * it is willing to have outstanding. This is the whole of the client's half of flow control: + * the server states a rate, and the client refuses to run more than + * {@code ackEvery * maxOutstandingAcks} messages past the last sequence the server confirmed. + * @throws FastPublishException if no acknowledgement arrives within the ack timeout + */ + private void awaitFlowWindow() throws FastPublishException { + while (!terminal && ackEvery > 0 && lastAckSeq + (ackEvery * maxOutstandingAcks) <= batchSeq) { + Message m = nextMessage(); + if (m == null) { + throw new FastPublishException(batchId, "Timed out waiting for a flow acknowledgement."); + } + process(m); + } + } + + /** + * Ask the server to re-send the current flow state and report any gap. Does not consume a + * batch sequence, which matters because incrementing would make a lost ping look like a gap + * and fail a {@link GapMode#Fail} batch. + *

+ * The ping is addressed to the subject of the first message in the batch, which the + * stream is already known to capture since it has taken a message on it, and which is where + * every other client sends it. A batch with no messages has no such subject and cannot be + * pinged. + * @throws FastPublishException if the batch is finished, or has no messages in it + */ + public void ping() throws FastPublishException { + requireUsable(); + if (firstSubject == null) { + throw new FastPublishException(batchId, "Cannot ping a batch with no messages"); + } + conn.publish(firstSubject, reply(batchSeq, FAST_BATCH_OP_PING), null, null); + Message m = nextMessage(); + if (m != null) { + process(m); + } + drain(); + } + + /** + * Give up on this batch without committing. The server cleans it up on its own inactivity + * timeout. Anything already persisted stays persisted, and because there is no commit there + * is no PublishAck, so there is no record of what that was. + *

+ * Also used internally where a failure leaves a batch that can never be used again: a first + * message the server never answered, and a commit whose acknowledgement never arrived. + */ + public void abandon() { + end(EndReason.Abandoned); + abandoned = true; + unsubscribe(); + } + + /** + * Abandon the batch if it has not already ended, and release the control channel + * subscription either way. + *

+ * This never commits. try-with-resources calls it on the exception path as well as the + * normal one, so a close that committed would commit a batch whose assembly had just + * failed. A batch that already ended - committed, or stopped by a gap or error - is left + * as it is and only its subscription is released, which matters because the gap and error + * endings do not release it on their own. Calling it more than once is harmless. + */ + @Override + public void close() { + if (!isTerminal()) { + abandon(); + } + unsubscribe(); + } + + // ------------------------------------------------------------------------------------ + // internals + // ------------------------------------------------------------------------------------ + + /** + * Whether anything more is expected from the server. A batch that ended on a gap or an error + * is terminal for adding but not finished: the server still owes the batch's final + * PublishAck, and the drain has to keep reading for it. + * @return true when nothing more will arrive + */ + private boolean isFinished() { + return abandoned || endReason.get() == EndReason.Committed; + } + + /** + * Whether the server ended this batch under the client, rather than the client ending it. + * @return true after a gap or an error ended the batch + */ + private boolean serverEnded() { + EndReason reason = endReason.get(); + return reason == EndReason.Gap || reason == EndReason.Error; + } + + /** + * Refuse a commit the batch cannot take. When the server already ended the batch, the + * exception carries the terminal PublishAck the server sent, because that ack is the only + * authoritative record of what was persisted and refusing without it throws the record away. + * @throws FastPublishException if the batch cannot be committed + */ + protected void requireCommittable() throws FastPublishException { + // Deliberately does not drain first. A batch the server ended goes through terminalAck(), + // which reads and processes everything queued on its way to the terminal ack, so no + // listener callback or counter is skipped. Draining here instead would also consume an + // unsolicited PublishAck before the commit is sent, which is the one case where the + // count check on that ack still has something to say. + if (abandoned) { + throw new FastPublishException(batchId, "Batch was abandoned."); + } + if (serverEnded()) { + String what = endReason.get() == EndReason.Gap ? "a gap" : "an error"; + FastPublishException e = new FastPublishException(batchId, + "Batch was ended by the server on " + what + ", so there is nothing to commit."); + e.setPublishAck(terminalAck()); + throw e; + } + requireUsable(); + } + + /** + * End a batch whose commit did not complete. The commit message is already on the wire, so + * the batch is in a state only the server knows: it may have committed and lost the ack, or + * never committed at all. Either way a second commit would be wrong, and the lost ack cannot + * be recovered by pinging, because a committed batch is cleaned up server side and a ping + * would be answered as an unknown batch. So the publisher is finished. + *

+ * Does nothing when the commit ran to its acknowledgement, since that already ended the + * batch, including when the acknowledgement carried a server error. + */ + protected void abandonIfCommitDidNotFinish() { + if (!isTerminal()) { + abandon(); + } + } + + /** + * Read until the terminal PublishAck of a server ended batch arrives. Returns null rather + * than throwing when it does not: the caller is already being told the batch failed, and + * ADR-50 makes these acks best effort, so a missing one is a thinner answer rather than a + * second failure. An ack that carries a server error cannot be turned into a PublishAck at + * all, which is the other way this returns null. + * @return the ack, or null + */ + private PublishAck terminalAck() { + if (finalAck != null) { + return finalAck; + } + try { + return awaitPubAck(); + } + catch (FastPublishException e) { + return null; + } + catch (IllegalStateException e) { + // the subscription is already gone, so the ack can no longer arrive + return null; + } + finally { + unsubscribe(); + } + } + + /** + * Throw unless the batch can still take an operation. + * @throws FastPublishException if the batch was abandoned or is already finished + */ + protected void requireUsable() throws FastPublishException { + if (abandoned) { + throw new FastPublishException(batchId, "Batch was abandoned."); + } + if (terminal) { + throw new FastPublishException(batchId, "Batch is already finished."); + } + } + + /** + * Everything a send does apart from the operation and what follows it. The sequence advances + * before the publish because the reply subject carries it, and firstSubject is recorded after, + * so a non-null firstSubject means at least one message actually reached the server. + * @param subject the subject + * @param userHeaders headers for this message + * @param data the payload + * @param op the fast batch operation code + * @return the batch sequence this message was published with + * @throws FastPublishException if the batch is no longer usable + */ + protected long _send(String subject, Headers userHeaders, byte[] data, String op) throws FastPublishException { + requireUsable(); + // The EOB sentinel is the next message in the batch on the wire but is never stored, so + // it takes the next sequence without batchSeq advancing. That keeps batchSeq meaning one + // thing - the messages the batch will store - which is what size() reports and what the + // pub ack's BatchSize counts. + boolean eob = FAST_BATCH_OP_COMMIT_EOB.equals(op); + long seq = eob ? batchSeq + 1 : ++batchSeq; + try { + conn.publish(subject, reply(seq, op), userHeaders, data); + } + catch (IllegalArgumentException | IllegalStateException e) { + // jnats rejects the publish itself for an invalid subject, a closed or draining + // connection, or a full reconnect buffer. A full reconnect buffer is the realistic + // one here, since this path runs at rates that fill it during a reconnect. None of + // them reaches the outgoing queue, so nothing left the client and the sequence is + // given back - unless this was the EOB sentinel, which never took one. + if (!eob) { + --batchSeq; + } + throw new FastPublishException(batchId, e); + } + if (firstSubject == null) { + firstSubject = subject; + } + return seq; + } + + /** + * Build the reply subject for one message. Every parameter the server needs is in it: + * the inbox to answer on, the batch id, the flow rate the client is asking for, the gap + * mode, the batch sequence and the operation. + * @param seq the batch sequence + * @param op the operation code + * @return the reply subject + */ + protected String reply(long seq, String op) { + return replyPrefix + seq + "." + op + "." + FAST_BATCH_SUFFIX; + } + + /** + * Read control messages until the authoritative PublishAck arrives, dispatching anything + * else to the listener on the way. + * @return the PublishAck + * @throws FastPublishException on timeout or a server reported error + */ + protected PublishAck awaitPubAck() throws FastPublishException { + try { + while (true) { + Message m = nextMessage(); + if (m == null) { + throw new FastPublishException(batchId, "Timed out waiting for the batch PublishAck."); + } + requireNotAStatus(m); + JsonValue jv = parse(m); + String type = jv == null ? null : type(jv); + if (type == null) { + // no recognised flow type means this is the PublishAck, which is terminal. + // Only the first ending counts: a gap ended batch still gets its final ack, + // and collecting it must not relabel the batch as committed. + end(EndReason.Committed); + unsubscribe(); + finalAck = new PublishAck(m); + validateAck(finalAck); + return finalAck; + } + handle(type, jv); + } + } + catch (IOException e) { + // PublishAck makes an IOException when the ack is invalid + throw new FastPublishException(batchId, e.getMessage()); + } + catch (JetStreamApiException e) { + throw new FastPublishException(batchId, e); + } + } + + /** + * Check the server's account of the batch against the client's own. ADR-50 defines + * {@code BatchSize} as the messages the batch stored, which is what {@link #size()} counts, + * so the two must agree on a batch that ran to a clean commit. + *

+ * Skipped once a gap or a per message error has been reported, because there the server + * received less than the client sent and the client's count is an upper bound rather than + * an equal. Failing on that difference would fail every gap abandoned batch. + * @param pa the terminal PublishAck + * @throws FastPublishException if the server's account disagrees with the client's + */ + private void validateAck(PublishAck pa) throws FastPublishException { + if (gapCount > 0 || pendingError != null) { + return; + } + if (pa.getBatchSize() != batchSeq) { + throw new FastPublishException(batchId, + "The server reported " + pa.getBatchSize() + " messages in the batch, the client sent " + batchSeq + "."); + } + if (!batchId.equals(pa.getBatchId())) { + throw new FastPublishException(batchId, + "The server reported batch id " + pa.getBatchId() + "."); + } + } + + /** + * Runs on the dispatcher's thread, and does the least it can: work out whether this message + * ends the batch, record that, and hand the message to the publishing thread. + *

+ * Recording it here is the whole point of being asynchronous. A batch the server abandons + * while the application is between publishes is known to be over immediately, rather than at + * the next {@code add}. Everything else - the flow accounting, the gap and error counters, + * the terminal ack, every listener callback - deliberately stays on the thread that called + * {@code add}, {@code commit} or {@code ping}, and still happens in arrival order, because + * the dispatcher delivers serially and the queue preserves that order. + * @param m the control message + */ + private void onControlMessage(Message m) { + classify(m); + control.add(m); + } + + /** + * Decide whether a control message ends the batch, without doing any of the work that + * follows from it. + * @param m the control message + */ + private void classify(Message m) { + if (gapMode != GapMode.Fail) { + return; // in Ok mode nothing the server reports ends the batch + } + JsonValue jv = parse(m); + String type = jv == null ? null : type(jv); + if (FAST_BATCH_TYPE_GAP.equals(type)) { + end(EndReason.Gap); + } + else if (FAST_BATCH_TYPE_ERR.equals(type)) { + end(EndReason.Error); + } + // A PublishAck is deliberately not classified here. It is normally the answer to a commit + // this client just sent, so the publishing thread is already on its way to reading it, + // and ending the batch from here would only race that. The case this method exists for is + // the other one: the server abandoning the batch under an application that has gone quiet. + } + + /** + * End the batch, keeping the first reason. Called from the dispatcher thread when a message + * arrives and from the publishing thread when the same message is processed, so it has to be + * idempotent and has to keep the earlier answer: a gap ended batch still receives a terminal + * PublishAck afterwards, and collecting that ack must not relabel it as committed. + * @param reason why the batch ended + */ + private void end(EndReason reason) { + // The reason is set first on purpose. Both fields are written here and read elsewhere, + // and only one order is safe to read: a caller that sees isTerminal() true must never + // then see Open, because that is the pair it acts on. The volatile write to terminal + // cannot be reordered before the compare and set, so seeing the flag guarantees seeing + // the reason. The reverse window - a reason set an instant before the flag - is + // harmless, and in fact useful, since serverEnded() noticing early only means the + // terminal ack is collected sooner. + endReason.compareAndSet(EndReason.Open, reason); + terminal = true; + } + + private Message nextMessage() throws FastPublishException { + try { + return control.poll(ackTimeout.toMillis(), TimeUnit.MILLISECONDS); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new FastPublishException(batchId, e); + } + } + + /** + * Process whatever the dispatcher has already handed over, and nothing more. A poll with no + * timeout on the internal queue returns null the moment it is empty, which is the common + * case on this path: it runs twice per add and the control channel is quiet by design, one + * acknowledgement per flow window rather than one per message. + */ + private void drain() throws FastPublishException { + while (!isFinished()) { + Message m = control.poll(); + if (m == null) { + return; + } + process(m); + } + } + + /** + * Refuse a status message on the control channel. The server answers `503 No Responders` on + * the reply subject when the subject a message went to has no subscriber at all, which for a + * fast batch means the stream does not capture it - a misconfigured subject rather than + * anything about the batch. It is not an acknowledgement and must not be read as one: doing + * so ends the batch as committed and reports "Invalid JetStream ack", neither of which is + * what happened. The batch cannot proceed either way, so it is given up here. + * @param m the control message + * @throws FastPublishException always, when the message is a status + */ + private void requireNotAStatus(Message m) throws FastPublishException { + if (m.isStatusMessage()) { + abandon(); + throw new FastPublishException(batchId, + "The server answered the batch with a status rather than an acknowledgement: " + + m.getStatus() + ". The stream may not capture the subject being published to."); + } + } + + private void process(Message m) throws FastPublishException { + requireNotAStatus(m); + JsonValue jv = parse(m); + String type = jv == null ? null : type(jv); + if (type == null) { + // a PubAck arrived early, which means the batch is over. It is kept rather than + // discarded: for a batch the server ended on a gap this is the only authoritative + // record of what was persisted, and the caller asks for it at commit time. + end(EndReason.Committed); + unsubscribe(); + try { + finalAck = new PublishAck(m); // throws when the server reported an error + } + catch (IOException e) { + throw new FastPublishException(batchId, e.getMessage()); + } + catch (JetStreamApiException e) { + throw new FastPublishException(batchId, e); + } + return; + } + handle(type, jv); + } + + private void handle(String type, JsonValue jv) { + if (FAST_BATCH_TYPE_ACK.equals(type)) { + // acks are cumulative and can arrive out of order, so only ever move forward + long seq = readLong(jv, SEQ, 0); + if (seq > lastAckSeq) { + lastAckSeq = seq; + } + long msgs = readLong(jv, MSGS, 0); + if (msgs > 0 && msgs != ackEvery) { + ackEvery = msgs; + listener.onFlowChange(msgs); + } + } + else if (FAST_BATCH_TYPE_GAP.equals(type)) { + // never touch lastAckSeq or ackEvery from a gap. Gaps are sent on detection and so + // arrive out of order with respect to flow acks. + lastGap = new FastFlowGap(jv); + gapCount++; + // recorded before the callback so a listener that queries the publisher sees it + listener.onGap(lastGap); + if (gapMode == GapMode.Fail) { + end(EndReason.Gap); + } + } + else if (FAST_BATCH_TYPE_ERR.equals(type)) { + pendingError = new FastFlowError(jv); + listener.onError(pendingError); + if (gapMode == GapMode.Fail) { + end(EndReason.Error); + } + } + } + + private static JsonValue parse(Message m) { + byte[] data = m.getData(); + if (data == null || data.length == 0) { + return null; + } + try { + return JsonParser.parse(data); + } + catch (JsonParseException e) { + return null; + } + } + + private static String type(JsonValue jv) { + // A PubAck has no type field, so "no recognised flow type" is a safe discriminator. + return readString(jv, TYPE); + } + + private void unsubscribe() { + try { + // the subscription belongs to the dispatcher and refuses to be unsubscribed on its + // own, so closing the dispatcher is the teardown: it drops the subscription and + // releases the thread. + conn.closeDispatcher(dispatcher); + } + catch (IllegalArgumentException | IllegalStateException ignore) { + // already closed, or the connection is gone. closeDispatcher throws + // IllegalArgumentException for a dispatcher it has already released, and this is + // reached more than once by design - abandon, close and the terminal ack all release. + } + } + + /** + * The settings both fast publishers share. Self typed so the setters return the concrete builder. + * @param the concrete builder type + * @param the publisher the builder makes + */ + public abstract static class Builder, T extends AbstractFastPublisher> { + /** + * Construct a builder with the default settings. + */ + protected Builder() {} + + Connection conn; + Duration ackTimeout; + String batchId; + GapMode gapMode = GapMode.Fail; + int maxFlow = DEFAULT_MAX_FLOW; + int maxOutstandingAcks = DEFAULT_MAX_OUTSTANDING_ACKS; + FastPublishListener listener = new FastPublishListener() {}; + + /** + * Return this, typed as the concrete builder. + * @return this builder + */ + protected abstract B self(); + + /** + * The connection to publish on. Required. + * @param conn the connection + * @return The Builder + */ + public B connection(Connection conn) { + this.conn = conn; + return self(); + } + + /** + * The batch id. Generated when not supplied. Cannot be longer than 64 characters. + * @param batchId the batch id + * @return The Builder + */ + public B batchId(String batchId) { + this.batchId = batchId; + return self(); + } + + /** + * How gaps are handled. Defaults to {@link GapMode#Fail}, which keeps a gap from silently + * becoming a hole in the data. + * @param gapMode the gap mode + * @return The Builder + */ + public B gapMode(GapMode gapMode) { + this.gapMode = gapMode == null ? GapMode.Fail : gapMode; + return self(); + } + + /** + * The upper bound on how many messages the server may go between acknowledgements. + * The server starts lower and works up toward this. + * Less than 1 means use {@value #DEFAULT_MAX_FLOW}; anything above + * {@value #MAX_FLOW_CEILING} is capped there. + * @param maxFlow the maximum flow + * @return The Builder + */ + public B maxFlow(int maxFlow) { + this.maxFlow = maxFlow < 1 ? DEFAULT_MAX_FLOW + : (maxFlow > MAX_FLOW_CEILING ? MAX_FLOW_CEILING : maxFlow); + return self(); + } + + /** + * How many acknowledgements the client is willing to have outstanding before it blocks. + * 1 is fully lockstep, throttled to one flow window at a time; + * {@value #DEFAULT_MAX_OUTSTANDING_ACKS} is the ADR-50 recommendation and suits most + * cases; {@value #MAX_OUTSTANDING_ACKS} helps on higher latency links. + * Less than 1 means use {@value #DEFAULT_MAX_OUTSTANDING_ACKS}; anything above + * {@value #MAX_OUTSTANDING_ACKS} is capped there. + * @param maxOutstandingAcks the maximum outstanding acks + * @return The Builder + */ + public B maxOutstandingAcks(int maxOutstandingAcks) { + this.maxOutstandingAcks = maxOutstandingAcks < 1 ? DEFAULT_MAX_OUTSTANDING_ACKS + : (maxOutstandingAcks > MAX_OUTSTANDING_ACKS ? MAX_OUTSTANDING_ACKS : maxOutstandingAcks); + return self(); + } + + /** + * How long to wait, in milliseconds, for a flow acknowledgement or the final PublishAck. + * Less than 1 means use the connection's timeout. Milliseconds rather than a Duration + * because no timeout below a millisecond is reasonable, and because jnats reads a + * duration under one nanosecond as wait forever, which would turn every wait in this + * publisher into a hang - the opposite of what the timeout is for. + * @param ackTimeoutMillis the ack timeout in milliseconds + * @return The Builder + */ + public B ackTimeout(long ackTimeoutMillis) { + this.ackTimeout = ackTimeoutMillis < 1 ? null : Duration.ofMillis(ackTimeoutMillis); + return self(); + } + + /** + * Where gap, error and flow change reports are delivered. + * @param listener the listener + * @return The Builder + */ + public B listener(FastPublishListener listener) { + this.listener = listener == null ? new FastPublishListener() {} : listener; + return self(); + } + + /** + * Validate the shared settings and fill in defaults. Call from build(). + */ + protected void validateAndDefault() { + validateNotNull(conn, "Connection required,"); + // There is no useful server side error to fall back on: a pre 2.14 server treats the + // $FI reply subject as an ordinary reply and never answers, so the client would just + // hang until ackTimeout. + if (!conn.getServerInfo().isNewerVersionThan("2.13.99")) { + throw new IllegalArgumentException("Fast ingest publish not available until server version 2.14.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"); + } + else { + // The id is one token of the reply subject the server parses right to left, so a + // dot would leave it reading only the last segment as the id, and a wildcard or a + // space would make the subject invalid. + validatePrintableExceptWildDotGt(batchId, "Batch ID", true); + } + } + + /** + * Build the publisher. This does not contact the server; feature detection happens on + * the first add. + * @return the publisher + */ + public abstract T build(); + } +} diff --git a/batch-publish/src/main/java/io/synadia/bp/BatchPublishException.java b/batch-publish/src/main/java/io/synadia/bp/BatchPublishException.java index a311ddc..edf80d9 100644 --- a/batch-publish/src/main/java/io/synadia/bp/BatchPublishException.java +++ b/batch-publish/src/main/java/io/synadia/bp/BatchPublishException.java @@ -1,4 +1,4 @@ -// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// Copyright (c) 2025-2026 Synadia Communications Inc. All Rights Reserved. // See LICENSE and NOTICE file for details. package io.synadia.bp; @@ -7,22 +7,46 @@ import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; +/** + * The exception thrown when a batch publish fails. It always carries the id of the batch + * that failed, and when the failure was reported by the server it also carries the + * underlying {@link JetStreamApiException} so the error code and description are available. + */ public class BatchPublishException extends Exception { + /** The server's api exception, or null when the failure did not come from the server. */ private final JetStreamApiException jsApiException; + + /** The id of the batch that failed. */ private final String batchId; + /** + * Construct an exception with a message. + * @param batchId the id of the batch that failed + * @param message the message describing the failure + */ public BatchPublishException(@NonNull String batchId, @NonNull String message) { super(message); this.batchId = batchId; jsApiException = null; } + /** + * Construct an exception for a failure the server reported, keeping the api exception + * so the error code and description can be read from it. + * @param batchId the id of the batch that failed + * @param cause the api exception from the server + */ public BatchPublishException(@NonNull String batchId, @NonNull JetStreamApiException cause) { super(cause); this.batchId = batchId; jsApiException = cause; } + /** + * Construct an exception from any other cause. + * @param batchId the id of the batch that failed + * @param cause the underlying exception + */ public BatchPublishException(@NonNull String batchId, @NonNull Throwable cause) { super(cause); this.batchId = batchId; @@ -34,11 +58,19 @@ public String getMessage() { return "[" + batchId + "] " + super.getMessage(); } + /** + * Get the id of the batch that failed. + * @return the batch id + */ @NonNull public String getBatchId() { return batchId; } + /** + * Get the api exception the server reported, if the failure came from the server. + * @return the api exception or null + */ @Nullable public JetStreamApiException getJsApiException() { return jsApiException; diff --git a/batch-publish/src/main/java/io/synadia/bp/BatchPublishOptions.java b/batch-publish/src/main/java/io/synadia/bp/BatchPublishOptions.java index 934ba30..7115c90 100644 --- a/batch-publish/src/main/java/io/synadia/bp/BatchPublishOptions.java +++ b/batch-publish/src/main/java/io/synadia/bp/BatchPublishOptions.java @@ -1,21 +1,37 @@ -// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// Copyright (c) 2025-2026 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.*; +/** + * Per message options, supplied when adding a message to a batch or when committing one. + * They carry the expectations the server checks before it accepts the message, and the + * message's ttl. A ttl set here takes precedence over the one set on the publisher. + *

+ * Acknowledgement settings are deliberately not here. Whether the first message is acked, how + * often the ones after it are, and how long to wait for an ack are all properties of the batch + * rather than of one message, so they live on the publisher's builder. Earlier releases carried + * copies of them here that were never read; they were removed in 0.3.0. + */ public class BatchPublishOptions { + /** The stream the message is expected to be stored in, or null when not set. */ public final String expectedStream; + + /** The expected last sequence of the stream, or UNSET_LAST_SEQUENCE when not set. */ public final long expectedLastSeq; + + /** The expected last sequence for the subject, or UNSET_LAST_SEQUENCE when not set. */ public final long expectedLastSubSeq; + + /** The subject the expected last subject sequence applies to, which can be a wildcard, or null to use the message's own subject. */ public final String expectedLastSubSeqSubject; + + /** The ttl for the message, or null when not set. */ public final MessageTtl messageTtl; private BatchPublishOptions(Builder b) { @@ -87,15 +103,10 @@ public static Builder 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. + * BatchPublishOptions are created using a Builder. The builder supports chaining and + * will create a default set of options if no methods are called. */ 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; @@ -107,46 +118,6 @@ public static class Builder { */ 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. diff --git a/batch-publish/src/main/java/io/synadia/bp/BatchPublisher.java b/batch-publish/src/main/java/io/synadia/bp/BatchPublisher.java index 80d6c3a..b7f2878 100644 --- a/batch-publish/src/main/java/io/synadia/bp/BatchPublisher.java +++ b/batch-publish/src/main/java/io/synadia/bp/BatchPublisher.java @@ -1,177 +1,94 @@ -// Copyright (c) 2025 Synadia Communications Inc. All Rights Reserved. +// Copyright (c) 2025-2026 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.JetStreamApiException; +import io.nats.client.Message; 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 java.util.concurrent.CompletionException; -import static io.nats.client.PublishOptions.DEFAULT_TIMEOUT; -import static io.nats.client.support.NatsJetStreamConstants.*; -import static io.nats.client.support.Validator.*; +import static io.nats.client.support.NatsJetStreamConstants.NATS_BATCH_COMMIT_STORE; -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; - } +/** + * Publishes an atomic batch, a group of up to 1000 messages that are all added to the stream or + * none are, and ends it by sending a final real message that is stored along with the rest. + *

+ * Use {@link EobBatchPublisher} instead when the batch is exactly the messages you already have + * and you do not want to hold one back, or invent a filler message, just to carry the commit. + *

+ * Requires a server at 2.12.0 or later and a stream configured with {@code allow_atomic}. + */ +public class BatchPublisher extends AbstractBatchPublisher { - @NonNull - public Duration getAckTimeout() { - return ackTimeout; - } - - public boolean ackFirst() { - return ackFirst; - } - - public int getAckEvery() { - return ackEvery; + private BatchPublisher(Builder b) { + super(b); } /** - * Gets the message ttl string. Might be null. Might be "never". - * 10 seconds would be "10s" for the server - * @return the message ttl string + * Publish the final message and commit the batch. + * @param subject the subject + * @param data the payload + * @return the PublishAck + * @throws BatchPublishException if the batch is not open or the server reports an error */ - 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 { + public PublishAck commit(@NonNull String subject, byte[] data) throws BatchPublishException { return commit(subject, null, data, null); } - public PublishAck commit(String subject, byte[] data, BatchPublishOptions opts) throws BatchPublishException { + /** + * Publish the final message and commit the batch. + * @param subject the subject + * @param data the payload + * @param opts per message options + * @return the PublishAck + * @throws BatchPublishException if the batch is not open or the server reports an error + */ + public PublishAck commit(@NonNull 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 { + /** + * Publish the final message and commit the batch. + * @param subject the subject + * @param userHeaders headers for the final message + * @param data the payload + * @return the PublishAck + * @throws BatchPublishException if the batch is not open or the server reports an error + */ + public PublishAck commit(@NonNull 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); - } + /** + * Publish the final message and commit the batch. + * @param subject the subject + * @param userHeaders headers for the final message + * @param data the payload + * @param opts per message options + * @return the PublishAck + * @throws BatchPublishException if the batch is not open or the server reports an error + */ + public PublishAck commit(@NonNull String subject, Headers userHeaders, byte[] data, BatchPublishOptions opts) throws BatchPublishException { + requireOpen(); + requireUserHeadersAllowed(userHeaders); + requireExpectedLastSequenceOnlyOnFirst(opts); try { ++lastSeq; - Message m = request(subject, userHeaders, data, true, opts); - return new PublishAck(m); + Message m = request(subject, userHeaders, data, NATS_BATCH_COMMIT_STORE, opts); + PublishAck pa = new PublishAck(m); + validateAck(pa); + return pa; + } + catch (NotSent e) { + // the commit message never left the client, so give its sequence back + --lastSeq; + throw e; } catch (IOException e) { // done this way because PublishAck makes an IOException if the ack is invalid. @@ -183,94 +100,64 @@ public PublishAck commit(String subject, Headers userHeaders, byte[] data, Batch throw new BatchPublishException(batchId, e); } finally { - state = State.Closed; + markClosed(); } } - public CompletableFuture commitAsync(String subject, byte[] data) { + /** + * Publish the final message and commit the batch, asynchronously. + * @param subject the subject + * @param data the payload + * @return a future for the PublishAck + */ + public CompletableFuture commitAsync(@NonNull String subject, byte[] data) { return commitAsync(subject, null, data, null); } - public CompletableFuture commitAsync(String subject, byte[] data, BatchPublishOptions opts) { + /** + * Publish the final message and commit the batch, asynchronously. + * @param subject the subject + * @param data the payload + * @param opts per message options + * @return a future for the PublishAck + */ + public CompletableFuture commitAsync(@NonNull String subject, byte[] data, BatchPublishOptions opts) { return commitAsync(subject, null, data, opts); } - public CompletableFuture commitAsync(String subject, Headers userHeaders, byte[] data) { + /** + * Publish the final message and commit the batch, asynchronously. + * @param subject the subject + * @param userHeaders headers for the final message + * @param data the payload + * @return a future for the PublishAck + */ + public CompletableFuture commitAsync(@NonNull String subject, Headers userHeaders, byte[] data) { return commitAsync(subject, userHeaders, data, null); } - public CompletableFuture commitAsync(String subject, Headers userHeaders, byte[] data, BatchPublishOptions opts) { + /** + * Publish the final message and commit the batch, asynchronously. + * @param subject the subject + * @param userHeaders headers for the final message + * @param data the payload + * @param opts per message options + * @return a future for the PublishAck + */ + public CompletableFuture commitAsync(@NonNull 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); + // CompletableFuture treats CompletionException as transport rather than cause: + // supplyAsync stores it as is and get() unwraps it, so the caller's + // ExecutionException.getCause() is this BatchPublishException itself. + throw new CompletionException(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 @@ -282,121 +169,30 @@ public static Builder 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; - } - + public static class Builder extends AbstractBatchPublisher.Builder { /** - * 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 + * Construct a builder with the default settings. */ - public Builder messageTtlSeconds(int msgTtlSeconds) { - this.messageTtl = msgTtlSeconds < 1 ? null : MessageTtl.seconds(msgTtlSeconds); - return this; - } + public Builder() {} - /** - * 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); + @Override + protected Builder self() { 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; + @Override + protected String newerThanVersion() { + return "2.11.99"; } - /** - * 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; + @Override + protected String tooOldMessage() { + return "Batch publish not available until server version 2.12.0."; } + @Override 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"); - } + validateAndDefault(); return new BatchPublisher(this); } } diff --git a/batch-publish/src/main/java/io/synadia/bp/BatchUtils.java b/batch-publish/src/main/java/io/synadia/bp/BatchUtils.java new file mode 100644 index 0000000..ab73f45 --- /dev/null +++ b/batch-publish/src/main/java/io/synadia/bp/BatchUtils.java @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.bp; + +import io.nats.client.Connection; + +/** + * Static information about batch publishing that is not carried on any publisher. + */ +public class BatchUtils { + private BatchUtils() {} // this class is not instantiated + + /** + * The maximum number of messages an atomic batch may contain on this connection's server. + *

+ * Hardcoded to 1000 today, the number ADR-50 documents, because the limit is documented + * rather than advertised: the server takes it from its {@code max_batch_size} option and + * reports it in neither INFO nor stream info, so a client has no way to ask. The connection + * is a parameter so that this can change without changing callers, if a later server + * publishes the value. The answer cannot change for a connection once established, so an + * implementation that has to ask the server may cache it. + *

+ * The count includes the message that carries the commit, when the batch ends by storing one. + * A batch ending with an EOB sentinel does not count the sentinel, so it may hold this many + * messages rather than one fewer. + * @param conn the connection whose server the limit applies to + * @return the maximum number of messages + */ + public static int getMaxBatchSize(Connection conn) { + return 1000; + } +} diff --git a/batch-publish/src/main/java/io/synadia/bp/EndReason.java b/batch-publish/src/main/java/io/synadia/bp/EndReason.java new file mode 100644 index 0000000..70c4266 --- /dev/null +++ b/batch-publish/src/main/java/io/synadia/bp/EndReason.java @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.bp; + +/** + * Why a fast ingest batch ended, or that it has not. + *

+ * A batch that ends on a gap or an error is over on the server as well as the client, and + * whatever it had already persisted stays persisted. A batch that is abandoned is over only on + * the client; the server drops it on its own inactivity timeout. + */ +public enum EndReason { + /** The batch is still running. */ + Open, + + /** The batch committed and the server answered with the authoritative PublishAck. */ + Committed, + + /** The server reported a gap while in {@link GapMode#Fail}, which abandons the batch. */ + Gap, + + /** The server reported a per message error while in {@link GapMode#Fail}. */ + Error, + + /** + * The client gave up: {@code abandon()}, {@code close()}, or a failure that leaves the batch + * unusable - a first message the server never answered, or a commit whose acknowledgement + * never arrived. + */ + Abandoned +} diff --git a/batch-publish/src/main/java/io/synadia/bp/EobBatchPublisher.java b/batch-publish/src/main/java/io/synadia/bp/EobBatchPublisher.java new file mode 100644 index 0000000..ed09abb --- /dev/null +++ b/batch-publish/src/main/java/io/synadia/bp/EobBatchPublisher.java @@ -0,0 +1,130 @@ +// Copyright (c) 2026 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.bp; + +import io.nats.client.JetStreamApiException; +import io.nats.client.Message; +import io.nats.client.api.PublishAck; + +import java.io.IOException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +import static io.nats.client.support.NatsJetStreamConstants.NATS_BATCH_COMMIT_EOB; + +/** + * Publishes an atomic batch and ends it without storing a final message. + *

+ * The commit is normally a header on a real message, so committing requires having a real message + * to send. If your transaction is exactly five KV writes, you would either have to hold the fifth + * write back and use it as the commit trigger, which couples "am I done?" to "do I have one more + * message?", or invent a filler message and permanently store a piece of junk in the stream. + * This publisher solves that: {@link #commit()} sends an end-of-batch sentinel that the server + * does not store. The server rewrites the header of the previously received last message so the + * batch commits normally, and the returned {@link PublishAck} count excludes the sentinel. + *

+ * Use {@link BatchPublisher} instead when the last thing you have to publish is genuinely the + * last message of your transaction. + *

+ * Requires a server at 2.14.0 or later and a stream configured with {@code allow_atomic}. + */ +public class EobBatchPublisher extends AbstractBatchPublisher { + + private EobBatchPublisher(Builder b) { + super(b); + } + + /** + * Commit the batch without storing a final message. + *

+ * ADR-50 calls this a commit: operation "Commit without storing the final message (EOB mode)". + * The sentinel is published on the subject of the first message added. There is + * deliberately no overload taking a subject: the sentinel must land on a subject the stream + * captures, and the stream has already taken a message on the first added subject. + * @return the PublishAck. Its batch size excludes the sentinel. + * @throws BatchPublishException if the batch is not open or has no messages in it + */ + public PublishAck commit() throws BatchPublishException { + requireOpen(); + if (firstSubject == null) { + throw new BatchPublishException(batchId, "Cannot commit an empty batch"); + } + try { + // the sentinel carries only the 3 batch headers. ADR-50 excludes it from the header + // check loop, so user headers and options here would silently do nothing. + Message m = request(firstSubject, null, null, NATS_BATCH_COMMIT_EOB, null); + PublishAck pa = new PublishAck(m); + validateAck(pa); + return pa; + } + catch (IOException e) { + // done this way because PublishAck makes an IOException if the ack is invalid. + throw new BatchPublishException(batchId, e.getMessage()); + } + catch (JetStreamApiException e) { + throw new BatchPublishException(batchId, e); + } + finally { + markClosed(); + } + } + + /** + * Commit the batch without storing a final message, asynchronously. + * @return a future for the PublishAck + */ + public CompletableFuture commitAsync() { + return CompletableFuture.supplyAsync(() -> { + try { + return commit(); + } + catch (BatchPublishException e) { + // CompletableFuture treats CompletionException as transport rather than cause: + // supplyAsync stores it as is and get() unwraps it, so the caller's + // ExecutionException.getCause() is this BatchPublishException itself. + throw new CompletionException(e); + } + }, conn.getOptions().getExecutor()); + } + + /** + * Get an instance of the builder, same as new EobBatchPublisher.Builder(); + * @return The Builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * The builder class for the EobBatchPublisher + */ + public static class Builder extends AbstractBatchPublisher.Builder { + /** + * Construct a builder with the default settings. + */ + public Builder() {} + + @Override + protected Builder self() { + return this; + } + + @Override + protected String newerThanVersion() { + // EOB is 2.14, later than plain atomic batch publish, so this gate is stricter. + return "2.13.99"; + } + + @Override + protected String tooOldMessage() { + return "EOB batch publish not available until server version 2.14.0."; + } + + @Override + public EobBatchPublisher build() { + validateAndDefault(); + return new EobBatchPublisher(this); + } + } +} diff --git a/batch-publish/src/main/java/io/synadia/bp/EobFastPublisher.java b/batch-publish/src/main/java/io/synadia/bp/EobFastPublisher.java new file mode 100644 index 0000000..220f245 --- /dev/null +++ b/batch-publish/src/main/java/io/synadia/bp/EobFastPublisher.java @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.bp; + +import io.nats.client.api.PublishAck; + +import static io.nats.client.support.NatsJetStreamConstants.FAST_BATCH_OP_COMMIT_EOB; + +/** + * Publishes a fast ingest batch and ends it without storing a final message. + *

+ * ADR-50 calls this a commit: operation "Commit without storing the final message (EOB mode)". + * The server does not store the sentinel, and the returned {@link PublishAck} count excludes it. + * Use it when the batch is exactly the messages you already published, rather than holding one + * message back to carry the commit or inventing a filler message to store. + *

+ * Use {@link FastPublisher} instead when the last thing you have to publish is genuinely the + * last message of the batch. + *

+ * Requires a server at 2.14.0 or later and a stream configured with {@code allow_batched}. + * See {@link AbstractFastPublisher} for what fast ingest gives up in exchange for throughput. + */ +public class EobFastPublisher extends AbstractFastPublisher { + + private EobFastPublisher(Builder b) { + super(b); + } + + /** + * Commit the batch without storing a final message. + *

+ * The sentinel is published on the subject of the first message added. There is + * deliberately no overload taking a subject: the sentinel must land on a subject the stream + * captures, and the stream has already taken a message on the first added subject. + * @return the authoritative PublishAck for the batch. Its batch size excludes the sentinel. + * @throws FastPublishException if the batch is finished or has no messages in it + */ + public PublishAck commit() throws FastPublishException { + requireCommittable(); + if (firstSubject == null) { + throw new FastPublishException(batchId, "Cannot commit an empty batch"); + } + _send(firstSubject, null, null, FAST_BATCH_OP_COMMIT_EOB); + try { + return awaitPubAck(); + } + finally { + abandonIfCommitDidNotFinish(); + } + } + + /** + * Get an instance of the builder, same as new EobFastPublisher.Builder(); + * @return The Builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * The builder class for the EobFastPublisher + */ + public static class Builder extends AbstractFastPublisher.Builder { + /** + * Construct a builder with the default settings. + */ + public Builder() {} + + @Override + protected Builder self() { + return this; + } + + @Override + public EobFastPublisher build() { + validateAndDefault(); + return new EobFastPublisher(this); + } + } +} diff --git a/batch-publish/src/main/java/io/synadia/bp/FastFlowError.java b/batch-publish/src/main/java/io/synadia/bp/FastFlowError.java new file mode 100644 index 0000000..fd84d8e --- /dev/null +++ b/batch-publish/src/main/java/io/synadia/bp/FastFlowError.java @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.bp; + +import io.nats.client.api.ApiResponse; +import io.nats.client.support.JsonValue; + +import static io.nats.client.support.ApiConstants.SEQ; +import static io.nats.client.support.JsonValueUtils.readLong; + +/** + * Reports that one message in a fast ingest batch failed a per message header check, such as + * {@code Nats-Expected-Last-Sequence}. + *

+ * ADR-50 deliberately keeps this out of the PublishAck: a PublishAck carries either an error or + * the persisted state, never both. Reporting the failure separately lets the client learn both + * that a given sequence failed and which sequences were persisted, and lets it surface the error + * the instant the server sees it rather than at the end of the batch. + *

+ * Extends {@link ApiResponse} so the nested error object parses for free, which is where + * {@link #getApiErrorCode()} and {@link #getDescription()} come from. + */ +public class FastFlowError extends ApiResponse { + private final long sequence; + + FastFlowError(JsonValue jv) { + super(jv); + sequence = readLong(jv, SEQ, 0); + } + + /** + * The batch sequence of the message that failed. + * @return the batch sequence + */ + public long getSequence() { + return sequence; + } + + @Override + public String toString() { + return "FastFlowError{sequence=" + sequence + ", code=" + getApiErrorCode() + ", description=" + getDescription() + '}'; + } +} diff --git a/batch-publish/src/main/java/io/synadia/bp/FastFlowGap.java b/batch-publish/src/main/java/io/synadia/bp/FastFlowGap.java new file mode 100644 index 0000000..bd37168 --- /dev/null +++ b/batch-publish/src/main/java/io/synadia/bp/FastFlowGap.java @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.bp; + +import io.nats.client.support.JsonValue; + +import static io.nats.client.support.ApiConstants.LAST_SEQ; +import static io.nats.client.support.ApiConstants.SEQ; +import static io.nats.client.support.JsonValueUtils.readLong; + +/** + * Reports that the server detected a gap in a fast ingest batch, meaning one or more messages + * were dropped or lost across a stream leader change. + *

+ * ADR-50 documents this as informational and losable. Only the final + * {@link io.nats.client.api.PublishAck} is authoritative about what was persisted. A gap is sent + * the instant the server detects it, so it arrives out of order with respect to flow acks and + * must never be used to move the acknowledged sequence or the flow rate. + */ +public class FastFlowGap { + private final long lastSequence; + private final long sequence; + + FastFlowGap(JsonValue jv) { + lastSequence = readLong(jv, LAST_SEQ, 0); + sequence = readLong(jv, SEQ, 0); + } + + /** + * The last batch sequence the server received before the gap. + * @return the last received batch sequence + */ + public long getLastSequence() { + return lastSequence; + } + + /** + * The batch sequence the server has resumed from. + * @return the current batch sequence + */ + public long getSequence() { + return sequence; + } + + @Override + public String toString() { + return "FastFlowGap{lastSequence=" + lastSequence + ", sequence=" + sequence + '}'; + } +} diff --git a/batch-publish/src/main/java/io/synadia/bp/FastPubAck.java b/batch-publish/src/main/java/io/synadia/bp/FastPubAck.java new file mode 100644 index 0000000..ed6ab09 --- /dev/null +++ b/batch-publish/src/main/java/io/synadia/bp/FastPubAck.java @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.bp; + +/** + * The result of adding one message to a fast ingest batch. + * This is not a JetStream PublishAck. A fast batch produces exactly one authoritative + * {@link io.nats.client.api.PublishAck}, at the commit. This type only reports where the + * batch stands locally. + */ +public class FastPubAck { + private final long batchSequence; + private final long ackSequence; + + FastPubAck(long batchSequence, long ackSequence) { + this.batchSequence = batchSequence; + this.ackSequence = ackSequence; + } + + /** + * The batch sequence assigned to the message that was just added. + * @return the batch sequence + */ + public long getBatchSequence() { + return batchSequence; + } + + /** + * The highest batch sequence the server has acknowledged so far. Acks are cumulative and + * arrive every N messages, so this normally trails {@link #getBatchSequence()}. + * @return the acknowledged batch sequence + */ + public long getAckSequence() { + return ackSequence; + } + + @Override + public String toString() { + return "FastPubAck{batchSequence=" + batchSequence + ", ackSequence=" + ackSequence + '}'; + } +} diff --git a/batch-publish/src/main/java/io/synadia/bp/FastPublishException.java b/batch-publish/src/main/java/io/synadia/bp/FastPublishException.java new file mode 100644 index 0000000..b6c81b4 --- /dev/null +++ b/batch-publish/src/main/java/io/synadia/bp/FastPublishException.java @@ -0,0 +1,124 @@ +// Copyright (c) 2026 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.bp; + +import io.nats.client.JetStreamApiException; +import io.nats.client.api.PublishAck; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Thrown when a fast ingest batch cannot proceed. Mirrors {@link BatchPublishException}. + */ +public class FastPublishException extends Exception { + /** The terminal PublishAck of a batch the server ended, when one arrived. */ + private PublishAck publishAck; + + /** The underlying JetStream api exception, when the failure came from one. */ + private final JetStreamApiException jsApiException; + + /** The id of the batch that failed. */ + private final String batchId; + + /** + * Construct with a message. + * @param batchId the batch id + * @param message the message + */ + public FastPublishException(@NonNull String batchId, @NonNull String message) { + super(message); + this.batchId = batchId; + jsApiException = null; + } + + /** + * Construct from a JetStreamApiException, preserving its error codes. + * @param batchId the batch id + * @param cause the cause + */ + public FastPublishException(@NonNull String batchId, @NonNull JetStreamApiException cause) { + super(cause); + this.batchId = batchId; + jsApiException = cause; + } + + /** + * Construct from any other cause. + * @param batchId the batch id + * @param cause the cause + */ + public FastPublishException(@NonNull String batchId, @NonNull Throwable cause) { + super(cause); + this.batchId = batchId; + jsApiException = null; + } + + @Override + public String getMessage() { + return "[" + batchId + "] " + super.getMessage(); + } + + /** + * The id of the batch that failed. + * @return the batch id + */ + @NonNull + public String getBatchId() { + return batchId; + } + + /** + * The underlying JetStreamApiException if there was one. + * @return the exception or null + */ + @Nullable + public JetStreamApiException getJsApiException() { + return jsApiException; + } + + /** + * The terminal PublishAck of a batch the server ended under the client, if it arrived. + *

+ * When a gap or a per message error ends a {@link GapMode#Fail} batch, the server abandons + * the batch and sends a final PublishAck reporting how far it actually got. ADR-50 makes + * that ack the only authoritative statement of what was persisted, a gap report explicitly + * not being one, so it is collected and attached here rather than discarded. Null when the + * batch ended some other way, and null when the ack never arrived, which ADR-50 allows + * because these acks are best effort. + * @return the terminal PublishAck or null + */ + @Nullable + public PublishAck getPublishAck() { + return publishAck; + } + + void setPublishAck(PublishAck publishAck) { + this.publishAck = publishAck; + } + + /** + * The error code from the response if this came from a JetStreamApiException, otherwise -1. + * @return the code + */ + public int getErrorCode() { + return jsApiException == null ? -1 : jsApiException.getErrorCode(); + } + + /** + * The api error code from the response if this came from a JetStreamApiException, otherwise -1. + * @return the code + */ + public int getApiErrorCode() { + return jsApiException == null ? -1 : jsApiException.getApiErrorCode(); + } + + /** + * The description from the response if this came from a JetStreamApiException, otherwise 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/FastPublishListener.java b/batch-publish/src/main/java/io/synadia/bp/FastPublishListener.java new file mode 100644 index 0000000..ba9eac5 --- /dev/null +++ b/batch-publish/src/main/java/io/synadia/bp/FastPublishListener.java @@ -0,0 +1,37 @@ +// Copyright (c) 2026 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.bp; + +/** + * Callbacks for the informational messages a fast ingest batch receives on its control channel. + * All methods default to doing nothing, so an implementation only overrides what it cares about. + *

+ * These are called on the thread that called {@code add}, {@code commit}, {@code close} or + * {@code ping}, never on a separate thread. A publisher that goes quiet will not deliver anything + * until it publishes again, so a batch that must notice gaps promptly should call + * {@link FastPublisher#ping()} periodically. + */ +public interface FastPublishListener { + /** + * The server detected a gap. In {@link GapMode#Fail} the batch is over and the final + * PublishAck is still coming; in {@link GapMode#Ok} the batch continues. + * @param gap the gap report + */ + default void onGap(FastFlowGap gap) {} + + /** + * A message failed a per message header check. In {@link GapMode#Fail} the batch is over; + * in {@link GapMode#Ok} the batch continues. + * @param error the error report + */ + default void onError(FastFlowError error) {} + + /** + * The server changed the flow rate, meaning how often it will acknowledge. The server may + * raise it, usually doubling toward the maximum the client asked for, or lower it, usually + * halving with a floor of 1, based on how loaded the stream is. + * @param ackEvery the new number of messages between acknowledgements + */ + default void onFlowChange(long ackEvery) {} +} diff --git a/batch-publish/src/main/java/io/synadia/bp/FastPublisher.java b/batch-publish/src/main/java/io/synadia/bp/FastPublisher.java new file mode 100644 index 0000000..694d1bb --- /dev/null +++ b/batch-publish/src/main/java/io/synadia/bp/FastPublisher.java @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.bp; + +import io.nats.client.api.PublishAck; +import io.nats.client.impl.Headers; +import org.jspecify.annotations.NonNull; + +import static io.nats.client.support.NatsJetStreamConstants.FAST_BATCH_OP_COMMIT; + +/** + * Publishes a fast ingest batch and ends it by sending a final real message that is stored + * along with the rest. + *

+ * Use {@link EobFastPublisher} instead when the batch is exactly the messages you already have + * and you do not want to hold one back, or invent a filler message, just to carry the commit. + *

+ * Requires a server at 2.14.0 or later and a stream configured with {@code allow_batched}. + * See {@link AbstractFastPublisher} for what fast ingest gives up in exchange for throughput. + */ +public class FastPublisher extends AbstractFastPublisher { + + private FastPublisher(Builder b) { + super(b); + } + + /** + * Publish the final message and commit the batch. + * @param subject the subject + * @param data the payload, may be null + * @return the authoritative PublishAck for the batch + * @throws FastPublishException if the batch is finished or the server reports an error + */ + public PublishAck commit(@NonNull String subject, byte[] data) throws FastPublishException { + return commit(subject, null, data); + } + + /** + * Publish the final message and commit the batch. + * @param subject the subject + * @param userHeaders headers for this message, may be null + * @param data the payload, may be null + * @return the authoritative PublishAck for the batch + * @throws FastPublishException if the batch is finished or the server reports an error + */ + public PublishAck commit(@NonNull String subject, Headers userHeaders, byte[] data) throws FastPublishException { + requireCommittable(); + _send(subject, userHeaders, data, FAST_BATCH_OP_COMMIT); + try { + return awaitPubAck(); + } + finally { + abandonIfCommitDidNotFinish(); + } + } + + /** + * Get an instance of the builder, same as new FastPublisher.Builder(); + * @return The Builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * The builder class for the FastPublisher + */ + public static class Builder extends AbstractFastPublisher.Builder { + /** + * Construct a builder with the default settings. + */ + public Builder() {} + + @Override + protected Builder self() { + return this; + } + + @Override + public FastPublisher build() { + validateAndDefault(); + return new FastPublisher(this); + } + } +} diff --git a/batch-publish/src/main/java/io/synadia/bp/GapMode.java b/batch-publish/src/main/java/io/synadia/bp/GapMode.java new file mode 100644 index 0000000..e855320 --- /dev/null +++ b/batch-publish/src/main/java/io/synadia/bp/GapMode.java @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.bp; + +import static io.nats.client.support.NatsJetStreamConstants.FAST_BATCH_GAP_FAIL; +import static io.nats.client.support.NatsJetStreamConstants.FAST_BATCH_GAP_OK; + +/** + * How a fast ingest batch reacts when the server detects a gap, meaning one or more messages + * were dropped by the server's overload protection or lost across a stream leader change. + * The mode is stated once in the reply subject when the batch starts and cannot be changed later. + */ +public enum GapMode { + /** + * Gaps are reported to the listener and the batch continues from the received sequence. + * Per message header check failures are also only reported, not fatal. + * This is what a metrics firehose wants. + */ + Ok(FAST_BATCH_GAP_OK), + + /** + * Any gap abandons the batch. The server stops accepting messages and sends a final + * PublishAck reporting how far it got. Per message header check failures also stop the batch. + * This is what a use case like ObjectStore needs, where a gap is a hole in a file. + */ + Fail(FAST_BATCH_GAP_FAIL); + + private final String wire; + + GapMode(String wire) { + this.wire = wire; + } + + /** + * The token used for this mode in the reply subject. + * @return the wire token, "ok" or "fail" + */ + @Override + public String toString() { + return wire; + } +} diff --git a/batch-publish/src/test/java/io/synadia/bp/BatchPublishTests.java b/batch-publish/src/test/java/io/synadia/bp/BatchPublishTests.java new file mode 100644 index 0000000..486576a --- /dev/null +++ b/batch-publish/src/test/java/io/synadia/bp/BatchPublishTests.java @@ -0,0 +1,585 @@ +// Copyright (c) 2026 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.bp; + +import io.nats.NatsRunnerUtils; +import io.nats.NatsServerRunner; +import io.nats.client.*; +import io.nats.client.api.MessageInfo; +import io.nats.client.api.PublishAck; +import io.nats.client.api.StorageType; +import io.nats.client.api.StreamConfiguration; +import io.nats.client.impl.Headers; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; + +import static io.nats.client.support.NatsJetStreamConstants.*; +import static org.junit.jupiter.api.Assertions.*; + +public class BatchPublishTests { + static NatsServerRunner runner; + static Connection nc; + static JetStreamManagement jsm; + + @BeforeAll + public static void beforeAll() throws Exception { + NatsRunnerUtils.setDefaultOutputLevel(Level.WARNING); + runner = new NatsServerRunner(false, true); + Options options = Options.builder() + .server(runner.getNatsLocalhostUri()) + .errorListener(new ErrorListener() {}) + .build(); + nc = Nats.connect(options); + jsm = nc.jetStreamManagement(); + } + + @AfterAll + public static void afterAll() throws Exception { + if (nc != null) { + nc.close(); + } + if (runner != null) { + runner.close(); + } + } + + // ---------------------------------------------------------------------------------- + // helpers + // ---------------------------------------------------------------------------------- + private static byte[] data(String s) { + return s.getBytes(StandardCharsets.UTF_8); + } + + private static String createStream(boolean allowAtomic, String... subjects) throws Exception { + String streamName = NUID.nextGlobalSequence(); + jsm.addStream(StreamConfiguration.builder() + .name(streamName) + .subjects(subjects) + .storageType(StorageType.Memory) + .allowAtomicPublish(allowAtomic) + .build()); + return streamName; + } + + private static BatchPublisher publisher() { + return BatchPublisher.builder().connection(nc).build(); + } + + private static String nextHeader(Subscription sub, String header) throws Exception { + Message m = sub.nextMessage(Duration.ofSeconds(2)); + assertNotNull(m, "the core subscriber should have seen the published message"); + assertNotNull(m.getHeaders(), "the message should have headers"); + return m.getHeaders().getFirst(header); + } + + private static long msgCount(String streamName) throws Exception { + return jsm.getStreamInfo(streamName).getStreamState().getMsgCount(); + } + + // ---------------------------------------------------------------------------------- + // happy path + // ---------------------------------------------------------------------------------- + @Test + public void testCommit() throws Exception { + String subject = NUID.nextGlobalSequence(); + String streamName = createStream(true, subject); + + BatchPublisher bp = publisher(); + bp.add(subject, data("1")); + bp.add(subject, data("2")); + PublishAck pa = bp.commit(subject, data("3")); + + // unlike EOB, the message that ends the batch is stored + assertEquals(3, pa.getBatchSize()); + assertEquals(bp.getBatchId(), pa.getBatchId()); + assertEquals(3, bp.size()); + assertEquals(3, msgCount(streamName)); + assertTrue(bp.isClosed()); + + MessageInfo mi = jsm.getMessage(streamName, 3); + assertNotNull(mi.getHeaders()); + assertEquals(NATS_BATCH_COMMIT_STORE, mi.getHeaders().getFirst(NATS_BATCH_COMMIT_HDR)); + assertEquals("3", new String(mi.getData(), StandardCharsets.UTF_8)); + } + + @Test + public void testHeadersOnEveryStoredMessage() throws Exception { + String subject = NUID.nextGlobalSequence(); + String streamName = createStream(true, subject); + + BatchPublisher bp = publisher(); + for (int i = 1; i <= 4; i++) { + Headers h = new Headers(); + h.put("my-header", "xyz-" + i); + bp.add(subject, h, data("data-" + i)); + } + Headers last = new Headers(); + last.put("my-header", "xyz-5"); + bp.commit(subject, last, data("data-5")); + + assertEquals(5, msgCount(streamName)); + for (int seq = 1; seq <= 5; seq++) { + MessageInfo mi = jsm.getMessage(streamName, seq); + Headers h = mi.getHeaders(); + assertNotNull(h, "seq " + seq + " should have headers"); + assertEquals(bp.getBatchId(), h.getFirst(NATS_BATCH_ID_HDR), "batch id on seq " + seq); + assertEquals(Integer.toString(seq), h.getFirst(NATS_BATCH_SEQUENCE_HDR), "batch sequence on seq " + seq); + assertEquals("xyz-" + seq, h.getFirst("my-header"), "user header on seq " + seq); + assertEquals("data-" + seq, new String(mi.getData(), StandardCharsets.UTF_8)); + // the commit header belongs only on the final message + if (seq == 5) { + assertEquals(NATS_BATCH_COMMIT_STORE, h.getFirst(NATS_BATCH_COMMIT_HDR)); + } + else { + assertNull(h.getFirst(NATS_BATCH_COMMIT_HDR), "seq " + seq + " must not carry a commit header"); + } + } + } + + @Test + public void testCommitAsync() throws Exception { + String subject = NUID.nextGlobalSequence(); + String streamName = createStream(true, subject); + + BatchPublisher bp = publisher(); + bp.add(subject, data("1")); + PublishAck pa = bp.commitAsync(subject, data("2")).get(); + assertEquals(2, pa.getBatchSize()); + assertEquals(2, msgCount(streamName)); + } + + // ---------------------------------------------------------------------------------- + // adding + // ---------------------------------------------------------------------------------- + @Test + public void testAckFirstAndAckEvery() throws Exception { + String subject = NUID.nextGlobalSequence(); + String streamName = createStream(true, subject); + + // ackEvery makes add() do a round trip every N messages. An unexpected ack payload + // would throw "Invalid ack returned from add with confirm", so reaching the commit proves + // the non-commit header path is still right. + BatchPublisher bp = BatchPublisher.builder() + .connection(nc) + .ackFirst(true) + .ackEvery(3) + .build(); + assertTrue(bp.ackFirst()); + assertEquals(3, bp.getAckEvery()); + + for (int i = 1; i <= 10; i++) { + bp.add(subject, data("d" + i)); + } + assertEquals(10, bp.size()); + PublishAck pa = bp.commit(subject, data("d11")); + assertEquals(11, pa.getBatchSize()); + assertEquals(11, bp.size()); + assertEquals(11, msgCount(streamName)); + } + + @Test + public void testAckFirstFalse() throws Exception { + String subject = NUID.nextGlobalSequence(); + String streamName = createStream(true, subject); + + BatchPublisher bp = BatchPublisher.builder().connection(nc).ackFirst(false).build(); + assertFalse(bp.ackFirst()); + bp.add(subject, data("1")); + bp.add(subject, data("2")); + // two adds plus the commit message is three stored messages + assertEquals(3, bp.commit(subject, data("3")).getBatchSize()); + assertEquals(3, bp.size()); + assertEquals(3, msgCount(streamName)); + } + + @Test + public void testAddAcked() throws Exception { + String subject = NUID.nextGlobalSequence(); + String streamName = createStream(true, subject); + + BatchPublisher bp = publisher(); + bp.addAcked(subject, data("1")); + bp.addAcked(subject, null, data("2")); + assertEquals(2, bp.size()); + assertEquals(3, bp.commit(subject, data("3")).getBatchSize()); + assertEquals(3, msgCount(streamName)); + } + + // ---------------------------------------------------------------------------------- + // guards + // ---------------------------------------------------------------------------------- + @Test + public void testDiscardAndStateGuards() throws Exception { + String subject = NUID.nextGlobalSequence(); + String streamName = createStream(true, subject); + + BatchPublisher bp = publisher(); + assertTrue(bp.isOpen()); + bp.add(subject, data("1")); + bp.discard(); + assertTrue(bp.isDiscarded()); + assertFalse(bp.isOpen()); + assertThrows(BatchPublishException.class, () -> bp.add(subject, data("2"))); + assertThrows(BatchPublishException.class, () -> bp.addAcked(subject, data("2"))); + assertThrows(BatchPublishException.class, () -> bp.commit(subject, data("2"))); + // a discarded batch was never committed, so nothing is stored + assertEquals(0, msgCount(streamName)); + + BatchPublisher committed = publisher(); + committed.add(subject, data("1")); + committed.commit(subject, data("2")); + assertTrue(committed.isClosed()); + assertThrows(BatchPublishException.class, () -> committed.add(subject, data("3"))); + } + + @Test + public void testExpectedLastSequenceOnlyOnFirstMessage() throws Exception { + String prefix = NUID.nextGlobalSequence(); + String subjectA = prefix + ".a"; + String subjectB = prefix + ".b"; + String streamName = createStream(true, prefix + ".>"); + + BatchPublishOptions expectLastSeq = BatchPublishOptions.builder().expectedLastSequence(0).build(); + BatchPublisher bp = publisher(); + bp.add(subjectA, data("1"), expectLastSeq); + + // ADR-50 allows it only on the first message, and the server kills the whole batch for + // it at commit time, so the client refuses before anything goes on the wire + BatchPublishException e = assertThrows(BatchPublishException.class, + () -> bp.add(subjectA, data("2"), expectLastSeq)); + assertTrue(e.getMessage().contains("Only the first message"), e.getMessage()); + assertThrows(BatchPublishException.class, () -> bp.commit(subjectA, data("2"), expectLastSeq)); + + // a rejected call spends no sequence and does not end the batch, so the caller can + // carry on with corrected options + assertEquals(1, bp.size()); + assertTrue(bp.isOpen()); + assertEquals(2, bp.commit(subjectA, data("2")).getBatchSize()); + assertEquals(2, msgCount(streamName)); + + // the per subject expectation is deliberately not restricted: the server allows it on + // any message as long as no earlier message in the batch wrote that same subject + BatchPublisher bp2 = publisher(); + bp2.add(subjectA, data("1")); + bp2.add(subjectB, data("2"), BatchPublishOptions.builder() + .expectedLastSubjectSequence(0) + .expectedLastSubjectSequenceSubject(subjectB) + .build()); + assertEquals(3, bp2.commit(subjectA, data("3")).getBatchSize()); + } + + @Test + public void testUserHeadersTheProtocolDoesNotAllow() throws Exception { + String subject = NUID.nextGlobalSequence(); + createStream(true, subject); + + BatchPublisher bp = publisher(); + + // the publisher writes these itself, and updateHeaders copies user headers over the top + // of them, so a caller header of the same name would corrupt the batch + for (String managed : new String[]{NATS_BATCH_ID_HDR, NATS_BATCH_SEQUENCE_HDR, NATS_BATCH_COMMIT_HDR}) { + Headers h = new Headers(); + h.put(managed, "anything"); + BatchPublishException e = assertThrows(BatchPublishException.class, + () -> bp.add(subject, h, data("1"))); + assertTrue(e.getMessage().contains(managed), e.getMessage()); + } + + // the server answers 10177 for this one on any message of a batch, including the first + Headers lastMsgId = new Headers(); + lastMsgId.put(EXPECTED_LAST_MSG_ID_HDR, "some-id"); + assertThrows(BatchPublishException.class, () -> bp.add(subject, lastMsgId, data("1"))); + + // Nats-Msg-Id is allowed: the server supports de-duplication in batches from 2.12.1 and + // only rejects a duplicate within one batch + Headers msgId = new Headers(); + msgId.put(MSG_ID_HDR, "id-1"); + bp.add(subject, msgId, data("1")); + + // the first message rule also applies to a raw header, not only to BatchPublishOptions + Headers expect = new Headers(); + expect.put(EXPECTED_LAST_SEQ_HDR, "0"); + BatchPublishException e = assertThrows(BatchPublishException.class, + () -> bp.add(subject, expect, data("2"))); + assertTrue(e.getMessage().contains("Only the first message"), e.getMessage()); + + // none of the rejections spent a sequence or ended the batch + assertEquals(1, bp.size()); + assertTrue(bp.isOpen()); + + // and on the first message the same raw header is fine + BatchPublisher first = publisher(); + first.add(subject, expect, data("1")); + assertEquals(2, first.commit(subject, data("2")).getBatchSize()); + } + + @Test + public void testConnectionRejectionIsChecked() throws Exception { + String subject = NUID.nextGlobalSequence(); + createStream(true, subject); + + // jnats rejects an invalid subject with an unchecked IllegalArgumentException. An add + // that fails must fail the same way whatever rejected it, so both send paths wrap it: + // the acked path goes through request, the plain path through publish. + BatchPublisher acked = BatchPublisher.builder().connection(nc).build(); + assertThrows(BatchPublishException.class, () -> acked.add("has space", data("1"))); + + BatchPublisher plain = BatchPublisher.builder().connection(nc).ackFirst(false).build(); + assertThrows(BatchPublishException.class, () -> plain.add("has space", data("1"))); + + // and because nothing left the client, the sequence is given back rather than spent: the + // batch carries on from where it was, with no hole for the server to reject + assertEquals(0, acked.size()); + assertEquals(0, plain.size()); + acked.add(subject, data("1")); + assertEquals(2, acked.commit(subject, data("2")).getBatchSize()); + + // the same on a commit that never leaves + BatchPublisher onCommit = publisher(); + onCommit.add(subject, data("1")); + assertThrows(BatchPublishException.class, () -> onCommit.commit("has space", data("2"))); + assertEquals(1, onCommit.size()); + } + + @Test + public void testAtomicDisabledSurfacesOnTheFirstAdd() throws Exception { + String subject = NUID.nextGlobalSequence(); + createStream(false, subject); + + // with ackFirst on, the first add is a request, so the server's rejection arrives there + // rather than at the commit. It must carry the server's error, not a generic sentence. + BatchPublisher bp = BatchPublisher.builder().connection(nc).build(); + BatchPublishException e = assertThrows(BatchPublishException.class, () -> bp.add(subject, data("1"))); + assertEquals(JS_ATOMIC_PUBLISH_DISABLED, e.getApiErrorCode()); + assertNotNull(e.getJsApiException()); + assertTrue(e.getMessage().contains("atomic publish is disabled"), e.getMessage()); + } + + @Test + public void testBatchSizeLimit() throws Exception { + String subject = NUID.nextGlobalSequence(); + String streamName = createStream(true, subject); + + // the server counts the commit message too, so 1000 adds plus a commit is 1001 and the + // whole batch is lost at the last step. Nothing guards this locally yet. + BatchPublisher bp = BatchPublisher.builder().connection(nc).ackFirst(false).build(); + for (int i = 1; i <= 1000; i++) { + bp.add(subject, data("d" + i)); + } + BatchPublishException e = assertThrows(BatchPublishException.class, () -> bp.commit(subject, data("last"))); + assertEquals(JS_ATOMIC_PUBLISH_TOO_LARGE_BATCH, e.getApiErrorCode()); + assertEquals(0, msgCount(streamName)); + + // 999 adds plus the commit is exactly the limit and is accepted + BatchPublisher ok = BatchPublisher.builder().connection(nc).ackFirst(false).build(); + for (int i = 1; i <= 999; i++) { + ok.add(subject, data("d" + i)); + } + assertEquals(1000, ok.commit(subject, data("last")).getBatchSize()); + assertEquals(1000, msgCount(streamName)); + } + + @Test + public void testMessageTtlAppliedAndPrecedence() throws Exception { + String subject = NUID.nextGlobalSequence(); + String streamName = NUID.nextGlobalSequence(); + jsm.addStream(StreamConfiguration.builder() + .name(streamName) + .subjects(subject) + .storageType(StorageType.Memory) + .allowAtomicPublish(true) + .allowMessageTtl(true) + .build()); + + // the server consumes the ttl header and strips it before storing + // (nats-server/server/stream.go:7253), so read it off the wire rather than out of the + // stream. A core subscriber sees exactly the header block the publisher built. + Subscription sub = nc.subscribe(subject); + + // the publisher ttl applies to a message with no options, and an options ttl wins over it + BatchPublisher bp = BatchPublisher.builder().connection(nc).messageTtlSeconds(60).build(); + bp.add(subject, data("publisher ttl")); + bp.add(subject, data("options ttl"), BatchPublishOptions.builder().messageTtlSeconds(30).build()); + bp.commit(subject, data("commit ttl"), BatchPublishOptions.builder().messageTtlNever().build()); + + assertEquals("60s", nextHeader(sub, MSG_TTL_HDR)); + assertEquals("30s", nextHeader(sub, MSG_TTL_HDR)); + assertEquals("never", nextHeader(sub, MSG_TTL_HDR)); + sub.unsubscribe(); + assertEquals(3, msgCount(streamName)); + } + + @Test + public void testTerminalOperationsAreIdempotent() throws Exception { + String subject = NUID.nextGlobalSequence(); + createStream(true, subject); + + BatchPublisher bp = publisher(); + bp.add(subject, data("1")); + bp.discard(); + bp.discard(); + assertTrue(bp.isDiscarded()); + assertFalse(bp.isOpen()); + + // discarding a committed batch does not rewrite what happened to it + BatchPublisher committed = publisher(); + committed.add(subject, data("1")); + committed.commit(subject, data("2")); + assertTrue(committed.isClosed()); + committed.discard(); + assertTrue(committed.isDiscarded(), "discard after commit is allowed and does change the state"); + } + + @Test + public void testCommitAsyncFailureCarriesTheCause() throws Exception { + String subject = NUID.nextGlobalSequence(); + createStream(true, subject); + BatchPublisher seed = publisher(); + seed.add(subject, data("1")); + seed.commit(subject, data("2")); + + // the future completes with the BatchPublishException itself, not a RuntimeException + // wrapping it, so the caller unwraps one layer rather than two + BatchPublisher bp = BatchPublisher.builder().connection(nc).ackFirst(false).build(); + bp.add(subject, data("1"), BatchPublishOptions.builder().expectedLastSequence(1).build()); + ExecutionException ee = assertThrows(ExecutionException.class, + () -> bp.commitAsync(subject, data("2")).get(5, TimeUnit.SECONDS)); + assertInstanceOf(BatchPublishException.class, ee.getCause()); + assertEquals(JS_WRONG_LAST_SEQUENCE, ((BatchPublishException)ee.getCause()).getApiErrorCode()); + } + + @Test + public void testAtomicDisabled() throws Exception { + String subject = NUID.nextGlobalSequence(); + createStream(false, subject); + + BatchPublisher bp = BatchPublisher.builder().connection(nc).ackFirst(false).build(); + bp.add(subject, data("1")); + BatchPublishException e = assertThrows(BatchPublishException.class, () -> bp.commit(subject, data("2"))); + assertEquals(JS_ATOMIC_PUBLISH_DISABLED, e.getApiErrorCode()); + } + + @Test + public void testExpectationsEnforcedOnCommit() throws Exception { + String subject = NUID.nextGlobalSequence(); + String streamName = createStream(true, subject); + + BatchPublishOptions opts = BatchPublishOptions.builder().expectedLastSequence(999).build(); + BatchPublisher bp = BatchPublisher.builder().connection(nc).ackFirst(false).build(); + bp.add(subject, data("1"), opts); + bp.add(subject, data("2")); + assertThrows(BatchPublishException.class, () -> bp.commit(subject, data("3"))); + assertEquals(0, msgCount(streamName)); + } + + @Test + public void testExpectationsSatisfied() throws Exception { + String subject = NUID.nextGlobalSequence(); + String streamName = createStream(true, subject); + + // a correct expectation must still let the batch through + BatchPublishOptions opts = BatchPublishOptions.builder().expectedLastSequence(0).build(); + BatchPublisher bp = BatchPublisher.builder().connection(nc).ackFirst(false).build(); + bp.add(subject, data("1"), opts); + assertEquals(2, bp.commit(subject, data("2")).getBatchSize()); + assertEquals(2, msgCount(streamName)); + } + + @Test + public void testMessageTtl() throws Exception { + String subject = NUID.nextGlobalSequence(); + String streamName = NUID.nextGlobalSequence(); + jsm.addStream(StreamConfiguration.builder() + .name(streamName) + .subjects(subject) + .storageType(StorageType.Memory) + .allowAtomicPublish(true) + .allowMessageTtl(true) + .build()); + + // the TTL travels in the same header block updateHeaders builds + BatchPublishOptions opts = BatchPublishOptions.builder().messageTtlSeconds(60).build(); + BatchPublisher bp = publisher(); + bp.add(subject, data("1"), opts); + assertEquals(2, bp.commit(subject, null, data("2"), opts).getBatchSize()); + assertEquals(2, msgCount(streamName)); + } + + // ---------------------------------------------------------------------------------- + // builder + // ---------------------------------------------------------------------------------- + @Test + public void testBatchIdGeneratedAndValidated() { + BatchPublisher generated = publisher(); + assertNotNull(generated.getBatchId()); + assertFalse(generated.getBatchId().isEmpty()); + + BatchPublisher explicit = BatchPublisher.builder().connection(nc).batchId("my-batch-id").build(); + assertEquals("my-batch-id", explicit.getBatchId()); + + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 65; i++) { + sb.append("x"); + } + assertThrows(IllegalArgumentException.class, + () -> BatchPublisher.builder().connection(nc).batchId(sb.toString()).build()); + + // the id must be a single subject token, because the fast publishers put it in the + // reply subject and the same rule is applied to both families + assertThrows(IllegalArgumentException.class, + () -> BatchPublisher.builder().connection(nc).batchId("has.dot").build()); + assertThrows(IllegalArgumentException.class, + () -> BatchPublisher.builder().connection(nc).batchId("has space").build()); + assertThrows(IllegalArgumentException.class, + () -> BatchPublisher.builder().connection(nc).batchId("wild*card").build()); + assertThrows(IllegalArgumentException.class, + () -> BatchPublisher.builder().connection(nc).batchId("gt>").build()); + + assertThrows(IllegalArgumentException.class, () -> BatchPublisher.builder().build()); + } + + @SuppressWarnings("deprecation") + @Test + public void testDeprecatedDurationAckTimeoutConverts() { + // 0.2.2 shipped the Duration setter, so removing it would have been a NoSuchMethodError + // for anyone who swapped the jar without recompiling. It converts and floors instead. + assertEquals(Duration.ofSeconds(3), + BatchPublisher.builder().connection(nc).ackTimeout(Duration.ofSeconds(3)).build().getAckTimeout()); + + // sub millisecond and zero become the default rather than an unbounded or instant wait + Duration dflt = BatchPublisher.builder().connection(nc).build().getAckTimeout(); + assertEquals(dflt, + BatchPublisher.builder().connection(nc).ackTimeout(Duration.ofNanos(500)).build().getAckTimeout()); + assertEquals(dflt, + BatchPublisher.builder().connection(nc).ackTimeout(Duration.ZERO).build().getAckTimeout()); + assertEquals(dflt, + BatchPublisher.builder().connection(nc).ackTimeout(Duration.ofSeconds(-5)).build().getAckTimeout()); + } + + @Test + public void testSharedBuilderSettingsApply() { + // the settings live on the shared base builder, so they must survive the self typing + BatchPublisher bp = BatchPublisher.builder() + .connection(nc) + .batchId("my-batch") + .ackFirst(false) + .ackEvery(5) + .messageTtlSeconds(30) + .ackTimeout(3000) + .build(); + + assertEquals("my-batch", bp.getBatchId()); + assertFalse(bp.ackFirst()); + assertEquals(5, bp.getAckEvery()); + assertEquals("30s", bp.getMessageTtl()); + assertEquals(Duration.ofSeconds(3), bp.getAckTimeout()); + } +} diff --git a/batch-publish/src/test/java/io/synadia/bp/EobBatchPublishTests.java b/batch-publish/src/test/java/io/synadia/bp/EobBatchPublishTests.java new file mode 100644 index 0000000..ad90ab6 --- /dev/null +++ b/batch-publish/src/test/java/io/synadia/bp/EobBatchPublishTests.java @@ -0,0 +1,234 @@ +// Copyright (c) 2026 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.bp; + +import io.nats.NatsRunnerUtils; +import io.nats.NatsServerRunner; +import io.nats.client.*; +import io.nats.client.api.MessageInfo; +import io.nats.client.api.PublishAck; +import io.nats.client.api.StorageType; +import io.nats.client.api.StreamConfiguration; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Level; + +import static io.nats.client.support.NatsJetStreamConstants.JS_ATOMIC_PUBLISH_DISABLED; +import static io.nats.client.support.NatsJetStreamConstants.NATS_BATCH_COMMIT_EOB; +import static io.nats.client.support.NatsJetStreamConstants.NATS_BATCH_COMMIT_HDR; +import static io.nats.client.support.NatsJetStreamConstants.NATS_BATCH_COMMIT_STORE; +import static org.junit.jupiter.api.Assertions.*; + +public class EobBatchPublishTests { + static NatsServerRunner runner; + static Connection nc; + static JetStreamManagement jsm; + + @BeforeAll + public static void beforeAll() throws Exception { + NatsRunnerUtils.setDefaultOutputLevel(Level.WARNING); + runner = new NatsServerRunner(false, true); + Options options = Options.builder() + .server(runner.getNatsLocalhostUri()) + .errorListener(new ErrorListener() {}) + .build(); + nc = Nats.connect(options); + jsm = nc.jetStreamManagement(); + } + + @AfterAll + public static void afterAll() throws Exception { + if (nc != null) { + nc.close(); + } + if (runner != null) { + runner.close(); + } + } + + // ---------------------------------------------------------------------------------- + // helpers + // ---------------------------------------------------------------------------------- + private static byte[] data(String s) { + return s.getBytes(StandardCharsets.UTF_8); + } + + private static String createStream(boolean allowAtomic, String... subjects) throws Exception { + String streamName = NUID.nextGlobalSequence(); + jsm.addStream(StreamConfiguration.builder() + .name(streamName) + .subjects(subjects) + .storageType(StorageType.Memory) + .allowAtomicPublish(allowAtomic) + .build()); + return streamName; + } + + private static EobBatchPublisher publisher() { + return EobBatchPublisher.builder().connection(nc).build(); + } + + private static long msgCount(String streamName) throws Exception { + return jsm.getStreamInfo(streamName).getStreamState().getMsgCount(); + } + + // ---------------------------------------------------------------------------------- + // happy path + // ---------------------------------------------------------------------------------- + @Test + public void testCommit() throws Exception { + String subject = NUID.nextGlobalSequence(); + String streamName = createStream(true, subject); + + EobBatchPublisher bp = publisher(); + bp.add(subject, data("1")); + bp.add(subject, data("2")); + bp.add(subject, data("3")); + PublishAck pa = bp.commit(); + + // the sentinel consumed a batch sequence but must not be counted or stored + assertEquals(3, pa.getBatchSize()); + assertEquals(bp.getBatchId(), pa.getBatchId()); + assertEquals(3, bp.size()); + assertEquals(3, msgCount(streamName)); + assertTrue(bp.isClosed()); + + // the real proof that EOB worked rather than an ordinary commit: the server rewrote + // the header of the previously received last message from nothing to "1". + MessageInfo mi = jsm.getMessage(streamName, 3); + assertNotNull(mi.getHeaders()); + assertEquals(NATS_BATCH_COMMIT_STORE, mi.getHeaders().getFirst(NATS_BATCH_COMMIT_HDR)); + assertEquals("3", new String(mi.getData(), StandardCharsets.UTF_8)); + } + + @Test + public void testSentinelUsesFirstSubject() throws Exception { + String prefix = NUID.nextGlobalSequence(); + String subjectA = prefix + ".a"; + String subjectB = prefix + ".b"; + createStream(true, prefix + ".*"); + + // The sentinel is never stored, so the stream cannot show us where it went. A plain core + // subscriber can: the sentinel is still published, it is just not persisted. + Subscription sub = nc.subscribe(prefix + ".*"); + + EobBatchPublisher bp = publisher(); + bp.add(subjectA, data("1")); // first -> this subject must carry the sentinel + bp.add(subjectB, data("2")); + bp.add(subjectB, data("3")); // last -> not the sentinel subject + bp.commit(); + + List eobSubjects = new ArrayList<>(); + Message m = sub.nextMessage(Duration.ofSeconds(2)); + while (m != null) { + if (m.getHeaders() != null + && NATS_BATCH_COMMIT_EOB.equals(m.getHeaders().getFirst(NATS_BATCH_COMMIT_HDR))) + { + eobSubjects.add(m.getSubject()); + } + m = sub.nextMessage(Duration.ofMillis(200)); + } + + assertEquals(1, eobSubjects.size(), "exactly one EOB sentinel should have been published"); + assertEquals(subjectA, eobSubjects.get(0), "the sentinel must go to the first subject added"); + } + + @Test + public void testCommitAsync() throws Exception { + String subject = NUID.nextGlobalSequence(); + String streamName = createStream(true, subject); + + EobBatchPublisher bp = publisher(); + bp.add(subject, data("1")); + bp.add(subject, data("2")); + PublishAck pa = bp.commitAsync().get(); + + assertEquals(2, pa.getBatchSize()); + assertEquals(2, msgCount(streamName)); + } + + // ---------------------------------------------------------------------------------- + // guards + // ---------------------------------------------------------------------------------- + @Test + public void testCommitEmptyBatch() throws Exception { + String subject = NUID.nextGlobalSequence(); + createStream(true, subject); + + // A batch cannot be only a sentinel. Checked locally, matching the other clients, and + // there is no subject overload that could get around it. + EobBatchPublisher bp = publisher(); + BatchPublishException e = assertThrows(BatchPublishException.class, bp::commit); + assertTrue(e.getMessage().contains("Cannot commit an empty batch"), e.getMessage()); + assertTrue(bp.isOpen(), "a rejected commit must not close the batch"); + } + + @Test + public void testCommitNotOpen() throws Exception { + String subject = NUID.nextGlobalSequence(); + createStream(true, subject); + + EobBatchPublisher committed = publisher(); + committed.add(subject, data("1")); + committed.commit(); + assertThrows(BatchPublishException.class, committed::commit); + + EobBatchPublisher discarded = publisher(); + discarded.add(subject, data("1")); + discarded.discard(); + assertThrows(BatchPublishException.class, discarded::commit); + } + + @Test + public void testAtomicDisabled() throws Exception { + String subject = NUID.nextGlobalSequence(); + createStream(false, subject); + + // ackFirst(false) so the adds are fire and forget and the error surfaces at the commit + EobBatchPublisher bp = EobBatchPublisher.builder().connection(nc).ackFirst(false).build(); + bp.add(subject, data("1")); + BatchPublishException e = assertThrows(BatchPublishException.class, bp::commit); + assertEquals(JS_ATOMIC_PUBLISH_DISABLED, e.getApiErrorCode()); + } + + @Test + public void testHonorsExpectations() throws Exception { + String subject = NUID.nextGlobalSequence(); + String streamName = createStream(true, subject); + + // only the first message of a batch may carry expectations. Set a wrong one and the + // whole batch must be rejected at commit with nothing stored. + BatchPublishOptions opts = BatchPublishOptions.builder().expectedLastSequence(999).build(); + EobBatchPublisher bp = EobBatchPublisher.builder().connection(nc).ackFirst(false).build(); + bp.add(subject, data("1"), opts); + bp.add(subject, data("2")); + assertThrows(BatchPublishException.class, bp::commit); + assertEquals(0, msgCount(streamName)); + } + + @Test + public void testSharedBuilderSettingsApply() { + // the settings live on the shared base builder, so they must survive the self typing + EobBatchPublisher bp = EobBatchPublisher.builder() + .connection(nc) + .batchId("my-eob-batch") + .ackFirst(false) + .ackEvery(5) + .messageTtlSeconds(30) + .ackTimeout(3000) + .build(); + + assertEquals("my-eob-batch", bp.getBatchId()); + assertFalse(bp.ackFirst()); + assertEquals(5, bp.getAckEvery()); + assertEquals("30s", bp.getMessageTtl()); + assertEquals(Duration.ofSeconds(3), bp.getAckTimeout()); + } +} diff --git a/batch-publish/src/test/java/io/synadia/bp/FastPublishTests.java b/batch-publish/src/test/java/io/synadia/bp/FastPublishTests.java new file mode 100644 index 0000000..d1f04db --- /dev/null +++ b/batch-publish/src/test/java/io/synadia/bp/FastPublishTests.java @@ -0,0 +1,817 @@ +// Copyright (c) 2026 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.bp; + +import io.nats.NatsRunnerUtils; +import io.nats.NatsServerRunner; +import io.nats.client.*; +import io.nats.client.api.PublishAck; +import io.nats.client.api.StorageType; +import io.nats.client.api.StreamConfiguration; +import io.nats.client.impl.Headers; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Level; + +import static io.nats.client.support.NatsJetStreamConstants.EXPECTED_LAST_SEQ_HDR; +import static io.nats.client.support.NatsJetStreamConstants.JS_BATCH_PUBLISH_DISABLED; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Gap injection under GapMode.Fail, meaning a real dropped message or a leader change, is not + * reliably reproducible against a single server, so it is not tested here. Cover it manually + * against a cluster. + */ +public class FastPublishTests { + static NatsServerRunner runner; + static Connection nc; + static JetStreamManagement jsm; + + @BeforeAll + public static void beforeAll() throws Exception { + NatsRunnerUtils.setDefaultOutputLevel(Level.WARNING); + runner = new NatsServerRunner(false, true); + Options options = Options.builder() + .server(runner.getNatsLocalhostUri()) + .errorListener(new ErrorListener() {}) + .build(); + nc = Nats.connect(options); + jsm = nc.jetStreamManagement(); + } + + @AfterAll + public static void afterAll() throws Exception { + if (nc != null) { + nc.close(); + } + if (runner != null) { + runner.close(); + } + } + + // ---------------------------------------------------------------------------------- + // helpers + // ---------------------------------------------------------------------------------- + private static byte[] data(String s) { + return s.getBytes(StandardCharsets.UTF_8); + } + + private static String createStream(boolean allowBatched, String... subjects) throws Exception { + String streamName = NUID.nextGlobalSequence(); + jsm.addStream(StreamConfiguration.builder() + .name(streamName) + .subjects(subjects) + .storageType(StorageType.Memory) + .allowBatched(allowBatched) + .build()); + return streamName; + } + + private static long msgCount(String streamName) throws Exception { + return jsm.getStreamInfo(streamName).getStreamState().getMsgCount(); + } + + private static FastPublisher.Builder builder() { + return FastPublisher.builder().connection(nc).ackTimeout(10_000); + } + + private static EobFastPublisher.Builder eobBuilder() { + return EobFastPublisher.builder().connection(nc).ackTimeout(10_000); + } + + // ---------------------------------------------------------------------------------- + // happy paths + // ---------------------------------------------------------------------------------- + @Test + public void testFastPublishGapOk() throws Exception { + String subject = NUID.nextGlobalSequence(); + String streamName = createStream(true, subject); + + FastPublisher fp = builder().gapMode(GapMode.Ok).build(); + for (int i = 1; i <= 1000; i++) { + fp.add(subject, data("data-" + i)); + } + PublishAck pa = fp.commit(subject, data("last")); + + assertEquals(1001, pa.getBatchSize()); + assertEquals(fp.getBatchId(), pa.getBatchId()); + assertEquals(1001, msgCount(streamName)); + assertTrue(fp.isTerminal()); + assertEquals(0, fp.gapCount()); + assertNull(fp.getLastGap()); + } + + @Test + public void testFastPublishGapFailEndingInEob() throws Exception { + String subject = NUID.nextGlobalSequence(); + String streamName = createStream(true, subject); + + EobFastPublisher fp = eobBuilder().gapMode(GapMode.Fail).build(); + for (int i = 1; i <= 100; i++) { + fp.add(subject, data("data-" + i)); + } + PublishAck pa = fp.commit(); + + // the EOB marker consumed a batch sequence but is not counted and not stored + assertEquals(100, pa.getBatchSize()); + assertEquals(100, msgCount(streamName)); + assertTrue(fp.isTerminal()); + assertEquals(0, fp.gapCount()); + assertNull(fp.getLastGap()); + } + + @Test + public void testSingleMessageBatch() throws Exception { + String subject = NUID.nextGlobalSequence(); + String streamName = createStream(true, subject); + + // ADR-50 carves this out as returning a plain PubAck with no preceding flow ack + FastPublisher fp = builder().build(); + fp.add(subject, data("one")); + PublishAck pa = fp.commit(subject, data("two")); + + assertEquals(2, pa.getBatchSize()); + assertEquals(2, msgCount(streamName)); + } + + @Test + public void testSizeMatchesBatchSize() throws Exception { + String subject = NUID.nextGlobalSequence(); + String streamNameEob = createStream(true, subject); + + // ADR-50: "The pub ack's BatchSize will reflect the messages in the batch, without + // counting the EOB message." size() must agree with the server, not count the sentinel. + EobFastPublisher eob = eobBuilder().build(); + for (int i = 1; i <= 10; i++) { + eob.add(subject, data("d" + i)); + } + PublishAck eobAck = eob.commit(); + assertEquals(10, eobAck.getBatchSize()); + assertEquals(10, eob.size(), "size() must exclude the EOB sentinel"); + assertEquals(eobAck.getBatchSize(), eob.size()); + assertEquals(10, msgCount(streamNameEob)); + + // a commit that stores its final message does count that message + String subject2 = NUID.nextGlobalSequence(); + String streamNameStore = createStream(true, subject2); + FastPublisher stored = builder().build(); + for (int i = 1; i <= 10; i++) { + stored.add(subject2, data("d" + i)); + } + PublishAck storeAck = stored.commit(subject2, data("final")); + assertEquals(11, storeAck.getBatchSize()); + assertEquals(11, stored.size(), "size() must count a stored final message"); + assertEquals(storeAck.getBatchSize(), stored.size()); + assertEquals(11, msgCount(streamNameStore)); + } + + // ---------------------------------------------------------------------------------- + // flow control + // ---------------------------------------------------------------------------------- + @Test + public void testFlowControlObserved() throws Exception { + String subject = NUID.nextGlobalSequence(); + createStream(true, subject); + + List flowChanges = new ArrayList<>(); + EobFastPublisher fp = eobBuilder() + .maxFlow(100) + .listener(new FastPublishListener() { + @Override + public void onFlowChange(long ackEvery) { + flowChanges.add(ackEvery); + } + }) + .build(); + + for (int i = 1; i <= 2000; i++) { + fp.add(subject, data("data-" + i)); + } + fp.commit(); + + assertFalse(flowChanges.isEmpty(), "the server must report a starting flow rate"); + assertTrue(fp.flow() > 0); + // the server ramps up toward, but never past, what we asked for + for (Long f : flowChanges) { + assertTrue(f <= 100, "flow " + f + " exceeded the requested maximum"); + } + } + + @Test + public void testFlowControlRespected() throws Exception { + String subject = NUID.nextGlobalSequence(); + createStream(true, subject); + + EobFastPublisher fp = eobBuilder().maxOutstandingAcks(1).maxFlow(10).build(); + for (int i = 1; i <= 500; i++) { + FastPubAck a = fp.add(subject, data("data-" + i)); + long outstanding = a.getBatchSequence() - a.getAckSequence(); + // with 1 outstanding ack allowed we may never be more than one full window ahead + assertTrue(outstanding <= fp.flow() * 2, + "outstanding " + outstanding + " exceeded twice the flow " + fp.flow()); + } + fp.commit(); + } + + @Test + public void testPingDoesNotAdvanceSequence() throws Exception { + String subject = NUID.nextGlobalSequence(); + String streamName = createStream(true, subject); + + EobFastPublisher fp = eobBuilder().build(); + fp.add(subject, data("1")); + fp.add(subject, data("2")); + long before = fp.size(); + fp.ping(); + assertEquals(before, fp.size(), "ping must not consume a batch sequence"); + + PublishAck pa = fp.commit(); + assertEquals(2, pa.getBatchSize()); + assertEquals(2, msgCount(streamName)); + } + + // ---------------------------------------------------------------------------------- + // errors and guards + // ---------------------------------------------------------------------------------- + @Test + public void testBatchedDisabled() throws Exception { + String subject = NUID.nextGlobalSequence(); + createStream(false, subject); + + // feature detection happens on the first add, which is where the server's answer lives + FastPublisher fp = builder().build(); + FastPublishException e = assertThrows(FastPublishException.class, () -> fp.add(subject, data("1"))); + assertEquals(JS_BATCH_PUBLISH_DISABLED, e.getApiErrorCode()); + } + + @Test + public void testHeaderCheckFailureGapOk() throws Exception { + String subject = NUID.nextGlobalSequence(); + createStream(true, subject); + + AtomicLong errorSeq = new AtomicLong(-1); + EobFastPublisher fp = eobBuilder() + .gapMode(GapMode.Ok) + .listener(new FastPublishListener() { + @Override + public void onError(FastFlowError error) { + errorSeq.set(error.getSequence()); + } + }) + .build(); + + for (int i = 1; i <= 10; i++) { + if (i == 5) { + Headers h = new Headers(); + h.put(EXPECTED_LAST_SEQ_HDR, "9999"); // wrong on purpose + fp.add(subject, h, data("data-" + i)); + } + else { + fp.add(subject, data("data-" + i)); + } + } + fp.commit(); + + // in Ok mode the failure is reported and the batch keeps going + assertNotEquals(-1, errorSeq.get(), "onError should have fired for the bad message"); + } + + @Test + public void testCommitEobEmptyBatch() throws Exception { + String subject = NUID.nextGlobalSequence(); + createStream(true, subject); + + EobFastPublisher fp = eobBuilder().build(); + FastPublishException e = assertThrows(FastPublishException.class, fp::commit); + assertTrue(e.getMessage().contains("Cannot commit an empty batch"), e.getMessage()); + } + + @Test + public void testAbandon() throws Exception { + String subject = NUID.nextGlobalSequence(); + createStream(true, subject); + + FastPublisher fp = builder().build(); + fp.add(subject, data("1")); + fp.abandon(); + + assertTrue(fp.isTerminal()); + assertThrows(FastPublishException.class, () -> fp.add(subject, data("2"))); + assertThrows(FastPublishException.class, () -> fp.commit(subject, data("2"))); + } + + @Test + public void testCommitAsTheFirstCall() throws Exception { + String subject = NUID.nextGlobalSequence(); + String streamName = createStream(true, subject); + + // legal: a one message batch. The server's parser rejects only an EOB at sequence 1, and + // the first-reply feature check lives in add, so this path skips it and learns about a + // server problem from the PublishAck instead. + FastPublisher fp = builder().build(); + PublishAck pa = fp.commit(subject, data("only")); + assertEquals(1, pa.getBatchSize()); + assertEquals(1, msgCount(streamName)); + assertEquals(EndReason.Committed, fp.getEndReason()); + + // the EOB commit is the one that cannot start a batch, and it is refused locally + EobFastPublisher eob = eobBuilder().build(); + assertThrows(FastPublishException.class, eob::commit); + } + + @Test + public void testTerminalOperationsAreIdempotent() throws Exception { + String subject = NUID.nextGlobalSequence(); + createStream(true, subject); + + FastPublisher fp = builder().build(); + fp.add(subject, data("1")); + fp.abandon(); + fp.abandon(); + fp.close(); + assertTrue(fp.isTerminal()); + assertEquals(EndReason.Abandoned, fp.getEndReason()); + + // every operation is refused once the batch is over, including ping + assertThrows(FastPublishException.class, () -> fp.add(subject, data("2"))); + assertThrows(FastPublishException.class, () -> fp.commit(subject, data("2"))); + assertThrows(FastPublishException.class, fp::ping); + } + + @Test + public void testCloseAbandonsNeverCommits() throws Exception { + String subject = NUID.nextGlobalSequence(); + createStream(true, subject); + + FastPublisher escaped; + try (FastPublisher fp = builder().build()) { + escaped = fp; + fp.add(subject, data("1")); + assertFalse(fp.isTerminal()); + } + + // close() is abandon(): the batch ends unusable and no PublishAck was ever produced. + // Whatever the server already persisted stays persisted, which is fast ingest, not a + // property of close. + assertTrue(escaped.isTerminal()); + assertThrows(FastPublishException.class, () -> escaped.add(subject, data("2"))); + + // and it is harmless after the batch has already ended on its own + FastPublisher committed = builder().build(); + committed.add(subject, data("1")); + assertNotNull(committed.commit(subject, data("2"))); + committed.close(); + assertTrue(committed.isTerminal()); + } + + @Test + public void testBatchIdTooLong() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 65; i++) { + sb.append("x"); + } + assertThrows(IllegalArgumentException.class, () -> builder().batchId(sb.toString()).build()); + } + + @Test + public void testBatchIdInvalidCharacters() { + // the id is one token of the reply subject, which the server parses right to left. A dot + // would leave the server reading only "dot" as the id while the client reported + // "has.dot", and a wildcard or a space makes the subject itself invalid. + assertThrows(IllegalArgumentException.class, () -> builder().batchId("has.dot").build()); + assertThrows(IllegalArgumentException.class, () -> builder().batchId("has space").build()); + assertThrows(IllegalArgumentException.class, () -> builder().batchId("wild*card").build()); + assertThrows(IllegalArgumentException.class, () -> builder().batchId("gt>").build()); + } + + @Test + public void testAckTimeoutNotInfinite() { + // jnats reads a duration under one nanosecond as wait forever, so a zero or negative ack + // timeout must fall back to the connection timeout rather than disabling every timeout in + // the publisher. + // + // Nothing captures this subject, so nothing answers the $FI reply - but a core subscriber + // has to be on it, or the server answers 503 no responders and the add fails on that + // instead, which would pass this test without ever reaching a timeout. + for (long millis : new long[]{0, -5000}) { + String subject = NUID.nextGlobalSequence(); + Subscription responder = nc.subscribe(subject); + FastPublisher fp = FastPublisher.builder().connection(nc).ackTimeout(millis).build(); + assertTimeoutPreemptively(Duration.ofSeconds(10), () -> { + FastPublishException e = assertThrows(FastPublishException.class, () -> fp.add(subject, data("1"))); + assertTrue(e.getMessage().contains("No response to the first message"), e.getMessage()); + }); + responder.unsubscribe(); + + // the first message is already on the wire and the batch never started, so the + // publisher is finished rather than left looking usable: a second add would append + // to a batch the server does not have + assertTrue(fp.isTerminal()); + assertEquals(EndReason.Abandoned, fp.getEndReason()); + assertThrows(FastPublishException.class, () -> fp.add(subject, data("2"))); + } + } + + @Test + public void testNoRespondersIsNotReadAsAnAck() throws Exception { + // publishing to a subject no stream captures leaves nobody to respond, and the server + // says so on the reply subject. A status is not an acknowledgement: reading it as one + // would end the batch as committed and report "Invalid JetStream ack", neither of which + // is what happened. + FastPublisher fp = builder().build(); + FastPublishException e = assertThrows(FastPublishException.class, + () -> fp.add(NUID.nextGlobalSequence(), data("1"))); + assertTrue(e.getMessage().contains("status"), e.getMessage()); + assertTrue(fp.isTerminal()); + assertEquals(EndReason.Abandoned, fp.getEndReason()); + } + + @Test + public void testConnectionRejectionIsChecked() throws Exception { + String subject = NUID.nextGlobalSequence(); + createStream(true, subject); + + // jnats rejects an invalid subject with an unchecked IllegalArgumentException, and an add + // that fails must fail the same way whatever rejected it + FastPublisher fp = builder().build(); + assertThrows(FastPublishException.class, () -> fp.add("has space", data("1"))); + + // nothing left the client, so the sequence is given back and the batch is still coherent: + // the next add is sequence 1 and the batch commits as a two message batch + assertEquals(0, fp.size()); + fp.add(subject, data("1")); + assertEquals(1, fp.size()); + assertEquals(2, fp.commit(subject, data("2")).getBatchSize()); + } + + /** + * The flow and gap tokens are stated to the server in the reply subject + * {@code ......$FI}, so a core subscriber can read back + * exactly what the builder decided. Counting from the end because the inbox contains dots. + */ + private static String replyToken(String reply, int fromEnd) { + String[] parts = reply.split("\\."); + return parts[parts.length - fromEnd]; + } + + private static Message publishOneAndCaptureReply(FastPublisher fp, String subject) throws Exception { + Subscription sub = nc.subscribe(subject); + fp.add(subject, data("1")); + Message m = sub.nextMessage(Duration.ofSeconds(2)); + assertNotNull(m, "the core subscriber should have seen the published message"); + sub.unsubscribe(); + return m; + } + + @Test + public void testMaxFlowStatedToServer() throws Exception { + String subject = NUID.nextGlobalSequence(); + createStream(true, subject); + + // less than 1 means "use the default", matching the Go implementation, rather than + // clamping to 1 which would silently be the slowest possible setting + FastPublisher zero = builder().maxFlow(0).build(); + assertEquals(Integer.toString(FastPublisher.DEFAULT_MAX_FLOW), + replyToken(publishOneAndCaptureReply(zero, subject).getReplyTo(), 5)); + zero.abandon(); + + FastPublisher explicit = builder().maxFlow(250).build(); + assertEquals("250", replyToken(publishOneAndCaptureReply(explicit, subject).getReplyTo(), 5)); + explicit.abandon(); + + // the server reads the token as a uint16, so it is capped rather than overflowing + FastPublisher huge = builder().maxFlow(999999).build(); + assertEquals(Integer.toString(FastPublisher.MAX_FLOW_CEILING), + replyToken(publishOneAndCaptureReply(huge, subject).getReplyTo(), 5)); + huge.abandon(); + } + + @Test + public void testGapModeStatedToServer() throws Exception { + String subject = NUID.nextGlobalSequence(); + createStream(true, subject); + + FastPublisher fail = builder().gapMode(GapMode.Fail).build(); + assertEquals("fail", replyToken(publishOneAndCaptureReply(fail, subject).getReplyTo(), 4)); + fail.abandon(); + + FastPublisher ok = builder().gapMode(GapMode.Ok).build(); + assertEquals("ok", replyToken(publishOneAndCaptureReply(ok, subject).getReplyTo(), 4)); + ok.abandon(); + + // null falls back to the safe default rather than throwing + assertEquals(GapMode.Fail, builder().gapMode(null).build().getGapMode()); + } + + /** + * The publisher's control channel is {@code ..>} while the reply subject it + * publishes with is {@code ......$FI}, so dropping the + * last five tokens of a captured reply gives a subject the publisher is subscribed to. + */ + private static String controlSubject(String reply) { + int cut = reply.length(); + for (int i = 0; i < 5; i++) { + cut = reply.lastIndexOf('.', cut - 1); + } + return reply.substring(0, cut); + } + + private static void injectGap(String control, long lastSeq, long seq) throws Exception { + nc.publish(control + ".gap", + data("{\"type\":\"gap\",\"last_seq\":" + lastSeq + ",\"seq\":" + seq + "}")); + nc.flush(Duration.ofSeconds(2)); + } + + private static void injectPubAck(String control, String stream, long seq, String batchId, long count) throws Exception { + // a control message with no "type" field is the terminal PublishAck + nc.publish(control + ".ack", + data("{\"stream\":\"" + stream + "\",\"seq\":" + seq + + ",\"batch\":\"" + batchId + "\",\"count\":" + count + "}")); + nc.flush(Duration.ofSeconds(2)); + } + + private static void injectError(String control, long seq, int errCode, String description) throws Exception { + nc.publish(control + ".err", + data("{\"type\":\"err\",\"seq\":" + seq + ",\"error\":{\"code\":400,\"err_code\":" + errCode + + ",\"description\":\"" + description + "\"}}")); + nc.flush(Duration.ofSeconds(2)); + } + + /** + * The control channel is read on the publisher's own dispatcher thread, so an injected + * message that ends the batch lands a moment after the publish that injected it. Tests that + * assert on what happens *after* the batch is known to be over have to wait for that, which + * is itself the new behavior: the publisher knows without being asked. + */ + private static void awaitTerminal(FastPublisher fp) throws Exception { + for (int i = 0; i < 200 && !fp.isTerminal(); i++) { + //noinspection BusyWait + Thread.sleep(5); + } + assertTrue(fp.isTerminal(), "the dispatcher should have classified the injected message"); + } + + /** + * Control messages are only read when the publisher publishes, so keep adding until the + * injected gap has been drained. One add is almost always enough. In {@link GapMode#Fail} + * that add throws rather than returning, because the drain at the front of add ends the + * batch before a sequence is spent on the message, so the throw is the expected outcome. + */ + private static void addUntilGapSeen(FastPublisher fp, String subject, long gaps) throws Exception { + for (int i = 0; i < 100 && fp.gapCount() < gaps; i++) { + try { + fp.add(subject, data("drain-" + i)); + } + catch (FastPublishException e) { + if (fp.isTerminal()) { + return; + } + throw e; + } + } + } + + /** + * Gap accounting, driven by gap frames published onto the publisher's own control channel + * rather than by a real dropped message, which needs a cluster. Parsing and accounting is + * all {@code gapCount()} and {@code getLastGap()} are, and that is what this covers. + */ + @Test + public void testGapAccounting() throws Exception { + String subject = NUID.nextGlobalSequence(); + String streamName = createStream(true, subject); + + AtomicLong delivered = new AtomicLong(); + FastPublisher ok = builder() + .gapMode(GapMode.Ok) + .listener(new FastPublishListener() { + @Override + public void onGap(FastFlowGap gap) { + delivered.incrementAndGet(); + } + }) + .build(); + + assertEquals(0, ok.gapCount()); + assertNull(ok.getLastGap()); + + String control = controlSubject(publishOneAndCaptureReply(ok, subject).getReplyTo()); + + injectGap(control, 10, 15); + addUntilGapSeen(ok, subject, 1); + assertEquals(1, ok.gapCount()); + assertNotNull(ok.getLastGap()); + assertEquals(10, ok.getLastGap().getLastSequence()); + assertEquals(15, ok.getLastGap().getSequence()); + + // Ok mode keeps going, so a second gap is counted and the later one replaces the first + injectGap(control, 40, 44); + addUntilGapSeen(ok, subject, 2); + assertEquals(2, ok.gapCount()); + assertEquals(40, ok.getLastGap().getLastSequence()); + assertEquals(44, ok.getLastGap().getSequence()); + assertEquals(2, delivered.get()); + assertFalse(ok.isTerminal()); + + // the injected gaps are invisible to the server, so the batch commits normally + PublishAck pa = ok.commit(subject, data("last")); + assertEquals(ok.size(), pa.getBatchSize()); + assertEquals(ok.size(), msgCount(streamName)); + + // Fail mode ends the batch on the first gap, so the count can never go past 1 + FastPublisher fail = builder().gapMode(GapMode.Fail).build(); + control = controlSubject(publishOneAndCaptureReply(fail, subject).getReplyTo()); + long sizeBeforeGap = fail.size(); + injectGap(control, 1, 3); + awaitTerminal(fail); + addUntilGapSeen(fail, subject, 1); + assertEquals(1, fail.gapCount()); + assertEquals(1, fail.getLastGap().getLastSequence()); + assertEquals(3, fail.getLastGap().getSequence()); + assertTrue(fail.isTerminal()); + + // the batch was already over before the add ran, so the gap cost no batch sequence and + // every later call is refused + assertEquals(sizeBeforeGap, fail.size()); + assertThrows(FastPublishException.class, () -> fail.add(subject, data("after"))); + fail.abandon(); + } + + @Test + public void testEndReasonSaysWhyTheBatchEnded() throws Exception { + String subject = NUID.nextGlobalSequence(); + createStream(true, subject); + + FastPublisher committed = builder().build(); + assertEquals(EndReason.Open, committed.getEndReason()); + committed.add(subject, data("1")); + assertEquals(EndReason.Open, committed.getEndReason()); + committed.commit(subject, data("2")); + assertEquals(EndReason.Committed, committed.getEndReason()); + + FastPublisher abandoned = builder().build(); + abandoned.add(subject, data("1")); + abandoned.abandon(); + assertEquals(EndReason.Abandoned, abandoned.getEndReason()); + + // close() is abandon(), so it reports the same ending + FastPublisher closed = builder().build(); + closed.add(subject, data("1")); + closed.close(); + assertEquals(EndReason.Abandoned, closed.getEndReason()); + + // a gap in Fail mode ends the batch on the server, not on the client's say so + FastPublisher gapped = builder().gapMode(GapMode.Fail).build(); + String control = controlSubject(publishOneAndCaptureReply(gapped, subject).getReplyTo()); + injectGap(control, 1, 3); + addUntilGapSeen(gapped, subject, 1); + assertEquals(EndReason.Gap, gapped.getEndReason()); + gapped.abandon(); + assertEquals(EndReason.Gap, gapped.getEndReason(), "abandon must not overwrite why it really ended"); + + // and a per message header check failure does the same in Fail mode + FastPublisher errored = builder().gapMode(GapMode.Fail).build(); + control = controlSubject(publishOneAndCaptureReply(errored, subject).getReplyTo()); + injectError(control, 2, 10071, "wrong last sequence: 1"); + awaitTerminal(errored); + + // the add drains the error first, which is what fires the listener and records it, and + // then refuses to publish into a batch that is over + assertThrows(FastPublishException.class, () -> errored.add(subject, data("after"))); + assertEquals(EndReason.Error, errored.getEndReason()); + assertNotNull(errored.getPendingError()); + assertEquals(10071, errored.getPendingError().getApiErrorCode()); + } + + @Test + public void testTheBatchIsKnownDeadWhileIdle() throws Exception { + String subject = NUID.nextGlobalSequence(); + createStream(true, subject); + + FastPublisher fp = builder().gapMode(GapMode.Fail).build(); + String control = controlSubject(publishOneAndCaptureReply(fp, subject).getReplyTo()); + assertFalse(fp.isTerminal()); + + // nothing is published from here on. The publisher's own dispatcher thread classifies the + // gap, so the application learns the batch is over without asking and without pinging. + injectGap(control, 1, 3); + awaitTerminal(fp); + assertEquals(EndReason.Gap, fp.getEndReason()); + + // the accounting deliberately did not happen there: counters and listener callbacks stay + // on the caller's thread, and run when it next looks + assertEquals(0, fp.gapCount()); + assertThrows(FastPublishException.class, () -> fp.add(subject, data("x"))); + assertEquals(1, fp.gapCount()); + assertNotNull(fp.getLastGap()); + } + + @Test + public void testTerminalAckOfAGapEndedBatchIsReachable() throws Exception { + String subject = NUID.nextGlobalSequence(); + String streamName = createStream(true, subject); + + FastPublisher fp = builder().gapMode(GapMode.Fail).build(); + String control = controlSubject(publishOneAndCaptureReply(fp, subject).getReplyTo()); + + // the server ends a Fail batch on a gap and then sends the batch's final PublishAck, + // which ADR-50 makes the only authoritative statement of what was persisted + injectGap(control, 1, 3); + injectPubAck(control, streamName, 7, fp.getBatchId(), 2); + addUntilGapSeen(fp, subject, 1); + assertEquals(EndReason.Gap, fp.getEndReason()); + + // committing a batch the server already ended cannot publish anything, but it must hand + // back that ack rather than refusing empty handed + FastPublishException e = assertThrows(FastPublishException.class, () -> fp.commit(subject, data("x"))); + assertNotNull(e.getPublishAck(), "the terminal ack must be collected, not discarded"); + assertEquals(2, e.getPublishAck().getBatchSize()); + assertEquals(streamName, e.getPublishAck().getStream()); + + // and collecting it does not relabel how the batch ended + assertEquals(EndReason.Gap, fp.getEndReason()); + } + + @Test + public void testTerminalAckAbsentWhenTheServerSendsNone() throws Exception { + String subject = NUID.nextGlobalSequence(); + createStream(true, subject); + + // same path, but nothing ever answers. ADR-50 makes these acks best effort, so the + // commit still fails and simply carries no ack rather than hanging or throwing twice. + FastPublisher fp = FastPublisher.builder().connection(nc).gapMode(GapMode.Fail).ackTimeout(500).build(); + String control = controlSubject(publishOneAndCaptureReply(fp, subject).getReplyTo()); + injectGap(control, 1, 3); + addUntilGapSeen(fp, subject, 1); + + FastPublishException e = assertThrows(FastPublishException.class, () -> fp.commit(subject, data("x"))); + assertNull(e.getPublishAck()); + assertEquals(EndReason.Gap, fp.getEndReason()); + } + + @Test + public void testAckCountIsValidatedExceptAfterAGap() throws Exception { + String subject = NUID.nextGlobalSequence(); + String streamName = createStream(true, subject); + + // on a clean batch the server's count must match the client's, so an ack claiming a + // different number is a failure rather than something to return + FastPublisher fp = builder().build(); + String control = controlSubject(publishOneAndCaptureReply(fp, subject).getReplyTo()); + fp.add(subject, data("2")); + injectPubAck(control, streamName, 9, fp.getBatchId(), 99); + FastPublishException e = assertThrows(FastPublishException.class, () -> fp.commit(subject, data("3"))); + assertTrue(e.getMessage().contains("99"), e.getMessage()); + + // after a gap the client's count is an upper bound rather than an equal, since the + // server received less than was sent, so the same mismatch must not be treated as one + FastPublisher gapped = builder().gapMode(GapMode.Fail).build(); + control = controlSubject(publishOneAndCaptureReply(gapped, subject).getReplyTo()); + injectGap(control, 1, 3); + injectPubAck(control, streamName, 9, gapped.getBatchId(), 1); + addUntilGapSeen(gapped, subject, 1); + FastPublishException gapEnd = assertThrows(FastPublishException.class, () -> gapped.commit(subject, data("x"))); + assertNotNull(gapEnd.getPublishAck(), "the terminal ack must survive, not be rejected for its count"); + assertEquals(1, gapEnd.getPublishAck().getBatchSize()); + } + + @Test + public void testPingUsesTheFirstSubject() throws Exception { + String first = NUID.nextGlobalSequence(); + String second = NUID.nextGlobalSequence(); + createStream(true, first, second); + + FastPublisher fp = builder().build(); + fp.add(first, data("1")); + fp.add(second, data("2")); + + // every other client pings the first subject of the batch, and so does this one + Subscription sub = nc.subscribe(first); + fp.ping(); + Message m = sub.nextMessage(Duration.ofSeconds(2)); + assertNotNull(m, "the ping should have gone to the first subject"); + sub.unsubscribe(); + + // and a batch with no messages has no subject to ping + FastPublisher empty = builder().build(); + FastPublishException e = assertThrows(FastPublishException.class, empty::ping); + assertTrue(e.getMessage().contains("no messages"), e.getMessage()); + } + + @Test + public void testOutstandingAcksClamped() { + // clamped into MIN..MAX rather than throwing + assertNotNull(builder().maxOutstandingAcks(0).build()); + assertNotNull(builder().maxOutstandingAcks(99).build()); + assertEquals(2, FastPublisher.DEFAULT_MAX_OUTSTANDING_ACKS); + assertEquals(3, FastPublisher.MAX_OUTSTANDING_ACKS); + } +} diff --git a/batch-publish/src/test/java/io/synadia/bp/ServerVersionGateTests.java b/batch-publish/src/test/java/io/synadia/bp/ServerVersionGateTests.java new file mode 100644 index 0000000..337e3b2 --- /dev/null +++ b/batch-publish/src/test/java/io/synadia/bp/ServerVersionGateTests.java @@ -0,0 +1,170 @@ +// Copyright (c) 2026 Synadia Communications Inc. All Rights Reserved. +// See LICENSE and NOTICE file for details. + +package io.synadia.bp; + +import io.nats.client.Connection; +import io.nats.client.Dispatcher; +import io.nats.client.api.ServerInfo; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Proxy; +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The publishers have different minimum server versions and each refuses to build against + * a server that is too old. A real server cannot cover this — the test server is always current, + * so the too-old branch would never execute. Connection is an interface and ServerInfo parses + * from the INFO json, so a proxy can claim to be any version. + */ +public class ServerVersionGateTests { + + private static Connection connectionReporting(String version) { + String infoJson = "{" + + "\"server_id\":\"TESTSERVERID\"," + + "\"server_name\":\"test\"," + + "\"version\":\"" + version + "\"," + + "\"proto\":1," + + "\"go\":\"go1.22\"," + + "\"host\":\"127.0.0.1\"," + + "\"port\":4222," + + "\"headers\":true," + + "\"max_payload\":1048576" + + "}"; + ServerInfo si = new ServerInfo(infoJson); + assertEquals(version, si.getVersion(), "the fake ServerInfo must actually report the version"); + + return (Connection) Proxy.newProxyInstance( + ServerVersionGateTests.class.getClassLoader(), + new Class[]{Connection.class}, + (proxy, method, args) -> { + switch (method.getName()) { + case "getServerInfo": + return si; + case "createInbox": + return "_INBOX.versiongatetest"; + case "subscribe": + return null; // never published on; build() is as far as these tests go + case "createDispatcher": + // the fast publishers subscribe their control channel at construction + return Proxy.newProxyInstance( + ServerVersionGateTests.class.getClassLoader(), + new Class[]{Dispatcher.class}, + (d, dm, da) -> { + switch (dm.getName()) { + case "toString": + return "FakeDispatcher"; + case "hashCode": + return System.identityHashCode(d); + case "equals": + return d == da[0]; + default: + return null; + } + }); + case "toString": + return "FakeConnection[" + version + "]"; + case "hashCode": + return System.identityHashCode(proxy); + case "equals": + return proxy == args[0]; + default: + return null; + } + }); + } + + // an explicit ackTimeout keeps build() from reaching for conn.getOptions() + private static final long ACK_TIMEOUT = 5000; + + // ---------------------------------------------------------------------------------- + // BatchPublisher - needs 2.12.0 + // ---------------------------------------------------------------------------------- + @Test + public void testBatchPublisherRejectsPre212() { + Connection old = connectionReporting("2.11.6"); + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> BatchPublisher.builder().connection(old).ackTimeout(ACK_TIMEOUT).build()); + assertTrue(e.getMessage().contains("2.12.0"), "message should name the required version: " + e.getMessage()); + } + + @Test + public void testBatchPublisherAllowsFrom212() { + assertNotNull(BatchPublisher.builder() + .connection(connectionReporting("2.12.0")).ackTimeout(ACK_TIMEOUT).build()); + assertNotNull(BatchPublisher.builder() + .connection(connectionReporting("2.13.5")).ackTimeout(ACK_TIMEOUT).build()); + } + + // ---------------------------------------------------------------------------------- + // EobBatchPublisher - needs 2.14.0, stricter than plain atomic + // ---------------------------------------------------------------------------------- + @Test + public void testEobBatchPublisherRejectsPre214() { + // 2.13.5 is new enough for an atomic batch but not for an EOB commit. This is the whole + // reason EOB is its own type: the batch is refused up front rather than after staging. + Connection old = connectionReporting("2.13.5"); + assertNotNull(BatchPublisher.builder().connection(old).ackTimeout(ACK_TIMEOUT).build(), + "2.13.5 must still be fine for a plain atomic batch"); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> EobBatchPublisher.builder().connection(old).ackTimeout(ACK_TIMEOUT).build()); + assertTrue(e.getMessage().contains("2.14.0"), "message should name the required version: " + e.getMessage()); + } + + @Test + public void testEobBatchPublisherAllowsFrom214() { + assertNotNull(EobBatchPublisher.builder() + .connection(connectionReporting("2.14.0")).ackTimeout(ACK_TIMEOUT).build()); + assertNotNull(EobBatchPublisher.builder() + .connection(connectionReporting("2.15.0")).ackTimeout(ACK_TIMEOUT).build()); + } + + // ---------------------------------------------------------------------------------- + // FastPublisher - needs 2.14.0 + // ---------------------------------------------------------------------------------- + @Test + public void testFastPublisherRejectsPre214() { + // there is no server side error to fall back on here: a pre 2.14 server treats the $FI + // reply subject as an ordinary reply and never answers, so without this gate the client + // would hang until ackTimeout. + Connection old = connectionReporting("2.13.5"); + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> FastPublisher.builder().connection(old).ackTimeout(ACK_TIMEOUT).build()); + assertTrue(e.getMessage().contains("2.14.0"), "message should name the required version: " + e.getMessage()); + } + + @Test + public void testFastPublisherAllowsFrom214() { + assertNotNull(FastPublisher.builder() + .connection(connectionReporting("2.14.0")).ackTimeout(ACK_TIMEOUT).build()); + } + + @Test + public void testEobFastPublisherGate() { + // both fast publishers share one gate, unlike the atomic pair whose minimums differ + Connection old = connectionReporting("2.13.5"); + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> EobFastPublisher.builder().connection(old).ackTimeout(ACK_TIMEOUT).build()); + assertTrue(e.getMessage().contains("2.14.0"), e.getMessage()); + + assertNotNull(EobFastPublisher.builder() + .connection(connectionReporting("2.14.0")).ackTimeout(ACK_TIMEOUT).build()); + } + + // ---------------------------------------------------------------------------------- + // the gate must not reject a dev or release candidate build of a good version + // ---------------------------------------------------------------------------------- + @Test + public void testPrereleaseVersionsAccepted() { + // the local test server reports 2.15.0-dev, so this shape has to keep working + assertNotNull(FastPublisher.builder() + .connection(connectionReporting("2.15.0-dev")).ackTimeout(ACK_TIMEOUT).build()); + assertNotNull(EobBatchPublisher.builder() + .connection(connectionReporting("2.15.0-dev")).ackTimeout(ACK_TIMEOUT).build()); + assertNotNull(EobFastPublisher.builder() + .connection(connectionReporting("2.15.0-dev")).ackTimeout(ACK_TIMEOUT).build()); + } +}