Skip to content

ADR-50 batch publish: Java vs the open questions #56

Description

@scottf

ADR-50 defines two ways to publish a group of messages as one unit. Atomic batch publish (server 2.12) stages messages and stores all of them or none. Fast ingest publish (server 2.14) stores them as they arrive, with no atomicity, and gives you a control channel over which the server tells you how fast you are allowed to go. They share a vocabulary — a batch id, a batch sequence, a PublishAck carrying batch and count — but they are separate wire protocols. Both must support ending a batch without storing a final message, which the protocol calls EOB.

Seven clients implement one or both: C, Go, Java, JavaScript, .NET, Python and Rust. Reading all seven against the specification turned up 25 points where the ADR is silent and the clients have answered differently, or answered the same way without that answer being written down anywhere. Those 25 are on the table at nats-io/nats-architecture-and-design#430, behavior by behavior across all seven clients, so the ADR can be amended once. Answer them there; this is only what they mean for Java.

This document is the Java slice. It is in three parts:

  1. Work that is not waiting on anyone — behavior that no group decision can make correct.
  2. Positions a pending decision could change — where this client's answer is defensible and the group has to choose.
  3. Already aligned — what not to touch.

The numbers in part 2 are the section and question numbers from that comparison, so you can point at one in discussion. Every claim was read from source in September 2026 and cites the file and line it came from, so anything here can be checked or contradicted.

Source read. Read from orbit.java/batch-publish on branch bp-eob-fast — complete but not yet released — plus the nats.java working copy for jnats v2 core behavior.

1. Work that is not waiting on anyone

No decision the group reaches can make the behavior below correct.

1.1 A 503 on the atomic path is reported as a cancellation, or as a status exception, depending on the application's connection options

The server answers a message that carries a reply subject and reaches no interest with a 503 No Responders status message. For a batch the realistic cause is that the stream does not capture the subject being published to.

The fast publisher handles this: requireNotAStatus abandons the batch and names the status and the likely cause (AbstractFastPublisher.java:721-727). The atomic publishers do not, and cannot do it the same way. They use conn.requestWithTimeout, so jnats decides what a 503 becomes, and that is set per connection rather than per request (NatsConnection.java:1523-1533):

  • With the default CancelAction.CANCEL, the future is cancelled and the caller gets a CancellationException indistinguishable in type from a timeout. The 503 message never reaches the publisher, so testing isStatusMessage() would not help.
  • With Options.Builder.reportNoResponders(), it arrives as a JetStreamStatusException and the reason survives.

So which diagnosis a user gets depends on their connection options, not on anything the batch publisher chose. There is no public per-request CancelAction in jnats v2. Closing this means either publishing with a reply and owning the subscription, as the fast side already does, or documenting that reportNoResponders() is required to get a usable error. jnats v3 defaults every request to REPORT, so the problem does not carry forward.

2. Positions a pending decision could change

Each of these is a defensible answer today. The group has to choose one for everybody.

