Geliştirdiğimiz uygulamalarımızda illa ki tamamlanması dakikalarca süren bir endpoint’in ortaya çıkması neredeyse kaçınılmazdır. Misal olarak; yıllarca toplanan verilerin istatistiki olarak raporlanması gerektiği durumlarda, toplu veri aktarımlarında veya response döndürülmeden önce birkaç servise ve veritabanına yayılan iş akışı süreçlerinde geliştirdiğimiz endpoint’ler, ister istemez haddinden fazla tedirginlik yaratan bekleme süreleri ile varlık gösterebiliyorlar. Ki böyle endpoint’lerin varlığından ziyade ortaya çıkardıkları sorunlar bizler için risk teşkil ediyor. Nedir bu sorunlar? diye sorarsanız… Bu endpoint’lerden beslenen servislerin ya da bizzat kullanıcıların dakikalar boyunca bekletiliyor olmalarıdır! Eğer bekleyen bir servisse timeout’a düşme ihtimali vardır, yok eğer bir kullanıcıysa en iyi ihtimalle bunalma ihtimali… Ve tüm bu süreç boyunca API’da bu request’i işlem süresi boyunca açık tutacaktır ve bu durumda da sunucu açısından bir thread’in ve concurrency bütçesi söz konusu olacaktır. Hele hele böyle bir durumda bu endpoint’te zuhur edecek olan küçük bir trafik artışı, API’nin geri kalanını da potansiyel olarak çökertecektir. Bu içeriğimizde bu tarz sorunlara karşı nasıl yaklaşımlar ve çözümler uygulamamız gerektiğine dair istişarede bulunacak ve çalışmalarımızı daha idealize hale getirmeye çalışıyor olacağız. O halde buyurun başlayalım…
Özellikle son zamanlarda bu konuda çok fazla yanlış uygulama gözlemlediğimi itiraf edebilirim. Muhtemelen insanlar, genellikle “API request’i 5 dakika sürüyor ama sorun değil” diye düşünmektedirler. Halbuki asıl problem kodun uzun sürmesi değil, HTTP’nin doğasının buna uygun olmamasıdır!
Önce şu soruyu sormakta fayda vardır… Bir API request’inin uzun sürmesi neden problemdir?
Uzun bir request bitene kadar TCP bağlantısı ve HTTP connection açık kalacak, Kestrel request context’i süre boyunca yaşayacak ve arada Nginx, IIS, API Gateway ve Load Balancer varsa onlar da isteğin tamamlanmasını timeout sayaçları çalışır vaziyette bekleyecektir. Tüm bunların yanında tabi ki de client’da request’in yanıtını (response) bekleyecektir. Şimdi düşünün… Böyle bir request’i 1000 kullanıcı aynı anda yaparsa ne olacak?
Ayrıca uzun request süreçlerinde birçok sistemin(Nginx, IIS, Azure App Service, API Gateway, Load Balancer ve Browser) timeout süreleri birbirlerinden farklı olduğu için ciddi bir risk söz konusu olacaktır. Şöyle ki; diyelim ki client timeout aldı ve retry mekanizması ile tekrar request’te bulundu. Eee, bu durumda tekrar aynı süreç başlatılacak ve netice olarak iki kez bu iş yükü cereyan etmiş olacaktır! (Bu tarz durumlara karşın idempotency cidden önem kazanmaktadır)
Peki çözüm ne hoca? dediğinizi duyar gibiyim… Doğrusunu söylemek gerekirse tek bir çözüm yok! Duruma göre uygulanması gereken farklı desenler var… Ve bence bunları aşağıdaki gibi sürelere göre kategorize edebiliriz:
| İş Süresi | Yaklaşım |
|---|---|
| < 2 sn | Senkron HTTP |
| 2 – 10 sn | Duruma göre senkron veya asenkron süreç |
| 10 sn+ | Background processing |
| Dakikalar | Queue + Worker |
| Saatler | Workflow / Job scheduler |
Hadi şimdi gelin tek tek desenleri doğrusuyla ve yanlışıyla incelemeye başlayalım.
- Fire-and-Forget
İlk olarak bu güne kadar gördüğüm en tehlikeli yaklaşımı ele alarak başlayalım istiyorum.app.MapGet("/fire-and-forget", () => { _ = Task.Run(async () => { while (true) { await Task.Delay(1500); Console.WriteLine("PDF generating..."); } }); return TypedResults.Ok(); });Bu yaklaşımda mantık; yapılacak işlem ne kadar uzun olursa olsun istek anında sonuçlansa da işlemin arka planda sürdürülmesini sağlamaktır. İlk bakışta her ne kadar pratik görülse de
Task.Runkomutu Asp.NET Core’da güvenilir bir arka plan işleme mekanizması olmadığından dolayı esasında oldukça sıkıntılı bir yaklaşım sergilenmektedir! Özellikle uygulama yeniden başlatılırsa task yarıda kalabilir yahut hata alınırsa kimse fark etmeksizin işlem süreci baltalanabilir. Ve tüm bu süreçte retry gibi bir mekanizma olmayacağı için de ciddi kayıplar söz konusu olabilir.Yanıt süresi ile çalışma süresi aynı şey olmak zorunda değildir! Haliyle request’lerin süreleri bu mantığa göre yapılandırılmak istenebilir. Ancak yanlış yöntemler ciddi maliyetlere sebebiyet verebileceği için dikkat edilmelidir…
- Background Queue
Sırada Microsoft’un önerdiği yaklaşımdan bahsedelim…Bu yaklaşımda ise yoğun işlem hacmine sahip endpoint’e gelen isteğin direkt işlevsellikten önce bir queue’ya aktarılması esas teşkil etmektedir. Şöyle ki;
Request➜APIİş kuyruğa eklenir➜Response➜Background ServiceKuyruktan iş alınır ve işlenirBurada görüldüğü üzere yine gelen request anında response döndürecektir. Ancak iş bir önceki yaklaşıma nazaran daha güvenli bir şekilde queue üzerinden background service ile yürütülüyor olacaktır. Bunun için uygulamanın belleğinde (in-memory) çalışan bir queue’dan yararlanılmaktadır. Bakın dikkat ederseniz henüz RabbitMQ, NATS vs. gibi harici bir message broker altyapısına gitmiyoruz! Daha yapılacak iş hacmi o derece ağır ve ölçeklendirme gerektirmediği için işi in-memory’de çözüyoruz 😉
Bu yaklaşım sayesinde HTTP request hem uzun süre açık kalmayacak hem de kullanıcı ve client bekletilmeyecektir. Gelen taleplere karşın işler, sırasıyla veya belirlendiği sayıda paralel bir şekilde işleniyor olacaktır.
Task.Runyaklaşımına göre çok daha kontrollü ve emniyetlidir! Ancak önemli bir dezavantajı vardır ki, o da, queue’nun uygulamanın belleğinde olmasıdır. Uygulama yeniden başlatıldığı taktirde queue’daki işler kaybolacaktır. Bu nedenle kritik sistemlerde kesinlikle harici message broker kullanılması elzem olacaktır.Örnek çalışma;
app.MapGet("/background-queue", async () => { await BackgroundTaskQueue.EnqueueAsync(async cancellationToken => { for (int i = 0; i < 10; i++) { await Task.Delay(1500, cancellationToken); Console.WriteLine($"PDF generating... {i}"); } }); return TypedResults.Ok(); });Burada gelen istek neticesinde yapılacak iş (her ne kadar for döngüsü de olsa) in-memory’de arka planda işlenir halde queue’ya alınmaktadır.
⚠️ Bu yaklaşımın kod bütünlüğünü görebilmek için şuradaki çalışmaya göz atabilirsiniz. - Message Broker
İş büyüdüğünde, queue artık process içerisinden çıkarılmalıdır. Bu artık gerçek anlamda ölçeklenebilir bir yapı sunacaktır.Bu yaklaşıma dair önceden klavyeye aldığım aşağıdaki içeriklere göz atmanızı tavsiye ederim:
- Polling Pattern
Bu yöntem de yıllardır kullanılan en güvenilir yaklaşımlardan biridir. İşin başlatılması, ama sonucunun beklenmemesi üzerine kurulmuş bir davranışa sahiptir. Sonucu daha sonra gelip sorulmalıdır.Tabi gelip neticenin sorulabilmesi için bir jobId değerine ihtiyaç olacaktır. Bunun için API, gelen request neticesinde anında
{
“jobId”: “a8f3d1”
}tarzı bir jobId değeri döndürecektir.
Neden buna polling denmektedir?
Çünkü client belirli aralıklarla sunucuya ‘Bitti mi?’ diye soru sormaktadır. Yani iletişim client tarafındadır. Sunucu, iş bitince kullanıcıya kendiliğinden haber vermemektedir!Client
│
POST /reports
│
◄────────── JobId
│
GET /reports/{id}
│
◄────────── Running
│
GET /reports/{id}
│
◄────────── Running
│
GET /reports/{id}
│
◄────────── CompletedBu yaklaşımı şöyle bir çalışma üzerinden örneklendirebiliriz;
app.MapPost("/polling-pattern", async (JobStore jobStore) => { var jobInfo = jobStore.Create(); await AspNetCore.ApiScalingPatterns.PollingPattern.BackgroundTaskQueue.EnqueueAsync(async cancellationToken => { jobInfo.Status = JobStatus.Running; jobStore.Update(jobInfo); //İŞLEM YAPILIYOR try { for (int i = 0; i < 10; i++) { await Task.Delay(1500, cancellationToken); Console.WriteLine($"PDF generating... {i}"); } jobInfo.Status = JobStatus.Completed; jobInfo.ResultUrl = "https://example.com/result.pdf"; } catch (Exception ex) { jobInfo.Status = JobStatus.Failed; jobInfo.Error = ex.Message; } //İŞLEM YAPILIYOR jobStore.Update(jobInfo); }); return TypedResults.Ok(new { jobId = jobInfo.Id }); });app.MapGet("/polling-pattern/{jobId}", async (JobStore jobStore, Guid jobId) => { var jobInfo = jobStore.Get(jobId); return TypedResults.Ok(jobInfo); });Evet, burada görüldüğü üzere ikinci oluşturduğumuz GET türünden endpoint sayesinde job’a dair durum sorgulamasını yapabilir ve yapılacak işe dair kontrolleri sağlayabiliriz.
⚠️ Bu yaklaşımın kod bütünlüğünü görebilmek için şuradaki çalışmaya göz atabilirsiniz. - Callback / Webhook
Sen beni bekleme, işim bitince ben seri ararım…
Polling’de client sürekli sunucuya sorarken, Webhook’ta sunucu işlem bittikten sonra client’a haber vermektedir. Yapılandırması oldukça basit mantığa dayanmaktadır. Hemen örneklendirirsek eğer;
app.MapPost("/callback-webhook/{*callbackUrl}", async (string callbackUrl, JobStore jobStore, IHttpClientFactory httpClientFactory) => { var jobId = Guid.NewGuid(); await AspNetCore.ApiScalingPatterns.Callback_Webhook.BackgroundTaskQueue.EnqueueAsync(async cancellationToken => { for (int i = 0; i < 10; i++) { await Task.Delay(1500, cancellationToken); Console.WriteLine($"PDF generating... {i}"); } var httpClient = httpClientFactory.CreateClient(); await httpClient.PostAsJsonAsync( requestUri: callbackUrl, value: new { JobId = jobId, Status = "Completed", DownloadUrl = "https://example.com/result.pdf" } ); }); return TypedResults.Ok(new { jobId = jobId }); });Yukarıdaki çalışmayı incelerseniz eğer uzun süre vakit alacak olan request’in işlemini JobStore‘a yazmak yerine bir callback işlemi eşliğinde yine bir queue’ya almaktayız. İşlem bittiği taktirde görüldüğü üzere callback tetiklenecektir. Bu callback endpoint’ini de aşağıdaki gibi tasarlayabiliriz:
app.MapPost("/api/report-completed", (ReportCompletedRequest request, ILogger<Program> logger) => { Console.WriteLine($"{request.JobId} | {request.Status} | {request.DownloadUrl}"); return Results.Ok(); }); - Workflow Engine
Bazı işler ise tek bir metottan ibaret olmamaktadır. Haliyle gelen request neticesinde birden fazla metot, hatta dış servis ve hatta distributed bir sistem tetiklenmeye başlayabilir. İşte böyle durumlar için queue tek başına yeterli olmayacaktır. Burada devreye workflow yaklaşımı girebilir. Bunun için sizlere önerebileceğim en çağdaş ve etkin workflow teknolojisi Dapr Workflow‘dur. Bu yaklaşım sayesinde sistemin state’ini rahatlıkla tutabilir, retry’ı kolayca yapılandırabilirsiniz. Ayrıca ihtiyaç doğrultusunda compensation uygulayabilir, timeout yönetimi sağlayabilir ve tüm bu süreci durable execution ile sağlıklı bir şekilde yönetebilirsiniz. - Streaming
Yoğun ve uzun da olsa bazı request’lerin neticelerini beklemek iş mantığı gereği zaruri olabilmektedir. Böyle durumlarda gelen request’in neticesini stream bir şekilde response edebiliriz. Bunun için Server-Sent Events (SSE) veya gRPC Streaming gibi çözümlere gidilebilir. Bu çözümlerde connection sürekli açık kalacak ancak client veya kullanıcı verisel işleyiş bakımından ilerlemeyi hissediyor olacaktır.
Burada incelediğimiz yöntemlerde ele aldığımız bellek içi (in-memory) kuyruk (queue) yaklaşımına ek olarak, bir veritabanı tabanlı çözümden de yararlanılabilir. Esas olan, belirlenen amaca ulaşmaktır. Bu yaklaşımların temel hedefi, özellikle endpoint’lerde meydana gelebilecek ani yük artışları karşısında sistemi daha dayanıklı ve güvenilir hâle getirmektir.
Kısaca özetlersek;
- Gelen request’ler içerisinde yavaş ya da uzun vadeli işlerin optimize edilmesi gerekmektedir. Bir şekilde bunların arka planda ya da farklı bir bağlamda çözülmesi gerekmektedir.
- Endpoint içerisinde
Task.Rungibi kaçış yöntemlerinden uzak durulmalıdır. Gerekirse işlem bir queue’ya alınmalıdır ama ekstradan bir thread oluşturmaktan kaçınılmalıdır! - İşlem tamamlandığında ise kullanıcı yahut client bilgilendirilmelidir! Evet, belirli periyotta işlemin durumunu yoklama yöntemiyle kontrol etmek iyi olabilir, ancak anlık bildirim daha iyidir!
İlgilenenlerin faydalanması dileğiyle…
Sonraki yazılarımda görüşmek üzere…
İyi çalışmalar…
