Skip to content

Commit 8825f8a

Browse files
committed
Feat: add Redis governance request persistence
1 parent 55c5b0b commit 8825f8a

6 files changed

Lines changed: 266 additions & 0 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using StackExchange.Redis;
2+
3+
namespace ModularityKit.Mutator.Governance.Redis.Storage.Persistence.Models;
4+
5+
/// <summary>
6+
/// Represents the Redis persistence payload for governed mutation request write.
7+
/// </summary>
8+
/// <param name="RequestId">The governed request identifier.</param>
9+
/// <param name="DataKey">The Redis key for the serialized request document.</param>
10+
/// <param name="RevisionKey">The Redis key for the request revision value.</param>
11+
/// <param name="Payload">The serialized request payload.</param>
12+
/// <param name="Revision">The revision value to persist.</param>
13+
internal sealed record RedisMutationRequestPersistenceRecord(
14+
string RequestId,
15+
RedisKey DataKey,
16+
RedisKey RevisionKey,
17+
RedisValue Payload,
18+
long Revision);
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
2+
using ModularityKit.Mutator.Governance.Redis.Keys;
3+
using ModularityKit.Mutator.Governance.Redis.Serialization;
4+
using StackExchange.Redis;
5+
6+
namespace ModularityKit.Mutator.Governance.Redis.Storage.Persistence.Reading;
7+
8+
/// <summary>
9+
/// Reads individual governed mutation requests from Redis persistence storage.
10+
/// </summary>
11+
internal sealed class RedisMutationRequestPersistenceDocumentReader(
12+
IDatabase database,
13+
RedisMutationRequestKeyspace keyspace)
14+
{
15+
private readonly IDatabase _database = database ?? throw new ArgumentNullException(nameof(database));
16+
private readonly RedisMutationRequestKeyspace _keyspace = keyspace ?? throw new ArgumentNullException(nameof(keyspace));
17+
18+
/// <summary>
19+
/// Reads a single governed mutation request by identifier.
20+
/// </summary>
21+
/// <param name="requestId">The request identifier.</param>
22+
/// <param name="cancellationToken">The cancellation token.</param>
23+
/// <returns>The request if it exists; otherwise <see langword="null" />.</returns>
24+
public async Task<MutationRequest?> GetAsync(
25+
string requestId,
26+
CancellationToken cancellationToken)
27+
{
28+
cancellationToken.ThrowIfCancellationRequested();
29+
30+
var payload = await _database.StringGetAsync(_keyspace.RequestData(requestId)).ConfigureAwait(false);
31+
return payload.HasValue
32+
? RedisMutationRequestSerializer.Deserialize(payload!)
33+
: null;
34+
}
35+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
2+
using ModularityKit.Mutator.Governance.Redis.Storage.Persistence.Reading;
3+
using ModularityKit.Mutator.Governance.Redis.Storage.Persistence.Writing;
4+
using StackExchange.Redis;
5+
6+
namespace ModularityKit.Mutator.Governance.Redis.Storage.Persistence;
7+
8+
/// <summary>
9+
/// Coordinates Redis persistence operations for governed mutation requests.
10+
/// </summary>
11+
internal sealed class RedisMutationRequestPersistence(
12+
IDatabase database,
13+
RedisMutationRequestPersistenceRecordFactory recordFactory,
14+
RedisMutationRequestPersistenceDocumentReader documentReader,
15+
RedisMutationRequestTransactionWriter transactionWriter)
16+
{
17+
private readonly IDatabase _database = database ?? throw new ArgumentNullException(nameof(database));
18+
private readonly RedisMutationRequestPersistenceRecordFactory _recordFactory = recordFactory ?? throw new ArgumentNullException(nameof(recordFactory));
19+
private readonly RedisMutationRequestPersistenceDocumentReader _documentReader = documentReader ?? throw new ArgumentNullException(nameof(documentReader));
20+
private readonly RedisMutationRequestTransactionWriter _transactionWriter = transactionWriter ?? throw new ArgumentNullException(nameof(transactionWriter));
21+
22+
/// <summary>
23+
/// Creates a new governed mutation request in Redis with an initial revision.
24+
/// </summary>
25+
/// <param name="request">The request to create.</param>
26+
/// <param name="cancellationToken">The cancellation token.</param>
27+
/// <returns>The persisted request with provider-managed revision values applied.</returns>
28+
public async Task<MutationRequest> Create(MutationRequest request, CancellationToken cancellationToken = default)
29+
{
30+
ArgumentNullException.ThrowIfNull(request);
31+
cancellationToken.ThrowIfCancellationRequested();
32+
33+
var persistedRequest = request with { Revision = 0 };
34+
var record = _recordFactory.Create(persistedRequest);
35+
var transaction = _database.CreateTransaction();
36+
37+
_transactionWriter.WriteCreate(transaction, record, persistedRequest);
38+
39+
var committed = await transaction.ExecuteAsync().ConfigureAwait(false);
40+
return !committed
41+
? throw new InvalidOperationException($"Mutation request '{request.RequestId}' already exists in Redis.")
42+
: persistedRequest;
43+
}
44+
45+
/// <summary>
46+
/// Attempts to store an updated governed mutation request using optimistic concurrency.
47+
/// </summary>
48+
/// <param name="request">The request to persist.</param>
49+
/// <param name="expectedRevision">The expected current revision.</param>
50+
/// <param name="cancellationToken">The cancellation token.</param>
51+
/// <returns>The persisted request if the update succeeds; otherwise <see langword="null" />.</returns>
52+
public async Task<MutationRequest?> TryStore(MutationRequest request, long expectedRevision, CancellationToken cancellationToken = default)
53+
{
54+
ArgumentNullException.ThrowIfNull(request);
55+
cancellationToken.ThrowIfCancellationRequested();
56+
57+
var currentRequest = await Get(request.RequestId, cancellationToken).ConfigureAwait(false);
58+
if (currentRequest is null || currentRequest.Revision != expectedRevision)
59+
return null;
60+
61+
var persistedRequest = request with { Revision = expectedRevision + 1 };
62+
var record = _recordFactory.Create(persistedRequest);
63+
var transaction = _database.CreateTransaction();
64+
65+
_transactionWriter.WriteUpdate(transaction, record, expectedRevision, currentRequest, persistedRequest);
66+
67+
var committed = await transaction.ExecuteAsync().ConfigureAwait(false);
68+
return committed ? persistedRequest : null;
69+
}
70+
71+
/// <summary>
72+
/// Reads a governed mutation request by identifier.
73+
/// </summary>
74+
/// <param name="requestId">The request identifier.</param>
75+
/// <param name="cancellationToken">The cancellation token.</param>
76+
/// <returns>The request if it exists; otherwise <see langword="null" />.</returns>
77+
public async Task<MutationRequest?> Get(string requestId, CancellationToken cancellationToken = default) =>
78+
await _documentReader.GetAsync(requestId, cancellationToken).ConfigureAwait(false);
79+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
2+
using ModularityKit.Mutator.Governance.Redis.Keys;
3+
using StackExchange.Redis;
4+
5+
namespace ModularityKit.Mutator.Governance.Redis.Storage.Persistence.Writing;
6+
7+
/// <summary>
8+
/// Maintains Redis secondary indexes for governed mutation request writes.
9+
/// </summary>
10+
internal sealed class RedisMutationRequestIndexWriter(
11+
RedisMutationRequestKeyspace keyspace)
12+
{
13+
private readonly RedisMutationRequestKeyspace _keyspace = keyspace ?? throw new ArgumentNullException(nameof(keyspace));
14+
15+
/// <summary>
16+
/// Adds a request to all Redis secondary indexes implied by its current state.
17+
/// </summary>
18+
/// <param name="transaction">The Redis transaction to append commands to.</param>
19+
/// <param name="request">The request to index.</param>
20+
public void Add(ITransaction transaction, MutationRequest request)
21+
{
22+
ArgumentNullException.ThrowIfNull(transaction);
23+
ArgumentNullException.ThrowIfNull(request);
24+
25+
foreach (var indexKey in _keyspace.EnumerateIndexes(request))
26+
_ = transaction.SetAddAsync(indexKey, request.RequestId);
27+
}
28+
29+
/// <summary>
30+
/// Removes a request from all Redis secondary indexes implied by its current state.
31+
/// </summary>
32+
/// <param name="transaction">The Redis transaction to append commands to.</param>
33+
/// <param name="request">The request to remove from indexes.</param>
34+
public void Remove(ITransaction transaction, MutationRequest request)
35+
{
36+
ArgumentNullException.ThrowIfNull(transaction);
37+
ArgumentNullException.ThrowIfNull(request);
38+
39+
foreach (var indexKey in _keyspace.EnumerateIndexes(request))
40+
_ = transaction.SetRemoveAsync(indexKey, request.RequestId);
41+
}
42+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
2+
using ModularityKit.Mutator.Governance.Redis.Keys;
3+
using ModularityKit.Mutator.Governance.Redis.Serialization;
4+
using ModularityKit.Mutator.Governance.Redis.Storage.Persistence.Models;
5+
6+
namespace ModularityKit.Mutator.Governance.Redis.Storage.Persistence.Writing;
7+
8+
/// <summary>
9+
/// Creates Redis persistence records from governed mutation requests.
10+
/// </summary>
11+
internal sealed class RedisMutationRequestPersistenceRecordFactory(
12+
RedisMutationRequestKeyspace keyspace)
13+
{
14+
private readonly RedisMutationRequestKeyspace _keyspace = keyspace ?? throw new ArgumentNullException(nameof(keyspace));
15+
16+
/// <summary>
17+
/// Creates a persistence record for the supplied request.
18+
/// </summary>
19+
/// <param name="request">The request to convert.</param>
20+
/// <returns>The Redis persistence record.</returns>
21+
public RedisMutationRequestPersistenceRecord Create(MutationRequest request)
22+
{
23+
ArgumentNullException.ThrowIfNull(request);
24+
25+
return new RedisMutationRequestPersistenceRecord(
26+
request.RequestId,
27+
_keyspace.RequestData(request.RequestId),
28+
_keyspace.RequestRevision(request.RequestId),
29+
RedisMutationRequestSerializer.Serialize(request),
30+
request.Revision);
31+
}
32+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
2+
using ModularityKit.Mutator.Governance.Redis.Storage.Persistence.Models;
3+
using StackExchange.Redis;
4+
5+
namespace ModularityKit.Mutator.Governance.Redis.Storage.Persistence.Writing;
6+
7+
/// <summary>
8+
/// Appends Redis transaction commands for governed mutation request create and update operations.
9+
/// </summary>
10+
internal sealed class RedisMutationRequestTransactionWriter(
11+
RedisMutationRequestIndexWriter indexWriter)
12+
{
13+
private readonly RedisMutationRequestIndexWriter _indexWriter = indexWriter ?? throw new ArgumentNullException(nameof(indexWriter));
14+
15+
/// <summary>
16+
/// Writes the Redis transaction commands required to create a new request.
17+
/// </summary>
18+
/// <param name="transaction">The Redis transaction to append commands to.</param>
19+
/// <param name="record">The persistence record to store.</param>
20+
/// <param name="request">The request being created.</param>
21+
public void WriteCreate(ITransaction transaction, RedisMutationRequestPersistenceRecord record, MutationRequest request)
22+
{
23+
ArgumentNullException.ThrowIfNull(transaction);
24+
ArgumentNullException.ThrowIfNull(record);
25+
ArgumentNullException.ThrowIfNull(request);
26+
27+
_ = transaction.AddCondition(Condition.KeyNotExists(record.DataKey));
28+
_ = transaction.AddCondition(Condition.KeyNotExists(record.RevisionKey));
29+
_ = transaction.StringSetAsync(record.DataKey, record.Payload);
30+
_ = transaction.StringSetAsync(record.RevisionKey, record.Revision);
31+
_indexWriter.Add(transaction, request);
32+
}
33+
34+
/// <summary>
35+
/// Writes the Redis transaction commands required to update an existing request revision.
36+
/// </summary>
37+
/// <param name="transaction">The Redis transaction to append commands to.</param>
38+
/// <param name="record">The persistence record to store.</param>
39+
/// <param name="expectedRevision">The expected current revision value.</param>
40+
/// <param name="currentRequest">The currently stored request state.</param>
41+
/// <param name="persistedRequest">The updated request state to persist.</param>
42+
public void WriteUpdate(
43+
ITransaction transaction,
44+
RedisMutationRequestPersistenceRecord record,
45+
long expectedRevision,
46+
MutationRequest currentRequest,
47+
MutationRequest persistedRequest)
48+
{
49+
ArgumentNullException.ThrowIfNull(transaction);
50+
ArgumentNullException.ThrowIfNull(record);
51+
ArgumentNullException.ThrowIfNull(currentRequest);
52+
ArgumentNullException.ThrowIfNull(persistedRequest);
53+
54+
_ = transaction.AddCondition(Condition.StringEqual(record.RevisionKey, expectedRevision));
55+
_ = transaction.StringSetAsync(record.DataKey, record.Payload);
56+
_ = transaction.StringSetAsync(record.RevisionKey, record.Revision);
57+
_indexWriter.Remove(transaction, currentRequest);
58+
_indexWriter.Add(transaction, persistedRequest);
59+
}
60+
}

0 commit comments

Comments
 (0)