From 9685292419c266c60d3350d567d6ae034a45b1d9 Mon Sep 17 00:00:00 2001 From: Dima <72738687+Dima-X@users.noreply.github.com> Date: Mon, 30 Sep 2024 13:34:44 +0900 Subject: [PATCH 1/2] [ScalarDB .NET Client] Add microservice transactions sample with Shared-cluster pattern with LINQ --- .gitignore | 406 +++++++++++++++++- .../.dockerignore | 3 + .../Client/Client.csproj | 23 + .../Binders/CustomerServiceClientBinder.cs | 22 + .../Binders/OrderServiceClientBinder.cs | 22 + .../Client/Commands/GetCustomerInfoCommand.cs | 37 ++ .../Client/Commands/GetOrderCommand.cs | 37 ++ .../Client/Commands/GetOrdersCommand.cs | 37 ++ .../Client/Commands/PlaceOrderCommand.cs | 67 +++ .../Client/Commands/RepaymentCommand.cs | 42 ++ .../Client/Logging.cs | 13 + .../Client/Program.cs | 21 + .../Common/Common.csproj | 14 + .../Common/CustomerService/Customer.cs | 21 + .../Common/FailedPreconditionException.cs | 10 + .../Common/InternalException.cs | 10 + .../Common/NotFoundException.cs | 10 + .../Common/OrderService/Item.cs | 20 + .../Common/OrderService/Order.cs | 20 + .../Common/OrderService/Statement.cs | 19 + .../CustomerService/CustomerDbContext.cs | 11 + .../CustomerService/CustomerService.cs | 239 +++++++++++ .../CustomerService/CustomerService.csproj | 23 + .../CustomerService/Program.cs | 33 ++ .../Properties/launchSettings.json | 20 + .../CustomerService/appsettings.json | 21 + .../DataLoader/DataLoader.cs | 94 ++++ .../DataLoader/DataLoader.csproj | 27 ++ .../DataLoader/Program.cs | 50 +++ .../DataLoader/SchemaCreator.cs | 52 +++ .../DataLoader/UsersCreator.cs | 67 +++ .../DataLoader/scalardb-options.json | 8 + .../Dockerfile-CustomerService | 20 + .../Dockerfile-OrderService | 20 + .../MicroserviceTransactionsSample-LINQ.sln | 65 +++ .../OrderService/OrderDbContext.cs | 13 + .../OrderService/OrderService.cs | 291 +++++++++++++ .../OrderService/OrderService.csproj | 23 + .../OrderService/Program.cs | 40 ++ .../Properties/launchSettings.json | 20 + .../OrderService/appsettings.json | 22 + .../Rpc/Protos/sample.proto | 178 ++++++++ .../Rpc/Rpc.csproj | 24 ++ .../docker-compose.yml | 69 +++ .../scalardb-cluster-node.properties | 30 ++ 45 files changed, 2311 insertions(+), 3 deletions(-) create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/.dockerignore create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Client.csproj create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/Binders/CustomerServiceClientBinder.cs create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/Binders/OrderServiceClientBinder.cs create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/GetCustomerInfoCommand.cs create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/GetOrderCommand.cs create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/GetOrdersCommand.cs create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/PlaceOrderCommand.cs create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/RepaymentCommand.cs create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Logging.cs create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Program.cs create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/Common.csproj create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/CustomerService/Customer.cs create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/FailedPreconditionException.cs create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/InternalException.cs create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/NotFoundException.cs create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/OrderService/Item.cs create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/OrderService/Order.cs create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/OrderService/Statement.cs create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/CustomerDbContext.cs create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/CustomerService.cs create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/CustomerService.csproj create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/Program.cs create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/Properties/launchSettings.json create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/appsettings.json create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/DataLoader.cs create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/DataLoader.csproj create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/Program.cs create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/SchemaCreator.cs create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/UsersCreator.cs create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/scalardb-options.json create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Dockerfile-CustomerService create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Dockerfile-OrderService create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/MicroserviceTransactionsSample-LINQ.sln create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/OrderService/OrderDbContext.cs create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/OrderService/OrderService.cs create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/OrderService/OrderService.csproj create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/OrderService/Program.cs create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/OrderService/Properties/launchSettings.json create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/OrderService/appsettings.json create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Rpc/Protos/sample.proto create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Rpc/Rpc.csproj create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/docker-compose.yml create mode 100644 dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/scalardb-cluster-node.properties diff --git a/.gitignore b/.gitignore index d15f8e67..0c35d68b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -# Created by https://www.toptal.com/developers/gitignore/api/java,gradle,intellij -# Edit at https://www.toptal.com/developers/gitignore?templates=java,gradle,intellij +# Created by https://www.toptal.com/developers/gitignore/api/java,gradle,intellij,visualstudio +# Edit at https://www.toptal.com/developers/gitignore?templates=java,gradle,intellij,visualstudio ### Intellij ### # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider @@ -132,4 +132,404 @@ gradle-app.setting ### Gradle Patch ### **/build/ -# End of https://www.toptal.com/developers/gitignore/api/java,gradle,intellij +### VisualStudio ### +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml + +### VisualStudio Patch ### +# Additional files built by Visual Studio + +# End of https://www.toptal.com/developers/gitignore/api/java,gradle,intellij,visualstudio diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/.dockerignore b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/.dockerignore new file mode 100644 index 00000000..1a89e0f7 --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/.dockerignore @@ -0,0 +1,3 @@ +**/bin/ +**/obj/ +**/.DS_Store diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Client.csproj b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Client.csproj new file mode 100644 index 00000000..3eb14fac --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Client.csproj @@ -0,0 +1,23 @@ + + + + Exe + net8.0 + enable + enable + 12 + MicroserviceTransactionsSample.Client + + + + + + + + + + + + + + diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/Binders/CustomerServiceClientBinder.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/Binders/CustomerServiceClientBinder.cs new file mode 100644 index 00000000..628d283a --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/Binders/CustomerServiceClientBinder.cs @@ -0,0 +1,22 @@ +using System.CommandLine.Binding; +using Grpc.Net.Client; +using static MicroserviceTransactionsSample.Rpc.CustomerService; + +namespace MicroserviceTransactionsSample.Client.Commands.Binders; + +public class CustomerServiceClientBinder : BinderBase +{ + private const string CustomerServiceUrl = "http://localhost:10010"; + + protected override CustomerServiceClient GetBoundValue(BindingContext bindingContext) + { + var customerServiceUrl = Environment.GetEnvironmentVariable("CUSTOMER_SERVICE_URL"); + if (String.IsNullOrEmpty(customerServiceUrl)) + customerServiceUrl = CustomerServiceUrl; + + var grpcOptions = new GrpcChannelOptions { LoggerFactory = Logging.GetLoggerFactory() }; + var grpcChannel = GrpcChannel.ForAddress(customerServiceUrl, grpcOptions); + + return new CustomerServiceClient(grpcChannel); + } +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/Binders/OrderServiceClientBinder.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/Binders/OrderServiceClientBinder.cs new file mode 100644 index 00000000..d433c287 --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/Binders/OrderServiceClientBinder.cs @@ -0,0 +1,22 @@ +using System.CommandLine.Binding; +using Grpc.Net.Client; +using static MicroserviceTransactionsSample.Rpc.OrderService; + +namespace MicroserviceTransactionsSample.Client.Commands.Binders; + +public class OrderServiceClientBinder : BinderBase +{ + private const string OrderServiceUrl = "http://localhost:10020"; + + protected override OrderServiceClient GetBoundValue(BindingContext bindingContext) + { + var orderServiceUrl = Environment.GetEnvironmentVariable("ORDER_SERVICE_URL"); + if (String.IsNullOrEmpty(orderServiceUrl)) + orderServiceUrl = OrderServiceUrl; + + var grpcOptions = new GrpcChannelOptions { LoggerFactory = Logging.GetLoggerFactory() }; + var grpcChannel = GrpcChannel.ForAddress(orderServiceUrl, grpcOptions); + + return new OrderServiceClient(grpcChannel); + } +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/GetCustomerInfoCommand.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/GetCustomerInfoCommand.cs new file mode 100644 index 00000000..21035615 --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/GetCustomerInfoCommand.cs @@ -0,0 +1,37 @@ +using System.CommandLine; +using MicroserviceTransactionsSample.Client.Commands.Binders; +using MicroserviceTransactionsSample.Rpc; +using static MicroserviceTransactionsSample.Rpc.CustomerService; + +namespace MicroserviceTransactionsSample.Client.Commands; + +public static class GetCustomerInfoCommand +{ + private const string Name = "GetCustomerInfo"; + private const string Description = "Get customer information"; + + private const string ArgName = "id"; + private const string ArgDescription = "customer ID"; + + public static Command Create() + { + var customerIdArg = new Argument(ArgName, ArgDescription); + var getCustomerInfoCommand = new Command(Name, Description) + { + customerIdArg + }; + + getCustomerInfoCommand.SetHandler(getCustomerInfo, + customerIdArg, + new CustomerServiceClientBinder()); + return getCustomerInfoCommand; + } + + private static async Task getCustomerInfo(int customerId, CustomerServiceClient client) + { + var request = new GetCustomerInfoRequest { CustomerId = customerId }; + var response = await client.GetCustomerInfoAsync(request); + + Console.WriteLine(response); + } +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/GetOrderCommand.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/GetOrderCommand.cs new file mode 100644 index 00000000..1a1974d4 --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/GetOrderCommand.cs @@ -0,0 +1,37 @@ +using System.CommandLine; +using MicroserviceTransactionsSample.Client.Commands.Binders; +using MicroserviceTransactionsSample.Rpc; +using static MicroserviceTransactionsSample.Rpc.OrderService; + +namespace MicroserviceTransactionsSample.Client.Commands; + +public static class GetOrderCommand +{ + private const string Name = "GetOrder"; + private const string Description = "Get order information by order ID"; + + private const string ArgName = "id"; + private const string ArgDescription = "order ID"; + + public static Command Create() + { + var orderIdArg = new Argument(ArgName, ArgDescription); + var getOrderCommand = new Command(Name, Description) + { + orderIdArg + }; + + getOrderCommand.SetHandler(getOrder, + orderIdArg, + new OrderServiceClientBinder()); + return getOrderCommand; + } + + private static async Task getOrder(string orderId, OrderServiceClient client) + { + var request = new GetOrderRequest { OrderId = orderId }; + var response = await client.GetOrderAsync(request); + + Console.WriteLine(response); + } +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/GetOrdersCommand.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/GetOrdersCommand.cs new file mode 100644 index 00000000..63254583 --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/GetOrdersCommand.cs @@ -0,0 +1,37 @@ +using System.CommandLine; +using MicroserviceTransactionsSample.Client.Commands.Binders; +using MicroserviceTransactionsSample.Rpc; +using static MicroserviceTransactionsSample.Rpc.OrderService; + +namespace MicroserviceTransactionsSample.Client.Commands; + +public static class GetOrdersCommand +{ + private const string Name = "GetOrders"; + private const string Description = "Get orders information by customer ID"; + + private const string ArgName = "customer_id"; + private const string ArgDescription = "customer ID"; + + public static Command Create() + { + var customerIdArg = new Argument(ArgName, ArgDescription); + var getOrdersCommand = new Command(Name, Description) + { + customerIdArg + }; + + getOrdersCommand.SetHandler(getOrders, + customerIdArg, + new OrderServiceClientBinder()); + return getOrdersCommand; + } + + private static async Task getOrders(int customerId, OrderServiceClient client) + { + var request = new GetOrdersRequest { CustomerId = customerId }; + var response = await client.GetOrdersAsync(request); + + Console.WriteLine(response); + } +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/PlaceOrderCommand.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/PlaceOrderCommand.cs new file mode 100644 index 00000000..e78bf890 --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/PlaceOrderCommand.cs @@ -0,0 +1,67 @@ +using System.CommandLine; +using MicroserviceTransactionsSample.Client.Commands.Binders; +using MicroserviceTransactionsSample.Rpc; +using Microsoft.Extensions.DependencyInjection; +using static MicroserviceTransactionsSample.Rpc.OrderService; + +namespace MicroserviceTransactionsSample.Client.Commands; + +public static class PlaceOrderCommand +{ + private const string Name = "PlaceOrder"; + private const string Description = "Place an order"; + + private const string ArgName1 = "customer_id"; + private const string ArgDescription1 = "customer ID"; + private const string ArgName2 = "orders"; + private const string ArgDescription2 = "orders. The format is \":,:,...\""; + + public static Command Create() + { + var customerIdArg = new Argument(ArgName1, ArgDescription1); + var ordersArg = new Argument>( + name: ArgName2, + parse: arg => + { + var argStr = arg.Tokens.First().Value; + var orders = argStr + .Split(',') + .Select(s => s.Split(':')) + .ToDictionary( + s => Int32.Parse(s[0]), + s => Int32.Parse(s[1]) + ); + + return orders; + }, + description: ArgDescription2) + { + Arity = ArgumentArity.ExactlyOne + }; + + var placeOrderCommand = new Command(Name, Description) + { + customerIdArg, + ordersArg + }; + + placeOrderCommand.SetHandler(placeOrder, + customerIdArg, + ordersArg, + new OrderServiceClientBinder()); + return placeOrderCommand; + } + + private static async Task placeOrder(int customerId, + Dictionary orders, + OrderServiceClient client) + { + var request = new PlaceOrderRequest { CustomerId = customerId }; + foreach (var order in orders) + request.ItemOrder.Add(new ItemOrder { ItemId = order.Key, Count = order.Value }); + + var response = await client.PlaceOrderAsync(request); + + Console.WriteLine(response); + } +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/RepaymentCommand.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/RepaymentCommand.cs new file mode 100644 index 00000000..e1dea888 --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/RepaymentCommand.cs @@ -0,0 +1,42 @@ +using System.CommandLine; +using MicroserviceTransactionsSample.Client.Commands.Binders; +using MicroserviceTransactionsSample.Rpc; +using static MicroserviceTransactionsSample.Rpc.CustomerService; + +namespace MicroserviceTransactionsSample.Client.Commands; + +public static class RepaymentCommand +{ + private const string Name = "Repayment"; + private const string Description = "Make a repayment"; + + private const string ArgName1 = "customer_id"; + private const string ArgDescription1 = "customer ID"; + private const string ArgName2 = "amount"; + private const string ArgDescription2 = "repayment amount"; + + public static Command Create() + { + var customerIdArg = new Argument(ArgName1, ArgDescription1); + var amountArg = new Argument(ArgName2, ArgDescription2); + var repaymentCommand = new Command(Name, Description) + { + customerIdArg, + amountArg + }; + + repaymentCommand.SetHandler(repay, + customerIdArg, + amountArg, + new CustomerServiceClientBinder()); + return repaymentCommand; + } + + private static async Task repay(int customerId, int amount, CustomerServiceClient client) + { + var request = new RepaymentRequest { CustomerId = customerId, Amount = amount }; + var response = await client.RepaymentAsync(request); + + Console.WriteLine(response); + } +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Logging.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Logging.cs new file mode 100644 index 00000000..38482a1d --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Logging.cs @@ -0,0 +1,13 @@ +using Microsoft.Extensions.Logging; + +namespace MicroserviceTransactionsSample.Client; + +public static class Logging +{ + public static ILoggerFactory GetLoggerFactory() + => LoggerFactory.Create(builder => + { + builder.SetMinimumLevel(LogLevel.Warning); + builder.AddSimpleConsole(options => { options.TimestampFormat = "HH:mm:ss "; }); + }); +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Program.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Program.cs new file mode 100644 index 00000000..95e0887b --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Program.cs @@ -0,0 +1,21 @@ +using System.CommandLine; +using MicroserviceTransactionsSample.Client.Commands; + +namespace MicroserviceTransactionsSample.Client; + +class Program +{ + static async Task Main(string[] args) + { + var rootCommand = new RootCommand("MicroserviceTransactionsSample.Client") + { + GetCustomerInfoCommand.Create(), + GetOrderCommand.Create(), + GetOrdersCommand.Create(), + PlaceOrderCommand.Create(), + RepaymentCommand.Create() + }; + + await rootCommand.InvokeAsync(args); + } +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/Common.csproj b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/Common.csproj new file mode 100644 index 00000000..05f267e4 --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/Common.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + MicroserviceTransactionsSample.Common + + + + + + + diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/CustomerService/Customer.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/CustomerService/Customer.cs new file mode 100644 index 00000000..ec061070 --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/CustomerService/Customer.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations.Schema; +using ScalarDB.Client.DataAnnotations; + +namespace MicroserviceTransactionsSample.Common.CustomerService; + +[Table("customer_service.customers")] +public class Customer +{ + [PartitionKey] + [Column("customer_id", Order = 0)] + public int Id { get; set; } + + [Column("name", Order = 1)] + public string Name { get; set; } = ""; + + [Column("credit_limit", Order = 2)] + public int CreditLimit { get; set; } + + [Column("credit_total", Order = 3)] + public int CreditTotal { get; set; } +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/FailedPreconditionException.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/FailedPreconditionException.cs new file mode 100644 index 00000000..40d42e54 --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/FailedPreconditionException.cs @@ -0,0 +1,10 @@ +using Grpc.Core; + +namespace MicroserviceTransactionsSample.Common; + +public class FailedPreconditionException : RpcException +{ + public FailedPreconditionException(string message) + : base(new Status(StatusCode.FailedPrecondition, message)) + { } +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/InternalException.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/InternalException.cs new file mode 100644 index 00000000..0e145c36 --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/InternalException.cs @@ -0,0 +1,10 @@ +using Grpc.Core; + +namespace MicroserviceTransactionsSample.Common; + +public class InternalException : RpcException +{ + public InternalException(string message, Exception? innerEx) + : base(new Status(StatusCode.Internal, message, innerEx)) + { } +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/NotFoundException.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/NotFoundException.cs new file mode 100644 index 00000000..eacafb49 --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/NotFoundException.cs @@ -0,0 +1,10 @@ +using Grpc.Core; + +namespace MicroserviceTransactionsSample.Common; + +public class NotFoundException : RpcException +{ + public NotFoundException(string message) + : base(new Status(StatusCode.NotFound, message)) + { } +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/OrderService/Item.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/OrderService/Item.cs new file mode 100644 index 00000000..8a3123fc --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/OrderService/Item.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations.Schema; +using ScalarDB.Client.DataAnnotations; + +namespace MicroserviceTransactionsSample.Common.OrderService; + +[Table("order_service.items")] +public class Item +{ + public static readonly Item EmptyItem = new() { Id = -1, Name = "", Price = 0 }; + + [PartitionKey] + [Column("item_id", Order = 0)] + public int Id { get; set; } + + [Column("name", Order = 1)] + public string Name { get; set; } = ""; + + [Column("price", Order = 2)] + public int Price { get; set; } +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/OrderService/Order.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/OrderService/Order.cs new file mode 100644 index 00000000..44dcf54d --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/OrderService/Order.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations.Schema; +using ScalarDB.Client.DataAnnotations; + +namespace MicroserviceTransactionsSample.Common.OrderService; + +[Table("order_service.orders")] +public class Order +{ + [PartitionKey] + [Column("customer_id", Order = 0)] + public int CustomerId { get; set; } + + [ClusteringKey] + [Column("timestamp", Order = 1)] + public long Timestamp { get; set; } + + [SecondaryIndex] + [Column("order_id", Order = 2)] + public string Id { get; set; } = ""; +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/OrderService/Statement.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/OrderService/Statement.cs new file mode 100644 index 00000000..131ec742 --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Common/OrderService/Statement.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations.Schema; +using ScalarDB.Client.DataAnnotations; + +namespace MicroserviceTransactionsSample.Common.OrderService; + +[Table("order_service.statements")] +public class Statement +{ + [PartitionKey] + [Column("order_id", Order = 0)] + public string OrderId { get; set; } = ""; + + [ClusteringKey] + [Column("item_id", Order = 1)] + public int ItemId { get; set; } + + [Column("count", Order = 2)] + public int Count { get; set; } +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/CustomerDbContext.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/CustomerDbContext.cs new file mode 100644 index 00000000..f4c60761 --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/CustomerDbContext.cs @@ -0,0 +1,11 @@ +using MicroserviceTransactionsSample.Common.CustomerService; +using ScalarDB.Client; + +namespace MicroserviceTransactionsSample.CustomerService; + +public class CustomerDbContext : ScalarDbContext +{ +#nullable disable + public ScalarDbSet Customers { get; set; } +#nullable restore +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/CustomerService.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/CustomerService.cs new file mode 100644 index 00000000..6bd7ca6d --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/CustomerService.cs @@ -0,0 +1,239 @@ +using Grpc.Core; +using MicroserviceTransactionsSample.Rpc; +using MicroserviceTransactionsSample.Common; +using ScalarDB.Client.Exceptions; +using static MicroserviceTransactionsSample.Rpc.CustomerService; + +namespace MicroserviceTransactionsSample.CustomerService; + +public class CustomerService : CustomerServiceBase +{ + private readonly CustomerDbContext _db; + private readonly ILogger _logger; + + public CustomerService(CustomerDbContext db, + ILogger logger) + { + _db = db; + _logger = logger; + } + + /// + /// Get customer information. This function processing operations can be used in both a normal + /// transaction and a global transaction. + /// + public override async Task GetCustomerInfo(GetCustomerInfoRequest request, + ServerCallContext context) + { + const string funcName = "Getting customer info"; + + var operations = () => + { + // Retrieve the customer info for the specified customer ID + var customer = _db.Customers.FirstOrDefault(c => c.Id == request.CustomerId); + if (customer == null) + throw new NotFoundException($"Customer not found (id: {request.CustomerId})"); + + return new GetCustomerInfoResponse + { + Id = customer.Id, + Name = customer.Name, + CreditLimit = customer.CreditLimit, + CreditTotal = customer.CreditTotal + }; + }; + + if (request.HasTransactionId) + { + // For a global transaction, execute the operations as a participant + return await execOperationsAsParticipant(funcName, request.TransactionId, operations); + } + else + { + // For a normal transaction, execute the operations + return await execOperations(funcName, operations); + } + } + + /// + /// Credit card payment. It's for a global transaction that spans OrderService and CustomerService. + /// + public override async Task Payment(PaymentRequest request, ServerCallContext context) + => await execOperationsAsParticipant( + "Payment", + request.TransactionId, + async () => + { + // Retrieve the customer info for the customer ID + var customer = _db.Customers.FirstOrDefault(c => c.Id == request.CustomerId); + if (customer == null) + throw new NotFoundException($"Customer not found (id: {request.CustomerId})"); + + // Update credit_total for the customer + // and check if the credit total exceeds the credit limit after payment + customer.CreditTotal += request.Amount; + if (customer.CreditTotal > customer.CreditLimit) + { + throw new FailedPreconditionException( + $"Credit limit exceeded ({customer.CreditTotal} > {customer.CreditLimit})"); + } + + // Save changes to the customer + await _db.Customers.UpdateAsync(customer); + + return new PaymentResponse(); + }); + + /// + /// Credit card repayment. + /// + public override async Task Repayment(RepaymentRequest request, ServerCallContext context) + => await execOperations( + "Repayment", + async () => + { + // Retrieve the customer info for the specified customer ID + var customer = _db.Customers.FirstOrDefault(c => c.Id == request.CustomerId); + if (customer == null) + throw new NotFoundException($"Customer not found (id: {request.CustomerId})"); + + // Reduce credit_total for the customer + // and check if over-repayment or not + customer.CreditTotal -= request.Amount; + if (customer.CreditTotal < 0) + throw new FailedPreconditionException($"Over repayment ({customer.CreditTotal})"); + + // Save changes to the customer + await _db.Customers.UpdateAsync(customer); + + return new RepaymentResponse(); + }); + + private Task execOperations(string funcName, + Func operations) + => execOperations(funcName, + () => Task.FromResult(operations())); + + private async Task execOperations(string funcName, + Func> operations) + { + var retryCount = 0; + Exception? lastException = null; + + while (true) + { + if (retryCount++ > 0) + { + // Retry the transaction three times maximum. + if (retryCount >= 3) + { + // If the transaction failed three times, return an error. + _logger.LogError(lastException, "{funcName} failed", funcName); + throw new InternalException(funcName + " failed", lastException); + } + + _logger.LogWarning(lastException, "Retrying the transaction after 100 milliseconds: {funcName}", funcName); + + await Task.Delay(TimeSpan.FromMilliseconds(100)); + } + + try + { + // Begin a transaction + await _db.BeginTransactionAsync(); + + // Execute operations + var response = await operations(); + + // Commit the transaction (even when the transaction is read-only, we need to commit) + await _db.CommitTransactionAsync(); + + // Return the response + return response; + } + catch (UnknownTransactionStatusException ex) + { + // If you catch `UnknownTransactionStatusException`, it indicates that the status of the + // transaction, whether it has succeeded or not, is unknown. In such a case, you need to + // check if the transaction is committed successfully or not and retry it if it failed. + // How to identify a transaction status is delegated to users + + _logger.LogError(ex, "{funcName} failed", funcName); + throw new InternalException(funcName + " failed", ex); + } + catch (TransactionException ex) + { + // For other cases, you can try retrying the transaction + + // Rollback the transaction + await tryRollbackTransaction(); + + // The thrown exception can be retryable. In such case, you can basically retry the + // transaction. However, for the other exceptions, the transaction may still fail if the + // cause of the exception is nontransient. For such a case, you need to limit the number + // of retries and give up retrying + lastException = ex; + } + catch (Exception ex) + { + // If exception is not inherited from `TransactionException` you cannot retry the transaction + _logger.LogError(ex, "{funcName} failed", funcName); + + // Rollback the transaction + await tryRollbackTransaction(); + + throw; + } + } + } + + private Task execOperationsAsParticipant(string funcName, + string transactionId, + Func operations) + => execOperationsAsParticipant(funcName, + transactionId, + () => Task.FromResult(operations())); + + private async Task execOperationsAsParticipant(string funcName, + string transactionId, + Func> operations) + { + try + { + // Join the transaction + await _db.JoinTransactionAsync(transactionId); + + // Execute operations and return the result + return await operations(); + } + catch (Exception ex) + { + _logger.LogError(ex, "{funcName} failed", funcName); + + if (ex is TransactionException) + throw new InternalException(funcName + " failed", ex); + else + throw; + } + } + + private async Task tryRollbackTransaction() + { + if (String.IsNullOrEmpty(_db.CurrentTransactionId)) + return; + + try + { + await _db.RollbackTransactionAsync(); + } + catch (TransactionException ex) + { + _logger.LogWarning(ex, "Rollback failed"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Rollback failed"); + throw; + } + } +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/CustomerService.csproj b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/CustomerService.csproj new file mode 100644 index 00000000..d4e450bd --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/CustomerService.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + MicroserviceTransactionsSample.CustomerService + + + + + + + + + + + + + + + + diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/Program.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/Program.cs new file mode 100644 index 00000000..f1dd0f53 --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/Program.cs @@ -0,0 +1,33 @@ +using ScalarDB.Client.Extensions; + +namespace MicroserviceTransactionsSample.CustomerService; + +public class Program +{ + public static async Task Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + builder.Services.AddLogging(o => + { + o.AddSimpleConsole(options => + { + options.TimestampFormat = "HH:mm:ss "; + }); + }); + + // Add services to the container. + builder.Services.AddGrpc(); + + // Add CustomerDbContext to the container. + builder.Services.AddScalarDbContext(); + + var app = builder.Build(); + + // Configure the HTTP request pipeline. + app.MapGrpcService(); + app.MapGet("/", () => "Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909"); + + await app.RunAsync(); + } +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/Properties/launchSettings.json b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/Properties/launchSettings.json new file mode 100644 index 00000000..8e3ad2d1 --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/Properties/launchSettings.json @@ -0,0 +1,20 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "applicationUrl": "http://localhost:5248", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true + }, + "https": { + "commandName": "Project", + "applicationUrl": "https://localhost:7291;http://localhost:5248", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true + } + } +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/appsettings.json b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/appsettings.json new file mode 100644 index 00000000..e324b418 --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/appsettings.json @@ -0,0 +1,21 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "ScalarDB.Client": "Debug" + } + }, + "ScalarDbOptions": { + "AuthEnabled": true, + "Username": "customer-service", + "Password": "customer-service" + }, + "AllowedHosts": "*", + "Urls": "http://*:10010", + "Kestrel": { + "EndpointDefaults": { + "Protocols": "Http2" + } + } +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/DataLoader.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/DataLoader.cs new file mode 100644 index 00000000..1b4c7ee1 --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/DataLoader.cs @@ -0,0 +1,94 @@ +using ScalarDB.Client; +using ScalarDB.Client.Extensions; +using MicroserviceTransactionsSample.Common.CustomerService; +using MicroserviceTransactionsSample.Common.OrderService; +using Microsoft.Extensions.Logging; +using ScalarDB.Client.Exceptions; + +namespace MicroserviceTransactionsSample.DataLoader; + +public class DataLoader +{ + private readonly IDistributedTransactionManager _manager; + private readonly ILogger _logger; + + public DataLoader(IDistributedTransactionManager manager, + ILoggerFactory loggerFactory) + { + _manager = manager; + _logger = loggerFactory.CreateLogger(); + } + + public async Task Load() + { + var attempts = Program.RetryCount; + + while (true) + { + IDistributedTransaction? transaction = null; + try + { + // fill the data + transaction = await _manager.BeginAsync(); + + // Customers + var customers = new[] + { + new Customer { Id = 1, Name = "Yamada Taro", CreditLimit = 10000, CreditTotal = 0 }, + new Customer { Id = 2, Name = "Yamada Hanako", CreditLimit = 10000, CreditTotal = 0 }, + new Customer { Id = 3, Name = "Suzuki Ichiro", CreditLimit = 10000, CreditTotal = 0 } + }; + + foreach (var customer in customers) + { + var key = new Dictionary { { nameof(Customer.Id), customer.Id } }; + if (await transaction.GetAsync(key) == null) + await transaction.InsertAsync(customer); + } + + // Items + var items = new[] + { + new Item { Id = 1, Name = "Apple", Price = 1000 }, + new Item { Id = 2, Name = "Orange", Price = 2000 }, + new Item { Id = 3, Name = "Grape", Price = 2500 }, + new Item { Id = 4, Name = "Mango", Price = 5000 }, + new Item { Id = 5, Name = "Melon", Price = 3000 } + }; + + foreach (var item in items) + { + var key = new Dictionary { { nameof(Item.Id), item.Id } }; + if (await transaction.GetAsync(key) == null) + await transaction.InsertAsync(item); + } + + await transaction.CommitAsync(); + + _logger.LogInformation("Initial data loaded"); + return; + } + catch (IllegalArgumentException) when (--attempts > 0) + { + // there's can be a lag until ScalarDB Cluster recognize namespaces and tables + // created in Cassandra, so if this method is called after `SchemaCreator.Create()` + // the first attempts can fail with 'The namespace does not exist' error + + _logger.LogWarning("Loading initial data failed. " + + "Retrying after {interval}...", Program.RetryInternal); + + transaction?.RollbackAsync(); + + await Task.Delay(Program.RetryInternal); + } + catch (Exception ex) + { + _logger.LogError(ex, "Loading initial data failed"); + + transaction?.RollbackAsync(); + + throw; + } + } + } +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/DataLoader.csproj b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/DataLoader.csproj new file mode 100644 index 00000000..9201b0bf --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/DataLoader.csproj @@ -0,0 +1,27 @@ + + + + Exe + net8.0 + enable + enable + MicroserviceTransactionsSample.DataLoader + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/Program.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/Program.cs new file mode 100644 index 00000000..b9710856 --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/Program.cs @@ -0,0 +1,50 @@ +using System.CommandLine; +using Microsoft.Extensions.Logging; +using ScalarDB.Client; + +namespace MicroserviceTransactionsSample.DataLoader; + +class Program +{ + internal const int RetryCount = 10; + internal static readonly TimeSpan RetryInternal = TimeSpan.FromSeconds(1); + + static async Task Main(string[] args) + { + var configOption = new Option("--config", "Path to the config file") { IsRequired = true }; + var resetDataOptions = new Option("--reset-data", "Recreate tables and other data"); + var logLevelOption = new Option(name: "--log-level", + description: "Minimum LogLevel", + getDefaultValue: () => LogLevel.Information); + + var rootCommand = new RootCommand("MicroserviceTransactionsSample.DataLoader") + { + configOption, + resetDataOptions, + logLevelOption + }; + + rootCommand.SetHandler(loadData, configOption, resetDataOptions, logLevelOption); + await rootCommand.InvokeAsync(args); + } + + private static async Task loadData(string configFilePath, bool resetData, LogLevel logLevel) + { + var loggerFactory = getDefaultLoggerFactory(logLevel); + var factory = TransactionFactory.Create(configFilePath, loggerFactory); + + using var admin = factory.GetTransactionAdmin(); + await (new SchemaCreator(admin, loggerFactory)).Create(resetData); + await (new UsersCreator(admin, loggerFactory)).Create(); + + using var manager = factory.GetTransactionManager(); + await (new DataLoader(manager, loggerFactory)).Load(); + } + + private static ILoggerFactory getDefaultLoggerFactory(LogLevel logLevel) + => LoggerFactory.Create(builder => + { + builder.SetMinimumLevel(logLevel); + builder.AddSimpleConsole(options => { options.TimestampFormat = "HH:mm:ss.fff "; }); + }); +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/SchemaCreator.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/SchemaCreator.cs new file mode 100644 index 00000000..2ef69063 --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/SchemaCreator.cs @@ -0,0 +1,52 @@ +using MicroserviceTransactionsSample.Common.CustomerService; +using MicroserviceTransactionsSample.Common.OrderService; +using Microsoft.Extensions.Logging; +using ScalarDB.Client; +using ScalarDB.Client.Extensions; + +namespace MicroserviceTransactionsSample.DataLoader; + +public class SchemaCreator +{ + private readonly IDistributedTransactionAdmin _admin; + private readonly ILogger _logger; + + public SchemaCreator(IDistributedTransactionAdmin admin, + ILoggerFactory loggerFactory) + { + _admin = admin; + _logger = loggerFactory.CreateLogger(); + } + + public async Task Create(bool resetIfNeeded) + { + try + { + // create tables etc. + await _admin.CreateCoordinatorTablesAsync(true); + + await _admin.CreateNamespaceAsync(true); + await _admin.CreateNamespaceAsync(true); + + if (resetIfNeeded) + { + await _admin.DropTableAsync(true); + await _admin.DropTableAsync(true); + await _admin.DropTableAsync(true); + await _admin.DropTableAsync(true); + } + + await _admin.CreateTableAsync(true); + await _admin.CreateTableAsync(true); + await _admin.CreateTableAsync(true); + await _admin.CreateTableAsync(true); + + _logger.LogInformation("Database schema initialized"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Initializing database schema failed"); + throw; + } + } +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/UsersCreator.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/UsersCreator.cs new file mode 100644 index 00000000..55ada722 --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/UsersCreator.cs @@ -0,0 +1,67 @@ +using Microsoft.Extensions.Logging; +using ScalarDB.Client; +using ScalarDB.Client.Core.Admin; +using ScalarDB.Client.Exceptions; + +namespace MicroserviceTransactionsSample.DataLoader; + +public class UsersCreator +{ + private const string CustomerServiceUsername = "customer-service"; + private const string CustomerServicePassword = "customer-service"; + private const string CustomerServiceNamespace = "customer_service"; + private const string OrderServiceUsername = "order-service"; + private const string OrderServicePassword = "order-service"; + private const string OrderServiceNamespace = "order_service"; + + private readonly Privilege[] _privileges = [Privilege.Read, Privilege.Create, Privilege.Write, Privilege.Delete]; + + private readonly IDistributedTransactionAdmin _admin; + private readonly ILogger _logger; + + public UsersCreator(IDistributedTransactionAdmin admin, + ILoggerFactory loggerFactory) + { + _admin = admin; + _logger = loggerFactory.CreateLogger(); + } + + public async Task Create() + { + var attempts = Program.RetryCount; + + while (true) + { + try + { + if (await _admin.GetUserAsync(CustomerServiceUsername) == null) + await _admin.CreateUserAsync(CustomerServiceUsername, CustomerServicePassword); + + if (await _admin.GetUserAsync(OrderServiceUsername) == null) + await _admin.CreateUserAsync(OrderServiceUsername, OrderServicePassword); + + await _admin.GrantAsync(CustomerServiceUsername, CustomerServiceNamespace, null, _privileges); + await _admin.GrantAsync(OrderServiceUsername, OrderServiceNamespace, null, _privileges); + + _logger.LogInformation("Privileges initialized"); + return; + } + catch (IllegalArgumentException) when (--attempts > 0) + { + // there's can be a lag until ScalarDB Cluster recognize namespaces and tables + // created in Cassandra, so if this method is called after `SchemaCreator.Create()` + // the first attempts can fail with 'The namespace does not exist' error + + _logger.LogWarning("Initializing privileges failed. " + + "Retrying after {interval}...", Program.RetryInternal); + + await Task.Delay(Program.RetryInternal); + } + catch (Exception ex) + { + _logger.LogError(ex, "Initializing privileges failed"); + throw; + } + } + } +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/scalardb-options.json b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/scalardb-options.json new file mode 100644 index 00000000..7514d77c --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/scalardb-options.json @@ -0,0 +1,8 @@ +{ + "ScalarDbOptions": { + "Address": "http://localhost:60053", + "AuthEnabled": true, + "Username": "admin", + "Password": "admin" + } +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Dockerfile-CustomerService b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Dockerfile-CustomerService new file mode 100644 index 00000000..876bc5bf --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Dockerfile-CustomerService @@ -0,0 +1,20 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build-env + +WORKDIR /src +COPY Rpc/Rpc.csproj Rpc/ +COPY Common/Common.csproj Common/ +COPY CustomerService/CustomerService.csproj CustomerService/ + +WORKDIR /src/CustomerService +RUN dotnet restore + +COPY Rpc ../Rpc +COPY Common ../Common +COPY CustomerService . +RUN dotnet publish -c Release -o /publish --no-restore + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime +WORKDIR /app +COPY --from=build-env /publish . +EXPOSE 10010 +ENTRYPOINT ["dotnet", "CustomerService.dll"] diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Dockerfile-OrderService b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Dockerfile-OrderService new file mode 100644 index 00000000..14ddf1e6 --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Dockerfile-OrderService @@ -0,0 +1,20 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build-env + +WORKDIR /src +COPY Rpc/Rpc.csproj Rpc/ +COPY Common/Common.csproj Common/ +COPY OrderService/OrderService.csproj OrderService/ + +WORKDIR /src/OrderService +RUN dotnet restore + +COPY Rpc ../Rpc +COPY Common ../Common +COPY OrderService . +RUN dotnet publish -c Release -o /publish --no-restore + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime +WORKDIR /app +COPY --from=build-env /publish . +EXPOSE 10020 +ENTRYPOINT ["dotnet", "OrderService.dll"] diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/MicroserviceTransactionsSample-LINQ.sln b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/MicroserviceTransactionsSample-LINQ.sln new file mode 100644 index 00000000..df95658d --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/MicroserviceTransactionsSample-LINQ.sln @@ -0,0 +1,65 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 25.0.1706.1 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rpc", "Rpc\Rpc.csproj", "{4E5BBF91-3192-4B9C-9F3F-F17B9EB325CC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CustomerService", "CustomerService\CustomerService.csproj", "{3CBEDF8B-3F8E-4709-BE3B-53417A8454BA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrderService", "OrderService\OrderService.csproj", "{5DACEEAA-9E81-477E-8166-1D4FD3614717}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Client", "Client\Client.csproj", "{D19408A7-98AB-4A98-A3FD-6240CBD33B60}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{3A94D79F-51B2-43EC-9EC0-05903114054D}" + ProjectSection(SolutionItems) = preProject + Dockerfile-OrderService = Dockerfile-OrderService + Dockerfile-CustomerService = Dockerfile-CustomerService + scalardb-cluster-node.properties = scalardb-cluster-node.properties + docker-compose.yml = docker-compose.yml + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common", "Common\Common.csproj", "{3A24DEF6-8412-4960-9CA6-E4608C1530FC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DataLoader", "DataLoader\DataLoader.csproj", "{9B114E0D-F905-447B-864B-D658B506F1A7}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {4E5BBF91-3192-4B9C-9F3F-F17B9EB325CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4E5BBF91-3192-4B9C-9F3F-F17B9EB325CC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4E5BBF91-3192-4B9C-9F3F-F17B9EB325CC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4E5BBF91-3192-4B9C-9F3F-F17B9EB325CC}.Release|Any CPU.Build.0 = Release|Any CPU + {3CBEDF8B-3F8E-4709-BE3B-53417A8454BA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3CBEDF8B-3F8E-4709-BE3B-53417A8454BA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3CBEDF8B-3F8E-4709-BE3B-53417A8454BA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3CBEDF8B-3F8E-4709-BE3B-53417A8454BA}.Release|Any CPU.Build.0 = Release|Any CPU + {5DACEEAA-9E81-477E-8166-1D4FD3614717}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5DACEEAA-9E81-477E-8166-1D4FD3614717}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5DACEEAA-9E81-477E-8166-1D4FD3614717}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5DACEEAA-9E81-477E-8166-1D4FD3614717}.Release|Any CPU.Build.0 = Release|Any CPU + {D19408A7-98AB-4A98-A3FD-6240CBD33B60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D19408A7-98AB-4A98-A3FD-6240CBD33B60}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D19408A7-98AB-4A98-A3FD-6240CBD33B60}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D19408A7-98AB-4A98-A3FD-6240CBD33B60}.Release|Any CPU.Build.0 = Release|Any CPU + {3A24DEF6-8412-4960-9CA6-E4608C1530FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3A24DEF6-8412-4960-9CA6-E4608C1530FC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3A24DEF6-8412-4960-9CA6-E4608C1530FC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3A24DEF6-8412-4960-9CA6-E4608C1530FC}.Release|Any CPU.Build.0 = Release|Any CPU + {9B114E0D-F905-447B-864B-D658B506F1A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9B114E0D-F905-447B-864B-D658B506F1A7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9B114E0D-F905-447B-864B-D658B506F1A7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9B114E0D-F905-447B-864B-D658B506F1A7}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {6DFADFD7-F984-49A1-9E33-C86076348167} + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + EndGlobalSection +EndGlobal diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/OrderService/OrderDbContext.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/OrderService/OrderDbContext.cs new file mode 100644 index 00000000..7d8ea28e --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/OrderService/OrderDbContext.cs @@ -0,0 +1,13 @@ +using MicroserviceTransactionsSample.Common.OrderService; +using ScalarDB.Client; + +namespace MicroserviceTransactionsSample.OrderService; + +public class OrderDbContext : ScalarDbContext +{ +#nullable disable + public ScalarDbSet Orders { get; set; } + public ScalarDbSet Statements { get; set; } + public ScalarDbSet Items { get; set; } +#nullable restore +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/OrderService/OrderService.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/OrderService/OrderService.cs new file mode 100644 index 00000000..d4696797 --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/OrderService/OrderService.cs @@ -0,0 +1,291 @@ +using Grpc.Core; +using MicroserviceTransactionsSample.Rpc; +using MicroserviceTransactionsSample.Common; +using MicroserviceTransactionsSample.Common.OrderService; +using ScalarDB.Client.Exceptions; +using static MicroserviceTransactionsSample.Rpc.OrderService; +using static MicroserviceTransactionsSample.Rpc.CustomerService; +using Order = MicroserviceTransactionsSample.Common.OrderService.Order; +using Statement = MicroserviceTransactionsSample.Common.OrderService.Statement; + +namespace MicroserviceTransactionsSample.OrderService; + +public class OrderService : OrderServiceBase +{ + private readonly OrderDbContext _db; + private readonly CustomerServiceClient _customerClient; + private readonly ILogger _logger; + + public OrderService(OrderDbContext db, + CustomerServiceClient customerClient, + ILogger logger) + { + _db = db; + _customerClient = customerClient; + _logger = logger; + } + + /// + /// Place an order. It's a transaction that spans OrderService and CustomerService + /// + public override async Task PlaceOrder(PlaceOrderRequest request, + ServerCallContext context) + => await execOperationsAsCoordinator( + "Placing an order", + async () => + { + var orderId = Guid.NewGuid().ToString(); + + // Insert the order info into the orders table + var order = new Order + { + Id = orderId, + CustomerId = request.CustomerId, + Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + }; + await _db.Orders.AddAsync(order); + + var amount = 0; + foreach (var itemOrder in request.ItemOrder) + { + // Insert the order statement into the statements table + var statement = new Statement + { + OrderId = orderId, + ItemId = itemOrder.ItemId, + Count = itemOrder.Count + }; + await _db.Statements.AddAsync(statement); + + // Retrieve the item info from the items table + var item = _db.Items.FirstOrDefault(i => i.Id == itemOrder.ItemId); + if (item == null) + throw new NotFoundException("Item not found"); + + // Calculate the total amount + amount += item.Price * itemOrder.Count; + } + + // Call the payment endpoint of Customer service + var paymentRequest = new PaymentRequest + { + TransactionId = _db.CurrentTransactionId, + CustomerId = request.CustomerId, + Amount = amount + }; + await _customerClient.PaymentAsync(paymentRequest); + + // Return the order id + return new PlaceOrderResponse { OrderId = orderId }; + }); + + /// + /// Get Order information by order ID + /// + public override async Task GetOrder(GetOrderRequest request, + ServerCallContext context) + => await execOperationsAsCoordinator( + "Getting an order", + async () => + { + // Retrieve the order info for the specified order ID + var order = _db.Orders.FirstOrDefault(o => o.Id == request.OrderId); + if (order == null) + throw new NotFoundException("Order not found"); + + // Get the customer name from the Customer service + var customerName = await getCustomerName(_db.CurrentTransactionId, + order.CustomerId); + + // Make an order protobuf to return + var rpcOrder = getRpcOrder(order, customerName); + return new GetOrderResponse { Order = rpcOrder }; + }); + + /// + /// Get Order information by customer ID + /// + public override async Task GetOrders(GetOrdersRequest request, + ServerCallContext context) + => await execOperationsAsCoordinator( + "Getting orders", + async () => + { + // Get the customer name from the Customer service + var customerName = await getCustomerName(_db.CurrentTransactionId, + request.CustomerId); + + // Retrieve the order info for the specified customer ID + var response = new GetOrdersResponse(); + var orders = _db.Orders.Where(order => order.CustomerId == request.CustomerId); + + foreach (var order in orders) + { + // Make an order protobuf to return + var rpcOrder = getRpcOrder(order, customerName); + response.Order.Add(rpcOrder); + } + + return response; + }); + + private Rpc.Order getRpcOrder(Order order, string customerName) + { + var rpcOrder = new Rpc.Order + { + OrderId = order.Id, + CustomerId = order.CustomerId, + CustomerName = customerName, + Timestamp = order.Timestamp + }; + + var total = 0; + + // Create statements for the order ID with data from Statements and Items tables + var rpcStatements = from statement in _db.Statements + join item in _db.Items + on statement.ItemId equals item.Id into items + from statItem in items.DefaultIfEmpty(Item.EmptyItem) + where statement.OrderId == order.Id + select new Rpc.Statement + { + ItemId = statItem.Id, + ItemName = statItem.Name, + Price = statItem.Price, + Count = statement.Count, + Total = statItem.Price * statement.Count + }; + + foreach (var rpcStatement in rpcStatements) + { + if (rpcStatement.ItemId == Item.EmptyItem.Id) + throw new NotFoundException($"Item not found"); + + rpcOrder.Statement.Add(rpcStatement); + + total += rpcStatement.Total; + } + + rpcOrder.Total = total; + return rpcOrder; + } + + private async Task getCustomerName(string transactionId, int customerId) + { + var request = new GetCustomerInfoRequest + { + TransactionId = transactionId, + CustomerId = customerId + }; + var customerInfo = await _customerClient.GetCustomerInfoAsync(request); + + return customerInfo.Name; + } + + private async Task execOperationsAsCoordinator(string funcName, + Func> operations) + { + var retryCount = 0; + Exception? lastException = null; + + while (true) + { + if (retryCount++ > 0) + { + // Retry the transaction three times maximum. + if (retryCount >= 3) + { + // If the transaction failed three times, return an error. + _logger.LogError(lastException, "{funcName} failed", funcName); + + if (lastException is RpcException) + throw lastException; + else + throw new InternalException(funcName + " failed", lastException); + } + + _logger.LogWarning(lastException, "Retrying the transaction after 100 milliseconds: {funcName}", funcName); + + await Task.Delay(TimeSpan.FromMilliseconds(100)); + } + + try + { + // Begin a transaction + await _db.BeginTransactionAsync(); + + // Execute operations + var response = await operations(); + + // Commit the transaction + await _db.CommitTransactionAsync(); + + // Return the response + return response; + } + catch (UnknownTransactionStatusException ex) + { + // If you catch `UnknownTransactionStatusException`, it indicates that the status of the + // transaction, whether it has succeeded or not, is unknown. In such a case, you need to + // check if the transaction is committed successfully or not and retry it if it failed. + // How to identify a transaction status is delegated to users + + _logger.LogError(ex, "{funcName} failed", funcName); + throw new InternalException(funcName + " failed", ex); + } + catch (RpcException ex) + when (ex.StatusCode is StatusCode.NotFound or StatusCode.FailedPrecondition) + { + // For `NOT_FOUND` and `FAILED_PRECONDITION` gRPC errors, you cannot retry the transaction + + // Rollback the transaction + await tryRollbackTransaction(); + throw; + } + catch (Exception ex) + when (ex is TransactionException or RpcException) + { + // For other `TransactionException` or gRPC cases, you can try retrying the transaction + + // Rollback the transaction + await tryRollbackTransaction(); + + // The thrown exception can be retryable. In such case, you can basically retry the + // transaction. However, for the other exceptions, the transaction may still fail if the + // cause of the exception is nontransient. For such a case, you need to limit the number + // of retries and give up retrying + lastException = ex; + } + catch (Exception ex) + { + // If exception is not inherited from `TransactionException` you cannot retry the transaction + _logger.LogError(ex, "{funcName} failed", funcName); + + // Rollback the transaction + await tryRollbackTransaction(); + + throw; + } + } + } + + private async Task tryRollbackTransaction() + { + if (String.IsNullOrEmpty(_db.CurrentTransactionId)) + return; + + try + { + await _db.RollbackTransactionAsync(); + } + catch (TransactionException ex) + { + _logger.LogWarning(ex, "Rollback failed"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Rollback failed"); + throw; + } + } +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/OrderService/OrderService.csproj b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/OrderService/OrderService.csproj new file mode 100644 index 00000000..22e54c31 --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/OrderService/OrderService.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + MicroserviceTransactionsSample.OrderService + + + + + + + + + + + + + + + + diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/OrderService/Program.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/OrderService/Program.cs new file mode 100644 index 00000000..51d07bdc --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/OrderService/Program.cs @@ -0,0 +1,40 @@ +using ScalarDB.Client.Extensions; +using static MicroserviceTransactionsSample.Rpc.CustomerService; + +namespace MicroserviceTransactionsSample.OrderService; + +public class Program +{ + public static async Task Main(string[] args) + { + var customerServiceUrl = Environment.GetEnvironmentVariable("CUSTOMER_SERVICE_URL")!; + + var builder = WebApplication.CreateBuilder(args); + + builder.Services.AddLogging(o => + { + o.AddSimpleConsole(options => + { + options.TimestampFormat = "HH:mm:ss "; + }); + }); + + // Add services to the container. + builder.Services.AddGrpc(); + builder.Services.AddGrpcClient(o => + { + o.Address = new Uri(customerServiceUrl); + }); + + // Add OrderDbContext to the container. + builder.Services.AddScalarDbContext(); + + var app = builder.Build(); + + // Configure the HTTP request pipeline. + app.MapGrpcService(); + app.MapGet("/", () => "Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909"); + + await app.RunAsync(); + } +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/OrderService/Properties/launchSettings.json b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/OrderService/Properties/launchSettings.json new file mode 100644 index 00000000..bce0acdd --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/OrderService/Properties/launchSettings.json @@ -0,0 +1,20 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "applicationUrl": "http://localhost:5167", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true + }, + "https": { + "commandName": "Project", + "applicationUrl": "https://localhost:7278;http://localhost:5167", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true + } + } +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/OrderService/appsettings.json b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/OrderService/appsettings.json new file mode 100644 index 00000000..777b4b05 --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/OrderService/appsettings.json @@ -0,0 +1,22 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "ScalarDB.Client": "Debug" + } + }, + "ScalarDbOptions": { + "AuthEnabled": true, + "Username": "order-service", + "Password": "order-service" + }, + "AllowedHosts": "*", + "Urls": "http://*:10020", + "Kestrel": { + "EndpointDefaults": { + "Protocols": "Http2" + } + } +} + diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Rpc/Protos/sample.proto b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Rpc/Protos/sample.proto new file mode 100644 index 00000000..0f46fbfa --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Rpc/Protos/sample.proto @@ -0,0 +1,178 @@ +syntax = "proto3"; + +option java_multiple_files = true; +option java_package = "sample.rpc"; +option java_outer_classname = "Sample"; +option csharp_namespace = "MicroserviceTransactionsSample.Rpc"; + +package rpc; + +// Order Service. +service OrderService { + // Places an order. It's for a global transaction that spans OrderService and CustomerService. + rpc PlaceOrder(PlaceOrderRequest) returns (PlaceOrderResponse) { + } + + // Retrieves order information by order ID. + rpc GetOrder(GetOrderRequest) returns (GetOrderResponse) { + } + + // Retrieves order information by customer ID. + rpc GetOrders(GetOrdersRequest) returns (GetOrdersResponse) { + } +} + +// An item to order. +message ItemOrder { + // The item ID of the item to order. + int32 item_id = 1; + + // The number of items to order. + int32 count = 2; +} + +// Request message for OrderService.PlaceOrder. +message PlaceOrderRequest { + // The customer ID of the customer who places the order. + int32 customer_id = 1; + + // The items to order. + repeated ItemOrder item_order = 2; +} + +// Response message for OrderService.PlaceOrder. +message PlaceOrderResponse { + string order_id = 1; +} + +// An order information. +message Order { + // The order ID. + string order_id = 1; + + // The timestamp when the order is placed. + int64 timestamp = 2; + + // The customer ID of the customer who places the order. + int32 customer_id = 3; + + // The name of the customer. + string customer_name = 4; + + // The statements of the order. + repeated Statement statement = 5; + + // The total price of the order. + int32 total = 6; +} + +// A statement of an order. +message Statement { + // The item ID of the item to order. + int32 item_id = 1; + + // The name of the item to order. + string item_name = 2; + + // The price of the item to order. + int32 price = 3; + + // The number of items to order. + int32 count = 4; + + // The total price of the item to order. + int32 total = 5; +} + +// Request message for OrderService.GetOrder. +message GetOrderRequest { + // The order ID. + string order_id = 1; +} + +// Response message for OrderService.GetOrder. +message GetOrderResponse { + // The order information. + Order order = 1; +} + +// Request message for OrderService.GetOrders. +message GetOrdersRequest { + // The customer ID of the customer. + int32 customer_id = 1; +} + +// Response message for OrderService.GetOrders. +message GetOrdersResponse { + // The order information. + repeated Order order = 1; +} + +// Customer Service. +service CustomerService { + // Get customer information. This function processing operations can be used in both a normal + // transaction and a global transaction. + rpc GetCustomerInfo(GetCustomerInfoRequest) returns (GetCustomerInfoResponse) { + } + + // Credit card payment. It's for a global transaction that spans OrderService and CustomerService. + rpc Payment(PaymentRequest) returns (PaymentResponse) { + } + + // Credit card repayment. + rpc Repayment(RepaymentRequest) returns (RepaymentResponse) { + } +} + +// Request message for CustomerService.GetCustomerInfo. +message GetCustomerInfoRequest { + // The global transaction ID. + optional string transaction_id = 1; + + // The customer ID of the customer. + int32 customer_id = 2; +} + +// Response message for CustomerService.GetCustomerInfo. +message GetCustomerInfoResponse { + // The ID of the customer. + int32 id = 1; + + // The name of the customer. + string name = 2; + + // The credit limit of the customer. + int32 credit_limit = 3; + + // The total credit of the customer. + int32 credit_total = 4; +} + +// Request message for CustomerService.Payment. +message PaymentRequest { + // The global transaction ID. + string transaction_id = 1; + + // The customer ID of the customer. + int32 customer_id = 2; + + // The amount of the payment. + int32 amount = 3; +} + +// Response message for CustomerService.Payment. +message PaymentResponse { +} + +// Request message for CustomerService.Repayment. +message RepaymentRequest { + // The customer ID of the customer. + int32 customer_id = 1; + + // The amount of the repayment. + int32 amount = 2; +} + +// Response message for CustomerService.Repayment. +message RepaymentResponse { +} diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Rpc/Rpc.csproj b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Rpc/Rpc.csproj new file mode 100644 index 00000000..80045655 --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Rpc/Rpc.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + enable + enable + MicroserviceTransactionsSample.Rpc + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/docker-compose.yml b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/docker-compose.yml new file mode 100644 index 00000000..ca2e3f39 --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/docker-compose.yml @@ -0,0 +1,69 @@ +services: + + mysql: + image: mysql:8.0 + environment: + MYSQL_ROOT_PASSWORD: mysql + container_name: "mysql-1" + restart: always + ports: + - "3306:3306" + networks: + - sample-network + + cassandra: + image: cassandra:3.11 + container_name: "cassandra-1" + restart: always + ports: + - "9042:9042" + networks: + - sample-network + + scalardb-cluster-node: + image: ghcr.io/scalar-labs/scalardb-cluster-node-byol-premium:3.13.0 + container_name: "scalardb-cluster-node-1" + restart: always + depends_on: + - mysql + - cassandra + ports: + - "60053:60053" + - "9080:9080" + volumes: + - ./scalardb-cluster-node.properties:/scalardb-cluster/node/scalardb-cluster-node.properties + networks: + - sample-network + + customer-service: + build: + context: . + dockerfile: Dockerfile-CustomerService + container_name: "customer-service-1" + entrypoint: ["dotnet", "CustomerService.dll"] + environment: + - ScalarDbOptions__Address=http://scalardb-cluster-node:60053 + restart: "always" + ports: + - "10010:10010" + networks: + - sample-network + + order-service: + build: + context: . + dockerfile: Dockerfile-OrderService + container_name: "order-service-1" + entrypoint: ["dotnet", "OrderService.dll"] + environment: + - ScalarDbOptions__Address=http://scalardb-cluster-node:60053 + - CUSTOMER_SERVICE_URL=http://customer-service:10010 + restart: "always" + ports: + - "10020:10020" + networks: + - sample-network + +networks: + sample-network: + name: sample-network diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/scalardb-cluster-node.properties b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/scalardb-cluster-node.properties new file mode 100644 index 00000000..0ccd294f --- /dev/null +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/scalardb-cluster-node.properties @@ -0,0 +1,30 @@ +scalar.db.storage=multi-storage +scalar.db.multi_storage.storages=cassandra,mysql + +# Cassandra for the order service tables +scalar.db.multi_storage.storages.cassandra.storage=cassandra +scalar.db.multi_storage.storages.cassandra.contact_points=cassandra +scalar.db.multi_storage.storages.cassandra.username=cassandra +scalar.db.multi_storage.storages.cassandra.password=cassandra + +# MySQL for the customer service tables +scalar.db.multi_storage.storages.mysql.storage=jdbc +scalar.db.multi_storage.storages.mysql.contact_points=jdbc:mysql://mysql:3306/ +scalar.db.multi_storage.storages.mysql.username=root +scalar.db.multi_storage.storages.mysql.password=mysql + +scalar.db.multi_storage.namespace_mapping=coordinator:cassandra,order_service:cassandra,customer_service:mysql +scalar.db.multi_storage.default_storage=mysql + +# Enable SQL +scalar.db.sql.enabled=true + +# Enable standalone mode +scalar.db.cluster.node.standalone_mode.enabled=true + +# Enable ScalarDB Auth +scalar.db.cluster.auth.enabled=true + +# License key configurations +scalar.db.cluster.node.licensing.license_key= +scalar.db.cluster.node.licensing.license_check_cert_pem= \ No newline at end of file From 9003658078af3ff245c6e6f81157f4320c14c6b2 Mon Sep 17 00:00:00 2001 From: Dima <72738687+Dima-X@users.noreply.github.com> Date: Tue, 1 Oct 2024 10:39:12 +0900 Subject: [PATCH 2/2] Apply suggestions from code review Co-authored-by: Josh Wong --- .../Client/Commands/GetOrdersCommand.cs | 2 +- .../CustomerService/CustomerService.cs | 2 +- .../DataLoader/Program.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/GetOrdersCommand.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/GetOrdersCommand.cs index 63254583..71073739 100644 --- a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/GetOrdersCommand.cs +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/Client/Commands/GetOrdersCommand.cs @@ -8,7 +8,7 @@ namespace MicroserviceTransactionsSample.Client.Commands; public static class GetOrdersCommand { private const string Name = "GetOrders"; - private const string Description = "Get orders information by customer ID"; + private const string Description = "Get information about orders by customer ID"; private const string ArgName = "customer_id"; private const string ArgDescription = "customer ID"; diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/CustomerService.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/CustomerService.cs index 6bd7ca6d..c03ca777 100644 --- a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/CustomerService.cs +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/CustomerService/CustomerService.cs @@ -101,7 +101,7 @@ public override async Task Repayment(RepaymentRequest request // and check if over-repayment or not customer.CreditTotal -= request.Amount; if (customer.CreditTotal < 0) - throw new FailedPreconditionException($"Over repayment ({customer.CreditTotal})"); + throw new FailedPreconditionException($"Over-repayment ({customer.CreditTotal})"); // Save changes to the customer await _db.Customers.UpdateAsync(customer); diff --git a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/Program.cs b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/Program.cs index b9710856..8142100a 100644 --- a/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/Program.cs +++ b/dotnet/microservice-transactions-sample-with-shared-cluster-with-linq/DataLoader/Program.cs @@ -12,7 +12,7 @@ class Program static async Task Main(string[] args) { var configOption = new Option("--config", "Path to the config file") { IsRequired = true }; - var resetDataOptions = new Option("--reset-data", "Recreate tables and other data"); + var resetDataOptions = new Option("--reset-data", "Re-create tables and other data"); var logLevelOption = new Option(name: "--log-level", description: "Minimum LogLevel", getDefaultValue: () => LogLevel.Information);