keepup-postgres 0.4.0__tar.gz → 0.5.0__tar.gz
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.
- {keepup_postgres-0.4.0 → keepup_postgres-0.5.0}/PKG-INFO +1 -1
- {keepup_postgres-0.4.0 → keepup_postgres-0.5.0}/keepup_postgres/__init__.py +1 -1
- keepup_postgres-0.5.0/keepup_postgres/transport.py +158 -0
- {keepup_postgres-0.4.0 → keepup_postgres-0.5.0}/keepup_postgres.egg-info/PKG-INFO +1 -1
- {keepup_postgres-0.4.0 → keepup_postgres-0.5.0}/keepup_postgres.egg-info/SOURCES.txt +1 -0
- {keepup_postgres-0.4.0 → keepup_postgres-0.5.0}/pyproject.toml +1 -1
- {keepup_postgres-0.4.0 → keepup_postgres-0.5.0}/README.md +0 -0
- {keepup_postgres-0.4.0 → keepup_postgres-0.5.0}/keepup_postgres.egg-info/dependency_links.txt +0 -0
- {keepup_postgres-0.4.0 → keepup_postgres-0.5.0}/keepup_postgres.egg-info/entry_points.txt +0 -0
- {keepup_postgres-0.4.0 → keepup_postgres-0.5.0}/keepup_postgres.egg-info/requires.txt +0 -0
- {keepup_postgres-0.4.0 → keepup_postgres-0.5.0}/keepup_postgres.egg-info/top_level.txt +0 -0
- {keepup_postgres-0.4.0 → keepup_postgres-0.5.0}/setup.cfg +0 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Carrying envelopes between replicas over PostgreSQL's NOTIFY (keepup-117).
|
|
2
|
+
|
|
3
|
+
The bus is the kernel's: the envelopes, the seal, the subscribers. The delivery
|
|
4
|
+
is the driver's, because holding a LISTEN connection and sending `pg_notify` is
|
|
5
|
+
what `asyncpg` does -- and `asyncpg` is what this distribution brings. A
|
|
6
|
+
deployment on a database that cannot carry a message between processes publishes
|
|
7
|
+
the in-process transport instead, and says so in its report.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any, Callable, Optional
|
|
13
|
+
|
|
14
|
+
from keepup.notification_bus import (
|
|
15
|
+
RECONNECT_DELAY_INITIAL,
|
|
16
|
+
RECONNECT_DELAY_MAX,
|
|
17
|
+
Transport,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
__all__ = ["PostgresTransport"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PostgresTransport(Transport):
|
|
26
|
+
"""LISTEN/NOTIFY: one connection listens for as long as it listens."""
|
|
27
|
+
|
|
28
|
+
def __init__(self):
|
|
29
|
+
self._connection: Optional[Any] = None
|
|
30
|
+
self._listener_task: Optional[asyncio.Task] = None
|
|
31
|
+
self._stopping = False
|
|
32
|
+
self._channel = ""
|
|
33
|
+
self._receive: Optional[Callable[[str], None]] = None
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def is_running(self) -> bool:
|
|
37
|
+
"""Whether the listening connection is up."""
|
|
38
|
+
return self._connection is not None
|
|
39
|
+
|
|
40
|
+
async def start(self, channel: str, receive: Callable[[str], None]) -> bool:
|
|
41
|
+
"""Listen on the application's channel, reopening the connection after a break."""
|
|
42
|
+
from keepup_db import db_config
|
|
43
|
+
|
|
44
|
+
if not db_config.is_postgres():
|
|
45
|
+
logger.info("Notifications need PostgreSQL; this deployment carries them "
|
|
46
|
+
"only inside its own process")
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
self._channel, self._receive = channel, receive
|
|
50
|
+
self._stopping = False
|
|
51
|
+
self._listener_task = asyncio.create_task(self._listen_forever())
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
async def stop(self) -> None:
|
|
55
|
+
"""Stop reconnecting and close the listening connection."""
|
|
56
|
+
self._stopping = True
|
|
57
|
+
if self._listener_task:
|
|
58
|
+
self._listener_task.cancel()
|
|
59
|
+
try:
|
|
60
|
+
await self._listener_task
|
|
61
|
+
except (asyncio.CancelledError, Exception):
|
|
62
|
+
pass
|
|
63
|
+
self._listener_task = None
|
|
64
|
+
await self._close_connection()
|
|
65
|
+
|
|
66
|
+
async def publish(self, channel: str, payload: str) -> bool:
|
|
67
|
+
"""Send one payload from the pool, never on the listening connection.
|
|
68
|
+
|
|
69
|
+
asyncpg runs one operation at a time on a connection: two envelopes
|
|
70
|
+
published there made the second fail with "another operation is in
|
|
71
|
+
progress", returned False and were lost without a trace, and every
|
|
72
|
+
publish held up the listener's reading (keepup-50). `pg_notify` needs no
|
|
73
|
+
LISTEN on the connection that sends it.
|
|
74
|
+
"""
|
|
75
|
+
from keepup_db import DatabaseManagerV2
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
await DatabaseManagerV2.execute_commit_async(
|
|
79
|
+
"SELECT pg_notify(:channel, :payload)",
|
|
80
|
+
{"channel": channel, "payload": payload})
|
|
81
|
+
return True
|
|
82
|
+
except Exception as error:
|
|
83
|
+
logger.error("Could not publish a notification: %s", error)
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
async def _close_connection(self) -> None:
|
|
87
|
+
if self._connection is None:
|
|
88
|
+
return
|
|
89
|
+
connection, self._connection = self._connection, None
|
|
90
|
+
try:
|
|
91
|
+
await connection.close()
|
|
92
|
+
except Exception as error:
|
|
93
|
+
logger.warning("Could not close the notification connection: %s", error)
|
|
94
|
+
|
|
95
|
+
async def _connect(self):
|
|
96
|
+
"""Open a connection dedicated to listening.
|
|
97
|
+
|
|
98
|
+
Deliberately not from the SQLAlchemy pool: LISTEN occupies a connection
|
|
99
|
+
for as long as it listens, and a pooled connection handed back would stop
|
|
100
|
+
delivering while still looking reusable.
|
|
101
|
+
"""
|
|
102
|
+
from keepup_db import db_config
|
|
103
|
+
import asyncpg
|
|
104
|
+
|
|
105
|
+
return await asyncpg.connect(
|
|
106
|
+
host=db_config.db_host,
|
|
107
|
+
port=int(db_config.db_port),
|
|
108
|
+
user=db_config.db_user,
|
|
109
|
+
password=db_config.db_password,
|
|
110
|
+
database=db_config.db_name,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def _on_notify(self, connection, pid, channel, payload) -> None:
|
|
114
|
+
"""asyncpg calls this from the connection's reader task."""
|
|
115
|
+
if self._receive is not None:
|
|
116
|
+
self._receive(payload)
|
|
117
|
+
|
|
118
|
+
async def _listen_forever(self) -> None:
|
|
119
|
+
"""Hold the listening connection, reopening it after a break.
|
|
120
|
+
|
|
121
|
+
The break is logged on both sides -- lost and restored -- because
|
|
122
|
+
delivery has no replay: what happened while this was down is not
|
|
123
|
+
recoverable from the bus, and a silent gap would be indistinguishable
|
|
124
|
+
from nothing having happened.
|
|
125
|
+
"""
|
|
126
|
+
delay = RECONNECT_DELAY_INITIAL
|
|
127
|
+
|
|
128
|
+
while not self._stopping:
|
|
129
|
+
try:
|
|
130
|
+
# Published only once it listens: is_running answers "envelopes
|
|
131
|
+
# from other replicas are arriving", and a connection still
|
|
132
|
+
# attaching its listener does not deliver yet.
|
|
133
|
+
connection = await self._connect()
|
|
134
|
+
try:
|
|
135
|
+
await connection.add_listener(self._channel, self._on_notify)
|
|
136
|
+
except BaseException:
|
|
137
|
+
await connection.close()
|
|
138
|
+
raise
|
|
139
|
+
self._connection = connection
|
|
140
|
+
logger.info("Notification bus listening on '%s'", self._channel)
|
|
141
|
+
delay = RECONNECT_DELAY_INITIAL
|
|
142
|
+
|
|
143
|
+
while not self._stopping and self._connection is not None:
|
|
144
|
+
if self._connection.is_closed():
|
|
145
|
+
raise ConnectionError("the listening connection was closed")
|
|
146
|
+
await asyncio.sleep(1)
|
|
147
|
+
|
|
148
|
+
except asyncio.CancelledError:
|
|
149
|
+
raise
|
|
150
|
+
except Exception as error:
|
|
151
|
+
await self._close_connection()
|
|
152
|
+
if self._stopping:
|
|
153
|
+
return
|
|
154
|
+
logger.warning(
|
|
155
|
+
"Notification bus lost its connection (%s); reconnecting in %.0fs",
|
|
156
|
+
error, delay)
|
|
157
|
+
await asyncio.sleep(delay)
|
|
158
|
+
delay = min(delay * 2, RECONNECT_DELAY_MAX)
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "keepup-postgres"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "PostgreSQL behind the abstraction: the dialect, the connection and what the database can do."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
File without changes
|
{keepup_postgres-0.4.0 → keepup_postgres-0.5.0}/keepup_postgres.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|