fix(plannings): stop the index endpoint silently dropping workers - #1705
Merged
Merged
Conversation
Index() fans out one async task per assigned site. Each task added its
row into a List<T> shared by every task -- after several awaits, so the
Add ran on arbitrary thread-pool threads, genuinely in parallel.
List<T> is not thread-safe: concurrent Add loses elements.
The dashboard could therefore return fewer workers than exist, with no
error and nothing in the log. A worker silently missing from the grid
is a missed shift, and it looks like a data problem rather than a race.
Task.WhenAll already returned exactly the data the shared list was
being used to collect, and that return value was discarded. Build the
result from it instead, dropping the nulls the "SDK site not found"
path returns -- which the old code never added either. No lock and no
ConcurrentBag: not sharing the state at all is less code than
synchronising it, and it makes tie-ordering deterministic, since
OrderBy is stable and ties now fall back to input order rather than
completion order.
Also dispose innerDbContext, which was created per task and never
disposed -- one DbContext and pooled connection leaked per site per
dashboard load. The two changes are load-bearing together: without the
dispose, the new test's fan-out would exhaust the connection pool.
This is what made the e1m Playwright shard flaky. It saw 14 of 16 rows,
so the positional #cell3_0 selector addressed a different worker; the
spec wrote five shifts, reopened what it thought was the same cell and
found it empty ("Expected 01:03, Received ''"). Confirmed against the
failing run's log, where the "not found in Sites list" warning -- the
only other path to a short row count -- never fired.
The e1m spec now reads the worker from the first dialog it opens and
asserts every later open matches, rather than trusting the row index.
It deliberately does not hardcode which worker row 3 is: that depends
on the server's collation of the seeded names, so pinning a name would
couple the spec to a derivation instead of to the property that
matters -- that all five opens hit the same row. It also gates on the
plannings/index POST that follows every assigned-site save, which it
previously raced.
The C# test is a contract test, not a regression test: it asserts that
every assigned site comes back, every time. A lost update cannot be
forced deterministically, so it repeats the call -- it would not
reliably fail against the old code, but it never passes wrongly.
Tests run only in CI, so none of this has executed locally.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟢 Approval recommended
No unresolved issues were identified that would block approval.
Pull request overview
Fixes the planning dashboard race that dropped workers during concurrent site processing and prevents per-site DbContext leaks.
Changes:
- Builds results from
Task.WhenAlland adds deterministic ordering. - Disposes per-site DbContexts.
- Adds backend contract coverage and strengthens the E2E worker-identity and reload checks.
File summaries
| File | Description |
|---|---|
| eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs | Updated as part of this pull request. |
| eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs | Updated as part of this pull request. |
| eform-client/playwright/e2e/plugins/time-planning-pn/e1m/dashboard-edit-multishift.spec.ts | Updated as part of this pull request. |
Review details
- Files reviewed: 3/3 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
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.
The bug
TimePlanningPlanningService.Index()— the endpoint behind the planning dashboard — fans out one async task per assigned site. Every task did this:The
Addruns after the awaits, so on arbitrary thread-pool threads, genuinely in parallel.List<T>is not thread-safe — concurrentAddloses elements.The dashboard could return fewer workers than exist, with no error and nothing in the log. A worker silently missing from the grid is a missed shift, and it presents as a data problem rather than a race.
The fix
Task.WhenAllalready returned exactly the data the shared list was collecting, and it was being thrown away. Build the result from it:No lock, no
ConcurrentBag— not sharing the state is less code than synchronising it. It also makes tie-ordering deterministic:OrderByis stable, so sites with equal keys now fall back to input order instead of completion order..OfType<>()is a real filter here, not a no-op: the lambda's inferred return type isTimePlanningPlanningModel?under this file's#nullable enable.Also fixed:
innerDbContextwas created per task and never disposed — one DbContext and pooled connection leaked per site per dashboard load. The two changes are load-bearing together; without the dispose, the new test's fan-out would exhaust the connection pool.How this connects to the flaky e1m shard
This is the cause. The shard saw 14 of 16 rows, so the positional
#cell3_0selector addressed a different worker: the spec wrote five shifts, reopened what it thought was the same cell, and found it empty —Expected "01:03", Received "".Confirmed against the failing run's log: the
not found in Sites listwarning, the only other path to a short row count, never fired.Test changes
e1m/dashboard-edit-multishift.spec.tsnow reads the worker from the first dialog it opens and asserts every later open matches, instead of trusting the row index. It deliberately does not hardcode which worker row 3 is — that depends on the server's collation of the seeded names (underdait isc d; under the host'sen-USdefault it would beag ah), so pinning a name would couple the spec to a derivation rather than to the property that matters: all five opens must hit the same row.It also gates on the
plannings/indexPOST that follows every assigned-site save, which it previously raced, and asserts the save reportedsuccess: trueso a failed save can't surface as an opaque gate timeout.PlanningServiceMultiShiftTestsgains a contract test, not a regression test — stated plainly because it matters: it asserts every assigned site comes back, every time. A lost update can't be forced deterministically, so it repeats the call. It would not reliably fail against the old code, but it never passes wrongly. No workflow change needed; that class is already in shardc.Known gaps, deliberate
b1m,c1mandd1mcarry the identical#firstColumn3/#cell3_0construct with neither the gate nor the identity check. Out of scope by agreement; the race fix removes the cause for all of them.PlanRegistrationHelperholiday-config double-checked lock has novolatile— benign on x86/x64, not guaranteed on ARM. Separate follow-up.🤖 Generated with Claude Code