pg-logical-repl 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.
- pg_logical_repl/__init__.py +3 -0
- pg_logical_repl/__main__.py +6 -0
- pg_logical_repl/capabilities.py +136 -0
- pg_logical_repl/catalog.py +320 -0
- pg_logical_repl/cli.py +277 -0
- pg_logical_repl/component.py +66 -0
- pg_logical_repl/config.py +356 -0
- pg_logical_repl/coverage.py +117 -0
- pg_logical_repl/cutover.py +239 -0
- pg_logical_repl/db.py +131 -0
- pg_logical_repl/ddl.py +352 -0
- pg_logical_repl/ddl_queue.py +461 -0
- pg_logical_repl/emergency.py +296 -0
- pg_logical_repl/errors.py +44 -0
- pg_logical_repl/health.py +194 -0
- pg_logical_repl/integrity.py +185 -0
- pg_logical_repl/logging_config.py +45 -0
- pg_logical_repl/monitoring.py +277 -0
- pg_logical_repl/plans.py +167 -0
- pg_logical_repl/preflight.py +290 -0
- pg_logical_repl/process.py +150 -0
- pg_logical_repl/recovery.py +188 -0
- pg_logical_repl/resync.py +267 -0
- pg_logical_repl/roles.py +123 -0
- pg_logical_repl/schema.py +89 -0
- pg_logical_repl/security.py +117 -0
- pg_logical_repl/service.py +773 -0
- pg_logical_repl/sql/base.sql +67 -0
- pg_logical_repl/sql/capture.sql +215 -0
- pg_logical_repl/sql/ddl_support.sql +109 -0
- pg_logical_repl/sql/freeze.sql +117 -0
- pg_logical_repl/sql/replay.sql +48 -0
- pg_logical_repl/state.py +160 -0
- pg_logical_repl/validation.py +353 -0
- pg_logical_repl-0.2.0.data/data/share/doc/pg-logical-repl/IMPLEMENTATION_STATUS.md +207 -0
- pg_logical_repl-0.2.0.data/data/share/doc/pg-logical-repl/README.md +406 -0
- pg_logical_repl-0.2.0.data/data/share/doc/pg-logical-repl/docs/DDL_IMPLEMENTATION.md +170 -0
- pg_logical_repl-0.2.0.data/data/share/doc/pg-logical-repl/docs/RELEASING.md +92 -0
- pg_logical_repl-0.2.0.data/data/share/doc/pg-logical-repl/docs/TESTING.md +142 -0
- pg_logical_repl-0.2.0.data/data/share/doc/pg-logical-repl/examples/local.yaml +55 -0
- pg_logical_repl-0.2.0.dist-info/METADATA +425 -0
- pg_logical_repl-0.2.0.dist-info/RECORD +45 -0
- pg_logical_repl-0.2.0.dist-info/WHEEL +5 -0
- pg_logical_repl-0.2.0.dist-info/entry_points.txt +2 -0
- pg_logical_repl-0.2.0.dist-info/top_level.txt +1 -0
pg_logical_repl/cli.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Thin CLI adapter; exactly one result envelope in machine mode."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import sys
|
|
9
|
+
import uuid
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from . import __version__, component, ddl, ddl_queue, logging_config, recovery
|
|
13
|
+
from .capabilities import describe
|
|
14
|
+
from .config import load
|
|
15
|
+
from .errors import ConfigurationError, ConnectorError
|
|
16
|
+
from .plans import KINDS
|
|
17
|
+
from .security import Redactor
|
|
18
|
+
from .service import Service
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Parser(argparse.ArgumentParser):
|
|
22
|
+
def error(self, message):
|
|
23
|
+
raise ConfigurationError(message)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parser():
|
|
27
|
+
p = Parser(prog="pg-logical-repl")
|
|
28
|
+
p.add_argument("--version", action="version", version=__version__)
|
|
29
|
+
p.add_argument("--capabilities", action="store_true")
|
|
30
|
+
p.add_argument("--component-capabilities", action="store_true", help=argparse.SUPPRESS)
|
|
31
|
+
p.add_argument("--contract", choices=[component.CONTRACT], help=argparse.SUPPRESS)
|
|
32
|
+
p.add_argument("--config", default=os.getenv("LC_CONFIG"))
|
|
33
|
+
p.add_argument("--machine", action="store_true")
|
|
34
|
+
p.add_argument("--request-id")
|
|
35
|
+
p.add_argument("--log-level", choices=["debug", "info", "warning"], default="info")
|
|
36
|
+
p.add_argument("--log-file")
|
|
37
|
+
commands = p.add_subparsers(dest="command")
|
|
38
|
+
for name in ("validate", "inspect-config", "check", "status", "report"):
|
|
39
|
+
commands.add_parser(name)
|
|
40
|
+
verify = commands.add_parser("verify")
|
|
41
|
+
verify.add_argument("--table")
|
|
42
|
+
verify.add_argument("--level", choices=["counts", "checksums", "full"], default="checksums")
|
|
43
|
+
plan = commands.add_parser("plan")
|
|
44
|
+
plan.add_argument("kind", choices=sorted(KINDS))
|
|
45
|
+
plan.add_argument("--out")
|
|
46
|
+
plan.add_argument("--sql-file")
|
|
47
|
+
plan.add_argument("--job-id", type=int)
|
|
48
|
+
plan.add_argument("--evidence")
|
|
49
|
+
plan.add_argument("--reason")
|
|
50
|
+
plan.add_argument("--action", choices=["skip", "applied-manually"])
|
|
51
|
+
plan.add_argument("--lsn")
|
|
52
|
+
plan.add_argument("--max-duration", type=int)
|
|
53
|
+
plan.add_argument("--wal-rate", type=int)
|
|
54
|
+
plan.add_argument("--source-unavailable", action="store_true")
|
|
55
|
+
plan.add_argument("--table")
|
|
56
|
+
for name in ("prepare", "start", "abort", "pause", "resume", "resolve", "cleanup", "resync-table"):
|
|
57
|
+
cmd = commands.add_parser(name)
|
|
58
|
+
cmd.add_argument("--plan", required=True)
|
|
59
|
+
cmd.add_argument("--plan-hash", required=True)
|
|
60
|
+
run = commands.add_parser("run")
|
|
61
|
+
run.add_argument("--once", action="store_true")
|
|
62
|
+
events = commands.add_parser("events")
|
|
63
|
+
events.add_argument("--after", type=int, default=0)
|
|
64
|
+
events.add_argument("--limit", type=int, default=100)
|
|
65
|
+
cutover = commands.add_parser("cutover").add_subparsers(dest="cutover_action", required=True)
|
|
66
|
+
for name in ("finalize", "commit"):
|
|
67
|
+
cmd = cutover.add_parser(name)
|
|
68
|
+
cmd.add_argument("--plan", required=True)
|
|
69
|
+
cmd.add_argument("--plan-hash", required=True)
|
|
70
|
+
if name == "finalize":
|
|
71
|
+
cmd.add_argument("--write-fence", required=True)
|
|
72
|
+
abort = cutover.add_parser("abort")
|
|
73
|
+
abort.add_argument("--reason", required=True)
|
|
74
|
+
ddls = commands.add_parser("ddl").add_subparsers(dest="ddl_action", required=True)
|
|
75
|
+
dc = ddls.add_parser("check")
|
|
76
|
+
dc.add_argument("file")
|
|
77
|
+
de = ddls.add_parser("exec")
|
|
78
|
+
de.add_argument("--sql-file", required=True)
|
|
79
|
+
de.add_argument("--plan", required=True)
|
|
80
|
+
de.add_argument("--plan-hash", required=True)
|
|
81
|
+
ddls.add_parser("log")
|
|
82
|
+
queue = ddls.add_parser("queue").add_subparsers(dest="queue_action", required=True)
|
|
83
|
+
queue.add_parser("list")
|
|
84
|
+
qr = queue.add_parser("retry")
|
|
85
|
+
qr.add_argument("id", type=int)
|
|
86
|
+
qs = queue.add_parser("skip")
|
|
87
|
+
qs.add_argument("id", type=int)
|
|
88
|
+
qs.add_argument("--evidence", required=True)
|
|
89
|
+
qs.add_argument("--plan", required=True)
|
|
90
|
+
qs.add_argument("--plan-hash", required=True)
|
|
91
|
+
return p
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def dispatch(args, service):
|
|
95
|
+
if args.command == "inspect-config":
|
|
96
|
+
return dict(
|
|
97
|
+
config_hash=service.config.hash,
|
|
98
|
+
migration_id=service.config.migration_id,
|
|
99
|
+
state_dir=str(service.config.state_dir),
|
|
100
|
+
ddl_mode=service.config.ddl_mode,
|
|
101
|
+
endpoints={
|
|
102
|
+
side: {
|
|
103
|
+
key: service.config.endpoint(side)[key]
|
|
104
|
+
for key in ("host", "port", "database", "user", "sslmode")
|
|
105
|
+
}
|
|
106
|
+
for side in ("source", "target")
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
if args.command == "validate":
|
|
110
|
+
return dict(valid=True, config_hash=service.config.hash)
|
|
111
|
+
if args.command == "verify":
|
|
112
|
+
return await service.verify(level=args.level, table=args.table)
|
|
113
|
+
if args.command in {"check", "status"}:
|
|
114
|
+
return await getattr(service, args.command)()
|
|
115
|
+
if args.command == "cutover":
|
|
116
|
+
if args.cutover_action == "abort":
|
|
117
|
+
return await service.cutover_abort(args.reason)
|
|
118
|
+
kw = {"write_fence": args.write_fence} if args.cutover_action == "finalize" else {}
|
|
119
|
+
return await getattr(service, "cutover_" + args.cutover_action)(args.plan, args.plan_hash, **kw)
|
|
120
|
+
if args.command == "report":
|
|
121
|
+
return dict(status=await service.status(), events=service.store.events())
|
|
122
|
+
if args.command == "plan":
|
|
123
|
+
extra = ddl.classify(Path(args.sql_file).read_text()) if args.sql_file else None
|
|
124
|
+
if args.kind == "ddl-queue-skip":
|
|
125
|
+
if args.job_id is None or not args.evidence:
|
|
126
|
+
raise ConfigurationError("ddl-queue-skip requires --job-id and --evidence")
|
|
127
|
+
extra = dict(job_id=args.job_id, **ddl_queue.read_evidence(args.evidence))
|
|
128
|
+
if args.kind in recovery.KINDS or args.kind in {"cleanup", "resync-table"} or args.source_unavailable:
|
|
129
|
+
extra = {
|
|
130
|
+
key: getattr(args, key)
|
|
131
|
+
for key in (
|
|
132
|
+
"reason",
|
|
133
|
+
"action",
|
|
134
|
+
"lsn",
|
|
135
|
+
"max_duration",
|
|
136
|
+
"wal_rate",
|
|
137
|
+
"source_unavailable",
|
|
138
|
+
"table",
|
|
139
|
+
)
|
|
140
|
+
if getattr(args, key) is not None
|
|
141
|
+
}
|
|
142
|
+
return await service.plan(args.kind, out=args.out, extra=extra)
|
|
143
|
+
if args.command == "ddl":
|
|
144
|
+
if args.ddl_action == "queue":
|
|
145
|
+
if args.queue_action == "list":
|
|
146
|
+
return await ddl_queue.listing(service)
|
|
147
|
+
if args.queue_action == "retry":
|
|
148
|
+
return await ddl_queue.retry(service, args.id)
|
|
149
|
+
return await ddl_queue.skip(
|
|
150
|
+
service,
|
|
151
|
+
args.plan,
|
|
152
|
+
args.plan_hash,
|
|
153
|
+
args.id,
|
|
154
|
+
ddl_queue.read_evidence(args.evidence)["reason"],
|
|
155
|
+
evidence_file=args.evidence,
|
|
156
|
+
)
|
|
157
|
+
if args.ddl_action == "check":
|
|
158
|
+
return ddl.classify(Path(args.file).read_text())
|
|
159
|
+
if args.ddl_action == "exec":
|
|
160
|
+
return await ddl.execute(service, args.plan, args.plan_hash, args.sql_file)
|
|
161
|
+
async with service.source.connect(readonly=True) as sc:
|
|
162
|
+
return [
|
|
163
|
+
dict(r)
|
|
164
|
+
for r in await sc.fetch("SELECT id,statement_sha256,captured_at FROM lc.ddl_log ORDER BY id")
|
|
165
|
+
]
|
|
166
|
+
if args.command in recovery.KINDS:
|
|
167
|
+
return await recovery.execute(service, args.command, args.plan, args.plan_hash)
|
|
168
|
+
if args.command == "resync-table":
|
|
169
|
+
from . import resync
|
|
170
|
+
|
|
171
|
+
return await resync.execute(service, args.plan, args.plan_hash)
|
|
172
|
+
if args.command == "cleanup":
|
|
173
|
+
from . import emergency
|
|
174
|
+
|
|
175
|
+
return await emergency.cleanup(service, args.plan, args.plan_hash)
|
|
176
|
+
if args.command in {"prepare", "start", "abort"}:
|
|
177
|
+
return await getattr(service, args.command)(args.plan, args.plan_hash)
|
|
178
|
+
if args.command == "events":
|
|
179
|
+
return service.store.events(args.after, args.limit)
|
|
180
|
+
if args.command == "run":
|
|
181
|
+
while True:
|
|
182
|
+
result = await service.run_once()
|
|
183
|
+
if args.once:
|
|
184
|
+
return result
|
|
185
|
+
if result["state"] in {"ABORTED", "DETACHED"}:
|
|
186
|
+
return result
|
|
187
|
+
await asyncio.sleep(service.config.get("daemon", "poll_interval_seconds", 5))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def main(argv=None):
|
|
191
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
192
|
+
redactor = Redactor()
|
|
193
|
+
code = 0
|
|
194
|
+
pg_play = "--component-capabilities" in argv or any(
|
|
195
|
+
value == "--contract=" + component.CONTRACT
|
|
196
|
+
or (value == "--contract" and argv[index + 1 : index + 2] == [component.CONTRACT])
|
|
197
|
+
for index, value in enumerate(argv)
|
|
198
|
+
)
|
|
199
|
+
envelope = dict(
|
|
200
|
+
contract_version="pg_logical_connector/result-v2",
|
|
201
|
+
tool="pg-logical-repl",
|
|
202
|
+
tool_version=__version__,
|
|
203
|
+
command=None,
|
|
204
|
+
request_id=str(uuid.uuid4()),
|
|
205
|
+
status="succeeded",
|
|
206
|
+
result=None,
|
|
207
|
+
artifacts=[],
|
|
208
|
+
advisories=[],
|
|
209
|
+
error=None,
|
|
210
|
+
)
|
|
211
|
+
try:
|
|
212
|
+
args = parser().parse_args(argv)
|
|
213
|
+
envelope.update(command=args.command, request_id=args.request_id or envelope["request_id"])
|
|
214
|
+
if args.capabilities or args.component_capabilities:
|
|
215
|
+
envelope.update(
|
|
216
|
+
command="capabilities", result=component.capabilities() if pg_play else describe()
|
|
217
|
+
)
|
|
218
|
+
if pg_play:
|
|
219
|
+
envelope, code = component.envelope(envelope, code)
|
|
220
|
+
print(json.dumps(envelope, ensure_ascii=False, indent=None if args.machine else 2))
|
|
221
|
+
return 0
|
|
222
|
+
if not args.command:
|
|
223
|
+
raise ConfigurationError("A command is required")
|
|
224
|
+
if not args.config:
|
|
225
|
+
raise ConfigurationError("--config or LC_CONFIG is required")
|
|
226
|
+
config = load(args.config)
|
|
227
|
+
service = Service(config, redactor)
|
|
228
|
+
service.store.request_id = envelope["request_id"]
|
|
229
|
+
logging_config.configure(args.log_level, args.log_file)
|
|
230
|
+
|
|
231
|
+
async def run_command():
|
|
232
|
+
loop = asyncio.get_running_loop()
|
|
233
|
+
task = asyncio.current_task()
|
|
234
|
+
loop.add_signal_handler(signal.SIGTERM, task.cancel)
|
|
235
|
+
try:
|
|
236
|
+
return await dispatch(args, service)
|
|
237
|
+
finally:
|
|
238
|
+
loop.remove_signal_handler(signal.SIGTERM)
|
|
239
|
+
|
|
240
|
+
envelope["result"] = asyncio.run(run_command())
|
|
241
|
+
if args.command == "check" and not envelope["result"]["passed"]:
|
|
242
|
+
envelope["status"] = "blocked"
|
|
243
|
+
code = 3
|
|
244
|
+
if args.command == "verify" and not envelope["result"]["passed"]:
|
|
245
|
+
envelope["status"] = "blocked"
|
|
246
|
+
code = 3
|
|
247
|
+
except (KeyboardInterrupt, asyncio.CancelledError) as exc:
|
|
248
|
+
code = 130 if isinstance(exc, KeyboardInterrupt) else 143
|
|
249
|
+
envelope.update(
|
|
250
|
+
status="cancelled",
|
|
251
|
+
error=dict(code="CANCELLED", message="Interrupted; inspect state before retry"),
|
|
252
|
+
)
|
|
253
|
+
except ConnectorError as exc:
|
|
254
|
+
code = exc.exit_code
|
|
255
|
+
envelope.update(status="failed", error=exc.as_dict())
|
|
256
|
+
except Exception as exc:
|
|
257
|
+
code = 6
|
|
258
|
+
envelope.update(
|
|
259
|
+
status="failed",
|
|
260
|
+
error=dict(
|
|
261
|
+
code="OPERATION_FAILED",
|
|
262
|
+
message=redactor.text(exc),
|
|
263
|
+
sqlstate=getattr(exc, "sqlstate", None),
|
|
264
|
+
retryable=False,
|
|
265
|
+
recovery_hint="Inspect state and catalog before retrying",
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
envelope = redactor.clean(envelope)
|
|
269
|
+
if pg_play:
|
|
270
|
+
envelope, code = component.envelope(envelope, code)
|
|
271
|
+
logging_config.close()
|
|
272
|
+
print(json.dumps(envelope, ensure_ascii=False, default=str, indent=None if "--machine" in argv else 2))
|
|
273
|
+
return code
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
if __name__ == "__main__":
|
|
277
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Optional pg_play transport; the native result and state formats stay unchanged."""
|
|
2
|
+
|
|
3
|
+
from . import __version__
|
|
4
|
+
from .capabilities import describe
|
|
5
|
+
|
|
6
|
+
CONTRACT = "pg_play/component/v2"
|
|
7
|
+
EXIT_CODES = dict(
|
|
8
|
+
success=0,
|
|
9
|
+
validation_error=2,
|
|
10
|
+
precondition_failed=3,
|
|
11
|
+
unsupported=4,
|
|
12
|
+
partial=5,
|
|
13
|
+
execution_error=6,
|
|
14
|
+
cancelled=7,
|
|
15
|
+
ownership_error=8,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def capabilities():
|
|
20
|
+
native = describe()
|
|
21
|
+
commands = {
|
|
22
|
+
name: {
|
|
23
|
+
**metadata,
|
|
24
|
+
"accepts_plan_hash": metadata["requires_plan"] is True,
|
|
25
|
+
}
|
|
26
|
+
for name, metadata in native["commands"].items()
|
|
27
|
+
}
|
|
28
|
+
commands["capabilities"] = dict(
|
|
29
|
+
mutates_target=False,
|
|
30
|
+
machine_output=True,
|
|
31
|
+
accepts_plan_hash=False,
|
|
32
|
+
)
|
|
33
|
+
return dict(
|
|
34
|
+
capability_schema_version="pg_play/capabilities/v1",
|
|
35
|
+
contract_version=CONTRACT,
|
|
36
|
+
component="pg_logical_repl",
|
|
37
|
+
component_version=__version__,
|
|
38
|
+
commands=commands,
|
|
39
|
+
exit_codes=EXIT_CODES,
|
|
40
|
+
machine_interface=dict(
|
|
41
|
+
machine_flag="--machine",
|
|
42
|
+
request_id_option="--request-id",
|
|
43
|
+
capabilities_option="--component-capabilities",
|
|
44
|
+
),
|
|
45
|
+
secret_policy=dict(credentials="references_only", output="redacted"),
|
|
46
|
+
contract_option=["--contract", CONTRACT],
|
|
47
|
+
native=native,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def envelope(value, code):
|
|
52
|
+
result = dict(value)
|
|
53
|
+
result["contract_version"] = CONTRACT
|
|
54
|
+
result["component"] = "pg_logical_repl"
|
|
55
|
+
result["component_version"] = result.pop("tool_version")
|
|
56
|
+
result.pop("tool")
|
|
57
|
+
result["command"] = result["command"] or "parse"
|
|
58
|
+
if code in {130, 143}:
|
|
59
|
+
code = EXIT_CODES["cancelled"]
|
|
60
|
+
elif code == 3:
|
|
61
|
+
result["status"] = "blocked"
|
|
62
|
+
elif code == 0 and result["command"] == "plan":
|
|
63
|
+
result["status"] = "planned"
|
|
64
|
+
# A successful inspection is distinct from a healthy migration. The result
|
|
65
|
+
# always retains the native state, including pending cleanup and BLOCKED.
|
|
66
|
+
return result, code
|