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 +27 -0
- walbox/abc.py +184 -0
- walbox/builder.py +50 -0
- walbox/checkpoint.py +191 -0
- walbox/client.py +383 -0
- walbox/errors.py +114 -0
- walbox/pgoutput.py +897 -0
- walbox/protocol.py +165 -0
- walbox/transaction.py +392 -0
- walbox/transport.py +383 -0
- walbox-1.0.0.dist-info/METADATA +156 -0
- walbox-1.0.0.dist-info/RECORD +14 -0
- walbox-1.0.0.dist-info/WHEEL +4 -0
- walbox-1.0.0.dist-info/licenses/LICENSE +21 -0
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
|
+
)
|