Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .config/dotnet-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
"isRoot": true,
"tools": {
"dotnet-outdated-tool": {
"version": "4.6.8",
"version": "4.6.9",
"commands": [
"dotnet-outdated"
],
"rollForward": false
}
}
}
}
66 changes: 34 additions & 32 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,36 +24,38 @@ jobs:
env:
DOTNET_CLI_TELEMETRY_OPTOUT: 1
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
cache: true
cache-dependency-path: '**/packages.linux-x64.lock.json'
dotnet-version: '8.x'

- name: Build
run: dotnet build -p TreatWarningsAsErrors=true

- name: Check for BOMs
run: ./dev/check-bom.sh

- name: Check formatting
run: |
if dotnet format --no-restore --verify-no-changes; then
echo "formatting passed"
else
rc="$?"
echo "formatting failed; run 'dotnet format'" >&2
exit "$rc"
fi

- name: Unit Tests
run: dotnet test --no-build test/UnitTests/UnitTests.csproj

- name: Integration Tests
run: dotnet test --no-build test/IntegrationTests/IntegrationTests.csproj
- name: Checkout
uses: actions/checkout@v6

- name: Setup dotnet
uses: actions/setup-dotnet@v5
with:
cache: true
cache-dependency-path: '**/packages.linux-x64.lock.json'
dotnet-version: |
8.x
10.x

- name: Build
run: dotnet build -p TreatWarningsAsErrors=true

- name: Check for BOMs
run: ./dev/check-bom.sh

- name: Check formatting
run: |
if dotnet format --no-restore --verify-no-changes; then
echo "formatting passed"
else
rc="$?"
echo "formatting failed; run 'dotnet format'" >&2
exit "$rc"
fi

- name: Unit Tests
run: dotnet test --no-build test/UnitTests/UnitTests.csproj

- name: Integration Tests
run: dotnet test --no-build test/IntegrationTests/IntegrationTests.csproj
16 changes: 10 additions & 6 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,29 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6

- name: Setup .NET
uses: actions/setup-dotnet@v4
- name: Setup dotnet
uses: actions/setup-dotnet@v5
with:
dotnet-version: 8.x.x
cache: true
cache-dependency-path: '**/packages.linux-x64.lock.json'
dotnet-version: |
8.x
10.x

- name: Write strong name key file
run: |
set +x # Disable command echoing for security
# Base64 decode the strong name key and save to keys directory
echo "$STRONG_NAME_KEY" | base64 -d > keys/NatsDistributedCache.2025-05-12.snk
chmod 600 keys/NatsDistributedCache.2025-05-12.snk

# Verify using the Docker-based script
./dev/verify-snk.sh
env:
STRONG_NAME_KEY: ${{secrets.STRONG_NAME_KEY}}

