Skip to content

Commit 7d9737d

Browse files
authored
Validation and health check changes. (#97)
1 parent 929adec commit 7d9737d

37 files changed

+560
-454
lines changed

CHANGELOG.md

+14
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,20 @@
22

33
Represents the **NuGet** versions.
44

5+
## v3.17.0
6+
- *Enhancement*: Additional `CoreEx.Validation` usability improvements:
7+
- `Validator.CreateFor<T>` added to enable the creation of a `CommonValidator<T>` instance for a specified type `T` (more purposeful name); synonym for existing `CommonValidator.Create<T>` (unchanged).
8+
- `Validator.Null<T>` added to enable simplified specification of a `IValidatorEx<T>` of `null` to avoid explicit `null` casting.
9+
- `Collection` extension method has additional overload to pass in the `IValidatorEx<TItem>` to use for each item in the collection; versus, having to use `CollectionRuleItem.Create`.
10+
- `Dictionary` extension method has additional overload to pass in the `IValidatorEx<TKey>` and `IValidator<TValue>` to use for each entry in the dictionary; versus, having to use `DictionaryRuleItem.Create`.
11+
- `MinimumCount` and `MaximumCount` extension methods for `ICollection` added to enable explicit specification of these two basic validations.
12+
- `Validation.CreateCollection` renamed to `Validation.CreateForCollection` and creates a `CommonValidator<TColl>`.
13+
- Existing `CollectionValidator` deprecated as the `CommonValidator<TColl>` offers same; removes duplication of capability.
14+
- `Validation.CreateDictionary` renamed to `Validation.CreateForDictionary` and creates a `CommonValidator<TDict>`.
15+
- Existing `DictionaryValidator` deprecated as the `CommonValidator<TDict>` offers same; removes duplication of capability.
16+
- *Enhancement*: Added `ServiceBusReceiverHealthCheck` to perform a peek message on the `ServiceBusReceiver` as a means to determine health. Use `IHealthChecksBuilder.AddServiceBusReceiverHealthCheck` to configure.
17+
- *Fixed:* The `FileLockSynchronizer`, `BlobLeaseSynchronizer` and `TableWorkStatePersistence` have had any file/blob/table validations/manipulations moved from the constructor to limit critical failures at startup from a DI perspective; now only performed where required/used. This also allows improved health check opportunities as means to verify.
18+
519
## v3.16.0
620
- *Enhancement*: Added basic [FluentValidator](https://docs.fluentvalidation.net/en/latest/) compatibility to the `CoreEx.Validation` by supporting _key_ (common) named capabilities:
721
- `AbstractValidator<T>` added as a wrapper for `Validator<T>`; with both supporting `RuleFor` method (wrapper for existing `Property`).

Common.targets

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<Project>
22
<PropertyGroup>
3-
<Version>3.16.0</Version>
3+
<Version>3.17.0</Version>
44
<LangVersion>preview</LangVersion>
55
<Authors>Avanade</Authors>
66
<Company>Avanade</Company>

samples/My.Hr/My.Hr.Api/Startup.cs

+5-2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
using OpenTelemetry.Trace;
66
using Az = Azure.Messaging.ServiceBus;
77
using CoreEx.Database.HealthChecks;
8+
using CoreEx.Azure.ServiceBus.HealthChecks;
9+
using Azure.Messaging.ServiceBus;
10+
using Microsoft.Extensions.DependencyInjection;
811

912
namespace My.Hr.Api;
1013

@@ -51,8 +54,8 @@ public void ConfigureServices(IServiceCollection services)
5154

5255
// Register the health checks.
5356
services
54-
.AddHealthChecks();
55-
//.AddTypeActivatedCheck<AzureServiceBusQueueHealthCheck>("Verification Queue", HealthStatus.Unhealthy, nameof(HrSettings.ServiceBusConnection), nameof(HrSettings.VerificationQueueName))
57+
.AddHealthChecks()
58+
.AddServiceBusReceiverHealthCheck(sp => sp.GetRequiredService<Az.ServiceBusClient>().CreateReceiver(sp.GetRequiredService<HrSettings>().VerificationQueueName), "verification-queue");
5659

5760
services.AddControllers();
5861

samples/My.Hr/My.Hr.Api/appsettings.Development.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
}
77
},
88
"AllowedHosts": "*",
9-
"VerificationQueueName": "pendingVerifications",
9+
"VerificationQueueName": "verification-queue",
1010
"ServiceBusConnection": "coreex.servicebus.windows.net",
1111
"ConnectionStrings": {
1212
"Database": "Data Source=.;Initial Catalog=My.HrDb;Integrated Security=True;TrustServerCertificate=true"

samples/My.Hr/My.Hr.Api/appsettings.json

+3
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,8 @@
1414
"AbsoluteExpirationRelativeToNow": "03:00:00",
1515
"SlidingExpiration": "00:45:00"
1616
}
17+
},
18+
"ServiceBusConnection": {
19+
"fullyQualifiedNamespace": "Endpoint=sb://top-secret.servicebus.windows.net/;SharedAccessKeyName=top-secret;SharedAccessKey=top-encrypted-secret"
1720
}
1821
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx
2+
3+
using Azure.Messaging.ServiceBus;
4+
using Microsoft.Extensions.Diagnostics.HealthChecks;
5+
using System;
6+
using System.Collections.Generic;
7+
using System.Threading;
8+
using System.Threading.Tasks;
9+
10+
namespace CoreEx.Azure.ServiceBus.HealthChecks
11+
{
12+
/// <summary>
13+
/// Provides a <see cref="ServiceBusReceiver"/> <see cref="IHealthCheck"/> to verify the receiver is accessible by peeking a message.
14+
/// </summary>
15+
/// <param name="receiverFactory">The <see cref="ServiceBusReceiver"/> create factory.</param>
16+
public class ServiceBusReceiverHealthCheck(Func<ServiceBusReceiver> receiverFactory) : IHealthCheck
17+
{
18+
private readonly Func<ServiceBusReceiver> _receiverFactory = receiverFactory.ThrowIfNull(nameof(receiverFactory));
19+
20+
/// <inheritdoc/>
21+
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
22+
{
23+
await using var receiver = _receiverFactory() ?? throw new InvalidOperationException("The ServiceBusReceiver factory returned null.");
24+
var msg = await receiver.PeekMessageAsync(null, cancellationToken).ConfigureAwait(false);
25+
return HealthCheckResult.Healthy(null, new Dictionary<string, object>{ { "message", msg is null ? "none" : new Message { MessageId = msg.MessageId, CorrelationId = msg.CorrelationId, Subject = msg.Subject, SessionId = msg.SessionId, PartitionKey = msg.PartitionKey } } });
26+
}
27+
28+
private class Message
29+
{
30+
public string? MessageId { get; set; }
31+
public string? CorrelationId { get; set; }
32+
public string? Subject { get; set; }
33+
public string? SessionId { get; set; }
34+
public string? PartitionKey { get; set; }
35+
}
36+
}
37+
}

