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 ADDED
@@ -0,0 +1,5 @@
1
+ from .events import ChangeEvent
2
+ from .stream import PGStream
3
+
4
+ __all__ = ["PGStream", "ChangeEvent"]
5
+ __version__ = "0.1.0"
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
@@ -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.")
@@ -0,0 +1,5 @@
1
+ from .base import Sink
2
+ from .pgvector import PgVectorSink
3
+ from .qdrant import QdrantSink
4
+
5
+ __all__ = ["Sink", "PgVectorSink", "QdrantSink"]
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.")