From 4841bfec47763d243d688208ad58b1682c991c32 Mon Sep 17 00:00:00 2001 From: Tobias Netskar Date: Fri, 13 Mar 2026 13:15:25 +0100 Subject: [PATCH 1/2] Change InstanceLocker from scoped to singleton service --- .../Extensions/ServiceCollectionExtensions.cs | 4 +- .../Telemetry/Telemetry.InstanceLockClient.cs | 10 + .../Clients/Storage/InstanceLockClient.cs | 47 ++- .../Internal/InstanceLocking/IInstanceLock.cs | 8 + .../InstanceLocking/IInstanceLocker.cs | 10 +- .../InstanceLocking/InstanceLocker.cs | 133 ++++++--- .../Internal/Process/ProcessEngine.cs | 14 +- .../Mocks/InstanceLockerMock.cs | 19 +- test/Altinn.App.Api.Tests/Program.cs | 2 +- ...y_ThrowsPlatformHttpException.verified.txt | 5 +- ...s.Init_ThenLock_WithCustomTtl.verified.txt | 3 + ...rowsInvalidOperationException.verified.txt | 5 +- ...y_ThrowsPlatformHttpException.verified.txt | 5 +- ...on_storageStatusCode=Conflict.verified.txt | 5 +- ...tatusCode=InternalServerError.verified.txt | 5 +- ...on_storageStatusCode=NotFound.verified.txt | 5 +- .../InstanceLocking/InstanceLockTests.cs | 271 +++++++++++++----- .../Internal/Process/ProcessEngineTest.cs | 3 +- 18 files changed, 377 insertions(+), 177 deletions(-) create mode 100644 src/Altinn.App.Core/Internal/InstanceLocking/IInstanceLock.cs create mode 100644 test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.Init_ThenLock_WithCustomTtl.verified.txt diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index 1d981eff2e..cb05d968f6 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -116,7 +116,7 @@ IWebHostEnvironment env services.AddHttpClient(); #pragma warning restore CS0618 // Type or member is obsolete services.AddHttpClient(); - services.AddHttpClient(); + services.AddSingleton(); services.AddHttpClient(); services.AddHttpClient(); @@ -370,7 +370,7 @@ private static void AddProcessServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); - services.AddScoped(); + services.AddSingleton(); // Process tasks services.AddTransient(); diff --git a/src/Altinn.App.Core/Features/Telemetry/Telemetry.InstanceLockClient.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.InstanceLockClient.cs index e3c7ff606f..876506049d 100644 --- a/src/Altinn.App.Core/Features/Telemetry/Telemetry.InstanceLockClient.cs +++ b/src/Altinn.App.Core/Features/Telemetry/Telemetry.InstanceLockClient.cs @@ -21,4 +21,14 @@ partial class Telemetry return activity; } + + internal Activity? StartUpdateInstanceLockActivity(Guid instanceGuid, int instanceOwnerPartyId, TimeSpan ttl) + { + var activity = ActivitySource.StartActivity("UpdateInstanceLock"); + activity?.SetInstanceId(instanceGuid); + activity?.SetInstanceOwnerPartyId(instanceOwnerPartyId); + activity?.SetTag("lock.ttl_seconds", (int)ttl.TotalSeconds); + + return activity; + } } diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs index 127921e579..03294e3d0e 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs @@ -8,37 +8,29 @@ using Altinn.App.Core.Helpers; using Altinn.App.Core.Internal.Auth; using Altinn.Platform.Storage.Interface.Models; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Altinn.App.Core.Infrastructure.Clients.Storage; -internal sealed class InstanceLockClient +internal sealed class InstanceLockClient( + IOptionsMonitor _platformSettings, + IAuthenticationTokenResolver _authenticationTokenResolver, + IHttpClientFactory _httpClientFactory, + Telemetry? _telemetry = null +) { - private readonly ILogger _logger; - private readonly HttpClient _client; - private readonly Telemetry? _telemetry; - private readonly IAuthenticationTokenResolver _authenticationTokenResolver; - private readonly AuthenticationMethod _defaultAuthenticationMethod = StorageAuthenticationMethod.CurrentUser(); private const string LockTokenHeaderName = "Altinn-Storage-Lock-Token"; - public InstanceLockClient( - IOptions platformSettings, - ILogger logger, - IAuthenticationTokenResolver authenticationTokenResolver, - HttpClient httpClient, - Telemetry? telemetry = null - ) + private HttpClient CreateHttpClient() { - _logger = logger; - _authenticationTokenResolver = authenticationTokenResolver; - httpClient.BaseAddress = new Uri(platformSettings.Value.ApiStorageEndpoint); - httpClient.DefaultRequestHeaders.Add(General.SubscriptionKeyHeaderName, platformSettings.Value.SubscriptionKey); + var settings = _platformSettings.CurrentValue; + var httpClient = _httpClientFactory.CreateClient(); + httpClient.BaseAddress = new Uri(settings.ApiStorageEndpoint); + httpClient.DefaultRequestHeaders.Add(General.SubscriptionKeyHeaderName, settings.SubscriptionKey); httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - _client = httpClient; - _telemetry = telemetry; + return httpClient; } public async Task AcquireInstanceLock( @@ -60,7 +52,8 @@ public async Task AcquireInstanceLock( var request = new InstanceLockRequest { TtlSeconds = (int)expiration.TotalSeconds }; var content = JsonContent.Create(request); - using var response = await _client.PostAsync(token, apiUrl, content, cancellationToken: cancellationToken); + using var client = CreateHttpClient(); + using var response = await client.PostAsync(token, apiUrl, content, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) { @@ -77,7 +70,7 @@ public async Task AcquireInstanceLock( } catch (Exception e) when (e is JsonException || e is InvalidOperationException) { - _logger.LogError(e, "Error reading response from the lock acquisition endpoint."); + activity?.Errored(e, "Error reading response from the lock acquisition endpoint."); } if (string.IsNullOrEmpty(lockToken)) @@ -91,17 +84,18 @@ public async Task AcquireInstanceLock( return lockToken; } - public async Task ReleaseInstanceLock( + public async Task UpdateInstanceLock( Guid instanceGuid, int instanceOwnerPartyId, string lockToken, + TimeSpan ttl, StorageAuthenticationMethod? authenticationMethod = null, CancellationToken cancellationToken = default ) { - using var activity = _telemetry?.StartReleaseInstanceLockActivity(instanceGuid, instanceOwnerPartyId); + using var activity = _telemetry?.StartUpdateInstanceLockActivity(instanceGuid, instanceOwnerPartyId, ttl); string apiUrl = $"instances/{instanceOwnerPartyId}/{instanceGuid}/lock"; - var instanceLockRequest = new InstanceLockRequest { TtlSeconds = 0 }; + var instanceLockRequest = new InstanceLockRequest { TtlSeconds = (int)ttl.TotalSeconds }; var userToken = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, @@ -113,7 +107,8 @@ public async Task ReleaseInstanceLock( request.Headers.Authorization = new AuthenticationHeaderValue(AuthorizationSchemes.Bearer, userToken); request.Headers.Add(LockTokenHeaderName, lockToken); - using var response = await _client.SendAsync(request, cancellationToken); + using var client = CreateHttpClient(); + using var response = await client.SendAsync(request, cancellationToken); if (!response.IsSuccessStatusCode) { diff --git a/src/Altinn.App.Core/Internal/InstanceLocking/IInstanceLock.cs b/src/Altinn.App.Core/Internal/InstanceLocking/IInstanceLock.cs new file mode 100644 index 0000000000..8bdc029deb --- /dev/null +++ b/src/Altinn.App.Core/Internal/InstanceLocking/IInstanceLock.cs @@ -0,0 +1,8 @@ +namespace Altinn.App.Core.Internal.InstanceLocking; + +internal interface IInstanceLock : IAsyncDisposable +{ + Task Lock(TimeSpan? ttl = null); + + Task UpdateTtl(TimeSpan ttl); +} diff --git a/src/Altinn.App.Core/Internal/InstanceLocking/IInstanceLocker.cs b/src/Altinn.App.Core/Internal/InstanceLocking/IInstanceLocker.cs index 6c7c814bb9..02d50169ff 100644 --- a/src/Altinn.App.Core/Internal/InstanceLocking/IInstanceLocker.cs +++ b/src/Altinn.App.Core/Internal/InstanceLocking/IInstanceLocker.cs @@ -1,8 +1,12 @@ namespace Altinn.App.Core.Internal.InstanceLocking; -internal interface IInstanceLocker : IAsyncDisposable +internal interface IInstanceLocker { - ValueTask LockAsync(); + IInstanceLock InitLock(); - ValueTask LockAsync(TimeSpan ttl); + Task Lock(); + + Task Lock(TimeSpan ttl); + + string? CurrentLockToken { get; } } diff --git a/src/Altinn.App.Core/Internal/InstanceLocking/InstanceLocker.cs b/src/Altinn.App.Core/Internal/InstanceLocking/InstanceLocker.cs index 4be57623c9..0f277e4ea5 100644 --- a/src/Altinn.App.Core/Internal/InstanceLocking/InstanceLocker.cs +++ b/src/Altinn.App.Core/Internal/InstanceLocking/InstanceLocker.cs @@ -1,45 +1,66 @@ +using Altinn.App.Core.Features; using Altinn.App.Core.Infrastructure.Clients.Storage; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; namespace Altinn.App.Core.Internal.InstanceLocking; -internal sealed partial class InstanceLocker( +internal sealed class InstanceLocker( InstanceLockClient client, - ILogger logger, - IHttpContextAccessor httpContextAccessor + IHttpContextAccessor httpContextAccessor, + Telemetry? telemetry = null ) : IInstanceLocker { - private readonly HttpContext _httpContext = - httpContextAccessor.HttpContext ?? throw new InvalidOperationException("HttpContext cannot be null."); + private static readonly AsyncLocal _currentLock = new(); - private InstanceLock? _lock; - - public ValueTask LockAsync() + public IInstanceLock InitLock() { - return LockAsync(TimeSpan.FromMinutes(5)); - } + var httpContext = + httpContextAccessor.HttpContext ?? throw new InvalidOperationException("HttpContext cannot be null."); - public async ValueTask LockAsync(TimeSpan ttl) - { - if (_lock is not null) + var (instanceOwnerPartyId, instanceGuid) = + GetInstanceIdentifiers(httpContext) + ?? throw new InvalidOperationException("Unable to extract instance identifiers."); + + var holder = _currentLock.Value; + if (holder?.LockToken is not null) { - return; + throw new InvalidOperationException( + "A lock is already held in the current async context. Use UpdateTtl on the existing lock to extend it." + ); } - var (instanceOwnerPartyId, instanceGuid) = - GetInstanceIdentifiers() ?? throw new InvalidOperationException("Unable to extract instance identifiers."); + if (holder is null) + { + holder = new InstanceLockHolder(); + _currentLock.Value = holder; + } - var lockToken = await client.AcquireInstanceLock(instanceGuid, instanceOwnerPartyId, ttl); + return new InstanceLockHandle(client, telemetry, holder, instanceGuid, instanceOwnerPartyId); + } - LogLockAcquired(logger, instanceGuid); + public Task Lock() + { + var handle = InitLock(); + return AcquireAndReturn(handle, ttl: null); + } + + public Task Lock(TimeSpan ttl) + { + var handle = InitLock(); + return AcquireAndReturn(handle, ttl); + } - _lock = new InstanceLock(instanceGuid, instanceOwnerPartyId, lockToken); + private static async Task AcquireAndReturn(IInstanceLock handle, TimeSpan? ttl) + { + await handle.Lock(ttl); + return handle; } - private (int instanceOwnerPartyId, Guid instanceGuid)? GetInstanceIdentifiers() + public string? CurrentLockToken => _currentLock.Value?.LockToken; + + private static (int instanceOwnerPartyId, Guid instanceGuid)? GetInstanceIdentifiers(HttpContext httpContext) { - var routeValues = _httpContext.Request.RouteValues; + var routeValues = httpContext.Request.RouteValues; if ( routeValues.TryGetValue("instanceOwnerPartyId", out var partyIdObj) @@ -54,36 +75,58 @@ public async ValueTask LockAsync(TimeSpan ttl) return null; } - public async ValueTask DisposeAsync() + private sealed class InstanceLockHolder { - if (_lock is null) + public string? LockToken { get; set; } + } + + private sealed class InstanceLockHandle( + InstanceLockClient client, + Telemetry? telemetry, + InstanceLockHolder holder, + Guid instanceGuid, + int instanceOwnerPartyId + ) : IInstanceLock + { + private static readonly TimeSpan _defaultTtl = TimeSpan.FromMinutes(5); + + public async Task Lock(TimeSpan? ttl = null) { - return; + if (holder.LockToken is not null) + { + return; + } + + var lockToken = await client.AcquireInstanceLock(instanceGuid, instanceOwnerPartyId, ttl ?? _defaultTtl); + holder.LockToken = lockToken; } - try + public async Task UpdateTtl(TimeSpan ttl) { - await client.ReleaseInstanceLock(_lock.InstanceGuid, _lock.InstanceOwnerPartyId, _lock.LockToken); + var lockToken = holder.LockToken ?? throw new InvalidOperationException("No lock held."); + await client.UpdateInstanceLock(instanceGuid, instanceOwnerPartyId, lockToken, ttl); } - catch (Exception e) + + public async ValueTask DisposeAsync() { - LogLockReleaseFailed(logger, _lock.InstanceGuid, e); - return; + var lockToken = holder.LockToken; + if (lockToken is null) + { + return; + } + + using var activity = telemetry?.StartReleaseInstanceLockActivity(instanceGuid, instanceOwnerPartyId); + + try + { + await client.UpdateInstanceLock(instanceGuid, instanceOwnerPartyId, lockToken, TimeSpan.Zero); + + holder.LockToken = null; + } + catch (Exception e) + { + activity?.Errored(e); + } } - - LogLockReleased(logger, _lock.InstanceGuid); - - _lock = null; } - - private sealed record InstanceLock(Guid InstanceGuid, int InstanceOwnerPartyId, string LockToken); - - [LoggerMessage(1, LogLevel.Debug, "Acquired lock for instance {InstanceGuid}.")] - private static partial void LogLockAcquired(ILogger logger, Guid instanceGuid); - - [LoggerMessage(2, LogLevel.Debug, "Released lock for instance {InstanceGuid}.")] - private static partial void LogLockReleased(ILogger logger, Guid instanceGuid); - - [LoggerMessage(3, LogLevel.Error, "Failed to release lock for instance {InstanceGuid}.")] - private static partial void LogLockReleaseFailed(ILogger logger, Guid instanceGuid, Exception e); } diff --git a/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs b/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs index 375bde1c83..6aba7eab77 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs @@ -161,6 +161,8 @@ public async Task Next(ProcessNextRequest request, Cancella bool firstIteration = true; int iterationCount = 0; + await using var instanceLock = _instanceLocker.InitLock(); + do { if (iterationCount >= MaxNextIterationsAllowed) @@ -198,7 +200,7 @@ public async Task Next(ProcessNextRequest request, Cancella Language = request.Language, }; - result = await ProcessNext(processNextRequest, ct); + result = await ProcessNext(processNextRequest, instanceLock, ct); if (!result.Success) { @@ -225,7 +227,11 @@ public async Task Next(ProcessNextRequest request, Cancella /// /// Internal method that performs a single process next operation without automatic service task handling. /// - private async Task ProcessNext(ProcessNextRequest request, CancellationToken ct = default) + private async Task ProcessNext( + ProcessNextRequest request, + IInstanceLock instanceLock, + CancellationToken ct = default + ) { Instance instance = request.Instance; @@ -255,8 +261,6 @@ out ProcessChangeResult? invalidProcessStateError }; } - await _instanceLocker.LockAsync(); - _logger.LogDebug( "User successfully authorized to perform process next. Task ID: {CurrentTaskId}. Task type: {AltinnTaskType}. Action: {ProcessNextAction}.", LogSanitizer.Sanitize(currentTaskId), @@ -264,6 +268,8 @@ out ProcessChangeResult? invalidProcessStateError LogSanitizer.Sanitize(request.Action ?? "none") ); + await instanceLock.Lock(); + string checkedAction = request.Action ?? ConvertTaskTypeToAction(altinnTaskType); bool isServiceTask = false; string? processNextAction = request.Action; diff --git a/test/Altinn.App.Api.Tests/Mocks/InstanceLockerMock.cs b/test/Altinn.App.Api.Tests/Mocks/InstanceLockerMock.cs index 8748dc3a6a..cd60a82e19 100644 --- a/test/Altinn.App.Api.Tests/Mocks/InstanceLockerMock.cs +++ b/test/Altinn.App.Api.Tests/Mocks/InstanceLockerMock.cs @@ -4,9 +4,22 @@ namespace Altinn.App.Api.Tests.Mocks; internal sealed class InstanceLockerMock : IInstanceLocker { - public ValueTask LockAsync() => ValueTask.CompletedTask; + public IInstanceLock InitLock() => NoOpLock.Instance; - public ValueTask LockAsync(TimeSpan ttl) => ValueTask.CompletedTask; + public Task Lock() => Task.FromResult(NoOpLock.Instance); - public ValueTask DisposeAsync() => ValueTask.CompletedTask; + public Task Lock(TimeSpan ttl) => Task.FromResult(NoOpLock.Instance); + + public string? CurrentLockToken => null; + + private sealed class NoOpLock : IInstanceLock + { + public static readonly NoOpLock Instance = new(); + + public Task Lock(TimeSpan? ttl = null) => Task.CompletedTask; + + public Task UpdateTtl(TimeSpan ttl) => Task.CompletedTask; + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } } diff --git a/test/Altinn.App.Api.Tests/Program.cs b/test/Altinn.App.Api.Tests/Program.cs index 415508d42e..efdf3badba 100644 --- a/test/Altinn.App.Api.Tests/Program.cs +++ b/test/Altinn.App.Api.Tests/Program.cs @@ -120,7 +120,7 @@ void ConfigureMockServices(IServiceCollection services, ConfigurationManager con services.AddTransient>(); services.AddTransient(); services.AddTransient(); - services.AddScoped(); + services.AddSingleton(); services.PostConfigureAll(options => { diff --git a/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt index 0cc7635a1e..e62bad6ebf 100644 --- a/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt +++ b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt @@ -24,9 +24,8 @@ Message: The response from the lock acquisition endpoint was not expected., StackTrace: at Altinn.App.Core.Infrastructure.Clients.Storage.InstanceLockClient.AcquireInstanceLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration, StorageAuthenticationMethod authenticationMethod, CancellationToken cancellationToken) -at Altinn.App.Core.Internal.InstanceLocking.InstanceLocker.LockAsync(TimeSpan ttl) ---- End of stack trace from previous location --- ---- End of stack trace from previous location --- +at Altinn.App.Core.Internal.InstanceLocking.InstanceLocker.InstanceLockHandle.Lock(Nullable`1 ttl) +at Altinn.App.Core.Internal.InstanceLocking.InstanceLocker.AcquireAndReturn(IInstanceLock handle, Nullable`1 ttl) at Xunit.Assert.RecordExceptionAsync(Func`1 testCode) } } \ No newline at end of file diff --git a/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.Init_ThenLock_WithCustomTtl.verified.txt b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.Init_ThenLock_WithCustomTtl.verified.txt new file mode 100644 index 0000000000..0d46a1e804 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.Init_ThenLock_WithCustomTtl.verified.txt @@ -0,0 +1,3 @@ +{ + RequestBody: {"ttlSeconds":60} +} \ No newline at end of file diff --git a/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.InvalidInstanceId_ThrowsInvalidOperationException.verified.txt b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.InvalidInstanceId_ThrowsInvalidOperationException.verified.txt index 0b5490f66d..5eeb58f35d 100644 --- a/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.InvalidInstanceId_ThrowsInvalidOperationException.verified.txt +++ b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.InvalidInstanceId_ThrowsInvalidOperationException.verified.txt @@ -4,9 +4,8 @@ Type: InvalidOperationException, Message: Unable to extract instance identifiers., StackTrace: -at Altinn.App.Core.Internal.InstanceLocking.InstanceLocker.LockAsync(TimeSpan ttl) ---- End of stack trace from previous location --- ---- End of stack trace from previous location --- +at Altinn.App.Core.Internal.InstanceLocking.InstanceLocker.InitLock() +at Altinn.App.Core.Internal.InstanceLocking.InstanceLocker.Lock() at Xunit.Assert.RecordExceptionAsync(Func`1 testCode) } } \ No newline at end of file diff --git a/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt index 0cc7635a1e..e62bad6ebf 100644 --- a/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt +++ b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt @@ -24,9 +24,8 @@ Message: The response from the lock acquisition endpoint was not expected., StackTrace: at Altinn.App.Core.Infrastructure.Clients.Storage.InstanceLockClient.AcquireInstanceLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration, StorageAuthenticationMethod authenticationMethod, CancellationToken cancellationToken) -at Altinn.App.Core.Internal.InstanceLocking.InstanceLocker.LockAsync(TimeSpan ttl) ---- End of stack trace from previous location --- ---- End of stack trace from previous location --- +at Altinn.App.Core.Internal.InstanceLocking.InstanceLocker.InstanceLockHandle.Lock(Nullable`1 ttl) +at Altinn.App.Core.Internal.InstanceLocking.InstanceLocker.AcquireAndReturn(IInstanceLock handle, Nullable`1 ttl) at Xunit.Assert.RecordExceptionAsync(Func`1 testCode) } } \ No newline at end of file diff --git a/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt index a220293cfa..86a25585c2 100644 --- a/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt +++ b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt @@ -21,9 +21,8 @@ Message: 409 Conflict, StackTrace: at Altinn.App.Core.Infrastructure.Clients.Storage.InstanceLockClient.AcquireInstanceLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration, StorageAuthenticationMethod authenticationMethod, CancellationToken cancellationToken) -at Altinn.App.Core.Internal.InstanceLocking.InstanceLocker.LockAsync(TimeSpan ttl) ---- End of stack trace from previous location --- ---- End of stack trace from previous location --- +at Altinn.App.Core.Internal.InstanceLocking.InstanceLocker.InstanceLockHandle.Lock(Nullable`1 ttl) +at Altinn.App.Core.Internal.InstanceLocking.InstanceLocker.AcquireAndReturn(IInstanceLock handle, Nullable`1 ttl) at Xunit.Assert.RecordExceptionAsync(Func`1 testCode) } } \ No newline at end of file diff --git a/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt index 7793637439..fb1f63beb9 100644 --- a/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt +++ b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt @@ -21,9 +21,8 @@ Message: 500 Internal Server Error, StackTrace: at Altinn.App.Core.Infrastructure.Clients.Storage.InstanceLockClient.AcquireInstanceLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration, StorageAuthenticationMethod authenticationMethod, CancellationToken cancellationToken) -at Altinn.App.Core.Internal.InstanceLocking.InstanceLocker.LockAsync(TimeSpan ttl) ---- End of stack trace from previous location --- ---- End of stack trace from previous location --- +at Altinn.App.Core.Internal.InstanceLocking.InstanceLocker.InstanceLockHandle.Lock(Nullable`1 ttl) +at Altinn.App.Core.Internal.InstanceLocking.InstanceLocker.AcquireAndReturn(IInstanceLock handle, Nullable`1 ttl) at Xunit.Assert.RecordExceptionAsync(Func`1 testCode) } } \ No newline at end of file diff --git a/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt index 5931ac0c2d..6ce4b1dc38 100644 --- a/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt +++ b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt @@ -21,9 +21,8 @@ Message: 404 Not Found, StackTrace: at Altinn.App.Core.Infrastructure.Clients.Storage.InstanceLockClient.AcquireInstanceLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration, StorageAuthenticationMethod authenticationMethod, CancellationToken cancellationToken) -at Altinn.App.Core.Internal.InstanceLocking.InstanceLocker.LockAsync(TimeSpan ttl) ---- End of stack trace from previous location --- ---- End of stack trace from previous location --- +at Altinn.App.Core.Internal.InstanceLocking.InstanceLocker.InstanceLockHandle.Lock(Nullable`1 ttl) +at Altinn.App.Core.Internal.InstanceLocking.InstanceLocker.AcquireAndReturn(IInstanceLock handle, Nullable`1 ttl) at Xunit.Assert.RecordExceptionAsync(Func`1 testCode) } } \ No newline at end of file diff --git a/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.cs b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.cs index 8a5f370e86..2402c97bdb 100644 --- a/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.cs @@ -45,7 +45,8 @@ public static Fixture Create(Action? registerCustomServices var mocks = new FixtureMocks(); mocks.AuthenticationContextMock.Setup(x => x.Current).Returns(_defaultAuth); - services.AddHttpClient(); + services.AddHttpClient(); + services.AddSingleton(); var httpContext = new DefaultHttpContext(); httpContext.Request.RouteValues.Add("instanceOwnerPartyId", InstanceOwnerPartyId); @@ -60,7 +61,7 @@ public static Fixture Create(Action? registerCustomServices services.AddRuntimeEnvironment(); - services.AddScoped(); + services.AddSingleton(); registerCustomServices?.Invoke(services); @@ -132,11 +133,10 @@ public async Task HappyPath() fixture.Server.Given(testRequestBuilder).RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK)); var httpClient = fixture.ServiceProvider.GetRequiredService().CreateClient(); + var instanceLocker = fixture.ServiceProvider.GetRequiredService(); - await using (var scope = fixture.ServiceProvider.CreateAsyncScope()) + await using (var handle = await instanceLocker.Lock()) { - var instanceLocker = scope.ServiceProvider.GetRequiredService(); - await instanceLocker.LockAsync(); using var response = await httpClient.GetAsync($"{fixture.ServerUrl}/test"); response.EnsureSuccessStatusCode(); } @@ -158,20 +158,15 @@ public async Task HappyPath() } [Fact] - public async Task HappyPath_MultipleLockCalls() + public async Task DoubleLock_ThrowsInvalidOperationException() { using var fixture = Fixture.Create(); var lockId = Guid.NewGuid(); var lockToken = GenerateLockToken(lockId); - var acquireLockRequestBuilder = fixture.GetAcquireLockRequestBuilder(); - var releaseLockRequestBuilder = fixture.GetReleaseLockRequestBuilder(lockToken); - - var testRequestBuilder = Request.Create().WithPath($"/test").UsingGet(); - fixture - .Server.Given(acquireLockRequestBuilder) + .Server.Given(fixture.GetAcquireLockRequestBuilder()) .RespondWith( Response .Create() @@ -180,37 +175,13 @@ public async Task HappyPath_MultipleLockCalls() ); fixture - .Server.Given(releaseLockRequestBuilder) + .Server.Given(fixture.GetReleaseLockRequestBuilder(lockToken)) .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK)); - fixture.Server.Given(testRequestBuilder).RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK)); - - var httpClient = fixture.ServiceProvider.GetRequiredService().CreateClient(); + var instanceLocker = fixture.ServiceProvider.GetRequiredService(); - await using (var scope = fixture.ServiceProvider.CreateAsyncScope()) - { - var instanceLocker = scope.ServiceProvider.GetRequiredService(); - await instanceLocker.LockAsync(); - await instanceLocker.LockAsync(); - await instanceLocker.LockAsync(); - using var response = await httpClient.GetAsync($"{fixture.ServerUrl}/test"); - response.EnsureSuccessStatusCode(); - } - - var requests = fixture.Server.LogEntries; - Assert.Equal(3, requests.Count); - - var acquireMatchResult = new RequestMatchResult(); - acquireLockRequestBuilder.GetMatchingScore(requests[0].RequestMessage, acquireMatchResult); - Assert.True(acquireMatchResult.IsPerfectMatch); - - var testMatchResult = new RequestMatchResult(); - testRequestBuilder.GetMatchingScore(requests[1].RequestMessage, testMatchResult); - Assert.True(testMatchResult.IsPerfectMatch); - - var releaseMatchResult = new RequestMatchResult(); - releaseLockRequestBuilder.GetMatchingScore(requests[2].RequestMessage, releaseMatchResult); - Assert.True(releaseMatchResult.IsPerfectMatch); + await using var handle = await instanceLocker.Lock(); + await Assert.ThrowsAsync(instanceLocker.Lock); } [Fact] @@ -234,11 +205,11 @@ public async Task LockReleasedOnException() .Server.Given(fixture.GetReleaseLockRequestBuilder(lockToken)) .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK)); + var instanceLocker = fixture.ServiceProvider.GetRequiredService(); + await Assert.ThrowsAsync(async () => { - await using var scope = fixture.ServiceProvider.CreateAsyncScope(); - var instanceLocker = scope.ServiceProvider.GetRequiredService(); - await instanceLocker.LockAsync(); + await using var handle = await instanceLocker.Lock(); throw new Exception(); }); @@ -268,10 +239,11 @@ public async Task CustomTtl_UsedInStorageApiCall() .Server.Given(fixture.GetReleaseLockRequestBuilder(lockToken)) .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK)); - await using (var scope = fixture.ServiceProvider.CreateAsyncScope()) + var instanceLocker = fixture.ServiceProvider.GetRequiredService(); + + await using (var handle = await instanceLocker.Lock(ttl)) { - var instanceLocker = scope.ServiceProvider.GetRequiredService(); - await instanceLocker.LockAsync(ttl); + // Lock acquired with custom TTL } var acquireRequests = fixture.Server.FindLogEntries(fixture.GetAcquireLockRequestBuilder()); @@ -302,10 +274,11 @@ public async Task LockReleaseFailure_DoesNotThrow() .Server.Given(fixture.GetReleaseLockRequestBuilder(lockToken)) .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.InternalServerError)); - await using (var scope = fixture.ServiceProvider.CreateAsyncScope()) + var instanceLocker = fixture.ServiceProvider.GetRequiredService(); + + await using (var handle = await instanceLocker.Lock()) { - var instanceLocker = scope.ServiceProvider.GetRequiredService(); - await instanceLocker.LockAsync(); + // Lock acquired, release will fail but should not throw } var releaseRequests = fixture.Server.FindLogEntries(fixture.GetReleaseLockRequestBuilder(lockToken)); @@ -324,12 +297,9 @@ public async Task StorageApiError_ThrowsCorrectPlatformHttpException(HttpStatusC .Server.Given(fixture.GetAcquireLockRequestBuilder()) .RespondWith(Response.Create().WithStatusCode(storageStatusCode)); - var exception = await Assert.ThrowsAsync(async () => - { - await using var scope = fixture.ServiceProvider.CreateAsyncScope(); - var instanceLocker = scope.ServiceProvider.GetRequiredService(); - await instanceLocker.LockAsync(); - }); + var instanceLocker = fixture.ServiceProvider.GetRequiredService(); + + var exception = await Assert.ThrowsAsync(instanceLocker.Lock); Assert.Single(fixture.Server.LogEntries); @@ -353,12 +323,9 @@ public async Task NullResponseBody_ThrowsPlatformHttpException() .WithBody("null") ); - var exception = await Assert.ThrowsAsync(async () => - { - await using var scope = fixture.ServiceProvider.CreateAsyncScope(); - var instanceLocker = scope.ServiceProvider.GetRequiredService(); - await instanceLocker.LockAsync(); - }); + var instanceLocker = fixture.ServiceProvider.GetRequiredService(); + + var exception = await Assert.ThrowsAsync(instanceLocker.Lock); Assert.Single(fixture.Server.LogEntries); @@ -380,12 +347,9 @@ public async Task EmptyJsonResponseBody_ThrowsPlatformHttpException() .WithBody("{}") ); - var exception = await Assert.ThrowsAsync(async () => - { - await using var scope = fixture.ServiceProvider.CreateAsyncScope(); - var instanceLocker = scope.ServiceProvider.GetRequiredService(); - await instanceLocker.LockAsync(); - }); + var instanceLocker = fixture.ServiceProvider.GetRequiredService(); + + var exception = await Assert.ThrowsAsync(instanceLocker.Lock); Assert.Single(fixture.Server.LogEntries); @@ -404,17 +368,178 @@ public async Task InvalidInstanceId_ThrowsInvalidOperationException() services.AddSingleton(httpContextAccessor); }); - var exception = await Assert.ThrowsAsync(async () => - { - await using var scope = fixture.ServiceProvider.CreateAsyncScope(); - var instanceLocker = scope.ServiceProvider.GetRequiredService(); - await instanceLocker.LockAsync(); - }); + var instanceLocker = fixture.ServiceProvider.GetRequiredService(); + + var exception = await Assert.ThrowsAsync(instanceLocker.Lock); Assert.Empty(fixture.Server.LogEntries); await Verify(new { Exception = exception }); } + [Fact] + public async Task CurrentLockToken_ReturnsToken_WhenLocked() + { + using var fixture = Fixture.Create(); + + var lockId = Guid.NewGuid(); + var lockToken = GenerateLockToken(lockId); + + fixture + .Server.Given(fixture.GetAcquireLockRequestBuilder()) + .RespondWith( + Response + .Create() + .WithStatusCode(HttpStatusCode.OK) + .WithBodyAsJson(new InstanceLockResponse { LockToken = lockToken }) + ); + + fixture + .Server.Given(fixture.GetReleaseLockRequestBuilder(lockToken)) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK)); + + var instanceLocker = fixture.ServiceProvider.GetRequiredService(); + + Assert.Null(instanceLocker.CurrentLockToken); + + await using (var handle = await instanceLocker.Lock()) + { + Assert.Equal(lockToken, instanceLocker.CurrentLockToken); + } + + Assert.Null(instanceLocker.CurrentLockToken); + } + + [Fact] + public void Init_ReturnsHandle_WithoutMakingHttpCalls() + { + using var fixture = Fixture.Create(); + + var instanceLocker = fixture.ServiceProvider.GetRequiredService(); + + var handle = instanceLocker.InitLock(); + + Assert.NotNull(handle); + Assert.Empty(fixture.Server.LogEntries); + Assert.Null(instanceLocker.CurrentLockToken); + } + + [Fact] + public async Task Init_ThenLock_AcquiresLock() + { + using var fixture = Fixture.Create(); + + var lockId = Guid.NewGuid(); + var lockToken = GenerateLockToken(lockId); + + fixture + .Server.Given(fixture.GetAcquireLockRequestBuilder()) + .RespondWith( + Response + .Create() + .WithStatusCode(HttpStatusCode.OK) + .WithBodyAsJson(new InstanceLockResponse { LockToken = lockToken }) + ); + + fixture + .Server.Given(fixture.GetReleaseLockRequestBuilder(lockToken)) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK)); + + var instanceLocker = fixture.ServiceProvider.GetRequiredService(); + + await using var handle = instanceLocker.InitLock(); + + // No HTTP call yet + Assert.Empty(fixture.Server.LogEntries); + Assert.Null(instanceLocker.CurrentLockToken); + + // Acquire the lock + await handle.Lock(); + + var acquireRequests = fixture.Server.FindLogEntries(fixture.GetAcquireLockRequestBuilder()); + Assert.Single(acquireRequests); + Assert.Equal(lockToken, instanceLocker.CurrentLockToken); + } + + [Fact] + public async Task Init_ThenLock_WithCustomTtl() + { + using var fixture = Fixture.Create(); + + var lockId = Guid.NewGuid(); + var lockToken = GenerateLockToken(lockId); + var ttl = TimeSpan.FromSeconds(60); + + fixture + .Server.Given(fixture.GetAcquireLockRequestBuilder()) + .RespondWith( + Response + .Create() + .WithStatusCode(HttpStatusCode.OK) + .WithBodyAsJson(new InstanceLockResponse { LockToken = lockToken }) + ); + + fixture + .Server.Given(fixture.GetReleaseLockRequestBuilder(lockToken)) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK)); + + var instanceLocker = fixture.ServiceProvider.GetRequiredService(); + + await using var handle = instanceLocker.InitLock(); + await handle.Lock(ttl); + + var acquireRequests = fixture.Server.FindLogEntries(fixture.GetAcquireLockRequestBuilder()); + Assert.Single(acquireRequests); + var requestBody = acquireRequests[0].RequestMessage.Body; + + await Verify(new { RequestBody = requestBody }); + } + + [Fact] + public async Task Lock_IsIdempotent_DoesNotMakeSecondHttpCall() + { + using var fixture = Fixture.Create(); + + var lockId = Guid.NewGuid(); + var lockToken = GenerateLockToken(lockId); + + fixture + .Server.Given(fixture.GetAcquireLockRequestBuilder()) + .RespondWith( + Response + .Create() + .WithStatusCode(HttpStatusCode.OK) + .WithBodyAsJson(new InstanceLockResponse { LockToken = lockToken }) + ); + + fixture + .Server.Given(fixture.GetReleaseLockRequestBuilder(lockToken)) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK)); + + var instanceLocker = fixture.ServiceProvider.GetRequiredService(); + + await using var handle = instanceLocker.InitLock(); + await handle.Lock(); + await handle.Lock(); // Second call should be a no-op + + var acquireRequests = fixture.Server.FindLogEntries(fixture.GetAcquireLockRequestBuilder()); + Assert.Single(acquireRequests); + } + + [Fact] + public async Task DisposeWithoutLock_DoesNotMakeHttpCalls() + { + using var fixture = Fixture.Create(); + + var instanceLocker = fixture.ServiceProvider.GetRequiredService(); + + await using (var handle = instanceLocker.InitLock()) + { + // Never call Lock — just dispose + } + + Assert.Empty(fixture.Server.LogEntries); + } + private string GenerateLockToken(Guid lockId) { return Convert.ToBase64String(lockId.ToByteArray()); diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.cs b/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.cs index 0f35e4f9ad..40280b0e75 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.cs @@ -1322,8 +1322,7 @@ public static Fixture Create( .ReturnsAsync(() => updatedInstance); } - instanceLockerMock.Setup(x => x.DisposeAsync()).Returns(ValueTask.CompletedTask); - instanceLockerMock.Setup(x => x.LockAsync()).Returns(ValueTask.CompletedTask); + instanceLockerMock.Setup(x => x.InitLock()).Returns(Moq.Mock.Of()); services.TryAddTransient(_ => authenticationContextMock.Object); services.TryAddTransient(_ => processNavigatorMock.Object); From 367dbdf35ed55e3d6e86537c14b21e137851d214 Mon Sep 17 00:00:00 2001 From: Tobias Netskar Date: Fri, 13 Mar 2026 13:15:25 +0100 Subject: [PATCH 2/2] add lock token header in DataClient --- src/Altinn.App.Core/Constants/General.cs | 5 ++ .../Extensions/HttpClientExtension.cs | 21 +++++++ .../Clients/Storage/DataClient.cs | 59 ++++++++++++++++--- .../Clients/Storage/InstanceLockClient.cs | 4 +- 4 files changed, 79 insertions(+), 10 deletions(-) diff --git a/src/Altinn.App.Core/Constants/General.cs b/src/Altinn.App.Core/Constants/General.cs index 6c8c9dc7a3..ccf303f3c7 100644 --- a/src/Altinn.App.Core/Constants/General.cs +++ b/src/Altinn.App.Core/Constants/General.cs @@ -39,4 +39,9 @@ public static class General /// Header name for platform access token /// internal const string PlatformAccessTokenHeaderName = "PlatformAccessToken"; + + /// + /// Header name for instance lock token + /// + internal const string LockTokenHeaderName = "Altinn-Storage-Lock-Token"; } diff --git a/src/Altinn.App.Core/Extensions/HttpClientExtension.cs b/src/Altinn.App.Core/Extensions/HttpClientExtension.cs index 16d5e8a1ef..d51b897c86 100644 --- a/src/Altinn.App.Core/Extensions/HttpClientExtension.cs +++ b/src/Altinn.App.Core/Extensions/HttpClientExtension.cs @@ -15,6 +15,7 @@ public static class HttpClientExtension /// The request Uri /// The http content /// The platformAccess tokens + /// The instance lock token /// The cancellation token /// A HttpResponseMessage public static async Task PostAsync( @@ -23,6 +24,7 @@ public static async Task PostAsync( string requestUri, HttpContent? content, string? platformAccessToken = null, + string? lockToken = null, CancellationToken cancellationToken = default ) { @@ -39,6 +41,11 @@ public static async Task PostAsync( request.Headers.Add(Constants.General.PlatformAccessTokenHeaderName, platformAccessToken); } + if (!string.IsNullOrEmpty(lockToken)) + { + request.Headers.Add(Constants.General.LockTokenHeaderName, lockToken); + } + return await httpClient.SendAsync(request, cancellationToken); } @@ -50,6 +57,7 @@ public static async Task PostAsync( /// The request Uri /// The http content /// The platformAccess tokens + /// The instance lock token /// The cancellation token /// A HttpResponseMessage public static async Task PutAsync( @@ -58,6 +66,7 @@ public static async Task PutAsync( string requestUri, HttpContent? content, string? platformAccessToken = null, + string? lockToken = null, CancellationToken cancellationToken = default ) { @@ -74,6 +83,11 @@ public static async Task PutAsync( request.Headers.Add(Constants.General.PlatformAccessTokenHeaderName, platformAccessToken); } + if (!string.IsNullOrEmpty(lockToken)) + { + request.Headers.Add(Constants.General.LockTokenHeaderName, lockToken); + } + return await httpClient.SendAsync(request, cancellationToken); } @@ -186,6 +200,7 @@ public static async Task PatchAsync( /// the authorization token (jwt) /// The request Uri /// The platformAccess tokens + /// The instance lock token /// The cancellation token /// A HttpResponseMessage public static async Task DeleteAsync( @@ -193,6 +208,7 @@ public static async Task DeleteAsync( string authorizationToken, string requestUri, string? platformAccessToken = null, + string? lockToken = null, CancellationToken cancellationToken = default ) { @@ -208,6 +224,11 @@ public static async Task DeleteAsync( request.Headers.Add(Constants.General.PlatformAccessTokenHeaderName, platformAccessToken); } + if (!string.IsNullOrEmpty(lockToken)) + { + request.Headers.Add(Constants.General.LockTokenHeaderName, lockToken); + } + return await httpClient.SendAsync(request, cancellationToken); } } diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs index 591bc2beb4..58be8d5735 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs @@ -12,6 +12,7 @@ using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.Auth; using Altinn.App.Core.Internal.Data; +using Altinn.App.Core.Internal.InstanceLocking; using Altinn.App.Core.Models; using Altinn.Platform.Storage.Interface.Models; using Microsoft.AspNetCore.Http; @@ -34,6 +35,7 @@ public sealed class DataClient : IDataClient private readonly ModelSerializationService _modelSerializationService; private readonly Telemetry? _telemetry; private readonly HttpClient _client; + private readonly IInstanceLocker _instanceLocker; private readonly AuthenticationMethod _defaultAuthenticationMethod = StorageAuthenticationMethod.CurrentUser(); @@ -52,6 +54,7 @@ public DataClient(HttpClient httpClient, IServiceProvider serviceProvider) _platformSettings = serviceProvider.GetRequiredService>().Value; _logger = serviceProvider.GetRequiredService>(); _telemetry = serviceProvider.GetService(); + _instanceLocker = serviceProvider.GetRequiredService(); httpClient.BaseAddress = new Uri(_platformSettings.ApiStorageEndpoint); httpClient.DefaultRequestHeaders.Add(General.SubscriptionKeyHeaderName, _platformSettings.SubscriptionKey); @@ -166,6 +169,7 @@ public async Task UpdateData( token, apiUrl, streamContent, + lockToken: _instanceLocker.CurrentLockToken, cancellationToken: cts.Token ); @@ -491,7 +495,12 @@ public async Task DeleteData( cancellationToken: cts.Token ); - HttpResponseMessage response = await _client.DeleteAsync(token, apiUrl, cancellationToken: cts.Token); + HttpResponseMessage response = await _client.DeleteAsync( + token, + apiUrl, + lockToken: _instanceLocker.CurrentLockToken, + cancellationToken: cts.Token + ); if (response.IsSuccessStatusCode) { @@ -529,7 +538,13 @@ public async Task InsertBinaryData( ); StreamContent content = request.CreateContentStream(); - HttpResponseMessage response = await _client.PostAsync(token, apiUrl, content, cancellationToken: cts.Token); + HttpResponseMessage response = await _client.PostAsync( + token, + apiUrl, + content, + lockToken: _instanceLocker.CurrentLockToken, + cancellationToken: cts.Token + ); if (response.IsSuccessStatusCode) { @@ -582,7 +597,13 @@ public async Task InsertBinaryData( }; } - HttpResponseMessage response = await _client.PostAsync(token, apiUrl, content, cancellationToken: cts.Token); + HttpResponseMessage response = await _client.PostAsync( + token, + apiUrl, + content, + lockToken: _instanceLocker.CurrentLockToken, + cancellationToken: cts.Token + ); if (response.IsSuccessStatusCode) { @@ -627,7 +648,13 @@ public async Task UpdateBinaryData( StreamContent content = request.CreateContentStream(); - HttpResponseMessage response = await _client.PutAsync(token, apiUrl, content, cancellationToken: cts.Token); + HttpResponseMessage response = await _client.PutAsync( + token, + apiUrl, + content, + lockToken: _instanceLocker.CurrentLockToken, + cancellationToken: cts.Token + ); if (response.IsSuccessStatusCode) { @@ -676,7 +703,13 @@ public async Task UpdateBinaryData( }; } - HttpResponseMessage response = await _client.PutAsync(token, apiUrl, content, cancellationToken: cts.Token); + HttpResponseMessage response = await _client.PutAsync( + token, + apiUrl, + content, + lockToken: _instanceLocker.CurrentLockToken, + cancellationToken: cts.Token + ); _logger.LogInformation("Update binary data result: {ResultCode}", response.StatusCode); if (response.IsSuccessStatusCode) { @@ -707,7 +740,13 @@ public async Task Update( ); StringContent jsonString = new(JsonConvert.SerializeObject(dataElement), Encoding.UTF8, "application/json"); - HttpResponseMessage response = await _client.PutAsync(token, apiUrl, jsonString, cancellationToken: cts.Token); + HttpResponseMessage response = await _client.PutAsync( + token, + apiUrl, + jsonString, + lockToken: _instanceLocker.CurrentLockToken, + cancellationToken: cts.Token + ); if (response.IsSuccessStatusCode) { @@ -750,6 +789,7 @@ public async Task LockDataElement( apiUrl, content: null, platformAccessToken: null, + lockToken: _instanceLocker.CurrentLockToken, cts.Token ); if (response.IsSuccessStatusCode) @@ -792,7 +832,12 @@ public async Task UnlockDataElement( instanceIdentifier, apiUrl ); - HttpResponseMessage response = await _client.DeleteAsync(token, apiUrl, cancellationToken: cts.Token); + HttpResponseMessage response = await _client.DeleteAsync( + token, + apiUrl, + lockToken: _instanceLocker.CurrentLockToken, + cancellationToken: cts.Token + ); if (response.IsSuccessStatusCode) { // ! TODO: this null-forgiving operator should be fixed/removed for the next major release diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs index 03294e3d0e..60a189eb8f 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs @@ -21,8 +21,6 @@ internal sealed class InstanceLockClient( { private readonly AuthenticationMethod _defaultAuthenticationMethod = StorageAuthenticationMethod.CurrentUser(); - private const string LockTokenHeaderName = "Altinn-Storage-Lock-Token"; - private HttpClient CreateHttpClient() { var settings = _platformSettings.CurrentValue; @@ -105,7 +103,7 @@ public async Task UpdateInstanceLock( using HttpRequestMessage request = new(HttpMethod.Patch, apiUrl); request.Content = JsonContent.Create(instanceLockRequest); request.Headers.Authorization = new AuthenticationHeaderValue(AuthorizationSchemes.Bearer, userToken); - request.Headers.Add(LockTokenHeaderName, lockToken); + request.Headers.Add(General.LockTokenHeaderName, lockToken); using var client = CreateHttpClient(); using var response = await client.SendAsync(request, cancellationToken);