keepup-postgres 0.4.0__tar.gz → 0.6.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keepup-postgres
3
- Version: 0.4.0
3
+ Version: 0.6.0
4
4
  Summary: PostgreSQL behind the abstraction: the dialect, the connection and what the database can do.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -9,4 +9,4 @@ The code moves here in keepup-124: until then the plugin lives in the framework'
9
9
  own `builtin/postgres.py` and this package is the home it is moving to.
10
10
  """
11
11
 
12
- __version__ = "0.4.0"
12
+ __version__ = "0.6.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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keepup-postgres
3
- Version: 0.4.0
3
+ Version: 0.6.0
4
4
  Summary: PostgreSQL behind the abstraction: the dialect, the connection and what the database can do.
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -1,6 +1,7 @@
1
1
  README.md
2
2
  pyproject.toml
3
3
  keepup_postgres/__init__.py
4
+ keepup_postgres/transport.py
4
5
  keepup_postgres.egg-info/PKG-INFO
5
6
  keepup_postgres.egg-info/SOURCES.txt
6
7
  keepup_postgres.egg-info/dependency_links.txt
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "keepup-postgres"
7
- version = "0.4.0"
7
+ version = "0.6.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"