db-cutover 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- db_cutover/__init__.py +3 -0
- db_cutover/__main__.py +3 -0
- db_cutover/cli.py +230 -0
- db_cutover/commands/__init__.py +0 -0
- db_cutover/commands/blue.py +189 -0
- db_cutover/commands/inspect.py +590 -0
- db_cutover/commands/replication.py +535 -0
- db_cutover/commands/switch.py +429 -0
- db_cutover/commands/terraform.py +346 -0
- db_cutover/config.py +286 -0
- db_cutover/db.py +489 -0
- db_cutover/output.py +77 -0
- db_cutover/pgbouncer.py +220 -0
- db_cutover/shell.py +88 -0
- db_cutover/skills/db-cutover/SKILL.md +140 -0
- db_cutover/skills/db-cutover/references/commands.md +104 -0
- db_cutover/skills/db-cutover/references/recovery.md +64 -0
- db_cutover/skills/db-cutover/references/rehearsal.md +69 -0
- db_cutover-0.2.0.dist-info/METADATA +231 -0
- db_cutover-0.2.0.dist-info/RECORD +23 -0
- db_cutover-0.2.0.dist-info/WHEEL +4 -0
- db_cutover-0.2.0.dist-info/entry_points.txt +2 -0
- db_cutover-0.2.0.dist-info/licenses/LICENSE +21 -0
db_cutover/__init__.py
ADDED
db_cutover/__main__.py
ADDED
db_cutover/cli.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""db-cutover CONFIG COMMAND [options]. Run `db-cutover --help` for the commands, and see docs/procedure.md."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import math
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tomllib
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import psycopg
|
|
15
|
+
|
|
16
|
+
from db_cutover import __version__, config
|
|
17
|
+
from db_cutover.commands import blue, inspect, replication, switch, terraform
|
|
18
|
+
from db_cutover.db import Context
|
|
19
|
+
from db_cutover.output import StepFailed, error_detail
|
|
20
|
+
from db_cutover.shell import CommandFailed
|
|
21
|
+
|
|
22
|
+
Command = Callable[[Context, argparse.Namespace], None]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def nonnegative_float(value: str) -> float:
|
|
26
|
+
number = float(value)
|
|
27
|
+
if not math.isfinite(number) or number < 0:
|
|
28
|
+
raise argparse.ArgumentTypeError("must be a finite number >= 0")
|
|
29
|
+
return number
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def positive_float(value: str) -> float:
|
|
33
|
+
number = nonnegative_float(value)
|
|
34
|
+
if number == 0:
|
|
35
|
+
raise argparse.ArgumentTypeError("must be greater than 0")
|
|
36
|
+
return number
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def positive_int(value: str) -> int:
|
|
40
|
+
number = int(value)
|
|
41
|
+
if number <= 0:
|
|
42
|
+
raise argparse.ArgumentTypeError("must be an integer greater than 0")
|
|
43
|
+
return number
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parser() -> argparse.ArgumentParser:
|
|
47
|
+
p = argparse.ArgumentParser(
|
|
48
|
+
prog="db-cutover",
|
|
49
|
+
description="Blue/green PostgreSQL cutover on RDS with logical replication and PgBouncer. Every command "
|
|
50
|
+
"that changes something is a dry run without --execute.",
|
|
51
|
+
epilog="`db-cutover skill` prints where the packaged agent skill is, and `db-cutover skill --install DIR` "
|
|
52
|
+
"copies it into an agent's skills directory.",
|
|
53
|
+
)
|
|
54
|
+
p.add_argument("--version", action="version", version=f"db-cutover {__version__}")
|
|
55
|
+
p.add_argument("config", help="path to the cutover's TOML config")
|
|
56
|
+
sub = p.add_subparsers(dest="command", required=True, metavar="COMMAND")
|
|
57
|
+
|
|
58
|
+
def add(name: str, fn: Command, help: str) -> argparse.ArgumentParser:
|
|
59
|
+
sp = sub.add_parser(name, help=help, description=(fn.__doc__ or help).strip())
|
|
60
|
+
sp.set_defaults(fn=fn)
|
|
61
|
+
return sp
|
|
62
|
+
|
|
63
|
+
def execute(sp: argparse.ArgumentParser) -> None:
|
|
64
|
+
sp.add_argument("--execute", action="store_true", help="make the change (without it: a dry run)")
|
|
65
|
+
|
|
66
|
+
# Read-only
|
|
67
|
+
add("env", inspect.cmd_env, "identifiers, hosts and instance settings")
|
|
68
|
+
sp = add("preflight", inspect.cmd_preflight, "read-only checks before starting")
|
|
69
|
+
sp.add_argument("--json", action="store_true")
|
|
70
|
+
sp = add("status", inspect.cmd_status, "one-shot status of BLUE, GREEN, replication and PgBouncer")
|
|
71
|
+
sp.add_argument("--json", action="store_true")
|
|
72
|
+
sp = add("watch", inspect.cmd_watch, "long-running: a line per change, ALERT lines for problems")
|
|
73
|
+
sp.add_argument("--interval", type=positive_float, default=30, help="seconds between database checks (default 30)")
|
|
74
|
+
sp.add_argument(
|
|
75
|
+
"--health-interval", type=positive_float, default=5, help="seconds between health checks (default 5)"
|
|
76
|
+
)
|
|
77
|
+
sp.add_argument(
|
|
78
|
+
"--slow-ms", type=positive_float, default=3000, help="health check slower than this alerts (default 3000)"
|
|
79
|
+
)
|
|
80
|
+
sp.add_argument(
|
|
81
|
+
"--health-timeout", type=positive_float, default=30, help="seconds before a health check fails (default 30)"
|
|
82
|
+
)
|
|
83
|
+
sp.add_argument(
|
|
84
|
+
"--max-retained-gb", type=positive_float, default=20, help="alert when BLUE keeps more WAL (default 20)"
|
|
85
|
+
)
|
|
86
|
+
sp = add("probe", inspect.cmd_probe, "long-running: writes through PgBouncer and reports errors and pauses")
|
|
87
|
+
execute(sp)
|
|
88
|
+
sp.add_argument("--interval", type=positive_float, default=0.5, help="seconds between writes (default 0.5)")
|
|
89
|
+
sp.add_argument(
|
|
90
|
+
"--slow-ms", type=positive_float, default=1000, help="writes slower than this are reported (default 1000)"
|
|
91
|
+
)
|
|
92
|
+
sp = add("pgbouncer", inspect.cmd_pgbouncer, "SHOW ... on every PgBouncer pod, or resume them all")
|
|
93
|
+
execute(sp)
|
|
94
|
+
sp.add_argument("what", choices=["pools", "servers", "clients", "databases", "stats", "config", "resume"])
|
|
95
|
+
|
|
96
|
+
# Before replication
|
|
97
|
+
sp = add("reboot-blue", blue.cmd_reboot_blue, "apply BLUE's pending parameters by rebooting it, PgBouncer paused")
|
|
98
|
+
execute(sp)
|
|
99
|
+
sp.add_argument("--force", action="store_true", help="reboot even if nothing is pending-reboot")
|
|
100
|
+
sp.add_argument("--allow-direct", action="store_true", help="reboot even with direct writers connected to BLUE")
|
|
101
|
+
sp.add_argument(
|
|
102
|
+
"--failover",
|
|
103
|
+
action="store_true",
|
|
104
|
+
help="Multi-AZ: fail over to the standby instead of restarting in place (longer: adds PgBouncer's DNS wait)",
|
|
105
|
+
)
|
|
106
|
+
sp.add_argument(
|
|
107
|
+
"--max-pause", type=positive_float, default=90, help="resume after this many seconds regardless (default 90)"
|
|
108
|
+
)
|
|
109
|
+
sp.add_argument(
|
|
110
|
+
"--pause-timeout", type=positive_float, default=30, help="give up if PAUSE takes longer (default 30)"
|
|
111
|
+
)
|
|
112
|
+
execute(add("prepare-blue", blue.cmd_prepare_blue, "REPLICA IDENTITY FULL where needed, and the probe table"))
|
|
113
|
+
|
|
114
|
+
# Replication
|
|
115
|
+
execute(add("copy-schema", replication.cmd_copy_schema, "create GREEN's database and copy BLUE's schema"))
|
|
116
|
+
execute(add("start-replication", replication.cmd_start_replication, "publication on BLUE, subscription on GREEN"))
|
|
117
|
+
sp = add("wait-sync", replication.cmd_wait_sync, "block until the initial copy is done and GREEN has caught up")
|
|
118
|
+
execute(sp)
|
|
119
|
+
sp.add_argument("--interval", type=positive_float, default=30)
|
|
120
|
+
sp.add_argument("--timeout", type=nonnegative_float, default=0, help="minutes; 0 waits forever")
|
|
121
|
+
sp = add("verify", replication.cmd_verify, "compare BLUE and GREEN (read-only)")
|
|
122
|
+
execute(sp)
|
|
123
|
+
sp.add_argument("--json", action="store_true")
|
|
124
|
+
sp.add_argument("--exact-count-rows", type=positive_int, default=100_000, help="count(*) tables up to this size")
|
|
125
|
+
sp.add_argument(
|
|
126
|
+
"--window-rows", type=positive_int, default=2_000, help="rows per hashed id window on bigger tables"
|
|
127
|
+
)
|
|
128
|
+
sp.add_argument("--tables", nargs="*", help="only these tables")
|
|
129
|
+
execute(add("sequences", replication.cmd_sequences, "move GREEN's sequences above BLUE's"))
|
|
130
|
+
|
|
131
|
+
# The switch
|
|
132
|
+
sp = add("cutover", switch.cmd_cutover, "switch PgBouncer from BLUE to GREEN")
|
|
133
|
+
execute(sp)
|
|
134
|
+
sp.add_argument(
|
|
135
|
+
"--rehearse", action="store_true", help="with --execute: run the barrier, then resume on BLUE instead of RELOAD"
|
|
136
|
+
)
|
|
137
|
+
sp.add_argument("--pause-timeout", type=positive_float, default=15, help="abort if PAUSE takes longer (default 15)")
|
|
138
|
+
sp.add_argument(
|
|
139
|
+
"--direct-wait", type=positive_float, default=3, help="seconds for BLUE's sessions to close (default 3)"
|
|
140
|
+
)
|
|
141
|
+
sp.add_argument("--catchup-timeout", type=positive_float, default=15, help="seconds for the marker (default 15)")
|
|
142
|
+
sp.add_argument(
|
|
143
|
+
"--max-paused", type=positive_float, default=20, help="abort if paused longer before RELOAD (default 20)"
|
|
144
|
+
)
|
|
145
|
+
sp.add_argument(
|
|
146
|
+
"--max-transaction-seconds", type=positive_float, default=5, help="older BLUE transactions block (default 5)"
|
|
147
|
+
)
|
|
148
|
+
sp = add("finish", switch.cmd_finish, "drop replication and the probe table once BLUE is quiet")
|
|
149
|
+
execute(sp)
|
|
150
|
+
sp.add_argument("--quiet-seconds", type=positive_float, default=60, help="BLUE must see no writes for this long")
|
|
151
|
+
|
|
152
|
+
# Terraform
|
|
153
|
+
sp = add("green-tf", terraform.cmd_green_tf, "write GREEN's Terraform file from BLUE's")
|
|
154
|
+
execute(sp)
|
|
155
|
+
sp.add_argument("--engine-version", required=True)
|
|
156
|
+
sp.add_argument("--family", help="parameter group family (default: derived from the engine version)")
|
|
157
|
+
sp.add_argument("--storage-type", help="e.g. gp3 (default: BLUE's)")
|
|
158
|
+
sp.add_argument("--set", action="append", metavar="NAME=HCL", help="another instance argument, e.g. multi_az=false")
|
|
159
|
+
sp.add_argument("--force", action="store_true", help="overwrite an existing GREEN file")
|
|
160
|
+
sp = add("promote-tf", terraform.cmd_promote_tf, "after finish: GREEN takes BLUE's Terraform names")
|
|
161
|
+
execute(sp)
|
|
162
|
+
sp.add_argument("--force", action="store_true", help="overwrite an existing old file")
|
|
163
|
+
return p
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
SKILL_NAME = "db-cutover"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def skill_dir() -> Path:
|
|
170
|
+
"""The agent skill: inside the installed package, or at the repository root in a source checkout."""
|
|
171
|
+
package = Path(__file__).resolve().parent
|
|
172
|
+
for candidate in (package / "skills" / SKILL_NAME, package.parents[1] / "skills" / SKILL_NAME):
|
|
173
|
+
if (candidate / "SKILL.md").is_file():
|
|
174
|
+
return candidate
|
|
175
|
+
raise FileNotFoundError(f"the {SKILL_NAME} skill is missing from this installation")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def skill_command(argv: list[str]) -> None:
|
|
179
|
+
"""`db-cutover skill`: takes no config, so it is parsed apart from the cutover commands."""
|
|
180
|
+
p = argparse.ArgumentParser(
|
|
181
|
+
prog="db-cutover skill",
|
|
182
|
+
description="The agent skill that teaches a coding agent to plan, rehearse and run a cutover with an "
|
|
183
|
+
"operator. Without options, prints the skill's directory.",
|
|
184
|
+
)
|
|
185
|
+
p.add_argument(
|
|
186
|
+
"--install", metavar="DIR", help="copy the skill to DIR/db-cutover (e.g. .claude/skills or .agents/skills)"
|
|
187
|
+
)
|
|
188
|
+
p.add_argument("--force", action="store_true", help="with --install: replace a db-cutover skill already there")
|
|
189
|
+
args = p.parse_args(argv)
|
|
190
|
+
source = skill_dir()
|
|
191
|
+
if not args.install:
|
|
192
|
+
print(source)
|
|
193
|
+
return
|
|
194
|
+
target = Path(args.install).expanduser() / SKILL_NAME
|
|
195
|
+
if target.exists():
|
|
196
|
+
if not args.force:
|
|
197
|
+
print(f"{target} already exists: pass --force to replace it", file=sys.stderr)
|
|
198
|
+
sys.exit(1)
|
|
199
|
+
shutil.rmtree(target)
|
|
200
|
+
shutil.copytree(source, target)
|
|
201
|
+
print(f"installed the {SKILL_NAME} skill (version {__version__}) to {target}")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def main(argv: list[str] | None = None) -> None:
|
|
205
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
206
|
+
if argv[:1] == ["skill"]:
|
|
207
|
+
skill_command(argv[1:])
|
|
208
|
+
return
|
|
209
|
+
args = parser().parse_args(argv)
|
|
210
|
+
try:
|
|
211
|
+
ctx = Context(config.load(args.config))
|
|
212
|
+
args.fn(ctx, args)
|
|
213
|
+
except config.ConfigError as e:
|
|
214
|
+
print(f"config error: {e}", file=sys.stderr)
|
|
215
|
+
sys.exit(2)
|
|
216
|
+
except StepFailed as e:
|
|
217
|
+
print(f"\nFAILED: {e}", file=sys.stderr)
|
|
218
|
+
sys.exit(1)
|
|
219
|
+
except psycopg.Error as e:
|
|
220
|
+
print(f"\nFAILED: {error_detail(e)}", file=sys.stderr)
|
|
221
|
+
sys.exit(1)
|
|
222
|
+
except subprocess.TimeoutExpired:
|
|
223
|
+
print("\nFAILED: external command timed out (command and output withheld)", file=sys.stderr)
|
|
224
|
+
sys.exit(1)
|
|
225
|
+
except (CommandFailed, OSError, ValueError, tomllib.TOMLDecodeError) as e:
|
|
226
|
+
print(f"\nFAILED: {type(e).__name__}; inspect configuration and local tool setup privately", file=sys.stderr)
|
|
227
|
+
sys.exit(1)
|
|
228
|
+
except KeyboardInterrupt:
|
|
229
|
+
print("\ninterrupted", file=sys.stderr)
|
|
230
|
+
sys.exit(130)
|
|
File without changes
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Commands that change BLUE before replication starts: reboot-blue and prepare-blue."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import psycopg
|
|
9
|
+
from psycopg import sql
|
|
10
|
+
|
|
11
|
+
from db_cutover import db
|
|
12
|
+
from db_cutover.commands.inspect import direct_writer_replicas
|
|
13
|
+
from db_cutover.commands.switch import split_direct
|
|
14
|
+
from db_cutover.db import Context, q1
|
|
15
|
+
from db_cutover.output import fail, log
|
|
16
|
+
from db_cutover.pgbouncer import PgBouncer, Pod
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def cmd_prepare_blue(ctx: Context, args: argparse.Namespace) -> None:
|
|
20
|
+
"""REPLICA IDENTITY FULL on tables without a primary key (their UPDATEs and DELETEs fail while they're in a
|
|
21
|
+
publication otherwise), and the probe table that markers and `probe` write to. Idempotent."""
|
|
22
|
+
cfg = ctx.cfg
|
|
23
|
+
with ctx.blue() as blue:
|
|
24
|
+
missing = db.tables_without_replica_identity(blue)
|
|
25
|
+
has_probe = db.table_exists(blue, f"public.{cfg.probe_table}")
|
|
26
|
+
if not args.execute:
|
|
27
|
+
for table in missing:
|
|
28
|
+
print(f" would run: ALTER TABLE {table} REPLICA IDENTITY FULL")
|
|
29
|
+
if not has_probe:
|
|
30
|
+
print(f" would create table public.{cfg.probe_table} and grant {cfg.app_user} INSERT and SELECT")
|
|
31
|
+
log("dry run: nothing changed. Run again with --execute." if missing or not has_probe else "nothing to do")
|
|
32
|
+
return
|
|
33
|
+
for table in missing:
|
|
34
|
+
log(f"ALTER TABLE {table} REPLICA IDENTITY FULL")
|
|
35
|
+
db.ddl(blue, sql.SQL("ALTER TABLE {} REPLICA IDENTITY FULL").format(sql.SQL(table)))
|
|
36
|
+
if has_probe:
|
|
37
|
+
log(f"{cfg.probe_table} already exists on BLUE")
|
|
38
|
+
else:
|
|
39
|
+
log(f"creating public.{cfg.probe_table} on BLUE")
|
|
40
|
+
table = sql.Identifier(cfg.probe_table)
|
|
41
|
+
user = sql.Identifier(cfg.app_user)
|
|
42
|
+
with blue.transaction():
|
|
43
|
+
blue.execute(
|
|
44
|
+
sql.SQL(
|
|
45
|
+
"""CREATE TABLE public.{} (
|
|
46
|
+
id bigserial PRIMARY KEY,
|
|
47
|
+
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
|
48
|
+
source text NOT NULL,
|
|
49
|
+
note text)"""
|
|
50
|
+
).format(table)
|
|
51
|
+
)
|
|
52
|
+
blue.execute(sql.SQL("GRANT SELECT, INSERT ON public.{} TO {}").format(table, user))
|
|
53
|
+
blue.execute(
|
|
54
|
+
sql.SQL("GRANT USAGE ON SEQUENCE public.{} TO {}").format(
|
|
55
|
+
sql.Identifier(f"{cfg.probe_table}_id_seq"), user
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
log("BLUE prepared")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _postmaster_start(ctx: Context) -> tuple[object, str] | None:
|
|
62
|
+
try:
|
|
63
|
+
with ctx.blue(timeout=5) as blue:
|
|
64
|
+
row = q1(blue, "SELECT pg_postmaster_start_time() AS t, current_setting('wal_level') AS w")
|
|
65
|
+
return (row["t"], row["w"]) if row else None
|
|
66
|
+
except psycopg.Error:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cmd_reboot_blue(ctx: Context, args: argparse.Namespace) -> None:
|
|
71
|
+
"""Applies BLUE's pending static parameters (rds.logical_replication) by rebooting it with PgBouncer paused, so
|
|
72
|
+
clients wait instead of failing. BLUE restarts in place, Multi-AZ or not: the parameter group belongs to the
|
|
73
|
+
instance, so a standby that takes over later starts with it too. --failover fails over instead, which
|
|
74
|
+
takes longer (PgBouncer has to wait out its DNS cache for the new address). Traffic resumes after --max-pause
|
|
75
|
+
seconds even if BLUE isn't back. Direct connections drop."""
|
|
76
|
+
cfg = ctx.cfg
|
|
77
|
+
inst = ctx.aws.instance(ctx.blue_id, refresh=True)
|
|
78
|
+
assert inst is not None
|
|
79
|
+
groups = [(g["DBParameterGroupName"], g["ParameterApplyStatus"]) for g in inst["DBParameterGroups"]]
|
|
80
|
+
print(f"BLUE {ctx.blue_id}: {inst['DBInstanceStatus']}, multi-AZ {inst['MultiAZ']}, parameter groups {groups}")
|
|
81
|
+
if not any(status == "pending-reboot" for _, status in groups) and not args.force:
|
|
82
|
+
fail("no parameter group is pending-reboot: apply the parameter change first, or pass --force")
|
|
83
|
+
if inst["DBInstanceStatus"] != "available":
|
|
84
|
+
fail(f"BLUE is {inst['DBInstanceStatus']}, not available")
|
|
85
|
+
failover = bool(inst["MultiAZ"]) and args.failover
|
|
86
|
+
before = _postmaster_start(ctx)
|
|
87
|
+
if not before:
|
|
88
|
+
fail("can't connect to BLUE")
|
|
89
|
+
# PgBouncer holds its clients through the restart; direct connections fail instead (a job runner logs
|
|
90
|
+
# ConnectionNotEstablished until BLUE is back).
|
|
91
|
+
replicas = direct_writer_replicas(ctx)
|
|
92
|
+
with ctx.blue() as blue:
|
|
93
|
+
direct, _ = split_direct(ctx, db.client_sessions(blue))
|
|
94
|
+
if (direct or any(r not in ("0", "") for r in replicas.values())) and not args.allow_direct:
|
|
95
|
+
fail(
|
|
96
|
+
f"direct writers are running (replicas {replicas}, {len(direct)} sessions on BLUE): they'd get connection "
|
|
97
|
+
"errors during the restart. Scale them to 0 first, or pass --allow-direct."
|
|
98
|
+
)
|
|
99
|
+
if not args.execute:
|
|
100
|
+
log(
|
|
101
|
+
f"dry run: would pause PgBouncer, reboot {ctx.blue_id}{' with failover' if failover else ''}, and resume "
|
|
102
|
+
f"once BLUE accepts connections (or after {args.max_pause}s). Run again with --execute."
|
|
103
|
+
)
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
with PgBouncer(cfg) as bouncer:
|
|
107
|
+
configs = bouncer.on_all(lambda pod: pod.admin("SHOW CONFIG"), timeout=10)
|
|
108
|
+
settings = {
|
|
109
|
+
pod: {r["key"]: r["value"] for r in rows} for pod, rows in configs.items() if isinstance(rows, list)
|
|
110
|
+
}
|
|
111
|
+
if len(settings) != len(bouncer.pods):
|
|
112
|
+
fail(f"SHOW CONFIG failed: {configs}")
|
|
113
|
+
original_wait = {pod: s.get("query_wait_timeout") for pod, s in settings.items()}
|
|
114
|
+
dns_ttl = max(float(s.get("dns_max_ttl", 15)) for s in settings.values())
|
|
115
|
+
wait_timeout = int(args.max_pause + 60)
|
|
116
|
+
|
|
117
|
+
def set_wait(pod: Pod, value: object) -> None:
|
|
118
|
+
pod.admin(f"SET query_wait_timeout = {value}")
|
|
119
|
+
|
|
120
|
+
def resume() -> None:
|
|
121
|
+
results = bouncer.resume_all()
|
|
122
|
+
if any(isinstance(r, Exception) for r in results.values()):
|
|
123
|
+
fail(f"could not confirm RESUME on every pod: {results}; manual recovery required")
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
results = bouncer.on_all(lambda pod: set_wait(pod, wait_timeout), timeout=10)
|
|
127
|
+
if any(isinstance(r, Exception) for r in results.values()):
|
|
128
|
+
fail(f"could not set query_wait_timeout on every pod: {results}")
|
|
129
|
+
log(f"query_wait_timeout raised to {wait_timeout}s (was {original_wait})")
|
|
130
|
+
started = time.monotonic()
|
|
131
|
+
results = bouncer.on_all(lambda pod: pod.admin("PAUSE"), timeout=args.pause_timeout)
|
|
132
|
+
errors = {k: v for k, v in results.items() if isinstance(v, Exception) and "already" not in str(v)}
|
|
133
|
+
if errors:
|
|
134
|
+
resume()
|
|
135
|
+
fail(f"PAUSE failed; resumed, nothing rebooted: {errors}")
|
|
136
|
+
log(f"PgBouncer paused in {(time.monotonic() - started) * 1000:.0f} ms; rebooting {ctx.blue_id}")
|
|
137
|
+
reboot = ["rds", "reboot-db-instance", "--db-instance-identifier", ctx.blue_id]
|
|
138
|
+
if failover:
|
|
139
|
+
reboot.append("--force-failover")
|
|
140
|
+
try:
|
|
141
|
+
ctx.aws.call(*reboot)
|
|
142
|
+
except Exception as e:
|
|
143
|
+
resume()
|
|
144
|
+
fail(f"the reboot call failed; resumed: {e}")
|
|
145
|
+
|
|
146
|
+
back = None
|
|
147
|
+
while True:
|
|
148
|
+
elapsed = time.monotonic() - started
|
|
149
|
+
if back is None:
|
|
150
|
+
now_start = _postmaster_start(ctx)
|
|
151
|
+
if now_start and now_start[0] != before[0]:
|
|
152
|
+
back = elapsed
|
|
153
|
+
log(f"{elapsed:.0f}s: BLUE is back (wal_level {now_start[1]})")
|
|
154
|
+
if failover:
|
|
155
|
+
log(f"waiting {dns_ttl:.0f}s for PgBouncer's DNS cache (dns_max_ttl) to see the failover")
|
|
156
|
+
else:
|
|
157
|
+
log(f"{elapsed:.0f}s: waiting for BLUE")
|
|
158
|
+
ready = back is not None and (not failover or elapsed - back >= dns_ttl)
|
|
159
|
+
if ready or elapsed > args.max_pause:
|
|
160
|
+
resume()
|
|
161
|
+
if ready:
|
|
162
|
+
log(f"RESUMED after {elapsed:.1f}s paused")
|
|
163
|
+
else:
|
|
164
|
+
fail(f"resumed after {elapsed:.0f}s without BLUE back: clients get errors until it is")
|
|
165
|
+
break
|
|
166
|
+
time.sleep(1)
|
|
167
|
+
except BaseException:
|
|
168
|
+
resume()
|
|
169
|
+
raise
|
|
170
|
+
finally:
|
|
171
|
+
restored = bouncer.on_all(lambda pod: set_wait(pod, original_wait[pod.name]), timeout=10)
|
|
172
|
+
if any(isinstance(r, Exception) for r in restored.values()):
|
|
173
|
+
fail(f"could not restore query_wait_timeout on every pod: {restored}; restore it manually")
|
|
174
|
+
log("query_wait_timeout restored")
|
|
175
|
+
|
|
176
|
+
# PostgreSQL accepts connections before RDS finishes its own post-reboot steps; traffic is already flowing.
|
|
177
|
+
log("waiting for RDS to report the instance available and its parameters in-sync")
|
|
178
|
+
deadline = time.monotonic() + 900
|
|
179
|
+
while time.monotonic() < deadline:
|
|
180
|
+
inst = ctx.aws.instance(ctx.blue_id, refresh=True) or {}
|
|
181
|
+
groups = [(g["DBParameterGroupName"], g["ParameterApplyStatus"]) for g in inst.get("DBParameterGroups", [])]
|
|
182
|
+
if inst.get("DBInstanceStatus") == "available" and all(status == "in-sync" for _, status in groups):
|
|
183
|
+
after = _postmaster_start(ctx)
|
|
184
|
+
log(f"BLUE available, parameter groups {groups}, wal_level {after[1] if after else 'unknown'}")
|
|
185
|
+
if not after or after[1] != "logical":
|
|
186
|
+
fail("wal_level is not logical after the reboot: check the parameter group")
|
|
187
|
+
return
|
|
188
|
+
time.sleep(10)
|
|
189
|
+
fail(f"RDS still reports {inst.get('DBInstanceStatus')} / {groups} after 15 minutes: check RDS events")
|