Skip to content

Commit fe2807b

Browse files
authored
Save a retried workout only once (#18)
* Save a retried workout only once With EF's retries, an insert whose commit confirmation was lost is repeated. Each workout now has an id created by the app with a unique index, so the repeated insert is recognised as already saved. * Give test workouts their own client id * Fix the SonarCloud findings in the new tests
1 parent b7b1479 commit fe2807b

14 files changed

Lines changed: 387 additions & 10 deletions

‎src/WorkoutTracker/Components/Pages/WorkoutPage.razor‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -160,18 +160,16 @@
160160
saving = true;
161161
state.Stop(UtcNow);
162162
var typeName = type!.Name;
163-
using (var activity = Telemetry.ActivitySource.StartActivity("SaveWorkout"))
163+
await using (var db = await DbFactory.CreateDbContextAsync())
164164
{
165-
activity?.SetTag("workouttracker.workout_type", typeName);
166-
activity?.SetTag("workouttracker.reps", state.Reps);
167-
await using var db = await DbFactory.CreateDbContextAsync();
168-
db.Workouts.Add(new Workout
165+
var workout = new Workout
169166
{
167+
ClientId = state.Id,
170168
WorkoutTypeId = Id,
171169
StartedAtUtc = state.StartedUtc,
172170
FinishedAtUtc = UtcNow
173-
});
174-
await db.SaveChangesAsync();
171+
};
172+
await db.SaveWorkoutAsync(workout, typeName, state.Reps);
175173
}
176174
Metrics.WorkoutSaved(typeName);
177175
if (saved) await Store.ClearAsync();

‎src/WorkoutTracker/Components/WorkoutState.cs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ namespace WorkoutTracker.Components;
77
// stays correct after a reload, including the time the phone was locked.
88
public class WorkoutState
99
{
10+
// A workout saved before ids existed has none in its JSON, so it gets a new one here when it's loaded.
11+
public Guid Id { get; set; } = Guid.NewGuid();
1012
public int TypeId { get; set; }
1113
public DateTime StartedUtc { get; set; }
1214
public int Reps { get; set; }

‎src/WorkoutTracker/Data/AppDbContext.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
1919
e.HasOne(x => x.WorkoutType).WithMany(t => t.Workouts)
2020
.HasForeignKey(x => x.WorkoutTypeId).OnDelete(DeleteBehavior.Cascade);
2121
e.HasIndex(x => new { x.WorkoutTypeId, x.StartedAtUtc });
22+
e.HasIndex(x => x.ClientId).IsUnique();
2223
});
2324
}
2425
}

