Add fast ingest publishing and atomic EOB to batch-publish - #55
Conversation
Adds the two publishers ADR-50 defines that this module did not have. EobBatchPublisher ends an atomic batch without storing a final message, so a transaction does not have to hold a message back or invent a filler one to carry the commit. FastPublisher and EobFastPublisher implement fast ingest, with server driven flow control, gap modes and per message error reporting. Fast ingest reads its control channel on a dispatcher of the publisher's own, and that thread only classifies: whether the server has ended the batch. The flow accounting, the counters, the terminal ack and every listener callback stay on the thread that called add, commit or ping, in arrival order. So a batch the server abandons while the application is idle is known to be over immediately, without moving user callbacks onto a thread the application did not choose. Changes to what was already here: - Ack timeouts are milliseconds. The Duration overload is deprecated and converts. A Duration under a millisecond meant wait forever on one path and time out immediately on the other. - The per message ack settings are gone from BatchPublishOptions. They were accepted and never read; they belong to the batch and have always worked on the publisher's builder. - Connection level rejections arrive as BatchPublishException rather than escaping unchecked, and a send the connection refused before queueing gives its batch sequence back rather than leaving a hole in the batch. - An acknowledged add reports the server's error rather than only "Invalid ack returned from add with confirm". - commitAsync completes with the BatchPublishException itself rather than a RuntimeException wrapping it. - A publisher level message ttl now applies to messages published without per message options. It was silently ignored. - Four things the batch protocol does not allow are refused locally rather than costing the whole batch: an expected last sequence after the first message, the batch protocol headers in user headers, Nats-Expected-Last-Msg-Id, and a batch id that is not a single subject token. - The commit's PublishAck is checked against the client's own count and batch id, skipped on the fast side once a gap makes that count an upper bound. 66 tests, and the eight examples run against nats-server 2.15.0-dev. Claude-Session: https://claude.ai/code/session_01JxJVFXSHMaxAeCUXVUkFxk
|
Claude finished @scottf's task in 9m 19s —— View job Code Review
The implementation correctly covers the four publisher types ADR-50 defines, and the structural choices — dedicated dispatcher thread for terminal-state detection, BlockingQueue hand-off for happens-before, idempotent A few issues found: Medium — fast publisher re-committable after a timeout
Medium — first-reply timeout leaves publisher in non-terminal state When Low — stale comment in The Javadoc for Low — momentary
Nit — duplicate import
|
|
|
||
| import io.nats.client.*; | ||
| import io.nats.client.api.PublishAck; | ||
| import io.nats.client.api.PublishAck; |
There was a problem hiding this comment.
PublishAck is imported twice (lines 7 and 8). Duplicate import; remove line 8.
There was a problem hiding this comment.
Fixed in ae7f5ea. Mine, from adding validateAck — Java permits duplicate imports so nothing caught it.
| private void awaitFirstReply() throws FastPublishException { | ||
| Message m = nextMessage(); | ||
| if (m == null) { | ||
| throw new FastPublishException(batchId, | ||
| "No response to the first message of the batch. The server may not support fast ingest publish."); | ||
| } |
There was a problem hiding this comment.
When nextMessage() returns null (timeout — typically a server too old to support fast ingest), the exception propagates but terminal is never set. The publisher is left with batchSeq = 1 and firstSubject != null in a non-terminal state. A subsequent add() call will pass requireUsable() and send FAST_BATCH_OP_APPEND (since batchSeq != 0), which targets a batch that was never initialized on the server.
The fix is to mark the batch terminal before throwing:
private void awaitFirstReply() throws FastPublishException {
Message m = nextMessage();
if (m == null) {
end(EndReason.Abandoned); // prevent further use of this orphaned batch
throw new FastPublishException(batchId,
"No response to the first message of the batch. The server may not support fast ingest publish.");
}
process(m);
}Users who use try-with-resources are already protected, but the publisher's own state should not allow re-use after this failure.
There was a problem hiding this comment.
Fixed in ae7f5ea, going one step further than suggested: abandon() rather than end(EndReason.Abandoned), so the dispatcher and its thread are released too. If the first message was never answered the batch is unrecoverable, so there is no reason to keep the control channel alive.
Writing the test for this turned up a second problem you did not flag, and it was hiding this one. Publishing to a subject no stream captures makes the server answer 503 No Responders on the reply subject, and that status was being read as the terminal PublishAck — ending the batch as Committed and reporting Invalid JetStream ack. So the timeout path was not even reachable in that scenario. A status is now refused as itself, and the timeout test subscribes a core responder to the subject so it actually reaches a timeout.
| public PublishAck commit(@NonNull String subject, Headers userHeaders, byte[] data) throws FastPublishException { | ||
| requireCommittable(); | ||
| _send(subject, userHeaders, data, FAST_BATCH_OP_COMMIT); | ||
| return awaitPubAck(); | ||
| } |
There was a problem hiding this comment.
If awaitPubAck() times out (or throws for any reason), the batch is not marked terminal. requireCommittable() would pass on a subsequent call, and a second FAST_BATCH_OP_COMMIT message would be sent to the server for a batch that may already be committed or in an unknown state.
This is the same problem BatchPublisher.commit() solves with finally { markClosed(); }. The fix for the fast side is analogous — call end(EndReason.Abandoned) in a finally block:
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 {
// awaitPubAck() either ends Committed or throws; either way,
// a second commit would be invalid.
if (!isTerminal()) {
end(EndReason.Abandoned);
}
}
}(Same applies to EobFastPublisher.commit().)
There was a problem hiding this comment.
Fixed in ae7f5ea, via a shared abandonIfCommitDidNotFinish() in the base so both fast commits get it.
One narrowing worth recording: an acknowledgement carrying a server error already ended the batch, because awaitPubAck calls end(Committed) before constructing the PublishAck, so the JetStreamApiException is thrown from an already-terminal publisher. The hole was the timeout and interrupt paths only.
And retrying is not an option even in principle — a lost final ack cannot be recovered by pinging, because a committed batch is cleaned up server side and the ping comes back as an unknown batch id. So terminal is the only correct end state here, which is what the fix does.
| 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); | ||
| return awaitPubAck(); |
There was a problem hiding this comment.
Same issue as FastPublisher.commit(): if awaitPubAck() times out the batch is not marked terminal, allowing commit() to be called again and sending a second commit sentinel.
There was a problem hiding this comment.
Fixed in ae7f5ea, same shared helper as FastPublisher.commit().
| * Take only what is already queued, never waiting. The pending count comes first because it | ||
| * is an {@code AtomicLong} read while the take locks the queue, and on this path the queue | ||
| * is almost always empty; the null timeout then makes the no-wait property structural |
There was a problem hiding this comment.
Stale comment: "The pending count comes first because it is an AtomicLong read while the take locks the queue" — there is no pending count or AtomicLong in this class. The implementation is just a no-wait control.poll(), which is correct and self-explanatory. The comment should be updated or dropped.
There was a problem hiding this comment.
Fixed in ae7f5ea. Stale from the change that moved the control channel onto a dispatcher — the comment described the synchronous read it replaced. Rewritten to describe what the method now does and why the empty case is the common one: it runs twice per add, against a channel that is quiet by design at one acknowledgement per flow window.
| private void end(EndReason reason) { | ||
| terminal = true; | ||
| endReason.compareAndSet(EndReason.Open, reason); | ||
| } |
There was a problem hiding this comment.
Subtle ordering gap between terminal and endReason. The class Javadoc states "the reason is Open exactly while isTerminal() is false." Strictly, that invariant can be momentarily violated: the dispatcher writes terminal = true (volatile), then CAS's endReason. A publishing thread that reads terminal in the awaitFlowWindow() loop condition could see true and exit, then call getEndReason() before a drain() call delivers the queue message that provides the BlockingQueue happens-before edge carrying the CAS result — and see Open.
In practice this is harmless because:
drain()is always called next byadd(), and the queue hand-off establishes the required happens-before.- The publishers are documented as single-threaded.
But the Javadoc claim is not quite accurate. Consider either reversing the order (CAS first, then the volatile write) or qualifying the claim to note the brief window, so the contract is precise.
There was a problem hiding this comment.
Fixed in ae7f5ea, with one correction to the reasoning.
Swapping is right, and for the reason you give: a caller that sees isTerminal() true must never then see Open, because that is the pair it acts on, and the volatile write to terminal cannot be reordered before the compare-and-set.
But swapping does not make the javadoc claim precisely accurate — it moves the window to the other side, where the reason is set an instant before the batch reads as terminal. No ordering makes the biconditional true. So the claim was changed rather than preserved: the guarantee is now stated one-directionally, that once isTerminal() is true the reason is never Open. The reverse window is harmless and mildly useful, since serverEnded() noticing early only means the terminal PublishAck of a gap-abandoned batch is collected sooner.
Six review findings, all real. A fast publisher whose first message was never answered is now finished rather than left looking usable. The message is already on the wire and the batch never started, so the next add would have appended to a batch the server does not have. It gives up and releases the control channel, since nothing is coming. A commit whose acknowledgement never arrives now ends the batch too. The commit message is on the wire and only the server knows what happened to it, so 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 is answered as an unknown batch. An acknowledgement carrying a server error already ended the batch, so only the timeout and interrupt paths changed. end() now sets the reason before the terminal flag. Both are written there 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 javadoc claimed the two agree exactly, which no ordering makes true, and now states the guarantee that holds. Also: a duplicate PublishAck import, and a drain() comment still describing the synchronous read it had before the control channel became asynchronous. Writing the test for the first of these turned up one the review did not raise. The server answers 503 no responders on the reply subject when the subject a batch publishes to has no subscriber, which for a fast batch means the stream does not capture it. That status was being read as the terminal PublishAck, ending the batch as committed and reporting "Invalid JetStream ack" - neither of which is what happened. A status is now refused as itself, naming the likely cause. 67 tests, and the eight examples run against nats-server 2.15.0-dev. Claude-Session: https://claude.ai/code/session_01JxJVFXSHMaxAeCUXVUkFxk
…e javadocs The 1000 message batch limit had no single home in the module. BatchUtils gives it one, a public static getMaxBatchSize(Connection). The number is hardcoded and nothing enforces it, which is deliberate: the server's max_batch_size is configurable and advertised nowhere a client can read, so a local guard would reject work a larger server accepts. Taking a Connection means the function can become a real lookup if the server ever reports the limit, without breaking any caller. A public constant could not have been removed again. Three javadocs said the subject of the first message is one the stream captures "by construction". That is not what construction means, and the actual reason is simpler and worth stating: the stream has already taken a message on that subject. It is where both the EOB sentinel and the ping go. Javadoc and compile clean. Claude-Session: https://claude.ai/code/session_01JxJVFXSHMaxAeCUXVUkFxk
Adds the two publishers ADR-50 defines that this module did not have, and makes a pass over what was already here.
What is new
EobBatchPublisherends an atomic batch without storing a final message, so a transaction does not have to hold a message back, or invent a filler one, just to carry the commit. The server discards the sentinel and reports a batch size that excludes it.FastPublisherandEobFastPublisherimplement fast ingest: server driven flow control,GapMode.Ok/GapMode.Fail, per message error reporting, andping. Not atomic — messages persist as they arrive, and you choose up front what a gap means to you.The fast control channel is read on a dispatcher of the publisher's own, and that thread does one thing: decide whether the server has ended the batch, and record it. The flow accounting, the counters, the terminal ack and every listener callback stay on the thread that called
add,commitorping, in arrival order. So a batch the server abandons while the application is idle is known to be over immediately, throughisTerminal()andgetEndReason(), without moving user callbacks onto a thread the application did not choose.When a gap or a per message error ends a
Failbatch, the server sends a finalPublishAcksaying how far it actually got. That ack is the only authoritative record of what was stored — ADR-50 says a gap report explicitly is not — so committing such a batch throws carrying it:catch (FastPublishException e) { e.getPublishAck(); }.Changes to what was already here
Version is
0.x, so these are breaking where noted.ackTimeout(long)on both builders; theDurationoverload is deprecated and converts, so existing code still compiles and links. ADurationunder a millisecond used to mean wait forever on one path and time out immediately on the other.BatchPublishOptions— breaking. They were accepted and never read. They belong to the batch and have always worked on the publisher's builder.BatchPublishExceptionrather than escapingaddunchecked, and a send the connection refused before queueing gives its batch sequence back rather than leaving a hole for the server to reject.addreports the server's error rather than onlyInvalid ack returned from add with confirm, soatomic publish is disabledno longer gets dropped on the floor.commitAsynccompletes with theBatchPublishExceptionitself rather than aRuntimeExceptionwrapping it — breaking for anyone unwrapping two layers.BatchPublishOptions.Nats-Expected-Last-Msg-Id, and a batch id that is not a single subject token.PublishAckis checked against the client's own count and batch id, skipped on the fast side once a gap makes that count an upper bound rather than an equal.Verification
66 tests, and all eight examples run end to end against
nats-server v2.15.0-dev. Javadoc is clean.