Skip to content

Write a live goal and the score it makes in one commit, not two - #81

Merged
JaspervdM80 merged 2 commits into
mainfrom
claude/issue-61-1jwf38
Aug 13, 2026
Merged

Write a live goal and the score it makes in one commit, not two#81
JaspervdM80 merged 2 commits into
mainfrom
claude/issue-61-1jwf38

Conversation

@JaspervdM80

@JaspervdM80 JaspervdM80 commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Closes #61.

Logging a goal at the touchline inserted the row through GameService and then recounted the
scoreline through MatchGoalService's own context. Two contexts are two connections, so those were
two transactions with a gap between them — and a SQLite lock timeout, a failure on the second save,
or Fly.io restarting the container mid-deploy all land in it, leaving the goal on file behind a
stale score. Removing a goal had the same shape.

GameService does both halves itself now. AddGoalAsync and RemoveGoalAsync take
recountScoreline; when it is set they open a transaction, save the goal, recount the scoreline
from the goals then on file, and commit the two together. MatchGoalService keeps the one thing
only a live match knows — the minute the clock showed — and delegates the rest, so goal storage
still has a single implementation and no context is passed between services. That avoids both
options #61 called non-trivial: no context or transaction crosses a service boundary, and goals are
not stored twice.

The recount deliberately runs after the save rather than before it, even though counting in memory
ahead of the insert would fit both writes into a single SaveChanges. That shape is a
read-modify-write on a row with no concurrency token: two admins on the same live match — the thing
LiveMatchNotifier exists for — would each read n goals and each write a scoreline of n+1.
Counting afterwards, with SQLite's write lock already held, makes the second one wait and then count
both. The first commit here had it the tidier, wrong way round; the second fixes it, and
known_issues.md now names the trap so it does not get "simplified" back.

recountScoreline defaults to false, which is the result page: there the score is typed by hand and
the goal list is allowed to be shorter than it, so recounting would rewrite a 3-1 as 1-0. Both
behaviours are pinned by tests. Game.CountScoreFrom is the recount this and the final whistle now
share, and it stays a recount rather than an increment — a score derived afresh from the goals
repairs itself.

Two related decisions from the ticket are settled in writing rather than in code, which #61 allowed
for explicitly. Creating a game may still save a season before the game, in SeasonService's own
context; the leftover is an empty season, which is a valid gapless window that the next game on that
date resolves to and reuses, and a test pins that reuse so the reasoning holds rather than merely
being believed. And the seeding insert in MatchPreferencesService.GetAsync — the one read in the
app that writes — now saves with CancellationToken.None, so navigating away cannot cancel a write
the rest of the app treats as having happened. The lookups above it stay cancellable. Writes still
do not take the page-lifetime token, which #61 ruled out and this does not revisit.

docs/patterns.md gains the rule and both accepted exceptions, docs/known_issues.md the two traps
(a transaction cannot span two AppDbContext instances; collapsing the goal write into one save
reintroduces a lost update), with models.md and architecture.md following the code.

Checked: dotnet build -c Release clean, dotnet test 406/406, scripts/coverage.sh 100% of
changed lines (34/34), cd tests/ui && npm test 39/39. The atomicity property is invisible from
outside until something interrupts the halves, so MatchGoalServiceTests counts commits through a
transaction interceptor; I verified that counter is not vacuous (two sequential goals give two
commits, not one per save).

No migration, and nothing here changes what an anonymous visitor can see — every touched write
already went through RunAdminAsync, and the one read that changed (MatchPreferencesService) is
reached only from admin-only callers.

🤖 Generated with Claude Code

https://claude.ai/code/session_013ys63zAujLcoaS7UP9F2wm

claude added 2 commits August 12, 2026 20:38
Logging a goal at the touchline inserted the row through GameService and then
recounted the scoreline through MatchGoalService's own context. Two contexts are
two connections, so those were two transactions with a gap between them — and a
SQLite lock timeout, a failure on the second save, or Fly.io restarting the
container mid-deploy all land in it, leaving the goal on file behind a stale
score. Removing a goal had the same shape.

GameService now does both halves itself: AddGoalAsync and RemoveGoalAsync take
recountScoreline, count the goals through the context they are about to save,
and write the row and the scoreline together. MatchGoalService keeps the one
thing only a live match knows — the minute the clock showed — and delegates the
rest, so goal storage still has a single implementation and no context is passed
between services. recountScoreline defaults to false for the result page, where
the score is typed by hand and the goal list is allowed to be shorter than it.

Game.CountScoreFrom is the recount both this and the final whistle now share,
and it stays a recount rather than an increment: a score derived afresh from the
goals repairs itself.

Two related decisions are settled in writing rather than in code. Creating a
game may still save a season before the game, in SeasonService's context — the
leftover is an empty season, which is a valid gapless window that the next game
on that date resolves to and reuses, and a test pins that reuse. And the seeding
insert in MatchPreferencesService.GetAsync, the one read in the app that writes,
now saves with CancellationToken.None, so navigating away cannot cancel a write
half of the app treats as having happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ys63zAujLcoaS7UP9F2wm
The recount was moved ahead of the insert to fit both writes into one
SaveChanges, and that made it a read-modify-write: the count came from the goals
the caller had just read, with the new one added in memory. Two admins on the
same live match — a coach and an assistant, which is what the notifier exists
for — each read n goals and each write a scoreline of n+1, leaving two goal rows
behind a score of one until the next recount repaired it. The two-context shape
this replaced could not lose that, because each recount ran after its own insert
had committed.

So the insert goes first again, and an explicit transaction holds the two
together instead of a shared save. By the time the recount runs, the goal is on
file and SQLite's write lock is held, so a second goal logged in the same moment
waits and then counts both. Atomicity is unchanged — one commit rather than one
save, and the tests count commits now.

RecountScorelineAsync also drops the two workarounds the in-memory count needed:
it no longer has to add the pending goal to the list or filter the removed one
out by id, because it reads them as the database has them.
@JaspervdM80 JaspervdM80 changed the title Write a live goal and the score it makes in one save, not two Write a live goal and the score it makes in one commit, not two Aug 13, 2026
@JaspervdM80
JaspervdM80 merged commit a76f040 into main Aug 13, 2026
4 checks passed
@JaspervdM80
JaspervdM80 deleted the claude/issue-61-1jwf38 branch August 13, 2026 07:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Multi-save write paths are not atomic

2 participants