walbox 1.0.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.
walbox/__init__.py ADDED
@@ -0,0 +1,27 @@
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 CheckpointHandle
6
+ from walbox.abc import Metrics
7
+ from walbox.abc import MetricsCallback
8
+ from walbox.abc import Transaction
9
+ from walbox.abc import WalboxOptions
10
+ from walbox.builder import Walbox
11
+ from walbox.checkpoint import ConnectionPool
12
+ from walbox.client import Client
13
+ from walbox.errors import WalboxError
14
+
15
+ __all__ = [
16
+ "ChangeEvent",
17
+ "ChangeKind",
18
+ "CheckpointHandle",
19
+ "Client",
20
+ "ConnectionPool",
21
+ "Metrics",
22
+ "MetricsCallback",
23
+ "Transaction",
24
+ "Walbox",
25
+ "WalboxError",
26
+ "WalboxOptions",
27
+ ]
walbox/abc.py ADDED
@@ -0,0 +1,184 @@
1
+ """Shared data types and protocols for walbox."""
2
+
3
+ import time
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass
6
+ from dataclasses import field
7
+ from enum import StrEnum
8
+ from typing import Any
9
+ from typing import Protocol
10
+
11
+ from psycopg import AsyncConnection
12
+
13
+ from walbox.errors import CheckpointError
14
+
15
+
16
+ class ChangeKind(StrEnum):
17
+ """The kind of row-level change a `ChangeEvent` carries."""
18
+
19
+ INSERT = "insert"
20
+ UPDATE = "update"
21
+ DELETE = "delete"
22
+ TRUNCATE = "truncate"
23
+
24
+
25
+ @dataclass
26
+ class ChangeEvent:
27
+ """A single row-level change within a transaction.
28
+
29
+ `new` and `old` depend on `kind`:
30
+
31
+ - INSERT: `new` has the inserted row, `old` is None.
32
+ - UPDATE: `new` has the row after the change, `old` has it before.
33
+ `old` needs a usable REPLICA IDENTITY (a primary key, or
34
+ REPLICA IDENTITY FULL) on the table; without one it's None.
35
+ - DELETE: `new` is None, `old` has the deleted row (same REPLICA
36
+ IDENTITY requirement as UPDATE).
37
+ - TRUNCATE: both are None.
38
+
39
+ Dict keys are column names; values are the column's Python-typed
40
+ value, with SQL NULL represented as None.
41
+ """
42
+
43
+ kind: ChangeKind
44
+ table: str
45
+ new: dict[str, Any] | None = None
46
+ old: dict[str, Any] | None = None
47
+
48
+
49
+ class CheckpointStore(Protocol):
50
+ """Durably tracks the last replayed LSN so replication can resume after restart."""
51
+
52
+ async def load(self) -> int | None:
53
+ """Return the last durably saved LSN, or None if none has been saved yet."""
54
+ ...
55
+
56
+ async def save(
57
+ self,
58
+ lsn: int,
59
+ *,
60
+ connection: AsyncConnection[Any] | None = None,
61
+ ) -> None:
62
+ """Durably persist `lsn` as the new replay position.
63
+
64
+ `connection`, if given, is an already-open Postgres connection or
65
+ transaction the implementation can join instead of opening its own.
66
+ """
67
+ ...
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class CheckpointHandle:
72
+ """A `CheckpointStore` bound to one run, handed to every handler call."""
73
+
74
+ _store: CheckpointStore
75
+ _on_saved: Callable[[int, float], None] | None = None
76
+ _max_lsn: int | None = None
77
+
78
+ async def save(
79
+ self,
80
+ lsn: int,
81
+ *,
82
+ connection: AsyncConnection[Any] | None = None,
83
+ ) -> None:
84
+ """Durably persist `lsn` via the bound `CheckpointStore`.
85
+
86
+ `connection`, if given, is an already-open Postgres connection or
87
+ transaction the underlying store can join instead of opening its own.
88
+
89
+ Raises:
90
+ CheckpointError: If `lsn` is greater than the commit LSN of the
91
+ transaction this handle was constructed for. Saving a
92
+ checkpoint ahead of what was actually processed risks
93
+ PostgreSQL recycling WAL for data walbox never handled.
94
+ """
95
+ if self._max_lsn is not None and lsn > self._max_lsn:
96
+ message = (
97
+ f"refusing to save checkpoint lsn={lsn}: greater than "
98
+ f"the dispatched transaction's commit_lsn={self._max_lsn}"
99
+ )
100
+ raise CheckpointError(message)
101
+ started_at = time.monotonic()
102
+ await self._store.save(lsn, connection=connection)
103
+ if self._on_saved is not None:
104
+ self._on_saved(lsn, time.monotonic() - started_at)
105
+
106
+
107
+ @dataclass(frozen=True, slots=True)
108
+ class Transaction:
109
+ """A committed transaction and the row-level changes it contains."""
110
+
111
+ xid: int
112
+ commit_lsn: int
113
+ commit_time: int
114
+ changes: list[ChangeEvent] = field(default_factory=list)
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class Metrics:
119
+ """A point-in-time snapshot of replication counters and gauges.
120
+
121
+ Handed to `WalboxOptions.on_metrics` on the same timer as status
122
+ updates. No historical aggregation (rolling windows, percentiles,
123
+ rates) is done here; that's on the application if it wants one.
124
+ """
125
+
126
+ consumer_name: str
127
+ receive_lsn: int
128
+ checkpoint_lsn: int
129
+ replication_lag_bytes: int
130
+ transactions_processed: int
131
+ changes_processed: int
132
+ reconnect_count: int
133
+ last_handler_latency_seconds: float
134
+ queue_depth: int
135
+ last_keepalive_at: float
136
+ last_checkpoint_latency_seconds: float
137
+ transactions_since_checkpoint: int
138
+
139
+
140
+ MetricsCallback = Callable[[Metrics], None]
141
+
142
+
143
+ @dataclass
144
+ class WalboxOptions:
145
+ """Configuration for a walbox client, shared by every checkpoint backend.
146
+
147
+ `Client` takes this plus a `CheckpointStore` directly;
148
+ `Walbox` constructs the checkpoint store for you.
149
+
150
+ `on_metrics` runs synchronously on the same timer as status updates.
151
+ Don't block in it; hand the `Metrics` snapshot off to a queue or task
152
+ if you need to send it elsewhere. See docs/production/monitoring.md.
153
+ """
154
+
155
+ consumer_name: str
156
+ dsn: str
157
+ slot_name: str
158
+ publication_name: str
159
+
160
+ max_pending_transactions: int = 100
161
+ status_interval: int = 10
162
+ on_metrics: MetricsCallback | None = None
163
+
164
+ def __post_init__(self) -> None:
165
+ """Validate required fields, raising `ValueError` if any are invalid.
166
+
167
+ Raises:
168
+ ValueError: If any required field is missing or invalid.
169
+ """
170
+ for name, value in (
171
+ ("consumer_name", self.consumer_name),
172
+ ("dsn", self.dsn),
173
+ ("slot_name", self.slot_name),
174
+ ("publication_name", self.publication_name),
175
+ ):
176
+ if not value or not value.strip():
177
+ message = f"{name} must not be blank"
178
+ raise ValueError(message)
179
+ if self.max_pending_transactions <= 0:
180
+ message = "max_pending_transactions must be > 0"
181
+ raise ValueError(message)
182
+ if self.status_interval <= 0:
183
+ message = "status_interval must be > 0"
184
+ raise ValueError(message)
walbox/builder.py ADDED
@@ -0,0 +1,50 @@
1
+ """Builds a fully wired `Client` from public `WalboxOptions`."""
2
+
3
+ from walbox.abc import WalboxOptions
4
+ from walbox.checkpoint import ConnectionPool
5
+ from walbox.checkpoint import PostgresCheckpointStore
6
+ from walbox.client import Client
7
+
8
+
9
+ class Walbox:
10
+ """Constructs a `Client` without exposing checkpoint-store wiring."""
11
+
12
+ @staticmethod
13
+ def build(options: WalboxOptions) -> Client:
14
+ """Build a client whose checkpoint store opens ad hoc connections.
15
+
16
+ Opens and closes a new connection for every `checkpoint.save()`
17
+ call. Prefer `build_with_pool` unless you have a reason not to add
18
+ the `psycopg-pool` dependency; this is here for that case, and for
19
+ checkpoint volume low enough that the per-call connection cost
20
+ doesn't matter.
21
+
22
+ Returns:
23
+ A `Client` ready to `run()`.
24
+ """
25
+ checkpoint_store = PostgresCheckpointStore(
26
+ options.dsn,
27
+ consumer_name=options.consumer_name,
28
+ )
29
+ return Client(options, checkpoint_store)
30
+
31
+ @staticmethod
32
+ def build_with_pool(
33
+ options: WalboxOptions,
34
+ pool: ConnectionPool,
35
+ ) -> Client:
36
+ """Build a client whose checkpoint store reuses your own connection pool.
37
+
38
+ `pool` is owned by the caller: opening and closing it (for example
39
+ via `async with AsyncConnectionPool(...) as pool:`) is your
40
+ responsibility, not walbox's. The same pool can also be reused for a
41
+ handler's own downstream writes.
42
+
43
+ Returns:
44
+ A `Client` ready to `run()`.
45
+ """
46
+ checkpoint_store = PostgresCheckpointStore.from_pool(
47
+ pool,
48
+ consumer_name=options.consumer_name,
49
+ )
50
+ return Client(options, checkpoint_store)
walbox/checkpoint.py ADDED
@@ -0,0 +1,191 @@
1
+ """CheckpointStore implementations for durably tracking replay position."""
2
+
3
+ import contextlib
4
+ import logging
5
+ import time
6
+ from collections.abc import AsyncGenerator
7
+ from collections.abc import Callable
8
+ from contextlib import AbstractAsyncContextManager
9
+ from typing import Any
10
+ from typing import Protocol
11
+
12
+ import psycopg
13
+ from psycopg import AsyncConnection
14
+ from psycopg import sql
15
+
16
+ from walbox.errors import CheckpointError
17
+
18
+ logger = logging.getLogger("walbox.checkpoint")
19
+
20
+ _Acquire = Callable[[], AbstractAsyncContextManager[AsyncConnection[Any]]]
21
+
22
+
23
+ class ConnectionPool(Protocol):
24
+ """Structural shape of a Postgres connection pool.
25
+
26
+ Matches `psycopg_pool.AsyncConnectionPool` (an async context manager
27
+ that checks out a connection and returns it on exit), but is never
28
+ imported from `psycopg_pool`. Any object shaped like this works, so
29
+ `PostgresCheckpointStore.from_pool` adds no dependency beyond `psycopg`.
30
+ """
31
+
32
+ def connection(self) -> AbstractAsyncContextManager[AsyncConnection[Any]]:
33
+ """Check out a connection, returning it to the pool on block exit."""
34
+ ...
35
+
36
+
37
+ @contextlib.asynccontextmanager
38
+ async def _connect(dsn: str) -> AsyncGenerator[AsyncConnection[Any]]:
39
+ async with await psycopg.AsyncConnection.connect(dsn) as conn:
40
+ yield conn
41
+
42
+
43
+ class PostgresCheckpointStore:
44
+ """A `CheckpointStore` backed by a row in a Postgres table.
45
+
46
+ `save(connection=...)` can join a caller-supplied connection's
47
+ transaction instead of opening its own, letting an application commit
48
+ its own sink write and the checkpoint update atomically in one
49
+ transaction.
50
+
51
+ Without `connection=`, `load()` and `save()` open one ad hoc connection
52
+ per call, which is fine for checkpointing's low call volume. Use
53
+ `from_pool` instead to reuse a connection pool the application already
54
+ manages.
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ dsn: str,
60
+ *,
61
+ consumer_name: str,
62
+ table: str = "walbox_checkpoint",
63
+ ) -> None:
64
+ """Initialize with the DSN to connect with and the consumer to track.
65
+
66
+ `table` must be a trusted, developer-supplied identifier, never
67
+ end-user input: Postgres doesn't allow parameterizing table names,
68
+ so it's composed into SQL via `sql.Identifier` instead of a bind
69
+ parameter.
70
+ """
71
+ self._acquire: _Acquire = lambda: _connect(dsn)
72
+ self._configure(consumer_name=consumer_name, table=table)
73
+
74
+ @classmethod
75
+ def from_pool(
76
+ cls,
77
+ pool: ConnectionPool,
78
+ *,
79
+ consumer_name: str,
80
+ table: str = "walbox_checkpoint",
81
+ ) -> "PostgresCheckpointStore":
82
+ """Build a store whose ad hoc `load()`/`save()` calls reuse `pool`.
83
+
84
+ `pool` is owned by the application; it's never connected to or
85
+ closed here. This only changes where connections for `load()` and
86
+ connection-less `save()` calls come from. `save(lsn, connection=...)`
87
+ already uses whatever connection the caller passes in, pool or not.
88
+
89
+ Returns:
90
+ A `PostgresCheckpointStore` backed by `pool`.
91
+ """
92
+ store = cls.__new__(cls)
93
+ store._acquire = pool.connection # ruff: ignore[private-member-access]: alternate constructor
94
+ store._configure(consumer_name=consumer_name, table=table) # ruff: ignore[private-member-access]
95
+ return store
96
+
97
+ def _configure(self, *, consumer_name: str, table: str) -> None:
98
+ self._consumer_name = consumer_name
99
+ self._table = sql.Identifier(table)
100
+ self._schema_ready = False
101
+
102
+ async def load(self) -> int | None:
103
+ """Return the last durably saved LSN, or None if this consumer has none yet.
104
+
105
+ Raises:
106
+ CheckpointError: If the saved LSN is negative.
107
+ """
108
+ started_at = time.monotonic()
109
+ async with self._acquire() as conn:
110
+ await self._ensure_schema(conn)
111
+ await conn.commit()
112
+ query = sql.SQL(
113
+ "SELECT lsn FROM {table} WHERE consumer_name = %s",
114
+ ).format(table=self._table)
115
+ cursor = await conn.execute(query, (self._consumer_name,))
116
+ row = await cursor.fetchone()
117
+ lsn = row[0] if row is not None else None
118
+ if lsn is not None and lsn < 0:
119
+ message = (
120
+ f"checkpoint for consumer {self._consumer_name!r} is negative: {lsn}"
121
+ )
122
+ raise CheckpointError(message)
123
+ logger.debug(
124
+ "checkpoint load completed in %.6fs, lsn=%s",
125
+ time.monotonic() - started_at,
126
+ lsn,
127
+ extra={"lsn": lsn},
128
+ )
129
+ return lsn
130
+
131
+ async def save(
132
+ self,
133
+ lsn: int,
134
+ *,
135
+ connection: AsyncConnection[Any] | None = None,
136
+ ) -> None:
137
+ """Durably persist `lsn` as the new replay position for this consumer.
138
+
139
+ If `connection` is given, the upsert (and, on first use, the
140
+ backing table's creation) runs on it and is left uncommitted, so the
141
+ caller's own commit makes it durable atomically with whatever else
142
+ it writes on that connection. Without `connection`, this acquires
143
+ its own connection and commits immediately.
144
+ """
145
+ started_at = time.monotonic()
146
+ if connection is not None:
147
+ await self._ensure_schema(connection)
148
+ await self._upsert(connection, lsn)
149
+ else:
150
+ async with self._acquire() as conn:
151
+ await self._ensure_schema(conn)
152
+ await self._upsert(conn, lsn)
153
+ await conn.commit()
154
+ logger.debug(
155
+ "checkpoint save completed in %.6fs, lsn=%s",
156
+ time.monotonic() - started_at,
157
+ lsn,
158
+ extra={"lsn": lsn},
159
+ )
160
+
161
+ async def _ensure_schema(self, conn: AsyncConnection[Any]) -> None:
162
+ """Create the backing table if needed, without committing.
163
+
164
+ `CREATE TABLE IF NOT EXISTS` is transactional in Postgres, so this
165
+ is safe to run on a caller-supplied `save(connection=...)` connection
166
+ and leave for the caller's own commit. Committing here would commit
167
+ the caller's in-progress transaction early, breaking the
168
+ same-transaction guarantee `connection=` exists to provide.
169
+ """
170
+ if self._schema_ready:
171
+ return
172
+ await conn.execute(
173
+ sql.SQL(
174
+ "CREATE TABLE IF NOT EXISTS {table} ("
175
+ "consumer_name TEXT PRIMARY KEY, "
176
+ "lsn BIGINT NOT NULL, "
177
+ "updated_at TIMESTAMPTZ NOT NULL DEFAULT now()"
178
+ ")",
179
+ ).format(table=self._table),
180
+ )
181
+ self._schema_ready = True
182
+
183
+ async def _upsert(self, conn: AsyncConnection[Any], lsn: int) -> None:
184
+ await conn.execute(
185
+ sql.SQL(
186
+ "INSERT INTO {table} (consumer_name, lsn) VALUES (%s, %s) "
187
+ "ON CONFLICT (consumer_name) DO UPDATE SET lsn = EXCLUDED.lsn, "
188
+ "updated_at = now()",
189
+ ).format(table=self._table),
190
+ (self._consumer_name, lsn),
191
+ )