# Question What this client does What changes if it goes the other way
1.1 / 1.2 Should clients fail fast on the 1000 message batch size limit, and should the server make the limit discoverable? No guard. BatchUtils.getMaxBatchSize(Connection) hardcodes 1000 and takes a Connection so it can become a real lookup without an api break, but nothing calls it internally. Python and Rust hard-code 1000 and check it before the sequence increments. The number is configurable server-side (max_batch_size) and advertised nowhere a client can read it. The guard is not written and is waiting on this decision.
2.1 Should clients guard the restricted headers and fail fast? Yes, all three: expected-last-sequence after the first message, Nats-Expected-Last-Msg-Id, and the three protocol headers, rejected in both the options object and raw headers. Nats-Msg-Id is allowed. If the group decides not to guard, Java is over-strict — but it is the strictness that turns a lost batch into a thrown exception. Three of six clients let all three through.
3.1 Should the EOB operation be named commit, or commitEob where both commits live in one type? commit on two types: BatchPublisher.commit(subject, data) and EobBatchPublisher.commit(), and the same split on the fast side. If one type with commitEob wins, the four publisher types collapse to two and the version gate moves from build() to the commit call — so a 2.12 server stops being refused up front, because the type no longer states which feature it needs.
6.2 Should clients acknowledge the first message of a batch by default? ackFirst defaults to true. One line either way.
6.3 Should clients offer a per-message ack override? Yes, four addAcked(...) overloads. The per-message ack settings were removed from BatchPublishOptions, where they were accepted and never read. Java and JS are the only two that offer this. If the batch-wide schedule is enough, the overloads go.
8.2 Should out-of-range flow settings be clamped or rejected? Silently clamps both values. Three strategies coexist: silent clamp, hard error, accept anything. If "reject" wins, the builder throws instead — which turns a working configuration into a startup failure for anyone relying on the clamp.
9.2 Should ping be public api or an internal recovery mechanism? Public ping(). Five of seven keep it internal; only JS and Java expose it. Callers using it to refresh flow state without publishing lose that if it goes private.
11.2 After a gap, should ack validation be skipped, or relaxed? Skipped entirely once a gap has been reported. Python relaxes to an upper-bound check instead, which still catches a batch-id mismatch and an impossible count. That is the stricter of the two and is worth adopting if it wins.
11.3 Should the client's count be stored messages, or messages sent including the sentinel? Stored messages, sentinel excluded, in both families. The sentinel ships at wire sequence n+1 without advancing the counter. If "including the sentinel" wins, both size() and the validation change. .NET's and Rust's fast publishers count it today.
14.1 / 14.2 / 14.3 May a client treat a rejection by its own connection as proof nothing reached the server — and so give the batch sequence back rather than end the batch? Yes, and Java's whole design rests on it. A publish rejected by jnats for an invalid subject, a closed or draining connection, or a full reconnect buffer never reaches the outgoing queue, so the sequence is given back and the batch stays consistent without ever having to be ended (AbstractBatchPublisher.java:402-412, AbstractFastPublisher.java:530-543). This is the load-bearing question of the set for Java. A "no" removes the give-back, the NotSent marker type, and the argument for keeping a batch usable after a failed publish. Worth knowing for the argument: the distinction is not derivable from the exception type, because CancellationException extends IllegalStateException and means the opposite — the message probably did go out. Only Java and C give the number back at all.

3. Already aligned

Every client that was read does, or is converging on, the same thing here. Listed so it is clear what not to change.

  • Atomic EOB exists. Only Java and .NET have it in both families.
  • Acting on a reported gap before the next publish, and knowing a batch is dead while idle. A dispatcher of the publisher's own classifies control messages, so isTerminal() and getEndReason() are current between publishes with no ping() needed, while all accounting and listener callbacks stay on the publishing thread. Java is the only client that does the second half; the fully synchronous clients cannot, and the asynchronous ones record nothing terminal.
  • Surfacing the terminal ack of a gap-abandoned batch. Carried on the exception, FastPublishException.getPublishAck(). Only three of seven keep this ack.
  • The ack shape. ackFirst(boolean), ackEvery(int) with <1 meaning never, ackTimeout(long millis). Five of six clients with an atomic publisher arrived at these same three settings independently.
  • Batch id validation. At most 64 characters, printable ASCII 33..126, and none of *, > or ., in both families. Java is the only client that lets a user supply an id, so the only one that has to validate one.
  • Flow defaults. 100 initial flow, 2 outstanding acks. Six of seven agree on both.
  • The ping subject. The first message's subject, and a batch with no messages is refused rather than pinged. All seven clients ping the first subject.
  • Counting and validating both families. Only Java and Python do both sides.
  • Gap mode default. GapMode.Fail, as an enum rather than an inverted boolean.
  • A 503 on the fast control channel. Recognised, the batch abandoned, and the error names the status and the likely cause. Java is the only client that recognises it there.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions