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 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
+ )