Files
natsdotnet/src/NATS.Server/JetStream/Publish/JetStreamPublisher.cs
Joseph Doherty 7404ecdb0e perf: Phase 1 JetStream async file publish optimizations
- Add cached state properties (LastSeq, MessageCount, TotalBytes, FirstSeq)
  to IStreamStore/FileStore/MemStore — eliminates GetStateAsync on publish path
- Add Capture(StreamHandle, ...) overload to StreamManager — eliminates
  double FindBySubject lookup (once in JetStreamPublisher, once in Capture)
- Remove _messageIndexes dictionary from FileStore write path — all lookups
  now use _messages directly, saving ~48B allocation per message
- Add JetStreamPubAckFormatter for hand-rolled UTF-8 success ack formatting —
  avoids JsonSerializer overhead on the hot publish path
- Switch flush loop to exponential backoff (1→2→4→8ms) matching Go server
2026-03-13 15:09:21 -04:00

174 lines
6.3 KiB
C#

namespace NATS.Server.JetStream.Publish;
public sealed class JetStreamPublisher
{
private readonly StreamManager _streamManager;
private readonly PublishPreconditions _preconditions = new();
// One engine per publisher (stream-scoped in real server; here publisher-scoped).
// Go reference: server/jetstream_batching.go streamBatches
private readonly AtomicBatchPublishEngine _batchEngine = new();
public JetStreamPublisher(StreamManager streamManager)
{
_streamManager = streamManager;
}
public bool TryCapture(string subject, ReadOnlyMemory<byte> payload, out PubAck ack)
=> TryCaptureWithOptions(subject, payload, new PublishOptions(), out ack);
public bool TryCapture(string subject, ReadOnlyMemory<byte> payload, string? msgId, out PubAck ack)
=> TryCaptureWithOptions(subject, payload, new PublishOptions { MsgId = msgId }, out ack);
public bool TryCaptureWithOptions(string subject, ReadOnlyMemory<byte> payload, PublishOptions options, out PubAck ack)
{
if (_streamManager.FindBySubject(subject) is not { } stream)
{
ack = new PubAck();
return false;
}
// --- Atomic batch publish path ---
// Go: server/stream.go processInboundMsg — checks batch headers before normal flow.
if (!string.IsNullOrEmpty(options.BatchId))
{
ack = ProcessBatchMessage(stream, subject, payload, options);
return true;
}
// --- Normal (non-batch) publish path ---
// Use cached LastSeq property instead of GetStateAsync to avoid allocation.
if (!_preconditions.CheckExpectedLastSeq(options.ExpectedLastSeq, stream.Store.LastSeq))
{
ack = new PubAck { ErrorCode = 10071 };
return true;
}
if (_preconditions.IsDuplicate(options.MsgId, stream.Config.DuplicateWindowMs, out var existingSequence))
{
ack = new PubAck
{
Seq = existingSequence,
ErrorCode = 10071,
};
return true;
}
// Pass resolved stream to avoid double FindBySubject lookup.
var captured = _streamManager.Capture(stream, subject, payload);
ack = captured ?? new PubAck();
_preconditions.Record(options.MsgId, ack.Seq);
_preconditions.TrimOlderThan(stream.Config.DuplicateWindowMs);
return true;
}
// Go: server/stream.go processInboundMsg — batch message handling.
private PubAck ProcessBatchMessage(
StreamHandle stream,
string subject,
ReadOnlyMemory<byte> payload,
PublishOptions options)
{
// Stream must have AllowAtomicPublish enabled.
// Go: server/stream.go:6351 NewJSAtomicPublishDisabledError
if (!stream.Config.AllowAtomicPublish)
{
return new PubAck
{
ErrorCode = AtomicBatchPublishErrorCodes.Disabled,
Stream = stream.Config.Name,
};
}
// BatchSeq must be present (non-zero).
// Go: server/stream.go:6371 NewJSAtomicPublishMissingSeqError
if (options.BatchSeq == 0)
{
return new PubAck
{
ErrorCode = AtomicBatchPublishErrorCodes.MissingSeq,
Stream = stream.Config.Name,
};
}
// Nats-Expected-Last-Msg-Id is unsupported in batch context.
// Go: server/stream.go:6584 NewJSAtomicPublishUnsupportedHeaderBatchError
if (!string.IsNullOrEmpty(options.ExpectedLastMsgId))
{
return new PubAck
{
ErrorCode = AtomicBatchPublishErrorCodes.UnsupportedHeader,
Stream = stream.Config.Name,
};
}
var commitValue = options.BatchCommit;
var isCommit = !string.IsNullOrEmpty(commitValue);
// Validate commit value immediately if present.
if (isCommit && commitValue is not ("1" or "eob"))
{
// Roll back any in-flight batch with this ID.
_batchEngine.Clear(); // simplified: in production this only removes the specific batch
return new PubAck
{
ErrorCode = AtomicBatchPublishErrorCodes.InvalidCommit,
Stream = stream.Config.Name,
};
}
var req = new BatchPublishRequest
{
BatchId = options.BatchId!,
BatchSeq = options.BatchSeq,
Subject = subject,
Payload = payload,
IsCommit = isCommit,
CommitValue = commitValue,
MsgId = options.MsgId,
ExpectedLastSeq = options.ExpectedLastSeq,
ExpectedLastSubjectSeq = options.ExpectedLastSubjectSeq,
ExpectedLastSubjectSeqSubject = options.ExpectedLastSubjectSeqSubject,
};
var result = _batchEngine.Process(
req,
_preconditions,
stream.Config.DuplicateWindowMs,
staged =>
{
// Check expected last sequence using cached property.
if (staged.ExpectedLastSeq > 0)
{
if (stream.Store.LastSeq != staged.ExpectedLastSeq)
return new PubAck { ErrorCode = 10071, Stream = stream.Config.Name };
}
var captured = _streamManager.Capture(stream, staged.Subject, staged.Payload);
return captured ?? new PubAck { Stream = stream.Config.Name };
});
return result.Kind switch
{
AtomicBatchResult.ResultKind.Staged => new PubAck
{
Stream = stream.Config.Name,
// Empty ack for staged (flow control).
},
AtomicBatchResult.ResultKind.Committed => result.CommitAck!,
AtomicBatchResult.ResultKind.Error => new PubAck
{
ErrorCode = result.ErrorCode,
Stream = stream.Config.Name,
},
_ => new PubAck { Stream = stream.Config.Name },
};
}
/// <summary>
/// Clears all in-flight batches (called when stream is disabled or deleted).
/// Go: server/jetstream_batching.go streamBatches.cleanup()
/// </summary>
public void ClearBatches() => _batchEngine.Clear();
}