Gerçek Hayattan Bug Avları 1: Backend — Kültür, Saat Dilimi, Socket ve Mutable Key
Backend bug'larında ilk hipotez çoğu zaman suçu başka bir sisteme atar; onu çürüten ucuz bir gözlemdir. Altı gerçek vaka: tr-TR'de bozulan rol kontrolü, UTC ile 3 saat kayan rapor, new HttpClient() ile biriken TIME_WAIT socket'leri, Dictionary'de değişen key, process'i çökerten async void (ve PID 1 container'da yarı ölü kalması), EF Core'da N+1; her birinde düzeltme, regression testi ve analyzer.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Entity Framework Core (EF Core) 10.0.14, xUnit v3 (xunit.v3 4.0.2) ve Microsoft.Extensions.TimeProvider.Testing 10.11.0 ile derlenip denenmiştir; container çıktıları Docker 29.2 ve mcr.microsoft.com/dotnet/aspnet:10.0 image'ı ile alınmıştır.
Önce Tahmin Et
Bookstore API'sinde (Application Programming Interface) bir endpoint, sipariş alındıktan sonra müşteriye onay maili gönderen bir method'u async void olarak çağırıyor. Mail sunucusu hata veriyor. Kullanıcı 201 Created alır mı? Process ayakta kalır mı? Peki aynı kod bir container'da, uygulama PID (process identifier) 1, yani container'ın ilk process'i olarak çalışırken ne olur? Bir de şunu düşün: bir Dictionary'de iki key birbirine Equals ile eşit olabilir mi?
Serinin son iki yazısı, ilk on bir yazıda kurduğumuz yöntemi gerçek vakalara uyguluyor. Bu yazıda backend'den altı vaka var ve hepsi aynı şablonla ilerliyor: belirti, ilk yanlış hipotez, daraltma, kök sebep, düzeltme ve onu kanıtlayan test. Mekaniğini başka serilerde anlattığımız konuları (Türkçe I, Dictionary key eşitliği, async void, N+1) yeniden anlatmıyorum; burada odak, o bug'ı production'da nasıl bulduğun.
Vaka 1: Türkçe Culture'da Bozulan Rol Kontrolü
Belirti: yeni açılan ikinci sunucuya düşen admin kullanıcıları yönetim ekranında 403 alıyor; aynı kullanıcı ilk sunucuya düşünce her şey normal. İlk yanlış hipotez: "Token'daki role claim'i bozuk geliyor ya da authorization cache'i eski." Aynı token iki sunucuda farklı sonuç verdiği için hipotez çöküyor: girdi aynı, sonuç farklı. Fark girdide değil, ortamda.
Daraltma için serinin ikinci yazısındaki ortam farkları listesine döndük ve iki sunucunun CultureInfo.CurrentCulture değerini health endpoint'ine ekledik. İlk sunucu en-US, yeni sunucu tr-TR. Kod tarafında şüpheli satır hemen bulundu:
namespace Bookstore.Hunts;
public static class RoleCheck
{
// hatalı: current culture'a göre büyük harfe çeviriyor
public static bool IsAdminBuggy(string role) => role.ToUpper() == "ADMIN";
public static bool IsAdmin(string role) =>
string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase);
}
Üç culture'da gerçek çıktı:
en-US "admin".ToUpper() = ADMIN buggy=True fixed=True
tr-TR "admin".ToUpper() = ADMİN buggy=False fixed=True
invariant "admin".ToUpper() = ADMIN buggy=True fixed=True
Kök sebep: Türkçede küçük "i"nin büyüğü noktalı "İ". Mekaniği C# Temelleri serisinin "C# String'ler: Interpolation, StringBuilder ve Türkçe I Tuzağı" yazısında; burada önemli olan, bu bug'ın yalnız culture'ı farklı makinede görünmesi. Düzeltme, karşılaştırmayı culture'dan bağımsız yapmak: bir role adı ya da protokol değeri gibi makine için anlamı olan metinlerde StringComparison.OrdinalIgnoreCase. Test, aynı kontrolü üç culture'da çalıştırıyor:
[Theory]
[InlineData("en-US")]
[InlineData("tr-TR")]
[InlineData("")]
public void Admin_role_is_recognized_in_every_culture(string cultureName)
{
CultureInfo original = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo(cultureName);
Assert.True(RoleCheck.IsAdmin("admin"));
}
finally
{
CultureInfo.CurrentCulture = original;
}
}
Testi önce hatalı method'a bağlayıp çalıştırdım; kırmızı sonuç tam olarak tek culture'ı gösteriyor:
failed Bookstore.Hunts.Tests.RoleCheckTests.Admin_role_is_recognized_in_every_culture(cultureName: "tr-TR") (1ms)
Assert.True() Failure
Expected: True
Actual: False
Bu sınıf bug'ı kalıcı olarak kapatmak için analyzer'ı devreye aldık. CA1311 ("Specify a culture or use an invariant version") Microsoft Learn'e göre .NET 10'da varsayılan olarak kapalı, CA1862 ise yalnız suggestion seviyesinde; yani normal build hiçbir şey söylemiyor. Projeye bir .editorconfig ekleyince build iki uyarı veriyor (mesajlar kısaltıldı):
[*.cs]
dotnet_diagnostic.CA1311.severity = warning
dotnet_diagnostic.CA1862.severity = warning
src/Bookstore.Hunts/RoleCheck.cs(5,53): warning CA1862: Prefer using 'string.Equals(string, StringComparison)' to perform a case-insensitive comparison, ...
src/Bookstore.Hunts/RoleCheck.cs(5,58): warning CA1311: Specify a culture or use an invariant version to avoid implicit dependency on current culture
Vaka 2: Üç Saat Kayan Günlük Satış Raporu
Belirti: finans ekibi, Bookstore'un "dünkü satışlar" raporunun ödeme sağlayıcısının gün sonu mutabakatıyla tutmadığını söylüyor. Fark her gün var ama küçük. İlk yanlış hipotez: "Ödeme sağlayıcısı bazı ödemeleri geç onaylıyor, onlar ertesi güne kayıyor." Bunu sınamak için tutmayan siparişleri tek tek listeledik. Hepsinin ortak bir özelliği vardı: Türkiye saatiyle 00:00 ile 03:00 arasında verilmişlerdi. Onay gecikmesi bu kadar düzenli bir saat aralığı üretmez; üç saat, UTC (Coordinated Universal Time) ile Türkiye arasındaki fark.
Siparişler doğru şekilde DateTime.UtcNow ile yazılıyordu. Raporun gün sınırları ise başka bir dönemden kalmıştı:
public static class DailySalesReportBuggy
{
public static (DateTime From, DateTime To) Yesterday()
{
// hatalı: sunucunun yerel gece yarısı, UTC değerlerle karşılaştırılıyor
DateTime today = DateTime.Today;
return (today.AddDays(-1), today);
}
}
Kök sebep: DateTime offset taşımaz. Veritabanından gelen değerin Kind'ı Unspecified olur ve >= karşılaştırması yalnız tick'lere bakar. Yerel gece yarısını UTC değerlerle karşılaştırınca rapor aslında "UTC günü"nü, yani Türkiye saatiyle 03:00'ten 03:00'e kadar olan aralığı topluyor. Beş siparişi Türkiye saatine göre dün 01:10, 10:15, 23:40 ve bugün 00:20, 02:50'ye yerleştirip raporu çalıştırdım:
TZ=Europe/Istanbul DateTime.Now=2026-11-08 12:47 DateTime.UtcNow=2026-11-08 09:47
buggy: [2026-11-07 00:00, 2026-11-08 00:00) -> orders 2,3,4,5
fixed: [2026-11-06 21:00, 2026-11-07 21:00) -> orders 1,2,3
Hatalı rapor dünün 01:10 siparişini kaçırıyor ve bugünün iki siparişini düne yazıyor. Aynı programı TZ=UTC ile çalıştırınca hatalı aralık değişmedi; çünkü bu saatte iki saat diliminde de tarih aynı. Job gece 01:00'de ve saat dilimi UTC olan bir container'da çalışırsa ise DateTime.Today Türkiye'deki günden bir önceki günü verir ve kayma bir güne çıkar. Bu yüzden test, job'ın gerçekten çalıştığı saati sabitliyor.
Düzeltme iki parça. Birincisi gün sınırını iş kuralının saat diliminde hesaplayıp UTC'ye çevirmek; ikincisi saati TimeProvider ile dışarıdan almak, böylece test istediği anı kurabiliyor:
public sealed class DailySalesReport(TimeProvider clock)
{
private static readonly TimeZoneInfo Istanbul =
TimeZoneInfo.FindSystemTimeZoneById("Europe/Istanbul");
public (DateTime FromUtc, DateTime ToUtc) Yesterday()
{
DateTime todayLocal = TimeZoneInfo.ConvertTime(clock.GetUtcNow(), Istanbul).Date;
DateTime fromLocal = todayLocal.AddDays(-1);
return (ToUtc(fromLocal), ToUtc(todayLocal));
}
private static DateTime ToUtc(DateTime local) =>
TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(local, DateTimeKind.Unspecified), Istanbul);
}
[Fact]
public void Yesterday_uses_istanbul_day_boundaries_when_job_runs_after_midnight()
{
// job Türkiye saatiyle 8 Kasım 01:00'de çalışıyor
FakeTimeProvider clock = new(new DateTimeOffset(2026, 11, 7, 22, 0, 0, TimeSpan.Zero));
DailySalesReport report = new(clock);
(DateTime fromUtc, DateTime toUtc) = report.Yesterday();
Assert.Equal(new DateTime(2026, 11, 6, 21, 0, 0, DateTimeKind.Utc), fromUtc);
Assert.Equal(new DateTime(2026, 11, 7, 21, 0, 0, DateTimeKind.Utc), toUtc);
}
Tarih tarafındaki ikinci klasik kayma, JSON (JavaScript Object Notation) içinde saat dilimi olmayan tarihin tarayıcıda bir gün kaymasıydı; o vaka serinin son yazısında.
Vaka 3: new HttpClient() ve Socket Exhaustion
Belirti: kampanya günü öğleden sonra stok servisine giden çağrılar aralıklı olarak SocketException ile düşüyor. İlk yanlış hipotez: "Stok servisi yükü kaldıramıyor." Ama stok servisinin kendi metrikleri sakin, hata bizim tarafımızda, bağlantı kurulmadan önce oluşuyor. Daraltma, process'in açtığı TCP (Transmission Control Protocol) bağlantılarının durumuna bakmakla başladı.
for (int i = 0; i < 1000; i++)
{
// hatalı: her istek için yeni bir HttpClient ve yeni bir connection pool
using HttpClient client = new();
await client.GetStringAsync("http://127.0.0.1:5501/stock/42");
}
Bu döngüyü kendi Kestrel'ime karşı çalıştırıp hemen ardından netstat ile o port'a ait bağlantıları saydım. Sonra aynı 1000 isteği tek bir paylaşılan client ile tekrarladım (arada TIME_WAIT'lerin temizlenmesini bekledim):
$ ./demo sockets new
mode=new requests=1000 elapsed=243 ms
$ netstat -an -p tcp | grep '127.0.0.1.5501' | awk '{print $NF}' | sort | uniq -c
1000 TIME_WAIT
$ ./demo sockets shared
mode=shared requests=1000 elapsed=120 ms
$ netstat -an -p tcp | grep '127.0.0.1.5501' | awk '{print $NF}' | sort | uniq -c
1 TIME_WAIT
Kök sebep: her HttpClient kendi handler'ı ve connection pool'u ile gelir; dispose edilince bağlantı kapanır ve bağlantıyı kapatan taraf o port'u bir süre TIME_WAIT'te tutar. Bu Mac'te net.inet.tcp.msl 15000 ms; ölçtüğümde TIME_WAIT kayıtları 30 ile 35 saniye arasında kayboldu, yani yaklaşık iki MSL (Maximum Segment Lifetime). Ephemeral port aralığı 49152–65535, yani 16.384 port. Kaba hesap: aynı hedefe saniyede 16.384 / 30 ≈ 546'dan fazla yeni bağlantı açarsan port biter. Kampanya günü tam olarak bu oldu.
Düzeltme Microsoft'un HttpClient yönergesindeki iki seçenekten biri: ya uzun ömürlü (static ya da singleton) bir client ve PooledConnectionLifetime, ya da IHttpClientFactory ile üretilen kısa ömürlü client'lar. İlki şöyle:
// tek instance; bağlantılar 5 dakikada bir yenilenir, DNS değişikliği de böylece fark edilir
HttpClient shared = new(new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) });
PooledConnectionLifetime ayrıntısı önemli: aynı yönerge, HttpClient'ın DNS (Domain Name System) kaydını yalnız bağlantı kurulurken çözdüğünü ve TTL'e (time to live) bakmadığını söylüyor. "Hep aynı client'ı kullan" düzeltmesini bu ayar olmadan yaparsan socket exhaustion'ı, servisin IP (Internet Protocol) adresi değişince eski adrese gitmeye devam eden yeni bir bug ile değiştirmiş olursun. Bu vakada unit test yerine regression kontrolü olarak new HttpClient() çağrılarını build'de yakalamak daha etkili: serinin on birinci yazısında kullandığımız BannedApiAnalyzers ile bu constructor'ı yasaklayabilirsin (bu adımı burada çalıştırmadım).
Vaka 4: Dictionary'de Kaybolan Key
Belirti: bazı siparişlerde aynı kitap iki ayrı satır olarak görünüyor, her biri adet 1. İlk yanlış hipotez: "Frontend aynı isteği iki kez gönderiyor." Network log'unda tek istek vardı. Sepet kodunu adım adım izledik:
public sealed record MutableCartLineKey
{
public required string Isbn { get; set; }
public required string Format { get; set; }
}
Dictionary<MutableCartLineKey, int> lines = [];
MutableCartLineKey added = new() { Isbn = "978-0-13-468599-1", Format = "paperback" };
lines[added] = 1;
// hatalı: key Dictionary'deyken değiştiriliyor (ISBN normalizasyonu)
added.Isbn = added.Isbn.Replace("-", "");
Console.WriteLine($"ContainsKey(added) = {lines.ContainsKey(added)}");
Console.WriteLine($"Keys.Any(k => k.Equals(added)) = {lines.Keys.Any(k => k.Equals(added))}");
MutableCartLineKey scanned = new() { Isbn = "9780134685991", Format = "paperback" };
if (lines.TryGetValue(scanned, out int quantity))
{
lines[scanned] = quantity + 1;
}
else
{
lines[scanned] = 1;
}
Console.WriteLine($"lines.Count = {lines.Count}");
Console.WriteLine($"keys equal = {lines.Keys.First().Equals(lines.Keys.Last())}");
ContainsKey(added) = False
Keys.Any(k => k.Equals(added)) = True
lines.Count = 2
keys equal = True
İlk iki satır, bu vakanın teşhis tekniği: hash ile yapılan arama (ContainsKey) key'i bulamıyor, lineer arama (Keys.Any) buluyor. Bu ikili, "key Dictionary'ye eklendikten sonra değişmiş" demenin en kısa yolu. Dikkat: lines.Keys.Contains(added) burada işe yaramaz, çünkü KeyCollection.Contains de içeride ContainsKey'i çağırır. Son satır ise bir Dictionary'nin içinde birbirine eşit iki key olduğunu gösteriyor; normalde imkânsız olan bir durum. ISBN (International Standard Book Number) normalizasyonu, key'in hash'i hesaplandıktan sonra yapılmış ve key yanlış bucket'ta kalmış.
Kök sebep, record'un value equality'sini set'li property'lerle birleştirmek. Mekaniği C# Koleksiyonlar serisinin "C# Dictionary ve HashSet: TryGetValue, Key Eşitliği ve Comparer" yazısında. Düzeltme, key'i değiştirilemez yapmak ve normalizasyonu key oluşturulurken tek bir yerde yapmak:
public readonly record struct CartLineKey
{
public string Isbn { get; }
public string Format { get; }
private CartLineKey(string isbn, string format) => (Isbn, Format) = (isbn, format);
public static CartLineKey Create(string isbn, string format) =>
new(isbn.Replace("-", "").Trim(), format.Trim().ToLowerInvariant());
}
[Fact]
public void Same_book_scanned_with_and_without_hyphens_is_one_line()
{
Dictionary<CartLineKey, int> lines = [];
lines[CartLineKey.Create("978-0-13-468599-1", "paperback")] = 1;
CartLineKey scanned = CartLineKey.Create("9780134685991", "Paperback");
lines[scanned] = lines.GetValueOrDefault(scanned) + 1;
Assert.Single(lines);
Assert.Equal(2, lines[scanned]);
}
Vaka 5: Process'i Çökerten async void
Belirti: API process'i günde birkaç kez yeniden başlıyor. Global exception handler'ın log'unda hiçbir şey yok, son log satırı her seferinde başarılı bir sipariş isteği. İlk yanlış hipotez: "Memory leak var, container OOM (Out Of Memory) ile öldürülüyor." Bunu sınamanın ucuz yolu exit code'a bakmak: OOM killer process'i SIGKILL ile öldürür, exit code 137 olur ve Docker OOMKilled=true der. Bizimki 134'tü, yani SIGABRT: process kendini sonlandırmıştı.
public sealed class OrderPlacedNotifier
{
// hatalı: async void, exception'ı yakalayacak bir çağıran yok
public async void OnOrderPlacedBuggy(int orderId)
{
await Task.Delay(100);
throw new SmtpException($"Mailbox unavailable while sending confirmation for order {orderId}");
}
}
app.MapPost("/orders/{id:int}", (int id, OrderPlacedNotifier notifier) =>
{
notifier.OnOrderPlacedBuggy(id);
return Results.Created($"/orders/{id}", new { id });
});
İstemci tarafında her şey yolunda görünüyor:
$ curl -s -i -X POST http://127.0.0.1:5502/orders/1042
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: Sun, 08 Nov 2026 09:51:58 GMT
Server: Kestrel
Location: /orders/1042
Transfer-Encoding: chunked
{"id":1042}
Process'in stderr'ine ise 100 ms sonra şu düşüyor (dosya yolu kısaltıldı):
Unhandled exception. System.Net.Mail.SmtpException: Mailbox unavailable while sending confirmation for order 1042
at Bookstore.Hunts.OrderPlacedNotifier.OnOrderPlacedBuggy(Int32 orderId) in .../src/Bookstore.Hunts/OrderPlacedNotifier.cs:line 10
at System.Threading.Tasks.Task.<>c.<ThrowAsync>b__124_1(Object state)
at System.Threading.QueueUserWorkItemCallback.Execute()
at System.Threading.ThreadPoolWorkQueue.Dispatch()
at System.Threading.PortableThreadPool.WorkerThread.WorkerThreadStart()
at System.Threading.Thread.StartCallback()
exit code: 134
Stack trace'in alt kısmı imza gibi: Task.<>c.<ThrowAsync> ve QueueUserWorkItemCallback. Exception bir request pipeline'ında değil, thread pool'a bırakılmış bir iş öğesinde fırlatılmış; ASP.NET Core'da SynchronizationContext olmadığı için async void'in exception'ı oraya gider ve process'i sonlandırır. Middleware'in ve UseExceptionHandler'ın bundan haberi olmaz. Mekaniği C# Asenkron serisinin "C# async void ve Fire-and-Forget: Kaybolan Exception'lar" yazısında.
Container'da ise sürpriz bir ayrıntı çıktı. Aynı uygulamayı aspnet:10.0 image'ında, dotnet doğrudan PID 1 olacak şekilde çalıştırdım. Exception yazıldı ama container kapanmadı:
$ docker inspect --format 'Status={{.State.Status}} ExitCode={{.State.ExitCode}}' debugwork-crash-12
Status=running ExitCode=0
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:5503/orders/1043
201
$ docker stats --no-stream --format '{{.CPUPerc}}' debugwork-crash-12
100.08%
Process "Unhandled exception" yazdıktan sonra bir CPU (Central Processing Unit) çekirdeğini %100 kullanarak ayakta kaldı ve yeni isteklere 201 dönmeye devam etti. Linux'un kill(2) ve pid_namespaces(7) man sayfalarına göre PID 1'e yalnız handler kurduğu sinyaller teslim edilir; gördüğüm davranış, SIGABRT sinyalinin process'i bu yüzden sonlandıramamasıyla tutarlı. Aynı container'ı docker run --init ile (PID 1 küçük bir init process'i olacak şekilde) başlattığımda sonuç beklenen hâle döndü:
Status=exited ExitCode=134 OOMKilled=false
Bu davranışı Docker Desktop üzerindeki Linux container'ında gördüm; kendi orchestrator'ında uygulaman PID 1 ise aynı denemeyi yap. Health check'i geçen ama yarı ölü bir process, düzgün çöken bir process'ten çok daha zor bulunur.
Düzeltme: method async Task olur ve exception'ı gözlemleyebilen bir yere bağlanır; mail gibi isteğin sonucunu etkilememesi gereken işler bir background queue'ya yazılır ve orada try/catch ile log'lanır. Test, exception'ın artık çağıran tarafından görülebildiğini doğruluyor:
[Fact]
public async Task Notification_failure_is_observable_by_the_caller()
{
OrderPlacedNotifier notifier = new();
await Assert.ThrowsAsync<SmtpException>(() => notifier.OnOrderPlacedAsync(1042));
}
Bu sınıf bug'ı build'de yakalamak için Microsoft.VisualStudio.Threading.Analyzers 18.12.1 paketini ekledim; build şu uyarıyı verdi (URL kısmı kısaltıldı):
src/Bookstore.Hunts/OrderPlacedNotifier.cs(7,23): warning VSTHRD100: Avoid "async void" methods, because any exceptions not handled by the method will crash the process
Vaka 6: EF Core'da N+1 ve Tracking Kaynaklı Yavaşlık
Belirti: admin panelindeki sipariş listesi, sipariş sayısı arttıkça yavaşlıyor. İlk yanlış hipotez: "Orders tablosunda index eksik." Ama veritabanının yavaş sorgu log'unda tek bir yavaş sorgu yok. Daraltma için soruyu değiştirdik: sorgular yavaş değilse, kaç tane sorgu var? EF Core'un LogTo'su ile yalnız çalıştırılan komutları saydık:
public static async Task<List<OrderListItem>> ListBuggyAsync(BookstoreDbContext db) =>
(await db.Orders.ToListAsync())
// hatalı: lazy loading proxy her Customer için ayrı sorgu atıyor
.Select(o => new OrderListItem(o.Id, o.Customer.Name, o.Total))
.ToList();
public static Task<List<OrderListItem>> ListAsync(BookstoreDbContext db) =>
db.Orders
.AsNoTracking()
.Select(o => new OrderListItem(o.Id, o.Customer.Name, o.Total))
.ToListAsync();
2000 sipariş ve 400 müşteriyle, SQLite in-memory veritabanında, ısınma turundan sonraki ölçüm:
buggy: rows=2000 sql commands=401 tracked=2400 elapsed=34 ms
fixed: rows=2000 sql commands=1 tracked=0 elapsed=1 ms
401 sorgu: biri siparişler için, 400'ü her müşteri için birer tane. N burada sipariş sayısı değil, farklı müşteri sayısı; identity resolution sayesinde aynı müşteri ikinci kez sorgulanmıyor. Bu yüzden test ortamında 3 müşteriyle "sadece 4 sorgu" görünür ve kimse fark etmez. Change tracker'da da 2400 entity birikmiş; salt okunur bir liste için gereksiz bir maliyet. In-memory SQLite'ta ağ gecikmesi yok; gerçek bir veritabanı sunucusunda her round trip'e ağ gecikmesi eklenir ve 401 round trip birkaç milisaniyeden saniyelere çıkar. Mekaniği EF Core serisinin "EF Core'da Performans Temelleri: N+1, Projection, AsNoTracking ve Compiled Query" yazısında.
Düzeltme projection: SQL (Structured Query Language) tarafında tek bir JOIN ile DTO (Data Transfer Object) üretmek ve tracking'i kapatmak. Regression testi süreye değil, sorgu sayısına bakıyor; süre makineye göre değişir, sorgu sayısı değişmez:
[Fact]
public async Task Order_list_runs_a_single_sql_command()
{
// ... iki müşteri ve iki sipariş seed edildi, commands sayacı LogTo ile artıyor
using BookstoreDbContext db = new(options);
commands = 0;
List<OrderListItem> list = await OrderQueries.ListAsync(db);
Assert.Equal(2, list.Count);
Assert.Equal(1, commands);
}
Testlerin hepsi birlikte:
Test run summary: Passed!
total: 7
failed: 0
succeeded: 7
skipped: 0
duration: 1s 317ms
Üretimde Dikkat
Altı vakanın beşinde ilk hipotez, bug'ın başka bir sistemde olduğunu söylüyordu: token, ödeme sağlayıcısı, stok servisi, frontend, veritabanı. Hipotezi çürüten her seferinde ucuz bir gözlemdi: iki sunucuda aynı token, tutmayan siparişlerin saat dağılımı, netstat çıktısı, tek network isteği, exit code, sorgu sayısı. Production'da pahalı araçlara geçmeden önce bu tür bir veya iki dakikalık gözlemlerle hipotezi elemek, saatler kazandırır.
Ortam bilgisini görünür yap. Culture, saat dilimi, runtime sürümü ve hangi instance'ın cevap verdiği, bir health ya da diagnostics endpoint'inde (yetki arkasında) dursun. Vaka 1 ve 2'de kök sebebe giden yol bu bilgiyle başladı. Container'larda TZ ve LANG değişkenlerini Dockerfile'da açıkça yaz; image'ın varsayılanına güvenme.
Exit code'u ve restart sayısını izle. 134 ile 137'yi ayırt etmek, "memory mi, exception mı" sorusunu tek bakışta cevaplıyor. Process'in çöktüğü anı yakalamak için serinin dokuzuncu yazısındaki otomatik crash dump ayarları burada da geçerli. Container'da uygulaman PID 1 ise --init ya da orchestrator'ının eşdeğerini kullan.
Sayaçları süreyle değil adetle karşılaştır: bağlantı sayısı, TIME_WAIT sayısı, sorgu sayısı, tracked entity sayısı. Süre gürültülüdür ve yük altında değişir; adet, kodun davranışını doğrudan anlatır ve teste dönüşebilir.
Sık Düşülen Tuzaklar
Bug'ı düzeltip testini yazmamak. Altı vakanın hepsinde düzeltme bir satırdı; değerli olan, aynı hatanın geri gelmesini engelleyen test ve analyzer kuralı. Serinin on birinci yazısındaki üç seviye (hotfix, regression test, bu sınıf bug'ı imkânsız kılan değişiklik) burada somutlaşıyor: OrdinalIgnoreCase, regression testi ve CA1311.
Düzeltmeyi ikinci bir bug ile yapmak. HttpClient'ı static yapıp PooledConnectionLifetime'ı unutmak DNS bug'ı getirir; DateTime.Now'ı DateTime.UtcNow ile değiştirip gün sınırını iş kuralının saat diliminde hesaplamamak raporu bu kez UTC gününe kaydırır.
Test ortamının küçük verisine güvenmek. N+1, 3 müşteriyle 4 sorgu; socket exhaustion, 10 istekle hiç görünmez; saat dilimi bug'ı, gece yarısından sonra çalışmayan bir testte asla kırmızı olmaz. Testin girdisini production'ın sınır koşullarına göre seç.
Stack trace'in alt kısmını okumamak. Vaka 5'te cevap en üstteki satırda değil, altındaki ThrowAsync ve QueueUserWorkItemCallback frame'lerindeydi. Serinin dördüncü yazısında async frame'leri okurken yaptığımız gibi, stack trace'i yalnız ilk satırına bakıp kapatma.
Küçük Bir Deneme
★☆☆ Vaka 1'deki .editorconfig'i kendi projene ekle ve build al. Kaç tane CA1311 uyarısı çıkıyor? Her birini "makine için mi, insan için mi" diye sınıflandır: makine için olanlar Ordinal/ToUpperInvariant, kullanıcıya gösterilenler açık bir culture ile.
★★☆ Vaka 2'deki testi, job'ın Türkiye saatiyle 23:30'da çalıştığı bir anla tekrar yaz. Sonuç değişiyor mu? Sonra hatalı versiyonu TimeProvider alacak şekilde değiştirip aynı iki anı onunla da dene ve hangi saatte kaç siparişin kaydığını hesapla.
★★★ Vaka 3'ü kendi makinende tekrarla: döngüyü 1000 yerine saniyede sabit sayıda istek atacak şekilde değiştir ve TIME_WAIT sayısının hangi değerde dengelendiğini ölç. Ölçtüğün sayının "istek hızı × TIME_WAIT süresi" ile tutup tutmadığını kontrol et.
Bu yazıdaki vakaların hepsi sunucunun içinde geçti. Serinin son yazısında sahne tarayıcı ile sunucu arasındaki sınıra taşınıyor: deploy sonrası beyaz ekran, yalnız production'da patlayan CORS (Cross-Origin Resource Sharing) preflight'ı, bir gün kayan doğum tarihi, para hesabında floating point ve unsubscribe edilmeyen observable ile büyüyen bellek; sonunda da serinin tek sayfalık debugging kontrol listesi.