walbox 1.0.0b0__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.
- walbox/__init__.py +23 -0
- walbox/abc.py +132 -0
- walbox/checkpoint.py +185 -0
- walbox/client.py +385 -0
- walbox/errors.py +115 -0
- walbox/pgoutput.py +940 -0
- walbox/protocol.py +182 -0
- walbox/transaction.py +447 -0
- walbox/transport.py +400 -0
- walbox-1.0.0b0.dist-info/METADATA +206 -0
- walbox-1.0.0b0.dist-info/RECORD +13 -0
- walbox-1.0.0b0.dist-info/WHEEL +4 -0
- walbox-1.0.0b0.dist-info/licenses/LICENSE +21 -0
walbox/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""walbox: a PostgreSQL logical replication client."""
|
|
2
|
+
|
|
3
|
+
from walbox.abc import ChangeEvent
|
|
4
|
+
from walbox.abc import ChangeKind
|
|
5
|
+
from walbox.abc import CheckpointStore
|
|
6
|
+
from walbox.abc import ReplicationOptions
|
|
7
|
+
from walbox.abc import Transaction
|
|
8
|
+
from walbox.checkpoint import FileCheckpointStore
|
|
9
|
+
from walbox.checkpoint import PostgresCheckpointStore
|
|
10
|
+
from walbox.client import ReplicationClient
|
|
11
|
+
from walbox.errors import WalboxError
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ChangeEvent",
|
|
15
|
+
"ChangeKind",
|
|
16
|
+
"CheckpointStore",
|
|
17
|
+
"FileCheckpointStore",
|
|
18
|
+
"PostgresCheckpointStore",
|
|
19
|
+
"ReplicationClient",
|
|
20
|
+
"ReplicationOptions",
|
|
21
|
+
"Transaction",
|
|
22
|
+
"WalboxError",
|
|
23
|
+
]
|
walbox/abc.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Shared data types and protocols for walbox."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from dataclasses import field
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Any
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
from psycopg import AsyncConnection
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ChangeKind(StrEnum):
|
|
14
|
+
"""The kind of row-level change a `ChangeEvent` carries."""
|
|
15
|
+
|
|
16
|
+
INSERT = "insert"
|
|
17
|
+
UPDATE = "update"
|
|
18
|
+
DELETE = "delete"
|
|
19
|
+
TRUNCATE = "truncate"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ChangeEvent:
|
|
24
|
+
"""A single row-level change within a transaction."""
|
|
25
|
+
|
|
26
|
+
kind: ChangeKind
|
|
27
|
+
table: str
|
|
28
|
+
new: dict[str, Any] | None = None
|
|
29
|
+
old: dict[str, Any] | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CheckpointStore(Protocol):
|
|
33
|
+
"""Durably tracks the last replayed LSN so replication can resume after restart."""
|
|
34
|
+
|
|
35
|
+
async def load(self) -> int | None:
|
|
36
|
+
"""Return the last durably saved LSN, or None if none has been saved yet."""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
async def save(
|
|
40
|
+
self,
|
|
41
|
+
lsn: int,
|
|
42
|
+
*,
|
|
43
|
+
connection: AsyncConnection[Any] | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Durably persist `lsn` as the new replay position.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
lsn: The replay position to persist.
|
|
49
|
+
connection: An optional already-open Postgres connection/
|
|
50
|
+
transaction for an implementation to join, instead of
|
|
51
|
+
opening its own.
|
|
52
|
+
"""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class CheckpointHandle:
|
|
58
|
+
"""A `CheckpointStore` bound to one transaction, so a handler can call `save`."""
|
|
59
|
+
|
|
60
|
+
_store: CheckpointStore
|
|
61
|
+
_on_saved: Callable[[int], None] | None = None
|
|
62
|
+
|
|
63
|
+
async def save(
|
|
64
|
+
self,
|
|
65
|
+
lsn: int,
|
|
66
|
+
*,
|
|
67
|
+
connection: AsyncConnection[Any] | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Durably persist `lsn` via the bound `CheckpointStore`.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
lsn: The replay position to persist.
|
|
73
|
+
connection: An optional already-open Postgres connection/
|
|
74
|
+
transaction for the underlying store to join, instead of
|
|
75
|
+
opening its own.
|
|
76
|
+
"""
|
|
77
|
+
await self._store.save(lsn, connection=connection)
|
|
78
|
+
if self._on_saved is not None:
|
|
79
|
+
self._on_saved(lsn)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class Transaction:
|
|
84
|
+
"""A committed transaction and the row-level changes it contains."""
|
|
85
|
+
|
|
86
|
+
xid: int
|
|
87
|
+
commit_lsn: int
|
|
88
|
+
commit_time: int
|
|
89
|
+
changes: list[ChangeEvent] = field(default_factory=list)
|
|
90
|
+
checkpoint: CheckpointHandle | None = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class Metrics:
|
|
95
|
+
"""A point-in-time snapshot of replication counters and gauges.
|
|
96
|
+
|
|
97
|
+
Handed to `ReplicationOptions.on_metrics` from the same periodic spot
|
|
98
|
+
the status-update timer already fires from -- no historical
|
|
99
|
+
aggregation (rolling windows, percentiles, rates) is done here; that's
|
|
100
|
+
the application's job if it wants one.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
receive_lsn: int
|
|
104
|
+
checkpoint_lsn: int
|
|
105
|
+
replication_lag_bytes: int
|
|
106
|
+
transactions_processed: int
|
|
107
|
+
changes_processed: int
|
|
108
|
+
reconnect_count: int
|
|
109
|
+
last_handler_latency_seconds: float
|
|
110
|
+
queue_depth: int
|
|
111
|
+
last_keepalive_at: float
|
|
112
|
+
last_checkpoint_latency_seconds: float
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
MetricsCallback = Callable[[Metrics], None]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class ReplicationOptions:
|
|
120
|
+
"""Options for replication."""
|
|
121
|
+
|
|
122
|
+
consumer_name: str
|
|
123
|
+
|
|
124
|
+
dsn: str
|
|
125
|
+
slot_name: str
|
|
126
|
+
publication_name: str
|
|
127
|
+
checkpoint_store: CheckpointStore
|
|
128
|
+
|
|
129
|
+
max_pending_transactions: int = 100
|
|
130
|
+
manage_checkpoint: bool = True
|
|
131
|
+
status_interval: int = 10
|
|
132
|
+
on_metrics: MetricsCallback | None = None
|
walbox/checkpoint.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""CheckpointStore implementations for durably tracking replay position."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import psycopg
|
|
11
|
+
from psycopg import AsyncConnection
|
|
12
|
+
from psycopg import sql
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("walbox.checkpoint")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FileCheckpointStore:
|
|
18
|
+
"""A crash-safe, disk-backed `CheckpointStore`.
|
|
19
|
+
|
|
20
|
+
Uses the standard write-to-temp-file, fsync, atomic-rename pattern: a
|
|
21
|
+
failure at any point during `save` leaves the previously-durable
|
|
22
|
+
checkpoint (if any) intact, never a half-written or corrupted one.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, path: str | Path) -> None:
|
|
26
|
+
"""Initialize with the path the checkpoint LSN is persisted to."""
|
|
27
|
+
self._path = Path(path)
|
|
28
|
+
|
|
29
|
+
async def load(self) -> int | None:
|
|
30
|
+
"""Return the last durably saved LSN, or None if the file doesn't exist yet."""
|
|
31
|
+
started_at = time.monotonic()
|
|
32
|
+
lsn = await asyncio.to_thread(self._load_sync)
|
|
33
|
+
logger.debug(
|
|
34
|
+
"checkpoint load completed in %.6fs, lsn=%s",
|
|
35
|
+
time.monotonic() - started_at,
|
|
36
|
+
lsn,
|
|
37
|
+
extra={"lsn": lsn},
|
|
38
|
+
)
|
|
39
|
+
return lsn
|
|
40
|
+
|
|
41
|
+
def _load_sync(self) -> int | None:
|
|
42
|
+
try:
|
|
43
|
+
text = self._path.read_text()
|
|
44
|
+
except FileNotFoundError:
|
|
45
|
+
return None
|
|
46
|
+
return int(text.strip())
|
|
47
|
+
|
|
48
|
+
async def save(
|
|
49
|
+
self,
|
|
50
|
+
lsn: int,
|
|
51
|
+
*,
|
|
52
|
+
connection: AsyncConnection[Any] | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Durably persist `lsn`.
|
|
55
|
+
|
|
56
|
+
`connection` is accepted (to satisfy the `CheckpointStore` Protocol)
|
|
57
|
+
and ignored -- a plain file can never join a Postgres transaction.
|
|
58
|
+
"""
|
|
59
|
+
started_at = time.monotonic()
|
|
60
|
+
await asyncio.to_thread(self._save_sync, lsn)
|
|
61
|
+
logger.debug(
|
|
62
|
+
"checkpoint save completed in %.6fs, lsn=%s",
|
|
63
|
+
time.monotonic() - started_at,
|
|
64
|
+
lsn,
|
|
65
|
+
extra={"lsn": lsn},
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def _save_sync(self, lsn: int) -> None:
|
|
69
|
+
tmp_path = self._path.with_name(self._path.name + ".tmp")
|
|
70
|
+
with tmp_path.open("w", encoding="utf-8") as f:
|
|
71
|
+
f.write(str(lsn))
|
|
72
|
+
f.flush()
|
|
73
|
+
os.fsync(f.fileno())
|
|
74
|
+
# Atomic on POSIX: readers never see a half-written file.
|
|
75
|
+
tmp_path.replace(self._path)
|
|
76
|
+
dir_fd = os.open(self._path.parent, os.O_RDONLY)
|
|
77
|
+
try:
|
|
78
|
+
# Durably persist the rename itself, not just the new file's bytes.
|
|
79
|
+
os.fsync(dir_fd)
|
|
80
|
+
finally:
|
|
81
|
+
os.close(dir_fd)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class PostgresCheckpointStore:
|
|
85
|
+
"""A `CheckpointStore` backed by a row in a Postgres table.
|
|
86
|
+
|
|
87
|
+
Its entire reason to exist is that `save` can join a *caller-supplied*
|
|
88
|
+
connection's transaction (via `connection=`) instead of always opening
|
|
89
|
+
its own -- letting an application commit its own sink write and the
|
|
90
|
+
checkpoint update atomically in one transaction, something a
|
|
91
|
+
`FileCheckpointStore` can never do.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
dsn: str,
|
|
97
|
+
*,
|
|
98
|
+
consumer_name: str,
|
|
99
|
+
table: str = "walbox_checkpoint",
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Initialize with the DSN to connect with and the consumer to track.
|
|
102
|
+
|
|
103
|
+
`table` is only ever a trusted, developer-supplied identifier (never
|
|
104
|
+
end-user input), so it's safely composed into SQL via
|
|
105
|
+
`psycopg.sql.Identifier` (imported here as `sql.Identifier`) rather
|
|
106
|
+
than passed as a bind parameter -- Postgres doesn't allow
|
|
107
|
+
parameterizing table names.
|
|
108
|
+
"""
|
|
109
|
+
self._dsn = dsn
|
|
110
|
+
self._consumer_name = consumer_name
|
|
111
|
+
self._table = sql.Identifier(table)
|
|
112
|
+
self._schema_ready = False
|
|
113
|
+
|
|
114
|
+
async def load(self) -> int | None:
|
|
115
|
+
"""Return the last durably saved LSN, or None if this consumer has none yet."""
|
|
116
|
+
started_at = time.monotonic()
|
|
117
|
+
async with await psycopg.AsyncConnection.connect(self._dsn) as conn:
|
|
118
|
+
await self._ensure_schema(conn)
|
|
119
|
+
query = sql.SQL(
|
|
120
|
+
"SELECT lsn FROM {table} WHERE consumer_name = %s",
|
|
121
|
+
).format(table=self._table)
|
|
122
|
+
cursor = await conn.execute(query, (self._consumer_name,))
|
|
123
|
+
row = await cursor.fetchone()
|
|
124
|
+
lsn = row[0] if row is not None else None
|
|
125
|
+
logger.debug(
|
|
126
|
+
"checkpoint load completed in %.6fs, lsn=%s",
|
|
127
|
+
time.monotonic() - started_at,
|
|
128
|
+
lsn,
|
|
129
|
+
extra={"lsn": lsn},
|
|
130
|
+
)
|
|
131
|
+
return lsn
|
|
132
|
+
|
|
133
|
+
async def save(
|
|
134
|
+
self,
|
|
135
|
+
lsn: int,
|
|
136
|
+
*,
|
|
137
|
+
connection: AsyncConnection[Any] | None = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Durably persist `lsn` as the new replay position for this consumer.
|
|
140
|
+
|
|
141
|
+
If `connection` is given, the upsert is executed on it and left
|
|
142
|
+
uncommitted -- the caller owns the transaction boundary, so this can
|
|
143
|
+
become durable atomically together with whatever else the caller
|
|
144
|
+
writes on that same connection. Without `connection`, this opens its
|
|
145
|
+
own ad hoc connection and commits immediately.
|
|
146
|
+
"""
|
|
147
|
+
started_at = time.monotonic()
|
|
148
|
+
if connection is not None:
|
|
149
|
+
await self._upsert(connection, lsn)
|
|
150
|
+
else:
|
|
151
|
+
async with await psycopg.AsyncConnection.connect(self._dsn) as conn:
|
|
152
|
+
await self._ensure_schema(conn)
|
|
153
|
+
await self._upsert(conn, lsn)
|
|
154
|
+
await conn.commit()
|
|
155
|
+
logger.debug(
|
|
156
|
+
"checkpoint save completed in %.6fs, lsn=%s",
|
|
157
|
+
time.monotonic() - started_at,
|
|
158
|
+
lsn,
|
|
159
|
+
extra={"lsn": lsn},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
async def _ensure_schema(self, conn: AsyncConnection[Any]) -> None:
|
|
163
|
+
if self._schema_ready:
|
|
164
|
+
return
|
|
165
|
+
await conn.execute(
|
|
166
|
+
sql.SQL(
|
|
167
|
+
"CREATE TABLE IF NOT EXISTS {table} ("
|
|
168
|
+
"consumer_name TEXT PRIMARY KEY, "
|
|
169
|
+
"lsn BIGINT NOT NULL, "
|
|
170
|
+
"updated_at TIMESTAMPTZ NOT NULL DEFAULT now()"
|
|
171
|
+
")",
|
|
172
|
+
).format(table=self._table),
|
|
173
|
+
)
|
|
174
|
+
await conn.commit()
|
|
175
|
+
self._schema_ready = True
|
|
176
|
+
|
|
177
|
+
async def _upsert(self, conn: AsyncConnection[Any], lsn: int) -> None:
|
|
178
|
+
await conn.execute(
|
|
179
|
+
sql.SQL(
|
|
180
|
+
"INSERT INTO {table} (consumer_name, lsn) VALUES (%s, %s) "
|
|
181
|
+
"ON CONFLICT (consumer_name) DO UPDATE SET lsn = EXCLUDED.lsn, "
|
|
182
|
+
"updated_at = now()",
|
|
183
|
+
).format(table=self._table),
|
|
184
|
+
(self._consumer_name, lsn),
|
|
185
|
+
)
|