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/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/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 127921e579..60a189eb8f 100644
--- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs
+++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs
@@ -8,37 +8,27 @@
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 +50,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 +68,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 +82,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,
@@ -111,9 +103,10 @@ public async Task ReleaseInstanceLock(
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 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);