Skip to content

Commit a1bd212

Browse files
roed314claude
andcommitted
Disable schema refresher permanently on hot standbys
A server in recovery refuses LISTEN outright (SQLSTATE 25006) and can never deliver notifications (NOTIFY is not WAL-logged), so retrying every 30s would warn forever. Verified against devmirror, which is a physical replica (PG 18.1, pg_is_in_recovery() = true) -- this is the situation for development copies of the website. Also document why abandoning an inherited listener without close() is safe (psycopg's pid-guarded GC). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8acae8f commit a1bd212

2 files changed

Lines changed: 47 additions & 2 deletions

File tree

lmfdb/schema_refresh.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@
5050
If psycodict does not provide the notification API (any release before 1.0),
5151
the refresher logs once and disables itself, so this module is safe to
5252
deploy against current psycodict.
53+
54+
The refresher likewise disables itself, for the life of the process, when
55+
the database is a hot standby: a server in recovery refuses ``LISTEN``
56+
outright (SQLSTATE 25006), and notifications cannot traverse physical
57+
replication anyway (``NOTIFY`` is not WAL-logged). This is the situation
58+
for development copies of the website pointing at devmirror; they keep the
59+
status quo (restart to pick up schema changes) unless a polling fallback is
60+
added later.
5361
"""
5462
import os
5563
import threading
@@ -88,6 +96,7 @@ def __init__(self, db=None, retry_interval=30.0):
8896
self._listener = None
8997
self._pid = None
9098
self._next_attempt = 0.0
99+
self._disabled = False
91100
self._logged_unavailable = False
92101
# before_request hooks may run concurrently under threaded or gevent
93102
# servers; one poller at a time is plenty, so extra callers just skip.
@@ -123,6 +132,8 @@ def check(self):
123132
self._lock.release()
124133

125134
def _check(self):
135+
if self._disabled:
136+
return
126137
if not self.available():
127138
if not self._logged_unavailable:
128139
logger.info(
@@ -134,8 +145,11 @@ def _check(self):
134145
if self._listener is not None and self._pid != os.getpid():
135146
# This process was forked (gunicorn --preload) after the listener
136147
# was built, so the socket is shared with the parent. Abandon it
137-
# without closing -- a close would corrupt the parent's copy --
138-
# and build our own below.
148+
# without closing: an explicit close would send a protocol
149+
# Terminate over the shared socket, killing the parent's copy,
150+
# while just dropping the reference is safe (psycopg skips the
151+
# protocol shutdown when collecting a connection in a process
152+
# other than the one that created it). Then build our own below.
139153
self._listener = None
140154
if self._listener is None:
141155
if time.monotonic() < self._next_attempt:
@@ -144,6 +158,19 @@ def _check(self):
144158
self._listener = self.db.listener()
145159
self._pid = os.getpid()
146160
except Exception as err:
161+
code = getattr(err, "sqlstate", None) or getattr(err, "pgcode", None)
162+
if code == "25006":
163+
# "cannot execute LISTEN during recovery": the database is
164+
# a hot standby, which can never deliver notifications
165+
# (NOTIFY is not WAL-logged), so this is permanent for the
166+
# life of the server -- disable rather than retry forever.
167+
self._disabled = True
168+
logger.info(
169+
"Database is a hot standby (%s); schema-change "
170+
"notifications are unavailable there, so table "
171+
"metadata will refresh only on restart", err
172+
)
173+
return
147174
self._next_attempt = time.monotonic() + self.retry_interval
148175
logger.warning(
149176
"Could not subscribe to schema-change notifications (%s); will retry", err

lmfdb/tests/test_schema_refresh.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,24 @@ def test_failed_refresh_drops_listener_for_retry():
135135
assert refresher._listener is not None
136136

137137

138+
def test_hot_standby_disables_permanently():
139+
class RecoveryError(RuntimeError):
140+
sqlstate = "25006"
141+
142+
db = StubDB()
143+
db.listen_error = RecoveryError("cannot execute LISTEN during recovery")
144+
refresher = SchemaRefresher(db=db, retry_interval=0.0)
145+
refresher.check()
146+
assert refresher._listener is None
147+
# Permanent: even after the retry interval (0s here) and with the error
148+
# cleared, no new subscription is attempted -- a standby can never
149+
# deliver notifications, so retrying would just warn forever.
150+
db.listen_error = None
151+
refresher.check()
152+
assert db.listeners == []
153+
assert db.refreshes == 0
154+
155+
138156
def test_forked_worker_builds_its_own_listener():
139157
db = StubDB()
140158
refresher = SchemaRefresher(db=db)

0 commit comments

Comments
 (0)