Skip to content

Commit 3ae6843

Browse files
authored
Feat: Add optimistic concurrency semantics to governance storage (#13)
2 parents 1ad13b1 + c56c889 commit 3ae6843

16 files changed

Lines changed: 537 additions & 298 deletions
Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using ModularityKit.Mutator.Abstractions.Context;
2+
using ModularityKit.Mutator.Governance.Abstractions.Exceptions;
23
using ModularityKit.Mutator.Governance.Abstractions.Lifecycle;
34
using ModularityKit.Mutator.Governance.Runtime.Lifecycle;
45
using ModularityKit.Mutator.Governance.Tests.TestSupport;
@@ -9,7 +10,7 @@ namespace ModularityKit.Mutator.Governance.Tests.Lifecycle;
910
public sealed class MutationRequestLifecycleAtomicityTests
1011
{
1112
[Fact]
12-
public async Task Stale_snapshot_transition_can_succeed_after_a_prior_lifecycle_update()
13+
public async Task Stale_snapshot_transition_is_rejected_after_a_prior_lifecycle_update()
1314
{
1415
var request = MutationRequestTestFactory.CreatePendingRequest();
1516
var store = new StaleSnapshotMutationRequestStore(request);
@@ -19,15 +20,17 @@ public async Task Stale_snapshot_transition_can_succeed_after_a_prior_lifecycle_
1920
request.RequestId,
2021
MutationContext.User("approver", "Approver", "Approve request"));
2122

22-
var canceled = await manager.Cancel(
23-
request.RequestId,
24-
MutationContext.User("operator", "Operator", "Cancel request"));
23+
var exception = await Assert.ThrowsAsync<MutationRequestConcurrencyException>(() =>
24+
manager.Cancel(
25+
request.RequestId,
26+
MutationContext.User("operator", "Operator", "Cancel request")));
2527

26-
Assert.Equal(2, store.StoreCount);
28+
Assert.Equal(1, store.StoreCount);
2729
Assert.All(store.GetSnapshots, snapshot => Assert.Equal(MutationRequestStatus.Pending, snapshot.Status));
2830
Assert.Equal(MutationRequestStatus.Approved, approved.Status);
29-
Assert.Equal(MutationRequestStatus.Canceled, canceled.Status);
30-
Assert.NotEqual(approved.Status, canceled.Status);
31-
Assert.Contains(store.Current.Status, new[] { MutationRequestStatus.Approved, MutationRequestStatus.Canceled });
31+
Assert.Equal(request.RequestId, exception.RequestId);
32+
Assert.Equal(0, exception.ExpectedRevision);
33+
Assert.Equal(MutationRequestStatus.Approved, store.Current.Status);
34+
Assert.Equal(1, store.Current.Revision);
3235
}
3336
}
Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using ModularityKit.Mutator.Abstractions.Context;
2+
using ModularityKit.Mutator.Governance.Abstractions.Exceptions;
23
using ModularityKit.Mutator.Governance.Abstractions.Lifecycle;
34
using ModularityKit.Mutator.Governance.Abstractions.Requests;
45
using ModularityKit.Mutator.Governance.Runtime.Storage;
@@ -10,32 +11,62 @@ namespace ModularityKit.Mutator.Governance.Tests.Lifecycle;
1011
public sealed class MutationRequestStoreContractTests
1112
{
1213
[Fact]
13-
public async Task Store_contract_allows_blind_overwrite_without_expected_revision_or_status()
14+
public async Task Create_contract_rejects_duplicate_request_ids()
1415
{
1516
var store = new InMemoryMutationRequestStore();
1617
var request = MutationRequestTestFactory.CreatePendingRequest();
1718

18-
await store.Store(request);
19+
var created = await store.Create(request);
1920

20-
var overwritten = request with
21+
var exception = await Assert.ThrowsAsync<MutationRequestAlreadyExistsException>(() =>
22+
store.Create(created));
23+
24+
Assert.Equal(request.RequestId, exception.RequestId);
25+
}
26+
27+
[Fact]
28+
public async Task TryStore_rejects_stale_revision_and_preserves_current_state()
29+
{
30+
var store = new InMemoryMutationRequestStore();
31+
var request = MutationRequestTestFactory.CreatePendingRequest();
32+
var created = await store.Create(request);
33+
34+
var firstUpdate = created with
35+
{
36+
Status = MutationRequestStatus.Approved,
37+
PendingReason = null,
38+
Decisions =
39+
[
40+
.. created.Decisions,
41+
MutationRequestDecision.Create(
42+
MutationRequestDecisionType.Approved,
43+
MutationContext.User("approver", "Approver", "Approve request"))
44+
]
45+
};
46+
47+
var persisted = await store.TryStore(firstUpdate, created.Revision);
48+
Assert.NotNull(persisted);
49+
50+
var staleUpdate = created with
2151
{
2252
Status = MutationRequestStatus.Canceled,
2353
PendingReason = null,
2454
Decisions =
2555
[
26-
.. request.Decisions,
56+
.. created.Decisions,
2757
MutationRequestDecision.Create(
2858
MutationRequestDecisionType.Canceled,
29-
MutationContext.User("operator", "Operator", "Canceled without guard"))
59+
MutationContext.User("operator", "Operator", "Cancel request"))
3060
]
3161
};
3262

33-
await store.Store(overwritten);
63+
var rejected = await store.TryStore(staleUpdate, created.Revision);
3464

3565
var loaded = await store.Get(request.RequestId);
3666

67+
Assert.Null(rejected);
3768
Assert.NotNull(loaded);
38-
Assert.Equal(MutationRequestStatus.Canceled, loaded.Status);
39-
Assert.Equal(overwritten.Decisions.Count, loaded.Decisions.Count);
69+
Assert.Equal(MutationRequestStatus.Approved, loaded.Status);
70+
Assert.Equal(1, loaded.Revision);
4071
}
4172
}

