Skip to content

About

Constrained inventory allocation that cannot oversell under concurrency: entity invariants, a DB CHECK constraint, and pessimistic locks taken in deterministic order. Lot-level FIFO tracking, quarantine/scrap, contract floors and ceilings. .NET 8, PostgreSQL, EF Core + Dapper.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

3 Commits

Folders and files

Repository files navigation

Material Allocation API

.NET 8 licence: MIT

Constrained inventory allocation that cannot oversell under concurrency. Lot-level FIFO tracking, contractual floors and ceilings, and a demo that races concurrent allocations at scarce stock rather than showing you a CRUD form.

Live demo: https://material-allocation.vectur45.com

What this repository is. This documents the design of a private implementation. The system is built, deployed and running — the demo above is live — but the source is not public. What you will find here: the design decisions with their rejected alternatives and costs, the architecture, the domain model, the API shape, and load-test scripts that run against the live deployment.

Source access for hiring conversations is available on request.


The problem

Allocating scarce stock to competing orders has exactly one unacceptable failure: promising the same unit twice.

The naive read-then-write loses under concurrency. Two transactions read on_hand = 100, each decides it can serve 80, each writes back 20. The database now says 20 remain and 160 were promised, and nothing errored. The bug surfaces downstream, at a customer who was told they had stock, and unwinding it means reversing commitments other systems already acted on.

Two further constraints make it more than a locking exercise. Contracts guarantee customers a floor and cap them at a ceiling, obligations that exist independently of how urgent today's orders are. And physical stock arrives in lots that must be consumed oldest-first, can be quarantined pending inspection, and can be scrapped — each of which moves the allocatable balance.

Design decisions

Full write-ups in docs/adr/. The short version:

Three independent layers prevent oversell. Entity invariants own the arithmetic; chk_skus_on_hand_non_negative makes a negative balance unrepresentable; SELECT … FOR UPDATE closes the read-then-write window. Layered deliberately — the lock prevents the race, the invariant prevents the logic error, the constraint prevents everything else. Layer 2 firing in production would be an incident, not a handled error.

Optimistic and pessimistic concurrency, split by contention profile. Allocation and cancel take row locks; the adjust endpoint uses a version token. Same codebase, opposite strategies, because one row changed rarely and several rows changed under contention are different problems.

Locks are always acquired in ascending id order. ORDER BY id FOR UPDATE, SKUs first then lots. Two orders sharing SKUs in different sequences would deadlock — intermittently, under load, invisibly in single-threaded tests. Sorting eliminates the circular wait rather than retrying through it.

Idempotency is a unique constraint, not a check-then-act. The middleware inserts into idempotency_keys; a racing duplicate fails the unique index and becomes 409 IDEMPOTENCY_IN_FLIGHT. Correct under any horizontal scaling with no distributed lock. 5xx responses are deliberately not persisted, so a transient blip cannot permanently poison a key.

Two passes: contractual floors, then priority. Floors by customer tier, then critical → high → standard, with ceiling headroom carried between passes so caps hold globally. A single pass cannot honour both rules, and the result stays explainable to a planner asking why their order got 40 instead of 100.

The application cannot run DDL. Migrations run as material_allocation_migrator from CI; the app runs as dotnetter with DML only. A compromised process cannot drop tables.

on_hand is mutable, not a derived ledger balance. Simple availability reads, at the cost of not being able to reconstruct the balance from events. Stated plainly, because the audit tables might otherwise imply a stronger guarantee than exists.

Domain model

Sku ──────────────────────── on_hand (mutable), version (concurrency token)
 ├── Lot                     physical batch — lot_code, quantity (immutable),
 │                           available_qty, status, received_at
 ├── Reservation             soft hold with TTL, released or expired
 └── AllocationEvent         immutable ledger of every unit movement

Customer ── CustomerContract ── floor_qty / ceiling_qty, tier
Order ───── OrderLine ─────── requested / allocated, priority

Lot lifecycle: available → quarantined → back to available, or → scrapped. Quarantine atomically decrements sku.on_hand by the lot's available_qty without zeroing it, so a release restores the exact held quantity. Scrapping from quarantined leaves on_hand untouched — it was already deducted. Every transition writes a LotEvent and an outbox message in the same transaction, and an invalid transition is a 409.

Allocation walks lots FIFO by received_at, locked FOR UPDATE in id order after the SKU locks are held. Each lot consumed produces its own AllocationEvent carrying lot_id, so the lot-to-order chain is fully traceable — GET /orders/{id}/lots and GET /lots/{id}/allocations close the loop in both directions.

Architecture

