Skip to content

Commit 7c75dc8

Browse files
committed
Feat: add Redis governance query read orchestration
1 parent 5449574 commit 7c75dc8

7 files changed

Lines changed: 708 additions & 0 deletions
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using Microsoft.Extensions.DependencyInjection.Extensions;
3+
using ModularityKit.Mutator.Governance.Abstractions.Queries.Contracts;
4+
using ModularityKit.Mutator.Governance.Abstractions.Storage;
5+
using ModularityKit.Mutator.Governance.Redis.Configuration;
6+
using ModularityKit.Mutator.Governance.Redis.Keys;
7+
using ModularityKit.Mutator.Governance.Redis.Storage;
8+
using ModularityKit.Mutator.Governance.Redis.Storage.Candidates;
9+
using ModularityKit.Mutator.Governance.Redis.Storage.Candidates.Execution;
10+
using ModularityKit.Mutator.Governance.Redis.Storage.Candidates.Planning;
11+
using ModularityKit.Mutator.Governance.Redis.Storage.Documents;
12+
using ModularityKit.Mutator.Governance.Redis.Storage.Documents.Keys;
13+
using ModularityKit.Mutator.Governance.Redis.Storage.Documents.Payloads;
14+
using ModularityKit.Mutator.Governance.Redis.Storage.Documents.Reading;
15+
using ModularityKit.Mutator.Governance.Redis.Storage.Identifiers;
16+
using ModularityKit.Mutator.Governance.Redis.Storage.Identifiers.Loading;
17+
using ModularityKit.Mutator.Governance.Redis.Storage.Persistence;
18+
using ModularityKit.Mutator.Governance.Redis.Storage.Persistence.Reading;
19+
using ModularityKit.Mutator.Governance.Redis.Storage.Persistence.Writing;
20+
using ModularityKit.Mutator.Governance.Redis.Storage.Queries;
21+
using ModularityKit.Mutator.Governance.Redis.Storage.Queries.Reading;
22+
using StackExchange.Redis;
23+
24+
namespace ModularityKit.Mutator.Governance.Redis;
25+
26+
/// <summary>
27+
/// Dependency injection registration for the Redis governance provider.
28+
/// </summary>
29+
public static class RedisGovernanceServiceCollectionExtensions
30+
{
31+
/// <summary>
32+
/// Registers Redis-backed governance request storage and query services.
33+
/// </summary>
34+
/// <param name="services">The service collection to configure.</param>
35+
/// <param name="connectionMultiplexer">The Redis connection multiplexer used by the provider.</param>
36+
/// <param name="configure">An optional callback for configuring provider options.</param>
37+
/// <returns>The same service collection for chaining.</returns>
38+
public static IServiceCollection AddRedisGovernanceStore(
39+
this IServiceCollection services,
40+
IConnectionMultiplexer connectionMultiplexer,
41+
Action<RedisMutationRequestStoreOptions>? configure = null)
42+
{
43+
ArgumentNullException.ThrowIfNull(services);
44+
ArgumentNullException.ThrowIfNull(connectionMultiplexer);
45+
46+
var options = new RedisMutationRequestStoreOptions();
47+
configure?.Invoke(options);
48+
49+
services.AddSingleton(connectionMultiplexer);
50+
services.AddSingleton(Microsoft.Extensions.Options.Options.Create(options));
51+
services.TryAddSingleton<RedisMutationRequestPersistence>();
52+
services.TryAddSingleton<RedisMutationRequestQueryReader>();
53+
services.TryAddSingleton(sp =>
54+
{
55+
var resolvedOptions = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<RedisMutationRequestStoreOptions>>();
56+
return new RedisMutationRequestKeyspace(resolvedOptions.Value);
57+
});
58+
services.TryAddSingleton(sp => sp.GetRequiredService<IConnectionMultiplexer>().GetDatabase());
59+
services.TryAddSingleton<RedisMutationRequestPersistenceRecordFactory>();
60+
services.TryAddSingleton<RedisMutationRequestPersistenceDocumentReader>();
61+
services.TryAddSingleton<RedisMutationRequestIndexWriter>();
62+
services.TryAddSingleton<RedisMutationRequestTransactionWriter>();
63+
services.TryAddSingleton<RedisMutationRequestIdentifierSetLoader>();
64+
services.TryAddSingleton<RedisMutationRequestIdSetReader>();
65+
services.TryAddSingleton<RedisMutationRequestDocumentKeyFactory>();
66+
services.TryAddSingleton<RedisMutationRequestPayloadReader>();
67+
services.TryAddSingleton<RedisMutationRequestDocumentReader>();
68+
services.TryAddSingleton<RedisMutationRequestCandidatePlanBuilder>();
69+
services.TryAddSingleton<RedisMutationRequestCandidateExecutor>();
70+
services.TryAddSingleton<RedisMutationRequestQueryCandidateSelector>();
71+
services.TryAddSingleton<RedisMutationRequestQueryDocumentLoader>();
72+
services.TryAddSingleton(sp => new RedisMutationRequestStore(
73+
sp.GetRequiredService<RedisMutationRequestPersistence>(),
74+
sp.GetRequiredService<RedisMutationRequestQueryReader>()));
75+
services.TryAddSingleton<IMutationRequestStore>(sp => sp.GetRequiredService<RedisMutationRequestStore>());
76+
services.TryAddSingleton<IMutationRequestQueryStore>(sp => sp.GetRequiredService<RedisMutationRequestStore>());
77+
78+
return services;
79+
}
80+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
using ModularityKit.Mutator.Governance.Abstractions.Queries.Model;
2+
using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
3+
4+
namespace ModularityKit.Mutator.Governance.Redis.Storage.Queries.Materialization;
5+
6+
/// <summary>
7+
/// Applies common ordering rules for Redis backed governance query results.
8+
/// </summary>
9+
internal static class RedisMutationRequestOrdering
10+
{
11+
public static IReadOnlyList<MutationRequest> ByCreated(IEnumerable<MutationRequest> requests)
12+
=> requests
13+
.OrderBy(request => request.CreatedAt)
14+
.ThenBy(request => request.RequestId)
15+
.ToList();
16+
17+
public static IReadOnlyList<MutationRequest> ByRecentApprovals(
18+
IEnumerable<MutationRequest> requests,
19+
int? take)
20+
{
21+
IEnumerable<MutationRequest> results = requests
22+
.OrderByDescending(MutationRequestQueryEvaluator.GetRecentApprovalTimestamp)
23+
.ThenByDescending(request => request.UpdatedAt)
24+
.ThenBy(request => request.RequestId);
25+
26+
if (take is >= 0)
27+
results = results.Take(take.Value);
28+
29+
return results.ToList();
30+
}
31+
32+
public static IReadOnlyList<MutationApprovalView> ByPendingApprovalView(
33+
IEnumerable<MutationApprovalView> views)
34+
=> views
35+
.OrderBy(view => view.Request.CreatedAt)
36+
.ThenBy(view => view.Request.RequestId)
37+
.ThenBy(view => view.Approval.StepOrder)
38+
.ThenBy(view => view.Approval.ApprovalId)
39+
.ToList();
40+
41+
public static IReadOnlyList<MutationRequestDecisionView> ByRecentDecisionView(
42+
IEnumerable<MutationRequestDecisionView> views,
43+
int? take)
44+
{
45+
IEnumerable<MutationRequestDecisionView> results = views
46+
.OrderByDescending(view => view.Decision.Timestamp)
47+
.ThenByDescending(view => view.Request.UpdatedAt)
48+
.ThenBy(view => view.Request.RequestId);
49+
50+
if (take is >= 0)
51+
results = results.Take(take.Value);
52+
53+
return results.ToList();
54+
}
55+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model;
2+
using ModularityKit.Mutator.Governance.Abstractions.Queries.Model;
3+
using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
4+
5+
namespace ModularityKit.Mutator.Governance.Redis.Storage.Queries.Materialization;
6+
7+
/// <summary>
8+
/// Applies governance query evaluators to materialized Redis request documents.
9+
/// </summary>
10+
internal static class RedisMutationRequestQueryMaterializer
11+
{
12+
/// <summary>
13+
/// Applies a general request query to already materialized requests.
14+
/// </summary>
15+
/// <param name="requests">The materialized requests.</param>
16+
/// <param name="query">The query to evaluate.</param>
17+
/// <returns>The filtered request results.</returns>
18+
public static IReadOnlyList<MutationRequest> ApplyQuery(
19+
IEnumerable<MutationRequest> requests,
20+
MutationRequestQuery query)
21+
{
22+
ArgumentNullException.ThrowIfNull(requests);
23+
ArgumentNullException.ThrowIfNull(query);
24+
25+
return RedisMutationRequestOrdering.ByCreated(
26+
requests.Where(request => MutationRequestQueryEvaluator.Matches(request, query)));
27+
}
28+
29+
/// <summary>
30+
/// Applies a pending request query to already materialized requests.
31+
/// </summary>
32+
/// <param name="requests">The materialized requests.</param>
33+
/// <param name="query">The query to evaluate.</param>
34+
/// <returns>The filtered pending-request results.</returns>
35+
public static IReadOnlyList<MutationRequest> ApplyPendingQuery(
36+
IEnumerable<MutationRequest> requests,
37+
MutationRequestQuery query)
38+
{
39+
ArgumentNullException.ThrowIfNull(requests);
40+
ArgumentNullException.ThrowIfNull(query);
41+
42+
return RedisMutationRequestOrdering.ByCreated(
43+
requests.Where(request =>
44+
request.Status == MutationRequestStatus.Pending &&
45+
MutationRequestQueryEvaluator.Matches(request, query)));
46+
}
47+
48+
/// <summary>
49+
/// Applies a pending approval queue query to already materialized requests.
50+
/// </summary>
51+
/// <param name="requests">The materialized requests.</param>
52+
/// <param name="query">The query to evaluate.</param>
53+
/// <returns>The filtered pending-approval-queue results.</returns>
54+
public static IReadOnlyList<MutationRequest> ApplyPendingApprovalQueueQuery(
55+
IEnumerable<MutationRequest> requests,
56+
MutationRequestQuery query)
57+
{
58+
ArgumentNullException.ThrowIfNull(requests);
59+
ArgumentNullException.ThrowIfNull(query);
60+
61+
return RedisMutationRequestOrdering.ByCreated(
62+
requests.Where(request =>
63+
request.Status == MutationRequestStatus.Pending &&
64+
request.PendingReason == PendingMutationReason.Approval &&
65+
MutationRequestQueryEvaluator.Matches(request, query)));
66+
}
67+
68+
/// <summary>
69+
/// Applies a recent approvals query to already materialized requests.
70+
/// </summary>
71+
/// <param name="requests">The materialized requests.</param>
72+
/// <param name="query">The query to evaluate.</param>
73+
/// <param name="take">An optional result limit.</param>
74+
/// <returns>The filtered recent-approval results.</returns>
75+
public static IReadOnlyList<MutationRequest> ApplyRecentApprovalsQuery(
76+
IEnumerable<MutationRequest> requests,
77+
MutationRequestQuery query,
78+
int? take)
79+
{
80+
ArgumentNullException.ThrowIfNull(requests);
81+
ArgumentNullException.ThrowIfNull(query);
82+
83+
var results = requests
84+
.Where(request =>
85+
MutationRequestQueryEvaluator.Matches(request, query) &&
86+
MutationRequestQueryEvaluator.HasApprovalActivity(request));
87+
88+
return RedisMutationRequestOrdering.ByRecentApprovals(results, take);
89+
}
90+
91+
/// <summary>
92+
/// Applies a pending approval view query to already materialized requests.
93+
/// </summary>
94+
/// <param name="requests">The materialized requests.</param>
95+
/// <param name="query">The approval query to evaluate.</param>
96+
/// <returns>The filtered approval-view results.</returns>
97+
public static IReadOnlyList<MutationApprovalView> ApplyPendingApprovalViewQuery(
98+
IEnumerable<MutationRequest> requests,
99+
MutationApprovalQuery query)
100+
{
101+
ArgumentNullException.ThrowIfNull(requests);
102+
ArgumentNullException.ThrowIfNull(query);
103+
104+
var views = RedisMutationRequestViewProjector
105+
.ToApprovalViews(requests)
106+
.Where(view => MutationApprovalQueryEvaluator.Matches(view.Request, view.Approval, query));
107+
108+
return RedisMutationRequestOrdering.ByPendingApprovalView(views);
109+
}
110+
111+
/// <summary>
112+
/// Applies a recent decision query to already materialized requests.
113+
/// </summary>
114+
/// <param name="requests">The materialized requests.</param>
115+
/// <param name="query">The decision query to evaluate.</param>
116+
/// <param name="take">An optional result limit.</param>
117+
/// <returns>The filtered decision-view results.</returns>
118+
public static IReadOnlyList<MutationRequestDecisionView> ApplyRecentDecisionQuery(
119+
IEnumerable<MutationRequest> requests,
120+
MutationRequestDecisionQuery query,
121+
int? take)
122+
{
123+
ArgumentNullException.ThrowIfNull(requests);
124+
ArgumentNullException.ThrowIfNull(query);
125+
126+
var views = RedisMutationRequestViewProjector
127+
.ToDecisionViews(requests)
128+
.Where(view => MutationRequestDecisionQueryEvaluator.Matches(view.Request, view.Decision, query));
129+
130+
return RedisMutationRequestOrdering.ByRecentDecisionView(views, take);
131+
}
132+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using ModularityKit.Mutator.Governance.Abstractions.Queries.Model;
2+
using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
3+
4+
namespace ModularityKit.Mutator.Governance.Redis.Storage.Queries.Materialization;
5+
6+
/// <summary>
7+
/// Projects governed request documents into query specific view models.
8+
/// </summary>
9+
internal static class RedisMutationRequestViewProjector
10+
{
11+
public static IEnumerable<MutationApprovalView> ToApprovalViews(IEnumerable<MutationRequest> requests)
12+
{
13+
ArgumentNullException.ThrowIfNull(requests);
14+
15+
return requests.SelectMany(request => request.ApprovalRequirements.Select(approval => new MutationApprovalView
16+
{
17+
Request = request,
18+
Approval = approval
19+
}));
20+
}
21+
22+
public static IEnumerable<MutationRequestDecisionView> ToDecisionViews(IEnumerable<MutationRequest> requests)
23+
{
24+
ArgumentNullException.ThrowIfNull(requests);
25+
26+
return requests.SelectMany(request => request.Decisions.Select(decision => new MutationRequestDecisionView
27+
{
28+
Request = request,
29+
Decision = decision
30+
}));
31+
}
32+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
using ModularityKit.Mutator.Governance.Abstractions.Lifecycle.Model;
2+
using ModularityKit.Mutator.Governance.Abstractions.Queries.Model;
3+
using ModularityKit.Mutator.Governance.Abstractions.Requests.Model;
4+
using ModularityKit.Mutator.Governance.Redis.Storage.Candidates;
5+
using ModularityKit.Mutator.Governance.Redis.Storage.Documents.Reading;
6+
7+
namespace ModularityKit.Mutator.Governance.Redis.Storage.Queries.Reading;
8+
9+
/// <summary>
10+
/// Loads governed request documents for Redis backed query flows.
11+
/// </summary>
12+
internal sealed class RedisMutationRequestQueryDocumentLoader(
13+
RedisMutationRequestQueryCandidateSelector candidateSelector,
14+
RedisMutationRequestDocumentReader documentReader)
15+
{
16+
private readonly RedisMutationRequestQueryCandidateSelector _candidateSelector =
17+
candidateSelector ?? throw new ArgumentNullException(nameof(candidateSelector));
18+
private readonly RedisMutationRequestDocumentReader _documentReader =
19+
documentReader ?? throw new ArgumentNullException(nameof(documentReader));
20+
21+
/// <summary>
22+
/// Loads governed request documents for a specific state.
23+
/// </summary>
24+
/// <param name="stateId">The state identifier.</param>
25+
/// <param name="cancellationToken">The cancellation token.</param>
26+
/// <returns>The loaded request documents.</returns>
27+
public async Task<IReadOnlyList<MutationRequest>> LoadByStateIdAsync(
28+
string stateId,
29+
CancellationToken cancellationToken)
30+
{
31+
var requestIds = await _candidateSelector.LoadByStateIdAsync(stateId, cancellationToken).ConfigureAwait(false);
32+
return await _documentReader.LoadOrderedByCreatedAsync(requestIds, cancellationToken).ConfigureAwait(false);
33+
}
34+
35+
/// <summary>
36+
/// Loads pending governed request documents, optionally narrowed by pending reason.
37+
/// </summary>
38+
/// <param name="reason">The optional pending reason.</param>
39+
/// <param name="cancellationToken">The cancellation token.</param>
40+
/// <returns>The loaded request documents.</returns>
41+
public async Task<IReadOnlyList<MutationRequest>> LoadPendingAsync(
42+
PendingMutationReason? reason,
43+
CancellationToken cancellationToken)
44+
{
45+
var requestIds = await _candidateSelector.LoadPendingAsync(reason, cancellationToken).ConfigureAwait(false);
46+
return await _documentReader.LoadOrderedByCreatedAsync(requestIds, cancellationToken).ConfigureAwait(false);
47+
}
48+
49+
/// <summary>
50+
/// Loads pending governed request documents for a specific state.
51+
/// </summary>
52+
/// <param name="stateId">The state identifier.</param>
53+
/// <param name="reason">The optional pending reason.</param>
54+
/// <param name="cancellationToken">The cancellation token.</param>
55+
/// <returns>The loaded request documents.</returns>
56+
public async Task<IReadOnlyList<MutationRequest>> LoadPendingByStateIdAsync(
57+
string stateId,
58+
PendingMutationReason? reason,
59+
CancellationToken cancellationToken)
60+
{
61+
var requestIds = await _candidateSelector.LoadPendingByStateIdAsync(stateId, reason, cancellationToken)
62+
.ConfigureAwait(false);
63+
return await _documentReader.LoadOrderedByCreatedAsync(requestIds, cancellationToken).ConfigureAwait(false);
64+
}
65+
66+
/// <summary>
67+
/// Loads governed request documents for a general request query.
68+
/// </summary>
69+
/// <param name="query">The query to narrow through Redis candidates.</param>
70+
/// <param name="cancellationToken">The cancellation token.</param>
71+
/// <returns>The loaded request documents.</returns>
72+
public async Task<IReadOnlyList<MutationRequest>> LoadByRequestQueryAsync(
73+
MutationRequestQuery query,
74+
CancellationToken cancellationToken)
75+
{
76+
ArgumentNullException.ThrowIfNull(query);
77+
78+
var requestIds = await _candidateSelector.LoadQueryCandidatesAsync(query, cancellationToken).ConfigureAwait(false);
79+
return await _documentReader.LoadOrderedByCreatedAsync(requestIds, cancellationToken).ConfigureAwait(false);
80+
}
81+
}

0 commit comments

Comments
 (0)