Skip to content

Commit 8acae8f

Browse files
roed314claude
andcommitted
Refresh table metadata when psycodict announces schema changes
Companion to roed314/psycodict#111: each web worker keeps a NotificationListener subscribed to the psycodict_schema channel and, on a non-blocking poll from a before_request hook, calls db.refresh_tables() when a schema change is announced, so column and table changes become visible without restarting workers. Reconnects with a catch-up refresh after listener failures, and is a no-op (one log line) when psycodict does not provide the notification API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d0ab659 commit 8acae8f

3 files changed

Lines changed: 361 additions & 0 deletions

File tree

lmfdb/app.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
from .logger import critical
2424
from .homepage import load_boxes, contribs
25+
from .schema_refresh import schema_refresher
2526

2627
LMFDB_VERSION = "LMFDB Release 1.2.1"
2728

@@ -340,6 +341,21 @@ def get_menu_cookie():
340341
"""
341342
g.show_menu = str(request.cookies.get('showmenu')) != "False"
342343

344+
##############################
345+
# Schema refreshing #
346+
##############################
347+
348+
349+
@app.before_request
350+
def refresh_schema_if_changed():
351+
"""
352+
Pick up schema changes announced on psycodict's LISTEN/NOTIFY channel, so
353+
that added or dropped columns and tables become visible to this worker
354+
without a restart. A non-blocking poll (and a no-op when psycodict does
355+
not provide the notification API).
356+
"""
357+
schema_refresher.check()
358+
343359
##############################
344360
# Top-level pages #
345361
##############################

lmfdb/schema_refresh.py

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
Keep a long-running website's table metadata in sync with the database.
4+
5+
psycodict reads each table's columns, types and sort information from the
6+
database once, when the connection is created. A website process therefore
7+
does not see schema changes made elsewhere: after a column is dropped its
8+
queries still mention the column and fail, and a newly added column stays
9+
invisible, until every worker process is restarted.
10+
11+
psycodict provides the two halves of the remedy:
12+
13+
- ``db.refresh_tables()`` re-reads the schema and updates the table objects
14+
in place, so references held by application code stay valid
15+
(roed314/psycodict#99);
16+
- schema-changing operations (``create_table``, ``drop_table``,
17+
``rename_table``, ``add_column``, ``drop_column`` and the reload swap)
18+
announce themselves via PostgreSQL's LISTEN/NOTIFY on the channel
19+
``psycodict_schema``, with the affected table's name as payload, and
20+
``db.listener()`` subscribes to that channel (roed314/psycodict#111).
21+
22+
This module ties the two together for the website. Each worker process owns
23+
a :class:`SchemaRefresher`, driven by a non-blocking ``check()`` from a
24+
``before_request`` hook: when a schema-change notification has arrived, the
25+
worker refreshes its table metadata before handling the request.
26+
27+
psycodict deliberately ships the notification API without threads, callbacks
28+
or automatic reconnection, leaving those policies to the application. The
29+
policies chosen here:
30+
31+
- **No background thread.** Web workers spend their life handling requests,
32+
so polling at request boundaries is both sufficient and free of the races a
33+
refresh-from-another-thread would invite. An idle worker can lag behind
34+
until its next request, which is harmless: with no requests there are no
35+
queries to fail. A non-blocking poll on an idle connection is just a
36+
socket read, so doing it every request costs nothing measurable.
37+
- **Reconnect with catch-up.** If the listening connection is lost, any
38+
notifications sent before a new ``LISTEN`` is issued are gone (PostgreSQL
39+
delivers only what is sent after). So the refresher backs off briefly,
40+
builds a fresh listener, and then does a full refresh to cover whatever it
41+
may have missed -- including the window between process start and the
42+
first subscription.
43+
- **Refresh everything, not just the named table.** The payload names the
44+
affected table, but ``refresh_tables()`` re-reads all metadata anyway; a
45+
whole-catalog refresh is cheap relative to how rarely schemas change, and
46+
it handles creates, drops and renames without special cases. The payload
47+
is still used for logging and for collapsing a burst of notifications into
48+
a single refresh.
49+
50+
If psycodict does not provide the notification API (any release before 1.0),
51+
the refresher logs once and disables itself, so this module is safe to
52+
deploy against current psycodict.
53+
"""
54+
import os
55+
import threading
56+
import time
57+
from logging import getLogger
58+
59+
try:
60+
from psycodict.notifications import SCHEMA_CHANNEL
61+
except ImportError:
62+
# psycodict without LISTEN/NOTIFY support; the refresher will disable
63+
# itself, but the channel name is part of psycodict's contract either way.
64+
SCHEMA_CHANNEL = "psycodict_schema"
65+
66+
logger = getLogger("lmfdb.schema_refresh")
67+
68+
69+
class SchemaRefresher:
70+
"""
71+
Refresh ``db``'s table metadata when a schema change is announced.
72+
73+
Drive it by calling :meth:`check` regularly -- the LMFDB app does so in a
74+
``before_request`` hook. ``check`` never blocks and never raises, so it
75+
cannot take a request down with it.
76+
77+
INPUT:
78+
79+
- ``db`` -- the database whose metadata to refresh; defaults to lmfdb's
80+
``db``, imported lazily so this module stays import-light
81+
- ``retry_interval`` -- seconds to wait before rebuilding the listener
82+
after a failure (default 30)
83+
"""
84+
85+
def __init__(self, db=None, retry_interval=30.0):
86+
self._db = db
87+
self.retry_interval = retry_interval
88+
self._listener = None
89+
self._pid = None
90+
self._next_attempt = 0.0
91+
self._logged_unavailable = False
92+
# before_request hooks may run concurrently under threaded or gevent
93+
# servers; one poller at a time is plenty, so extra callers just skip.
94+
self._lock = threading.Lock()
95+
96+
@property
97+
def db(self):
98+
if self._db is None:
99+
from lmfdb import db
100+
self._db = db
101+
return self._db
102+
103+
def available(self):
104+
"""
105+
Whether psycodict provides both the notification API (``listener``)
106+
and the refresh API (``refresh_tables``).
107+
"""
108+
return hasattr(self.db, "listener") and hasattr(self.db, "refresh_tables")
109+
110+
def check(self):
111+
"""
112+
Poll for schema-change notifications, refreshing metadata if any arrived.
113+
"""
114+
if not self._lock.acquire(blocking=False):
115+
# Another thread is polling; it will see anything we would have.
116+
return
117+
try:
118+
self._check()
119+
except Exception:
120+
# A refresher bug must never take down the request that ran it.
121+
logger.exception("Unexpected error while checking for schema changes")
122+
finally:
123+
self._lock.release()
124+
125+
def _check(self):
126+
if not self.available():
127+
if not self._logged_unavailable:
128+
logger.info(
129+
"psycodict does not provide schema-change notifications; "
130+
"table metadata will refresh only on restart"
131+
)
132+
self._logged_unavailable = True
133+
return
134+
if self._listener is not None and self._pid != os.getpid():
135+
# This process was forked (gunicorn --preload) after the listener
136+
# 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.
139+
self._listener = None
140+
if self._listener is None:
141+
if time.monotonic() < self._next_attempt:
142+
return
143+
try:
144+
self._listener = self.db.listener()
145+
self._pid = os.getpid()
146+
except Exception as err:
147+
self._next_attempt = time.monotonic() + self.retry_interval
148+
logger.warning(
149+
"Could not subscribe to schema-change notifications (%s); will retry", err
150+
)
151+
return
152+
# Notifications sent while we were not subscribed are lost, so
153+
# catch up with a full refresh on every (re)subscription.
154+
self._refresh("subscribed to schema-change notifications")
155+
return
156+
try:
157+
notifications = self._listener.poll()
158+
except Exception as err:
159+
self._drop_listener()
160+
self._next_attempt = time.monotonic() + self.retry_interval
161+
logger.warning("Lost the schema-change listener (%s); will resubscribe", err)
162+
return
163+
tables = sorted({payload for channel, payload in notifications if channel == SCHEMA_CHANNEL})
164+
if tables:
165+
self._refresh("schema changed for %s" % (", ".join(tables)))
166+
167+
def _refresh(self, reason):
168+
try:
169+
self.db.refresh_tables()
170+
except Exception as err:
171+
# Staying subscribed with stale metadata would silently swallow
172+
# the failure; drop the listener so the next check resubscribes
173+
# and the catch-up refresh retries this one.
174+
self._drop_listener()
175+
self._next_attempt = time.monotonic() + self.retry_interval
176+
logger.warning("Failed to refresh table metadata (%s): %s; will retry", reason, err)
177+
else:
178+
logger.info("Refreshed table metadata: %s", reason)
179+
180+
def _drop_listener(self):
181+
if self._listener is not None:
182+
try:
183+
self._listener.close()
184+
except Exception:
185+
pass
186+
self._listener = None
187+
188+
def close(self):
189+
"""
190+
Close the listener (if any); a later ``check`` subscribes anew.
191+
"""
192+
self._drop_listener()
193+
194+
195+
schema_refresher = SchemaRefresher()

