The Kladde runs in Azure Container Apps with a single replica (lesson 2.9.5). As long as that stays so, the hub from the last two lessons works without any further effort. But Container Apps can start more than one replica when the load rises, and during a new revision two run briefly. In this lesson you see what then happens to the live messages and which two tools exist against it. At the end there is a decision for the Kladde, written down and with reasons.
Why a second instance “loses” messages
Every instance of the Kladde is a process of its own with a SignalR of its own. It knows the connections that arrived at it and no others. The
groups live in its memory too. If someone books and the booking is saved on instance A, A calls Clients.Group("office"). That reaches the
members of the group that A knows. Whoever is connected to instance B hears nothing. There is no error and no line in the log; the message
simply doesn't arrive.
You can show that in a test, without Azure and without a proxy: two factories of the same app in the same test process, so two instances, one
connection on each, and the booking is announced on the first. For that, KladdeFactory gets a property for a Redis address, which is only
needed in a moment:
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 is not set.");
(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 Sample", 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
skipped Kladde.Web.Tests.TwoInstancesTests.WithRedisEveryClientHearsIt
KLADDE_TEST_REDIS is not set.
Test run summary: Passed!
total: 2
failed: 0
succeeded: 1
skipped: 1
duration: 5s 766ms
The first test is green, and that is the bad news: Ada, connected to the first instance, hears the booking, Tom, connected to the second, doesn't. The second test is skipped until there is a Redis; more on that below. Here a test waits two seconds for something that isn't supposed to come. That is the exception to the rule from lesson 2.13.2, and it is defensible here: there is no message you could send afterward as a marker, because exactly that one wouldn't arrive either.