Skip to content

Commit 774f9b6

Browse files
authored
Fix the DR drill's replica startup race; add a never-promoted standby (#266)
* Health-check the DR drill's Postgres over TCP so the replica waits for the real server * Add a never-promoted standby to the failover drills
1 parent c247fda commit 774f9b6

3 files changed

Lines changed: 65 additions & 19 deletions

File tree

‎AGENTS.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ the vocabulary. Read it before naming a concept in code, a test name, or an issu
2020
publishes no host port, so run the DB tests through `just test`. The coverage gate is on by
2121
default, so a partial run trips it; pass `--no-cov` while iterating.
2222
- `just test-dr` runs the failover drills in `tests_dr/` (primary killed, primary's packets
23-
dropped), each on a fresh primary/replica cluster from `docker-compose.dr.yml`. It needs the
24-
Docker socket and `NET_ADMIN`, takes a few minutes, and is not in CI.
23+
dropped), each on a fresh cluster from `docker-compose.dr.yml`: a primary, a replica that gets
24+
promoted, and a standby that stays read-only. It needs the Docker socket and `NET_ADMIN`, takes
25+
a few minutes, and is not in CI.
2526
- Nothing validates Markdown links outside `docs/`. `just docs-build` runs `mkdocs --strict` over
2627
the site only; root Markdown, `.github/`, and `docs/agents/` are unchecked.
2728

‎docker-compose.dr.yml‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@ services:
1515
depends_on:
1616
pg-replica:
1717
condition: service_healthy
18+
pg-standby:
19+
condition: service_healthy
1820
environment:
1921
- PRIMARY_DSN=postgresql://outbox:outbox@pg-primary:5432/outbox
22+
- STANDBY_DSN=postgresql://outbox:outbox@pg-standby:5432/outbox
2023
- REPLICA_DSN=postgresql://outbox:outbox@pg-replica:5432/outbox
2124

2225
pg-primary:
@@ -29,7 +32,7 @@ services:
2932
volumes:
3033
- ./tests_dr/primary-init.sh:/docker-entrypoint-initdb.d/primary-init.sh:ro
3134
healthcheck:
32-
test: ["CMD-SHELL", "pg_isready -U outbox -d outbox"]
35+
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U outbox -d outbox"]
3336
interval: 1s
3437
timeout: 3s
3538
retries: 30
@@ -47,7 +50,25 @@ services:
4750
pg-primary:
4851
condition: service_healthy
4952
healthcheck:
50-
test: ["CMD-SHELL", "pg_isready -U outbox -d outbox"]
53+
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U outbox -d outbox"]
54+
interval: 1s
55+
timeout: 3s
56+
retries: 60
57+
58+
pg-standby:
59+
image: postgres:17
60+
container_name: outbox-dr-standby
61+
user: postgres
62+
entrypoint: ["bash", "/replica-entrypoint.sh"]
63+
environment:
64+
- PGPASSWORD=outbox
65+
volumes:
66+
- ./tests_dr/replica-entrypoint.sh:/replica-entrypoint.sh:ro
67+
depends_on:
68+
pg-primary:
69+
condition: service_healthy
70+
healthcheck:
71+
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U outbox -d outbox"]
5172
interval: 1s
5273
timeout: 3s
5374
retries: 60

‎tests_dr/test_failover.py‎

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717

1818
PRIMARY_DSN = os.environ.get("PRIMARY_DSN", "")
19+
STANDBY_DSN = os.environ.get("STANDBY_DSN", "")
1920
REPLICA_DSN = os.environ.get("REPLICA_DSN", "")
2021
DOCKER_SOCKET = "/var/run/docker.sock"
2122
PRIMARY_CONTAINER = "outbox-dr-primary"
@@ -33,19 +34,31 @@
3334
pytestmark = 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

5164
async 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+
102123
async 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"])
111132
async 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"\npublish 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

Comments
 (0)