src/CoreEx.Azure/ServiceBus/IServiceCollectionExtensions.cs

+22
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22

33
using CoreEx;
44
using CoreEx.Azure.ServiceBus;
5+
using CoreEx.Azure.ServiceBus.HealthChecks;
56
using CoreEx.Configuration;
67
using CoreEx.Events;
78
using CoreEx.Events.Subscribing;
9+
using Microsoft.Extensions.Diagnostics.HealthChecks;
810
using Microsoft.Extensions.Logging;
911
using System;
12+
using System.Collections.Generic;
1013
using Asb = Azure.Messaging.ServiceBus;
1114

1215
namespace Microsoft.Extensions.DependencyInjection
@@ -75,5 +78,24 @@ public static IServiceCollection AddAzureServiceBusPurger(this IServiceCollectio
7578
configure?.Invoke(sp, sbp);
7679
return sbp;
7780
});
81+
82+
/// <summary>
83+
/// Adds a <see cref="ServiceBusReceiverHealthCheck"/> that will peek a message from the Azure Service Bus receiver to confirm health.
84+
/// </summary>
85+
/// <param name="builder">The <see cref="IHealthChecksBuilder"/>.</param>
86+
/// <param name="name">The health check name. Defaults to '<c>azure-service-bus-receiver</c>'.</param>
87+
/// <param name="serviceBusReceiverFactory">The <see cref="Asb.ServiceBusReceiver"/> factory.</param>
88+
/// <param name="failureStatus">The <see cref="HealthStatus"/> that should be reported when the health check reports a failure. If the provided value is <c>null</c>, then <see cref="HealthStatus.Unhealthy"/> will be reported.</param>
89+
/// <param name="tags">A list of tags that can be used for filtering health checks.</param>
90+
/// <param name="timeout">An optional <see cref="TimeSpan"/> representing the timeout of the check.</param>
91+
public static IHealthChecksBuilder AddServiceBusReceiverHealthCheck(this IHealthChecksBuilder builder, Func<IServiceProvider, Asb.ServiceBusReceiver> serviceBusReceiverFactory, string? name = null, HealthStatus? failureStatus = default, IEnumerable<string>? tags = default, TimeSpan? timeout = default)
92+
{
93+
serviceBusReceiverFactory.ThrowIfNull(nameof(serviceBusReceiverFactory));
94+
95+
return builder.Add(new HealthCheckRegistration(name ?? "azure-service-bus-receiver", sp =>
96+
{
97+
return new ServiceBusReceiverHealthCheck(() => serviceBusReceiverFactory(sp));
98+
}, failureStatus, tags, timeout));
99+
}
78100
}
79101
}

