Skip to content
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
761 changes: 423 additions & 338 deletions .editorconfig

Large diffs are not rendered by default.

30 changes: 18 additions & 12 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,22 @@
<PackageVersion Include="CommunityToolkit.Aspire.Hosting.Sqlite" Version="13.4.0" />
<PackageVersion Include="CommunityToolkit.Aspire.Microsoft.EntityFrameworkCore.Sqlite" Version="9.7.2" />
<PackageVersion Include="FSharp.Core" Version="9.0.300" />
<PackageVersion Include="Google.Protobuf" Version="3.36.0" />
<PackageVersion Include="Grpc.AspNetCore" Version="2.83.0" />
<PackageVersion Include="Grpc.Net.ClientFactory" Version="2.83.0" />
<PackageVersion Include="Grpc.Tools" Version="2.83.0" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.OpenAPI" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.11" />
<PackageVersion Include="Microsoft.AspNetCore.OpenAPI" Version="10.0.11" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.8.0" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.8.0" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery.Yarp" Version="10.8.0" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.9.0" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.9.0" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery.Yarp" Version="10.9.0" />
<PackageVersion Include="Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk" Version="10.0.8" />
<PackageVersion Include="Microsoft.OpenApi" Version="2.3.1" />
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
Expand All @@ -37,6 +42,7 @@
<PackageVersion Include="OpenTelemetry" Version="1.17.0" />
<PackageVersion Include="OpenTelemetry.Api" Version="1.17.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.12.0-beta.2" />
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
</ItemGroup>
<!-- Microsoft common packages -->
<ItemGroup>
Expand All @@ -62,11 +68,11 @@
</ItemGroup>
<!-- OpenTelemetry -->
<ItemGroup>
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.17.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.GrpcNetClient" Version="1.12.0-beta.1" />
</ItemGroup>
<!-- Tests and benchmarks -->
Expand Down Expand Up @@ -96,4 +102,4 @@
<PackageVersion Include="CNinnovation.Codebreaker.Cosmos" Version="3.9.0" />
<PackageVersion Include="CNinnovation.Codebreaker.SqlServer" Version="3.9.0" />
</ItemGroup>
</Project>
</Project>
10 changes: 10 additions & 0 deletions ch13/AsyncStreamingChannels/AsyncStreamingChannels.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

</Project>
154 changes: 154 additions & 0 deletions ch13/AsyncStreamingChannels/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Source code for: Expert CSharp Programming.
// Author: Christian Nagel.
// Licensed under the MIT License.

using System.Diagnostics;
using System.Text;
using System.Threading.Channels;

Console.OutputEncoding = Encoding.UTF8;

// =====================================================
// System.Threading.Channels – fan-out pub/sub news feed
// =====================================================
Console.WriteLine("System.Threading.Channels – Fan-Out Pub/Sub News Feed");
Console.WriteLine("-----------------------------------------------------");

// Ingestion channel: two publishers write here (bounded, backpressure-aware)
var ingestChannel = Channel.CreateBounded<NewsArticle>(
new BoundedChannelOptions(20)
{
FullMode = BoundedChannelFullMode.Wait,
SingleWriter = false, // two publishers
SingleReader = true, // one dispatcher reads and fans out
});

// Per-subscriber channels (unbounded – dispatcher controls pacing)
var techChannel = Channel.CreateUnbounded<NewsArticle>();
var financeChannel = Channel.CreateUnbounded<NewsArticle>();

var ingestWriter = ingestChannel.Writer;

// Two publishers – simulating different news sources
Task publisher1 = PublishNewsAsync("Reuters", ingestWriter, 5, delay: 100);
Task publisher2 = PublishNewsAsync("Bloomberg", ingestWriter, 5, delay: 150);

// Dispatcher: routes each article to matching subscriber channels
Task dispatcher = DispatchNewsAsync(
ingestChannel.Reader,
new NewsSubscription("Tech", techChannel.Writer),
new NewsSubscription("Finance", financeChannel.Writer));

