SignalR'da Dayanıklılık: Automatic Reconnect, Stateful Reconnect, Streaming ve MessagePack
Stateful reconnect yalnızca anında toparlanan kısa kopuşlarda mesaj kaybını önler; saniyeler süren kesintide yeni bağlantı ve kayıp mesaj var. Jitter'lı retry policy, onreconnected'da grupları yeniden kurmak, IAsyncEnumerable ile server ve client streaming, iptal, MessagePack'in byte kazancı ile PascalCase ve tarih tuzağı, yavaş istemci limitleri.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Microsoft.AspNetCore.SignalR.Protocols.MessagePack 10.0.14, Angular 22.2, @microsoft/signalr ve @microsoft/signalr-protocol-msgpack 10.0.11 ile derlenip denenmiştir.
Önce Tahmin Et
Sunucuda AllowStatefulReconnects'i, istemcide withStatefulReconnect()'i açtın. Stateful reconnect'in vaadi, kopuşta kaçan mesajların yeniden gönderilmesi. Kullanıcının Wi-Fi'ı 2 saniye gidip geliyor ve bu arada sunucu 7 mesaj gönderiyor. Kullanıcı bu 7 mesajı alır mı?
Cevabı JavaScript client'ının kaynak kodunda buldum ve beklediğim gibi değildi. Bu yazıda kopuşu bir hata değil, mobil ağlarda, laptop kapakları kapanırken ve her deploy'da yaşanan olağan bir olay olarak ele alıyoruz: yeniden bağlanma, mesaj kaybı, streaming, MessagePack ve yavaş istemciler.
withAutomaticReconnect ve Özel Retry Policy
SignalR istemcisi varsayılan olarak bağlantı koptuğunda hiçbir şey yapmıyor; bağlantı Disconnected'a düşüyor ve orada kalıyor. withAutomaticReconnect() parametresiz kullanıldığında 0, 2, 10 ve 30 saniye bekleyerek dört kez deniyor ve sonra vazgeçiyor. 42 saniye süren bir sunucu deploy'unda bu yetmeyebilir, binlerce istemcinin aynı saniyelerde yeniden bağlanması da sunucuyu zorlar. Kendi policy'ni yazabilirsin:
// 0, 1, 2, 4, 8 ... sn (en fazla 10) + %20 jitter; 60 sn sonra vazgeç
const retryPolicy: IRetryPolicy = {
nextRetryDelayInMilliseconds(context: RetryContext): number | null {
if (context.elapsedMilliseconds > 60_000) {
return null;
}
if (context.previousRetryCount === 0) {
return 0;
}
const base = Math.min(1000 * 2 ** (context.previousRetryCount - 1), 10_000);
return base * (0.8 + Math.random() * 0.4);
},
};
null dönmek "vazgeç" demek; o noktada bağlantı Disconnected oluyor ve onclose çağrılıyor. İlk denemeyi beklemeden yapmak kısa kopuşları hızlı kapatıyor; jitter (±%20 rastgele sapma) ise sunucu yeniden başladığında bütün istemcilerin aynı milisaniyede gelmesini engelliyor. RetryContext ayrıca retryReason ile kopuşun sebebini veriyor; örneğin 401 aldıysan yeniden denemek yerine kullanıcıyı login'e yönlendirmek daha anlamlı.
Yeniden Bağlanınca: UI Durumu ve Grupları Yeniden Kurmak
@Injectable({ providedIn: 'root' })
export class InventoryService {
private readonly connection: HubConnection = new HubConnectionBuilder()
.withUrl('http://localhost:17607/hubs/inventory')
.withAutomaticReconnect(retryPolicy)
.withStatefulReconnect()
.configureLogging(LogLevel.Warning)
.build();
readonly status = signal<ConnectionStatus>('disconnected');
constructor() {
this.connection.onreconnecting(() => this.status.set('reconnecting'));
this.connection.onreconnected(() => {
this.status.set('connected');
// yeni connection id: gruplara yeniden katıl, kaçan durumu HTTP ile tazele
});
this.connection.onclose(() => this.status.set('disconnected'));
}
async start(): Promise<void> {
this.status.set('connecting');
await this.connection.start();
this.status.set('connected');
}
}
Üç callback, üç UI (User Interface) durumu. onreconnecting'de kullanıcıya "bağlantı koptu, yeniden deneniyor" bandı göster ve canlı veriye dayanan düğmeleri pasifleştir; onreconnected'da bandı kaldır; onclose'da "sayfayı yenile" seçeneği sun. Tarayıcıda sayfa açıkken sunucuyu kill -9 ile öldürdüğümde ekran "Status: reconnecting" oldu, sunucuyu yeniden başlattıktan birkaç saniye sonra "Status: connected"a döndü.
onreconnected'ın içi boş bırakılmayacak kadar önemli. Bir önceki yazılardan hatırla: yeniden bağlanan bağlantı yeni bir connection id alıyor ve hiçbir gruba üye değil. Bu callback, izlenen grupları yeniden katılmak ve kopuş sırasında kaçan durumu sıradan bir HTTP (Hypertext Transfer Protocol) isteğiyle tazelemek için doğru yer.
Stateful Reconnect: Kısa Kopuşlarda Mesaj Kaybını Önlemek
app.MapHub<InventoryHub>("/hubs/inventory", options =>
options.AllowStatefulReconnects = true);
.NET 8 ile gelen stateful reconnect'te iki taraf da gönderdiği mesajları, karşı taraf aldığını onaylayana kadar bir buffer'da tutuyor (varsayılan 100.000 byte). Transport koparsa istemci aynı connection id ile yeniden bağlanıyor ve onaylanmamış mesajlar yeniden gönderiliyor. Bunu test etmek için istemcileri sunucuya, istediğim an bütün bağlantıları kesebildiğim küçük bir TCP (Transmission Control Protocol) proxy'si üzerinden bağladım. İki istemci: biri yalnızca withAutomaticReconnect, diğeri ek olarak withStatefulReconnect. Sunucu 20 ms arayla 100 sıra numaralı mesaj gönderirken proxy'deki bağlantıları kestim (proxy yeni bağlantıları hemen kabul etmeye devam etti):
0.1s connected: plain=RxgL6AdcA25VXXQF8CxyHg stateful=DuibFD7nk5g5o_XeTfsIRw
1.1s --- network drop (0 ms)
1.1s plain reconnecting: WebSocket closed with status code: 1006 (no reason given).
1.1s --- network back
1.1s plain reconnected, connectionId EdAbrbm6U8IxYYbHxnsBSg
plain id EdAbrbm6U8IxYYbHxnsBSg, received 99/100, missing: 48
stateful id DuibFD7nk5g5o_XeTfsIRw, received 100/100, missing: none
Normal istemci yeni bir connection id aldı ve yeniden bağlandığı birkaç milisaniyede gönderilen 48 numaralı mesajı kaybetti. Stateful istemcide ise onreconnecting hiç tetiklenmedi, connection id aynı kaldı ve 100 mesajın hepsi geldi. Uygulama kodu kopuşu fark etmedi bile.
Şimdi baştaki soru. Aynı testi proxy'yi 2 saniye boyunca yeni bağlantıları reddedecek şekilde, 300 ms aralıklı 20 mesajla tekrarladım:
0.1s connected: plain=Yg65VctRomzFyldfz_yI0w stateful=hRxCTixP1e3Cq2wPbelQNA
1.1s --- network drop (2 s)
1.1s plain reconnecting: WebSocket closed with status code: 1006 (no reason given).
1.1s stateful reconnecting: WebSocket closed with status code: 1006 (no reason given).
3.1s --- network back
3.7s stateful reconnected, connectionId SpdjDYTFBTzrdkZ02NG1cA
3.8s plain reconnected, connectionId Hmh1dlyTJvUy5iZ3w1QKKw
plain ids now Hmh1dlyTJvUy5iZ3w1QKKw, received: 1,2,3,4,14,15,16,17,18,19,20
stateful ids now SpdjDYTFBTzrdkZ02NG1cA, received: 1,2,3,4,14,15,16,17,18,19,20
İki istemci de 5'ten 13'e kadar olan mesajları kaybetti ve ikisi de yeni bir connection id aldı. Sebebini @microsoft/signalr 10.0.11'in HttpConnection.js dosyasında buldum: transport kapanınca stateful reconnect aynı bağlantıya yalnızca bir kez, hemen yeniden bağlanmayı deniyor; o deneme başarısız olursa bağlantıyı durduruyor ve iş withAutomaticReconnect'e, yani yepyeni bir bağlantıya kalıyor. Baştaki sorunun cevabı: hayır. Stateful reconnect, anında toparlanan kısa transport kopuşları (bir proxy'nin bağlantıyı kesmesi, mobil ağda baz istasyonu değişimi) için; saniyeler süren kesintiler için değil. Kaçan mesajları her durumda telafi etmen gerekiyorsa uygulama seviyesinde bir sıra numarası ve "şu numaradan sonrasını ver" çağrısı gerekiyor, tıpkı SSE'deki (Server-Sent Events) Last-Event-ID gibi.
Stateful reconnect yalnızca WebSocket transport'unda çalışıyor ve her iki tarafta buffer tuttuğu için bağlantı başına bellek maliyeti var; StatefulReconnectBufferSize ile ayarlanabiliyor.
Server→Client Streaming
public sealed record StockLevel(int BookId, string Title, int Available, DateTimeOffset At);
public sealed class InventoryHub(ILogger<InventoryHub> logger) : Hub
{
// server -> client stream: istemci aboneliği bırakınca cancellationToken tetiklenir
public async IAsyncEnumerable<StockLevel> WatchStock(
int bookId, int count, [EnumeratorCancellation] CancellationToken cancellationToken)
{
int sent = 0;
try
{
for (int i = 0; i < count; i++)
{
yield return new StockLevel(bookId, "Clean Code", 100 - i, DateTimeOffset.UtcNow);
sent++;
await Task.Delay(100, cancellationToken);
}
}
finally
{
logger.LogInformation("WatchStock({BookId}) ended after {Sent} items, cancelled: {Cancelled}",
bookId, sent, cancellationToken.IsCancellationRequested);
}
}
}
Hub method'u IAsyncEnumerable<T> (C# Asenkron serisinin IAsyncEnumerable yazısı) ya da ChannelReader<T> döndürünce SignalR onu bir stream olarak gönderiyor: her öğe ayrı bir mesaj, sonunda bir tamamlanma mesajı. Clients.All.SendAsync'ten farkı, stream'in bir isteğe bağlı olması: istemci başlatıyor, istemci bitirebiliyor. İstemci aboneliği bıraktığında [EnumeratorCancellation] ile işaretli token tetikleniyor. Angular tarafında stream'i bir Observable'a sarıyoruz:
watchStock(bookId: number, count: number): Observable<StockLevel> {
return new Observable<StockLevel>((subscriber) => {
const stream = this.connection.stream<StockLevel>('WatchStock', bookId, count).subscribe({
next: (level) => subscriber.next(level),
error: (err) => subscriber.error(err),
complete: () => subscriber.complete(),
});
// RxJS aboneliği bırakılınca sunucudaki stream de iptal edilir
return () => stream.dispose();
});
}
Tarayıcıda 30 öğelik stream'i izleyen component "Clean Code available: 71 (updates: 30)" gösterdi. Node istemcisiyle 1.000 öğelik bir stream başlatıp 350 ms sonra aboneliği bıraktığımda:
disposed after 4 items
info: Bookstore.Realtime.InventoryHub[0]
WatchStock(7) ended after 4 items, cancelled: True
Sunucu dördüncü öğeden sonra durdu. Component yok olduğunda takeUntilDestroyed aboneliği bırakıyor, dispose() sunucuya iptal mesajı gönderiyor ve sunucu kalan 996 öğeyi üretmekle uğraşmıyor. Bunu yapmazsan istemcinin hiç görmeyeceği veriyi hem üretir hem gönderirsin.
Client→Server Streaming
// client -> server stream: depo el terminalinden gelen ISBN taramaları
public async Task<int> UploadScans(IAsyncEnumerable<string> isbns)
{
int count = 0;
await foreach (string isbn in isbns)
{
count++;
logger.LogInformation("Scanned {Isbn}", isbn);
}
return count;
}
const scans = new signalR.Subject();
const upload = c.invoke('UploadScans', scans);
for (const isbn of ['9780132350884', '9780201485677', '9780134494166']) { scans.next(isbn); await wait(50); }
scans.complete();
console.log('UploadScans ->', await upload);
UploadScans -> 3
Parametre olarak IAsyncEnumerable<T> ya da ChannelReader<T> alan method'a istemci bir Subject veriyor; her next() sunucuya bir öğe olarak gidiyor, complete() döngüyü bitiriyor ve invoke'un promise'i method'un dönüş değeriyle çözülüyor. Depodaki el terminalinin taradığı ISBN'leri (International Standard Book Number) teker teker HTTP isteği atmadan, tek bir çağrı içinde akıtmak için uygun. Bir önceki yazıdaki 32 KB'lık mesaj limitine de takılmıyorsun, çünkü her öğe ayrı bir mesaj.
MessagePack Protocol: Ne Zaman?
builder.Services.AddSignalR()
.AddMessagePackProtocol();
Sunucu tarafında tek satır; istemci withHubProtocol(new MessagePackHubProtocol()) ile seçiyor ve sunucu iki protocol'ü aynı anda destekliyor. 50 öğelik aynı stream'i iki protocol ile, sunucudan istemciye geçen byte'ları proxy'de sayarak ölçtüm:
json 50 items, 6511 bytes server->client, first item: {"bookId":7,"title":"Clean Code","available":100,"at":"2026-11-08T02:44:51.40816+00:00"}
messagepack 50 items, 3009 bytes server->client, first item: {"BookId":7,"Title":"Clean Code","Available":100,"At":["2026-11-08T02:44:56.497Z",0]}
Byte'lar yarıdan aza indi. Ama ilk öğeye bak: property adları PascalCase geldi (BookId, Available) ve DateTimeOffset bir tarih ile offset'ten oluşan bir array'e dönüştü. JSON (JavaScript Object Notation) protocol'ü camelCase kullandığı için TypeScript tarafında level.available yazan kod, protocol'ü değiştirdiğin an undefined okumaya başlar. MessagePack'te property adları C#'taki gibi taşınıyor ve tarih tipleri farklı kodlanıyor; geçiş yapacaksan istemcideki interface'leri ve tarih dönüşümlerini birlikte değiştirmen gerekiyor.
Ne zaman değer? Saniyede binlerce küçük mesaj (canlı fiyat, oyun durumu, telemetri), binary veri (byte array JSON'da base64'e dönüşüp üçte bir büyür) ve mobil ağda trafik maliyeti olan senaryolar. Değmediği yer: dakikada birkaç bildirim gönderen bir uygulama. Orada debug kolaylığı (tarayıcının network sekmesinde mesajı okuyabilmek) birkaç yüz byte'tan değerli. Native AOT (Ahead-of-Time) ile derlenen bir SignalR sunucusunun yalnızca JSON protocol'ünü desteklediğini de not et.
Yavaş İstemci ve Backpressure
Her bağlantının sunucuda bir gönderim buffer'ı var. Mobil ağdaki yavaş bir istemci mesajları okuyamadığında buffer doluyor. SignalR'ın dokümante ettiği sınırlar bu durumu belirliyor: TransportMaxBufferSize (varsayılan 64 KB) sunucunun bir bağlantı için tamponlayacağı en büyük veri, TransportSendTimeout (varsayılan 10 saniye) tek bir gönderimin tamamlanması için beklenecek en uzun süre; bu süre aşılırsa bağlantı kapatılıyor. Yani yavaş istemci sonsuza kadar bellek tüketmiyor, belli bir noktada bağlantısı kesiliyor ve yeniden bağlanıp güncel durumu çekmesi gerekiyor.
Stream'lerde backpressure doğal olarak await üzerinden işliyor: IAsyncEnumerable üreten method bir sonraki öğeyi, bir önceki gönderilebildiğinde üretiyor. ChannelReader kullanıyorsan bounded bir channel seç; unbounded bir channel'a hızlı üretici, yavaş tüketici ile yazmak belleği sessizce doldurur. İstemciden gelen stream'lerde de StreamBufferCapacity (varsayılan 10) sunucunun işlemeden önce tamponlayacağı öğe sayısını sınırlıyor; sınır dolunca o bağlantıdan gelen yeni mesajların işlenmesi, sunucu öğeleri tüketene kadar bekliyor.
Yayın (Clients.All) tarafında pratik kural: hızla değişen bir değeri her değişimde değil, belirli aralıklarla ve yalnızca son hâliyle gönder. Saniyede 50 kez değişen bir stok sayısını 200 ms'de bir göndermek kullanıcı için fark yaratmaz, yavaş istemcilerin buffer'ını ise beşte bire indirir.
Üretimde Dikkat
Yeniden bağlanma fırtınasını planla. Deploy sırasında bir sunucu kapanınca ona bağlı bütün istemciler aynı anda yeniden bağlanmaya çalışır. İlk denemenin 0 ms olması iyi, ama sonrakilere jitter ekle; sunucu tarafında da negotiate ve authentication'ın bu yükü kaldırabildiğini load test ile doğrula.
Bağlantı durumunu metric olarak izle: istemci tarafında onreconnecting ve onclose sayılarını telemetry'ye gönder. "Kullanıcıların %5'i her 10 dakikada bir yeniden bağlanıyor" bilgisi, bir proxy'nin idle timeout'unu ya da bir load balancer ayarını gösteren en net sinyal.
Stateful reconnect'i açtığında bellek hesabını güncelle: bağlantı başına 100.000 byte'a kadar buffer, 10.000 bağlantıda en kötü durumda 1 GB civarı ek bellek demek.
Sık Düşülen Tuzaklar
withAutomaticReconnect'i eklemeyip bağlantının kendiliğinden toparlanacağını sanmak. Varsayılan davranış ilk kopuşta Disconnected'da kalmak.
Varsayılan dört denemeyle yetinmek. 42 saniyelik bir deploy, varsayılan policy'nin bütün denemelerini tüketebilir; uzun ve jitter'lı bir policy yaz.
Stateful reconnect'i "mesaj kaybı yok" garantisi sanmak. Yalnızca anında toparlanan kopuşlarda çalışıyor; birkaç saniyelik kesintide yeni bir bağlantı ve kayıp mesajlar var.
onreconnected'da grupları ve güncel durumu yeniden kurmamak. Yeni bağlantı hiçbir grupta değil.
Stream aboneliğini bırakmamak. Component kapandıktan sonra sunucu üretmeye ve göndermeye devam eder.
MessagePack'e geçip istemcideki property adlarını ve tarih alanlarını güncellememek. Kod derlenir, veri undefined gelir.
Küçük Bir Deneme
★☆☆ Angular uygulamasında withAutomaticReconnect()'i parametresiz kullan, sunucuyu durdur ve tarayıcı konsolunda denemeler arasındaki süreleri ölç. Dördüncü denemeden sonra ne oluyor?
★★☆ InventoryService'e "izlenen kitaplar" listesi ekle ve onreconnected'da her biri için WatchStock'u yeniden başlat. Sunucuyu yeniden başlatarak stream'in kendiliğinden devam ettiğini doğrula.
★★★ Sıra numaralı yayına uygulama seviyesinde telafi ekle: istemci son aldığı numarayı tutsun, onreconnected'da GetMissedSince(lastSeq) adında bir hub method'u çağırsın ve sunucu son 1.000 mesajı tutan bir buffer'dan eksikleri döndürsün. 2 saniyelik kopuş testinde 5–13 arasındaki mesajların artık geldiğini doğrula.
Bir sonraki yazıda tek sunucunun sınırından çıkıyoruz: ikinci bir sunucu eklenince mesajların yarısının neden kaybolduğu, sticky session, Redis backplane, Azure SignalR Service ve SignalR'ı production'da izlemek.