Compare commits

..

13 Commits

36 changed files with 1708 additions and 2 deletions
+8
View File
@@ -0,0 +1,8 @@
.git
.idea
.DS_Store
**/bin/
**/obj/
output/
device-simulator-mac-arm64
*.md
+27
View File
@@ -0,0 +1,27 @@
# --- Build the Go API ---
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go/go.mod ./
COPY go/ ./
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server
# --- API runtime ---
FROM alpine:3.20 AS api
RUN apk add --no-cache curl
WORKDIR /app
COPY --from=build /out/server /app/server
COPY --from=build /src/devices.csv /app/devices.csv
EXPOSE 6733
HEALTHCHECK --interval=2s --timeout=2s --start-period=5s --retries=15 \
CMD curl -sf http://127.0.0.1:6733/healthz || exit 1
ENTRYPOINT ["/app/server"]
# --- Device simulator (writes results.txt into /output) ---
FROM alpine:3.20 AS simulator
COPY device-simulator-linux-arm64 /usr/local/bin/device-simulator
RUN chmod +x /usr/local/bin/device-simulator \
&& mkdir -p /output \
&& chmod 0777 /output
WORKDIR /output
VOLUME ["/output"]
ENTRYPOINT ["device-simulator"]
+30
View File
@@ -0,0 +1,30 @@
# --- Build the .NET API ---
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY dotnet/SafelyYou/SafelyYou.csproj dotnet/SafelyYou/
RUN dotnet restore dotnet/SafelyYou/SafelyYou.csproj
COPY dotnet/SafelyYou/ dotnet/SafelyYou/
RUN dotnet publish dotnet/SafelyYou/SafelyYou.csproj -c Release -o /app/publish --no-restore
# --- API runtime ---
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS api
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /app/publish .
ENV ASPNETCORE_URLS=http://+:6733
EXPOSE 6733
HEALTHCHECK --interval=2s --timeout=2s --start-period=5s --retries=15 \
CMD curl -s -o /dev/null http://127.0.0.1:6733/ || exit 1
ENTRYPOINT ["dotnet", "SafelyYou.dll"]
# --- Device simulator (writes results.txt into /output) ---
FROM alpine:3.20 AS simulator
COPY device-simulator-linux-arm64 /usr/local/bin/device-simulator
RUN chmod +x /usr/local/bin/device-simulator \
&& mkdir -p /output \
&& chmod 0777 /output
WORKDIR /output
VOLUME ["/output"]
ENTRYPOINT ["device-simulator"]
+56 -2
View File
@@ -1,3 +1,57 @@
# SafelyYouCodingChallenge
# SafelyYouCodingChallenge - Michael DiLeo
Challenge implementation for Safely You
I did this challenge using my primary lanuage of C# to spike the implementation and then migrated to Go with AI, but asked the AI to guide me in better understanding the differences between the c# and Go code.
For the C# code, I used some AI, which I will outline below, though it was much more limited.
The approach that I took after scaffolding the project and creating DTO's from the openapi.json was thinking how to structure the domain.
I created strong value types for things like `DeviceID`, which in C# are free for single values.
I started with the device heartbeats and I initially took an approach that I removed, which was to use a `PriorityQueue` to track the history.
PriorityQueue is a fairly high performance collection that can dequeue based on a priority, which I set to the inverse of the timestamp, so later entries came first.
However, I did this thinking of a "wrench" situation and "what if the timestamps come out of order?" Eventually I removed that altogether and replaced it with dropping something that's earlier than the last known timestamp.
I also added locking on mutating the inner collections, where a `ConcurrentDictionary<DeviceId, DeviceHeartBeatHistory>` handled synchronization on creating a device and the `DeviceHeartBeatHistory` locks on mutation. I chose this path because the challenge said to keep things simple and not have a database. So, everything here is in-memory.
Initially with the heartbeat history, I was tracking timestamp ranges and if a new timestamp came within the 1 minute + {some jitter offset}, it would get merged as a block of "start to end uptimes" and then an interruption would a new entry with a new start time. That is now removed, so the current code doesn't track things like if a heart beat comes too early, ie 30 seconds. The current code strongly assumes that all devices will report at 1 minute, so if a device restarts and sends a healthcheck at less than a minute, it will throw off the totals.
Where I used AI was to help fix an expression compiler error in the PriorityQueue implementation, but when I decided on the new approach, I manually added the new fields and then asked the AI to migrate the logic.
One other thing that I did was that I tracked timestamps using `BigInteger` instead of `long`, since Go uses nanoseconds and I wasn't sure if there'd be any gotchas. This was likely not necessary and `BigInteger` is notably slower than using `long`. But this is a habit from doing coding challenges where you just assume they're going to give you an arbitrarily large number somewhere.
One other bit of AI usage was that I asked it for the format string to mimic the Go timestamp, so it quickly re-implemented the Go formatting in a long function. I thought this was hilarious and kept it.
The dotnet version has more logging than the go version because it took me a while to find out that the simulator was using `/api/v1` as the route.
### Go version differences
One difference in the Go version is that the `Store` shared a mutex between the heartbeats and upload histories, whereas the dotnet version uses ConcurrentDictionary on the outer and locking on the inner so there's a touch more separation there, but the mutex is still only at the top layer and child operations have their own mutex. It could be split out between the two, but for this case I think it's fine.
I chose to keep the strong `DeviceId` in Go, even though it may be more idiomatic to keep it as a string, but I chose to lean more toward correctness, as a strong type allows better formatting, validation, etc. For this project, is it necessary? Not really, but it does make things a bit clearer, I think.
### Things to consider
**Security** - never heard of it! Nothing is authenticated, but I'd expect a device to have a signature that is set up before hand.
**How long did I spend** - several hours. Some of that was scaffolding and getting things set up. I wound up using AI to generate the DTO's because the dotnet tools wanted to generate too much, so I got tired of researching. After that was debugging and setting up the docker containers. The Go migration was pretty quick by comparison, and then lastly is writing the readme.
**Other Metrics** - right now any metric expansion requires creating custom types and their own aggregations. It certainly isn't Grafana. If you're ok with the bill, exposing these items as metrics endpoints would be far easier to expand, but the custom implementations do allow for far lower data usage
**Storage** - doesn't exist here, so no pod expansion at all. A shared data store would be preferable. Some of this is time series data, so using a time series database or PostgreSQL plugin could be beneficial.
**Extensibility** - The implementations are in different files and the main code is stored in the internal directory, so that helps with extension. There are tests on the core logic and the output matches the expected exactly. Documentation is not something that I've written much of outside of this ReadMe, though there are some comments both by me and the AI.
Extending the models depends on what you want to accomplish, such as if you want more generality like
re-implementing Prometheus/Grafana or something more custom. There are options like putting these values as a metric endpoint, putting them into kafka with a stream aggregator to do whatever compression/aggregation you want, writing explicit models and code for each type, or something generic that leverages Traits/static abstract to have common functions or a module to handle code in the storage. I'd lean away from trying to over-genericize the implementations.
**Git History** - The git history of commits on this is fairly clean, so you'll be able to see things like the PriorityQueue. I did commits at major completion checkpoints.
**Scaling** - none, this is in-memory only
**Deployment** - it's already a docker container with the simulator, so it isn't far from that.
**Further AI Usage** - in addition to having the AI walk me through the conversion as an extended learning exercise, I also had it create the docker files.
**Performance / Runtime Complexity** - in order to satisfy the metrics used for the return endpoint, a lot of the history is dropped and auto-aggregated. Maps are used to get the history of a device and the data is then appended. `sent_at` on `POST /stats` is ignored. This could allow duplicates, such as if a device implements a retry that didn't actually fail. There's also no concept of a Request ID to ensure idempotency. However, the complexity is O(1) for lookups and since unexpected out of sequence data is dropped, there's no attempt to rectify that something came in the past, as it's assumed to be invalid. This allows for much smaller complexity and simpler logic.
## Running the Simulator
To run this project, clone it and be sure to copy the linux simulator into your directory [Linux ARM](https://sy-fleet-interview-assets.s3.us-east-2.amazonaws.com/device-simulator-linux-arm64) [Linux AMD](https://sy-fleet-interview-assets.s3.us-east-2.amazonaws.com/device-simulator-linux-amd64). It's not that I don't trust you guys, but I decided to run the simulator in docker instead of my native system.
To run the Go version, run `docker compose up --build`, for the dotnet version, run `docker compose -f docker-compose.dotnet.yml up --build`. The output will be written to the `output` folder at the root.
+27
View File
@@ -0,0 +1,27 @@
services:
api:
build:
context: .
target: api
ports:
- "6733:6733"
healthcheck:
test: ["CMD", "curl", "-s", "-o", "/dev/null", "http://127.0.0.1:6733/"]
interval: 2s
timeout: 2s
retries: 15
start_period: 5s
simulator:
build:
context: .
target: simulator
depends_on:
api:
condition: service_healthy
command: ["-host", "api", "-port", "6733"]
working_dir: /output
volumes:
- ./output:/output
# Ensure the bind-mounted host dir is writable for results.txt
user: "0:0"
+27
View File
@@ -0,0 +1,27 @@
services:
api:
build:
context: .
target: api
ports:
- "6733:6733"
healthcheck:
test: ["CMD", "curl", "-sf", "http://127.0.0.1:6733/healthz"]
interval: 2s
timeout: 2s
retries: 15
start_period: 5s
simulator:
build:
context: .
target: simulator
depends_on:
api:
condition: service_healthy
command: ["-host", "api", "-port", "6733"]
working_dir: /output
volumes:
- ./output:/output
# Ensure the bind-mounted host dir is writable for results.txt
user: "0:0"
+4
View File
@@ -0,0 +1,4 @@
<Solution>
<Project Path="SafelyYou/SafelyYou.csproj" />
<Project Path="SafelyYouTests/SafelyYouTests.csproj" />
</Solution>
+7
View File
@@ -0,0 +1,7 @@
using System.Text.Json.Serialization;
namespace SafelyYou.Api;
public record ErrorResponse(
[property: JsonPropertyName("msg")] string Msg
);
@@ -0,0 +1,8 @@
using System.Text.Json.Serialization;
namespace SafelyYou.Api;
public record GetDeviceStatsResponse(
[property: JsonPropertyName("avg_upload_time")] string AvgUploadTime,
[property: JsonPropertyName("uptime")] double Uptime
);
+7
View File
@@ -0,0 +1,7 @@
using System.Text.Json.Serialization;
namespace SafelyYou.Api;
public record HeartbeatRequest(
[property: JsonPropertyName("sent_at")] DateTimeOffset SentAt
);
+7
View File
@@ -0,0 +1,7 @@
using System.Text.Json.Serialization;
namespace SafelyYou.Api;
public record NotFoundResponse(
[property: JsonPropertyName("msg")] string Msg
);
@@ -0,0 +1,8 @@
using System.Text.Json.Serialization;
namespace SafelyYou.Api;
public record UploadStatsRequest(
[property: JsonPropertyName("sent_at")] DateTimeOffset SentAt,
[property: JsonPropertyName("upload_time")] long UploadTime
);
+93
View File
@@ -0,0 +1,93 @@
using System.Collections.Concurrent;
namespace SafelyYou.Data;
public readonly record struct DeviceId(string Value);
public sealed class KnownDevices
{
private readonly HashSet<DeviceId> _knownDevices;
public KnownDevices(HashSet<DeviceId> knownDevices) => _knownDevices = knownDevices;
public bool Contains(DeviceId deviceId) => _knownDevices.Contains(deviceId);
}
public sealed class DeviceHeartBeatHistory
{
private readonly Lock _lock = new();
public enum AddResult { Added, OutOfSequence }
public long NumberOfHeartBeats { get; private set; }
public DateTimeOffset? FirstHeartBeat { get; private set; }
public DateTimeOffset? LastHeartBeat { get; private set; }
public AddResult Add(DateTimeOffset sentAt)
{
lock (_lock)
{
// 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++;
return AddResult.Added;
}
}
}
public sealed class HeartBeatHistories
{
private readonly ConcurrentDictionary<DeviceId, DeviceHeartBeatHistory> _histories = new();
private readonly Lock _lock = new ();
public DeviceHeartBeatHistory.AddResult Add(DeviceId deviceId, DateTimeOffset sentAt)
{
if (!_histories.TryGetValue(deviceId, out var history))
{
lock (_lock)
{
history = new();
_histories[deviceId] = history;
}
}
return history.Add(sentAt);
}
public double Uptime(DeviceId deviceId)
{
if (!_histories.TryGetValue(deviceId, out var history)) return 0;
var heartBeatMinutes =
(history.FirstHeartBeat, history.LastHeartBeat) switch
{
({} first, {} second ) => (second - first).TotalMinutes,
(not null, null) => 1,
(null, _) => 0
};
if (heartBeatMinutes == 0) return 0L;
var uptime = (history.NumberOfHeartBeats / heartBeatMinutes) * 100L;
return uptime;
}
}
+29
View File
@@ -0,0 +1,29 @@
using System.Collections.Concurrent;
using System.Numerics;
namespace SafelyYou.Data;
public sealed class UploadTimes
{
private readonly Lock _lock = new();
private readonly ConcurrentDictionary<DeviceId, (BigInteger TotalUploadTime, int UploadCounts)> _uploadHistories = new();
public void Add(DeviceId deviceId, BigInteger uploadTimeNanos)
{
lock (_lock)
{
if (!_uploadHistories.TryGetValue(deviceId, out var history))
{
history = (BigInteger.Zero, 0);
}
_uploadHistories[deviceId] = (history.TotalUploadTime + uploadTimeNanos, history.UploadCounts + 1);
}
}
public BigInteger AverageUploadTimeNanoseconds(DeviceId deviceId)
{
if (!_uploadHistories.TryGetValue(deviceId, out var history)) return BigInteger.Zero;
return history.TotalUploadTime / history.UploadCounts;
}
}
+157
View File
@@ -0,0 +1,157 @@
using SafelyYou;
using SafelyYou.Api;
using SafelyYou.Data;
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<KnownDevices>(_ =>
{
var deviceIds = File.ReadAllLines("./devices.csv").Skip(1).Select(id => new DeviceId(id)).ToHashSet();
Log.Logger.Information("@deviceIds", deviceIds);
return new KnownDevices(deviceIds);
});
builder.Services.AddSingleton<HeartBeatHistories>();
builder.Services.AddSingleton<UploadTimes>();
var app = builder.Build();
app.UseSerilogRequestLogging();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
//app.UseHttpsRedirection();
// heartbeat history
// upload stats
app.MapPost("api/v1/devices/{deviceId}/heartbeat", (string deviceId, HeartbeatRequest request,
KnownDevices knownDevices, HeartBeatHistories histories) =>
{
var device = new DeviceId(deviceId);
if (!knownDevices.Contains(device))
{
return Results.NotFound(new NotFoundResponse($"Device ID {deviceId} not found"));
}
var addResult = histories.Add(device, request.SentAt);
return addResult switch
{
DeviceHeartBeatHistory.AddResult.Added => Results.NoContent(),
DeviceHeartBeatHistory.AddResult.OutOfSequence => Results.InternalServerError(
new ErrorResponse("Attempted to add a timestamp that was out of sequence or invalid.")),
_ => throw new ArgumentOutOfRangeException()
};
});
app.MapPost("api/v1/devices/{deviceId}/stats", (string deviceId, UploadStatsRequest request, KnownDevices knownDevices, UploadTimes uploadTimes) =>
{
var device = new DeviceId(deviceId);
if (!knownDevices.Contains(device))
{
return Results.NotFound(new NotFoundResponse($"Device ID {deviceId} not found"));
}
uploadTimes.Add(device, request.UploadTime);
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("api/v1/devices/{deviceId}/stats", (string deviceId, KnownDevices knownDevices, HeartBeatHistories histories, UploadTimes uploadTimes) =>
{
var device = new DeviceId(deviceId);
if (!knownDevices.Contains(device))
{
return Results.NotFound(new NotFoundResponse($"Device ID {deviceId} not found"));
}
var averageUploadTimeNanos = uploadTimes.AverageUploadTimeNanoseconds(device);
var timeSpanText = FormatAsGoDuration(averageUploadTimeNanos);
var uptime = histories.Uptime(device);
return Results.Ok(new GetDeviceStatsResponse(timeSpanText, uptime));
// no 500 here
});
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<char> 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..]);
}
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:6733",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7175;http://localhost:6734",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+27
View File
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7" />
<PackageReference Include="Serilog" Version="4.4.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
</ItemGroup>
<ItemGroup>
<Folder Include="Api\" />
</ItemGroup>
<ItemGroup>
<None Remove="devices.csv" />
<Content Include="devices.csv">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
+9
View File
@@ -0,0 +1,9 @@
@SafelyYou_HostAddress = http://localhost:6733/api
POST {{SafelyYou_HostAddress}}/devices/38-4e-73-e0-33-59/heartbeat
Accept: application/json
{
"sent_at": "2026-08-06T16:51:22.861Z"
}
###
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
+6
View File
@@ -0,0 +1,6 @@
device_id
60-6b-44-84-dc-64
b4-45-52-a2-f1-3c
26-9a-66-01-33-83
18-b8-87-e7-1f-06
38-4e-73-e0-33-59
1 device_id
2 60-6b-44-84-dc-64
3 b4-45-52-a2-f1-3c
4 26-9a-66-01-33-83
5 18-b8-87-e7-1f-06
6 38-4e-73-e0-33-59
+193
View File
@@ -0,0 +1,193 @@
{
"openapi": "3.0.3",
"info": {
"title": "Fleet Management Simple Metrics Server",
"description": "Simple and correct implementation of the Fleet Management Metrics Coding Assessment",
"contact": {
"name": "API Support",
"url": "http://www.example.com/support",
"email": "support@example.com"
},
"license": {
"name": "None",
"url": "https://safely-you.com"
},
"version": "1.0.0"
},
"servers": [
{
"url": "http://127.0.0.1:6733/api/v1",
"description": "local server"
}
],
"paths": {
"/devices/{device_id}/heartbeat": {
"post": {
"description": "Register a heartbeat from a device",
"parameters": [
{
"$ref": "#/components/parameters/DeviceIDPathParam"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"title": "HeartbeatRequest",
"required": ["sent_at"],
"properties": {
"sent_at": {
"type": "string",
"format": "date-time"
}
}
}
}
}
},
"responses": {
"204": {
"description": "the request was completed successfully"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"500": {
"$ref": "#/components/responses/Error"
}
}
}
},
"/devices/{device_id}/stats": {
"post": {
"description": "Add per device statistics",
"parameters": [
{
"$ref": "#/components/parameters/DeviceIDPathParam"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"title": "UploadStatsRequest",
"required": ["sent_at", "upload_time"],
"properties": {
"sent_at": {
"type": "string",
"format": "date-time"
},
"upload_time": {
"description": "the number of nanoseconds it took to upload a video",
"type": "integer"
}
}
}
}
}
},
"responses": {
"204": {
"description": "the request was completed successfully"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"500": {
"$ref": "#/components/responses/Error"
}
}
},
"get": {
"description": "Return device stats",
"parameters": [
{
"$ref": "#/components/parameters/DeviceIDPathParam"
}
],
"responses": {
"200": {
"description": "Device statistics",
"content": {
"application/json": {
"schema": {
"title": "GetDeviceStatsResponse",
"required": ["avg_upload_time", "uptime"],
"properties": {
"avg_upload_time": {
"description": "returned as a time duration string. Eg: 5m10s",
"type": "string"
},
"uptime": {
"description": "Uptime as a percentage. eg: 98.999",
"type": "number",
"format": "double"
}
}
}
}
}
},
"204": {
"description": "the request was completed successfully"
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"500": {
"$ref": "#/components/responses/Error"
}
}
}
}
},
"components": {
"parameters": {
"DeviceIDPathParam": {
"name": "device_id",
"in": "path",
"description": "ID of a device to register heartbeat with",
"required": true,
"schema": {
"type": "string"
}
}
},
"responses": {
"NotFound": {
"description": "Device not found",
"content": {
"application/json": {
"schema": {
"title": "NotFoundResponse",
"type": "object",
"required": ["msg"],
"properties": {
"msg": {
"type": "string"
}
}
}
}
}
},
"Error": {
"description": "Server Error",
"content": {
"application/json": {
"schema": {
"title": "ErrorResponse",
"type": "object",
"required": ["msg"],
"properties": {
"msg": {
"type": "string"
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,111 @@
using SafelyYou.Data;
namespace SafelyYouTests;
public class DeviceHeartBeatHistoryTests
{
private static readonly DateTimeOffset T0 = new(2024, 1, 1, 12, 0, 0, TimeSpan.Zero);
private static readonly DateTimeOffset T1 = T0.AddMinutes(1);
private static readonly DateTimeOffset T2 = T0.AddMinutes(2);
[Test]
public void Add_FirstHeartbeat_SetsFirstLastAndCount()
{
var history = new DeviceHeartBeatHistory();
var result = history.Add(T0);
Assert.Multiple(() =>
{
Assert.That(result, Is.EqualTo(DeviceHeartBeatHistory.AddResult.Added));
Assert.That(history.NumberOfHeartBeats, Is.EqualTo(1));
Assert.That(history.FirstHeartBeat, Is.EqualTo(T0));
Assert.That(history.LastHeartBeat, Is.EqualTo(T0));
});
}
[Test]
public void Add_LaterHeartbeat_UpdatesLastAndIncrementsCount()
{
var history = new DeviceHeartBeatHistory();
history.Add(T0);
var result = history.Add(T1);
Assert.Multiple(() =>
{
Assert.That(result, Is.EqualTo(DeviceHeartBeatHistory.AddResult.Added));
Assert.That(history.NumberOfHeartBeats, Is.EqualTo(2));
Assert.That(history.FirstHeartBeat, Is.EqualTo(T0));
Assert.That(history.LastHeartBeat, Is.EqualTo(T1));
});
}
[Test]
public void Add_SameTimestampAsLast_IsAccepted()
{
var history = new DeviceHeartBeatHistory();
history.Add(T0);
var result = history.Add(T0);
Assert.Multiple(() =>
{
Assert.That(result, Is.EqualTo(DeviceHeartBeatHistory.AddResult.Added));
Assert.That(history.NumberOfHeartBeats, Is.EqualTo(2));
Assert.That(history.FirstHeartBeat, Is.EqualTo(T0));
Assert.That(history.LastHeartBeat, Is.EqualTo(T0));
});
}
[Test]
public void Add_EarlierThanLast_ReturnsOutOfSequenceAndDoesNotMutate()
{
var history = new DeviceHeartBeatHistory();
history.Add(T1);
var result = history.Add(T0);
Assert.Multiple(() =>
{
Assert.That(result, Is.EqualTo(DeviceHeartBeatHistory.AddResult.OutOfSequence));
Assert.That(history.NumberOfHeartBeats, Is.EqualTo(1));
Assert.That(history.FirstHeartBeat, Is.EqualTo(T1));
Assert.That(history.LastHeartBeat, Is.EqualTo(T1));
});
}
[Test]
public void Add_MultipleInOrder_KeepsFirstAndAdvancesLast()
{
var history = new DeviceHeartBeatHistory();
Assert.That(history.Add(T0), Is.EqualTo(DeviceHeartBeatHistory.AddResult.Added));
Assert.That(history.Add(T1), Is.EqualTo(DeviceHeartBeatHistory.AddResult.Added));
Assert.That(history.Add(T2), Is.EqualTo(DeviceHeartBeatHistory.AddResult.Added));
Assert.Multiple(() =>
{
Assert.That(history.NumberOfHeartBeats, Is.EqualTo(3));
Assert.That(history.FirstHeartBeat, Is.EqualTo(T0));
Assert.That(history.LastHeartBeat, Is.EqualTo(T2));
});
}
[Test]
public void Add_WithGap_StillCountsHeartbeat()
{
var history = new DeviceHeartBeatHistory();
history.Add(T0);
var muchLater = T0.AddHours(1);
var result = history.Add(muchLater);
Assert.Multiple(() =>
{
Assert.That(result, Is.EqualTo(DeviceHeartBeatHistory.AddResult.Added));
Assert.That(history.NumberOfHeartBeats, Is.EqualTo(2));
Assert.That(history.LastHeartBeat, Is.EqualTo(muchLater));
});
}
}
@@ -0,0 +1,89 @@
using SafelyYou.Data;
namespace SafelyYouTests;
public class HeartBeatHistoriesUptimeTests
{
private static readonly DeviceId Device = new("device-1");
private static readonly DateTimeOffset T0 = new(2024, 1, 1, 12, 0, 0, TimeSpan.Zero);
[Test]
public void Uptime_UnknownDevice_ReturnsZero()
{
var histories = new HeartBeatHistories();
Assert.That(histories.Uptime(Device), Is.EqualTo(0));
}
[Test]
public void Uptime_SingleHeartbeat_ReturnsZero()
{
var histories = new HeartBeatHistories();
histories.Add(Device, T0);
// first == last => TotalMinutes is 0, guarded to return 0
Assert.That(histories.Uptime(Device), Is.EqualTo(0));
}
[Test]
public void Uptime_TwoHeartbeatsOneMinuteApart_ReturnsTwoHundred()
{
var histories = new HeartBeatHistories();
histories.Add(Device, T0);
histories.Add(Device, T0.AddMinutes(1));
// (2 beats / 1 minute) * 100
Assert.That(histories.Uptime(Device), Is.EqualTo(200).Within(0.001));
}
[Test]
public void Uptime_TwoHeartbeatsOverTwoMinutes_ReturnsOneHundred()
{
var histories = new HeartBeatHistories();
histories.Add(Device, T0);
histories.Add(Device, T0.AddMinutes(2));
// (2 beats / 2 minutes) * 100
Assert.That(histories.Uptime(Device), Is.EqualTo(100).Within(0.001));
}
[Test]
public void Uptime_ThreeHeartbeatsOverTwoMinutes_ReturnsOneHundredFifty()
{
var histories = new HeartBeatHistories();
histories.Add(Device, T0);
histories.Add(Device, T0.AddMinutes(1));
histories.Add(Device, T0.AddMinutes(2));
// (3 beats / 2 minutes) * 100
Assert.That(histories.Uptime(Device), Is.EqualTo(150).Within(0.001));
}
[Test]
public void Uptime_IsPerDevice()
{
var other = new DeviceId("device-2");
var histories = new HeartBeatHistories();
histories.Add(Device, T0);
histories.Add(Device, T0.AddMinutes(1));
histories.Add(other, T0);
Assert.Multiple(() =>
{
Assert.That(histories.Uptime(Device), Is.EqualTo(200).Within(0.001));
Assert.That(histories.Uptime(other), Is.EqualTo(0));
});
}
[Test]
public void Uptime_OutOfSequenceHeartbeat_DoesNotAffectCalculation()
{
var histories = new HeartBeatHistories();
histories.Add(Device, T0.AddMinutes(1));
histories.Add(Device, T0); // rejected
histories.Add(Device, T0.AddMinutes(3));
// still 2 beats over 2 minutes
Assert.That(histories.Uptime(Device), Is.EqualTo(100).Within(0.001));
}
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0"/>
<PackageReference Include="NUnit" Version="4.3.2"/>
<PackageReference Include="NUnit.Analyzers" Version="4.7.0"/>
<PackageReference Include="NUnit3TestAdapter" Version="5.0.0"/>
</ItemGroup>
<ItemGroup>
<Using Include="NUnit.Framework"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SafelyYou\SafelyYou.csproj"/>
</ItemGroup>
</Project>
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"encoding/csv"
"fmt"
"io"
"log"
"net/http"
"os"
"github.com/michaeldileo/SafelyYouCodingChallenge/internal/api"
"github.com/michaeldileo/SafelyYouCodingChallenge/internal/store"
)
func main() {
deviceIDs, err := loadDeviceIDs("devices.csv")
if err != nil {
log.Fatalf("load devices: %v", err)
}
s := store.New(deviceIDs)
log.Printf("loaded %d known devices", len(deviceIDs))
srv := api.NewServer(s)
addr := ":6733"
fmt.Println("listening on", addr)
if err := http.ListenAndServe(addr, srv.Handler()); err != nil {
log.Fatal(err)
}
}
// loadDeviceIDs reads devices.csv (header + one device_id per row).
// Lives in main on purpose: startup I/O stays at the process edge;
// the store only receives already-parsed IDs.
func loadDeviceIDs(path string) ([]store.DeviceID, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
r := csv.NewReader(f)
// Skip header: device_id
if _, err := r.Read(); err != nil {
return nil, fmt.Errorf("read header: %w", err)
}
var ids []store.DeviceID
for {
record, err := r.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
if len(record) == 0 || record[0] == "" {
continue
}
ids = append(ids, store.DeviceID(record[0]))
}
return ids, nil
}
+6
View File
@@ -0,0 +1,6 @@
device_id
60-6b-44-84-dc-64
b4-45-52-a2-f1-3c
26-9a-66-01-33-83
18-b8-87-e7-1f-06
38-4e-73-e0-33-59
1 device_id
2 60-6b-44-84-dc-64
3 b4-45-52-a2-f1-3c
4 26-9a-66-01-33-83
5 18-b8-87-e7-1f-06
6 38-4e-73-e0-33-59
+3
View File
@@ -0,0 +1,3 @@
module github.com/michaeldileo/SafelyYouCodingChallenge
go 1.26.3
+130
View File
@@ -0,0 +1,130 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/michaeldileo/SafelyYouCodingChallenge/internal/store"
)
// Server wires HTTP handlers to the in-memory store.
// Constructed in main and passed dependencies explicitly (no DI container).
type Server struct {
store *store.Store
}
func NewServer(s *store.Store) *Server {
return &Server{store: s}
}
// Handler returns the HTTP routes for this API.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.healthz)
mux.HandleFunc("POST /api/v1/devices/{device_id}/heartbeat", s.postHeartbeat)
mux.HandleFunc("POST /api/v1/devices/{device_id}/stats", s.postStats)
mux.HandleFunc("GET /api/v1/devices/{device_id}/stats", s.getStats)
return mux
}
type heartbeatRequest struct {
SentAt time.Time `json:"sent_at"`
}
type uploadStatsRequest struct {
SentAt time.Time `json:"sent_at"` // accepted for contract; ignored in average
UploadTime int64 `json:"upload_time"` // nanoseconds
}
type getDeviceStatsResponse struct {
AvgUploadTime string `json:"avg_upload_time"`
Uptime float64 `json:"uptime"`
}
// OpenAPI NotFoundResponse / ErrorResponse both use {"msg": "..."}.
type msgResponse struct {
Msg string `json:"msg"`
}
func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}
func (s *Server) postHeartbeat(w http.ResponseWriter, r *http.Request) {
id := store.DeviceID(r.PathValue("device_id"))
if !s.store.Known(id) {
writeJSON(w, http.StatusNotFound, msgResponse{
Msg: fmt.Sprintf("Device ID %s not found", id),
})
return
}
var req heartbeatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusInternalServerError, msgResponse{
Msg: "invalid request body",
})
return
}
if err := s.store.AddHeartbeat(id, req.SentAt); err != nil {
if errors.Is(err, store.ErrOutOfSequence) {
writeJSON(w, http.StatusInternalServerError, msgResponse{
Msg: "Attempted to add a timestamp that was out of sequence or invalid.",
})
return
}
writeJSON(w, http.StatusInternalServerError, msgResponse{Msg: err.Error()})
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) postStats(w http.ResponseWriter, r *http.Request) {
id := store.DeviceID(r.PathValue("device_id"))
if !s.store.Known(id) {
writeJSON(w, http.StatusNotFound, msgResponse{
Msg: fmt.Sprintf("Device ID %s not found", id),
})
return
}
var req uploadStatsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusInternalServerError, msgResponse{
Msg: "invalid request body",
})
return
}
// sent_at is decoded above but intentionally unused for the average.
s.store.AddUpload(id, time.Duration(req.UploadTime))
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) getStats(w http.ResponseWriter, r *http.Request) {
id := store.DeviceID(r.PathValue("device_id"))
if !s.store.Known(id) {
writeJSON(w, http.StatusNotFound, msgResponse{
Msg: fmt.Sprintf("Device ID %s not found", id),
})
return
}
avg := s.store.AverageUploadTime(id)
writeJSON(w, http.StatusOK, getDeviceStatsResponse{
AvgUploadTime: avg.String(), // HTTP-edge formatting (Q13)
Uptime: s.store.Uptime(id),
})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
+80
View File
@@ -0,0 +1,80 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/michaeldileo/SafelyYouCodingChallenge/internal/store"
)
func TestPostHeartbeat_UnknownDevice_404(t *testing.T) {
s := store.New([]store.DeviceID{"known"})
srv := NewServer(s)
req := httptest.NewRequest(http.MethodPost, "/api/v1/devices/missing/heartbeat", strings.NewReader(`{"sent_at":"2024-01-01T12:00:00Z"}`))
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
var body msgResponse
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Msg == "" {
t.Fatal("expected non-empty msg")
}
}
func TestPostAndGetStats_RoundTrip(t *testing.T) {
id := store.DeviceID("device-1")
s := store.New([]store.DeviceID{id})
srv := NewServer(s)
h := srv.Handler()
t0 := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// two heartbeats one minute apart → uptime 200
postJSON(t, h, http.MethodPost, "/api/v1/devices/device-1/heartbeat",
`{"sent_at":"`+t0.Format(time.RFC3339)+`"}`, http.StatusNoContent)
postJSON(t, h, http.MethodPost, "/api/v1/devices/device-1/heartbeat",
`{"sent_at":"`+t0.Add(time.Minute).Format(time.RFC3339)+`"}`, http.StatusNoContent)
// upload 197331667813 ns → "3m17.331667813s"
postJSON(t, h, http.MethodPost, "/api/v1/devices/device-1/stats",
`{"sent_at":"2024-01-01T12:00:00Z","upload_time":197331667813}`, http.StatusNoContent)
req := httptest.NewRequest(http.MethodGet, "/api/v1/devices/device-1/stats", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var got getDeviceStatsResponse
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.Uptime != 200 {
t.Fatalf("uptime = %v, want 200", got.Uptime)
}
if got.AvgUploadTime != "3m17.331667813s" {
t.Fatalf("avg_upload_time = %q, want 3m17.331667813s", got.AvgUploadTime)
}
}
func postJSON(t *testing.T, h http.Handler, method, path, body string, wantStatus int) {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != wantStatus {
t.Fatalf("%s %s status = %d, want %d; body=%s", method, path, rec.Code, wantStatus, rec.Body.String())
}
}
+94
View File
@@ -0,0 +1,94 @@
package store
import (
"errors"
"sync"
"time"
)
// ErrOutOfSequence is returned when a heartbeat timestamp is earlier than
// the latest accepted heartbeat for that device.
var ErrOutOfSequence = errors.New("heartbeat out of sequence")
// heartbeatHistory tracks one device's heartbeat timeline.
// Unexported: only Store methods expose behavior.
type heartbeatHistory struct {
mu sync.Mutex
count int64
first time.Time
last time.Time
}
func (h *heartbeatHistory) add(sentAt time.Time) error {
h.mu.Lock()
defer h.mu.Unlock()
if h.first.IsZero() {
h.first = sentAt
h.last = sentAt
h.count = 1
return nil
}
// Equal timestamps are allowed (matches C#: sentAt < LastHeartBeat only).
if sentAt.Before(h.last) {
return ErrOutOfSequence
}
h.last = sentAt
h.count++
return nil
}
func (h *heartbeatHistory) uptime() float64 {
h.mu.Lock()
defer h.mu.Unlock()
if h.first.IsZero() {
return 0
}
minutes := h.last.Sub(h.first).Minutes()
// Single heartbeat (or identical timestamps): treat span as one minute.
if minutes == 0 {
minutes = 1
}
return float64(h.count) / minutes * 100
}
// historyFor returns the per-device history, creating it if needed.
// The store-level lock only covers map insert/lookup; callers then use
// the history's own lock for mutations/reads.
func (s *Store) historyFor(id DeviceID) *heartbeatHistory {
s.mu.Lock()
defer s.mu.Unlock()
h, ok := s.heartbeats[id]
if !ok {
h = &heartbeatHistory{}
s.heartbeats[id] = h
}
return h
}
// AddHeartbeat records a heartbeat for deviceID.
// Returns ErrOutOfSequence if sentAt is earlier than the last accepted beat.
func (s *Store) AddHeartbeat(deviceID DeviceID, sentAt time.Time) error {
return s.historyFor(deviceID).add(sentAt)
}
// Uptime returns uptime percent for deviceID:
//
// (heartbeatCount / minutesBetweenFirstAndLast) * 100
//
// Unknown / never-seen devices return 0. A zero-length span is treated as 1 minute.
func (s *Store) Uptime(deviceID DeviceID) float64 {
s.mu.Lock()
h, ok := s.heartbeats[deviceID]
s.mu.Unlock()
if !ok {
return 0
}
return h.uptime()
}
+106
View File
@@ -0,0 +1,106 @@
package store
import (
"errors"
"testing"
"time"
)
func TestAddHeartbeat_FirstSetsFirstLastAndCount(t *testing.T) {
s := New(nil)
id := DeviceID("device-1")
t0 := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
if err := s.AddHeartbeat(id, t0); err != nil {
t.Fatalf("AddHeartbeat: %v", err)
}
if got := s.Uptime(id); got != 100 {
t.Fatalf("uptime = %v, want 100 (1 beat / 1 minute)", got)
}
}
func TestAddHeartbeat_LaterUpdatesLast(t *testing.T) {
s := New(nil)
id := DeviceID("device-1")
t0 := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
t1 := t0.Add(time.Minute)
_ = s.AddHeartbeat(id, t0)
if err := s.AddHeartbeat(id, t1); err != nil {
t.Fatalf("AddHeartbeat: %v", err)
}
// (2 beats / 1 minute) * 100
if got := s.Uptime(id); got != 200 {
t.Fatalf("uptime = %v, want 200", got)
}
}
func TestAddHeartbeat_SameTimestampAccepted(t *testing.T) {
s := New(nil)
id := DeviceID("device-1")
t0 := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
_ = s.AddHeartbeat(id, t0)
if err := s.AddHeartbeat(id, t0); err != nil {
t.Fatalf("same timestamp should be accepted: %v", err)
}
// span still zero → 1 minute floor; 2 beats → 200
if got := s.Uptime(id); got != 200 {
t.Fatalf("uptime = %v, want 200", got)
}
}
func TestAddHeartbeat_OutOfSequence(t *testing.T) {
s := New(nil)
id := DeviceID("device-1")
t0 := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
t1 := t0.Add(time.Minute)
_ = s.AddHeartbeat(id, t1)
err := s.AddHeartbeat(id, t0)
if !errors.Is(err, ErrOutOfSequence) {
t.Fatalf("err = %v, want ErrOutOfSequence", err)
}
// rejected beat must not affect uptime: still 1 beat → 100
if got := s.Uptime(id); got != 100 {
t.Fatalf("uptime = %v, want 100", got)
}
}
func TestUptime_TwoHeartbeatsOverTwoMinutes(t *testing.T) {
s := New(nil)
id := DeviceID("device-1")
t0 := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
_ = s.AddHeartbeat(id, t0)
_ = s.AddHeartbeat(id, t0.Add(2*time.Minute))
if got := s.Uptime(id); got != 100 {
t.Fatalf("uptime = %v, want 100", got)
}
}
func TestUptime_UnknownDevice(t *testing.T) {
s := New(nil)
if got := s.Uptime(DeviceID("missing")); got != 0 {
t.Fatalf("uptime = %v, want 0", got)
}
}
func TestUptime_IsPerDevice(t *testing.T) {
s := New(nil)
a := DeviceID("device-1")
b := DeviceID("device-2")
t0 := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
_ = s.AddHeartbeat(a, t0)
_ = s.AddHeartbeat(a, t0.Add(time.Minute))
_ = s.AddHeartbeat(b, t0)
if got := s.Uptime(a); got != 200 {
t.Fatalf("device-1 uptime = %v, want 200", got)
}
if got := s.Uptime(b); got != 100 {
t.Fatalf("device-2 uptime = %v, want 100 (single beat = 1 minute)", got)
}
}
+38
View File
@@ -0,0 +1,38 @@
package store
import "sync"
// DeviceID is a typed device identifier.
// Using a named type (instead of plain string) makes call sites clearer
// and prevents accidentally mixing unrelated string values.
type DeviceID string
// Store holds in-memory fleet metrics for known devices.
type Store struct {
known map[DeviceID]struct{}
// mu protects the heartbeats/uploads maps (insert/lookup of entries only).
// Each per-device history has its own mutex for mutations.
mu sync.Mutex
heartbeats map[DeviceID]*heartbeatHistory
uploads map[DeviceID]*uploadHistory
}
// New creates a Store seeded with the given known device IDs.
func New(known []DeviceID) *Store {
m := make(map[DeviceID]struct{}, len(known))
for _, id := range known {
m[id] = struct{}{}
}
return &Store{
known: m,
heartbeats: make(map[DeviceID]*heartbeatHistory),
uploads: make(map[DeviceID]*uploadHistory),
}
}
// Known reports whether deviceID was present in the startup device list.
func (s *Store) Known(deviceID DeviceID) bool {
_, ok := s.known[deviceID]
return ok
}
+61
View File
@@ -0,0 +1,61 @@
package store
import (
"sync"
"time"
)
// uploadHistory tracks running total/count of upload durations for one device.
type uploadHistory struct {
mu sync.Mutex
total time.Duration
count int64
}
func (u *uploadHistory) add(d time.Duration) {
u.mu.Lock()
defer u.mu.Unlock()
u.total += d
u.count++
}
func (u *uploadHistory) average() time.Duration {
u.mu.Lock()
defer u.mu.Unlock()
if u.count == 0 {
return 0
}
// Truncating integer division — same idea as C# BigInteger total / count.
return u.total / time.Duration(u.count)
}
func (s *Store) uploadFor(id DeviceID) *uploadHistory {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.uploads[id]
if !ok {
u = &uploadHistory{}
s.uploads[id] = u
}
return u
}
// AddUpload records an upload duration (nanoseconds as time.Duration) for deviceID.
// sent_at from the API is intentionally ignored here; the HTTP layer still accepts it.
func (s *Store) AddUpload(deviceID DeviceID, uploadTime time.Duration) {
s.uploadFor(deviceID).add(uploadTime)
}
// AverageUploadTime returns the mean upload duration for deviceID.
// Formatting for the API (duration.String()) belongs at the HTTP edge.
// Unknown / never-seen devices return 0.
func (s *Store) AverageUploadTime(deviceID DeviceID) time.Duration {
s.mu.Lock()
u, ok := s.uploads[deviceID]
s.mu.Unlock()
if !ok {
return 0
}
return u.average()
}
+69
View File
@@ -0,0 +1,69 @@
package store
import (
"testing"
"time"
)
func TestAddUpload_AverageOfOne(t *testing.T) {
s := New(nil)
id := DeviceID("device-1")
d := 197331667813 * time.Nanosecond // ~3m17.331667813s
s.AddUpload(id, d)
if got := s.AverageUploadTime(id); got != d {
t.Fatalf("avg = %v, want %v", got, d)
}
}
func TestAddUpload_AverageOfMany(t *testing.T) {
s := New(nil)
id := DeviceID("device-1")
s.AddUpload(id, 100*time.Nanosecond)
s.AddUpload(id, 200*time.Nanosecond)
s.AddUpload(id, 300*time.Nanosecond)
// (100+200+300)/3 = 200
if got := s.AverageUploadTime(id); got != 200*time.Nanosecond {
t.Fatalf("avg = %v, want 200ns", got)
}
}
func TestAverageUploadTime_UnknownDevice(t *testing.T) {
s := New(nil)
if got := s.AverageUploadTime(DeviceID("missing")); got != 0 {
t.Fatalf("avg = %v, want 0", got)
}
}
func TestAverageUploadTime_IsPerDevice(t *testing.T) {
s := New(nil)
a := DeviceID("device-1")
b := DeviceID("device-2")
s.AddUpload(a, 100*time.Nanosecond)
s.AddUpload(a, 300*time.Nanosecond)
s.AddUpload(b, 50*time.Nanosecond)
if got := s.AverageUploadTime(a); got != 200*time.Nanosecond {
t.Fatalf("device-1 avg = %v, want 200ns", got)
}
if got := s.AverageUploadTime(b); got != 50*time.Nanosecond {
t.Fatalf("device-2 avg = %v, want 50ns", got)
}
}
func TestAverageUploadTime_TruncatingDivision(t *testing.T) {
s := New(nil)
id := DeviceID("device-1")
// 10+11 = 21; 21/2 = 10 in integer division
s.AddUpload(id, 10*time.Nanosecond)
s.AddUpload(id, 11*time.Nanosecond)
if got := s.AverageUploadTime(id); got != 10*time.Nanosecond {
t.Fatalf("avg = %v, want 10ns (truncating)", got)
}
}
+59
View File
@@ -0,0 +1,59 @@
[device-simulator] 2026/08/06 20:52:13 finished loading stats requests
[device-simulator] 2026/08/06 20:52:13 finished loading heartbeat requests
[device-simulator] 2026/08/06 20:52:13 starting requests for device_id: b4-45-52-a2-f1-3c
[device-simulator] 2026/08/06 20:52:13 starting requests for device_id: 26-9a-66-01-33-83
[device-simulator] 2026/08/06 20:52:13 starting requests for device_id: 18-b8-87-e7-1f-06
[device-simulator] 2026/08/06 20:52:13 starting requests for device_id: 38-4e-73-e0-33-59
[device-simulator] 2026/08/06 20:52:13 starting requests for device_id: 60-6b-44-84-dc-64
[device-simulator] 2026/08/06 20:52:13 executing 100 stats and 479 heartbeats for 60-6b-44-84-dc-64
[device-simulator] 2026/08/06 20:52:13 executing 100 stats and 479 heartbeats for 38-4e-73-e0-33-59
[device-simulator] 2026/08/06 20:52:13 executing 100 stats and 480 heartbeats for b4-45-52-a2-f1-3c
[device-simulator] 2026/08/06 20:52:13 executing 100 stats and 446 heartbeats for 26-9a-66-01-33-83
[device-simulator] 2026/08/06 20:52:13 executing 100 stats and 474 heartbeats for 18-b8-87-e7-1f-06
[device-simulator] 2026/08/06 20:52:13 ############ RESULTS #################
[device-simulator] 2026/08/06 20:52:13
DeviceID: 60-6b-44-84-dc-64
Uptime
Expected: 99.79167
Actual: 99.79167
AvgUploadTime
Expected: 3m7.893379134s
Actual: 3m7.893379134s
[device-simulator] 2026/08/06 20:52:13
DeviceID: b4-45-52-a2-f1-3c
Uptime
Expected: 100.00000
Actual: 100.00000
AvgUploadTime
Expected: 3m19.085533836s
Actual: 3m19.085533836s
[device-simulator] 2026/08/06 20:52:13
DeviceID: 26-9a-66-01-33-83
Uptime
Expected: 92.91667
Actual: 92.91667
AvgUploadTime
Expected: 3m21.858747766s
Actual: 3m21.858747766s
[device-simulator] 2026/08/06 20:52:13
DeviceID: 18-b8-87-e7-1f-06
Uptime
Expected: 98.75000
Actual: 98.75000
AvgUploadTime
Expected: 3m17.331667813s
Actual: 3m17.331667813s
[device-simulator] 2026/08/06 20:52:13
DeviceID: 38-4e-73-e0-33-59
Uptime
Expected: 99.79167
Actual: 99.79167
AvgUploadTime
Expected: 3m29.226522788s
Actual: 3m29.226522788s
[device-simulator] 2026/08/06 20:52:13 all done!