fix(lastleg): read committed order command replays after lock waits

This commit is contained in:
2026-10-03 12:13:34 +08:00
parent 34cac9c42a
commit eadb431f33
6 changed files with 100 additions and 8 deletions
@@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
@@ -55,7 +56,9 @@ public class LastlegOrderCommandService {
@Resource private InventoryCommandService inventory;
@Resource private ObjectMapper objectMapper;
@Transactional
// Ownership is read before waiting for the order lock. Under MySQL repeatable
// read that snapshot can hide the preceding writer's idempotency record.
@Transactional(isolation = Isolation.READ_COMMITTED)
public LastlegOrderCommandDtos.HoldResponse hold(String requestedCustomerId, long orderId,
String idempotencyKey,
LastlegOrderCommandDtos.HoldRequest request,
@@ -122,7 +125,7 @@ public class LastlegOrderCommandService {
return response;
}
@Transactional
@Transactional(isolation = Isolation.READ_COMMITTED)
public LastlegOrderCommandDtos.ManualNodesResponse saveManualNodes(String requestedCustomerId, long orderId,
String idempotencyKey,
LastlegOrderCommandDtos.ManualNodesRequest request,
@@ -137,6 +137,32 @@ class InventoryHoldConcurrencyPersistenceTest {
assertEquals(12,amount("available_packages")+amount("hold_packages")+amount("allocated_packages")+amount("picked_packages")+shipped);
assertEquals(shipped,jdbc.queryForObject("SELECT COALESCE(-SUM(delta_packages),0) FROM ll_inventory_ledger WHERE business_type='SHIP'",Long.class));
}
@Test void simultaneousDuplicateHoldAndReleaseAdvanceOnceAndKeepIndependentHold() throws Exception {
inventory.hold("C1","operator",move(22,2,"OTHER-HOLD"));
ExecutorService pool=Executors.newFixedThreadPool(2);
try {
for (boolean held : new boolean[]{true,false}) {
int version=held?0:1;
String key=held?"parallel-hold":"parallel-release";
var ready=new CountDownLatch(2);var start=new CountDownLatch(1);
Callable<LastlegOrderCommandDtos.HoldResponse> command=()->{
ready.countDown();await(start);
return orders.hold("C1",11,key,new LastlegOrderCommandDtos.HoldRequest(held,false,"quality",version),"operator","Operator");
};
var first=pool.submit(command);var second=pool.submit(command);
await(ready);start.countDown();
var response=first.get(8,TimeUnit.SECONDS);
assertEquals(response,second.get(8,TimeUnit.SECONDS));
assertEquals(version+1,response.version());
assertEquals(version+1,orderMapper.selectById(11L).getVersion());
}
assertEquals(2,count("ll_order_command_record"));
assertEquals(2,count("ll_order_hold_history"));
assertEquals(5,count("ll_inventory_operation"));
assertEquals(5,count("ll_inventory_ledger"));
assertEquals(10,amount("available_packages"));assertEquals(2,amount("hold_packages"));conserved(0);
} finally {pool.shutdownNow();assertTrue(pool.awaitTermination(10,TimeUnit.SECONDS));}
}
@Test void secondHoldCycleMustMoveInventoryAgainAndNotReuseTheFirstCycle() {
hold(true,0,"hold-one"); hold(false,1,"release-one"); hold(true,2,"hold-two");
assertEquals(0,amount("available_packages")); assertEquals(12,amount("hold_packages"));
@@ -66,6 +66,18 @@ class LastlegOrderCommandServiceTest {
ReflectionTestUtils.setField(service, "objectMapper", JsonMapper.builder().findAndAddModules().build());
}
@Test
void commandTransactionsReadCommittedAfterWaitingForTheOrderLock() throws Exception {
for (var entry : List.of(
new Object[]{"hold", LastlegOrderCommandDtos.HoldRequest.class},
new Object[]{"saveManualNodes", LastlegOrderCommandDtos.ManualNodesRequest.class})) {
var method=LastlegOrderCommandService.class.getMethod((String)entry[0],String.class,long.class,
String.class,(Class<?>)entry[1],String.class,String.class);
assertEquals(org.springframework.transaction.annotation.Isolation.READ_COMMITTED,
method.getAnnotation(org.springframework.transaction.annotation.Transactional.class).isolation());
}
}
@Test
void holdsOnlySelectedOrderWithVersionAndImmutableHistory() {
LastlegOrder order = order(11L, 3);