Skip to content

Refresh table metadata when psycodict announces schema changes#45

Draft
roed-math wants to merge 12 commits into
mainfrom
schema-refresh
Draft

Refresh table metadata when psycodict announces schema changes#45
roed-math wants to merge 12 commits into
mainfrom
schema-refresh

Conversation

@roed-math

@roed-math roed-math commented Jul 22, 2026

Copy link
Copy Markdown
Owner

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. Draft until psycodict 1.0 (with LMFDB#111) is released — but safe to merge early, since the refresher disables itself (with one log line) when psycodict lacks the API, and requirements.txt already installs psycodict from git master, so the feature lights up on its own once LMFDB#111 merges. To try it today: pip install "git+https://github.com/roed314/psycodict.git@listen-notify".

What it does. Each web worker owns a SchemaRefresher (new module lmfdb/schema_refresh.py), driven by a before_request hook. The refresher keeps a db.listener() subscribed to the psycodict_schema channel; each request does a non-blocking poll() — a socket read on an idle dedicated connection. When notifications have arrived, it calls db.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.

Policies this PR chooses — the things psycodict#111 deliberately leaves to the application:

  • No background thread. Sync workers are single-threaded; polling at request boundaries avoids refresh-during-request races and costs nothing measurable. An idle worker lags until its next request, which is harmless: no requests means no queries to fail. A non-blocking lock makes the hook safe under threaded/gevent servers too.
  • Reconnect with catch-up. On listener failure: drop it, back off 30s, resubscribe — and do a full refresh_tables() on every (re)subscription, because notifications sent while unsubscribed are lost (LISTEN only 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.
  • Whole-catalog refresh. The payload (a table name) is used for logging and for collapsing bursts into one refresh; 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.
  • Fork-safety. Under 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.py unit-tests the control flow with stub db/listener objects (subscribe + catch-up, batch dedupe + channel filtering, 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. Verified locally: 7 passed under sage -python -m pytest, and the site serves normally with today's psycodict (the hook logs the one-line "not available" notice exactly once and stays out of the way).

Deployment topology (verified live). devmirror is a physical hot standby (PG 18.1, pg_is_in_recovery() = true): a server in recovery refuses LISTEN outright (cannot execute LISTEN during recovery, SQLSTATE 25006), and NOTIFY is 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 cannot NOTIFY). 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_change additionally bumps a one-row meta_schema_gen table 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 cannot LISTEN polls that single-row SELECT at most every few seconds. That is a small psycodict follow-up plus ~15 lines here; the refresh-on-UndefinedColumn fallback discussed in psycodict#99 remains a complementary option.

🤖 Generated with Claude Code

roed314 and others added 11 commits July 17, 2026 13:22
Follow-up to LMFDB#7064, which enabled TCP keepalives on the PostgreSQL
connection with hardcoded values. Expose them as command-line / config.ini
options instead, so each site can tune or disable them:

    --postgresql-keepalives           (0|1, default 1)
    --postgresql-keepalives-idle      (seconds, default 30)
    --postgresql-keepalives-interval  (seconds, default 10)
    --postgresql-keepalives-count     (default 5)

The defaults are unchanged, so behaviour is identical out of the box, but
a production or development site can now pass --postgresql-keepalives 0 to
fall back to the OS defaults, or adjust the timings, without editing code.

These land in options["postgresql"] via the normal argparse/config
machinery, which also fixes the previous approach: the hardcoded
setdefault() ran after the config was parsed, so a value in config.ini was
silently ignored (contrary to the comment claiming it took precedence).
Now a config.ini or command-line value is genuinely respected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The last LMFDB tables using the search/extras split (maass_newforms,
maass_rigor) have had their extras columns merged into the search table,
and psycodict is removing support for the mechanism entirely before its
1.0 release (roed314/psycodict#59).

- lmfdb_database.py: column_description and drop_table no longer append
  self.extra_cols (empty for every table now); create_extra_table and
  move_column leave _nolog_changetypes, since neither operation exists
- knowledge/main.py: the missing-column-knowls report drops extra_cols
- api/api.py: stop building extra_schema for the collection and data
  pages, and stop passing it to the templates
- api/templates/apischema.html: drop the extra-columns rows

This is compatible with both the current psycodict and the version that
removes extras support, since extra_cols is [] for every table today.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stop referring to psycodict extras tables
psycodict is switching from psycopg2 to psycopg3
(roed314/psycodict#88).  Query fragments composed in the LMFDB are
executed by psycodict's _execute, so they must come from whichever
driver psycodict is built on -- and since both drivers can be installed
at once, try/except imports are not a valid probe.

The new lmfdb/utils/psycopg_compat.py keys off psycodict's re-exported
SQL and provides SQL, Identifier, Placeholder, Literal, Composable,
Composed plus DatabaseError, DataError, NumericValueOutOfRange and
QueryCanceledError from the matching driver.  All sixteen direct
psycopg2 imports (search/verify/knowledge/users/api/groups/ecnf, the
dynamic-knowls test and the old belyi script) now import from it, so
the LMFDB works unchanged on either side of psycodict's transition;
once psycodict is pinned past it, the module body can shrink to plain
psycopg imports.

requirements.txt now asks for psycodict[pgbinary], which installs
whichever driver the checked-out psycodict needs (psycopg2-binary
today, psycopg[binary] after the port) instead of naming psycopg2
directly.

Verified by loading the compat module against both psycodict versions:
all names resolve from the matching driver, composed statements execute
through _execute, and a statement_timeout raises the exported
QueryCanceledError under both -- that exception is what turns timeouts
into the 'search took too long' page rather than a 500.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
psycodict is underscore-prefixing methods that are not part of its
public API.  Rename the log_db_change override to _log_db_change
(keeping the old name bound so the hook works under either psycodict),
and call is_alive / can_read_write_knowls / can_read_write_userdb
through version-tolerant getattr fallbacks, so this can merge before
or after the psycodict change.  Simplify once psycodict is pinned.

The db.cursor() calls in riemann/stieltjes and zeros/zeta are sqlite3
connections, not psycodict, and are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Track psycodict's driver instead of importing psycopg2 directly
Follow psycodict's API narrowing (psycodict#92)
…nfig

Make the database TCP keepalive settings configurable
…hecks

check_external intended to tolerate unreachable external servers, but the
errno check never fired: urllib wraps the underlying socket error, so
URLError.errno is always None (the real errno lives on e.reason.errno).
Connection timeouts therefore failed CI even though refused connections
were tolerated via a string fallback. Look at e.reason instead, catch
bare TimeoutError/ConnectionError too (read timeouts are not wrapped in
URLError), skip visibly rather than passing silently, and bound each
request with a 30s timeout instead of the ~130s kernel connect timeout.

Also remove the two downloads from beta.lmfdb.org in
test_mf_hecke_cc_dataset: they exercised the deployed beta site rather
than the code under test, and their connect timeouts were the single
largest cause of CI failures. External link checking is tracked in LMFDB#6225.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fix flaky CI: tolerate unreachable external servers, remove beta download checks
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants