fix(firstleg): read concurrent customs initialization with current row lock
This commit is contained in:
+10
-2
@@ -563,7 +563,12 @@ public class ContainerCustomsService {
|
||||
if (!"COMPLETED".equals(plan.loadingPlanStatus())) throw conflict("装柜完成后才能处理报关资料");
|
||||
boolean refund = rows.stream().anyMatch(row -> "TAX_REFUND".equals(row.taxMode()));
|
||||
mapper.insertContainer(IdWorker.getId(), planId, plan.containerBookingId(), refund ? WAITING : READY);
|
||||
return refresh(planId);
|
||||
// A concurrent INSERT IGNORE may wait for another request's insert to commit. The
|
||||
// transaction's earlier plan reads can retain an RR snapshot that cannot see that row.
|
||||
// Read the winning aggregate under its row lock, rather than reusing a snapshot read.
|
||||
ContainerRow container = mapper.lockContainer(planId);
|
||||
if (container == null) throw conflict("整柜报关记录初始化失败");
|
||||
return refresh(planId, container);
|
||||
}
|
||||
|
||||
private ContainerRow ensureMutable(long planId) {
|
||||
@@ -573,7 +578,10 @@ public class ContainerCustomsService {
|
||||
}
|
||||
|
||||
private ContainerRow refresh(long planId) {
|
||||
ContainerRow value = mapper.container(planId);
|
||||
return refresh(planId, mapper.container(planId));
|
||||
}
|
||||
|
||||
private ContainerRow refresh(long planId, ContainerRow value) {
|
||||
if (value == null || !Set.of(WAITING, READY).contains(value.status())) return value;
|
||||
String status = mapper.missingRefundCount(planId, value.id()) == 0 ? READY : WAITING;
|
||||
if (!status.equals(value.status())) {
|
||||
|
||||
+10
@@ -11,6 +11,16 @@ import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ContainerCustomsMapperContractTest {
|
||||
@Test void initializationFlushesSessionCacheBeforeCurrentAggregateRead() {
|
||||
var configuration = new org.apache.ibatis.session.Configuration();
|
||||
configuration.addMapper(ContainerCustomsMapper.class);
|
||||
String namespace = ContainerCustomsMapper.class.getName() + ".";
|
||||
assertThat(configuration.getMappedStatement(namespace + "insertContainer").isFlushCacheRequired()).isTrue();
|
||||
var currentRead = configuration.getMappedStatement(namespace + "lockContainer");
|
||||
assertThat(currentRead.getBoundSql(java.util.Map.of("planId",9L)).getSql()).contains("FOR UPDATE");
|
||||
assertThat(currentRead.getCache()).isNull();
|
||||
}
|
||||
|
||||
@Test void merchantOrderUsesCompletedTaxRefundPlanWithRealCompletionTimestamp() throws Exception {
|
||||
String value=sql("completedPlanForOrder",long.class,long.class);
|
||||
assertThat(value)
|
||||
|
||||
+57
-2
@@ -52,6 +52,7 @@ class ContainerCustomsServiceTest {
|
||||
when(mapper.planInbounds(9L)).thenReturn(List.of());
|
||||
when(mapper.plan(9L)).thenReturn(plan);
|
||||
when(mapper.container(9L)).thenReturn(container(ContainerCustomsService.READY,0));
|
||||
when(mapper.lockContainer(9L)).thenReturn(container(ContainerCustomsService.READY,0));
|
||||
|
||||
var view=service.platformView(9L);
|
||||
|
||||
@@ -78,11 +79,12 @@ class ContainerCustomsServiceTest {
|
||||
when(mapper.planInbounds(9L)).thenReturn(List.of());
|
||||
when(mapper.plan(9L)).thenReturn(new ContainerCustomsMapper.PlanRow(9L,"LP9",8L,"COMPLETED","CAB","C"));
|
||||
when(mapper.container(9L)).thenReturn(container(ContainerCustomsService.READY,0));
|
||||
when(mapper.lockContainer(9L)).thenReturn(container(ContainerCustomsService.READY,0));
|
||||
|
||||
assertThatThrownBy(()->service.start(9L,5L,"admin","empty-plan",new ActionRequest(0,true,null)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("没有实际货物,无需报关");
|
||||
|
||||
verify(mapper,never()).lockContainer(9L);
|
||||
verify(mapper).lockContainer(9L);
|
||||
verify(mapper,never()).start(anyLong(),anyInt(),anyLong(),anyString());
|
||||
}
|
||||
|
||||
@@ -91,6 +93,7 @@ class ContainerCustomsServiceTest {
|
||||
when(mapper.completedPlanForOrder(7L,21L)).thenReturn(9L);
|
||||
when(mapper.planInbounds(9L)).thenReturn(List.of(row));
|
||||
when(mapper.container(9L)).thenReturn(container(ContainerCustomsService.READY,2));
|
||||
when(mapper.lockContainer(9L)).thenReturn(container(ContainerCustomsService.READY,2));
|
||||
when(mapper.actualBoxCount(9L,7L,"TAX_REFUND")).thenReturn(3L);
|
||||
|
||||
var view=service.merchantOrderView(7L,21L);
|
||||
@@ -101,6 +104,56 @@ class ContainerCustomsServiceTest {
|
||||
assertThat(view.inbounds()).extracting(value->value.orderId()).containsExactly(21L);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({"WAITING_CUSTOMER_DOCUMENTS", "DECLARING", "COMPLETED"})
|
||||
void concurrentFirstReadUsesCommittedWinnerInsteadOfOldSnapshot(String status) throws Exception {
|
||||
var own = new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND");
|
||||
var other = new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",12L,22L,"OTHER","OTHER-I",8L,"其他客户","TAX_REFUND");
|
||||
when(mapper.completedPlanForOrder(7L,21L)).thenReturn(9L);
|
||||
when(mapper.planInbounds(9L)).thenReturn(List.of(own, other));
|
||||
// RR snapshot predates another request's committed insert; INSERT IGNORE lost that race.
|
||||
when(mapper.insertContainer(anyLong(),eq(9L),eq(8L),anyString())).thenReturn(0);
|
||||
when(mapper.container(9L)).thenReturn(null);
|
||||
when(mapper.lockContainer(9L)).thenReturn(container(status, 3));
|
||||
when(mapper.missingRefundCount(9L,2L)).thenReturn(2);
|
||||
when(mapper.actualBoxCount(9L,7L,"TAX_REFUND")).thenReturn(100L);
|
||||
|
||||
var pool = Executors.newFixedThreadPool(4);
|
||||
try {
|
||||
var reads = java.util.stream.IntStream.range(0,4)
|
||||
.mapToObj(index -> pool.submit(() -> service.merchantOrderView(7L,21L))).toList();
|
||||
for (var read : reads) {
|
||||
var view = read.get(5,TimeUnit.SECONDS);
|
||||
assertThat(view.status()).isEqualTo(status);
|
||||
assertThat(view.version()).isEqualTo(3);
|
||||
assertThat(view.actualBoxCount()).isEqualTo(100);
|
||||
assertThat(view.inbounds()).extracting(value->value.orderId()).containsExactly(21L);
|
||||
}
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
}
|
||||
verify(mapper,times(4)).lockContainer(9L);
|
||||
verify(mapper,never()).container(9L);
|
||||
verify(mapper,never()).refreshStatus(anyLong(),anyInt(),anyString());
|
||||
}
|
||||
|
||||
@Test void currentAggregateStillRefreshesCoverageStatusAfterInitialization() {
|
||||
var row = new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND");
|
||||
when(mapper.completedPlanForOrder(7L,21L)).thenReturn(9L);
|
||||
when(mapper.planInbounds(9L)).thenReturn(List.of(row));
|
||||
when(mapper.lockContainer(9L)).thenReturn(container(ContainerCustomsService.WAITING,3));
|
||||
when(mapper.missingRefundCount(9L,2L)).thenReturn(0);
|
||||
when(mapper.refreshStatus(2L,3,ContainerCustomsService.READY)).thenReturn(1);
|
||||
// This transaction's own update is visible to its subsequent read.
|
||||
when(mapper.container(9L)).thenReturn(container(ContainerCustomsService.READY,4));
|
||||
|
||||
var view = service.merchantOrderView(7L,21L);
|
||||
|
||||
assertThat(view.status()).isEqualTo(ContainerCustomsService.READY);
|
||||
assertThat(view.version()).isEqualTo(4);
|
||||
verify(mapper).refreshStatus(2L,3,ContainerCustomsService.READY);
|
||||
}
|
||||
|
||||
@Test void uploadedSourceViewReturnsPersistedNonNullVersion(){
|
||||
var inbound=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND");
|
||||
var aggregate=container(ContainerCustomsService.READY,2);
|
||||
@@ -174,6 +227,7 @@ class ContainerCustomsServiceTest {
|
||||
when(mapper.planInbounds(9L)).thenReturn(List.of(inbound));
|
||||
when(mapper.plan(9L)).thenReturn(new ContainerCustomsMapper.PlanRow(9L,"LP9",8L,"COMPLETED","CAB","C"));
|
||||
when(mapper.container(9L)).thenReturn(declaring);
|
||||
when(mapper.lockContainer(9L)).thenReturn(declaring);
|
||||
when(mapper.actualBoxCount(9L,null,null)).thenReturn(0L);
|
||||
when(mapper.finalDocument(31L)).thenReturn(null);
|
||||
when(mapper.completion(31L)).thenReturn(null);
|
||||
@@ -215,6 +269,7 @@ class ContainerCustomsServiceTest {
|
||||
var file=new MockMultipartFile("file","source.bin","application/octet-stream",new byte[]{1});
|
||||
when(mapper.planInbounds(9L)).thenReturn(List.of(inbound));
|
||||
when(mapper.container(9L)).thenReturn(declaring);
|
||||
when(mapper.lockContainer(9L)).thenReturn(declaring);
|
||||
when(mapper.document(1L)).thenReturn(source);
|
||||
when(mapper.lockContainerById(2L)).thenReturn(declaring);
|
||||
|
||||
@@ -425,7 +480,7 @@ class ContainerCustomsServiceTest {
|
||||
var completed=container(ContainerCustomsService.COMPLETED,4);
|
||||
var buy=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","BUY_DECLARATION");
|
||||
when(mapper.claimCommand(anyLong(),eq(5L),eq("COMPLETE_ALL_BUY_CUSTOMS_9"),eq("all-buy-key"),anyString())).thenReturn(1);
|
||||
when(mapper.lockContainer(9L)).thenReturn(declaring);
|
||||
when(mapper.lockContainer(9L)).thenReturn(declaring,completed);
|
||||
when(mapper.planInbounds(9L)).thenReturn(List.of(buy));
|
||||
when(mapper.complete(2L,3,5L,"admin")).thenReturn(1);
|
||||
when(mapper.completeCommand(5L,"COMPLETE_ALL_BUY_CUSTOMS_9","all-buy-key",2L)).thenReturn(1);
|
||||
|
||||
Reference in New Issue
Block a user