- name: Pack
run: dotnet pack -c Release -p:version=${GITHUB_REF#refs/*/v} -o ./publish

Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ const string natsUrl = "nats://localhost:4222";
var builder = Host.CreateDefaultBuilder(args);
builder.ConfigureServices(services =>
{
services.AddNatsClient(natsBuilder => natsBuilder.ConfigureOptions(opts => opts with { Url = natsUrl }));
services.AddNatsClient(natsBuilder =>
natsBuilder.ConfigureOptions(optsBuilder => optsBuilder.Configure(opts =>
opts.Opts = opts.Opts with { Url = natsUrl })));
services.AddNatsHybridCache(options =>
{
options.BucketName = "cache";
Expand Down Expand Up @@ -99,7 +101,9 @@ const string natsUrl = "nats://localhost:4222";
var builder = Host.CreateDefaultBuilder(args);
builder.ConfigureServices(services =>
{
services.AddNatsClient(natsBuilder => natsBuilder.ConfigureOptions(opts => opts with { Url = natsUrl }));
services.AddNatsClient(natsBuilder =>
natsBuilder.ConfigureOptions(optsBuilder => optsBuilder.Configure(opts =>
opts.Opts = opts.Opts with { Url = natsUrl })));
services.AddNatsDistributedCache(options =>
{
options.BucketName = "cache";
Expand Down
2 changes: 1 addition & 1 deletion src/NatsDistributedCache/NatsCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ async Task UpdateEntryExpirationAsync(NatsKVEntry<CacheEntry> kvEntry)
{
// Use optimistic concurrency control with the last revision
// todo: remove cast after https://github.com/nats-io/nats.net/pull/852 is released
await ((NatsKVStore)kvStore).UpdateAsync(
await kvStore.UpdateWithTtlAsync(
encodedKey,
kvEntry.Value,
kvEntry.Revision,
Expand Down
8 changes: 4 additions & 4 deletions src/NatsDistributedCache/NatsDistributedCache.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.5" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.5" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.5" />
<PackageReference Include="NATS.Client.KeyValueStore" Version="2.6.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
<PackageReference Include="NATS.Client.KeyValueStore" Version="2.7.0" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" />
</ItemGroup>

Expand Down
151 changes: 138 additions & 13 deletions src/NatsDistributedCache/NatsExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,16 @@ namespace CodeCargo.Nats.DistributedCache;

public static class NatsExtensions
{
private const string NatsExpectedLastSubjectSequence = "Nats-Expected-Last-Subject-Sequence";
private const string NatsTtl = "Nats-TTL";
private static readonly Regex ValidKeyRegex = new(pattern: @"\A[-/_=\.a-zA-Z0-9]+\z", RegexOptions.Compiled);
private static readonly NatsKVException KeyCannotBeEmptyException = new("Key cannot be empty");
private static readonly NatsKVException KeyCannotStartOrEndWithPeriodException = new("Key cannot start or end with a period");
private static readonly NatsKVException KeyContainsInvalidCharactersException = new("Key contains invalid characters");

private static readonly NatsKVException KeyCannotStartOrEndWithPeriodException =
new("Key cannot start or end with a period");

private static readonly NatsKVException KeyContainsInvalidCharactersException =
new("Key contains invalid characters");

/// <summary>
/// Put a value into the bucket using the key
Expand All @@ -30,9 +35,15 @@ public static class NatsExtensions
/// and with history set to 1. Otherwise, the TTL behavior is undefined.
/// History is set to 1 by default, so you should be fine unless you changed it explicitly.
/// </remarks>
public static async ValueTask<ulong> PutWithTtlAsync<T>(this INatsKVStore store, string key, T value, TimeSpan ttl = default, INatsSerialize<T>? serializer = default, CancellationToken cancellationToken = default)
public static async ValueTask<ulong> PutWithTtlAsync<T>(
this INatsKVStore store,
string key,
T value,
TimeSpan ttl = default,
INatsSerialize<T>? serializer = default,
CancellationToken cancellationToken = default)
{
var result = await TryPutWithTtlAsync(store, key, value, ttl, serializer, cancellationToken);
var result = await store.TryPutWithTtlAsync(key, value, ttl, serializer, cancellationToken);
if (!result.Success)
{
ThrowException(result.Error);
Expand All @@ -57,7 +68,13 @@ public static async ValueTask<ulong> PutWithTtlAsync<T>(this INatsKVStore store,
/// and with history set to 1. Otherwise, the TTL behavior is undefined.
/// History is set to 1 by default, so you should be fine unless you changed it explicitly.
/// </remarks>
public static async ValueTask<NatsResult<ulong>> TryPutWithTtlAsync<T>(this INatsKVStore store, string key, T value, TimeSpan ttl = default, INatsSerialize<T>? serializer = default, CancellationToken cancellationToken = default)
public static async ValueTask<NatsResult<ulong>> TryPutWithTtlAsync<T>(
this INatsKVStore store,
string key,
T value,
TimeSpan ttl = default,
INatsSerialize<T>? serializer = default,
CancellationToken cancellationToken = default)
{
var keyValidResult = TryValidateKey(key);
if (!keyValidResult.Success)
Expand All @@ -68,31 +85,139 @@ public static async ValueTask<NatsResult<ulong>> TryPutWithTtlAsync<T>(this INat
NatsHeaders? headers = default;
if (ttl != default)
{
headers = new NatsHeaders
{
{ NatsTtl, ToTtlString(ttl) },
};
headers = new NatsHeaders { { NatsTtl, ToTtlString(ttl) }, };
}

var publishResult = await store.JetStreamContext.TryPublishAsync($"$KV.{store.Bucket}.{key}", value, serializer: serializer, headers: headers, cancellationToken: cancellationToken);
var publishResult = await store.JetStreamContext.TryPublishAsync(
$"$KV.{store.Bucket}.{key}",
value,
serializer: serializer,
headers: headers,
cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (publishResult.Success)
{
var ack = publishResult.Value;
if (ack.Error != null)
{
return new NatsJSApiException(ack.Error);
}
else if (ack.Duplicate)

if (ack.Duplicate)
{
return new NatsJSDuplicateMessageException(ack.Seq);
}

return ack.Seq;
}
else

return publishResult.Error;
}

/// <summary>
/// Update an entry in the bucket only if last update revision matches
/// </summary>
/// <param name="store">NATS key-value store instance</param>
/// <param name="key">Key of the entry</param>
/// <param name="value">Value of the entry</param>
/// <param name="revision">Last revision number to match</param>
/// <param name="ttl">Time to live for the entry (requires the <see cref="NatsKVConfig.LimitMarkerTTL"/> to be set to true). For a key that should never expire, use the <see cref="TimeSpan.MaxValue"/> constant. This feature is only available on NATS server v2.11 and later.</param>
/// <param name="serializer">Serializer to use for the message type.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> used to cancel the API call.</param>
/// <typeparam name="T">Serialized value type</typeparam>
/// <returns>The revision number of the updated entry</returns>
/// <remarks>
/// TTLs should only be used when the store is configured with a storage type that supports expiration,
/// and with history set to 1. Otherwise, the TTL behavior is undefined.
/// History is set to 1 by default, so you should be fine unless you changed it explicitly.
/// </remarks>
public static async ValueTask<ulong> UpdateWithTtlAsync<T>(
this INatsKVStore store,
string key,
T value,
ulong revision,
TimeSpan ttl,
INatsSerialize<T>? serializer = default,
CancellationToken cancellationToken = default)
{
var result = await store.TryUpdateWithTtlAsync(key, value, revision, ttl, serializer, cancellationToken);
if (!result.Success)
{
ThrowException(result.Error);
}

return result.Value;
}

/// <summary>
/// Tries to update an entry in the bucket only if last update revision matches
/// </summary>
/// <param name="store">NATS key-value store instance</param>
/// <param name="key">Key of the entry</param>
/// <param name="value">Value of the entry</param>
/// <param name="revision">Last revision number to match</param>
/// <param name="ttl">Time to live for the entry (requires the <see cref="NatsKVConfig.LimitMarkerTTL"/> to be set to true). For a key that should never expire, use the <see cref="TimeSpan.MaxValue"/> constant. This feature is only available on NATS server v2.11 and later.</param>
/// <param name="serializer">Serializer to use for the message type.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> used to cancel the API call.</param>
/// <typeparam name="T">Serialized value type</typeparam>
/// <returns>A NatsResult object representing the revision number of the updated entry or an error.</returns>
/// <remarks>
/// TTLs should only be used when the store is configured with a storage type that supports expiration,
/// and with history set to 1. Otherwise, the TTL behavior is undefined.
/// History is set to 1 by default, so you should be fine unless you changed it explicitly.
/// </remarks>
public static async ValueTask<NatsResult<ulong>> TryUpdateWithTtlAsync<T>(
this INatsKVStore store,
string key,
T value,
ulong revision,
TimeSpan ttl,
INatsSerialize<T>? 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 store.JetStreamContext.TryPublishAsync(
$"$KV.{store.Bucket}.{key}",
value,
headers: headers,
serializer: serializer,
cancellationToken: cancellationToken);
if (publishResult.Success)
{
return publishResult.Error;
var ack = publishResult.Value;
if (ack.Error is
{ ErrCode: 10071, Code: 400, Description: not null }
or { ErrCode: 10164, Code: 400, Description: not null }
&& ack.Error.Description.StartsWith("wrong last sequence", StringComparison.OrdinalIgnoreCase))
{
return new NatsKVWrongLastRevisionException(ack.Error);
}

if (ack.Error != null)
{
return new NatsJSApiException(ack.Error);
}

if (ack.Duplicate)
{
return new NatsJSDuplicateMessageException(ack.Seq);
}

return ack.Seq;
}

return publishResult.Error;
}

/// <summary>
Expand Down
Loading