From 3258fabb9dd5a714a35b31aefdcd1fa745f9dd94 Mon Sep 17 00:00:00 2001 From: haik Date: Sat, 30 Aug 2025 15:39:51 +0400 Subject: [PATCH] dotnet performance boost --- lessons/202/cs-app-aot/AmazonS3Uploader.cs | 60 ++---- lessons/202/cs-app-aot/DbOptions.cs | 11 - lessons/202/cs-app-aot/Device.cs | 13 -- lessons/202/cs-app-aot/Dockerfile | 28 ++- lessons/202/cs-app-aot/Dockerfile.alpine | 24 --- lessons/202/cs-app-aot/Image.cs | 17 -- lessons/202/cs-app-aot/Program.cs | 188 ++++++++++-------- lessons/202/cs-app-aot/S3Options.cs | 14 -- lessons/202/cs-app-aot/StaticData.cs | 17 ++ lessons/202/cs-app-aot/cs-app-aot.csproj | 24 +-- lessons/202/cs-app-aot/cs-app-aot.sln | 24 +++ lessons/202/cs-app/AmazonS3Uploader.cs | 57 ++---- lessons/202/cs-app/Config.cs | 13 -- lessons/202/cs-app/Device.cs | 6 - lessons/202/cs-app/Dockerfile | 17 +- lessons/202/cs-app/Dockerfile.alpine | 23 --- lessons/202/cs-app/Image.cs | 6 - lessons/202/cs-app/Program.cs | 167 +++++++++------- .../202/cs-app/Properties/launchSettings.json | 24 --- lessons/202/cs-app/StaticData.cs | 20 ++ lessons/202/cs-app/cs-app.csproj | 22 +- lessons/202/cs-app/cs-app.sln | 24 +++ 22 files changed, 368 insertions(+), 431 deletions(-) delete mode 100644 lessons/202/cs-app-aot/DbOptions.cs delete mode 100644 lessons/202/cs-app-aot/Device.cs delete mode 100644 lessons/202/cs-app-aot/Dockerfile.alpine delete mode 100644 lessons/202/cs-app-aot/Image.cs delete mode 100644 lessons/202/cs-app-aot/S3Options.cs create mode 100644 lessons/202/cs-app-aot/StaticData.cs create mode 100644 lessons/202/cs-app-aot/cs-app-aot.sln delete mode 100644 lessons/202/cs-app/Config.cs delete mode 100644 lessons/202/cs-app/Device.cs delete mode 100644 lessons/202/cs-app/Dockerfile.alpine delete mode 100644 lessons/202/cs-app/Image.cs create mode 100644 lessons/202/cs-app/StaticData.cs create mode 100644 lessons/202/cs-app/cs-app.sln diff --git a/lessons/202/cs-app-aot/AmazonS3Uploader.cs b/lessons/202/cs-app-aot/AmazonS3Uploader.cs index e9d2db5c3..1139ce20c 100644 --- a/lessons/202/cs-app-aot/AmazonS3Uploader.cs +++ b/lessons/202/cs-app-aot/AmazonS3Uploader.cs @@ -1,54 +1,30 @@ +using Amazon; +using Amazon.Runtime; using Amazon.S3; using Amazon.S3.Model; -using Amazon.Runtime; -internal sealed class AmazonS3Uploader : IDisposable +namespace cs_app_aot; + +public sealed class AmazonS3Uploader { - private readonly AmazonS3Client client; + private readonly AmazonS3Client _client; - public AmazonS3Uploader(string? accessKey, string? secretKey, string? endpoint) + public AmazonS3Uploader(string? accessKey, string? secretKey, string? endpoint, string? region) { - var credentials = new BasicAWSCredentials(accessKey, secretKey); - var clientConfig = new AmazonS3Config() + var creds = new BasicAWSCredentials(accessKey, secretKey); + var cfg = new AmazonS3Config { - ServiceURL = endpoint, - ForcePathStyle = true, + ForcePathStyle = true }; - client = new AmazonS3Client(credentials, clientConfig); - } + if (!string.IsNullOrWhiteSpace(endpoint)) + cfg.ServiceURL = endpoint; + if (!string.IsNullOrWhiteSpace(region)) + cfg.RegionEndpoint = RegionEndpoint.GetBySystemName(region); - public void Dispose() - { - client.Dispose(); + _client = new AmazonS3Client(creds, cfg); } - public async Task Upload(string? bucket, string key, string? path) - { - try - { - PutObjectRequest putRequest = new PutObjectRequest - { - BucketName = bucket, - Key = key, - FilePath = path - }; - - PutObjectResponse response = await client.PutObjectAsync(putRequest); - } - catch (AmazonS3Exception amazonS3Exception) - { - if (amazonS3Exception.ErrorCode != null && - (amazonS3Exception.ErrorCode.Equals("InvalidAccessKeyId") - || - amazonS3Exception.ErrorCode.Equals("InvalidSecurity"))) - { - throw new Exception("Check the provided AWS Credentials."); - } - else - { - throw new Exception("Error occurred: " + amazonS3Exception.Message); - } - } - } -} + public Task Upload(string? bucket, string key, string? path, CancellationToken ct = default) + => _client.PutObjectAsync(new PutObjectRequest { BucketName = bucket, Key = key, FilePath = path }, ct); +} \ No newline at end of file diff --git a/lessons/202/cs-app-aot/DbOptions.cs b/lessons/202/cs-app-aot/DbOptions.cs deleted file mode 100644 index d9cb690a3..000000000 --- a/lessons/202/cs-app-aot/DbOptions.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace cs_app; - -internal sealed class DbOptions -{ - public const string PATH = "Db"; - - public string Host { get; set; } = string.Empty; - public string User { get; set; } = string.Empty; - public string Password { get; set; } = string.Empty; - public string Database { get; set; } = string.Empty; -} diff --git a/lessons/202/cs-app-aot/Device.cs b/lessons/202/cs-app-aot/Device.cs deleted file mode 100644 index a519af600..000000000 --- a/lessons/202/cs-app-aot/Device.cs +++ /dev/null @@ -1,13 +0,0 @@ -internal readonly struct Device -{ - public string Uuid { get; } - public string Mac { get; } - public string Firmware { get; } - - public Device(string uuid, string mac, string firmware) - { - Uuid = uuid; - Mac = mac; - Firmware = firmware; - } -} diff --git a/lessons/202/cs-app-aot/Dockerfile b/lessons/202/cs-app-aot/Dockerfile index a416365b7..981040518 100644 --- a/lessons/202/cs-app-aot/Dockerfile +++ b/lessons/202/cs-app-aot/Dockerfile @@ -1,20 +1,18 @@ -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-jammy AS build +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-noble AS build ARG TARGETARCH -WORKDIR /source - -# copy csproj and restore as distinct layers -COPY *.csproj . +WORKDIR /src +COPY *.csproj ./ RUN dotnet restore -a $TARGETARCH - -# copy everything else and build app COPY . . -RUN dotnet publish -a $TARGETARCH --no-restore -o /app /p:AOT=true /p:UseAppHost=false -RUN rm /app/*.dbg /app/*.Development.json - +# ensure assembly name (adjust if your project file name differs) +RUN dotnet publish -c Release -a $TARGETARCH -r linux-x64 -o /app \ + /p:PublishAot=true /p:StripSymbols=true /p:IlcGenerateStackTraceData=false \ + /p:AssemblyName=cs-app-aot -# final stage/image -FROM mcr.microsoft.com/dotnet/aspnet:8.0-jammy-chiseled -EXPOSE 8080 +FROM mcr.microsoft.com/dotnet/runtime-deps:9.0-noble-chiseled +ENV ASPNETCORE_URLS=http://+:8080 \ + DOTNET_EnableDiagnostics=0 WORKDIR /app -COPY --from=build /app . -ENTRYPOINT ["./cs-app"] \ No newline at end of file +COPY --from=build /app ./ +EXPOSE 8080 +ENTRYPOINT ["./cs-app-aot"] \ No newline at end of file diff --git a/lessons/202/cs-app-aot/Dockerfile.alpine b/lessons/202/cs-app-aot/Dockerfile.alpine deleted file mode 100644 index 3a24f4066..000000000 --- a/lessons/202/cs-app-aot/Dockerfile.alpine +++ /dev/null @@ -1,24 +0,0 @@ -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build -ARG TARGETARCH -WORKDIR /source - -# copy csproj and restore as distinct layers -COPY *.csproj . -RUN dotnet restore -a $TARGETARCH - -# copy and publish app and libraries -COPY . . -RUN dotnet publish --no-restore -a $TARGETARCH -o /app /p:AOT=true /p:UseAppHost=false -RUN rm /app/*.dbg /app/*.Development.json - - -# Enable globalization and time zones: -# https://github.com/dotnet/dotnet-docker/blob/main/samples/enable-globalization.md -# final stage/image -FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine -EXPOSE 8080 -WORKDIR /app -COPY --from=build /app . -# Uncomment to enable non-root user -# USER $APP_UID -ENTRYPOINT ["./aspnetapp"] diff --git a/lessons/202/cs-app-aot/Image.cs b/lessons/202/cs-app-aot/Image.cs deleted file mode 100644 index 9d59b23bf..000000000 --- a/lessons/202/cs-app-aot/Image.cs +++ /dev/null @@ -1,17 +0,0 @@ -internal readonly struct Image -{ - public string ObjKey { get; } - public Guid ImageUuid { get; } - public DateTime CreatedAt { get; } - - public Image() - { - ImageUuid = Guid.NewGuid(); - CreatedAt = DateTime.Now; - } - - public Image(string key) : this() - { - ObjKey = key; - } -} \ No newline at end of file diff --git a/lessons/202/cs-app-aot/Program.cs b/lessons/202/cs-app-aot/Program.cs index 89d08301e..691cae349 100644 --- a/lessons/202/cs-app-aot/Program.cs +++ b/lessons/202/cs-app-aot/Program.cs @@ -1,109 +1,131 @@ -using Prometheus; using System.Diagnostics; -using Npgsql; -using cs_app; using System.Text.Json.Serialization; +using cs_app_aot; +using Npgsql; +using NpgsqlTypes; +using Prometheus; +using Metrics = Prometheus.Metrics; -// Initialize the Web App var builder = WebApplication.CreateSlimBuilder(args); -// Configure JSON source generatino for AOT support -builder.Services.ConfigureHttpJsonOptions(options => +// hard-off logging for benchmarks (no sinks) +builder.Logging.ClearProviders(); +builder.Logging.SetMinimumLevel(LogLevel.None); + +// JSON source-gen for AOT; harmless on JIT +builder.Services.ConfigureHttpJsonOptions(o => { - options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default); + o.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default); }); -// Load configuration -var dbOptions = new DbOptions(); -builder.Configuration.GetSection(DbOptions.PATH).Bind(dbOptions); - -var s3Options = new S3Options(); -builder.Configuration.GetSection(S3Options.PATH).Bind(s3Options); - -// Establish S3 session. -using var amazonS3 = new AmazonS3Uploader(s3Options.User, s3Options.Secret, s3Options.Endpoint); - -// Create Postgre connection string -var connString = $"Host={dbOptions.Host};Username={dbOptions.User};Password={dbOptions.Password};Database={dbOptions.Database}"; +// config +var cfg = new AppConfig(builder.Configuration); +builder.Services.AddSingleton(cfg); -Console.WriteLine(connString); +// AWS S3 +builder.Services.AddSingleton(new AmazonS3Uploader(cfg.User, cfg.Secret, cfg.S3Endpoint, cfg.Region)); -// Establish Postgres connection -await using var dataSource = new NpgsqlSlimDataSourceBuilder(connString).Build(); - -// Counter variable is used to increment image id -var counter = 0; +// Npgsql (AOT/trimming friendly) +var csb = new NpgsqlConnectionStringBuilder +{ + Host = cfg.DbHost, + Username = cfg.DbUser, + Password = cfg.DbPassword, + Database = cfg.DbDatabase, + Pooling = true, + MaxPoolSize = 256, + MinPoolSize = 16, + NoResetOnClose = true, + AutoPrepareMinUsages = 2, + MaxAutoPrepare = 32, + Multiplexing = true +}; +builder.Services.AddSingleton(_ => new NpgsqlSlimDataSourceBuilder(csb.ConnectionString).Build()); var app = builder.Build(); -// Create Summary Prometheus metric to measure latency of the requests. -var summary = Metrics.CreateSummary("myapp_request_duration_seconds", "Duration of the request.", new SummaryConfiguration -{ - LabelNames = ["op"], - Objectives = [new QuantileEpsilonPair(0.9, 0.01), new QuantileEpsilonPair(0.99, 0.001)] -}); +// Prometheus Summary (prebind labels to skip tiny allocs) +var summary = Metrics.CreateSummary("myapp_request_duration_seconds", "Duration of the request.", + new SummaryConfiguration + { + LabelNames = ["op"], + Objectives = [new QuantileEpsilonPair(0.9, 0.01), new QuantileEpsilonPair(0.99, 0.001)] + }); +var s3Dur = summary.WithLabels("s3"); +var dbDur = summary.WithLabels("db"); -// Enable the /metrics page to export Prometheus metrics. +// endpoints app.MapMetrics(); +app.MapGet("/healthz", () => Results.Ok()); +app.MapGet("/api/devices", () => Results.Ok(StaticData.Devices)); -// Create endpoint that returns the status of the application. -// Placeholder for the health check -app.MapGet("/healthz", () => Results.Ok("OK")); +app.MapGet("/api/images", + async (HttpContext http, + AmazonS3Uploader s3, + NpgsqlDataSource dataSource) => + { + var id = Interlocked.Increment(ref StaticData.Counter) - 1; + var image = new Image($"cs-thumbnail-{id}.png"); + + // S3 + var t0 = Stopwatch.GetTimestamp(); + await s3.Upload(cfg.S3Bucket, image.ObjKey, cfg.S3ImgPath, http.RequestAborted); + s3Dur.Observe(Stopwatch.GetElapsedTime(t0).TotalSeconds); + + // DB + var t1 = Stopwatch.GetTimestamp(); + await using (var cmd = dataSource.CreateCommand(StaticData.ImageInsertSql)) + { + cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Uuid, Value = image.ImageUuid }); + cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Text, Value = image.ObjKey }); + cmd.Parameters.Add(new NpgsqlParameter + { NpgsqlDbType = NpgsqlDbType.TimestampTz, Value = image.CreatedAt }); + await cmd.ExecuteNonQueryAsync(http.RequestAborted); + } + + dbDur.Observe(Stopwatch.GetElapsedTime(t1).TotalSeconds); + return Results.Ok(); + }); -// Create endpoint that returns a list of connected devices. -app.MapGet("/api/devices", () => -{ - Device[] devices = [ - new("b0e42fe7-31a5-4894-a441-007e5256afea", "5F-33-CC-1F-43-82", "2.1.6"), - new("0c3242f5-ae1f-4e0c-a31b-5ec93825b3e7", "EF-2B-C4-F5-D6-34", "2.1.5"), - new("b16d0b53-14f1-4c11-8e29-b9fcef167c26", "62-46-13-B7-B3-A1", "3.0.0"), - new("51bb1937-e005-4327-a3bd-9f32dcf00db8", "96-A8-DE-5B-77-14", "1.0.1"), - new("e0a1d085-dce5-48db-a794-35640113fa67", "7E-3B-62-A6-09-12", "3.5.6") - ]; - - return Results.Ok(devices); -}); +app.Run(); -// Create endpoint that uoloades image to S3 and writes metadate to Postgres -app.MapGet("/api/images", async () => -{ - // Generate a new image. - var image = new Image($"cs-thumbnail-{counter}.png"); +// ---- app types ---- +[JsonSerializable(typeof(Device[]))] +internal partial class AppJsonSerializerContext : JsonSerializerContext; - // Get the current time to record the duration of the S3 request. - var s3StartTime = Stopwatch.GetTimestamp(); +public sealed class AppConfig(IConfiguration config) +{ + public string? DbDatabase = config.GetValue("Db:database"); + public string? DbHost = config.GetValue("Db:host"); + public string? DbPassword = config.GetValue("Db:password"); + public string? DbUser = config.GetValue("Db:user"); - // Upload the image to S3. - await amazonS3.Upload(s3Options.Bucket, image.ObjKey, s3Options.ImgPath); + public string? Region = config.GetValue("S3:region"); + public string? S3Bucket = config.GetValue("S3:bucket"); + public string? S3Endpoint = config.GetValue("S3:endpoint"); + public string? S3ImgPath = config.GetValue("S3:imgPath"); + public string? Secret = config.GetValue("S3:secret"); + public string? User = config.GetValue("S3:user"); +} - // Record the duration of the request to S3. - summary.WithLabels(["s3"]).Observe(Stopwatch.GetElapsedTime(s3StartTime).TotalSeconds); - // Get the current time to record the duration of the Database request. - var dbStartTime = Stopwatch.GetTimestamp(); +public sealed class Device +{ + public required string Uuid { get; init; } + public required string Mac { get; init; } + public required string Firmware { get; init; } +} - // Prepare the database query to insert a record. - const string sqlQuery = "INSERT INTO cs_image VALUES ($1, $2, $3)"; +public readonly struct Image +{ + public string ObjKey { get; } + public Guid ImageUuid { get; } + public DateTime CreatedAt { get; } - // Execute the query to create a new image record. - await using (var cmd = dataSource.CreateCommand(sqlQuery)) + public Image(string key) { - cmd.Parameters.AddWithValue(image.ImageUuid); - cmd.Parameters.AddWithValue(image.ObjKey); - cmd.Parameters.AddWithValue(image.CreatedAt); - await cmd.ExecuteNonQueryAsync(); + ObjKey = key; + ImageUuid = Guid.NewGuid(); + CreatedAt = DateTime.UtcNow; } - - // Record the duration of the insert query. - summary.WithLabels(["db"]).Observe(Stopwatch.GetElapsedTime(dbStartTime).TotalSeconds); - - // Increment the counter. - counter++; - - return Results.Ok("Saved!"); -}); - -app.Run(); - -[JsonSerializable(typeof(Device[]))] -internal partial class AppJsonSerializerContext : JsonSerializerContext; \ No newline at end of file +} \ No newline at end of file diff --git a/lessons/202/cs-app-aot/S3Options.cs b/lessons/202/cs-app-aot/S3Options.cs deleted file mode 100644 index 60f675553..000000000 --- a/lessons/202/cs-app-aot/S3Options.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace cs_app; - -internal sealed class S3Options -{ - public const string PATH = "S3"; - - public string Region { get; set; } = string.Empty; - public string Bucket { get; set; } = string.Empty; - public string Endpoint { get; set; } = string.Empty; - public bool PathStyle { get; set; } - public string User { get; set; } = string.Empty; - public string Secret { get; set; } = string.Empty; - public string ImgPath { get; set; } = string.Empty; -} diff --git a/lessons/202/cs-app-aot/StaticData.cs b/lessons/202/cs-app-aot/StaticData.cs new file mode 100644 index 000000000..56c91a4cc --- /dev/null +++ b/lessons/202/cs-app-aot/StaticData.cs @@ -0,0 +1,17 @@ +namespace cs_app_aot; + +public static class StaticData +{ + public const string ImageInsertSql = "INSERT INTO cs_image VALUES ($1, $2, $3)"; + + public static int Counter = 0; + + public static readonly Device[] Devices = + [ + new() { Uuid = "b0e42fe7-31a5-4894-a441-007e5256afea", Mac = "5F-33-CC-1F-43-82", Firmware = "2.1.6" }, + new() { Uuid = "0c3242f5-ae1f-4e0c-a31b-5ec93825b3e7", Mac = "EF-2B-C4-F5-D6-34", Firmware = "2.1.5" }, + new() { Uuid = "b16d0b53-14f1-4c11-8e29-b9fcef167c26", Mac = "62-46-13-B7-B3-A1", Firmware = "3.0.0" }, + new() { Uuid = "51bb1937-e005-4327-a3bd-9f32dcf00db8", Mac = "96-A8-DE-5B-77-14", Firmware = "1.0.1" }, + new() { Uuid = "e0a1d085-dce5-48db-a794-35640113fa67", Mac = "7E-3B-62-A6-09-12", Firmware = "3.5.6" } + ]; +} \ No newline at end of file diff --git a/lessons/202/cs-app-aot/cs-app-aot.csproj b/lessons/202/cs-app-aot/cs-app-aot.csproj index 1cf7eeea5..1f11a6362 100644 --- a/lessons/202/cs-app-aot/cs-app-aot.csproj +++ b/lessons/202/cs-app-aot/cs-app-aot.csproj @@ -1,17 +1,17 @@ - - net8.0 - enable - enable - cs_app - true - + + net9.0 + enable + enable + cs_app_aot + true + - - - - - + + + + + diff --git a/lessons/202/cs-app-aot/cs-app-aot.sln b/lessons/202/cs-app-aot/cs-app-aot.sln new file mode 100644 index 000000000..1a8caeb44 --- /dev/null +++ b/lessons/202/cs-app-aot/cs-app-aot.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cs-app-aot", "cs-app-aot.csproj", "{B6D6BF87-FBDF-D2DA-F801-8720BFA020C6}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B6D6BF87-FBDF-D2DA-F801-8720BFA020C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B6D6BF87-FBDF-D2DA-F801-8720BFA020C6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B6D6BF87-FBDF-D2DA-F801-8720BFA020C6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B6D6BF87-FBDF-D2DA-F801-8720BFA020C6}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {0CBA062B-3E0D-492F-B095-77B63923E4CD} + EndGlobalSection +EndGlobal diff --git a/lessons/202/cs-app/AmazonS3Uploader.cs b/lessons/202/cs-app/AmazonS3Uploader.cs index 0d5cd64c2..8c45a2185 100644 --- a/lessons/202/cs-app/AmazonS3Uploader.cs +++ b/lessons/202/cs-app/AmazonS3Uploader.cs @@ -1,49 +1,30 @@ +using Amazon; +using Amazon.Runtime; using Amazon.S3; using Amazon.S3.Model; -using Amazon.Runtime; -public class AmazonS3Uploader +namespace cs_app; + +public sealed class AmazonS3Uploader { - private readonly AmazonS3Client client; + private readonly AmazonS3Client _client; - public AmazonS3Uploader(string? accessKey, string? secretKey, string? endpoint) + public AmazonS3Uploader(string? accessKey, string? secretKey, string? endpoint, string? region) { - var credentials = new BasicAWSCredentials(accessKey, secretKey); - var clientConfig = new AmazonS3Config() + var creds = new BasicAWSCredentials(accessKey, secretKey); + var cfg = new AmazonS3Config { - ServiceURL = endpoint, - ForcePathStyle = true, + ForcePathStyle = true }; - client = new AmazonS3Client(credentials, clientConfig); - } + if (!string.IsNullOrWhiteSpace(endpoint)) + cfg.ServiceURL = endpoint; + if (!string.IsNullOrWhiteSpace(region)) + cfg.RegionEndpoint = RegionEndpoint.GetBySystemName(region); - public async Task Upload(string? bucket, string key, string? path) - { - try - { - PutObjectRequest putRequest = new PutObjectRequest - { - BucketName = bucket, - Key = key, - FilePath = path - }; - - PutObjectResponse response = await client.PutObjectAsync(putRequest); - } - catch (AmazonS3Exception amazonS3Exception) - { - if (amazonS3Exception.ErrorCode != null && - (amazonS3Exception.ErrorCode.Equals("InvalidAccessKeyId") - || - amazonS3Exception.ErrorCode.Equals("InvalidSecurity"))) - { - throw new Exception("Check the provided AWS Credentials."); - } - else - { - throw new Exception("Error occurred: " + amazonS3Exception.Message); - } - } + _client = new AmazonS3Client(creds, cfg); } -} + + public Task Upload(string? bucket, string key, string? path, CancellationToken ct = default) + => _client.PutObjectAsync(new PutObjectRequest { BucketName = bucket, Key = key, FilePath = path }, ct); +} \ No newline at end of file diff --git a/lessons/202/cs-app/Config.cs b/lessons/202/cs-app/Config.cs deleted file mode 100644 index e6db721e0..000000000 --- a/lessons/202/cs-app/Config.cs +++ /dev/null @@ -1,13 +0,0 @@ -public class Config(IConfiguration config) -{ - public string? Region = config.GetValue("S3:region"); - public string? User = config.GetValue("S3:user"); - public string? Secret = config.GetValue("S3:secret"); - public string? S3Endpoint = config.GetValue("S3:endpoint"); - public string? S3ImgPath = config.GetValue("S3:imgPath"); - public string? S3Bucket = config.GetValue("S3:bucket"); - public string? DbHost = config.GetValue("Db:host"); - public string? DbUser = config.GetValue("Db:user"); - public string? DbPassword = config.GetValue("Db:password"); - public string? DbDatabase = config.GetValue("Db:database"); -} diff --git a/lessons/202/cs-app/Device.cs b/lessons/202/cs-app/Device.cs deleted file mode 100644 index e5720dac3..000000000 --- a/lessons/202/cs-app/Device.cs +++ /dev/null @@ -1,6 +0,0 @@ -public class Device -{ - public required string Uuid { get; set; } - public required string Mac { get; set; } - public required string Firmware { get; set; } -} diff --git a/lessons/202/cs-app/Dockerfile b/lessons/202/cs-app/Dockerfile index 29989b6a2..ea235aa6d 100644 --- a/lessons/202/cs-app/Dockerfile +++ b/lessons/202/cs-app/Dockerfile @@ -1,19 +1,20 @@ -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-jammy AS build +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-noble AS build ARG TARGETARCH WORKDIR /source -# copy csproj and restore as distinct layers COPY *.csproj . RUN dotnet restore -a $TARGETARCH -# copy everything else and build app COPY . . -RUN dotnet publish -a $TARGETARCH --no-restore -o /app +RUN dotnet publish -c Release -a $TARGETARCH --no-restore -o /app /p:PublishReadyToRun=true - -# final stage/image -FROM mcr.microsoft.com/dotnet/aspnet:8.0-jammy-chiseled +# Runtime stage (chiseled = smaller, faster startup) +FROM mcr.microsoft.com/dotnet/aspnet:9.0-noble-chiseled +ENV ASPNETCORE_URLS=http://+:8080 \ + DOTNET_EnableDiagnostics=0 EXPOSE 8080 WORKDIR /app -COPY --from=build /app . +COPY --from=build /app ./ + +USER $APP_UID ENTRYPOINT ["./cs-app"] \ No newline at end of file diff --git a/lessons/202/cs-app/Dockerfile.alpine b/lessons/202/cs-app/Dockerfile.alpine deleted file mode 100644 index c0418d3b7..000000000 --- a/lessons/202/cs-app/Dockerfile.alpine +++ /dev/null @@ -1,23 +0,0 @@ -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build -ARG TARGETARCH -WORKDIR /source - -# copy csproj and restore as distinct layers -COPY *.csproj . -RUN dotnet restore -a $TARGETARCH - -# copy and publish app and libraries -COPY . . -RUN dotnet publish --no-restore -a $TARGETARCH -o /app - - -# Enable globalization and time zones: -# https://github.com/dotnet/dotnet-docker/blob/main/samples/enable-globalization.md -# final stage/image -FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine -EXPOSE 8080 -WORKDIR /app -COPY --from=build /app . -# Uncomment to enable non-root user -# USER $APP_UID -ENTRYPOINT ["./aspnetapp"] diff --git a/lessons/202/cs-app/Image.cs b/lessons/202/cs-app/Image.cs deleted file mode 100644 index 1969e4662..000000000 --- a/lessons/202/cs-app/Image.cs +++ /dev/null @@ -1,6 +0,0 @@ -public class Image(string key) -{ - public Guid ImageUuid = Guid.NewGuid(); - public DateTime CreatedAt = DateTime.Now; - public string ObjKey = key; -} \ No newline at end of file diff --git a/lessons/202/cs-app/Program.cs b/lessons/202/cs-app/Program.cs index 68fa9c5b5..0a04d8705 100644 --- a/lessons/202/cs-app/Program.cs +++ b/lessons/202/cs-app/Program.cs @@ -1,97 +1,122 @@ -using Prometheus; using System.Diagnostics; +using cs_app; using Npgsql; +using NpgsqlTypes; +using Prometheus; +using Metrics = Prometheus.Metrics; -// Initialize the Web App var builder = WebApplication.CreateBuilder(args); -// Load app config from file. -var config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); -var appConfig = new Config(config); - -// Establish S3 session. -var amazonS3 = new AmazonS3Uploader(appConfig.User, appConfig.Secret, appConfig.S3Endpoint); - -// Create Postgre connection string -var connString = string.Format("Host={0};Username={1};Password={2};Database={3}", appConfig.DbHost, appConfig.DbUser, appConfig.DbPassword, appConfig.DbDatabase); +// --- Logging off for benchmark; in prod emit to async file/sink --- +builder.Logging.ClearProviders(); +builder.Logging.SetMinimumLevel(LogLevel.None); -Console.WriteLine(connString); +// Config (same keys) +var appConfig = new AppConfig(builder.Configuration); -// Establish Postgres connection -await using var dataSource = NpgsqlDataSource.Create(connString); +// S3 +builder.Services.AddSingleton(new AmazonS3Uploader(appConfig.User, appConfig.Secret, appConfig.S3Endpoint, + appConfig.Region)); -// Conuter variable is used to increment image id -var counter = 0; +// Npgsql pool tuned for hot path +var csb = new NpgsqlConnectionStringBuilder +{ + Host = appConfig.DbHost, + Username = appConfig.DbUser, + Password = appConfig.DbPassword, + Database = appConfig.DbDatabase, + Pooling = true, + MaxPoolSize = 256, + MinPoolSize = 16, + NoResetOnClose = true, + AutoPrepareMinUsages = 2, + MaxAutoPrepare = 32, + Multiplexing = true +}; + +// Register data source as app-lifetime singleton +builder.Services.AddSingleton(_ => new NpgsqlSlimDataSourceBuilder(csb.ConnectionString).Build()); var app = builder.Build(); // Create Summary Prometheus metric to measure latency of the requests. -var summary = Metrics.CreateSummary("myapp_request_duration_seconds", "Duration of the request.", new SummaryConfiguration -{ - LabelNames = ["op"], - Objectives = [new QuantileEpsilonPair(0.9, 0.01), new QuantileEpsilonPair(0.99, 0.001)] -}); +var summary = Metrics.CreateSummary("myapp_request_duration_seconds", "Duration of the request.", + new SummaryConfiguration + { + LabelNames = ["op"], + Objectives = [new QuantileEpsilonPair(0.9, 0.01), new QuantileEpsilonPair(0.99, 0.001)] + }); +var s3Dur = summary.WithLabels("s3"); +var dbDur = summary.WithLabels("db"); -// Enable the /metrics page to export Prometheus metrics. app.MapMetrics(); - -// Create endpoint that returns the status of the application. -// Placeholder for the health check -app.MapGet("/healthz", () => "OK"); +app.MapGet("/healthz", () => Results.Ok()); // Create endpoint that returns a list of connected devices. -app.MapGet("/api/devices", () => -{ - Device[] devices = [ - new() { Uuid = "b0e42fe7-31a5-4894-a441-007e5256afea", Mac = "5F-33-CC-1F-43-82", Firmware = "2.1.6" }, - new() { Uuid = "0c3242f5-ae1f-4e0c-a31b-5ec93825b3e7", Mac = "EF-2B-C4-F5-D6-34", Firmware = "2.1.5" }, - new() { Uuid = "b16d0b53-14f1-4c11-8e29-b9fcef167c26", Mac = "62-46-13-B7-B3-A1", Firmware = "3.0.0" }, - new() { Uuid = "51bb1937-e005-4327-a3bd-9f32dcf00db8", Mac = "96-A8-DE-5B-77-14", Firmware = "1.0.1" }, - new() { Uuid = "e0a1d085-dce5-48db-a794-35640113fa67", Mac = "7E-3B-62-A6-09-12", Firmware = "3.5.6" } - ]; - - return devices; -}); +app.MapGet("/api/devices", () => Results.Ok(StaticData.Devices)); -// Create endpoint that uoloades image to S3 and writes metadate to Postgres -app.MapGet("/api/images", async () => +// Create endpoint that uploads image to S3 and writes metadata to Postgres +app.MapGet("/api/images", async (HttpContext httpContext, + AmazonS3Uploader amazonS3, + NpgsqlDataSource dataSource) => { - // Generate a new image. - var image = new Image(string.Format("cs-thumbnail-{0}.png", counter)); - - // Get the current time to record the duration of the S3 request. - var s3Stopwatch = Stopwatch.StartNew(); - - // Upload the image to S3. - await amazonS3.Upload(appConfig.S3Bucket, image.ObjKey, appConfig.S3ImgPath); + var id = Interlocked.Increment(ref StaticData.Counter) - 1; // start at 0 + var image = new Image($"cs-thumbnail-{id}.png"); - // Record the duration of the request to S3. - s3Stopwatch.Stop(); - summary.WithLabels(["s3"]).Observe(s3Stopwatch.Elapsed.TotalSeconds); + // S3 + var t0 = Stopwatch.GetTimestamp(); + await amazonS3.Upload(appConfig.S3Bucket, image.ObjKey, appConfig.S3ImgPath, httpContext.RequestAborted); + s3Dur.Observe(Stopwatch.GetElapsedTime(t0).TotalSeconds); - // Get the current time to record the duration of the Database request. - var dBStopwatch = Stopwatch.StartNew(); - - // Prepare the database query to insert a record. - var sqlQuery = string.Format("INSERT INTO {0} VALUES ($1, $2, $3)", "cs_image"); - - // Execute the query to create a new image record. - await using (var cmd = dataSource.CreateCommand(sqlQuery)) + // DB + var t1 = Stopwatch.GetTimestamp(); + await using (var cmd = dataSource.CreateCommand(StaticData.ImageInsertSql)) { - cmd.Parameters.AddWithValue(image.ImageUuid); - cmd.Parameters.AddWithValue(image.ObjKey); - cmd.Parameters.AddWithValue(image.CreatedAt); - await cmd.ExecuteNonQueryAsync(); + cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Uuid, Value = image.ImageUuid }); + cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Text, Value = image.ObjKey }); + cmd.Parameters.Add(new NpgsqlParameter + { NpgsqlDbType = NpgsqlDbType.TimestampTz, Value = image.CreatedAt }); + await cmd.ExecuteNonQueryAsync(httpContext.RequestAborted); } + dbDur.Observe(Stopwatch.GetElapsedTime(t1).TotalSeconds); - // Record the duration of the insert query. - dBStopwatch.Stop(); - summary.WithLabels(["db"]).Observe(dBStopwatch.Elapsed.TotalSeconds); - - // Icrement counter. - counter++; - - return "Saved!"; + return Results.Ok(); }); app.Run(); + +public sealed class AppConfig(IConfiguration config) +{ + public string? DbDatabase = config.GetValue("Db:database"); + public string? DbHost = config.GetValue("Db:host"); + public string? DbPassword = config.GetValue("Db:password"); + public string? DbUser = config.GetValue("Db:user"); + public string? Region = config.GetValue("S3:region"); + public string? S3Bucket = config.GetValue("S3:bucket"); + public string? S3Endpoint = config.GetValue("S3:endpoint"); + public string? S3ImgPath = config.GetValue("S3:imgPath"); + public string? Secret = config.GetValue("S3:secret"); + public string? User = config.GetValue("S3:user"); +} + + +public sealed class Device +{ + public required string Uuid { get; init; } + public required string Mac { get; init; } + public required string Firmware { get; init; } +} + +public readonly struct Image +{ + public string ObjKey { get; } + public Guid ImageUuid { get; } + public DateTime CreatedAt { get; } + + public Image(string key) + { + ObjKey = key; + ImageUuid = Guid.NewGuid(); + CreatedAt = DateTime.UtcNow; + } +} \ No newline at end of file diff --git a/lessons/202/cs-app/Properties/launchSettings.json b/lessons/202/cs-app/Properties/launchSettings.json index e5580d538..a6efeb564 100644 --- a/lessons/202/cs-app/Properties/launchSettings.json +++ b/lessons/202/cs-app/Properties/launchSettings.json @@ -1,23 +1,6 @@ { "$schema": "http://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:17988", - "sslPort": 44309 - } - }, "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:5105", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, "https": { "commandName": "Project", "dotnetRunMessages": true, @@ -26,13 +9,6 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } } } } diff --git a/lessons/202/cs-app/StaticData.cs b/lessons/202/cs-app/StaticData.cs new file mode 100644 index 000000000..c1917852a --- /dev/null +++ b/lessons/202/cs-app/StaticData.cs @@ -0,0 +1,20 @@ +namespace cs_app; + +public static class StaticData +{ + // Pre-allocate SQL string; keep same table & columns order as original + public const string ImageInsertSql = "INSERT INTO cs_image VALUES ($1, $2, $3)"; + + //make thread-safe + public static int Counter = 0; + + //Init once + public static readonly Device[] Devices = + [ + new() { Uuid = "b0e42fe7-31a5-4894-a441-007e5256afea", Mac = "5F-33-CC-1F-43-82", Firmware = "2.1.6" }, + new() { Uuid = "0c3242f5-ae1f-4e0c-a31b-5ec93825b3e7", Mac = "EF-2B-C4-F5-D6-34", Firmware = "2.1.5" }, + new() { Uuid = "b16d0b53-14f1-4c11-8e29-b9fcef167c26", Mac = "62-46-13-B7-B3-A1", Firmware = "3.0.0" }, + new() { Uuid = "51bb1937-e005-4327-a3bd-9f32dcf00db8", Mac = "96-A8-DE-5B-77-14", Firmware = "1.0.1" }, + new() { Uuid = "e0a1d085-dce5-48db-a794-35640113fa67", Mac = "7E-3B-62-A6-09-12", Firmware = "3.5.6" } + ]; +} \ No newline at end of file diff --git a/lessons/202/cs-app/cs-app.csproj b/lessons/202/cs-app/cs-app.csproj index b6b7adee7..f29f2da5c 100644 --- a/lessons/202/cs-app/cs-app.csproj +++ b/lessons/202/cs-app/cs-app.csproj @@ -1,16 +1,16 @@ - - net8.0 - enable - enable - cs_app - + + net9.0 + enable + enable + cs_app + - - - - - + + + + + diff --git a/lessons/202/cs-app/cs-app.sln b/lessons/202/cs-app/cs-app.sln new file mode 100644 index 000000000..7ee421869 --- /dev/null +++ b/lessons/202/cs-app/cs-app.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cs-app", "cs-app.csproj", "{05A4C554-28E9-7A2A-D927-4099EB20E1F9}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {05A4C554-28E9-7A2A-D927-4099EB20E1F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {05A4C554-28E9-7A2A-D927-4099EB20E1F9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {05A4C554-28E9-7A2A-D927-4099EB20E1F9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {05A4C554-28E9-7A2A-D927-4099EB20E1F9}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {731C59EE-6D6C-4530-95DA-241784F94972} + EndGlobalSection +EndGlobal