Refresh table metadata when psycodict announces schema changes - #45
Refresh table metadata when psycodict announces schema changes#45roed-math wants to merge 6 commits into
Conversation
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>
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>
The new test file was never added to the explicit CI inventory, so every shard died at the "didn't miss any test files" guard before running any test or lint: schedule it in the paired proddb/devmirror matrix entries that already cover lmfdb/tests, and bump the expected file count to 45. Also make the payload handling actually observable in the tests -- the old batch mixed channels but only counted refreshes, so it would have passed with the channel filter removed, and it never repeated a payload, so it never exercised the deduplication the PR claims. A batch of purely foreign notifications now asserts no refresh, and a burst with repeats asserts the computed reason names each table once, sorted, with the other channel's payload absent. Documentation corrections, no behavior change: only the steady-state poll is non-blocking (subscribing and refreshing do database I/O); the lock stops two pollers colliding but does not serialize refresh_tables() against queries in other requests, so it does not by itself make threaded or gevent workers safe; and against pre-1.0 psycodict the refresher logs once and stays a no-op rather than setting _disabled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Addressed the review: the two blocking CI fixes, plus the test and documentation accuracy items. Required, and the reason every shard was red
Test accuracy (item 3) The old batch did mix in a foreign channel, but only asserted a refresh count, so it would have passed with the channel filter deleted; and it never repeated a payload, so it never exercised the deduplication the description claims. Now:
Confirmed by mutation: deleting the Documentation (items 4, 5, 6)
Description (item 7) Rewritten: psycodict#111 merged 2026-07-22 and shipped in 1.0.0rc1, and No behavior changed anywhere: the two workflow files, the two docstring/comment passages, the Local: 9 passed under CI is queued rather than running at the moment, so I have not yet seen a shard get past the guard on the hosted runners; that is the thing to watch on this push, since every shard previously stopped at |
The module docstring still introduced check() as "non-blocking" without qualification, and the ImportError fallback comment still said the refresher would "disable itself" against a pre-1.0 psycodict when it actually just stays a no-op. Both now match what the code does; the class docstring already drew the distinction correctly. While here, "likewise disables itself" for the hot-standby case had lost its antecedent when the pre-1.0 wording changed, so say plainly that this one case really does disable the refresher, in contrast to the no-op. Documentation only, no behavior change: _disabled is still set solely on SQLSTATE 25006, so capability re-detection is preserved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
GPT signed off. |
Companion PR to roed314/psycodict#111 (LISTEN/NOTIFY), showing what the consumer side looks like: the website picks up schema changes without restarting every worker. psycodict#111 has since merged and shipped, so nothing here is waiting on it:
requirements.txtasks for released psycodict (psycodict[pgbinary]>=1.0.0rc1,<2), CI resolves 1.0.0rc2, and every version in that range provides both halves of the API. The refresher still tolerates an older psycodict, logging one line and staying a no-op, so an environment that has not upgraded keeps working. Still a draft pending psycodict 1.0.0 final (only release candidates are out so far).What it does. Each web worker owns a
SchemaRefresher(new modulelmfdb/schema_refresh.py), driven by abefore_requesthook. The refresher keeps adb.listener()subscribed to thepsycodict_schemachannel; each request does a non-blockingpoll(), a socket read on an idle dedicated connection. When notifications have arrived, it callsdb.refresh_tables()(psycodict#99, already merged) once per batch, so added/dropped/renamed columns and tables become visible before the request is handled. Log lines record each refresh with the table names from the notification payloads. Only that steady-state poll is non-blocking: establishing a listener and refreshing metadata do talk to the database and can block, they just happen rarely. Whatcheck()does guarantee unconditionally is that no exception of its own reaches the request.Policies this PR chooses — the things psycodict#111 deliberately leaves to the application:
refresh_tables()against queries running in other requests, so it is not on its own enough for threaded or gevent workers; the design assumes the single-threaded workers we actually run, where a request boundary is a moment with nothing in flight.refresh_tables()on every (re)subscription, because notifications sent while unsubscribed are lost (LISTENonly delivers what is sent after it). The same catch-up covers the window between worker start and first subscription, and a failed refresh drops the listener so the next resubscription retries it.refresh_tables()re-reads everything anyway, which handles create/drop/rename uniformly. Per-table refresh via the payload is an easy later optimization if the full refresh ever gets expensive.gunicorn --preload, an inherited listener is abandoned (not closed — its socket is shared with the parent) and each worker builds its own.Cost. One extra idle PG connection per worker, one non-blocking poll per request, one catch-up refresh per worker per (re)subscription; refreshes otherwise happen only when the schema actually changes.
Tests.
lmfdb/tests/test_schema_refresh.pyunit-tests the control flow with stub db/listener objects (subscribe + catch-up, foreign-channel filtering, duplicate-payload collapse, subscription-failure backoff and recovery, lost-listener resubscribe, failed-refresh retry, hot-standby permanent-disable, fork handling) and passes against any psycodict version; the live NOTIFY→LISTEN round trip is covered by psycodict's own tests in LMFDB#111. Filtering and dedupe are asserted on the refresh reason the refresher computes, not merely on a refresh count, so dropping the channel filter or thesorted(set(...))collapse fails the suite (checked by mutating each in turn). The file is registered inmatrix_includes.jsonunder bothproddbanddevmirror, and the test-file count guard inpython-package.ymlis bumped to 45. Verified locally: 9 passed undersage -python -m pytest, pyflakes/pylint/ruff clean.Deployment topology (verified live). devmirror is a physical hot standby (PG 18.1,
pg_is_in_recovery() = true): a server in recovery refusesLISTENoutright (cannot execute LISTEN during recovery, SQLSTATE 25006), andNOTIFYis not WAL-logged, so notifications cannot reach websites pointing at it no matter what the sync does — no trigger or event trigger on devmirror can help (triggers don't fire during WAL replay, and a standby cannotNOTIFY). The refresher therefore treats SQLSTATE 25006 at subscribe time as permanent and disables itself for the life of the process, so development copies of the website (which read devmirror) keep today's behavior — restart to pick up schema changes — with a single info log line instead of a retry loop. Production/beta webservers, whose schema changes are applied by psycodict-driven processes on the primaries they read from, get the live refresh.We've decided not to pursue automatic refresh for dev copies, since prod/beta don't need it. For the record, the workable design would be a replicated schema-generation marker:
_notify_schema_changeadditionally bumps a one-rowmeta_schema_gentable in the same transaction on the primary. The row replicates through WAL in commit order — so by the time a website sees the bump, the schema change itself has already replayed on the standby (a guarantee primary-side LISTEN cannot give) — and a website that cannotLISTENpolls that single-row SELECT at most every few seconds. That is a small psycodict follow-up plus ~15 lines here; the refresh-on-UndefinedColumnfallback discussed in psycodict#99 remains a complementary option.🤖 Generated with Claude Code