Skip to content

Inventory decrements have no concurrency control; concurrent use-stock / material-issues lose writes #33

Description

@dhokanson-armoryworks

Summary

Every inventory decrement is check-then-act with an absolute SET on save, and bin_contents has no concurrency token. Concurrent issues against the same bin content lose writes: the movement ledger records every issue, but on-hand only reflects the last writer.

Reproduction (beta.25 image, isolated stack, Engineer kiosk)

  1. POST /api/v1/inventory/receive-stock {partId, quantity: 100}
  2. Fire 4 concurrent POST /api/v1/inventory/use-stock {partId, quantity: 30}
  3. All four return 204
  4. GET /api/v1/parts/{id}/inventory-summarytotalQuantity 40 (120 issued, 60 deducted)
  5. GET /api/v1/inventory/movements lists all four Issue movements of 30 plus the Receive of 100, so the ledger says 120 left a bin that held 100 while on-hand reads 40.

Same shape on the ERP-proper path: 4 concurrent POST /api/v1/jobs/{id}/material-issues of 30 against 100 on hand → four 201s, four MaterialIssue rows, final on-hand 70. Three writes lost.

Two barcode scanners issuing the same part at the same moment reproduce this on a real floor.

Where

All decrements follow the same pattern (existing.Quantity -= data.Quantity after a read-and-compare):

  • forge.api/Features/Inventory/UseStock.cs (~L63-74)
  • forge.api/Features/Jobs/CreateMaterialIssue.cs (~L62-71)
  • forge.api/Features/Scanner/ExecuteScanIssue.cs (~L78-83)
  • forge.api/Features/Inventory/TransferStock.cs (~L44), AdjustStock.cs (~L41), SetOnHandQuantity.cs (~L86), CreateReservation.cs (~L37-56)
  • Mobile/MoveStock.cs delegates to TransferStockCommand, so /api/v1/mobile/stock/move inherits it

BinContent derives from a bare BaseEntity; BinContentConfiguration declares no IsRowVersion/IsConcurrencyToken; forge-db/schema/tables/bin_contents.sql has neither a version column nor a CHECK (quantity >= 0).

The codebase already applies the right pattern elsewhere: SELECT … FOR UPDATE in ForgeGlPostingEngine.cs, FiscalPeriodCloseService.cs, ApproveVendorBill.cs; FOR UPDATE SKIP LOCKED in Leads/Queue/PullQueueHandler.cs. It is simply never applied to bin_contents.

Suggested fix

  • Row version (xmin as a concurrency token, or an explicit version column) on bin_contents, with the standard retry-on-DbUpdateConcurrencyException in the decrement handlers, or SELECT … FOR UPDATE on the bin content row inside the decrement transaction.
  • CHECK (quantity >= 0) and CHECK (reserved_quantity <= quantity) on bin_contents as the last line of defence.
  • A concurrency test that fires N parallel use-stock calls and asserts on-hand equals the ledger.

Found by the 2026-08-30 arsenal-as-client gap audit; every claim above was reproduced live and survived adversarial verification.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    • Status
      Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions