1616
1717
1818PRIMARY_DSN = os .environ .get ("PRIMARY_DSN" , "" )
19+ STANDBY_DSN = os .environ .get ("STANDBY_DSN" , "" )
1920REPLICA_DSN = os .environ .get ("REPLICA_DSN" , "" )
2021DOCKER_SOCKET = "/var/run/docker.sock"
2122PRIMARY_CONTAINER = "outbox-dr-primary"
3334pytestmark = pytest .mark .skipif (not PRIMARY_DSN , reason = "needs the docker-compose.dr.yml cluster" )
3435
3536
36- async def _connect_to_primary () -> asyncpg .Connection :
37- """Try each host under its own timeout; asyncpg's ``timeout`` covers all hosts at once."""
38- errors : list [Exception ] = []
39- for dsn in (PRIMARY_DSN , REPLICA_DSN ):
40- try :
41- async with asyncio .timeout (CONNECT_TIMEOUT_SECONDS ):
42- return await asyncpg .connect (
43- dsn , target_session_attrs = "primary" , command_timeout = COMMAND_TIMEOUT_SECONDS
44- )
45- except (OSError , asyncpg .TargetServerAttributeNotMatched ) as e :
46- errors .append (e )
47- msg = "no primary reachable"
48- raise ExceptionGroup (msg , errors )
37+ def _primary_connector (rejected_standbys : list [str ]) -> Callable [[], Awaitable [asyncpg .Connection ]]:
38+ """Try each host under its own timeout; asyncpg's ``timeout`` covers all hosts at once.
39+
40+ The standby sits between the old primary and the promoted replica, so after failover every
41+ connect has to reject it as read-only before it reaches the new primary.
42+ """
43+
44+ async def connect () -> asyncpg .Connection :
45+ errors : list [Exception ] = []
46+ for dsn in (PRIMARY_DSN , STANDBY_DSN , REPLICA_DSN ):
47+ try :
48+ async with asyncio .timeout (CONNECT_TIMEOUT_SECONDS ):
49+ return await asyncpg .connect (
50+ dsn , target_session_attrs = "primary" , command_timeout = COMMAND_TIMEOUT_SECONDS
51+ )
52+ except asyncpg .TargetServerAttributeNotMatched as e :
53+ if dsn == STANDBY_DSN :
54+ rejected_standbys .append (dsn )
55+ errors .append (e )
56+ except OSError as e :
57+ errors .append (e )
58+ msg = "no primary reachable"
59+ raise ExceptionGroup (msg , errors )
60+
61+ return connect
4962
5063
5164async def _docker (method : str , path : str , body : dict [str , object ] | None = None ) -> None :
@@ -99,6 +112,14 @@ async def _wait_replica_caught_up() -> None:
99112 await replica .close ()
100113
101114
115+ async def _standby_in_recovery () -> bool :
116+ standby = await asyncpg .connect (STANDBY_DSN )
117+ try :
118+ return bool (await standby .fetchval ("SELECT pg_is_in_recovery()" ))
119+ finally :
120+ await standby .close ()
121+
122+
102123async def _promote_replica () -> None :
103124 replica = await asyncpg .connect (REPLICA_DSN )
104125 try :
@@ -109,7 +130,8 @@ async def _promote_replica() -> None:
109130
110131@pytest .mark .parametrize ("fail_primary" , [_kill_primary , _blackhole_primary ], ids = ["kill" , "blackhole" ])
111132async def test_subscriber_recovers_after_failover (fail_primary : Callable [[], Awaitable [None ]]) -> None :
112- engine = create_async_engine ("postgresql+asyncpg://" , async_creator = _connect_to_primary )
133+ rejected_standbys : list [str ] = []
134+ engine = create_async_engine ("postgresql+asyncpg://" , async_creator = _primary_connector (rejected_standbys ))
113135 metadata = MetaData ()
114136 table = make_outbox_table (metadata , table_name = "dr_outbox" )
115137 async with engine .begin () as conn :
@@ -169,8 +191,10 @@ async def publish_until_accepted(n: int, deadline: float) -> None:
169191 await publish (BEFORE + AFTER )
170192 published_at = time .monotonic ()
171193 await _wait_until (lambda : BEFORE + AFTER in handled_at , deadline = published_at + 3 , what = "NOTIFY wakeup" )
194+ assert rejected_standbys , "no connect attempt reached the standby"
195+ assert await _standby_in_recovery ()
172196
173197 print ( # noqa: T201
174198 f"\n publish recovered { publish_recovered :.1f} s, subscriber recovered { subscriber_recovered :.1f} s, "
175- f"redeliveries { len (deliveries ) - len (handled_at )} " ,
199+ f"redeliveries { len (deliveries ) - len (handled_at )} , standby rejected { len ( rejected_standbys ) } x " ,
176200 )
0 commit comments