修复:补强使用并发幂等复核

This commit is contained in:
焦龙言
2026-07-26 21:43:55 +08:00
parent bc774c74e6
commit 33228faffc
4 changed files with 359 additions and 13 deletions
@@ -712,6 +712,17 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
if for_update:
actions.append('site-mapping-lock')
async def get_usage_by_key(
_db: AsyncSession,
_idempotency_key: str,
_site_ids: list[int] | None = None,
*,
for_update: bool = False,
**_kwargs: Any,
) -> None:
if for_update:
actions.append('usage-current-read')
async def add_usage(_db: AsyncSession, usage: BizUsageRecord) -> BizUsageRecord:
actions.append('usage-flush')
usage.usage_id = 61
@@ -740,7 +751,11 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
with (
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(AccessService, 'assert_site_access', new=access),
patch.object(OperationDao, 'get_usage_by_idempotency_key', new=AsyncMock(return_value=None)),
patch.object(
OperationDao,
'get_usage_by_idempotency_key',
new=AsyncMock(side_effect=get_usage_by_key),
),
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=self.pallet)),
patch.object(InventoryDao, 'get_inventory_for_update', new=AsyncMock(side_effect=lock_inventory)),
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(side_effect=lock_pallets)),
@@ -760,6 +775,7 @@ class TestRecordUsageCore(unittest.IsolatedAsyncioTestCase):
'inventory-lock',
'pallet-lock',
'filling-lock',
'usage-current-read',
'site-lock',
'site-mapping-lock',
'line-lock',
@@ -1065,6 +1081,58 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
request_hash=request_hash or UsageService._build_request_hash(self.page),
)
def _patch_concurrent_retry_path(
self,
*,
inventory: BizPalletInventory,
recovered: BizUsageRecord,
site_scope: list[int] | None = None,
site_status: str = '0',
revoke_mapping: bool = False,
) -> tuple[ExitStack, dict[str, AsyncMock], list[str]]:
actions: list[str] = []
locked_site = make_site(status=site_status)
async def read_key(*_args: Any, **kwargs: Any) -> BizUsageRecord | None:
for_update = kwargs.get('for_update', False)
actions.append('usage-current-read' if for_update else 'usage-snapshot-read')
return recovered if for_update else None
async def lock_inventory(*_args: Any) -> BizPalletInventory:
actions.append('inventory-lock')
return inventory
async def lock_pallet(*_args: Any) -> list[BizPallet]:
actions.append('pallet-lock')
return [self.core.pallet]
async def lock_filling(*_args: Any) -> BizFillingRecord:
actions.append('filling-lock')
return self.core.filling
async def lock_site(*_args: Any, **_kwargs: Any) -> BizCustomerSite:
actions.append('site-lock')
return locked_site
async def check_access(*_args: Any, **kwargs: Any) -> None:
if kwargs.get('for_update', False):
actions.append('site-mapping-lock')
if revoke_mapping:
raise PermissionException(data='', message='无权访问该客户现场')
stack, mocks = self.core._patch_record_path(
inventory=inventory,
site=locked_site,
site_scope=site_scope,
key_read=AsyncMock(side_effect=read_key),
access=AsyncMock(side_effect=check_access),
)
mocks['inventory_lock'].side_effect = lock_inventory
mocks['pallet_lock'].side_effect = lock_pallet
mocks['filling_lock'].side_effect = lock_filling
mocks['site_lock'].side_effect = lock_site
return stack, mocks, actions
async def test_exact_retry_rechecks_current_mapping_and_returns_without_mutation(self) -> None:
existing = self._exact_usage()
inventory_lock = AsyncMock()
@@ -1084,7 +1152,12 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
):
result = await UsageService.record_usage_services(self.db, self.page, self.user)
get_key.assert_awaited_once_with(self.db, 'usage-key', site_ids=[9])
get_key.assert_awaited_once_with(
self.db,
'usage-key',
site_ids=[9],
for_update=False,
)
site_lock.assert_awaited_once_with(self.db, 9, for_update=True)
access.assert_awaited_once_with(self.db, self.user, 9, for_update=True)
inventory_lock.assert_not_awaited()
@@ -1123,10 +1196,186 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_concurrent_exact_retry_wins_before_stale_inventory_rejection(self) -> None:
cases = (
(
'first-request-emptied-pallet',
make_inventory(
current_line_id=4,
active_filling_id=None,
remaining_packages=0,
flow_state=PalletFlowState.AT_SITE_EMPTY.value,
version=4,
),
False,
0,
'1',
),
(
'first-request-left-less-than-original-usage',
make_inventory(
current_line_id=4,
remaining_packages=2,
flow_state=PalletFlowState.AT_SITE_PARTIAL.value,
version=4,
),
True,
2,
'0',
),
)
for name, inventory, expect_filling_lock, remaining_after, site_status in cases:
with self.subTest(case=name):
self.db.reset_mock()
recovered = self._exact_usage()
recovered.remaining_packages_after = remaining_after
stack, mocks, actions = self._patch_concurrent_retry_path(
inventory=inventory,
recovered=recovered,
site_status=site_status,
)
key_read = mocks['key_read']
inventory_before = (
inventory.active_filling_id,
inventory.current_line_id,
inventory.remaining_packages,
inventory.flow_state,
inventory.version,
)
with stack:
try:
result = await UsageService.record_usage_services(
self.db,
self.page,
self.user,
)
except ServiceException as exc:
self.fail(f'精确并发重试被旧库存状态提前拒绝:{exc.message}')
expected_actions = [
'usage-snapshot-read',
'inventory-lock',
'pallet-lock',
]
if expect_filling_lock:
expected_actions.append('filling-lock')
expected_actions.extend(
[
'usage-current-read',
'site-lock',
'site-mapping-lock',
]
)
self.assertEqual(expected_actions, actions)
self.assertEqual([False, True], [call.kwargs['for_update'] for call in key_read.await_args_list])
self.assertTrue(all(call.kwargs['site_ids'] == [9] for call in key_read.await_args_list))
if expect_filling_lock:
mocks['filling_lock'].assert_awaited_once()
else:
mocks['filling_lock'].assert_not_awaited()
mocks['site_lock'].assert_awaited_once_with(self.db, 9, for_update=True)
mocks['line_lock'].assert_not_awaited()
mocks['add_usage'].assert_not_awaited()
mocks['add_event'].assert_not_awaited()
mocks['update_filling'].assert_not_awaited()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
self.assertEqual(
inventory_before,
(
inventory.active_filling_id,
inventory.current_line_id,
inventory.remaining_packages,
inventory.flow_state,
inventory.version,
),
)
self.assertEqual(61, result.result)
async def test_concurrent_exact_retry_rechecks_current_mapping_before_success(self) -> None:
inventory = make_inventory(
current_line_id=4,
active_filling_id=None,
remaining_packages=0,
flow_state=PalletFlowState.AT_SITE_EMPTY.value,
version=4,
)
recovered = self._exact_usage()
stack, mocks, _ = self._patch_concurrent_retry_path(
inventory=inventory,
recovered=recovered,
revoke_mapping=True,
)
key_read = mocks['key_read']
access = mocks['access']
with stack:
try:
await UsageService.record_usage_services(self.db, self.page, self.user)
except PermissionException as exc:
self.assertEqual('无权访问该使用记录', exc.message)
except ServiceException as exc:
self.fail(f'撤权并发重试未进入 current mapping 复核:{exc.message}')
else:
self.fail('current mapping 已撤权时不得返回幂等成功')
self.assertEqual([False, True], [call.kwargs['for_update'] for call in key_read.await_args_list])
mocks['site_lock'].assert_awaited_once_with(self.db, 9, for_update=True)
self.assertEqual([False, True], [call.kwargs.get('for_update', False) for call in access.await_args_list])
mocks['line_lock'].assert_not_awaited()
mocks['add_usage'].assert_not_awaited()
mocks['add_event'].assert_not_awaited()
mocks['update_filling'].assert_not_awaited()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_concurrent_current_read_rejects_hash_or_actual_site_conflict(self) -> None:
request_hash = UsageService._build_request_hash(self.page)
conflicts = (
('different-request-hash', make_usage(request_hash='different-hash')),
('different-actual-site', make_usage(site_id=10, request_hash=request_hash)),
)
for name, recovered in conflicts:
with self.subTest(case=name):
self.db.reset_mock()
inventory = make_inventory(
current_line_id=4,
active_filling_id=None,
remaining_packages=0,
flow_state=PalletFlowState.AT_SITE_EMPTY.value,
version=4,
)
stack, mocks, _ = self._patch_concurrent_retry_path(
inventory=inventory,
recovered=recovered,
site_scope=[9, 10],
)
key_read = mocks['key_read']
with stack:
try:
await UsageService.record_usage_services(self.db, self.page, self.user)
except ServiceException as exc:
self.assertEqual(
'使用失败,幂等键已被其他请求使用',
exc.message,
)
else:
self.fail('锁后命中的摘要或实际现场冲突不得返回成功')
self.assertEqual([False, True], [call.kwargs['for_update'] for call in key_read.await_args_list])
self.assertTrue(all(call.kwargs['site_ids'] == [9, 10] for call in key_read.await_args_list))
mocks['site_lock'].assert_not_awaited()
mocks['line_lock'].assert_not_awaited()
mocks['add_usage'].assert_not_awaited()
mocks['add_event'].assert_not_awaited()
mocks['update_filling'].assert_not_awaited()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_integrity_error_recovers_only_fresh_scoped_exact_result(self) -> None:
recovered = self._exact_usage()
scope = AsyncMock(side_effect=[[9], [9]])
key_read = AsyncMock(side_effect=[None, recovered])
key_read = AsyncMock(side_effect=[None, None, recovered])
add_usage = AsyncMock(side_effect=integrity_error())
stack, mocks = self.core._patch_record_path(
site_scope=scope,
@@ -1138,8 +1387,9 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
self.assertEqual(61, result.result)
self.assertEqual(2, scope.await_count)
self.assertEqual(2, key_read.await_count)
self.assertEqual([9], key_read.await_args_list[1].kwargs['site_ids'])
self.assertEqual(3, key_read.await_count)
self.assertEqual([False, True, False], [call.kwargs['for_update'] for call in key_read.await_args_list])
self.assertEqual([9], key_read.await_args_list[2].kwargs['site_ids'])
self.assertEqual(2, self.db.rollback.await_count)
self.db.commit.assert_not_awaited()
mocks['add_event'].assert_not_awaited()
@@ -1148,7 +1398,7 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
for recovered in (self._exact_usage(request_hash='different-hash'), None):
with self.subTest(recovered=recovered is not None):
self.db.reset_mock()
key_read = AsyncMock(side_effect=[None, recovered])
key_read = AsyncMock(side_effect=[None, None, recovered])
stack, _ = self.core._patch_record_path(
key_read=key_read,
add_usage=AsyncMock(side_effect=integrity_error()),
@@ -1171,7 +1421,8 @@ class TestUsageIdempotency(unittest.IsolatedAsyncioTestCase):
await UsageService.record_usage_services(self.db, self.page, self.user)
self.assertEqual(2, scope.await_count)
self.assertEqual(1, key_read.await_count)
self.assertEqual(2, key_read.await_count)
self.assertEqual([False, True], [call.kwargs['for_update'] for call in key_read.await_args_list])
self.assertEqual(2, self.db.rollback.await_count)
mocks['add_event'].assert_not_awaited()
self.db.commit.assert_not_awaited()