pgstream 0.1.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.
- pgstream/__init__.py +5 -0
- pgstream/decoder.py +248 -0
- pgstream/events.py +42 -0
- pgstream/py.typed +0 -0
- pgstream/replication.py +225 -0
- pgstream/sinks/__init__.py +5 -0
- pgstream/sinks/base.py +53 -0
- pgstream/sinks/pgvector.py +91 -0
- pgstream/sinks/qdrant.py +114 -0
- pgstream/stream.py +277 -0
- pgstream-0.1.0.dist-info/METADATA +296 -0
- pgstream-0.1.0.dist-info/RECORD +13 -0
- pgstream-0.1.0.dist-info/WHEEL +4 -0
pgstream/__init__.py
ADDED
pgstream/decoder.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import struct
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime, timezone, timedelta
|
|
6
|
+
from typing import NamedTuple
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_PG_EPOCH = datetime(2000, 1, 1, tzinfo=timezone.utc)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _pg_ts_to_datetime(microseconds: int) -> datetime:
|
|
13
|
+
return _PG_EPOCH + timedelta(microseconds=microseconds)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _lsn_to_str(lsn: int) -> str:
|
|
17
|
+
high = lsn >> 32
|
|
18
|
+
low = lsn & 0xFFFFFFFF
|
|
19
|
+
return f"{high:X}/{low:X}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ColumnInfo:
|
|
24
|
+
name: str
|
|
25
|
+
type_oid: int
|
|
26
|
+
is_key: bool
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class RelationInfo:
|
|
31
|
+
oid: int
|
|
32
|
+
schema: str
|
|
33
|
+
table: str
|
|
34
|
+
columns: list[ColumnInfo] = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PgOutputDecoder:
|
|
38
|
+
"""Stateful parser for the pgoutput logical replication protocol (v1).
|
|
39
|
+
|
|
40
|
+
Caches ``Relation`` messages keyed by OID so that subsequent DML messages
|
|
41
|
+
can be decoded into named columns. Transaction context (LSN, timestamp,
|
|
42
|
+
XID) is tracked from ``Begin`` messages and attached to every emitted event.
|
|
43
|
+
|
|
44
|
+
Usage::
|
|
45
|
+
|
|
46
|
+
decoder = PgOutputDecoder()
|
|
47
|
+
for raw_msg in replication_cursor:
|
|
48
|
+
event = decoder.decode(raw_msg.payload)
|
|
49
|
+
if event is not None:
|
|
50
|
+
yield event
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self) -> None:
|
|
54
|
+
self._relations: dict[int, RelationInfo] = {}
|
|
55
|
+
self._current_lsn: str = "0/0"
|
|
56
|
+
self._current_commit_time: datetime = _PG_EPOCH
|
|
57
|
+
self._current_xid: int = 0
|
|
58
|
+
|
|
59
|
+
def decode(self, payload: bytes) -> dict | None:
|
|
60
|
+
"""Decode one raw pgoutput message.
|
|
61
|
+
|
|
62
|
+
Returns a dict for Insert/Update/Delete/Truncate messages, or ``None``
|
|
63
|
+
for Begin/Commit/Relation and other metadata messages.
|
|
64
|
+
"""
|
|
65
|
+
if not payload:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
msg_type = chr(payload[0])
|
|
69
|
+
data = payload[1:]
|
|
70
|
+
|
|
71
|
+
if msg_type == "B":
|
|
72
|
+
self._handle_begin(data)
|
|
73
|
+
elif msg_type == "C":
|
|
74
|
+
self._handle_commit(data)
|
|
75
|
+
elif msg_type == "R":
|
|
76
|
+
self._handle_relation(data)
|
|
77
|
+
elif msg_type == "I":
|
|
78
|
+
return self._handle_insert(data)
|
|
79
|
+
elif msg_type == "U":
|
|
80
|
+
return self._handle_update(data)
|
|
81
|
+
elif msg_type == "D":
|
|
82
|
+
return self._handle_delete(data)
|
|
83
|
+
elif msg_type == "T":
|
|
84
|
+
return self._handle_truncate(data)
|
|
85
|
+
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
def _handle_begin(self, data: bytes) -> None:
|
|
89
|
+
final_lsn, commit_ts, xid = struct.unpack_from(">qqi", data, 0)
|
|
90
|
+
self._current_lsn = _lsn_to_str(final_lsn)
|
|
91
|
+
self._current_commit_time = _pg_ts_to_datetime(commit_ts)
|
|
92
|
+
self._current_xid = xid
|
|
93
|
+
|
|
94
|
+
def _handle_commit(self, data: bytes) -> None:
|
|
95
|
+
commit_lsn, end_lsn, commit_ts = struct.unpack_from(">qqq", data, 1)
|
|
96
|
+
self._current_lsn = _lsn_to_str(commit_lsn)
|
|
97
|
+
self._current_commit_time = _pg_ts_to_datetime(commit_ts)
|
|
98
|
+
|
|
99
|
+
def _handle_relation(self, data: bytes) -> None:
|
|
100
|
+
offset = 0
|
|
101
|
+
oid, = struct.unpack_from(">I", data, offset)
|
|
102
|
+
offset += 4
|
|
103
|
+
schema, offset = _read_cstring(data, offset)
|
|
104
|
+
table, offset = _read_cstring(data, offset)
|
|
105
|
+
offset += 1 # replica identity byte
|
|
106
|
+
num_cols, = struct.unpack_from(">H", data, offset)
|
|
107
|
+
offset += 2
|
|
108
|
+
|
|
109
|
+
columns: list[ColumnInfo] = []
|
|
110
|
+
for _ in range(num_cols):
|
|
111
|
+
col_flags = data[offset]
|
|
112
|
+
offset += 1
|
|
113
|
+
col_name, offset = _read_cstring(data, offset)
|
|
114
|
+
type_oid, type_mod = struct.unpack_from(">Ii", data, offset)
|
|
115
|
+
offset += 8
|
|
116
|
+
columns.append(ColumnInfo(
|
|
117
|
+
name=col_name,
|
|
118
|
+
type_oid=type_oid,
|
|
119
|
+
is_key=bool(col_flags & 0x01),
|
|
120
|
+
))
|
|
121
|
+
|
|
122
|
+
self._relations[oid] = RelationInfo(
|
|
123
|
+
oid=oid, schema=schema, table=table, columns=columns
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def _handle_insert(self, data: bytes) -> dict | None:
|
|
127
|
+
offset = 0
|
|
128
|
+
oid, = struct.unpack_from(">I", data, offset)
|
|
129
|
+
offset += 4
|
|
130
|
+
relation = self._get_relation(oid)
|
|
131
|
+
if relation is None:
|
|
132
|
+
return None
|
|
133
|
+
offset += 1 # skip 'N' byte
|
|
134
|
+
row, offset = self._decode_tuple(data, offset, relation)
|
|
135
|
+
return self._make_event("insert", relation, row, None)
|
|
136
|
+
|
|
137
|
+
def _handle_update(self, data: bytes) -> dict | None:
|
|
138
|
+
offset = 0
|
|
139
|
+
oid, = struct.unpack_from(">I", data, offset)
|
|
140
|
+
offset += 4
|
|
141
|
+
relation = self._get_relation(oid)
|
|
142
|
+
if relation is None:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
old_row: dict | None = None
|
|
146
|
+
marker = chr(data[offset])
|
|
147
|
+
if marker in ("K", "O"):
|
|
148
|
+
offset += 1
|
|
149
|
+
old_row, offset = self._decode_tuple(data, offset, relation)
|
|
150
|
+
marker = chr(data[offset])
|
|
151
|
+
|
|
152
|
+
assert marker == "N", f"Expected 'N' in UPDATE, got {marker!r}"
|
|
153
|
+
offset += 1
|
|
154
|
+
new_row, offset = self._decode_tuple(data, offset, relation)
|
|
155
|
+
return self._make_event("update", relation, new_row, old_row)
|
|
156
|
+
|
|
157
|
+
def _handle_delete(self, data: bytes) -> dict | None:
|
|
158
|
+
offset = 0
|
|
159
|
+
oid, = struct.unpack_from(">I", data, offset)
|
|
160
|
+
offset += 4
|
|
161
|
+
relation = self._get_relation(oid)
|
|
162
|
+
if relation is None:
|
|
163
|
+
return None
|
|
164
|
+
offset += 1 # skip 'K' or 'O' byte
|
|
165
|
+
old_row, offset = self._decode_tuple(data, offset, relation)
|
|
166
|
+
return self._make_event("delete", relation, old_row, None)
|
|
167
|
+
|
|
168
|
+
def _handle_truncate(self, data: bytes) -> dict | None:
|
|
169
|
+
offset = 0
|
|
170
|
+
num_relations, = struct.unpack_from(">I", data, offset)
|
|
171
|
+
offset += 4
|
|
172
|
+
offset += 1 # flags byte
|
|
173
|
+
if num_relations == 0:
|
|
174
|
+
return None
|
|
175
|
+
oid, = struct.unpack_from(">I", data, offset)
|
|
176
|
+
relation = self._get_relation(oid)
|
|
177
|
+
if relation is None:
|
|
178
|
+
return None
|
|
179
|
+
return self._make_event("truncate", relation, {}, None)
|
|
180
|
+
|
|
181
|
+
def _get_relation(self, oid: int) -> RelationInfo | None:
|
|
182
|
+
rel = self._relations.get(oid)
|
|
183
|
+
if rel is None:
|
|
184
|
+
import warnings
|
|
185
|
+
warnings.warn(
|
|
186
|
+
f"pgstream: received DML for unknown relation OID {oid}. "
|
|
187
|
+
"The Relation message may have been missed. Skipping event."
|
|
188
|
+
)
|
|
189
|
+
return rel
|
|
190
|
+
|
|
191
|
+
def _decode_tuple(
|
|
192
|
+
self, data: bytes, offset: int, relation: RelationInfo
|
|
193
|
+
) -> tuple[dict[str, str | None], int]:
|
|
194
|
+
num_cols, = struct.unpack_from(">H", data, offset)
|
|
195
|
+
offset += 2
|
|
196
|
+
row: dict[str, str | None] = {}
|
|
197
|
+
|
|
198
|
+
for i in range(num_cols):
|
|
199
|
+
col_name = relation.columns[i].name if i < len(relation.columns) else f"col_{i}"
|
|
200
|
+
col_type = chr(data[offset])
|
|
201
|
+
offset += 1
|
|
202
|
+
|
|
203
|
+
if col_type == "n":
|
|
204
|
+
row[col_name] = None
|
|
205
|
+
elif col_type == "u":
|
|
206
|
+
# Unchanged TOASTed value — not sent by Postgres; emit None.
|
|
207
|
+
row[col_name] = None
|
|
208
|
+
elif col_type == "t":
|
|
209
|
+
val_len, = struct.unpack_from(">I", data, offset)
|
|
210
|
+
offset += 4
|
|
211
|
+
row[col_name] = data[offset : offset + val_len].decode("utf-8")
|
|
212
|
+
offset += val_len
|
|
213
|
+
elif col_type == "b":
|
|
214
|
+
val_len, = struct.unpack_from(">I", data, offset)
|
|
215
|
+
offset += 4
|
|
216
|
+
row[col_name] = data[offset : offset + val_len].hex()
|
|
217
|
+
offset += val_len
|
|
218
|
+
else:
|
|
219
|
+
raise ValueError(
|
|
220
|
+
f"pgstream decoder: unknown column type byte {col_type!r} "
|
|
221
|
+
f"for column {col_name!r} in {relation.schema}.{relation.table}"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
return row, offset
|
|
225
|
+
|
|
226
|
+
def _make_event(
|
|
227
|
+
self,
|
|
228
|
+
operation: str,
|
|
229
|
+
relation: RelationInfo,
|
|
230
|
+
row: dict,
|
|
231
|
+
old_row: dict | None,
|
|
232
|
+
) -> dict:
|
|
233
|
+
return {
|
|
234
|
+
"operation": operation,
|
|
235
|
+
"schema": relation.schema,
|
|
236
|
+
"table": relation.table,
|
|
237
|
+
"row": row,
|
|
238
|
+
"old_row": old_row,
|
|
239
|
+
"lsn": self._current_lsn,
|
|
240
|
+
"commit_time": self._current_commit_time,
|
|
241
|
+
"xid": self._current_xid,
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _read_cstring(data: bytes, offset: int) -> tuple[str, int]:
|
|
246
|
+
end = data.index(b"\x00", offset)
|
|
247
|
+
value = data[offset:end].decode("utf-8")
|
|
248
|
+
return value, end + 1
|
pgstream/events.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ChangeEvent:
|
|
10
|
+
"""A single committed row-level change decoded from the Postgres WAL.
|
|
11
|
+
|
|
12
|
+
Attributes:
|
|
13
|
+
operation: One of ``"insert"``, ``"update"``, ``"delete"``, ``"truncate"``.
|
|
14
|
+
schema: Postgres schema name (e.g. ``"public"``).
|
|
15
|
+
table: Table name (e.g. ``"documents"``).
|
|
16
|
+
row: New row as ``{column: value}``. Values are always strings or
|
|
17
|
+
``None``; cast them yourself (e.g. ``int(event.row["id"])``).
|
|
18
|
+
For DELETE this contains the old/key row. For TRUNCATE it is
|
|
19
|
+
an empty dict.
|
|
20
|
+
old_row: Previous row on UPDATE or DELETE when ``REPLICA IDENTITY FULL``
|
|
21
|
+
is set. ``None`` otherwise.
|
|
22
|
+
lsn: WAL Log Sequence Number at commit (e.g. ``"0/1A3F28"``).
|
|
23
|
+
commit_time: UTC datetime of the transaction commit.
|
|
24
|
+
xid: Postgres transaction ID.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
operation: Literal["insert", "update", "delete", "truncate"]
|
|
28
|
+
schema: str
|
|
29
|
+
table: str
|
|
30
|
+
row: dict[str, str | None]
|
|
31
|
+
old_row: dict[str, str | None] | None
|
|
32
|
+
lsn: str
|
|
33
|
+
commit_time: datetime
|
|
34
|
+
xid: int
|
|
35
|
+
|
|
36
|
+
def __repr__(self) -> str:
|
|
37
|
+
row_preview = {k: v for k, v in list(self.row.items())[:3]}
|
|
38
|
+
suffix = "..." if len(self.row) > 3 else ""
|
|
39
|
+
return (
|
|
40
|
+
f"ChangeEvent({self.operation} {self.schema}.{self.table} "
|
|
41
|
+
f"lsn={self.lsn} row={row_preview}{suffix})"
|
|
42
|
+
)
|
pgstream/py.typed
ADDED
|
File without changes
|
pgstream/replication.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import select
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
import psycopg2
|
|
10
|
+
import psycopg2.extras
|
|
11
|
+
from psycopg2.extras import (
|
|
12
|
+
LogicalReplicationConnection,
|
|
13
|
+
ReplicationCursor,
|
|
14
|
+
ReplicationMessage,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from .decoder import PgOutputDecoder
|
|
18
|
+
from .events import ChangeEvent
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("pgstream.replication")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SlotManager:
|
|
24
|
+
"""Creates and drops the replication slot and publication.
|
|
25
|
+
|
|
26
|
+
Uses a normal (non-replication) psycopg2 connection for all DDL.
|
|
27
|
+
All methods are safe to call from a thread executor.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, dsn: str, slot_name: str, publication_name: str) -> None:
|
|
31
|
+
self._dsn = dsn
|
|
32
|
+
self.slot_name = slot_name
|
|
33
|
+
self.publication_name = publication_name
|
|
34
|
+
|
|
35
|
+
def setup(self, tables: list[str]) -> None:
|
|
36
|
+
"""Create the publication and replication slot if they don't exist.
|
|
37
|
+
|
|
38
|
+
Idempotent — safe to call on every startup.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
tables: Unqualified table names to watch, e.g. ``["documents"]``.
|
|
42
|
+
"""
|
|
43
|
+
conn = psycopg2.connect(self._dsn)
|
|
44
|
+
conn.autocommit = True
|
|
45
|
+
try:
|
|
46
|
+
cur = conn.cursor()
|
|
47
|
+
|
|
48
|
+
cur.execute(
|
|
49
|
+
"SELECT 1 FROM pg_publication WHERE pubname = %s",
|
|
50
|
+
(self.publication_name,),
|
|
51
|
+
)
|
|
52
|
+
if cur.fetchone() is None:
|
|
53
|
+
table_list = ", ".join(f'"{t}"' for t in tables)
|
|
54
|
+
cur.execute(
|
|
55
|
+
f"CREATE PUBLICATION {self.publication_name} FOR TABLE {table_list}"
|
|
56
|
+
)
|
|
57
|
+
logger.info("Created publication %r for tables: %s", self.publication_name, tables)
|
|
58
|
+
else:
|
|
59
|
+
logger.info("Publication %r already exists — skipping.", self.publication_name)
|
|
60
|
+
|
|
61
|
+
cur.execute(
|
|
62
|
+
"SELECT 1 FROM pg_replication_slots WHERE slot_name = %s",
|
|
63
|
+
(self.slot_name,),
|
|
64
|
+
)
|
|
65
|
+
if cur.fetchone() is None:
|
|
66
|
+
cur.execute(
|
|
67
|
+
"SELECT pg_create_logical_replication_slot(%s, 'pgoutput')",
|
|
68
|
+
(self.slot_name,),
|
|
69
|
+
)
|
|
70
|
+
logger.info("Created replication slot %r.", self.slot_name)
|
|
71
|
+
else:
|
|
72
|
+
logger.info("Replication slot %r already exists — skipping.", self.slot_name)
|
|
73
|
+
finally:
|
|
74
|
+
conn.close()
|
|
75
|
+
|
|
76
|
+
def teardown(self, drop_slot: bool = True, drop_publication: bool = True) -> None:
|
|
77
|
+
"""Drop the replication slot and/or publication.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
drop_slot: Drop the replication slot (default ``True``).
|
|
81
|
+
drop_publication: Drop the publication (default ``True``).
|
|
82
|
+
|
|
83
|
+
Warning:
|
|
84
|
+
Dropping the slot causes Postgres to stop retaining WAL. Any events
|
|
85
|
+
that occur while the slot is absent will be permanently lost.
|
|
86
|
+
"""
|
|
87
|
+
conn = psycopg2.connect(self._dsn)
|
|
88
|
+
conn.autocommit = True
|
|
89
|
+
try:
|
|
90
|
+
cur = conn.cursor()
|
|
91
|
+
if drop_slot:
|
|
92
|
+
cur.execute(
|
|
93
|
+
"SELECT pg_drop_replication_slot(%s) "
|
|
94
|
+
"WHERE EXISTS (SELECT 1 FROM pg_replication_slots WHERE slot_name = %s)",
|
|
95
|
+
(self.slot_name, self.slot_name),
|
|
96
|
+
)
|
|
97
|
+
logger.info("Dropped replication slot %r.", self.slot_name)
|
|
98
|
+
if drop_publication:
|
|
99
|
+
cur.execute(f"DROP PUBLICATION IF EXISTS {self.publication_name}")
|
|
100
|
+
logger.info("Dropped publication %r.", self.publication_name)
|
|
101
|
+
finally:
|
|
102
|
+
conn.close()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ReplicationStream:
|
|
106
|
+
"""Opens a replication connection and streams :class:`ChangeEvent` objects.
|
|
107
|
+
|
|
108
|
+
Blocking — runs the Postgres replication protocol loop in the calling
|
|
109
|
+
thread. :class:`~pgstream.stream.PGStream` wraps this in a background
|
|
110
|
+
thread so the asyncio event loop is not blocked.
|
|
111
|
+
|
|
112
|
+
Call :meth:`stop` from any thread to exit the loop cleanly.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
KEEPALIVE_INTERVAL = 10.0
|
|
116
|
+
|
|
117
|
+
def __init__(self, dsn: str, slot_name: str, publication_name: str) -> None:
|
|
118
|
+
self._dsn = dsn
|
|
119
|
+
self._slot_name = slot_name
|
|
120
|
+
self._publication_name = publication_name
|
|
121
|
+
self._stop_event = threading.Event()
|
|
122
|
+
self._decoder = PgOutputDecoder()
|
|
123
|
+
self._conn: psycopg2.extensions.connection | None = None
|
|
124
|
+
|
|
125
|
+
def stop(self) -> None:
|
|
126
|
+
"""Signal the streaming loop to stop after the current message."""
|
|
127
|
+
self._stop_event.set()
|
|
128
|
+
if self._conn is not None:
|
|
129
|
+
try:
|
|
130
|
+
self._conn.close()
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
def stream(
|
|
135
|
+
self,
|
|
136
|
+
tables: list[str],
|
|
137
|
+
on_event: Callable[[ChangeEvent], None],
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Stream WAL events from Postgres, calling *on_event* for each.
|
|
140
|
+
|
|
141
|
+
The LSN is ACKed to Postgres only after *on_event* returns without
|
|
142
|
+
raising, guaranteeing at-least-once delivery.
|
|
143
|
+
|
|
144
|
+
Blocks until :meth:`stop` is called or a fatal error occurs.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
tables: Table names being watched (used for filtering truncates).
|
|
148
|
+
on_event: Synchronous callback invoked for each :class:`ChangeEvent`.
|
|
149
|
+
"""
|
|
150
|
+
self._stop_event.clear()
|
|
151
|
+
|
|
152
|
+
self._conn = psycopg2.connect(
|
|
153
|
+
self._dsn,
|
|
154
|
+
connection_factory=LogicalReplicationConnection,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
cur: ReplicationCursor = self._conn.cursor()
|
|
159
|
+
cur.start_replication(
|
|
160
|
+
slot_name=self._slot_name,
|
|
161
|
+
decode=False,
|
|
162
|
+
options={
|
|
163
|
+
"proto_version": "1",
|
|
164
|
+
"publication_names": self._publication_name,
|
|
165
|
+
},
|
|
166
|
+
)
|
|
167
|
+
logger.info(
|
|
168
|
+
"Started replication from slot %r / publication %r",
|
|
169
|
+
self._slot_name,
|
|
170
|
+
self._publication_name,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
last_keepalive = time.monotonic()
|
|
174
|
+
|
|
175
|
+
while not self._stop_event.is_set():
|
|
176
|
+
try:
|
|
177
|
+
msg: ReplicationMessage | None = cur.read_message()
|
|
178
|
+
except psycopg2.OperationalError:
|
|
179
|
+
break
|
|
180
|
+
|
|
181
|
+
if msg is not None:
|
|
182
|
+
raw = self._decoder.decode(bytes(msg.payload))
|
|
183
|
+
|
|
184
|
+
if raw is not None:
|
|
185
|
+
event = ChangeEvent(
|
|
186
|
+
operation=raw["operation"],
|
|
187
|
+
schema=raw["schema"],
|
|
188
|
+
table=raw["table"],
|
|
189
|
+
row=raw["row"],
|
|
190
|
+
old_row=raw["old_row"],
|
|
191
|
+
lsn=raw["lsn"],
|
|
192
|
+
commit_time=raw["commit_time"],
|
|
193
|
+
xid=raw["xid"],
|
|
194
|
+
)
|
|
195
|
+
on_event(event)
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
cur.send_feedback(flush_lsn=msg.data_start)
|
|
199
|
+
except (psycopg2.InterfaceError, psycopg2.OperationalError):
|
|
200
|
+
break
|
|
201
|
+
last_keepalive = time.monotonic()
|
|
202
|
+
|
|
203
|
+
else:
|
|
204
|
+
try:
|
|
205
|
+
fd = self._conn.fileno()
|
|
206
|
+
select.select([fd], [], [], 1.0)
|
|
207
|
+
except OSError:
|
|
208
|
+
break
|
|
209
|
+
|
|
210
|
+
now = time.monotonic()
|
|
211
|
+
if now - last_keepalive > self.KEEPALIVE_INTERVAL:
|
|
212
|
+
try:
|
|
213
|
+
cur.send_feedback()
|
|
214
|
+
except (psycopg2.InterfaceError, psycopg2.OperationalError):
|
|
215
|
+
break
|
|
216
|
+
last_keepalive = now
|
|
217
|
+
|
|
218
|
+
finally:
|
|
219
|
+
try:
|
|
220
|
+
if self._conn and not self._conn.closed:
|
|
221
|
+
self._conn.close()
|
|
222
|
+
except Exception:
|
|
223
|
+
pass
|
|
224
|
+
self._conn = None
|
|
225
|
+
logger.info("Replication stream stopped.")
|
pgstream/sinks/base.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Sink(ABC):
|
|
7
|
+
"""Abstract base class for all pgstream vector store sinks.
|
|
8
|
+
|
|
9
|
+
Implement this interface to add support for a new vector store.
|
|
10
|
+
All I/O methods are async.
|
|
11
|
+
|
|
12
|
+
Example::
|
|
13
|
+
|
|
14
|
+
class MyVectorDB(Sink):
|
|
15
|
+
async def upsert(self, id: str, vector: list[float], payload: dict | None = None) -> None:
|
|
16
|
+
await self._client.upsert(id, vector, metadata=payload)
|
|
17
|
+
|
|
18
|
+
async def delete(self, id: str) -> None:
|
|
19
|
+
await self._client.delete(id)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
async def upsert(
|
|
24
|
+
self,
|
|
25
|
+
id: str,
|
|
26
|
+
vector: list[float],
|
|
27
|
+
payload: dict | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Insert or update a vector in the store.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
id: Unique identifier for this document (stringified PK).
|
|
33
|
+
vector: Dense float embedding, e.g. ``[0.12, -0.45, ...]``.
|
|
34
|
+
payload: Optional metadata stored alongside the vector.
|
|
35
|
+
"""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
async def delete(self, id: str) -> None:
|
|
40
|
+
"""Remove a vector from the store by its ID.
|
|
41
|
+
|
|
42
|
+
Implementations should be idempotent — deleting a non-existent ID
|
|
43
|
+
must not raise.
|
|
44
|
+
"""
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
async def close(self) -> None:
|
|
48
|
+
"""Release resources held by this sink (connections, HTTP sessions, etc.).
|
|
49
|
+
|
|
50
|
+
Called automatically by :meth:`~pgstream.stream.PGStream.stop`.
|
|
51
|
+
Override if your sink holds a long-lived connection.
|
|
52
|
+
"""
|
|
53
|
+
pass
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
import asyncpg
|
|
7
|
+
|
|
8
|
+
from .base import Sink
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("pgstream.sinks.pgvector")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PgVectorSink(Sink):
|
|
14
|
+
"""Writes vectors to a `pgvector <https://github.com/pgvector/pgvector>`_-enabled Postgres table.
|
|
15
|
+
|
|
16
|
+
The target table must have this schema::
|
|
17
|
+
|
|
18
|
+
CREATE EXTENSION IF NOT EXISTS vector;
|
|
19
|
+
CREATE TABLE embeddings (
|
|
20
|
+
id TEXT PRIMARY KEY,
|
|
21
|
+
vector VECTOR(1536), -- match your model's output dimension
|
|
22
|
+
payload JSONB
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
dsn: Postgres connection string.
|
|
27
|
+
table: Target table name (default ``"embeddings"``).
|
|
28
|
+
dimension: Embedding dimension — informational only.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
dsn: str,
|
|
34
|
+
table: str = "embeddings",
|
|
35
|
+
dimension: int | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._dsn = dsn
|
|
38
|
+
self._table = table
|
|
39
|
+
self._dimension = dimension
|
|
40
|
+
self._pool: asyncpg.Pool | None = None
|
|
41
|
+
|
|
42
|
+
async def _get_pool(self) -> asyncpg.Pool:
|
|
43
|
+
if self._pool is None:
|
|
44
|
+
self._pool = await asyncpg.create_pool(
|
|
45
|
+
self._dsn,
|
|
46
|
+
min_size=1,
|
|
47
|
+
max_size=5,
|
|
48
|
+
init=self._init_connection,
|
|
49
|
+
)
|
|
50
|
+
return self._pool
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
async def _init_connection(conn: asyncpg.Connection) -> None:
|
|
54
|
+
await conn.execute("SET search_path TO public")
|
|
55
|
+
|
|
56
|
+
async def upsert(
|
|
57
|
+
self,
|
|
58
|
+
id: str,
|
|
59
|
+
vector: list[float],
|
|
60
|
+
payload: dict | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Insert or update a vector row. Uses ``INSERT ... ON CONFLICT DO UPDATE``."""
|
|
63
|
+
pool = await self._get_pool()
|
|
64
|
+
vector_str = "[" + ",".join(str(v) for v in vector) + "]"
|
|
65
|
+
payload_json = json.dumps(payload) if payload is not None else "{}"
|
|
66
|
+
|
|
67
|
+
await pool.execute(
|
|
68
|
+
f"""
|
|
69
|
+
INSERT INTO {self._table} (id, vector, payload)
|
|
70
|
+
VALUES ($1, $2::vector, $3::jsonb)
|
|
71
|
+
ON CONFLICT (id) DO UPDATE
|
|
72
|
+
SET vector = EXCLUDED.vector,
|
|
73
|
+
payload = EXCLUDED.payload
|
|
74
|
+
""",
|
|
75
|
+
id,
|
|
76
|
+
vector_str,
|
|
77
|
+
payload_json,
|
|
78
|
+
)
|
|
79
|
+
logger.debug("Upserted id=%s into %s", id, self._table)
|
|
80
|
+
|
|
81
|
+
async def delete(self, id: str) -> None:
|
|
82
|
+
"""Delete a row by ``id``. No-op if the row does not exist."""
|
|
83
|
+
pool = await self._get_pool()
|
|
84
|
+
await pool.execute(f"DELETE FROM {self._table} WHERE id = $1", id)
|
|
85
|
+
logger.debug("Deleted id=%s from %s", id, self._table)
|
|
86
|
+
|
|
87
|
+
async def close(self) -> None:
|
|
88
|
+
if self._pool is not None:
|
|
89
|
+
await self._pool.close()
|
|
90
|
+
self._pool = None
|
|
91
|
+
logger.info("PgVectorSink pool closed.")
|