flowchart TB
  C[Client / ERP] -->|Idempotency-Key| MW[IdempotencyMiddleware]
  MW -->|duplicate in flight| C409[409 IDEMPOTENCY_IN_FLIGHT]
  MW --> AUTH[JWT + 4-role RBAC]
  AUTH --> ALLOC[AllocationService]

  subgraph TX[single transaction]
    ALLOC -->|1. SELECT … ORDER BY id FOR UPDATE| SKU[(skus)]
    ALLOC -->|2. lots FOR UPDATE, id asc| LOT[(lots · FIFO by received_at)]
    ALLOC -->|3. entity invariants| DOM[Sku.AllocateUnits]
    DOM -->|4. CHECK chk_skus_on_hand_non_negative| SKU
    ALLOC --> EV[(allocation_events)]
    ALLOC --> OB[(outbox_messages)]
  end

  RUN[POST /allocations/run] -->|202 + poll| W[AllocationRunWorker]
  W -->|pass 1 floors, pass 2 priority| ALLOC
  OB --> RELAY[OutboxRelayJob]
Loading
Concern Choice
Writes, aggregate mutation, locking queries EF Core 8 (FromSqlRaw for FOR UPDATE)
Multi-result reads — order detail, paginated lists, lot traceability Dapper
Auth JWT, four roles: warehouse-ops, sales-ops, allocation-manager, read-only

Implementation

ASP.NET Core 8 · PostgreSQL + EF Core 8 (writes, aggregate mutation, FromSqlRaw locking queries) + Dapper (multi-result reads) · JWT with four roles — warehouse-ops, sales-ops, allocation-manager, read-only · React + Tailwind client · CI/CD on Gitea Actions.

Roughly 138 C# files across the API and test projects, plus the React client.

The Contention Arena is the demo

The client is not a CRUD portal. It bootstraps deliberately scarce FIFO stock and N competing orders, fires concurrent allocates, and shows the lot board, live availability, lot-to-order provenance, the event ledger, and a conservation banner asserting that on_hand + allocated = received held through the race.

Route What it demonstrates
/ Concurrent allocation race against scarce lots
/orders/:id Cancel, reserve, short-TTL reservation expiry
/runs Async POST /allocations/run, polling, 409 RUN_IN_PROGRESS
/lab Staging for SKUs, lots, customers, contracts, orders

Racing allocations and watching the invariant hold is the fastest way to see what this project is about — considerably faster than reading the rest of this page.

API

These run against the live demo. POST /api/v1/auth/token is anonymous and issues a JWT for whichever of the four roles you ask for, so the calls below are reachable without a provisioned credential.

Allocate against current stock:

curl -X POST https://material-allocation.vectur45.com/api/v1/orders/{orderId}/allocate \
  -H 'Authorization: Bearer <allocation-manager token>' \
  -H 'Idempotency-Key: alloc-8f3c1e'

Partial allocation is a success, not an error — unfulfilled lines stay open and the response says partial or full explicitly.

Receive a lot, then quarantine it:

curl -X POST https://material-allocation.vectur45.com/api/v1/skus/{skuId}/lots \
  -H 'Authorization: Bearer <warehouse-ops token>' \
  -H 'Content-Type: application/json' \
  -d '{ "lotCode": "L-2609-01", "quantity": 500 }'

curl -X POST https://material-allocation.vectur45.com/api/v1/lots/{lotId}/quarantine \
  -H 'Authorization: Bearer <warehouse-ops token>' \
  -H 'Content-Type: application/json' \
  -d '{ "notes": "pending incoming inspection" }'

Trace which lots filled an order:

curl https://material-allocation.vectur45.com/api/v1/orders/{orderId}/lots -H 'Authorization: Bearer <token>'

Each entry carries both lotStatusAtAllocation (snapshotted at commit) and lotStatusNow.

Testing

xUnit with Testcontainers and WebApplicationFactory against a real PostgreSQL container — everything that matters here is database behaviour: the FOR UPDATE ordering, the CHECK constraint, the idempotency unique index. An in-memory provider would pass while the real thing oversold.

The concurrency tests are the ones worth reading. They fire parallel allocations with Task.WhenAll against scarce stock and assert the conservation invariant exactly: on_hand + total_allocated = received, and one order fully allocated while the other is partial — never both full, never both starved. Asserting the invariant rather than a specific outcome is what makes them meaningful under nondeterministic scheduling. The lot suite does the same for parallel quarantine: one 200, one 409, on_hand correct.

Not covered: rate limiting, transient-failure retry policy, and API versioning are open gaps, named in the project's production-gaps.md rather than quietly absent. Sustained load is in load/.

Load testing

See load/ — the interesting measurement here is not throughput but throughput under contention, and whether the conservation invariant survives it.

Source access

The implementation is private. If you are evaluating this for a role and want to read the code, ask and I will arrange access.

The design decisions in docs/adr/ are the substance of what the code does — each records what was chosen, what was rejected, and what it cost.

Licence

The documentation in this repository is MIT — see LICENSE.

About

Constrained inventory allocation that cannot oversell under concurrency: entity invariants, a DB CHECK constraint, and pessimistic locks taken in deterministic order. Lot-level FIFO tracking, quarantine/scrap, contract floors and ceilings. .NET 8, PostgreSQL, EF Core + Dapper.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors