Skip to content

fix(inventory): see a flock archived while feed usage waits on the item lock - #1026

Merged
mforce merged 3 commits into
mainfrom
fix/1022-flock-lookup-untracked
Oct 2, 2026
Merged

mforce merged 3 commits into
mainfrom
fix/1022-flock-lookup-untracked

Conversation

@mforce

@mforce mforce commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

Closes #1022

Feed usage now refuses a flock that is archived while the request waits on the inventory item lock. Before this change it recorded the usage anyway.

Why it happened

RecordFeedUsageHandler reads the flock twice. The first read, before the transaction, finds the daily-entry link (#446). The second, after the item's FOR UPDATE lock, checks that the flock can still record production; it exists to catch a deplete or archive that commits while the request is parked on the lock. Both reads went through a tracked query. EF identity resolution returns the instance the change tracker already holds, with the state it had when first loaded, so the second read saw the flock as it was before the transaction opened.

The fix

IFlockLookup returns FlockDetails snapshots, never entities, so its reads have no reason to track. FlockLookup now calls two new untracked repository reads, GetReadOnlyAsync and GetReadOnlyForFlockScopedWriteAsync. They run the same queries as GetByIdAsync and GetByIdForFlockScopedWriteAsync with AsNoTracking() added. The tracked reads stay as they are for the lifecycle handlers that mutate the flock.

The names follow the repo's GetReadOnlyAsync convention, which TrackedMutationReadTests.AllMutableRepositoryReads_AreTracked exempts by name. The scoped-write read keeps IgnoreQueryFilters() and reinstates AccountId exactly like its tracked twin, so it has its own tenant-bypass allow-list row.

Only the feed-usage double read changes behaviour. The peer handlers that call IFlockLookup (water usage, daily entries, expenses, flock assignment) never mutate the flock. The simulation seeder does mutate flocks in the same scope: EnsureFlockAsync creates one through IFlockModule, then TransitionFlockAsync reads it through the lookup and runs a tracked lifecycle handler. Each of those writes saves before the next lookup read, so the untracked read returns the values the tracked instance held.

Change map

BEFORE                                          AFTER
RecordFeedUsageHandler                          RecordFeedUsageHandler (unchanged)
 ├─ pre-tx  IFlockLookup.GetForFlockScopedWrite   ├─ pre-tx  IFlockLookup.GetForFlockScopedWrite
 │           └─ tracked read: Flock now tracked   │           └─ untracked read
 ├─ tx: item FOR UPDATE                           ├─ tx: item FOR UPDATE
 ├─ tx: IFlockLookup.GetForFlockScopedWrite       ├─ tx: IFlockLookup.GetForFlockScopedWrite
 │       └─ same tracked instance, stale status   │       └─ fresh row: sees the archive
 └─ tx: lots FOR UPDATE                           └─ tx: lots FOR UPDATE

FlockLookup                                     FlockLookup
 ├─ GetAsync  ─► IFlockRepository.GetByIdAsync    ├─ GetAsync  ─► GetReadOnlyAsync ◄── new, untracked
 └─ GetForFlockScopedWriteAsync                   └─ GetForFlockScopedWriteAsync
     ─► GetByIdForFlockScopedWriteAsync               ─► GetReadOnlyForFlockScopedWriteAsync ◄── new, untracked

Unchanged: GetByIdAsync and GetByIdForFlockScopedWriteAsync stay tracked for
Deplete, Archive, Reactivate, Update and RecordBirdMovement.
tenant-bypass-allowlist.json  + FlockRepository.GetReadOnlyForFlockScopedWriteAsync

Verification

Test counts, measured at both ends:

Project main 08cf44c This branch
Domain 495 495
Application 655 655
AppHost 10 10
Integration 1879 1880 (+1 regression test)

The main integration run had one SeedCommandTests demo-seed case exceed its 60 s budget while still seeding, at load average 11.7. The demo seeder is untouched; that class then passed 11/11 alone. main 08cf44c has the same tree as #1021's tested tip fe067c99, where the baseline was taken.

dotnet build Cluckwork.sln is clean with warnings as errors. No entity, mapping or migration changes.

Regression test FeedUsageLockOrderTests.FlockArchivedWhileUsageWaitsForTheItemLock_IsRefused holds the item row lock in a separate connection, waits until the usage request is provably parked behind it (pg_blocking_pids), archives the flock, then releases the lock. It expects 422 FeedUsage.FlockNotActive and no stock consumed.

Each mutation below was run against the committed fix, then reverted:

Guards the change had to satisfy:

  • TenantBypassRealTreeTests reported the new IgnoreQueryFilters site. Its allow-list row copies the tracked twin's justification.
  • TrackedMutationReadTests.AllMutableRepositoryReads_AreTracked exempts reads named ReadOnly, which is why the new reads carry that name. It stays green, and the tracked reads it guards are unchanged.

pstack:deslop ran on the branch before opening. It found one problem: the allow-list justification listed the four calling handlers, a hand-kept list that would rot. The fix trimmed it to the twin row's wording. A /noslop self-review then rewrote one test comment that narrated history into the constraint it protects.

Nothing user-visible changes apart from the refusal itself, so this PR has no screenshots.

mforce added 3 commits October 2, 2026 13:59
…em lock

RecordFeedUsageHandler reads the flock before its transaction and again
after the item's FOR UPDATE lock. Both reads were tracked, so EF handed
the second read the instance the first had loaded, and a flock archived
while the request waited on the lock still passed the eligibility check.

IFlockLookup returns snapshots, so its two flock reads now go through
untracked repository twins. The tracked reads stay for the lifecycle
handlers that mutate the flock.
@mforce

mforce commented Oct 2, 2026

Copy link
Copy Markdown
Owner Author

Review of record: Codex gpt-6-astra (xhigh), round 1, at head debd841d. Posted by the coordinator. Log paths named below are local to the review host.

PR #1026 review

Reviewed debd841de5f2eabe036aea3272392cc8b5837183 against 08cf44c6669d8740ba5c53cf354850db1c725449 in /home/mforce/.cluckwork-slices/review-1026. Read the PR, issue #1022, decision records #852 and #269, and the applicable repository rules. No delegation.

Findings

No P0–P2 findings. No CONFIRMED or PLAUSIBLE product defect found in this change. The fix closes the reported archive-during-item-lock-wait window, including when the same context already tracks the flock.

Nits

P3 · CONFIRMED · PR description; src/Cluckwork.Infrastructure/Persistence/SimulationDataSeeder.cs:886

The PR description incorrectly says no lookup caller mutates a flock in the same scope and that the simulation seeder reads it once per request.

Concrete counterexample: one simulation-seed scope calls EnsureFlockAsync, which creates and saves a tracked flock through IFlockModule, then TransitionFlockAsync reads it through the lookup and invokes a tracked lifecycle handler. The same seeder also repeatedly invokes the daily-entry, feed and water handlers. See lines 806, 869, 875, 886, 1008, 1164, 1798 and 1818. SimulationSeedFactory.InitializeAsync resolves and runs the seeder inside one scope at tests/Cluckwork.Api.IntegrationTests/SimulationSeederTests.cs:100.

This is a description error, not a product defect. The lifecycle handlers save before returning, so later untracked reads see their changes. Both selected simulation tests passed. Replace the blanket claim with the narrower conclusion that the peer handlers do not mutate the flock and the seeder saves its lifecycle changes before subsequent reads.

Caller audit

All ten peer handlers consume copied FlockDetails values. None mutates the flock, attaches the copy, or depends on entity identity. Their other repositories do not load a flock navigation; mortality writes append BirdMovement rows without loading or changing a flock.

Caller and lookup line Use and tracking assessment
Users/AssignFlock/AssignFlockHandler.cs:48 One filtered read for existence and audit name. Writes an assignment, not the flock.
Expenses/CreateExpense/CreateExpenseHandler.cs:33 One optional filtered read for farm membership. The later account currency lock does not reread the flock.
Expenses/AdjustExpense/AdjustExpenseHandler.cs:41 One filtered read only when retargeting the expense. No entity identity dependency.
Inventory/RecordFeedUsage/RecordFeedUsageHandler.cs:55 and :89 First scoped-write read supplies provenance keys; second follows the item lock and checks eligibility. The second now sees a committed archive.
Inventory/RecordWaterUsage/RecordWaterUsageHandler.cs:31 One guarded scoped-write read supplies eligibility and provenance keys.
Inventory/UpdateWaterUsage/UpdateWaterUsageHandler.cs:34 One filtered read checks eligibility; only the usage entity is changed.
DailyEntries/RecordDailyEntry/RecordDailyEntryHandler.cs:35 One guarded scoped-write read checks farm and lifecycle.
DailyEntries/SubmitDailyEntry/SubmitDailyEntryHandler.cs:57 One guarded scoped-write read checks lifecycle. The mortality append does not mutate a flock.
DailyEntries/AdjustDailyEntry/AdjustDailyEntryHandler.cs:49 One filtered eligibility read; changes entry, lots and mortality ledger.
DailyEntries/VoidDailyEntry/VoidDailyEntryHandler.cs:43 One filtered eligibility read; changes entry, lots and mortality ledger.

Paths in the table are relative to src/Cluckwork.Application/Features/.

The flock-detail and production-report endpoints use one filtered lookup. The other lookup-using endpoints call unchanged display-name reads. The simulation seeder can have both tracked entities and fresh snapshots in one scope, as discussed above; its saved lifecycle sequence remains valid. Tracked repository methods used by lifecycle handlers are unchanged.

A fresh lookup can deliberately disagree with an older tracked instance after an external commit. A temporary real-Postgres probe confirmed both lookup methods returned Archived while the context's original tracked instance remained Active, with no second tracked flock. No caller reviewed depends on preserving that stale value. Freshness here relies on the existing default READ COMMITTED transactions; this change introduces no flock lock and does not close the later read-to-commit race.

Tenancy, scope and SQL

A temporary DbCommandInterceptor captured the actual commands from both tracked/untracked method pairs and asserted identical SQL and parameter representations for assigned, unassigned, foreign-account and missing flock ids. The selected columns were identical. The predicates were:

-- GetByIdAsync / GetReadOnlyAsync
WHERE f."AccountId" = @ef_filter__AccountId
  AND (@ef_filter__IsUnrestricted3 OR f."Id" = ANY (@ef_filter__AssignedFlockIds))
  AND f."Id" = @id
LIMIT 1

-- GetByIdForFlockScopedWriteAsync / GetReadOnlyForFlockScopedWriteAsync
WHERE f."AccountId" = @accountId AND f."Id" = @id
LIMIT 1

Both filtered methods returned only the assigned own-account flock. Both scoped-write methods also returned the own-account unassigned flock, but refused foreign and missing ids. This is the intended #388 bypass of a stale request-start assignment snapshot after the live guard succeeds. All four scoped-write callers still invoke IFlockScopeGuard first. The unresolved-actor test passed, as did the existing live-assignment and tenant/scope integration checks. No tenancy concurrency token, interceptor or write path changes.

The new row at tests/Cluckwork.Application.Tests/TenantBypass/Data/tenant-bypass-allowlist.json:108 names the exact enclosing method and file and justifies the same bypass as its tracked twin. It has no token hash. This is consistent with the existing JSON allow-list, whose matching uses file plus symbol (GuardScanner.cs:456). The symbol + set + Roslyn-token-hash scheme belongs to the separate filter-free-set-sites.tsv classification, for sets whose model lacks a filter (TenantBypassRealTreeTests.cs:167). Flocks has a model filter and does not belong to that classification. The new row neither enlarges the existing bypass nor requires a new hash field under the current scanner. Tenant-bypass guards passed.

Regression and mutation evidence

The test's ordering is deterministic. It acquires the inventory-item row lock before starting the request, observes a waiter through pg_blocking_pids, commits the archive in another context, then releases the item lock. A unique item and holder backend have one competing request, so the helper's “any waiter behind this holder” criterion identifies the intended request. The 50 ms polling interval does not decide the ordering. The test checks the exact refusal code, unchanged lot quantity and absence of usage rows. Its directly constructed holder context correctly avoids retry-enabled manual-transaction restrictions under #269.

Experiment Observed result
Committed fix PASS: 422 FeedUsage.FlockNotActive, stock 100, zero usages.
Point scoped-write lookup back to GetByIdForFlockScopedWriteAsync RED at test line 40: expected UnprocessableEntity, actual OK.
Remove only AsNoTracking() from GetReadOnlyForFlockScopedWriteAsync RED at the same assertion: actual OK.
Keep lookup untracked; temporarily route only the handler's pre-transaction read through tracked IFlockRepository.GetByIdForFlockScopedWriteAsync PASS. The in-transaction lookup is fresh despite the tracked first read.
Restore the complete committed tree PASS again.

Logs: /tmp/1026-review-tracked-mutation.log, /tmp/1026-review-no-tracking-mutation.log, /tmp/1026-review-pretracked.log, /tmp/1026-review-restored.log. Captured SQL and seeder results are in /tmp/1026-review-sql-and-seeder.log.

Scope and verification

The eight-file diff is confined to #1022: untracked lookup reads, repository declarations/implementation, the necessary allow-list row, updated test stub, regression test and documentation. The #852 correction accurately removes the claim that tracking is unchanged and explains the stale-instance failure. No schema, dependency, authorization or lifecycle mutation changes.

  • Solution build, including the final restored tree: 0 warnings, 0 errors. This exercises build: enforce file-scoped namespaces and using placement at build time #985's style gate.
  • Targeted integration selection: 103 passed, covering feed, water, expense, daily-entry adjustment/submission, flock management/scope and tracked mutation reads.
  • Application/architecture/tenant-bypass/lookup selection: 385 passed.
  • Unresolved-actor guard: 1 passed.
  • Two simulation-seeder checks, temporary SQL/scope probe and restored regression: 4 passed in their combined run.
  • All throwaway mutations and the temporary probe were removed. Final git status --short was empty at the assigned head; git diff --check passed. No commits, pushes or GitHub comments.

The full test suite was not rerun. Verification was targeted to the changed behavior and its callers.

Verdict: APPROVE; no product defect found, with one non-blocking PR-description nit.

@mforce

mforce commented Oct 2, 2026

Copy link
Copy Markdown
Owner Author

Description corrected per the Astra review's P3 (no code change, no new commit). The blanket claim that no IFlockLookup caller mutates a flock in the same scope is replaced. The peer handlers never mutate the flock. The simulation seeder does: EnsureFlockAsync creates one through IFlockModule, then TransitionFlockAsync reads it through the lookup and runs a tracked lifecycle handler. Each of those writes saves before the next lookup read.

@mforce
mforce merged commit 582f8c8 into main Oct 2, 2026
30 of 31 checks passed
@mforce
mforce deleted the fix/1022-flock-lookup-untracked branch October 2, 2026 14:51
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.

fix(inventory): feed-usage eligibility misses a flock archived while the request waits on the item lock

1 participant