test_phase_three_ownership.py 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. from __future__ import annotations
  2. from pathlib import Path
  3. import pytest
  4. from sqlalchemy import select
  5. from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
  6. from script_build_host.domain.errors import (
  7. MissionAlreadyOwned,
  8. MissionFencingTokenStale,
  9. MissionStopRequested,
  10. )
  11. from script_build_host.domain.records import BuildStatus
  12. from script_build_host.infrastructure.legacy_tables import script_build_record
  13. from script_build_host.infrastructure.ownership import FencedCommandGate, OwnerLease
  14. from script_build_host.infrastructure.tables import mission_binding_table
  15. from script_build_host.repositories.legacy_state import SqlAlchemyLegacyBuildStateRepository
  16. from script_build_host.repositories.sqlalchemy import SqlAlchemyMissionBindingRepository
  17. async def _bound_build(sessions: async_sessionmaker[AsyncSession]) -> int:
  18. state = SqlAlchemyLegacyBuildStateRepository(sessions)
  19. build_id = await state.create(
  20. execution_id=1,
  21. topic_build_id=2,
  22. topic_id=3,
  23. agent_type="AigcAgent",
  24. agent_config={},
  25. data_source_url=None,
  26. strategies_config={},
  27. root_trace_id="root-owner-test",
  28. )
  29. await SqlAlchemyMissionBindingRepository(sessions).create(
  30. script_build_id=build_id,
  31. root_trace_id="root-owner-test",
  32. input_snapshot_id=1,
  33. engine_version="test",
  34. schema_version="test/v1",
  35. )
  36. return build_id
  37. @pytest.mark.asyncio
  38. async def test_owner_lease_excludes_second_owner_and_grows_epoch(
  39. database: tuple[AsyncEngine, async_sessionmaker[AsyncSession]], tmp_path: Path
  40. ) -> None:
  41. _, sessions = database
  42. build_id = await _bound_build(sessions)
  43. first = OwnerLease(sessions, tmp_path / "leases", owner_instance_id="owner-a")
  44. second = OwnerLease(sessions, tmp_path / "leases", owner_instance_id="owner-b")
  45. first_token = await first.acquire(build_id)
  46. with pytest.raises(MissionAlreadyOwned):
  47. await second.acquire(build_id)
  48. await first.release()
  49. second_token = await second.acquire(build_id)
  50. assert second_token.owner_epoch == first_token.owner_epoch + 1
  51. with pytest.raises(MissionFencingTokenStale):
  52. await FencedCommandGate(sessions).verify(first_token)
  53. await second.release()
  54. @pytest.mark.asyncio
  55. async def test_stop_epoch_fences_current_owner_without_taking_process_lease(
  56. database: tuple[AsyncEngine, async_sessionmaker[AsyncSession]], tmp_path: Path
  57. ) -> None:
  58. _, sessions = database
  59. build_id = await _bound_build(sessions)
  60. lease = OwnerLease(sessions, tmp_path / "leases", owner_instance_id="owner")
  61. token = await lease.acquire(build_id)
  62. gate = FencedCommandGate(sessions)
  63. await gate.verify(token)
  64. assert await gate.request_stop(build_id) == token.stop_epoch + 1
  65. with pytest.raises(MissionStopRequested):
  66. await gate.verify(token)
  67. assert (
  68. await SqlAlchemyLegacyBuildStateRepository(sessions).get_status(build_id)
  69. is BuildStatus.STOPPING
  70. )
  71. await lease.release()
  72. @pytest.mark.asyncio
  73. async def test_phase_three_state_and_snapshot_mutations_share_the_owner_fence(
  74. database: tuple[AsyncEngine, async_sessionmaker[AsyncSession]], tmp_path: Path
  75. ) -> None:
  76. _, sessions = database
  77. build_id = await _bound_build(sessions)
  78. lease = OwnerLease(sessions, tmp_path / "leases", owner_instance_id="phase-three-owner")
  79. token = await lease.acquire(build_id)
  80. gate = FencedCommandGate(sessions)
  81. try:
  82. await gate.compare_and_set_input_snapshot(token, expected_snapshot_id=1, new_snapshot_id=2)
  83. await gate.begin_phase(token)
  84. await gate.set_checkpoint(
  85. token,
  86. checkpoint_code="PHASE_THREE_ROOT_ACCEPTED",
  87. summary="accepted",
  88. )
  89. await gate.set_checkpoint(
  90. token,
  91. checkpoint_code="PHASE_THREE_ROOT_ACCEPTED",
  92. summary="accepted",
  93. )
  94. async with sessions() as session:
  95. assert (
  96. await session.scalar(
  97. select(mission_binding_table.c.input_snapshot_id).where(
  98. mission_binding_table.c.script_build_id == build_id
  99. )
  100. )
  101. == 2
  102. )
  103. row = (
  104. (
  105. await session.execute(
  106. select(script_build_record).where(script_build_record.c.id == build_id)
  107. )
  108. )
  109. .mappings()
  110. .one()
  111. )
  112. assert row["status"] == BuildStatus.PARTIAL.value
  113. assert row["error_message"] == "PHASE_THREE_ROOT_ACCEPTED"
  114. await gate.request_stop(build_id)
  115. with pytest.raises(MissionStopRequested):
  116. await gate.begin_phase(token)
  117. with pytest.raises(MissionStopRequested):
  118. await gate.compare_and_set_input_snapshot(
  119. token, expected_snapshot_id=2, new_snapshot_id=3
  120. )
  121. finally:
  122. await lease.release()
  123. @pytest.mark.asyncio
  124. async def test_failed_build_cannot_be_resurrected_by_an_owner_token(
  125. database: tuple[AsyncEngine, async_sessionmaker[AsyncSession]], tmp_path: Path
  126. ) -> None:
  127. _, sessions = database
  128. build_id = await _bound_build(sessions)
  129. lease = OwnerLease(sessions, tmp_path / "leases", owner_instance_id="failed-owner")
  130. token = await lease.acquire(build_id)
  131. state = SqlAlchemyLegacyBuildStateRepository(sessions)
  132. await state.set_status(build_id, BuildStatus.FAILED, error_summary="failed")
  133. gate = FencedCommandGate(sessions)
  134. try:
  135. with pytest.raises(MissionFencingTokenStale):
  136. await gate.begin_phase(token)
  137. with pytest.raises(MissionFencingTokenStale):
  138. await gate.set_checkpoint(
  139. token,
  140. checkpoint_code="PHASE_THREE_ROOT_ACCEPTED",
  141. summary="must not restore",
  142. )
  143. with pytest.raises(MissionFencingTokenStale):
  144. await gate.compare_and_set_input_snapshot(
  145. token, expected_snapshot_id=1, new_snapshot_id=2
  146. )
  147. assert await state.get_status(build_id) is BuildStatus.FAILED
  148. finally:
  149. await lease.release()