feat: add jetstream publish preconditions and dedupe

This commit is contained in:
Joseph Doherty
2026-02-23 06:05:01 -05:00
parent d73e7e2f88
commit 6825839191
4 changed files with 61 additions and 4 deletions

View File

@@ -3,6 +3,7 @@ namespace NATS.Server.JetStream.Publish;
public sealed class JetStreamPublisher
{
private readonly StreamManager _streamManager;
private readonly PublishPreconditions _preconditions = new();
public JetStreamPublisher(StreamManager streamManager)
{
@@ -10,7 +11,20 @@ public sealed class JetStreamPublisher
}
public bool TryCapture(string subject, ReadOnlyMemory<byte> payload, out PubAck ack)
=> TryCapture(subject, payload, null, out ack);
public bool TryCapture(string subject, ReadOnlyMemory<byte> payload, string? msgId, out PubAck ack)
{
if (_preconditions.IsDuplicate(msgId, out var existingSequence))
{
ack = new PubAck
{
Seq = existingSequence,
ErrorCode = 10071,
};
return true;
}
var captured = _streamManager.Capture(subject, payload);
if (captured == null)
{
@@ -19,7 +33,7 @@ public sealed class JetStreamPublisher
}
ack = captured;
_preconditions.Record(msgId, ack.Seq);
return true;
}
}

View File

@@ -0,0 +1,25 @@
using System.Collections.Concurrent;
namespace NATS.Server.JetStream.Publish;
public sealed class PublishPreconditions
{
private readonly ConcurrentDictionary<string, ulong> _dedupe = new(StringComparer.Ordinal);
public bool IsDuplicate(string? msgId, out ulong existingSequence)
{
existingSequence = 0;
if (string.IsNullOrEmpty(msgId))
return false;
return _dedupe.TryGetValue(msgId, out existingSequence);
}
public void Record(string? msgId, ulong sequence)
{
if (string.IsNullOrEmpty(msgId))
return;
_dedupe[msgId] = sequence;
}
}

View File

@@ -35,12 +35,15 @@ internal sealed class JetStreamApiFixture : IAsyncDisposable
return fixture;
}
public Task<PubAck> PublishAndGetAckAsync(string subject, string payload)
public Task<PubAck> PublishAndGetAckAsync(string subject, string payload, string? msgId = null, bool expectError = false)
{
if (_publisher.TryCapture(subject, Encoding.UTF8.GetBytes(payload), out var ack))
if (_publisher.TryCapture(subject, Encoding.UTF8.GetBytes(payload), msgId, out var ack))
return Task.FromResult(ack);
return Task.FromResult(new PubAck { ErrorCode = 404 });
if (expectError)
return Task.FromResult(new PubAck { ErrorCode = 404 });
throw new InvalidOperationException($"No stream matched subject '{subject}'.");
}
public Task<JetStreamApiResponse> RequestLocalAsync(string subject, string payload)

View File

@@ -0,0 +1,15 @@
namespace NATS.Server.Tests;
public class JetStreamPublishPreconditionTests
{
[Fact]
public async Task Duplicate_msg_id_is_rejected_with_expected_error()
{
await using var fixture = await JetStreamApiFixture.StartWithStreamAsync("D", "d.*");
await fixture.PublishAndGetAckAsync("d.a", "x", msgId: "id-1");
var second = await fixture.PublishAndGetAckAsync("d.a", "x", msgId: "id-1", expectError: true);
second.ErrorCode.ShouldBe(10071);
}
}