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.
Files changed (45) hide show
  1. pg_logical_repl/__init__.py +3 -0
  2. pg_logical_repl/__main__.py +6 -0
  3. pg_logical_repl/capabilities.py +136 -0
  4. pg_logical_repl/catalog.py +320 -0
  5. pg_logical_repl/cli.py +277 -0
  6. pg_logical_repl/component.py +66 -0
  7. pg_logical_repl/config.py +356 -0
  8. pg_logical_repl/coverage.py +117 -0
  9. pg_logical_repl/cutover.py +239 -0
  10. pg_logical_repl/db.py +131 -0
  11. pg_logical_repl/ddl.py +352 -0
  12. pg_logical_repl/ddl_queue.py +461 -0
  13. pg_logical_repl/emergency.py +296 -0
  14. pg_logical_repl/errors.py +44 -0
  15. pg_logical_repl/health.py +194 -0
  16. pg_logical_repl/integrity.py +185 -0
  17. pg_logical_repl/logging_config.py +45 -0
  18. pg_logical_repl/monitoring.py +277 -0
  19. pg_logical_repl/plans.py +167 -0
  20. pg_logical_repl/preflight.py +290 -0
  21. pg_logical_repl/process.py +150 -0
  22. pg_logical_repl/recovery.py +188 -0
  23. pg_logical_repl/resync.py +267 -0
  24. pg_logical_repl/roles.py +123 -0
  25. pg_logical_repl/schema.py +89 -0
  26. pg_logical_repl/security.py +117 -0
  27. pg_logical_repl/service.py +773 -0
  28. pg_logical_repl/sql/base.sql +67 -0
  29. pg_logical_repl/sql/capture.sql +215 -0
  30. pg_logical_repl/sql/ddl_support.sql +109 -0
  31. pg_logical_repl/sql/freeze.sql +117 -0
  32. pg_logical_repl/sql/replay.sql +48 -0
  33. pg_logical_repl/state.py +160 -0
  34. pg_logical_repl/validation.py +353 -0
  35. pg_logical_repl-0.2.0.data/data/share/doc/pg-logical-repl/IMPLEMENTATION_STATUS.md +207 -0
  36. pg_logical_repl-0.2.0.data/data/share/doc/pg-logical-repl/README.md +406 -0
  37. pg_logical_repl-0.2.0.data/data/share/doc/pg-logical-repl/docs/DDL_IMPLEMENTATION.md +170 -0
  38. pg_logical_repl-0.2.0.data/data/share/doc/pg-logical-repl/docs/RELEASING.md +92 -0
  39. pg_logical_repl-0.2.0.data/data/share/doc/pg-logical-repl/docs/TESTING.md +142 -0
  40. pg_logical_repl-0.2.0.data/data/share/doc/pg-logical-repl/examples/local.yaml +55 -0
  41. pg_logical_repl-0.2.0.dist-info/METADATA +425 -0
  42. pg_logical_repl-0.2.0.dist-info/RECORD +45 -0
  43. pg_logical_repl-0.2.0.dist-info/WHEEL +5 -0
  44. pg_logical_repl-0.2.0.dist-info/entry_points.txt +2 -0
  45. 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