Write a live goal and the score it makes in one commit, not two - #81
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #61.
Logging a goal at the touchline inserted the row through
GameServiceand then recounted thescoreline through
MatchGoalService's own context. Two contexts are two connections, so those weretwo 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.
GameServicedoes both halves itself now.AddGoalAsyncandRemoveGoalAsynctakerecountScoreline; when it is set they open a transaction, save the goal, recount the scorelinefrom the goals then on file, and commit the two together.
MatchGoalServicekeeps the one thingonly 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 aread-modify-write on a row with no concurrency token: two admins on the same live match — the thing
LiveMatchNotifierexists 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.mdnow names the trap so it does not get "simplified" back.recountScorelinedefaults to false, which is the result page: there the score is typed by hand andthe 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.CountScoreFromis the recount this and the final whistle nowshare, 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 owncontext; 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 theapp that writes — now saves with
CancellationToken.None, so navigating away cannot cancel a writethe 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.mdgains the rule and both accepted exceptions,docs/known_issues.mdthe two traps(a transaction cannot span two
AppDbContextinstances; collapsing the goal write into one savereintroduces a lost update), with
models.mdandarchitecture.mdfollowing the code.Checked:
dotnet build -c Releaseclean,dotnet test406/406,scripts/coverage.sh100% ofchanged lines (34/34),
cd tests/ui && npm test39/39. The atomicity property is invisible fromoutside until something interrupts the halves, so
MatchGoalServiceTestscounts commits through atransaction 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) isreached only from admin-only callers.
🤖 Generated with Claude Code
https://claude.ai/code/session_013ys63zAujLcoaS7UP9F2wm