Inventory Service ve gRPC ile Sync İletişim: Stok Rezervasyonu, Deadline ve Resilience
Ordering, stok rezervasyonu için Inventory'yi 2 saniyelik deadline'lı bir gRPC çağrısıyla kullanıyor; rezervasyon sipariş id'siyle idempotent, rowversion ile optimistic concurrency'li ve 15 dakikalık TTL ile kendiliğinden serbest kalıyor. Yazı 20 paralel siparişte çakışmaları, deadline'ın sunucudaki işi iptal etmesini, cleartext HTTP/2 tuzağını, buf ile breaking change kontrolünü ve Inventory kapalıyken checkout'un bedelini gerçek çıktılarla gösteriyor.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Aspire 13.6, Grpc.AspNetCore ve Grpc.Net.ClientFactory 2.85, Google.Protobuf 3.36, Entity Framework Core 10 ve Buf CLI 1.75 ile derlenip denenmiştir.
Önce Tahmin Et
Inventory'de Middlemarch'tan 25 adet var. Yirmi müşteri aynı anda birer adet sipariş veriyor. Inventory her rezervasyonda stok satırını okuyor, ayrılan adedi artırıp yazıyor; satırın SQL Server rowversion kolonu concurrency token. Yirmi isteğin kaçı başarılı olur, kaç tanesi "satır sen okuduktan sonra değişti" hatasına çarpar? İkinci soru: Ordering, Inventory'yi 2 saniyelik deadline ile çağırıyor; Inventory o gün yavaş ve her çağrıya 3 saniyede cevap veriyor. Ordering 2 saniyede vazgeçtiğinde Inventory işine devam edip rezervasyonu yine de yapar mı? Cevaplar gerçek çalıştırmalarda.
inventory.proto: gRPC Serisindeki Contract'tan Bookstore'a
gRPC ve GraphQL serisinin ilk yazısında, sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için bir inventory.proto tasarlamıştık. Inventory service'i aynı contract'ı kullanıyor; Bookstore'un microservice'lerinin ihtiyaç duymadığı alanları çıkardık ve numaralarını reserved yaptık. Field number'ların ve geriye uyumluluğun mantığını o yazıda anlattık:
syntax = "proto3";
package bookstore.inventory.v1;
option csharp_namespace = "Bookstore.Inventory.V1";
import "google/protobuf/timestamp.proto";
// gRPC ve GraphQL serisindeki contract, Bookstore'un microservice'lerinin kullanmadığı
// alanlar olmadan. Numaraları reserved kalıyor.
service InventoryService {
rpc GetStock (GetStockRequest) returns (GetStockResponse);
rpc ReserveStock (ReserveStockRequest) returns (ReserveStockResponse);
rpc ReleaseStock (ReleaseStockRequest) returns (ReleaseStockResponse);
}
message GetStockResponse {
reserved 5, 7, 8;
reserved "unit_price", "reorder_level", "warehouse_quantities";
string book_id = 1;
int32 available = 2;
int32 reserved_quantity = 3;
StockStatus status = 4;
google.protobuf.Timestamp updated_at = 6;
}
message ReservationLine {
string book_id = 1;
int32 quantity = 2;
}
// order_id çağrıyı idempotent yapar: aynı sipariş her zaman aynı rezervasyonu alır.
message ReserveStockRequest {
string order_id = 1;
repeated ReservationLine lines = 2;
}
message ReserveStockResponse {
string reservation_id = 1;
google.protobuf.Timestamp expires_at = 2;
}
book_id kitabın ISBN (International Standard Book Number; kitabın uluslararası kimlik numarası) değeri, order_id Ordering'in ürettiği Guid. StockStatus ve ReleaseStock mesajları gRPC serisindekiyle aynı olduğu için burada göstermiyoruz.
Contract'ı paylaşmak ve kırıcı değişikliği yakalamak
Proto dosyası Inventory'nin klasöründe, kendi projesinde duruyor: src/Services/Inventory/Bookstore.Inventory.Contracts. Inventory server tarafını, Ordering client tarafını bu projeden alıyor. Proje bir contract paketi gibi davranıyor: kendi sürümü var ve yalnız protobuf'un ihtiyaç duyduğu paketlere bağlı:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- Inventory'nin yayınladığı contract paketi. Breaking change yeni bir sürüm numarası değil, yeni bir proto paketi (v2) ister. -->
<Version>1.0.0</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Google.Protobuf" />
<PackageReference Include="Grpc.Core.Api" />
<PackageReference Include="Grpc.Tools">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<Protobuf Include="Protos/bookstore/inventory/v1/inventory.proto" ProtoRoot="Protos" GrpcServices="Both" />
</ItemGroup>
</Project>
01'de yazdığımız architecture test bu projeyi hemen yakaladı. .Contracts ile biten bir projenin hiçbir pakete bağlı olmamasını istemiştik:
failed Bookstore.ArchitectureTests.SharedCodeRules.Contract_projects_have_no_logic_dependencies (7ms)
Assert.All() Failure: 3 out of 3 items in the collection did not pass.
[0]: Item: "Google.Protobuf"
Error: Assert.Contains() Failure: Item not found in collection
Collection: []
Not found: "Google.Protobuf"
[1]: Item: "Grpc.Core.Api"
Error: Assert.Contains() Failure: Item not found in collection
Collection: []
Not found: "Grpc.Core.Api"
[2]: Item: "Grpc.Tools"
Error: Assert.Contains() Failure: Item not found in collection
Kural yanlış değildi, eksikti. gRPC contract'ı protobuf runtime'ına, gRPC'nin temel tiplerine ve kod üreticisine ihtiyaç duyuyor; bunlar mantık ya da altyapı değil. İzin listesine bu üçünü bilinçli olarak ekledik; testin değeri de burada: kuralı değiştirmek bir kod değişikliği ve pull request'te görünüyor.
// Contracts projesi mesajları tanımlar; mantık ya da altyapı paketi taşımaz.
// gRPC contract'ları protobuf runtime'ı, gRPC temel tipleri ve kod üreticisi ister; başka bir şey değil.
private static readonly string[] AllowedContractPackages = ["Google.Protobuf", "Grpc.Core.Api", "Grpc.Tools"];
Mono-repo'da Ordering bu projeye project reference ile bağlı; Inventory proto'yu değiştirdiğinde Ordering aynı commit'te yeniden derleniyor. Bu rahatlık bir risk de taşıyor: kırıcı bir değişiklik derleme hatası vermeden, yalnız çalışan sistemde ortaya çıkabilir. Bir field number'ın ya da tipinin değişmesi C#'ta derlenir, ama eski sürümle çalışan bir service'in mesajını yanlış okur. CI'da Buf CLI'ın breaking komutu main'deki proto'yla karşılaştırma yapıyor. Bilerek üç hata yaptığımız bir değişiklikte (book_id'yi isbn olarak yeniden adlandırmak, quantity'yi int64 yapmak, reserved olan 5 numarasını yeni bir alana vermek) çıktı şu:
buf breaking --against ../main
bookstore/inventory/v1/inventory.proto:29:1:Previously present reserved name "unit_price" on message "GetStockResponse" was deleted.
bookstore/inventory/v1/inventory.proto:29:1:Previously present reserved range "[5]" on message "GetStockResponse" is missing values: [5] were removed.
bookstore/inventory/v1/inventory.proto:41:3:Field "1" with name "isbn" on message "ReservationLine" changed option "json_name" from "bookId" to "isbn".
bookstore/inventory/v1/inventory.proto:41:10:Field "1" on message "ReservationLine" changed name from "book_id" to "isbn".
bookstore/inventory/v1/inventory.proto:42:3:Field "2" with name "quantity" on message "ReservationLine" changed type from "int32" to "int64".
exit=100
Proto klasöründeki buf.yaml yalnız breaking: use: [FILE] kuralını açıyor; FILE en sıkı kategori, yeniden adlandırmayı da kırıcı sayıyor. Sıfırdan farklı çıkış kodu pipeline'ı durduruyor. Gerçekten kırıcı bir değişiklik gerekiyorsa bookstore.inventory.v2 paketini v1'in yanında yayınlayıp ikisini bir süre birlikte sunmak, gRPC serisinde anlattığımız yol.
Inventory Service: Stok, Rezervasyon ve Idempotency
Monolith'te Inventory'nin kitabı BookId ile tanıyan bir StockItem tablosu ve tek transaction içinde çalışan bir ReserveAsync method'u vardı. Service'te üç şey değişti: kitap ISBN ile tanınıyor, rezervasyon ayrı bir kayıt (StockReservation) ve her rezervasyonun bir bitiş zamanı var. Sebebi şu: monolith'te rezervasyon ve sipariş aynı transaction'daydı, biri başarısız olursa ikisi birden geri alınıyordu. Şimdi rezervasyon Inventory'nin veritabanında, sipariş Ordering'inkinde. Aralarında transaction yok; Microservices serisinin "Microservice'lerde Transaction Yok" yazısının konusu tam olarak bu.
Rezervasyonun kalbi StockReservations.ReserveAsync:
public async Task<StockReservation> ReserveAsync(Guid orderId, IReadOnlyList<StockRequest> lines, CancellationToken ct)
{
Dictionary<string, int> wanted = lines.GroupBy(l => l.Isbn).ToDictionary(g => g.Key, g => g.Sum(l => l.Quantity));
for (int attempt = 1; ; attempt++)
{
// Idempotent: aynı sipariş için tekrarlanan çağrı, zaten sahip olduğu rezervasyonu alır
StockReservation? existing = await db.Reservations.AsNoTracking().FirstOrDefaultAsync(r => r.OrderId == orderId, ct);
if (existing is not null)
{
return existing;
}
try
{
return await TryReserveAsync(orderId, wanted, ct);
}
catch (DbUpdateConcurrencyException) when (attempt < MaxAttempts)
{
// Biz okuduktan sonra başka bir rezervasyon bu satırlardan birini değiştirdi: yeniden oku ve dene
logger.LogWarning("Concurrency conflict for order {OrderId} on attempt {Attempt}, retrying", orderId, attempt);
db.ChangeTracker.Clear();
// Yarışan istekler aynı anda tekrar çakışmasın diye kısa ve rastgele bir bekleme
await Task.Delay(TimeSpan.FromMilliseconds(Random.Shared.Next(10, 50) * attempt), ct);
}
catch (DbUpdateConcurrencyException)
{
throw new RpcException(new Status(StatusCode.Aborted, "Stock is changing too fast; retry the reservation."));
}
catch (DbUpdateException ex) when (ex.InnerException is SqlException { Number: 2601 or 2627 })
{
// Aynı sipariş paralel bir çağrıyla rezerve edildi; döngünün bir sonraki turu o rezervasyonu döner
db.ChangeTracker.Clear();
}
}
}
Idempotency bu tasarımın ilk şartı. Ordering'in gRPC çağrısı bir ağ kesintisinde tekrar edilebilir: ServiceDefaults'taki standard resilience handler bir bağlantı hatasında isteği yeniden gönderir, ya da kullanıcı sayfayı yenileyip aynı siparişi tekrar onaylar. Rezervasyon tablosunda OrderId üzerinde unique index var. Aynı sipariş için gelen ikinci çağrı ya döngünün başında var olan rezervasyonu buluyor ya da paralel geldiyse unique index ihlaline (SQL Server hata kodu 2601/2627) çarpıp bir sonraki turda onu buluyor. Repo'daki tools/inventory-probe.cs dosyası, .NET 10'un dosya tabanlı uygulamalarından biri; Inventory'yi elle denemek için küçük bir gRPC istemcisi. Aynı order id ile iki kez çağırdık:
dotnet run tools/inventory-probe.cs -- http://localhost:60906 reserve c05805c1-7a9c-4df5-86d7-08ef3de0e32e 9780141439549 2
reservation=01a1da49-69db-754a-9f2c-644a4fc3fb4f expiresAt=2026-11-14T13:55:06.7479460Z
dotnet run tools/inventory-probe.cs -- http://localhost:60906 reserve c05805c1-7a9c-4df5-86d7-08ef3de0e32e 9780141439549 2
reservation=01a1da49-69db-754a-9f2c-644a4fc3fb4f expiresAt=2026-11-14T13:55:06.7479460Z
dotnet run tools/inventory-probe.cs -- http://localhost:60906 stock 9780141439549
9780141439549: available=3 reserved=22 status=Low
dotnet run tools/inventory-probe.cs -- http://localhost:60906 release c05805c1-7a9c-4df5-86d7-08ef3de0e32e
releasedLines=1
dotnet run tools/inventory-probe.cs -- http://localhost:60906 release c05805c1-7a9c-4df5-86d7-08ef3de0e32e
releasedLines=0
İki çağrı aynı rezervasyon id'sini aldı ve stoktan yalnız bir kez 2 adet düştü. Serbest bırakma da idempotent: ikinci release hiçbir satırı değiştirmedi. 07'de saga'nın compensation adımı bu RPC'yi (Remote Procedure Call; uzak bir service'teki method'u çağırma) çağıracak ve aynı mesajın iki kez işlenmesi stoğu iki kez geri vermeyecek. Stoğu olmayan bir kitap ise gRPC'nin FailedPrecondition durumuyla dönüyor, hangi kitapların eksik olduğu da unavailable-books trailer'ında:
dotnet run tools/inventory-probe.cs -- http://localhost:60906 reserve <new-guid> 9780060934347 1
FailedPrecondition: Not enough stock for: 9780060934347.
Aynı Kitaba Aynı Anda: rowversion ve Optimistic Concurrency
Stok satırının RowVersion kolonu SQL Server'ın rowversion tipi: satır her güncellendiğinde veritabanı onu kendisi değiştiriyor. Entity Framework Core (EF Core) onu IsRowVersion() ile concurrency token yapıyor ve her UPDATE'in WHERE'ine ekliyor. İki rezervasyon aynı satırı okuyup ikisi birden yazmaya çalışırsa ikincisinin UPDATE'i 0 satır günceller ve DbUpdateConcurrencyException fırlar. Mekanizmanın ayrıntısı Entity Framework Core serisinin Concurrency yazısında; burada sonuçlarına bakıyoruz.
Önce son kitap. Seed verisinde The Left Hand of Darkness'tan tek bir adet bıraktık ve beş müşteri aynı anda Ordering'e sipariş gönderdi:
Ada -> 201
Alan -> 409
Grace -> 409
Edsger -> 409
Linus -> 409
Bir sipariş oluştu, dördü "Out of stock" aldı. Son kitap iki kişiye satılmadı. Sonra Önce Tahmin Et'teki senaryo: Middlemarch'tan 25 adet varken yirmi paralel sipariş. İlk denemede sonuç şaşırtıcıydı:
19 201
1 500
[inventory] Concurrency conflict for order 01a1da48-24b5-79eb-a520-38b9c2ab4493 on attempt 2, retrying
[inventory] Error status code 'Aborted' with detail 'Stock is changing too fast; retry the reservation.' raised.
[ordering] Grpc.Core.RpcException: Status(StatusCode="Aborted", Detail="Stock is changing too fast; retry the reservation.")
On dört çakışma oldu ve bir istek üç denemenin üçünde de çakıştı. Inventory doğru davranıp Aborted döndü; Ordering ise bu durumu tanımadığı için müşteriye 500 verdi. İki düzeltme yaptık. Inventory'de denemeler arasına kısa ve rastgele bir bekleme koyduk ve deneme sayısını beşe çıkardık; aynı anda çakışan istekler bir sonraki turda yine aynı anda çakışmasın. Ordering'de Aborted'ı da "şimdilik olmadı, tekrar dene" sınıfına aldık. Aynı deneme, düzeltmeden sonra:
20 201
14 attempt 1
2 attempt 2
1 attempt 3
İlk satır HTTP durum kodlarının, alttakiler Inventory log'undaki çakışmaların deneme numarasına göre dağılımı. Yirmi siparişin yirmisi de oluştu ve stok 25'ten 5'e indi (available=5 reserved=20). Toplam 17 çakışma oldu: on dört istek ilk denemede çakışıp yeniden okuyarak devam etti, birkaçı ikinci ve üçüncü denemeye kaldı. Önce Tahmin Et'in cevabı bu: optimistic concurrency'de çakışma bir hata değil, normal akışın parçası; önemli olan çakışmanın her zaman doğru sonuçla bitmesi.
Monolith'teki çözüm farklıydı: ExecuteUpdate ile tek bir koşullu UPDATE ... SET QuantityReserved = QuantityReserved + @n WHERE QuantityOnHand - QuantityReserved >= @n. O yaklaşım satırı okumadan atomik çalışıyor ve çakışma yaşamıyor. Burada rowversion'ı seçtik, çünkü rezervasyon artık birden fazla satırı (stok satırları ve rezervasyon kaydı) aynı transaction'da değiştiriyor ve kuralların C# tarafında okunup kontrol edilmesi işimize geliyor. Çok sıcak birkaç kitap için (kampanyada herkesin aldığı kitap gibi) koşullu UPDATE daha az çakışma ve daha az tur demek; ölçerek karar vermek gereken bir yer.
Ordering'den gRPC Çağrısı: AddGrpcClient, Service Discovery ve Cleartext HTTP/2
Ordering, Inventory'nin client'ını IHttpClientFactory üzerinden alıyor:
// IHttpClientFactory üzerinden gRPC client: service discovery ve standard resilience handler ona da uygulanır.
// "_grpc." Inventory'nin grpc adlı endpoint'ini seçer. gRPC channel yalnız http/https adres kabul ettiği için
// "https+http" yedekli yazımı burada kullanılamıyor.
builder.Services.AddGrpcClient<InventoryService.InventoryServiceClient>(options =>
options.Address = new Uri("http://_grpc.inventory"));
Bu iki satıra gelene kadar iki hata aldık ve ikisi de anlatmaya değer. İlk denemede adres 04'teki gibi https+http://inventory'ydi; Ordering'in log'unda System.InvalidOperationException: No address resolver configured for the scheme 'https+http'. çıktı. gRPC channel adresi kendisi çözmeye çalışıyor ve bu yazımı tanımıyor; çözüm düz http:// (production'da TLS (Transport Layer Security; trafiği şifreleyen protokol) ile https://).
İkinci hata daha öğreticiydi. Inventory'nin tek bir http endpoint'i vardı ve her sipariş 2 saniye sonra DeadlineExceeded ile bitiyordu. Ordering'in log'u sebebi gösteriyordu:
[ordering] System.Net.Http.HttpRequestException: The HTTP/2 server closed the connection. HTTP/2 error code 'HTTP_1_1_REQUIRED' (0xd). (HttpProtocolError)
[ordering] warn: Polly[0]
[ordering] Resilience event occurred. EventName: 'OnRetry', Source: '-standard//Standard-Retry', Operation Key: '', Result: 'The HTTP/2 server closed the
connection. HTTP/2 error code 'HTTP_1_1_REQUIRED' (0xd). (HttpProtocolError)'
[ordering] warn: Grpc.Net.Client.Internal.GrpcCall[7]
[ordering] gRPC call deadline exceeded.
gRPC HTTP/2 ister. TLS varken istemci ve sunucu ALPN (Application-Layer Protocol Negotiation; TLS handshake'inde protokolün seçildiği eklenti) ile HTTP/2'de anlaşır. TLS yokken bu pazarlık yok: Kestrel'in varsayılan Http1AndHttp2 ayarı şifresiz bir port'ta pratikte HTTP/1.1 konuşuyor ve HTTP/2 ile gelen istemciyi geri çeviriyor. Log'da bir şey daha var: standard resilience handler bu hatayı geçici bir ağ hatası sanıp tekrar denedi, her deneme aynı hatayı aldı ve sonunda 2 saniyelik deadline doldu. Kalıcı bir yapılandırma hatası, retry yüzünden "yavaş Inventory" gibi görünüyordu.
Çözüm, Inventory'ye iki endpoint vermek: health check'ler için HTTP/1.1 konuşan http ve yalnız HTTP/2 konuşan grpc. Kestrel'e endpoint'leri ve protokollerini AppHost veriyor:
// İki endpoint: launch profile'dan gelen "http" (health check'ler) ve "grpc". TLS'siz gRPC yalnız HTTP/2
// konuşan bir port ister; bu yüzden Kestrel iki endpoint'i de protokolleriyle birlikte AppHost'tan alıyor.
IResourceBuilder<ProjectResource> inventory = builder.AddProject<Projects.Bookstore_Inventory_Grpc>("inventory")
.WithHttpEndpoint(name: "grpc")
.WithEndpoint("grpc", endpoint => endpoint.Transport = "http2")
.WithReference(inventoryDb)
.WaitForCompletion(inventoryMigrator)
.WithHttpHealthCheck("/health");
EndpointReference inventoryHttp = inventory.GetEndpoint("http");
EndpointReference inventoryGrpc = inventory.GetEndpoint("grpc");
inventory
.WithEnvironment("Kestrel__Endpoints__Http__Url",
ReferenceExpression.Create($"http://localhost:{inventoryHttp.Property(EndpointProperty.TargetPort)}"))
.WithEnvironment("Kestrel__Endpoints__Grpc__Url",
ReferenceExpression.Create($"http://localhost:{inventoryGrpc.Property(EndpointProperty.TargetPort)}"))
.WithEnvironment("Kestrel__Endpoints__Grpc__Protocols", "Http2");
TargetPort, Aspire'ın proxy'sinin arkasında service'in gerçekten dinlediği port. Kestrel endpoint'leri configuration'dan gelince Aspire'ın verdiği ASPNETCORE_URLS'i kullanmıyor ve bunu log'a yazıyor: Overriding address(es) 'http://localhost:21781, http://localhost:21780'. Binding to endpoints defined via IConfiguration and/or UseKestrel(). Bu uyarı burada beklenen davranış. Ordering ise http://_grpc.inventory ile, service discovery'nin "inventory service'inin grpc adlı endpoint'i" yazımıyla doğru port'u buluyor. Aynı ayrımı Kubernetes'te de yapacağız; orada TLS'i ya service mesh ya da ingress üstlenecek, 14. yazının konusu.
Ordering ile Inventory arasında bir başlama sırası yok: ordering resource'u WithReference(inventory) alıyor ama WaitFor(inventory) almıyor. Biri yeniden başlarken diğerinin açılmayı reddetmesi, production'da da istemediğimiz bir bağ.
Deadline ve Inventory Kapalıyken Checkout
Ordering'in sipariş handler'ı rezervasyonu siparişi kaydetmeden önce yapıyor ve çağrıya kendi deadline'ını veriyor:
// Kendi deadline'ı olan sync çağrı: Inventory'ye en fazla bu kadar süre veriyoruz, sonra vazgeçiyoruz
TimeSpan deadline = configuration.GetValue("Ordering:ReservationDeadline", TimeSpan.FromSeconds(2));
try
{
ReserveStockResponse reserved = await inventory.ReserveStockAsync(
new ReserveStockRequest
{
OrderId = order.Id.ToString(),
Lines = { order.Items.Select(i => new ReservationLine { BookId = i.Isbn, Quantity = i.Quantity }) }
},
deadline: clock.GetUtcNow().UtcDateTime + deadline,
cancellationToken: ct);
order.MarkStockReserved(Guid.Parse(reserved.ReservationId), reserved.ExpiresAt.ToDateTime());
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.FailedPrecondition)
{
string unavailable = ex.Trailers.GetValue("unavailable-books") ?? "";
return new PlaceOrderOutcome.OutOfStock(unavailable.Split(',', StringSplitOptions.RemoveEmptyEntries));
}
catch (RpcException ex) when (ex.StatusCode is StatusCode.Unavailable or StatusCode.DeadlineExceeded or StatusCode.Aborted)
{
// Bilinmeyen sonuç: rezervasyon Inventory tarafında oluşmuş olabilir. TTL onu serbest bırakır
logger.LogWarning("Stock reservation for order {OrderId} failed with {Status}", order.Id, ex.StatusCode);
return new PlaceOrderOutcome.InventoryUnavailable(ex.StatusCode);
}
db.Orders.Add(order);
Rezervasyon başarılıysa sipariş StockReserved durumunda kaydediliyor ve cevapta rezervasyonun bitiş zamanı da var:
{"id":"01a1da47-d0e5-7251-a185-f2d17b1c70f5","status":"StockReserved","placedAtUtc":"2026-11-14T13:38:22.053555","customerName":"Grace Hopper","customerEmail":"grace@example.com","items":[{"isbn":"9780141439587","title":"Emma","unitPrice":249.90,"quantity":2,"lineTotal":499.80}],"total":499.80,"currency":"TRY","stockReservedUntilUtc":"2026-11-14T13:53:22.336546"}
Önce Tahmin Et'teki ikinci soru için Inventory'ye geliştirme amaçlı bir interceptor ekledik: Inventory:SimulatedLatency ayarı verildiğinde her çağrıyı o kadar bekletiyor ve beklemeyi context.CancellationToken ile yapıyor. Gecikmeyi 3 saniyeye ayarladık:
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.4","title":"Checkout is temporarily unavailable","status":503,"detail":"Stock could not be reserved. No order was created; please try again in a moment.","grpcStatus":"DeadlineExceeded","traceId":"00-1e6107605be897b7b15bd8ad9a70447e-981eeca942b13a36-03"}
HTTP 503 in 2.292299s
[inventory] Simulating 00:00:03 latency; caller deadline is 2026-11-14T13:41:16.9236250Z
[inventory] Service method 'ReserveStock' canceled.
[inventory] System.Threading.Tasks.TaskCanceledException: A task was canceled.
[ordering] gRPC call deadline exceeded.
[ordering] Execution attempt. Source: '-standard//Standard-Retry', Operation Key: '', Result: 'The operation was canceled.', Handled: 'False', Attempt:
Deadline, gRPC'de yalnız istemcinin beklediği süre değil; grpc-timeout header'ı ile sunucuya da gidiyor. Inventory'nin log'undaki "caller deadline" satırı bunu gösteriyor. Süre dolunca sunucudaki context.CancellationToken tetiklendi ve ReserveStock iptal edildi; rezervasyon hiç yapılmadı. Önce Tahmin Et'in cevabı: Inventory işine devam etmiyor, çünkü handler'dan veritabanına kadar her await aynı token'ı taşıyor. Token'ı EF Core sorgusuna vermeyi unutsaydık, Ordering vazgeçtikten sonra da rezervasyon yapılırdı. Standard resilience handler'ın satırı da önemli: Handled: 'False', yani deadline'dan kaynaklanan iptali tekrar denemedi. Ordering'in cevabı 2,3 saniyede 503 oldu ve sipariş kaydedilmedi.
Inventory tamamen kapalıyken sonuç benzer. aspire resource inventory stop ile durdurup sipariş gönderdik: HTTP 503 in 2.616873s, yine DeadlineExceeded. Lokalde Aspire'ın proxy'si bağlantıyı kabul edip cevapsız bıraktığı için hata deadline olarak görünüyor; kapalı bir port'a giden bağlantıda ise istemci bir HttpRequestException alır ve yukarıda gördüğümüz gibi standard retry onu tekrar dener.
Burada ürünün sorması gereken soru şu: Inventory çalışmazken checkout ne yapmalı? İki seçenek var. Bizim seçtiğimiz: siparişi oluşturmamak, 503 ile "birazdan tekrar dene" demek. Basit ve dürüst, ama Inventory'nin her arızası doğrudan kaybedilmiş satış demek. Diğeri: siparişi Pending olarak kabul edip rezervasyonu sonra, bir mesajla yapmak. Müşteri "siparişin alındı, onay bekleniyor" ekranı görür; Inventory düzelince rezervasyon yapılır ya da stok yoksa sipariş iptal edilir. Microservices serisinin İletişim Stilleri yazısında Bookstore için hedef olarak ikincisini seçmiştik: checkout'un tek senkron bağımlılığı, süresi sınırlanmış ödeme olmalı. 06'da event'leri, 07'de checkout saga'sını kurduğumuzda rezervasyon bu yazıdaki senkron çağrıdan saga'nın mesajla ilerleyen bir adımına dönüşecek. gRPC ise kitap sayfasındaki anlık stok sorgusu için kalacak.
Rezervasyonun Süresi: TTL ve Serbest Bırakma
Yukarıdaki catch bloğundaki yorum önemli: DeadlineExceeded "Inventory rezervasyonu yapmadı" demek değil, "cevabı zamanında alamadık" demek. Inventory rezervasyonu yapıp cevabı göndermiş olabilir; ya da Ordering rezervasyonu aldıktan sonra siparişi kaydederken çökebilir. İki durumda da Inventory'de sahipsiz bir rezervasyon kalır. Monolith'te tek transaction bunu geri alıyordu; burada güvenlik ağı rezervasyonun süresi (TTL, Time To Live; bir kaydın geçerli kalacağı süre). Her rezervasyon 15 dakika geçerli ve süresi dolan rezervasyonları bir background service serbest bırakıyor:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using PeriodicTimer timer = new(options.Value.SweepInterval, clock);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
await ExpireDueReservationsAsync(stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Expiring reservations failed; trying again on the next tick");
}
}
}
Davranışı görmek için TTL'i 20 saniyeye, tarama aralığını 5 saniyeye indirdik ve bir rezervasyon yaptık:
13:41:27
reservation=01a1da4a-b42f-7a0f-9360-f22afccc4575 expiresAt=2026-11-14T13:41:51.3111920Z
9780141439471: available=23 reserved=2 status=InStock
13:41:55
[inventory] Reserved 1 line(s) for order b26fb95b-065b-4085-a414-1f31e4f4b687 until 2026-11-14T13:41:51.3111920Z
[inventory] Reservation 01a1da4a-b42f-7a0f-9360-f22afccc4575 for order b26fb95b-065b-4085-a414-1f31e4f4b687 Expired
9780141439471: available=25 reserved=0 status=InStock
Rezervasyon 13:41:51'de doldu, bir sonraki taramada serbest bırakıldı ve stok 25'e döndü. 15 dakika bir business kararı: ödeme adımının en kötü durumda sürebileceği zamandan uzun, kampanya gününde stoğu gereksiz yere kilitlemeyecek kadar kısa olmalı. 07'deki saga, sipariş onaylandığında rezervasyonu kalıcı hâle getirecek ve iptalde ReleaseStock'u çağıracak; TTL o adımlar başarısız olduğunda devreye giren son çare olarak kalacak.
Üretimde Dikkat
Kubernetes'te gRPC load balancing'i ayrıca düşün. gRPC tek bir uzun ömürlü HTTP/2 bağlantısı üzerinde çalışır; Kubernetes'in ClusterIP service'i bağlantı seviyesinde dağıttığı için Ordering'in bütün çağrıları Inventory'nin tek bir pod'una gidebilir. Çözümler (headless service ile client-side load balancing, ya da HTTP seviyesinde, yani L7 (OSI modelinin 7. katmanı; uygulama katmanı) çalışan bir proxy) gRPC ve GraphQL serisinin Production'da gRPC yazısında.
Çakışma oranını ölç. "Concurrency conflict" log'u bir metric'e dönüşmeli. Kampanya günü bir kitapta deneme sayısının sınırına sık ulaşılıyorsa, o kitap için koşullu UPDATE'e geçmenin ya da rezervasyonu kuyruğa almanın zamanı gelmiş demektir.
Deadline'ı bütçeden türet. Ordering'in 2 saniyesi keyfi değil: checkout için Mimariye Giriş serisinde koyduğumuz p95 < 800 ms hedefinin çok üstünde bir üst sınır, "Inventory gerçekten cevap vermiyor" demek için yeterince uzun. BFF (Backend for Frontend; Angular'a özel ara backend) 09'da kendi timeout'unu koyduğunda, alt çağrıların deadline'ları onun içine sığmalı.
Sahipsiz rezervasyonları say. TTL ile serbest bırakılan rezervasyon sayısı, sipariş tarafında bir şeylerin ters gittiğinin en erken göstergesi. Sıfıra yakın olmalı; artıyorsa ya Ordering çöküyor ya da deadline'lar çok kısa.
Sık Düşülen Tuzaklar
Cleartext gRPC'yi varsayılan Kestrel ayarlarıyla çalıştırmaya çalışmak. Yukarıdaki HTTP_1_1_REQUIRED hatası, retry ve deadline'ın arkasına saklandığında saatlerce "yavaşlık" olarak aranabilir. TLS yoksa gRPC port'u Http2 olmalı.
Idempotent olmayan bir RPC'yi retry'lı bir pipeline'a koymak. ServiceDefaults her client'a retry ekliyor. ReserveStock sipariş id'siyle idempotent olmasaydı, bir bağlantı hatasından sonraki tekrar stoğu iki kez düşürürdü.
Deadline vermemek. Deadline'sız bir gRPC çağrısı varsayılan olarak sonsuza kadar bekleyebilir; resilience pipeline'ındaki timeout bir sınır koysa da sunucu tarafı bunu bilmez ve çalışmaya devam eder. Deadline hem istemcide hem sunucuda işi durduruyor.
Sunucuda CancellationToken'ı veritabanına kadar taşımamak. Deadline'ın sunucuya gitmesi, sunucu onu kullanmıyorsa işe yaramaz.
DeadlineExceeded'ı "olmadı" diye yorumlamak. Sonuç bilinmiyor; telafisi TTL, idempotent tekrar ya da durum sorgusu. Rezervasyonun yapılmadığını varsayıp aynı siparişi farklı bir id ile tekrar göndermek, stoğu iki kez kilitler.
Küçük Bir Deneme
★☆☆ tools/inventory-probe.cs ile War and Peace'ten (3 adet) 2 adet rezerve et, sonra aynı kitaptan farklı bir sipariş id'siyle 2 adet daha iste. Hangi gRPC durumu dönüyor? İlk rezervasyonu serbest bırakıp ikinci isteği tekrarla.
★★☆ StockReservations'ı monolith'teki gibi koşullu ExecuteUpdateAsync ile yeniden yaz ve Middlemarch deneyini 50 paralel istekle iki sürümde karşılaştır: çakışma sayısı ve toplam süre ne oluyor? Rezervasyon kaydı ile stok güncellemesini aynı transaction'da tutmak için ne yapman gerekti?
★★★ Ordering'in deadline'ını gelen HTTP isteğinin kalan süresinden türet: POST /api/v1/orders'a ASP.NET Core'un request timeout'uyla 3 saniyelik bir sınır koy, gRPC deadline'ını bu sınırdan kalan sürenin yarısı olarak hesapla. Inventory'ye 1, 2 ve 4 saniyelik gecikme vererek davranışı ölç.
Inventory artık kendi verisinin sahibi ve aynı son kitabı iki kişiye satmıyor; Ordering onu deadline'lı bir gRPC çağrısıyla kullanıyor. Ama bu yazının en önemli bulgusu bir bedel: Inventory kapalıyken sipariş de alınamıyor ve sipariş ile rezervasyon arasında bir transaction yok. Bir sonraki yazıda bu boşluğu kapatmaya başlıyoruz: Wolverine ve RabbitMQ ile integration event'ler, Catalog ve Ordering'de transactional outbox, Ordering'in kitap kopyasını Catalog'un event'leriyle güncellemek ve mesaj sırası bozulduğunda versiyon alanıyla eski event'i yok saymak.