‎src/WorkoutTracker/Data/Models.cs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ public class WorkoutType
1010
public class Workout
1111
{
1212
public int Id { get; set; }
13+
// Created by the app when the workout starts, so an insert that EF repeats after a lost commit confirmation
14+
// hits the unique index instead of storing the workout twice.
15+
public Guid ClientId { get; set; }
1316
public int WorkoutTypeId { get; set; }
1417
public WorkoutType? WorkoutType { get; set; }
1518
public DateTime StartedAtUtc { get; set; }
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using Microsoft.EntityFrameworkCore;
2+
3+
namespace WorkoutTracker.Data;
4+
5+
public static class WorkoutCommands
6+
{
7+
// Returns true if the workout was already stored: EF repeated the insert after its commit confirmation was lost.
8+
public static async Task<bool> SaveWorkoutAsync(this AppDbContext db, Workout workout, string typeName, int reps,
9+
CancellationToken cancellationToken = default)
10+
{
11+
using var activity = Telemetry.ActivitySource.StartActivity("SaveWorkout");
12+
activity?.SetTag("workouttracker.workout_type", typeName);
13+
activity?.SetTag("workouttracker.reps", reps);
14+
var alreadySaved = await AddAsync(db, workout, cancellationToken);
15+
activity?.SetTag("workouttracker.already_saved", alreadySaved);
16+
return alreadySaved;
17+
}
18+
19+
static async Task<bool> AddAsync(AppDbContext db, Workout workout, CancellationToken cancellationToken)
20+
{
21+
db.Workouts.Add(workout);
22+
try
23+
{
24+
await db.SaveChangesAsync(cancellationToken);
25+
return false;
26+
}
27+
// ClientId is the only unique index on Workouts.
28+
catch (DbUpdateException ex) when (SqlErrors.IsUniqueViolation(ex))
29+
{
30+
return true;
31+
}
32+
}
33+
}

‎src/WorkoutTracker/Migrations/20261021113437_AddWorkoutClientId.Designer.cs‎

Lines changed: 97 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using System;
2+
using Microsoft.EntityFrameworkCore.Migrations;
3+
4+
#nullable disable
5+
6+
namespace WorkoutTracker.Migrations
7+
{
8+
/// <inheritdoc />
9+
public partial class AddWorkoutClientId : Migration
10+
{
11+
/// <inheritdoc />
12+
protected override void Up(MigrationBuilder migrationBuilder)
13+
{
14+
migrationBuilder.AddColumn<Guid>(
15+
name: "ClientId",
16+
table: "Workouts",
17+
type: "uniqueidentifier",
18+
nullable: false,
19+
// Not the generated empty Guid: existing rows need distinct values, or the unique index below fails.
20+
// Only here, not in the model: SQLite (bUnit tests, EnsureCreated) has no NEWID().
21+
defaultValueSql: "NEWID()");
22+
23+
migrationBuilder.CreateIndex(
24+
name: "IX_Workouts_ClientId",
25+
table: "Workouts",
26+
column: "ClientId",
27+
unique: true);
28+
}
29+
30+
/// <inheritdoc />
31+
protected override void Down(MigrationBuilder migrationBuilder)
32+
{
33+
migrationBuilder.DropIndex(
34+
name: "IX_Workouts_ClientId",
35+
table: "Workouts");
36+
37+
migrationBuilder.DropColumn(
38+
name: "ClientId",
39+
table: "Workouts");
40+
}
41+
}
42+
}

‎src/WorkoutTracker/Migrations/AppDbContextModelSnapshot.cs‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
1717
{
1818
#pragma warning disable 612, 618
1919
modelBuilder
20-
.HasAnnotation("ProductVersion", "10.0.12")
20+
.HasAnnotation("ProductVersion", "10.0.13")
2121
.HasAnnotation("Relational:MaxIdentifierLength", 128);
2222

2323
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
@@ -30,6 +30,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
3030

3131
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
3232

33+
b.Property<Guid>("ClientId")
34+
.HasColumnType("uniqueidentifier");
35+
3336
b.Property<DateTime>("FinishedAtUtc")
3437
.HasColumnType("datetime2");
3538

@@ -41,6 +44,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
4144

4245
b.HasKey("Id");
4346

47+
b.HasIndex("ClientId")
48+
.IsUnique();
49+
4450
b.HasIndex("WorkoutTypeId", "StartedAtUtc");
4551

4652
b.ToTable("Workouts");

‎tests/WorkoutTracker.Tests/Components/WorkoutPageTests.cs‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ public async Task Loading_the_page_records_a_span_with_the_type_id()
6363
[Fact]
6464
public async Task Finish_records_a_span_and_counts_the_workout()
6565
{
66-
using var spans = new ActivityCollector(a => a.OperationName == "SaveWorkout");
66+
using var trace = new TestTrace();
67+
using var spans = new ActivityCollector(a => a.OperationName == "SaveWorkout" && a.TraceId == trace.Id);
6768
using var saved = new MetricCollector<long>(Services.GetRequiredService<IMeterFactory>(), Telemetry.Name, "workouttracker.workouts.saved");
6869
var (page, _) = await StartWorkoutAsync();
6970
await Button(page, "+1").ClickAsync();
@@ -232,6 +233,38 @@ public async Task Finish_saves_when_the_page_was_opened_and_when_it_was_finished
232233
Assert.Equal(openedAt.AddMinutes(12), workout.FinishedAtUtc);
233234
}
234235

236+
[Fact]
237+
public async Task Finish_saves_the_workout_with_its_id()
238+
{
239+
var (page, _) = await StartWorkoutAsync();
240+
var saved = JsonSerializer.Deserialize<WorkoutState>(
241+
JSInterop.Invocations["workoutState.save"][^1].Arguments[0]!.ToString()!)!;
242+
saveGate.Release();
243+
244+
await Button(page, "Finish").ClickAsync();
245+
246+
await using var db = Database.CreateDbContext();
247+
var workout = await db.Workouts.SingleAsync(Xunit.TestContext.Current.CancellationToken);
248+
Assert.NotEqual(Guid.Empty, saved.Id);
249+
Assert.Equal(saved.Id, workout.ClientId);
250+
}
251+
252+
[Fact]
253+
public async Task Restored_workout_keeps_its_id()
254+
{
255+
var type = await Database.AddWorkoutTypeAsync("Push-ups");
256+
var id = Guid.NewGuid();
257+
SetUpSavedWorkout(new WorkoutState { Id = id, TypeId = type.Id, StartedUtc = Time.GetUtcNow().UtcDateTime, Started = true });
258+
var page = Render<WorkoutPage>(p => p.Add(x => x.Id, type.Id));
259+
page.WaitForElement("button.wide");
260+
saveGate.Release();
261+
262+
await Button(page, "Finish").ClickAsync();
263+
264+
await using var db = Database.CreateDbContext();
265+
Assert.Equal(id, (await db.Workouts.SingleAsync(Xunit.TestContext.Current.CancellationToken)).ClientId);
266+
}
267+
235268
// The page redraws the timer on a real 250 ms tick, so a moved clock shows up within a few ticks.
236269
static readonly TimeSpan TickTimeout = TimeSpan.FromSeconds(5);
237270

‎tests/WorkoutTracker.Tests/Data/DatabaseMigratorTests.cs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using Microsoft.Data.SqlClient;
22
using Microsoft.EntityFrameworkCore;
33
using Microsoft.EntityFrameworkCore.Infrastructure;
4+
using Microsoft.EntityFrameworkCore.Migrations;
45
using Microsoft.Extensions.Logging.Testing;
56
using WorkoutTracker.Data;
67

@@ -26,6 +27,26 @@ public async Task Applies_all_migrations_to_an_empty_database()
2627
Assert.Empty(logger.Collector.GetSnapshot());
2728
}
2829

30+
[Fact]
31+
public async Task Existing_workouts_get_distinct_client_ids()
32+
{
33+
var factory = Factory(sqlServer.ConnectionString(SqlServerFixture.NewDatabaseName()));
34+
await using var db = await factory.CreateDbContextAsync(TestContext.Current.CancellationToken);
35+
var migrator = db.GetService<IMigrator>();
36+
await migrator.MigrateAsync("20260920173150_InitialCreate", TestContext.Current.CancellationToken);
37+
await db.Database.ExecuteSqlRawAsync("""
38+
INSERT INTO WorkoutTypes (Name) VALUES ('Push ups');
39+
INSERT INTO Workouts (WorkoutTypeId, StartedAtUtc, FinishedAtUtc)
40+
VALUES (1, '2026-10-01T07:00:00', '2026-10-01T07:10:00'), (1, '2026-10-02T07:00:00', '2026-10-02T07:10:00');
41+
""", TestContext.Current.CancellationToken);
42+
43+
await migrator.MigrateAsync(cancellationToken: TestContext.Current.CancellationToken);
44+
45+
var clientIds = await db.Workouts.Select(w => w.ClientId).ToListAsync(TestContext.Current.CancellationToken);
46+
Assert.Equal(2, clientIds.Distinct().Count());
47+
Assert.DoesNotContain(Guid.Empty, clientIds);
48+
}
49+
2950
[Fact]
3051
public async Task Wrong_password_fails_without_retrying()
3152
{

0 commit comments

Comments
 (0)