src/CoreEx.Azure/Storage/BlobLeaseSynchronizer.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,6 @@ public class BlobLeaseSynchronizer : IServiceSynchronizer
3737
public BlobLeaseSynchronizer(BlobContainerClient client)
3838
{
3939
_client = client.ThrowIfNull(nameof(client));
40-
_client.CreateIfNotExists();
41-
4240
_timer = new Lazy<Timer>(() => new Timer(_ =>
4341
{
4442
foreach (var kvp in _dict.ToArray())
@@ -74,6 +72,8 @@ public bool Enter<T>(string? name = null)
7472

7573
_dict.GetOrAdd(GetName<T>(name), fn =>
7674
{
75+
_client.CreateIfNotExists();
76+
7777
var blob = _client.GetBlobClient(GetName<T>(name));
7878
try
7979
{

src/CoreEx.Azure/Storage/TableWorkStatePersistence.cs

+43-4
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ public class TableWorkStatePersistence : IWorkStatePersistence
2323
private readonly TableClient _workStateTableClient;
2424
private readonly TableClient _workDataTableClient;
2525
private readonly IJsonSerializer _jsonSerializer;
26+
private readonly SemaphoreSlim _semaphore = new(1, 1);
27+
private volatile bool _firstTime = true;
2628

2729
/// <summary>
2830
/// Initializes a new instance of the <see cref="TableWorkStatePersistence"/> class.
@@ -84,6 +86,7 @@ private class WorkDataEntity() : ITableEntity
8486
private const int _maxChunks = 15;
8587
private const int _maxSize = _chunkSize * _maxChunks;
8688
private readonly BinaryData?[] _data = new BinaryData?[_maxChunks];
89+
8790
public WorkDataEntity(BinaryData data) : this()
8891
{
8992
var arr = data.ToArray();
@@ -101,6 +104,7 @@ public WorkDataEntity(BinaryData data) : this()
101104
_data[i++] = BinaryData.FromBytes(chunk);
102105
}
103106
}
107+
104108
public string PartitionKey { get; set; } = GetPartitionKey();
105109
public string RowKey { get; set; } = null!;
106110
public DateTimeOffset? Timestamp { get; set; }
@@ -149,9 +153,35 @@ public WorkDataEntity(BinaryData data) : this()
149153
/// </summary>
150154
private static string GetPartitionKey() => (ExecutionContext.HasCurrent ? ExecutionContext.Current.TenantId : null) ?? "default";
151155

156+
/// <summary>
157+
/// Creates the tables if they do not already exist.
158+
/// </summary>
159+
private async Task CreateIfNotExistsAsync(CancellationToken cancellationToken)
160+
{
161+
if (_firstTime)
162+
{
163+
await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
164+
try
165+
{
166+
if (_firstTime)
167+
{
168+
await _workDataTableClient.CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false);
169+
await _workStateTableClient.CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false);
170+
_firstTime = false;
171+
}
172+
}
173+
finally
174+
{
175+
_semaphore.Release();
176+
}
177+
}
178+
}
179+
152180
/// <inheritdoc/>
153181
public async Task<WorkState?> GetAsync(string id, CancellationToken cancellationToken)
154182
{
183+
await CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false);
184+
155185
var er = await _workStateTableClient.GetEntityIfExistsAsync<WorkStateEntity>(GetPartitionKey(), id, cancellationToken: cancellationToken).ConfigureAwait(false);
156186
if (!er.HasValue)
157187
return null;
@@ -182,25 +212,34 @@ public WorkDataEntity(BinaryData data) : this()
182212
/// <summary>
183213
/// Performs an upsert (create/update).
184214
/// </summary>
185-
private async Task UpsertAsync(WorkState state, CancellationToken cancellationToken)
186-
=> await _workStateTableClient.UpsertEntityAsync(new WorkStateEntity(state), TableUpdateMode.Replace, cancellationToken).ConfigureAwait(false);
215+
private async Task UpsertAsync(WorkState state, CancellationToken cancellationToken)
216+
{
217+
await CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false);
218+
await _workStateTableClient.UpsertEntityAsync(new WorkStateEntity(state), TableUpdateMode.Replace, cancellationToken).ConfigureAwait(false);
219+
}
187220

188221
/// <inheritdoc/>
189222
public async Task DeleteAsync(string id, CancellationToken cancellationToken)
190223
{
224+
await CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false);
191225
await _workDataTableClient.DeleteEntityAsync(GetPartitionKey(), id, cancellationToken: cancellationToken).ConfigureAwait(false);
192226
await _workStateTableClient.DeleteEntityAsync(GetPartitionKey(), id, cancellationToken: cancellationToken).ConfigureAwait(false);
193227
}
194228

195229
/// <inheritdoc/>
196230
public async Task<BinaryData?> GetDataAsync(string id, CancellationToken cancellationToken)
197231
{
232+
await CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false);
233+
198234
var er = await _workDataTableClient.GetEntityIfExistsAsync<WorkDataEntity>(GetPartitionKey(), id, cancellationToken: cancellationToken).ConfigureAwait(false);
199235
return er.HasValue ? er.Value!.ToSingleData() : null;
200236
}
201237

202238
/// <inheritdoc/>
203-
public Task SetDataAsync(string id, BinaryData data, CancellationToken cancellationToken)
204-
=> _workDataTableClient.UpsertEntityAsync(new WorkDataEntity(data) { PartitionKey = GetPartitionKey(), RowKey = id }, TableUpdateMode.Replace, cancellationToken: cancellationToken);
239+
public async Task SetDataAsync(string id, BinaryData data, CancellationToken cancellationToken)
240+
{
241+
await CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false);
242+
await _workDataTableClient.UpsertEntityAsync(new WorkDataEntity(data) { PartitionKey = GetPartitionKey(), RowKey = id }, TableUpdateMode.Replace, cancellationToken: cancellationToken).ConfigureAwait(false);
243+
}
205244
}
206245
}