// Two independent subscribers – each receives only its category stream
Task consumer1 = ConsumeNewsAsync("Subscriber-A [Tech]", techChannel.Reader);
Task consumer2 = ConsumeNewsAsync("Subscriber-B [Finance]", financeChannel.Reader);

// Wait for publishers, complete the ingestion channel, then drain everything
await Task.WhenAll(publisher1, publisher2);
ingestWriter.Complete();
await dispatcher;
await Task.WhenAll(consumer1, consumer2);
Console.WriteLine();

static async Task DispatchNewsAsync(
ChannelReader<NewsArticle> source,
params NewsSubscription[] subscribers)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(subscribers);

try
{
// Topic routing: each article is forwarded only to matching subscribers
await foreach (NewsArticle article in source.ReadAllAsync())
{
foreach (NewsSubscription subscriber in subscribers)
{
if (article.Category != subscriber.Category) continue;
await subscriber.Writer.WriteAsync(article);
}
}
}
finally
{
foreach (NewsSubscription subscriber in subscribers)
subscriber.Writer.Complete();
}
}

static async Task PublishNewsAsync(
string source,
ChannelWriter<NewsArticle> channelWriter,
int count,
int delay)
{
string[] categories = ["Tech", "Finance", "Sports", "Politics"];

for (int i = 1; i <= count; i++)
{
NewsArticle article = new(
Id: $"{source}-{i:000}",
Headline: $"{source} headline #{i}",
Category: categories[Random.Shared.Next(categories.Length)],
PublishedAt: DateTimeOffset.UtcNow);

await channelWriter.WriteAsync(article);
Console.WriteLine($"Published [{article.Id}] {article.Headline} ({article.Category})");
await Task.Delay(delay);
}
}

static async Task ConsumeNewsAsync(
string consumerName,
ChannelReader<NewsArticle> channelReader)
{
await foreach (NewsArticle article in channelReader.ReadAllAsync())
{
Console.WriteLine($" {consumerName}: [{article.Id}] {article.Headline}");
await Task.Delay(20); // simulate processing
}
}

// ==============================================
// Channels – performance: throughput measurement
// ==============================================
Console.WriteLine("Channel Throughput Benchmark");
Console.WriteLine("----------------------------");

const int MessageCount = 100_000;
Channel<int> benchChannel = Channel.CreateUnbounded<int>(
new UnboundedChannelOptions { SingleWriter = true, SingleReader = true });

Stopwatch sw = Stopwatch.StartNew();

Task producer = Task.Run(async () =>
{
for (int i = 0; i < MessageCount; i++)
await benchChannel.Writer.WriteAsync(i);
benchChannel.Writer.Complete();
});

int received = 0;
Task consumer = Task.Run(async () =>
{
await foreach (int _ in benchChannel.Reader.ReadAllAsync())
received++;
});

await Task.WhenAll(producer, consumer);
sw.Stop();

double throughputPerSec = MessageCount / (sw.Elapsed.TotalSeconds);
Console.WriteLine($"{MessageCount:N0} messages in {sw.ElapsedMilliseconds} ms");
Console.WriteLine($"Throughput: {throughputPerSec:N0} messages/sec");
Console.WriteLine();

// ============================================================
// Domain types
// ============================================================

sealed record StockTick(string Symbol, decimal Price, DateTimeOffset Timestamp);

sealed record NewsSubscription(string Category, ChannelWriter<NewsArticle> Writer);

sealed record NewsArticle(string Id, string Headline, string Category, DateTimeOffset PublishedAt);
133 changes: 133 additions & 0 deletions ch13/Common/Blazor.ServiceDefaults/BackgroundExportHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Net;
using System.Net.Http.Headers;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Polly;

namespace Blazor.ServiceDefaults;

