diff --git a/src/NATS.Client.JetStream/Models/ApiStats.cs b/src/NATS.Client.JetStream/Models/ApiStats.cs
index 261272e91..e555e67b9 100644
--- a/src/NATS.Client.JetStream/Models/ApiStats.cs
+++ b/src/NATS.Client.JetStream/Models/ApiStats.cs
@@ -2,6 +2,14 @@ namespace NATS.Client.JetStream.Models;
public record ApiStats
{
+ ///
+ /// Supported API level.
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("level")]
+ [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.Never)]
+ [System.ComponentModel.DataAnnotations.Range(0, int.MaxValue)]
+ public int Level { get; set; }
+
///
/// Total number of API requests received for this account
///
diff --git a/src/NATS.Client.KeyValueStore/INatsKVStore.cs b/src/NATS.Client.KeyValueStore/INatsKVStore.cs
index f98d3ec9d..566406c6c 100644
--- a/src/NATS.Client.KeyValueStore/INatsKVStore.cs
+++ b/src/NATS.Client.KeyValueStore/INatsKVStore.cs
@@ -26,18 +26,6 @@ public interface INatsKVStore
/// Revision number
ValueTask PutAsync(string key, T value, INatsSerialize? serializer = default, CancellationToken cancellationToken = default);
- ///
- /// Put a value into the bucket using the key
- ///
- /// Key of the entry
- /// Value of the entry
- /// Time to live for the entry (requires the to be set to true). For a key that should never expire, use the constant.
- /// Serializer to use for the message type.
- /// A used to cancel the API call.
- /// Serialized value type
- /// Revision number
- ValueTask PutAsync(string key, T value, TimeSpan ttl, INatsSerialize? serializer = default, CancellationToken cancellationToken = default);
-
///
/// Tries to put a value into the bucket using the key
///
@@ -52,21 +40,6 @@ public interface INatsKVStore
///
ValueTask> TryPutAsync(string key, T value, INatsSerialize? serializer = default, CancellationToken cancellationToken = default);
- ///
- /// Tries to put a value into the bucket using the key
- ///
- /// Key of the entry
- /// Value of the entry
- /// Time to live for the entry (requires the to be set to true). For a key that should never expire, use the constant. This feature is only available on NATS server v2.11 and later.
- /// Serializer to use for the message type.
- /// A used to cancel the API call.
- /// Serialized value type
- /// Revision number
- ///
- /// Use this method to avoid exceptions
- ///
- ValueTask> TryPutAsync(string key, T value, TimeSpan ttl, INatsSerialize? serializer = default, CancellationToken cancellationToken = default);
-
///
/// Create a new entry in the bucket only if it doesn't exist
///
@@ -83,7 +56,7 @@ public interface INatsKVStore
///
/// Key of the entry
/// Value of the entry
- /// Time to live for the entry (requires the to be set to true). For a key that should never expire, use the constant. This feature is only available on NATS server v2.11 and later.
+ /// Time to live for the entry (requires the to be set to true). For a key that should never expire, use the constant. This feature is only available on NATS server v2.11 and later.
/// Serializer to use for the message type.
/// A used to cancel the API call.
/// Serialized value type
@@ -109,7 +82,7 @@ public interface INatsKVStore
///
/// Key of the entry
/// Value of the entry
- /// Time to live for the entry (requires the to be set to true). For a key that should never expire, use the constant. This feature is only available on NATS server v2.11 and later.
+ /// Time to live for the entry (requires the to be set to true). For a key that should never expire, use the constant. This feature is only available on NATS server v2.11 and later.
/// Serializer to use for the message type.
/// A used to cancel the API call.
/// Serialized value type
@@ -131,19 +104,6 @@ public interface INatsKVStore
/// The revision number of the updated entry
ValueTask UpdateAsync(string key, T value, ulong revision, INatsSerialize? serializer = default, CancellationToken cancellationToken = default);
- ///
- /// Update an entry in the bucket only if last update revision matches
- ///
- /// Key of the entry
- /// Value of the entry
- /// Last revision number to match
- /// Time to live for the entry (requires the to be set to true). For a key that should never expire, use the constant. This feature is only available on NATS server v2.11 and later.
- /// Serializer to use for the message type.
- /// A used to cancel the API call.
- /// Serialized value type
- /// The revision number of the updated entry
- ValueTask UpdateAsync(string key, T value, ulong revision, TimeSpan ttl, INatsSerialize? serializer = default, CancellationToken cancellationToken = default);
-
///
/// Tries to update an entry in the bucket only if last update revision matches
///
@@ -159,22 +119,6 @@ public interface INatsKVStore
///
ValueTask> TryUpdateAsync(string key, T value, ulong revision, INatsSerialize? serializer = default, CancellationToken cancellationToken = default);
- ///
- /// Tries to update an entry in the bucket only if last update revision matches
- ///
- /// Key of the entry
- /// Value of the entry
- /// Last revision number to match
- /// Time to live for the entry (requires the to be set to true). For a key that should never expire, use the constant. This feature is only available on NATS server v2.11 and later.
- /// Serializer to use for the message type.
- /// A used to cancel the API call.
- /// Serialized value type
- /// A NatsResult object representing the revision number of the updated entry or an error.
- ///
- /// Use this method to avoid exceptions
- ///
- ValueTask> TryUpdateAsync(string key, T value, ulong revision, TimeSpan ttl, INatsSerialize? serializer = default, CancellationToken cancellationToken = default);
-
///
/// Delete an entry from the bucket
///
@@ -203,6 +147,15 @@ public interface INatsKVStore
/// A used to cancel the API call.
ValueTask PurgeAsync(string key, NatsKVDeleteOpts? opts = default, CancellationToken cancellationToken = default);
+ ///
+ /// Purge an entry from the bucket
+ ///
+ /// Key of the entry
+ /// Time to live for the purge marker (requires the to be set to true). For a key that should never expire, use the constant. This feature is only available on NATS server v2.11 and later.
+ /// Delete options
+ /// A used to cancel the API call.
+ ValueTask PurgeAsync(string key, TimeSpan ttl, NatsKVDeleteOpts? opts = default, CancellationToken cancellationToken = default);
+
///
/// Tries to purge an entry from the bucket
///
@@ -215,6 +168,19 @@ public interface INatsKVStore
///
ValueTask TryPurgeAsync(string key, NatsKVDeleteOpts? opts = default, CancellationToken cancellationToken = default);
+ ///
+ /// Tries to purge an entry from the bucket
+ ///
+ /// Key of the entry
+ /// Time to live for the purge marker (requires the to be set to true). For a key that should never expire, use the constant. This feature is only available on NATS server v2.11 and later.
+ /// Delete options
+ /// A used to cancel the API call.
+ /// A NatsResult object representing success or an error.
+ ///
+ /// Use this method to avoid exceptions
+ ///
+ ValueTask TryPurgeAsync(string key, TimeSpan ttl, NatsKVDeleteOpts? opts = default, CancellationToken cancellationToken = default);
+
///
/// Get an entry from the bucket using the key
///
diff --git a/src/NATS.Client.KeyValueStore/NatsKVConfig.cs b/src/NATS.Client.KeyValueStore/NatsKVConfig.cs
index 9cbb47508..61aac595a 100644
--- a/src/NATS.Client.KeyValueStore/NatsKVConfig.cs
+++ b/src/NATS.Client.KeyValueStore/NatsKVConfig.cs
@@ -84,16 +84,10 @@ public record NatsKVConfig
public IDictionary? Metadata { get; set; }
///
- /// If true, the bucket will allow TTL on individual keys.
+ /// How long the bucket keeps markers when keys are removed by the TTL setting, 0 meaning markers are not supported.
///
/// This feature is only available on NATS server v2.11 and later.
- public bool AllowMsgTTL { get; set; }
-
- ///
- /// Enables and sets a duration for adding server markers for delete, purge and max age limits.
- ///
- /// This feature is only available on NATS server v2.11 and later.
- public TimeSpan SubjectDeleteMarkerTTL { get; set; }
+ public TimeSpan LimitMarkerTTL { get; init; }
}
///
diff --git a/src/NATS.Client.KeyValueStore/NatsKVContext.cs b/src/NATS.Client.KeyValueStore/NatsKVContext.cs
index dd31b76b0..83712287d 100644
--- a/src/NATS.Client.KeyValueStore/NatsKVContext.cs
+++ b/src/NATS.Client.KeyValueStore/NatsKVContext.cs
@@ -58,6 +58,20 @@ public async ValueTask CreateStoreAsync(NatsKVConfig config, Cance
{
ValidateBucketName(config.Bucket);
+ if (config.LimitMarkerTTL < TimeSpan.Zero || (config.LimitMarkerTTL > TimeSpan.Zero && config.LimitMarkerTTL < TimeSpan.FromSeconds(1)))
+ {
+ throw new NatsKVException("Invalid LimitMarkerTTL");
+ }
+
+ if (config.LimitMarkerTTL > TimeSpan.Zero)
+ {
+ var info = await JetStreamContext.JSRequestResponseAsync
+ /// TTL
+ /// String representing the number of seconds Go time.ParseDuration() can understand.
+ private static string ToTTLString(TimeSpan ttl)
+ => ttl == TimeSpan.MaxValue ? "never" : $"{(int)ttl.TotalSeconds:D}s";
+
///
/// Valid keys are \A[-/_=\.a-zA-Z0-9]+\z, additionally they may not start or end in .
///
@@ -686,6 +641,106 @@ private static NatsResult TryValidateKey(string key)
[MethodImpl(MethodImplOptions.NoInlining)]
private static void ThrowException(Exception exception) => throw exception;
+
+ private async ValueTask> TryUpdateInternalAsync(string key, T value, ulong revision, TimeSpan ttl, INatsSerialize? serializer = default, CancellationToken cancellationToken = default)
+ {
+ var keyValidResult = TryValidateKey(key);
+ if (!keyValidResult.Success)
+ {
+ return keyValidResult.Error;
+ }
+
+ var headers = new NatsHeaders { { NatsExpectedLastSubjectSequence, revision.ToString() } };
+ if (ttl > TimeSpan.Zero)
+ {
+ headers.Add(NatsTTL, ToTTLString(ttl));
+ }
+
+ var publishResult = await JetStreamContext.TryPublishAsync(_kvBucket + key, value, headers: headers, serializer: serializer, cancellationToken: cancellationToken);
+ if (publishResult.Success)
+ {
+ var ack = publishResult.Value;
+ if (ack.Error is { ErrCode: 10071, Code: 400, Description: not null } && ack.Error.Description.StartsWith("wrong last sequence", StringComparison.OrdinalIgnoreCase))
+ {
+ return new NatsKVWrongLastRevisionException();
+ }
+ else if (ack.Error != null)
+ {
+ return new NatsJSApiException(ack.Error);
+ }
+ else if (ack.Duplicate)
+ {
+ return new NatsJSDuplicateMessageException(ack.Seq);
+ }
+
+ return ack.Seq;
+ }
+ else
+ {
+ return publishResult.Error;
+ }
+ }
+
+ private async ValueTask TryDeleteInternalAsync(string key, TimeSpan ttl, NatsKVDeleteOpts? opts = default, CancellationToken cancellationToken = default)
+ {
+ var keyValidResult = TryValidateKey(key);
+ if (!keyValidResult.Success)
+ {
+ return keyValidResult.Error;
+ }
+
+ opts ??= new NatsKVDeleteOpts();
+
+ var headers = new NatsHeaders();
+
+ if (opts.Purge)
+ {
+ headers.Add(KVOperation, OperationPurge);
+ headers.Add(NatsRollup, RollupSub);
+ if (ttl > TimeSpan.Zero)
+ {
+ if (!_supportsTTL)
+ {
+ return ThisStoreDoesNotSupportTTLException;
+ }
+
+ headers.Add(NatsTTL, ToTTLString(ttl));
+ }
+ }
+ else
+ {
+ headers.Add(KVOperation, OperationDel);
+ }
+
+ if (opts.Revision != default)
+ {
+ headers.Add(NatsExpectedLastSubjectSequence, opts.Revision.ToString());
+ }
+
+ var publishResult = await JetStreamContext.TryPublishAsync(_kvBucket + key, null, headers: headers, cancellationToken: cancellationToken);
+ if (publishResult.Success)
+ {
+ var ack = publishResult.Value;
+ if (ack.Error is { ErrCode: 10071, Code: 400, Description: not null } && ack.Error.Description.StartsWith("wrong last sequence", StringComparison.OrdinalIgnoreCase))
+ {
+ return new NatsKVWrongLastRevisionException();
+ }
+ else if (ack.Error != null)
+ {
+ return new NatsJSApiException(ack.Error);
+ }
+ else if (ack.Duplicate)
+ {
+ return new NatsJSDuplicateMessageException(ack.Seq);
+ }
+
+ return NatsResult.Default;
+ }
+ else
+ {
+ return publishResult.Error;
+ }
+ }
}
-public record NatsKVStatus(string Bucket, bool IsCompressed, StreamInfo Info);
+public record NatsKVStatus(string Bucket, bool IsCompressed, TimeSpan LimitMarkerTTL, StreamInfo Info);
diff --git a/tests/NATS.Client.KeyValueStore.Tests/KeyValueStoreTest.cs b/tests/NATS.Client.KeyValueStore.Tests/KeyValueStoreTest.cs
index a6620de43..c78e71305 100644
--- a/tests/NATS.Client.KeyValueStore.Tests/KeyValueStoreTest.cs
+++ b/tests/NATS.Client.KeyValueStore.Tests/KeyValueStoreTest.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics;
using NATS.Client.Core.Tests;
using NATS.Client.JetStream.Models;
using NATS.Client.Platform.Windows.Tests;
@@ -461,6 +462,23 @@ await Assert.ThrowsAsync(async () =>
}
}
+ [SkipIfNatsServer(versionLaterThan: "2.11")]
+ public async Task TestMessageTTLApiNotSupportedupport()
+ {
+ var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+ var cancellationToken = cts.Token;
+
+ await using var server = await NatsServerProcess.StartAsync();
+ await using var nats = new NatsConnection(new NatsOpts { Url = server.Url });
+
+ var js = new NatsJSContext(nats);
+ var kv = new NatsKVContext(js);
+
+ // Config validation
+ var exception = await Assert.ThrowsAsync(() => kv.CreateStoreAsync(new NatsKVConfig("kv1") { LimitMarkerTTL = TimeSpan.FromSeconds(10) }, cancellationToken: cancellationToken).AsTask());
+ _output.WriteLine(exception.Message);
+ }
+
[SkipIfNatsServer(versionEarlierThan: "2.11")]
public async Task TestMessageTTL()
{
@@ -473,24 +491,52 @@ public async Task TestMessageTTL()
var js = new NatsJSContext(nats);
var kv = new NatsKVContext(js);
- var store = await kv.CreateStoreAsync(new NatsKVConfig("kv1") { AllowMsgTTL = true }, cancellationToken: cancellationToken);
+ // Check TTL support
+ var exception = await Assert.ThrowsAsync(async () =>
+ {
+ var store1 = await kv.CreateStoreAsync(new NatsKVConfig("kv0") { LimitMarkerTTL = TimeSpan.Zero }, cancellationToken: cancellationToken);
+ await store1.CreateAsync("k1", "v1", ttl: TimeSpan.FromSeconds(60), cancellationToken: cancellationToken);
+ });
+ Assert.Equal("This store does not support TTL", exception.Message);
+
+ // Check API version
+ var info = await js.JSRequestResponseAsync("$JS.API.INFO", null, cancellationToken);
+ Assert.True(info.Api.Level >= 1);
+
+ // Config validation
+ await Assert.ThrowsAsync(() => kv.CreateStoreAsync(new NatsKVConfig("kv1") { LimitMarkerTTL = TimeSpan.FromSeconds(-1) }, cancellationToken: cancellationToken).AsTask());
+ await Assert.ThrowsAsync(() => kv.CreateStoreAsync(new NatsKVConfig("kv1") { LimitMarkerTTL = TimeSpan.FromSeconds(.99) }, cancellationToken: cancellationToken).AsTask());
+
+ var store = await kv.CreateStoreAsync(new NatsKVConfig("kv1") { LimitMarkerTTL = TimeSpan.FromSeconds(2) }, cancellationToken: cancellationToken);
for (var i = 0; i < 10; i++)
{
- await store.PutAsync($"k{i}", $"v{i}", TimeSpan.FromSeconds(1), cancellationToken: cancellationToken);
+ await store.CreateAsync($"k{i}", $"v{i}", TimeSpan.FromSeconds(2), cancellationToken: cancellationToken);
}
- var state = await store.GetStatusAsync();
+ var state = await store.GetStatusAsync(cancellationToken);
Assert.Equal(10, state.Info.State.Messages);
Assert.Equal(1ul, state.Info.State.FirstSeq);
Assert.Equal(10ul, state.Info.State.LastSeq);
- // Sleep for two seconds, now all the messages should be gone
- await Task.Delay(2000);
- state = await store.GetStatusAsync();
+ await Retry.Until(
+ reason: "messages are deleted",
+ condition: async () =>
+ {
+ var state1 = await store.GetStatusAsync(cancellationToken);
+ _output.WriteLine($"Messages: {state1.Info.State.Messages}");
+ _output.WriteLine($"FirstSeq: {state1.Info.State.FirstSeq}");
+ _output.WriteLine($"LastSeq: {state1.Info.State.LastSeq}");
+
+ return state1.Info.State is { Messages: 0, FirstSeq: 21, LastSeq: 20 };
+ },
+ retryDelay: TimeSpan.FromSeconds(2),
+ timeout: TimeSpan.FromSeconds(30));
+
+ state = await store.GetStatusAsync(cancellationToken);
Assert.Equal(0, state.Info.State.Messages);
- Assert.Equal(11ul, state.Info.State.FirstSeq);
- Assert.Equal(10ul, state.Info.State.LastSeq);
+ Assert.Equal(21ul, state.Info.State.FirstSeq);
+ Assert.Equal(20ul, state.Info.State.LastSeq);
}
[SkipIfNatsServer(versionEarlierThan: "2.11")]
@@ -505,9 +551,9 @@ public async Task TestTTLMessageWhenTTLDisabledOnStream()
var js = new NatsJSContext(nats);
var kv = new NatsKVContext(js);
- var store = await kv.CreateStoreAsync(new NatsKVConfig("kv1") { AllowMsgTTL = false }, cancellationToken: cancellationToken);
- var exception = await Assert.ThrowsAsync(async () => await store.PutAsync($"somekey", $"somevalue", TimeSpan.FromSeconds(1), cancellationToken: cancellationToken));
- Assert.Equal("per-message TTL is disabled", exception.Message);
+ var store = await kv.CreateStoreAsync(new NatsKVConfig("kv1") { LimitMarkerTTL = TimeSpan.Zero }, cancellationToken: cancellationToken);
+ var exception = await Assert.ThrowsAsync(async () => await store.CreateAsync($"somekey", $"somevalue", TimeSpan.FromSeconds(1), cancellationToken: cancellationToken));
+ Assert.Equal("This store does not support TTL", exception.Message);
}
[SkipIfNatsServer(versionEarlierThan: "2.11")]
@@ -522,7 +568,7 @@ public async Task SetsSubjectDeleteMarkerTTL()
var js = new NatsJSContext(nats);
var kv = new NatsKVContext(js);
- var store = await kv.CreateStoreAsync(new NatsKVConfig("kv1") { AllowMsgTTL = true, SubjectDeleteMarkerTTL = TimeSpan.FromSeconds(2) }, cancellationToken: cancellationToken);
+ var store = await kv.CreateStoreAsync(new NatsKVConfig("kv1") { LimitMarkerTTL = TimeSpan.FromSeconds(2) }, cancellationToken: cancellationToken);
var info = await js.GetStreamAsync("KV_kv1");
Assert.Equal(TimeSpan.FromSeconds(2), info.Info.Config.SubjectDeleteMarkerTTL);
}
@@ -542,8 +588,7 @@ public async Task SubjectDeleteMarkerTTL_enabled_removals_should_be_interpreted_
var store = await kv.CreateStoreAsync(
new NatsKVConfig("kv1")
{
- AllowMsgTTL = true,
- SubjectDeleteMarkerTTL = TimeSpan.FromHours(1),
+ LimitMarkerTTL = TimeSpan.FromHours(1),
MaxAge = TimeSpan.FromSeconds(4),
},
cancellationToken: cancellationToken);
@@ -589,27 +634,37 @@ public async Task TestMessageNeverExpire()
var js = new NatsJSContext(nats);
var kv = new NatsKVContext(js);
- var store = await kv.CreateStoreAsync(new NatsKVConfig("kv1") { AllowMsgTTL = true, MaxAge = TimeSpan.FromSeconds(1) }, cancellationToken: cancellationToken);
+ var store = await kv.CreateStoreAsync(new NatsKVConfig("kv1") { LimitMarkerTTL = TimeSpan.FromSeconds(2) }, cancellationToken: cancellationToken);
// The first message we publish is set to "never expire", therefore it won't age out with the MaxAge policy.
- await store.PutAsync($"k0", $"v0", TimeSpan.MaxValue, cancellationToken: cancellationToken);
+ await store.CreateAsync($"k0", $"v0", TimeSpan.MaxValue, cancellationToken: cancellationToken);
+
+ await Task.Delay(1000);
for (var i = 1; i < 11; i++)
{
- await store.PutAsync($"k{i}", $"v{i}", TimeSpan.FromSeconds(1), cancellationToken: cancellationToken);
+ await store.CreateAsync($"k{i}", $"v{i}", TimeSpan.FromSeconds(2), cancellationToken: cancellationToken);
}
- var state = await store.GetStatusAsync();
+ var state = await store.GetStatusAsync(cancellationToken);
Assert.Equal(11, state.Info.State.Messages);
Assert.Equal(1ul, state.Info.State.FirstSeq);
Assert.Equal(11ul, state.Info.State.LastSeq);
- // Sleep for two seconds, only the first message should be there
- await Task.Delay(2000);
- state = await store.GetStatusAsync();
+ await Retry.Until(
+ reason: "messages are deleted",
+ condition: async () =>
+ {
+ var state1 = await store.GetStatusAsync(cancellationToken);
+ return state1.Info.State is { Messages: 1, FirstSeq: 1, LastSeq: 21 };
+ },
+ retryDelay: TimeSpan.FromSeconds(2),
+ timeout: TimeSpan.FromSeconds(30));
+
+ state = await store.GetStatusAsync(cancellationToken);
Assert.Equal(1, state.Info.State.Messages);
Assert.Equal(1ul, state.Info.State.FirstSeq);
- Assert.Equal(11ul, state.Info.State.LastSeq);
+ Assert.Equal(21ul, state.Info.State.LastSeq);
}
[Fact]