src/CoreEx.Database.SqlServer/DatabaseServiceCollectionExtensions.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ public static IServiceCollection AddSqlServerEventOutboxHostedService(this IServ
3535
var hc = healthCheck ? new TimerHostedServiceHealthCheck() : null;
3636
if (hc is not null)
3737
{
38-
var sb = new StringBuilder("EventOutbox");
38+
var sb = new StringBuilder("sql-server-event-outbox");
3939
if (partitionKey is not null)
4040
sb.Append($"-PartitionKey-{partitionKey}");
4141

src/CoreEx.Database/DatabaseServiceCollectionExtensions.cs

+30-4
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,20 @@ public static class DatabaseServiceCollectionExtensions
2222
public static IServiceCollection AddDatabase(this IServiceCollection services, Func<IServiceProvider, IDatabase> create, bool healthCheck = true)
2323
{
2424
services.AddScoped(sp => create(sp) ?? throw new InvalidOperationException($"An {nameof(IDatabase)} instance must be instantiated."));
25-
return AddHealthCheck(services, healthCheck);
25+
return AddHealthCheck(services, healthCheck, null);
26+
}
27+
28+
/// <summary>
29+
/// Adds an <see cref="IDatabase"/> as a scoped service including a corresponding health check.
30+
/// </summary>
31+
/// <param name="services">The <see cref="IServiceCollection"/>.</param>
32+
/// <param name="create">The function to create the <see cref="IDatabase"/> instance.</param>
33+
/// <param name="healthCheckName">The health check name; defaults to '<c>database</c>'.</param>
34+
/// <returns>The <see cref="IServiceCollection"/> to support fluent-style method-chaining.</returns>
35+
public static IServiceCollection AddDatabase(this IServiceCollection services, Func<IServiceProvider, IDatabase> create, string? healthCheckName)
36+
{
37+
services.AddScoped(sp => create(sp) ?? throw new InvalidOperationException($"An {nameof(IDatabase)} instance must be instantiated."));
38+
return AddHealthCheck(services, true, healthCheckName);
2639
}
2740

2841
/// <summary>
@@ -35,16 +48,29 @@ public static IServiceCollection AddDatabase(this IServiceCollection services, F
3548
public static IServiceCollection AddDatabase<TDb>(this IServiceCollection services, bool healthCheck = true) where TDb : class, IDatabase
3649
{
3750
services.AddScoped<IDatabase, TDb>();
38-
return AddHealthCheck(services, healthCheck);
51+
return AddHealthCheck(services, healthCheck, null);
52+
}
53+
54+
/// <summary>
55+
/// Adds an <see cref="IDatabase"/> as a scoped service including a corresponding health check.
56+
/// </summary>
57+
/// <typeparam name="TDb">The <see cref="IDatabase"/> <see cref="Type"/>.</typeparam>
58+
/// <param name="services">The <see cref="IServiceCollection"/>.</param>
59+
/// <param name="healthCheckName">The health check name; defaults to '<c>database</c>'.</param>
60+
/// <returns>The <see cref="IServiceCollection"/> to support fluent-style method-chaining.</returns>
61+
public static IServiceCollection AddDatabase<TDb>(this IServiceCollection services, string? healthCheckName) where TDb : class, IDatabase
62+
{
63+
services.AddScoped<IDatabase, TDb>();
64+
return AddHealthCheck(services, true, healthCheckName);
3965
}
4066

4167
/// <summary>
4268
/// Adds the <see cref="DatabaseHealthCheck{TDatabase}"/> where configured to do so.
4369
/// </summary>
44-
private static IServiceCollection AddHealthCheck(this IServiceCollection services, bool healthCheck)
70+
private static IServiceCollection AddHealthCheck(this IServiceCollection services, bool healthCheck, string? healthCheckName)
4571
{
4672
if (healthCheck)
47-
services.AddHealthChecks().AddTypeActivatedCheck<DatabaseHealthCheck<IDatabase>>("Database", HealthStatus.Unhealthy, tags: default!, timeout: TimeSpan.FromSeconds(30));
73+
services.AddHealthChecks().AddTypeActivatedCheck<DatabaseHealthCheck<IDatabase>>(healthCheckName ?? "database", HealthStatus.Unhealthy, tags: default!, timeout: TimeSpan.FromSeconds(30));
4874

4975
return services;
5076
}

0 commit comments

Comments
 (0)