Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

v3.17.0 #97

Merged
merged 1 commit into from
Apr 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

Represents the **NuGet** versions.

## v3.17.0
- *Enhancement*: Additional `CoreEx.Validation` usability improvements:
- `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).
- `Validator.Null<T>` added to enable simplified specification of a `IValidatorEx<T>` of `null` to avoid explicit `null` casting.
- `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`.
- `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`.
- `MinimumCount` and `MaximumCount` extension methods for `ICollection` added to enable explicit specification of these two basic validations.
- `Validation.CreateCollection` renamed to `Validation.CreateForCollection` and creates a `CommonValidator<TColl>`.
- Existing `CollectionValidator` deprecated as the `CommonValidator<TColl>` offers same; removes duplication of capability.
- `Validation.CreateDictionary` renamed to `Validation.CreateForDictionary` and creates a `CommonValidator<TDict>`.
- Existing `DictionaryValidator` deprecated as the `CommonValidator<TDict>` offers same; removes duplication of capability.
- *Enhancement*: Added `ServiceBusReceiverHealthCheck` to perform a peek message on the `ServiceBusReceiver` as a means to determine health. Use `IHealthChecksBuilder.AddServiceBusReceiverHealthCheck` to configure.
- *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.

## v3.16.0
- *Enhancement*: Added basic [FluentValidator](https://docs.fluentvalidation.net/en/latest/) compatibility to the `CoreEx.Validation` by supporting _key_ (common) named capabilities:
- `AbstractValidator<T>` added as a wrapper for `Validator<T>`; with both supporting `RuleFor` method (wrapper for existing `Property`).
Expand Down
2 changes: 1 addition & 1 deletion Common.targets
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>3.16.0</Version>
<Version>3.17.0</Version>
<LangVersion>preview</LangVersion>
<Authors>Avanade</Authors>
<Company>Avanade</Company>
Expand Down
7 changes: 5 additions & 2 deletions samples/My.Hr/My.Hr.Api/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
using OpenTelemetry.Trace;
using Az = Azure.Messaging.ServiceBus;
using CoreEx.Database.HealthChecks;
using CoreEx.Azure.ServiceBus.HealthChecks;
using Azure.Messaging.ServiceBus;
using Microsoft.Extensions.DependencyInjection;

namespace My.Hr.Api;

Expand Down Expand Up @@ -51,8 +54,8 @@ public void ConfigureServices(IServiceCollection services)

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

services.AddControllers();

Expand Down
2 changes: 1 addition & 1 deletion samples/My.Hr/My.Hr.Api/appsettings.Development.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
}
},
"AllowedHosts": "*",
"VerificationQueueName": "pendingVerifications",
"VerificationQueueName": "verification-queue",
"ServiceBusConnection": "coreex.servicebus.windows.net",
"ConnectionStrings": {
"Database": "Data Source=.;Initial Catalog=My.HrDb;Integrated Security=True;TrustServerCertificate=true"
Expand Down
3 changes: 3 additions & 0 deletions samples/My.Hr/My.Hr.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,8 @@
"AbsoluteExpirationRelativeToNow": "03:00:00",
"SlidingExpiration": "00:45:00"
}
},
"ServiceBusConnection": {
"fullyQualifiedNamespace": "Endpoint=sb://top-secret.servicebus.windows.net/;SharedAccessKeyName=top-secret;SharedAccessKey=top-encrypted-secret"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright (c) Avanade. Licensed under the MIT License. See https://github.com/Avanade/CoreEx

using Azure.Messaging.ServiceBus;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace CoreEx.Azure.ServiceBus.HealthChecks
{
/// <summary>
/// Provides a <see cref="ServiceBusReceiver"/> <see cref="IHealthCheck"/> to verify the receiver is accessible by peeking a message.
/// </summary>
/// <param name="receiverFactory">The <see cref="ServiceBusReceiver"/> create factory.</param>
public class ServiceBusReceiverHealthCheck(Func<ServiceBusReceiver> receiverFactory) : IHealthCheck
{
private readonly Func<ServiceBusReceiver> _receiverFactory = receiverFactory.ThrowIfNull(nameof(receiverFactory));

/// <inheritdoc/>
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
await using var receiver = _receiverFactory() ?? throw new InvalidOperationException("The ServiceBusReceiver factory returned null.");
var msg = await receiver.PeekMessageAsync(null, cancellationToken).ConfigureAwait(false);
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 } } });
}

private class Message
{
public string? MessageId { get; set; }
public string? CorrelationId { get; set; }
public string? Subject { get; set; }
public string? SessionId { get; set; }
public string? PartitionKey { get; set; }
}
}
}
22 changes: 22 additions & 0 deletions src/CoreEx.Azure/ServiceBus/IServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

using CoreEx;
using CoreEx.Azure.ServiceBus;
using CoreEx.Azure.ServiceBus.HealthChecks;
using CoreEx.Configuration;
using CoreEx.Events;
using CoreEx.Events.Subscribing;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using Asb = Azure.Messaging.ServiceBus;

namespace Microsoft.Extensions.DependencyInjection
Expand Down Expand Up @@ -75,5 +78,24 @@ public static IServiceCollection AddAzureServiceBusPurger(this IServiceCollectio
configure?.Invoke(sp, sbp);
return sbp;
});

/// <summary>
/// Adds a <see cref="ServiceBusReceiverHealthCheck"/> that will peek a message from the Azure Service Bus receiver to confirm health.
/// </summary>
/// <param name="builder">The <see cref="IHealthChecksBuilder"/>.</param>
/// <param name="name">The health check name. Defaults to '<c>azure-service-bus-receiver</c>'.</param>
/// <param name="serviceBusReceiverFactory">The <see cref="Asb.ServiceBusReceiver"/> factory.</param>
/// <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>
/// <param name="tags">A list of tags that can be used for filtering health checks.</param>
/// <param name="timeout">An optional <see cref="TimeSpan"/> representing the timeout of the check.</param>
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)
{
serviceBusReceiverFactory.ThrowIfNull(nameof(serviceBusReceiverFactory));

return builder.Add(new HealthCheckRegistration(name ?? "azure-service-bus-receiver", sp =>
{
return new ServiceBusReceiverHealthCheck(() => serviceBusReceiverFactory(sp));
}, failureStatus, tags, timeout));
}
}
}
4 changes: 2 additions & 2 deletions src/CoreEx.Azure/Storage/BlobLeaseSynchronizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,6 @@ public class BlobLeaseSynchronizer : IServiceSynchronizer
public BlobLeaseSynchronizer(BlobContainerClient client)
{
_client = client.ThrowIfNull(nameof(client));
_client.CreateIfNotExists();

_timer = new Lazy<Timer>(() => new Timer(_ =>
{
foreach (var kvp in _dict.ToArray())
Expand Down Expand Up @@ -74,6 +72,8 @@ public bool Enter<T>(string? name = null)

_dict.GetOrAdd(GetName<T>(name), fn =>
{
_client.CreateIfNotExists();

var blob = _client.GetBlobClient(GetName<T>(name));
try
{
Expand Down
47 changes: 43 additions & 4 deletions src/CoreEx.Azure/Storage/TableWorkStatePersistence.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ public class TableWorkStatePersistence : IWorkStatePersistence
private readonly TableClient _workStateTableClient;
private readonly TableClient _workDataTableClient;
private readonly IJsonSerializer _jsonSerializer;
private readonly SemaphoreSlim _semaphore = new(1, 1);
private volatile bool _firstTime = true;

/// <summary>
/// Initializes a new instance of the <see cref="TableWorkStatePersistence"/> class.
Expand Down Expand Up @@ -84,6 +86,7 @@ private class WorkDataEntity() : ITableEntity
private const int _maxChunks = 15;
private const int _maxSize = _chunkSize * _maxChunks;
private readonly BinaryData?[] _data = new BinaryData?[_maxChunks];

public WorkDataEntity(BinaryData data) : this()
{
var arr = data.ToArray();
Expand All @@ -101,6 +104,7 @@ public WorkDataEntity(BinaryData data) : this()
_data[i++] = BinaryData.FromBytes(chunk);
}
}

public string PartitionKey { get; set; } = GetPartitionKey();
public string RowKey { get; set; } = null!;
public DateTimeOffset? Timestamp { get; set; }
Expand Down Expand Up @@ -149,9 +153,35 @@ public WorkDataEntity(BinaryData data) : this()
/// </summary>
private static string GetPartitionKey() => (ExecutionContext.HasCurrent ? ExecutionContext.Current.TenantId : null) ?? "default";

/// <summary>
/// Creates the tables if they do not already exist.
/// </summary>
private async Task CreateIfNotExistsAsync(CancellationToken cancellationToken)
{
if (_firstTime)
{
await _semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_firstTime)
{
await _workDataTableClient.CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false);
await _workStateTableClient.CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false);
_firstTime = false;
}
}
finally
{
_semaphore.Release();
}
}
}

/// <inheritdoc/>
public async Task<WorkState?> GetAsync(string id, CancellationToken cancellationToken)
{
await CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false);

var er = await _workStateTableClient.GetEntityIfExistsAsync<WorkStateEntity>(GetPartitionKey(), id, cancellationToken: cancellationToken).ConfigureAwait(false);
if (!er.HasValue)
return null;
Expand Down Expand Up @@ -182,25 +212,34 @@ public WorkDataEntity(BinaryData data) : this()
/// <summary>
/// Performs an upsert (create/update).
/// </summary>
private async Task UpsertAsync(WorkState state, CancellationToken cancellationToken)
=> await _workStateTableClient.UpsertEntityAsync(new WorkStateEntity(state), TableUpdateMode.Replace, cancellationToken).ConfigureAwait(false);
private async Task UpsertAsync(WorkState state, CancellationToken cancellationToken)
{
await CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false);
await _workStateTableClient.UpsertEntityAsync(new WorkStateEntity(state), TableUpdateMode.Replace, cancellationToken).ConfigureAwait(false);
}

/// <inheritdoc/>
public async Task DeleteAsync(string id, CancellationToken cancellationToken)
{
await CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false);
await _workDataTableClient.DeleteEntityAsync(GetPartitionKey(), id, cancellationToken: cancellationToken).ConfigureAwait(false);
await _workStateTableClient.DeleteEntityAsync(GetPartitionKey(), id, cancellationToken: cancellationToken).ConfigureAwait(false);
}

/// <inheritdoc/>
public async Task<BinaryData?> GetDataAsync(string id, CancellationToken cancellationToken)
{
await CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false);

var er = await _workDataTableClient.GetEntityIfExistsAsync<WorkDataEntity>(GetPartitionKey(), id, cancellationToken: cancellationToken).ConfigureAwait(false);
return er.HasValue ? er.Value!.ToSingleData() : null;
}

/// <inheritdoc/>
public Task SetDataAsync(string id, BinaryData data, CancellationToken cancellationToken)
=> _workDataTableClient.UpsertEntityAsync(new WorkDataEntity(data) { PartitionKey = GetPartitionKey(), RowKey = id }, TableUpdateMode.Replace, cancellationToken: cancellationToken);
public async Task SetDataAsync(string id, BinaryData data, CancellationToken cancellationToken)
{
await CreateIfNotExistsAsync(cancellationToken).ConfigureAwait(false);
await _workDataTableClient.UpsertEntityAsync(new WorkDataEntity(data) { PartitionKey = GetPartitionKey(), RowKey = id }, TableUpdateMode.Replace, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public static IServiceCollection AddSqlServerEventOutboxHostedService(this IServ
var hc = healthCheck ? new TimerHostedServiceHealthCheck() : null;
if (hc is not null)
{
var sb = new StringBuilder("EventOutbox");
var sb = new StringBuilder("sql-server-event-outbox");
if (partitionKey is not null)
sb.Append($"-PartitionKey-{partitionKey}");

Expand Down
34 changes: 30 additions & 4 deletions src/CoreEx.Database/DatabaseServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,20 @@ public static class DatabaseServiceCollectionExtensions
public static IServiceCollection AddDatabase(this IServiceCollection services, Func<IServiceProvider, IDatabase> create, bool healthCheck = true)
{
services.AddScoped(sp => create(sp) ?? throw new InvalidOperationException($"An {nameof(IDatabase)} instance must be instantiated."));
return AddHealthCheck(services, healthCheck);
return AddHealthCheck(services, healthCheck, null);
}

/// <summary>
/// Adds an <see cref="IDatabase"/> as a scoped service including a corresponding health check.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/>.</param>
/// <param name="create">The function to create the <see cref="IDatabase"/> instance.</param>
/// <param name="healthCheckName">The health check name; defaults to '<c>database</c>'.</param>
/// <returns>The <see cref="IServiceCollection"/> to support fluent-style method-chaining.</returns>
public static IServiceCollection AddDatabase(this IServiceCollection services, Func<IServiceProvider, IDatabase> create, string? healthCheckName)
{
services.AddScoped(sp => create(sp) ?? throw new InvalidOperationException($"An {nameof(IDatabase)} instance must be instantiated."));
return AddHealthCheck(services, true, healthCheckName);
}

/// <summary>
Expand All @@ -35,16 +48,29 @@ public static IServiceCollection AddDatabase(this IServiceCollection services, F
public static IServiceCollection AddDatabase<TDb>(this IServiceCollection services, bool healthCheck = true) where TDb : class, IDatabase
{
services.AddScoped<IDatabase, TDb>();
return AddHealthCheck(services, healthCheck);
return AddHealthCheck(services, healthCheck, null);
}

/// <summary>
/// Adds an <see cref="IDatabase"/> as a scoped service including a corresponding health check.
/// </summary>
/// <typeparam name="TDb">The <see cref="IDatabase"/> <see cref="Type"/>.</typeparam>
/// <param name="services">The <see cref="IServiceCollection"/>.</param>
/// <param name="healthCheckName">The health check name; defaults to '<c>database</c>'.</param>
/// <returns>The <see cref="IServiceCollection"/> to support fluent-style method-chaining.</returns>
public static IServiceCollection AddDatabase<TDb>(this IServiceCollection services, string? healthCheckName) where TDb : class, IDatabase
{
services.AddScoped<IDatabase, TDb>();
return AddHealthCheck(services, true, healthCheckName);
}

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

return services;
}
Expand Down
Loading
Loading