Die Kladde läuft in Azure Container Apps mit einem einzigen Replikat (Lektion 2.9.5). Solange das so bleibt, funktioniert der Hub aus den beiden letzten Lektionen ohne weiteres Zutun. Container Apps kann aber mehr als ein Replikat starten, wenn die Last steigt, und während einer neuen Revision laufen kurz zwei. In dieser Lektion sehen Sie, was dann mit den Live-Nachrichten passiert, und welche zwei Werkzeuge es dagegen gibt. Am Ende steht eine Entscheidung für die Kladde, schriftlich und begründet.
Warum eine zweite Instanz Nachrichten „verliert“
Jede Instanz der Kladde ist ein eigener Prozess mit einem eigenen SignalR. Sie kennt die Verbindungen, die bei ihr angekommen sind, und
keine anderen. Auch die Gruppen leben in ihrem Speicher. Bucht jemand, und die Buchung wird auf Instanz A gespeichert, ruft A
Clients.Group("buero"). Das erreicht die Mitglieder der Gruppe, die A kennt. Wer mit Instanz B verbunden ist, hört nichts. Es gibt keinen
Fehler und keine Zeile im Log, die Nachricht kommt einfach nicht an.
Das lässt sich in einem Test zeigen, ohne Azure und ohne Proxy: zwei Fabriken derselben App im selben Testprozess, also zwei Instanzen, je
eine Verbindung an jeder, und die Buchung wird auf der ersten gemeldet. KladdeFactory bekommt dafür eine Eigenschaft für eine Redis-Adresse,
die erst gleich gebraucht wird:
public string? Redis { get; init; }
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
if (Redis is not null)
{
builder.UseSetting("ConnectionStrings:redis", Redis);
}
// … the test sign-in and the store as before …
}
tests/Kladde.Web.Tests/TwoInstancesTests.cs:
using Kladde.Web.Auth;
using Kladde.Web.Live;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;
namespace Kladde.Web.Tests;
// Two instances of the same app, each with its own client; the booking is announced on the first one.
public sealed class TwoInstancesTests
{
private static readonly DateTimeOffset Tuesday = new(2025, 3, 25, 10, 0, 0, TimeSpan.FromHours(1));
[Fact]
public async Task WithoutABackplaneOnlyTheClientsOfTheSendingInstanceHearIt()
{
(bool first, bool second) = await AnnounceOnTheFirstAsync(redis: null);
Assert.True(first);
Assert.False(second);
}
[Fact]
public async Task WithRedisEveryClientHearsIt()
{
string? redis = Environment.GetEnvironmentVariable("KLADDE_TEST_REDIS");
Assert.SkipWhen(redis is null, "KLADDE_TEST_REDIS ist nicht gesetzt.");
(bool first, bool second) = await AnnounceOnTheFirstAsync(redis);
Assert.True(first);
Assert.True(second);
}
private static async Task<(bool First, bool Second)> AnnounceOnTheFirstAsync(string? redis)
{
await using KladdeFactory one = new() { Redis = redis };
await using KladdeFactory two = new() { Redis = redis };
await using HubConnection ada = one.CreateHubConnection("Ada Beispiel", KladdeRoles.Office);
await using HubConnection tom = two.CreateHubConnection("Tom Wieland", KladdeRoles.Management);
TaskCompletionSource adaHeard = new(TaskCreationOptions.RunContinuationsAsynchronously);
TaskCompletionSource tomHeard = new(TaskCreationOptions.RunContinuationsAsynchronously);
ada.On<BookingNotice>(nameof(IAppointmentsClient.BookingCreated), _ => adaHeard.TrySetResult());
tom.On<BookingNotice>(nameof(IAppointmentsClient.BookingCreated), _ => tomHeard.TrySetResult());
await ada.StartAsync(TestContext.Current.CancellationToken);
await tom.StartAsync(TestContext.Current.CancellationToken);
using (IServiceScope scope = one.Services.CreateScope())
{
await scope.ServiceProvider.GetRequiredService<LiveBookings>()
.AnnounceAsync(new BookingNotice("b-3", "Mara Feld", Tuesday, Tuesday.AddMinutes(30)));
}
bool first = await Task.WhenAny(adaHeard.Task, Task.Delay(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)) == adaHeard.Task;
bool second = await Task.WhenAny(tomHeard.Task, Task.Delay(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken)) == tomHeard.Task;
return (first, second);
}
}
dotnet test -- --filter-class Kladde.Web.Tests.TwoInstancesTests
übersprungen Kladde.Web.Tests.TwoInstancesTests.WithRedisEveryClientHearsIt
KLADDE_TEST_REDIS ist nicht gesetzt.
Testlaufzusammenfassung: Bestanden!
gesamt: 2
fehlgeschlagen: 0
erfolgreich: 1
übersprungen: 1
Dauer: 5s 766ms
Der erste Test ist grün, und das ist die schlechte Nachricht: Ada, mit der ersten Instanz verbunden, hört die Buchung, Tom, mit der zweiten, nicht. Der zweite Test wird übersprungen, bis es ein Redis gibt; dazu weiter unten. Hier wartet ein Test zwei Sekunden auf etwas, das nicht kommen soll. Das ist die Ausnahme von der Regel aus Lektion 2.13.2, und sie ist hier vertretbar: Es gibt keine Nachricht, die man als Marke danach schicken könnte, denn genau die käme auch nicht an.