The allocation engine is the correctness-critical path of Tranche. It accepts investor commitments against LIVE opportunities, reserves units and wallet funds atomically, and emits audit and outbox records in the same database transaction.
POST /api/v1/opportunities/{id}/commitments
Authorization: Bearer <INVESTOR token>
Idempotency-Key: <UUID>
Request body:
{
"unitsRequested": 10,
"amount": 100000.00
}amount must equal unitsRequested × unitPrice. The requested amount must meet minimumLot.
All commitment processing runs inside a single @Transactional method on AllocationEngine.allocate():
| Step | In transaction? |
|---|---|
| Idempotency lookup | Yes |
SELECT ... FOR UPDATE on opportunity |
Yes |
| Fill computation | Yes |
SELECT ... FOR UPDATE on investor profile |
Yes |
| Wallet lock (available → locked) | Yes |
Decrement remaining_units |
Yes |
Insert investment_orders |
Yes |
Insert allocations |
Yes |
Insert portfolio_positions |
Yes |
Opportunity status → FULLY_SUBSCRIBED |
Yes |
Insert audit_logs |
Yes |
Insert outbox_events |
Yes |
| Redis cache eviction | After commit (Spring @CacheEvict) |
If any step fails, the entire transaction rolls back — no partial allocation, no orphan audit row, no outbox event.
Rejected commitments that must be idempotent use @Transactional(noRollbackFor = BusinessException.class) so the persisted REJECTED order commits before the 422 response is returned.
- Client requirement:
Idempotency-Keyheader (UUID) on every commitment request. - Database enforcement:
UNIQUE (idempotency_key, investor_id)oninvestment_orders. - Application flow:
- Before processing, look up existing order by
(key, investor). - If found → return stored response (
200 OKreplay). - If not found → process allocation and insert order.
- On concurrent duplicate insert → catch
DataIntegrityViolationException, reload winner row, return replay.
- Before processing, look up existing order by
Rejected orders are also stored with the idempotency key so retries receive the same 422 outcome.
Concurrent investors committing to the same opportunity must not read stale remaining_units. Optimistic retry loops add latency and complexity under high contention; invoice allocation prioritises correctness over throughput.
All threads acquire locks in this fixed order:
- Opportunity row —
PESSIMISTIC_WRITEviafindByIdForUpdate() - Investor profile row —
PESSIMISTIC_WRITEviafindByUserIdForUpdate()
Different investors on the same opportunity serialize on step 1. The same investor on different opportunities may block on their wallet at step 2 — never in reverse order.
| Lock target | Prevents |
|---|---|
Opportunity FOR UPDATE |
Two threads allocating more than remaining_units |
Investor profile FOR UPDATE |
Double-spend of wallet balance |
UNIQUE (idempotency_key, investor_id) |
Double-processing the same HTTP retry |
CHECK (remaining_units >= 0) |
Over-allocation even if application logic regresses |
UNIQUE (investment_order_id) on allocations |
One allocation per order |
@Version on Opportunity and InvestorProfile remains for non-allocation updates (admin edits). The allocation path does not rely on optimistic retry.
After the opportunity row is locked, AllocationCalculator.computeFill() runs:
unitsToAllocate = min(unitsRequested, remainingUnits)
amountAllocated = unitsToAllocate × unitPrice
| Condition | Result |
|---|---|
remainingUnits == 0 |
REJECTED / INSUFFICIENT_UNITS |
amountAllocated < minimumLot |
REJECTED / BELOW_MINIMUM_LOT |
unitsToAllocate < unitsRequested |
PARTIAL — allocate available units |
unitsToAllocate == unitsRequested |
FULL |
Partial fills still require sufficient wallet balance for the allocated amount (not the full request).
When remaining_units reaches zero after decrement, OpportunityStateMachine transitions LIVE → FULLY_SUBSCRIBED.
| Race | Mitigation |
|---|---|
| Two investors over-subscribe 100 units | Opportunity FOR UPDATE serializes; second thread sees updated remaining_units |
| Same investor double-clicks submit | Idempotency unique constraint + pre-check |
| Retry after network timeout | Idempotency replay returns original order |
| Wallet debited twice | Investor profile FOR UPDATE + single debit per confirmed order |
| Audit/outbox missing after allocation | Same transaction as business writes |
| Stale listing shows old unit count | Cache evicted on successful allocation |
Funds move from wallet_balance to locked_balance (not an external payment gateway in this phase):
wallet_balance -= amountAllocated
locked_balance += amountAllocated
The check wallet_balance >= amountAllocated runs while the investor profile row is locked.
allocation/
controller/AllocationController.java
service/AllocationEngine.java ← transaction owner
service/CommitmentService.java ← idempotency header validation
domain/AllocationCalculator.java ← pure fill/yield math
domain/InvestmentOrder.java
domain/Allocation.java
audit/service/AuditService.java
notification/service/OutboxWriter.java
portfolio/domain/PortfolioPosition.java ← created on successful allocation
- Unit:
AllocationCalculatorTest— fill decisions and amount validation - Integration:
ConcurrentCommitmentIntegrationTest— parallel threads, sum(allocations) ≤ total units - Integration:
IdempotencyIntegrationTest— duplicate key → single order row