Skip to content
Open
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
2 changes: 1 addition & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<GlobalPackageReference Include="CSharpier.MSBuild" Version="1.3.0" />
<GlobalPackageReference Include="CSharpier.MSBuild" Version="1.4.0" />
<GlobalPackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" />
<GlobalPackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.403" />
<GlobalPackageReference Include="Microsoft.SourceLink.GitHub" Version="10.0.403" />
Expand Down
117 changes: 55 additions & 62 deletions samples/NetEvolve.Pulse.Xample.Aot/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,75 +23,68 @@ void Check(bool condition, string description)

// Scenario 1: AddPulse with the source-generated handler registrations, covering reference-type,
// value-type and void responses, a query, a stream query, and closed as well as open-generic event handlers.
await RunAsync(
configure: null,
async (mediator, recorder) =>
{
var order = await mediator
.SendAsync<CreateOrderCommand, OrderResult>(new CreateOrderCommand("A-1"))
.ConfigureAwait(false);
Check(order == new OrderResult("A-1", Accepted: true), "command with reference-type response");
await RunAsync(configure: null, async (mediator, recorder) =>
{
var order = await mediator
.SendAsync<CreateOrderCommand, OrderResult>(new CreateOrderCommand("A-1"))
.ConfigureAwait(false);
Check(order == new OrderResult("A-1", Accepted: true), "command with reference-type response");

var sum = await mediator
.SendAsync<AddNumbersCommand, int>(new AddNumbersCommand(2, 3))
.ConfigureAwait(false);
Check(sum == 5, "command with value-type response");
var sum = await mediator.SendAsync<AddNumbersCommand, int>(new AddNumbersCommand(2, 3)).ConfigureAwait(false);
Check(sum == 5, "command with value-type response");

await mediator.SendAsync(new PingCommand()).ConfigureAwait(false);
Check(recorder.Invocations.Contains(nameof(PingHandler)), "void command");
await mediator.SendAsync(new PingCommand()).ConfigureAwait(false);
Check(recorder.Invocations.Contains(nameof(PingHandler)), "void command");

var greeting = await mediator
.QueryAsync<GreetingQuery, string>(new GreetingQuery("AOT"))
.ConfigureAwait(false);
Check(string.Equals(greeting, "Hello, AOT!", StringComparison.Ordinal), "query");
var greeting = await mediator.QueryAsync<GreetingQuery, string>(new GreetingQuery("AOT")).ConfigureAwait(false);
Check(string.Equals(greeting, "Hello, AOT!", StringComparison.Ordinal), "query");

var countdown = await CollectAsync(
mediator.StreamQueryAsync<CountdownStreamQuery, string>(new CountdownStreamQuery(3))
)
.ConfigureAwait(false);
Check(string.Join(',', countdown) == "3,2,1", "stream query");
var countdown = await CollectAsync(
mediator.StreamQueryAsync<CountdownStreamQuery, string>(new CountdownStreamQuery(3))
)
.ConfigureAwait(false);
Check(string.Join(',', countdown) == "3,2,1", "stream query");

await mediator.PublishAsync(new OrderCreatedEvent("A-1")).ConfigureAwait(false);
Check(recorder.Invocations.Contains(nameof(OrderCreatedHandler)), "event handler");
Check(
recorder.Invocations.Contains("OrderNotificationHandler<OrderCreatedEvent>"),
"second generated event handler for the same event"
);
Check(
recorder.Invocations.Contains("AuditEventHandler<OrderCreatedEvent>"),
"open-generic event handler closed by the DI container"
);
await mediator.PublishAsync(new OrderCreatedEvent("A-1")).ConfigureAwait(false);
Check(recorder.Invocations.Contains(nameof(OrderCreatedHandler)), "event handler");
Check(
recorder.Invocations.Contains("OrderNotificationHandler<OrderCreatedEvent>"),
"second generated event handler for the same event"
);
Check(
recorder.Invocations.Contains("AuditEventHandler<OrderCreatedEvent>"),
"open-generic event handler closed by the DI container"
);

await mediator.PublishAsync(new OrderCancelledEvent("A-1")).ConfigureAwait(false);
Check(
recorder.Invocations.Contains("OrderNotificationHandler<OrderCancelledEvent>"),
"event handler implementing two event handler interfaces"
);
var notificationInstances = recorder
.Invocations.Where(static i => i.StartsWith("OrderNotificationHandler#", StringComparison.Ordinal))
.ToList();
Check(
notificationInstances.Count == 2 && notificationInstances.Distinct(StringComparer.Ordinal).Count() == 1,
"event handler sharing one instance across two event handler interfaces"
);
await mediator.PublishAsync(new OrderCancelledEvent("A-1")).ConfigureAwait(false);
Check(
recorder.Invocations.Contains("OrderNotificationHandler<OrderCancelledEvent>"),
"event handler implementing two event handler interfaces"
);
var notificationInstances = recorder
.Invocations.Where(static i => i.StartsWith("OrderNotificationHandler#", StringComparison.Ordinal))
.ToList();
Check(
notificationInstances.Count == 2 && notificationInstances.Distinct(StringComparer.Ordinal).Count() == 1,
"event handler sharing one instance across two event handler interfaces"
);

// The outbox providers persist ToOutboxEventTypeName() and rehydrate it with Type.GetType. Their IL2057
// suppressions rely on this round-trip for event types that the application publishes. The event type is
// never named through typeof, and the persisted name is computed at runtime, so the compiler cannot root
// the type for the lookup.
var shipment = new ShipmentDispatchedEvent("S-1");
await mediator.PublishAsync(shipment).ConfigureAwait(false);
Check(
recorder.Invocations.Contains("AuditEventHandler<ShipmentDispatchedEvent>"),
"event without a closed handler"
);
var persistedEventType = shipment.GetType().ToOutboxEventTypeName();
Check(
ResolveOutboxEventType(persistedEventType) == shipment.GetType(),
"outbox event type name round-trip for a published event type"
);
}
)
// The outbox providers persist ToOutboxEventTypeName() and rehydrate it with Type.GetType. Their IL2057
// suppressions rely on this round-trip for event types that the application publishes. The event type is
// never named through typeof, and the persisted name is computed at runtime, so the compiler cannot root
// the type for the lookup.
var shipment = new ShipmentDispatchedEvent("S-1");
await mediator.PublishAsync(shipment).ConfigureAwait(false);
Check(
recorder.Invocations.Contains("AuditEventHandler<ShipmentDispatchedEvent>"),
"event without a closed handler"
);
var persistedEventType = shipment.GetType().ToOutboxEventTypeName();
Check(
ResolveOutboxEventType(persistedEventType) == shipment.GetType(),
"outbox event type name round-trip for a published event type"
);
})
.ConfigureAwait(false);

// Scenario 2: open-generic interceptors (activity/metrics and logging, with options bound through the
Expand Down
102 changes: 48 additions & 54 deletions src/NetEvolve.Pulse.AspNetCore/Internals/XmlDocumentationReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,34 +41,31 @@ internal static bool TryGetSummary(Type type, out string? summary)
{
ArgumentNullException.ThrowIfNull(type);

summary = SummaryCache.GetOrAdd(
type,
static t =>
summary = SummaryCache.GetOrAdd(type, static t =>
{
var assemblyLocation = t.Assembly.Location;
if (string.IsNullOrEmpty(assemblyLocation))
{
return null;
}

var xmlPath = Path.ChangeExtension(assemblyLocation, ".xml");
var members = LoadDocumentation(xmlPath);
if (members is null)
{
return null;
}

// XML doc ids use '.' for nested types and the open generic definition (e.g. `1).
var fullName = (t.IsConstructedGenericType ? t.GetGenericTypeDefinition() : t).FullName;
if (fullName is null)
{
var assemblyLocation = t.Assembly.Location;
if (string.IsNullOrEmpty(assemblyLocation))
{
return null;
}

var xmlPath = Path.ChangeExtension(assemblyLocation, ".xml");
var members = LoadDocumentation(xmlPath);
if (members is null)
{
return null;
}

// XML doc ids use '.' for nested types and the open generic definition (e.g. `1).
var fullName = (t.IsConstructedGenericType ? t.GetGenericTypeDefinition() : t).FullName;
if (fullName is null)
{
return null;
}

var memberId = "T:" + fullName.Replace('+', '.');
return members.TryGetValue(memberId, out var value) ? value : null;
return null;
}
);

var memberId = "T:" + fullName.Replace('+', '.');
return members.TryGetValue(memberId, out var value) ? value : null;
});

return summary is not null;
}
Expand All @@ -84,36 +81,33 @@ internal static bool TryGetSummary(Type type, out string? summary)
/// when the file does not exist or contains no usable documentation.
/// </returns>
internal static IReadOnlyDictionary<string, string>? LoadDocumentation(string xmlPath) =>
DocumentationCache.GetOrAdd(
xmlPath,
static path =>
DocumentationCache.GetOrAdd(xmlPath, static path =>
{
if (!File.Exists(path))
{
return null;
}

try
{
var document = XDocument.Load(path);
var members = document
.Descendants("member")
.Select(member => new
{
Name = (string?)member.Attribute("name"),
Summary = member.Element("summary") is { } summary ? RenderText(summary) : null,
})
.Where(entry => entry.Name is not null && entry.Summary is not null)
.ToDictionary(entry => entry.Name!, entry => entry.Summary!, StringComparer.Ordinal);

return members.Count > 0 ? members : null;
}
catch (Exception ex) when (ex is IOException or System.Xml.XmlException or UnauthorizedAccessException)
{
if (!File.Exists(path))
{
return null;
}

try
{
var document = XDocument.Load(path);
var members = document
.Descendants("member")
.Select(member => new
{
Name = (string?)member.Attribute("name"),
Summary = member.Element("summary") is { } summary ? RenderText(summary) : null,
})
.Where(entry => entry.Name is not null && entry.Summary is not null)
.ToDictionary(entry => entry.Name!, entry => entry.Summary!, StringComparer.Ordinal);

return members.Count > 0 ? members : null;
}
catch (Exception ex) when (ex is IOException or System.Xml.XmlException or UnauthorizedAccessException)
{
return null;
}
return null;
}
);
});

/// <summary>
/// Renders the text of a documentation element as a single line, replacing inline reference
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ public TypeValueConverter()

private static string ConvertToString(Type type)
{
var name =
type.AssemblyQualifiedName
var name = type.AssemblyQualifiedName
?? throw new InvalidOperationException($"Cannot get assembly-qualified name for type: {type}");

if (name.Length > OutboxMessageSchema.MaxLengths.EventType)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,7 @@ private static Type ResolveCommandType(string commandType)
return cached;
}

var resolved =
Type.GetType(commandType, throwOnError: false)
var resolved = Type.GetType(commandType, throwOnError: false)
?? throw new InvalidOperationException($"Cannot resolve command type '{commandType}'.");

return CommandTypeCache.GetOrAdd(commandType, resolved);
Expand Down
14 changes: 5 additions & 9 deletions src/NetEvolve.Pulse.Kafka/Outbox/KafkaMessageTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,17 +121,13 @@ public async Task SendBatchAsync(IEnumerable<OutboxMessage> messages, Cancellati

try
{
_producer.Produce(
topic,
kafkaMessage,
report =>
_producer.Produce(topic, kafkaMessage, report =>
{
if (report.Error.IsError)
{
if (report.Error.IsError)
{
errors.Add(new ProduceException<string, string>(report.Error, report));
}
errors.Add(new ProduceException<string, string>(report.Error, report));
}
);
});
}
catch (ProduceException<string, string> ex)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,7 @@ public async Task ReplayAsync(Guid id, CancellationToken cancellationToken = def
{
cancellationToken.ThrowIfCancellationRequested();

var entry =
await GetByIdAsync(id, cancellationToken).ConfigureAwait(false)
var entry = await GetByIdAsync(id, cancellationToken).ConfigureAwait(false)
?? throw new CommandDeadLetterEntryNotFoundException(id);

if (entry.Status == CommandDeadLetterStatus.Dismissed)
Expand Down
3 changes: 1 addition & 2 deletions src/NetEvolve.Pulse.MySql/Outbox/MySqlOutboxRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -245,8 +245,7 @@ public async Task AddAsync(OutboxMessage message, CancellationToken cancellation
if (transaction is not null)
{
// Use the connection from the ambient transaction to avoid mismatch
var connection =
transaction.Connection
var connection = transaction.Connection
?? throw new InvalidOperationException("Transaction has no associated connection.");

var command = new MySqlCommand(_insertSql, connection, transaction);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,7 @@ public PollyEventInterceptor(IServiceProvider serviceProvider)
ArgumentNullException.ThrowIfNull(serviceProvider);

// Try to resolve keyed pipeline first (per-event), then fallback to global
_pipeline =
serviceProvider.GetKeyedService<ResiliencePipeline>(typeof(TEvent))
_pipeline = serviceProvider.GetKeyedService<ResiliencePipeline>(typeof(TEvent))
?? serviceProvider.GetService<ResiliencePipeline>()
?? throw new InvalidOperationException(
$"No ResiliencePipeline registered for {typeof(TEvent).Name} or globally."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,7 @@ public PollyRequestInterceptor(IServiceProvider serviceProvider)
ArgumentNullException.ThrowIfNull(serviceProvider);

// Try to resolve keyed pipeline first (per-request), then fallback to global
_pipeline =
serviceProvider.GetKeyedService<ResiliencePipeline<TResponse>>(typeof(TRequest))
_pipeline = serviceProvider.GetKeyedService<ResiliencePipeline<TResponse>>(typeof(TRequest))
?? serviceProvider.GetService<ResiliencePipeline<TResponse>>()
?? throw new InvalidOperationException(
$"No ResiliencePipeline<{typeof(TResponse).Name}> registered for {typeof(TRequest).Name} or globally."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,7 @@ public PollyStreamQueryInterceptor(IServiceProvider serviceProvider)

// Try to resolve keyed pipeline first (per-query), then fallback to global
// If neither is registered, _pipeline remains null and the interceptor passes through
_pipeline =
serviceProvider.GetKeyedService<ResiliencePipeline>(typeof(TQuery))
_pipeline = serviceProvider.GetKeyedService<ResiliencePipeline>(typeof(TQuery))
?? serviceProvider.GetService<ResiliencePipeline>();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,7 @@ public async Task ReplayAsync(Guid id, CancellationToken cancellationToken = def
var connection = await CreateConnectionAsync(cancellationToken).ConfigureAwait(false);
await using (connection.ConfigureAwait(false))
{
var entry =
await GetByIdAsync(connection, id, cancellationToken).ConfigureAwait(false)
var entry = await GetByIdAsync(connection, id, cancellationToken).ConfigureAwait(false)
?? throw new CommandDeadLetterEntryNotFoundException(id);

if (entry.Status == CommandDeadLetterStatus.Dismissed)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,7 @@ public async Task AddAsync(OutboxMessage message, CancellationToken cancellation
if (transaction is not null)
{
// Use the connection from the ambient transaction to avoid mismatch
var connection =
transaction.Connection
var connection = transaction.Connection
?? throw new InvalidOperationException("Transaction has no associated connection.");

var command = new NpgsqlCommand(_insertSql, connection, transaction);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,7 @@ public async Task ReplayAsync(Guid id, CancellationToken cancellationToken = def
var connection = await CreateConnectionAsync(cancellationToken).ConfigureAwait(false);
await using (connection.ConfigureAwait(false))
{
var entry =
await GetByIdAsync(connection, id, cancellationToken).ConfigureAwait(false)
var entry = await GetByIdAsync(connection, id, cancellationToken).ConfigureAwait(false)
?? throw new CommandDeadLetterEntryNotFoundException(id);

if (entry.Status == CommandDeadLetterStatus.Dismissed)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -283,8 +283,7 @@ public async Task AddAsync(OutboxMessage message, CancellationToken cancellation

if (transaction is not null)
{
var connection =
transaction.Connection
var connection = transaction.Connection
?? throw new InvalidOperationException("Transaction has no associated connection.");

var command = new SqliteCommand(_insertSql, connection, transaction);
Expand Down
Loading
Loading