Tests/ModularityKit.Mutator.Governance.Tests/Resolution/MutationRequestVersionResolutionPersistenceTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ public async Task Resolve_does_not_persist_decision_history_unless_caller_saves_
1717
var resolver = new MutationRequestVersionResolver();
1818
var request = MutationRequestTestFactory.CreateApprovedSecurityRequest("v10");
1919

20-
await store.Store(request);
20+
await store.Create(request);
2121

2222
var resolution = resolver.Resolve(
2323
request,

Tests/ModularityKit.Mutator.Governance.Tests/TestSupport/StaleSnapshotMutationRequestStore.cs

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,40 @@ public MutationRequest Current
2626

2727
public IReadOnlyList<MutationRequest> GetSnapshots => _getSnapshots;
2828

29-
public Task Store(
29+
public Task<MutationRequest> Create(
3030
MutationRequest request,
3131
CancellationToken cancellationToken = default)
3232
{
3333
lock (_gate)
3434
{
3535
StoreCount++;
36-
_current = request;
36+
_current = request with
37+
{
38+
Revision = 0
39+
};
3740
}
3841

39-
return Task.CompletedTask;
42+
return Task.FromResult(_current);
43+
}
44+
45+
public Task<MutationRequest?> TryStore(
46+
MutationRequest request,
47+
long expectedRevision,
48+
CancellationToken cancellationToken = default)
49+
{
50+
lock (_gate)
51+
{
52+
if (_current.Revision != expectedRevision)
53+
return Task.FromResult<MutationRequest?>(null);
54+
55+
StoreCount++;
56+
_current = request with
57+
{
58+
Revision = expectedRevision + 1
59+
};
60+
61+
return Task.FromResult<MutationRequest?>(_current);
62+
}
4063
}
4164

4265
public Task<MutationRequest?> Get(
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
using ModularityKit.Mutator.Abstractions.Exceptions;
2+
3+
namespace ModularityKit.Mutator.Governance.Abstractions.Exceptions;
4+
5+
/// <summary>
6+
/// Thrown when governance storage is asked to create a request that already exists.
7+
/// </summary>
8+
public sealed class MutationRequestAlreadyExistsException(string requestId)
9+
: MutationException($"Mutation request '{requestId}' already exists.")
10+
{
11+
/// <summary>
12+
/// Stable identifier of the duplicate request.
13+
/// </summary>
14+
public string RequestId { get; } = requestId;
15+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using ModularityKit.Mutator.Abstractions.Exceptions;
2+
3+
namespace ModularityKit.Mutator.Governance.Abstractions.Exceptions;
4+
5+
/// <summary>
6+
/// Thrown when a governance request transition loses an optimistic concurrency race.
7+
/// </summary>
8+
public sealed class MutationRequestConcurrencyException(
9+
string requestId,
10+
long expectedRevision)
11+
: MutationException(
12+
$"Mutation request '{requestId}' could not be updated because revision '{expectedRevision}' is stale.")
13+
{
14+
/// <summary>
15+
/// Stable identifier of the request that lost the concurrency race.
16+
/// </summary>
17+
public string RequestId { get; } = requestId;
18+
19+
/// <summary>
20+
/// Revision the runtime expected to update.
21+
/// </summary>
22+
public long ExpectedRevision { get; } = expectedRevision;
23+
}

src/Governance/Abstractions/Requests/MutationRequest.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ public sealed record MutationRequest
6060
/// </summary>
6161
public IReadOnlyList<MutationRequestDecision> Decisions { get; init; } = [];
6262

63+
/// <summary>
64+
/// Optimistic concurrency revision for the governed request.
65+
/// </summary>
66+
public long Revision { get; init; }
67+
6368
/// <summary>
6469
/// Expected version or concurrency token for the target state.
6570
/// </summary>

src/Governance/Abstractions/Storage/IMutationRequestStore.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,21 @@ namespace ModularityKit.Mutator.Governance.Abstractions.Storage;
99
public interface IMutationRequestStore
1010
{
1111
/// <summary>
12-
/// Stores or updates a mutation request.
12+
/// Creates a new governed mutation request in persistence.
1313
/// </summary>
14-
Task Store(
14+
Task<MutationRequest> Create(
1515
MutationRequest request,
1616
CancellationToken cancellationToken = default);
1717

18+
/// <summary>
19+
/// Stores a request only when the persisted revision matches the expected revision.
20+
/// Returns the persisted request with incremented revision on success, or <c>null</c> on conflict.
21+
/// </summary>
22+
Task<MutationRequest?> TryStore(
23+
MutationRequest request,
24+
long expectedRevision,
25+
CancellationToken cancellationToken = default);
26+
1827
/// <summary>
1928
/// Retrieves a single mutation request by its stable identifier.
2029
/// </summary>

src/Governance/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ Key types:
3838
- `MutationRequestVersionResolution`
3939
- `MutationRequestVersionResolutionOutcome`
4040
- `VersionedRequestResolutionStrategy`
41+
- `MutationRequestAlreadyExistsException`
42+
- `MutationRequestConcurrencyException`
4143
- `MutationRequestNotFoundException`
4244
- `InvalidMutationRequestTransitionException`
4345

0 commit comments

Comments
 (0)