From 459b90dcd7fc52e2faae17764f1da7baf7aa6528 Mon Sep 17 00:00:00 2001 From: Michael DiLeo Date: Thu, 6 Aug 2026 15:54:07 -0500 Subject: [PATCH] dotnet - add locking since the simulator seems to run a lot in parallel change the port to match what the simulator uses by default add serilog as part of debugging --- dotnet/SafelyYou/Devices.cs | 38 +++++---- dotnet/SafelyYou/Program.cs | 82 +++++++++++++++++-- .../SafelyYou/Properties/launchSettings.json | 4 +- dotnet/SafelyYou/SafelyYou.csproj | 3 + dotnet/SafelyYou/SafelyYou.http | 7 +- dotnet/SafelyYou/UploadTimes.cs | 12 ++- 6 files changed, 117 insertions(+), 29 deletions(-) diff --git a/dotnet/SafelyYou/Devices.cs b/dotnet/SafelyYou/Devices.cs index b784d54..428dd63 100644 --- a/dotnet/SafelyYou/Devices.cs +++ b/dotnet/SafelyYou/Devices.cs @@ -16,6 +16,7 @@ public sealed class KnownDevices public sealed class DeviceHeartBeatHistory { + private readonly Lock _lock = new(); public enum AddResult { Added, OutOfSequence } @@ -26,36 +27,43 @@ public sealed class DeviceHeartBeatHistory public AddResult Add(DateTimeOffset sentAt) { - // Reject heartbeats that arrive before the latest registered time. - // Gaps are fine: uptime can be derived from first, last, and count. - if (LastHeartBeat is null) + lock (_lock) { - FirstHeartBeat = sentAt; + // Reject heartbeats that arrive before the latest registered time. + // Gaps are fine: uptime can be derived from first, last, and count. + if (LastHeartBeat is null) + { + FirstHeartBeat = sentAt; + LastHeartBeat = sentAt; + NumberOfHeartBeats = 1; + return AddResult.Added; + } + + if (sentAt < LastHeartBeat) + return AddResult.OutOfSequence; + + // note: does not account for rejecting heartbeats that come between the one minute window. LastHeartBeat = sentAt; - NumberOfHeartBeats = 1; + NumberOfHeartBeats++; return AddResult.Added; } - - if (sentAt < LastHeartBeat) - return AddResult.OutOfSequence; - - // note: does not account for rejecting heartbeats that come between the one minute window. - LastHeartBeat = sentAt; - NumberOfHeartBeats++; - return AddResult.Added; } } public sealed class HeartBeatHistories { private readonly ConcurrentDictionary _histories = new(); + private readonly Lock _lock = new (); public DeviceHeartBeatHistory.AddResult Add(DeviceId deviceId, DateTimeOffset sentAt) { if (!_histories.TryGetValue(deviceId, out var history)) { - history = new(); - _histories[deviceId] = history; + lock (_lock) + { + history = new(); + _histories[deviceId] = history; + } } return history.Add(sentAt); diff --git a/dotnet/SafelyYou/Program.cs b/dotnet/SafelyYou/Program.cs index 2a4157b..2877b05 100644 --- a/dotnet/SafelyYou/Program.cs +++ b/dotnet/SafelyYou/Program.cs @@ -1,15 +1,23 @@ using SafelyYou; using SafelyYou.Api; +using Serilog; var builder = WebApplication.CreateBuilder(args); // Add services to the container. // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi + +Log.Logger = new LoggerConfiguration() + .WriteTo.Console() + .CreateLogger(); + +builder.Services.AddSerilog(); builder.Services.AddOpenApi(); builder.Services.AddSingleton(_ => { - var deviceIds = File.ReadAllLines("/devices.csv").Skip(1).Select(id => new DeviceId(id)).ToHashSet(); + var deviceIds = File.ReadAllLines("./devices.csv").Skip(1).Select(id => new DeviceId(id)).ToHashSet(); + Log.Logger.Information("@deviceIds", deviceIds); return new KnownDevices(deviceIds); }); @@ -17,6 +25,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); var app = builder.Build(); +app.UseSerilogRequestLogging(); // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) @@ -24,12 +33,12 @@ if (app.Environment.IsDevelopment()) app.MapOpenApi(); } -app.UseHttpsRedirection(); +//app.UseHttpsRedirection(); // heartbeat history // upload stats -app.MapPost("/devices/{deviceId}/heartbeat", (string deviceId, HeartbeatRequest request, +app.MapPost("api/v1/devices/{deviceId}/heartbeat", (string deviceId, HeartbeatRequest request, KnownDevices knownDevices, HeartBeatHistories histories) => { var device = new DeviceId(deviceId); @@ -49,7 +58,7 @@ app.MapPost("/devices/{deviceId}/heartbeat", (string deviceId, HeartbeatRequest }; }); -app.MapPost("/devices/{deviceId}/stats", (string deviceId, UploadStatsRequest request, KnownDevices knownDevices, UploadTimes uploadTimes) => +app.MapPost("api/v1/devices/{deviceId}/stats", (string deviceId, UploadStatsRequest request, KnownDevices knownDevices, UploadTimes uploadTimes) => { var device = new DeviceId(deviceId); if (!knownDevices.Contains(device)) @@ -62,9 +71,10 @@ app.MapPost("/devices/{deviceId}/stats", (string deviceId, UploadStatsRequest re return Results.NoContent(); // not sure where the 500 would go here beyond doing a try/catch + // note: sent_at isn't used here since the data is a short-cut of aggregation, so it's dropped. }); -app.MapGet("/devices/{deviceId}/stats", (string deviceId, KnownDevices knownDevices, HeartBeatHistories histories, UploadTimes uploadTimes) => +app.MapGet("api/v1/devices/{deviceId}/stats", (string deviceId, KnownDevices knownDevices, HeartBeatHistories histories, UploadTimes uploadTimes) => { var device = new DeviceId(deviceId); if (!knownDevices.Contains(device)) @@ -73,7 +83,7 @@ app.MapGet("/devices/{deviceId}/stats", (string deviceId, KnownDevices knownDevi } var averageUploadTimeNanos = uploadTimes.AverageUploadTimeNanoseconds(device); - var timeSpanText = TimeSpan.FromTicks((long)(averageUploadTimeNanos / 100)).ToString(@"m\ms\s"); // loses accuracy on the Go nanos for this text conversion + var timeSpanText = FormatAsGoDuration(averageUploadTimeNanos); var uptime = histories.Uptime(device); return Results.Ok(new GetDeviceStatsResponse(timeSpanText, uptime)); @@ -84,3 +94,63 @@ app.MapGet("/devices/{deviceId}/stats", (string deviceId, KnownDevices knownDevi app.Run(); +// Matches Go's time.Duration.String() (e.g. "3m17.331667813s"). +static string FormatAsGoDuration(System.Numerics.BigInteger nanos) +{ + if (nanos <= 0) return "0s"; + + var u = (ulong)nanos; + Span buf = stackalloc char[32]; + var w = buf.Length; + + // Fractional seconds from nanoseconds, omitting trailing zeros — same as Go fmtFrac(..., 9). + var frac = u % 1_000_000_000UL; + u /= 1_000_000_000UL; + + buf[--w] = 's'; + if (frac != 0) + { + var print = false; + for (var i = 0; i < 9; i++) + { + var digit = frac % 10; + frac /= 10; + if (digit != 0) print = true; + if (print) buf[--w] = (char)('0' + digit); + } + buf[--w] = '.'; + } + + var secs = u % 60; + u /= 60; + do + { + buf[--w] = (char)('0' + secs % 10); + secs /= 10; + } while (secs > 0); + + if (u > 0) + { + buf[--w] = 'm'; + var mins = u % 60; + u /= 60; + do + { + buf[--w] = (char)('0' + mins % 10); + mins /= 10; + } while (mins > 0); + + if (u > 0) + { + buf[--w] = 'h'; + do + { + buf[--w] = (char)('0' + u % 10); + u /= 10; + } while (u > 0); + } + } + + return new string(buf[w..]); +} + diff --git a/dotnet/SafelyYou/Properties/launchSettings.json b/dotnet/SafelyYou/Properties/launchSettings.json index 2a9616d..9dae2d2 100644 --- a/dotnet/SafelyYou/Properties/launchSettings.json +++ b/dotnet/SafelyYou/Properties/launchSettings.json @@ -5,7 +5,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "http://localhost:5213", + "applicationUrl": "http://localhost:6733", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -14,7 +14,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "https://localhost:7175;http://localhost:5213", + "applicationUrl": "https://localhost:7175;http://localhost:6734", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/dotnet/SafelyYou/SafelyYou.csproj b/dotnet/SafelyYou/SafelyYou.csproj index 4338008..8a15dc5 100644 --- a/dotnet/SafelyYou/SafelyYou.csproj +++ b/dotnet/SafelyYou/SafelyYou.csproj @@ -8,6 +8,9 @@ + + + diff --git a/dotnet/SafelyYou/SafelyYou.http b/dotnet/SafelyYou/SafelyYou.http index 3e71c46..6a483d7 100644 --- a/dotnet/SafelyYou/SafelyYou.http +++ b/dotnet/SafelyYou/SafelyYou.http @@ -1,6 +1,9 @@ -@SafelyYou_HostAddress = http://localhost:5213 +@SafelyYou_HostAddress = http://localhost:6733/api -GET {{SafelyYou_HostAddress}}/weatherforecast/ +POST {{SafelyYou_HostAddress}}/devices/38-4e-73-e0-33-59/heartbeat Accept: application/json +{ + "sent_at": "2026-08-06T16:51:22.861Z" +} ### diff --git a/dotnet/SafelyYou/UploadTimes.cs b/dotnet/SafelyYou/UploadTimes.cs index 04599f8..935980e 100644 --- a/dotnet/SafelyYou/UploadTimes.cs +++ b/dotnet/SafelyYou/UploadTimes.cs @@ -5,16 +5,20 @@ namespace SafelyYou; public sealed class UploadTimes { + private readonly Lock _lock = new(); private readonly ConcurrentDictionary _uploadHistories = new(); public void Add(DeviceId deviceId, BigInteger uploadTimeNanos) { - if (!_uploadHistories.TryGetValue(deviceId, out var history)) + lock (_lock) { - history = (BigInteger.Zero, 0); - } + if (!_uploadHistories.TryGetValue(deviceId, out var history)) + { + history = (BigInteger.Zero, 0); + } - _uploadHistories[deviceId] = (history.TotalUploadTime + uploadTimeNanos, history.UploadCounts + 1); + _uploadHistories[deviceId] = (history.TotalUploadTime + uploadTimeNanos, history.UploadCounts + 1); + } } public BigInteger AverageUploadTimeNanoseconds(DeviceId deviceId)