修复:完善旧数据纳入事务读取
This commit is contained in:
@@ -69,8 +69,10 @@ class GraphSession:
|
||||
|
||||
def __init__(self, rows: dict[str, dict[int, SimpleNamespace]]) -> None:
|
||||
self.rows = rows
|
||||
self.statements: list[Any] = []
|
||||
|
||||
async def execute(self, statement: Any) -> _RowsResult:
|
||||
self.statements.append(statement)
|
||||
columns = list(statement.selected_columns)
|
||||
table_name = columns[0].table.name
|
||||
sql = str(statement.compile(compile_kwargs={'literal_binds': True}))
|
||||
@@ -115,6 +117,28 @@ def make_batch(**overrides: Any) -> SimpleNamespace:
|
||||
return SimpleNamespace(**values)
|
||||
|
||||
|
||||
class CommitExpiringBatch:
|
||||
"""模拟 AsyncSession commit 后 ORM 属性过期。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.expired = False
|
||||
self._batch_name = '旧数据回归批次'
|
||||
self.batch_id = 7
|
||||
self.status = 'ACTIVE'
|
||||
self.preview_action = None
|
||||
self.preview_token_hash = None
|
||||
self.preview_scope_hash = None
|
||||
self.preview_expires_at = None
|
||||
self.update_by = 'admin'
|
||||
self.update_time = datetime(2026, 9, 23, 9, 0, 0)
|
||||
|
||||
@property
|
||||
def batch_name(self) -> str:
|
||||
if self.expired:
|
||||
raise RuntimeError('commit 后不得读取过期 ORM 属性')
|
||||
return self._batch_name
|
||||
|
||||
|
||||
def preview_command() -> AdoptionPreviewRequestModel:
|
||||
return AdoptionPreviewRequestModel(roots=[AdoptionRootModel(resourceType='pallet', resourceIds=[10])])
|
||||
|
||||
@@ -214,6 +238,16 @@ class TestBatchGraphTest(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual({41}, closure['biz_pallet_event'])
|
||||
self.assertEqual({10}, closure['biz_pallet'])
|
||||
|
||||
async def test_execute_closure_uses_for_update_current_reads(self) -> None:
|
||||
roots = [AdoptionRootModel(resourceType='pallet', resourceIds=[10])]
|
||||
|
||||
await TestBatchGraph.compute_locked_closure(self.db, roots)
|
||||
|
||||
self.assertTrue(self.db.statements)
|
||||
for statement in self.db.statements:
|
||||
with self.subTest(sql=str(statement)):
|
||||
self.assertIn('FOR UPDATE', str(statement.compile()).upper())
|
||||
|
||||
|
||||
class TestBatchAdoptionContractTest(unittest.TestCase):
|
||||
def test_public_service_signatures_are_stable(self) -> None:
|
||||
@@ -288,6 +322,10 @@ class TestBatchAdoptionServiceTest(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(7, result.batch_id)
|
||||
self.assertEqual(2, result.total_count)
|
||||
self.assertEqual(list(BATCH_MODELS), [impact.table_name for impact in result.impacts])
|
||||
self.assertEqual(
|
||||
list(TABLE_LABELS.items()),
|
||||
[(impact.table_name, impact.label) for impact in result.impacts],
|
||||
)
|
||||
self.assertEqual(
|
||||
[('P-10',), ('U-50',)],
|
||||
[tuple(impact.keys) for impact in result.impacts if impact.count],
|
||||
@@ -299,6 +337,20 @@ class TestBatchAdoptionServiceTest(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertLessEqual(batch.preview_expires_at, after + timedelta(seconds=601))
|
||||
self.db.commit.assert_awaited_once_with()
|
||||
|
||||
async def test_preview_materializes_response_before_commit_expires_batch(self) -> None:
|
||||
batch = CommitExpiringBatch()
|
||||
|
||||
async def expire_batch() -> None:
|
||||
batch.expired = True
|
||||
|
||||
self.db.commit.side_effect = expire_batch
|
||||
with self._preview_stack(batch):
|
||||
result = await TestBatchAdoptionService.preview(self.db, 7, preview_command(), make_admin())
|
||||
|
||||
self.assertEqual('旧数据回归批次', result.batch_name)
|
||||
self.db.commit.assert_awaited_once_with()
|
||||
self.db.rollback.assert_not_awaited()
|
||||
|
||||
async def test_closure_rejects_any_already_tagged_record(self) -> None:
|
||||
batch = make_batch()
|
||||
with (
|
||||
@@ -338,7 +390,7 @@ class TestBatchAdoptionServiceTest(unittest.IsolatedAsyncioTestCase):
|
||||
patch.object(TestBatchDao, 'get_batch_for_update', AsyncMock(return_value=preview_batch)),
|
||||
patch.object(TestBatchAdoptionDao, 'lock_roots', AsyncMock()),
|
||||
patch.object(TestBatchAdoptionDao, 'lock_selection', AsyncMock()),
|
||||
patch.object(TestBatchGraph, 'compute_closure', AsyncMock(side_effect=[self.selection, changed])),
|
||||
patch.object(TestBatchGraph, 'compute_locked_closure', AsyncMock(side_effect=[self.selection, changed])),
|
||||
patch.object(TestBatchAdoptionDao, 'set_test_batch_id', set_batch),
|
||||
self.assertRaisesRegex(ServiceWarning, '预览范围已变化'),
|
||||
):
|
||||
@@ -365,6 +417,33 @@ class TestBatchAdoptionServiceTest(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
self.db.rollback.assert_awaited_once_with()
|
||||
|
||||
async def test_execute_rejects_closed_batch_before_business_row_access(self) -> None:
|
||||
lock_roots = AsyncMock()
|
||||
compute_closure = AsyncMock()
|
||||
lock_selection = AsyncMock()
|
||||
update = AsyncMock()
|
||||
with (
|
||||
patch.object(TestBatchDao, 'get_batch_for_update', AsyncMock(return_value=make_batch(status='CLOSED'))),
|
||||
patch.object(TestBatchAdoptionDao, 'lock_roots', lock_roots),
|
||||
patch.object(TestBatchGraph, 'compute_locked_closure', compute_closure),
|
||||
patch.object(TestBatchAdoptionDao, 'lock_selection', lock_selection),
|
||||
patch.object(TestBatchAdoptionDao, 'set_test_batch_id', update),
|
||||
self.assertRaisesRegex(ServiceWarning, '测试批次已关闭'),
|
||||
):
|
||||
await TestBatchAdoptionService.execute(
|
||||
self.db,
|
||||
7,
|
||||
execute_command('x' * 32),
|
||||
make_admin(),
|
||||
)
|
||||
|
||||
lock_roots.assert_not_awaited()
|
||||
compute_closure.assert_not_awaited()
|
||||
lock_selection.assert_not_awaited()
|
||||
update.assert_not_awaited()
|
||||
self.db.rollback.assert_awaited_once_with()
|
||||
self.db.commit.assert_not_awaited()
|
||||
|
||||
async def test_execute_rejects_expired_token(self) -> None:
|
||||
raw_token = 'p' * 43
|
||||
batch = make_batch(
|
||||
@@ -394,7 +473,7 @@ class TestBatchAdoptionServiceTest(unittest.IsolatedAsyncioTestCase):
|
||||
patch.object(TestBatchDao, 'get_batch_for_update', AsyncMock(return_value=batch)),
|
||||
patch.object(TestBatchAdoptionDao, 'lock_roots', AsyncMock()),
|
||||
patch.object(TestBatchAdoptionDao, 'lock_selection', AsyncMock()),
|
||||
patch.object(TestBatchGraph, 'compute_closure', AsyncMock(return_value=self.selection)),
|
||||
patch.object(TestBatchGraph, 'compute_locked_closure', AsyncMock(return_value=self.selection)),
|
||||
patch.object(TestBatchAdoptionDao, 'find_tagged_record', AsyncMock(return_value=None)),
|
||||
patch.object(TestBatchAdoptionDao, 'set_test_batch_id', update),
|
||||
):
|
||||
@@ -432,7 +511,7 @@ class TestBatchAdoptionServiceTest(unittest.IsolatedAsyncioTestCase):
|
||||
patch.object(TestBatchDao, 'get_batch_for_update', AsyncMock(return_value=batch)),
|
||||
patch.object(TestBatchAdoptionDao, 'lock_roots', AsyncMock()),
|
||||
patch.object(TestBatchAdoptionDao, 'lock_selection', AsyncMock()),
|
||||
patch.object(TestBatchGraph, 'compute_closure', AsyncMock(return_value=self.selection)),
|
||||
patch.object(TestBatchGraph, 'compute_locked_closure', AsyncMock(return_value=self.selection)),
|
||||
patch.object(TestBatchAdoptionDao, 'find_tagged_record', AsyncMock(return_value=None)),
|
||||
patch.object(TestBatchAdoptionDao, 'set_test_batch_id', update),
|
||||
self.assertRaisesRegex(RuntimeError, 'update failed'),
|
||||
|
||||
Reference in New Issue
Block a user