lmfdb/tests/test_schema_refresh.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
Tests for lmfdb.schema_refresh.
4+
5+
These use stub database/listener objects, so they exercise the refresher's
6+
control flow (subscribe, poll, refresh, failure, fork handling) without
7+
needing a psycodict that provides the LISTEN/NOTIFY API; they pass against
8+
any psycodict version.
9+
"""
10+
11+
from lmfdb.schema_refresh import SCHEMA_CHANNEL, SchemaRefresher
12+
13+
14+
class StubListener:
15+
def __init__(self, db):
16+
self._db = db
17+
self.closed = False
18+
19+
def poll(self, timeout=0.0):
20+
if self._db.poll_error is not None:
21+
raise self._db.poll_error
22+
batch, self._db.pending = self._db.pending, []
23+
return batch
24+
25+
def close(self):
26+
self.closed = True
27+
28+
29+
class StubDB:
30+
"""
31+
Duck-types the (tiny) psycodict surface the refresher touches.
32+
"""
33+
def __init__(self):
34+
self.refreshes = 0
35+
self.pending = []
36+
self.listeners = []
37+
self.listen_error = None
38+
self.poll_error = None
39+
self.refresh_error = None
40+
41+
def listener(self):
42+
if self.listen_error is not None:
43+
raise self.listen_error
44+
listener = StubListener(self)
45+
self.listeners.append(listener)
46+
return listener
47+
48+
def refresh_tables(self):
49+
if self.refresh_error is not None:
50+
raise self.refresh_error
51+
self.refreshes += 1
52+
53+
54+
def test_unavailable_psycodict_is_a_noop():
55+
# An object with neither listener() nor refresh_tables(), like psycodict
56+
# before 1.0: the refresher must disable itself, not crash the request.
57+
refresher = SchemaRefresher(db=object())
58+
refresher.check()
59+
refresher.check()
60+
assert refresher._listener is None
61+
62+
63+
def test_subscribe_then_notify():
64+
db = StubDB()
65+
refresher = SchemaRefresher(db=db)
66+
# The first check subscribes and does a catch-up refresh (notifications
67+
# sent before LISTEN are lost, so a new subscriber cannot assume it has
68+
# seen everything).
69+
refresher.check()
70+
assert len(db.listeners) == 1
71+
assert db.refreshes == 1
72+
# A quiet poll does not refresh.
73+
refresher.check()
74+
assert db.refreshes == 1
75+
# One batch of notifications = one refresh; other channels are ignored.
76+
db.pending = [
77+
(SCHEMA_CHANNEL, "nf_fields"),
78+
(SCHEMA_CHANNEL, "ec_curvedata"),
79+
("some_other_channel", "ignored"),
80+
]
81+
refresher.check()
82+
assert db.refreshes == 2
83+
refresher.check()
84+
assert db.refreshes == 2
85+
86+
87+
def test_subscription_failure_backs_off_then_recovers():
88+
db = StubDB()
89+
db.listen_error = RuntimeError("connection refused")
90+
refresher = SchemaRefresher(db=db, retry_interval=1000)
91+
refresher.check()
92+
assert refresher._listener is None
93+
assert db.refreshes == 0
94+
# Within the retry interval, no new attempt is made even though the
95+
# database has recovered.
96+
db.listen_error = None
97+
refresher.check()
98+
assert refresher._listener is None
99+
# Once the interval has passed, it subscribes and catches up.
100+
refresher._next_attempt = 0.0
101+
refresher.check()
102+
assert len(db.listeners) == 1
103+
assert db.refreshes == 1
104+
105+
106+
def test_lost_listener_resubscribes_with_catchup():
107+
db = StubDB()
108+
refresher = SchemaRefresher(db=db, retry_interval=0.0)
109+
refresher.check()
110+
assert db.refreshes == 1
111+
db.poll_error = RuntimeError("server closed the connection unexpectedly")
112+
refresher.check()
113+
assert refresher._listener is None
114+
assert db.listeners[0].closed
115+
db.poll_error = None
116+
# The resubscription's catch-up refresh covers notifications that were
117+
# lost while disconnected.
118+
refresher.check()
119+
assert len(db.listeners) == 2
120+
assert db.refreshes == 2
121+
122+
123+
def test_failed_refresh_drops_listener_for_retry():
124+
db = StubDB()
125+
refresher = SchemaRefresher(db=db, retry_interval=0.0)
126+
db.refresh_error = RuntimeError("could not read meta_tables")
127+
refresher.check()
128+
# The catch-up refresh failed: rather than staying subscribed with stale
129+
# metadata, the listener is dropped so the next check retries in full.
130+
assert refresher._listener is None
131+
assert db.refreshes == 0
132+
db.refresh_error = None
133+
refresher.check()
134+
assert db.refreshes == 1
135+
assert refresher._listener is not None
136+
137+
138+
def test_forked_worker_builds_its_own_listener():
139+
db = StubDB()
140+
refresher = SchemaRefresher(db=db)
141+
refresher.check()
142+
# Simulate a fork: the recorded pid no longer matches this process.
143+
refresher._pid = -1
144+
refresher.check()
145+
# The inherited listener is abandoned *without* close (its socket is
146+
# shared with the parent process) and a fresh one is built, followed by
147+
# the usual catch-up refresh.
148+
assert len(db.listeners) == 2
149+
assert not db.listeners[0].closed
150+
assert db.refreshes == 2

0 commit comments

Comments
 (0)