Compare commits
13 Commits
f662073332
...
953cb5dd33
| Author | SHA1 | Date | |
|---|---|---|---|
| 953cb5dd33 | |||
| 15a4d2fb03 | |||
| 984e8da231 | |||
| e17fbc14a0 | |||
| 459b90dcd7 | |||
| 9a1664bdbb | |||
| 903a87f947 | |||
| 6e88380fea | |||
| 9f94290439 | |||
| c5da73d0ec | |||
| 3b54437269 | |||
| 8af110873b | |||
| 9e201e4d4d |
@@ -0,0 +1,8 @@
|
|||||||
|
.git
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
**/bin/
|
||||||
|
**/obj/
|
||||||
|
output/
|
||||||
|
device-simulator-mac-arm64
|
||||||
|
*.md
|
||||||
+27
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<Solution>
|
||||||
|
<Project Path="SafelyYou/SafelyYou.csproj" />
|
||||||
|
<Project Path="SafelyYouTests/SafelyYouTests.csproj" />
|
||||||
|
</Solution>
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace SafelyYou.Api;
|
||||||
|
|
||||||
|
public record HeartbeatRequest(
|
||||||
|
[property: JsonPropertyName("sent_at")] DateTimeOffset SentAt
|
||||||
|
);
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
@@ -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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
@@ -0,0 +1,3 @@
|
|||||||
|
module github.com/michaeldileo/SafelyYouCodingChallenge
|
||||||
|
|
||||||
|
go 1.26.3
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!
|
||||||
Reference in New Issue
Block a user