/// <summary>
/// A DelegatingHandler that works around the OTel SDK's sync-over-async
/// deadlock on WASM. The SDK calls SendAsync().GetAwaiter().GetResult()
/// in OtlpExportClient.SendHttpRequest(), which blocks the single WASM
/// thread. This handler returns 200 immediately to unblock the SDK,
/// then sends the real request with retries in the background.
/// </summary>
internal sealed class BackgroundExportHandler(
ResiliencePipeline<HttpResponseMessage> pipeline,
IServiceProvider serviceProvider) : DelegatingHandler(new HttpClientHandler())
{
private ILogger? _logger;
private ILogger Logger => _logger ??= serviceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("Aspire.OtlpExport");

protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
// Capture request data before returning — the SDK disposes the
// HttpRequestMessage via 'using' after .GetResult() completes.
var snapshot = RequestSnapshot.Capture(request);

// Send the real request with retries in the background.
_ = SendWithRetryAsync(snapshot, CancellationToken.None);

// Return 200 immediately so the SDK's sync .GetResult() unblocks.
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}

private async Task SendWithRetryAsync(
RequestSnapshot snapshot, CancellationToken cancellationToken)
{
try
{
var response = await pipeline.ExecuteAsync(async token =>
{
using var clone = snapshot.CreateRequest();
return await base.SendAsync(clone, token).ConfigureAwait(false);
}, cancellationToken).ConfigureAwait(false);

if (!response.IsSuccessStatusCode)
{
Logger.LogWarning(
"OTLP export to {Uri} completed with status {StatusCode} after retries.",
snapshot.RequestUri, response.StatusCode);
}

response.Dispose();
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Logger.LogWarning(ex,
"OTLP export to {Uri} failed after retries.", snapshot.RequestUri);
}
}
}

/// <summary>
/// Captures the essential parts of an HttpRequestMessage so it can be
/// cloned for each retry attempt after the original request is disposed
/// by the SDK. The OTLP SDK always sends ByteArrayContent (protobuf),
/// so ReadAsByteArrayAsync completes synchronously.
/// </summary>
internal sealed class RequestSnapshot
{
public HttpMethod Method { get; init; } = null!;
public Uri RequestUri { get; init; } = null!;
public List<KeyValuePair<string, IEnumerable<string>>> Headers { get; init; } = null!;
public byte[]? ContentBytes { get; init; }
public MediaTypeHeaderValue? ContentType { get; init; }

public static RequestSnapshot Capture(HttpRequestMessage request)
{
byte[]? contentBytes = null;
MediaTypeHeaderValue? contentType = null;

if (request.Content is not null)
{
// ByteArrayContent.ReadAsByteArrayAsync completes synchronously
// since the bytes are already in memory — safe to .GetResult().
contentBytes = request.Content.ReadAsByteArrayAsync()
.GetAwaiter().GetResult();
contentType = request.Content.Headers.ContentType;
}

// Copy headers since the original request will be disposed.
var headers = new List<KeyValuePair<string, IEnumerable<string>>>();
foreach (var header in request.Headers)
{
headers.Add(new(header.Key, [.. header.Value]));
}

return new RequestSnapshot
{
Method = request.Method,
RequestUri = request.RequestUri!,
Headers = headers,
ContentBytes = contentBytes,
ContentType = contentType,
};
}

public HttpRequestMessage CreateRequest()
{
var clone = new HttpRequestMessage(Method, RequestUri);

foreach (var header in Headers)
{
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
}

if (ContentBytes is not null)
{
clone.Content = new ByteArrayContent(ContentBytes);
if (ContentType is not null)
{
clone.Content.Headers.ContentType = ContentType;
}
}

return clone;
}
}
25 changes: 25 additions & 0 deletions ch13/Common/Blazor.ServiceDefaults/Blazor.ServiceDefaults.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">

<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<!-- .NET 11 preview needed - don't use CPM until release of .NET 11 -->
</PropertyGroup>

<ItemGroup>
<SupportedPlatform Include="browser" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="11.0.0-preview.7.26381.103" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.9.0" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="10.9.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.18.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.18.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.18.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.18.0" />
</ItemGroup>

</Project>
Loading