Skip to content

Migrate database driver from psycopg2 to psycopg3#4114

Draft
adobloug wants to merge 10 commits into
Uninett:masterfrom
adobloug:feature/3190-psycopg3-migration
Draft

Migrate database driver from psycopg2 to psycopg3#4114
adobloug wants to merge 10 commits into
Uninett:masterfrom
adobloug:feature/3190-psycopg3-migration

Conversation

@adobloug

@adobloug adobloug commented Jul 3, 2026

Copy link
Copy Markdown

Fixes #3190.

psycopg2 is in maintenance mode; this migrates NAV's database driver to
psycopg3. The ±infinity timestamp blocker (NAV stores unresolved events as
datetime.max'infinity') is solved by the
django-psycopg-infinity
backend.

Strategy (Django-first): rather than port the legacy nav.db connection
cache to the psycopg3 API, migrate its raw callers onto django.db.connection
and let Django's psycopg3 backend handle isolation, autocommit, encoding, error
wrapping and pooling. Genuinely-standalone tools (run before/without the ORM)
keep a raw connection with minimal psycopg3 fixes.

What this PR does

  • Backend: add psycopg>=3.2 + django-psycopg-infinity; switch the DB
    engine. psycopg2 is retained transitionally (still used by the legacy
    cache callers below) and removed in the follow-up.
  • Django-first callers → django.db.connection + transaction.atomic():
    event.py, logengine.py, bin/mailin.py, bin/navclean.py, snmptrapd
    (+ handlers), smsd/navdbqueue.py.
  • LISTEN/NOTIFY readers (ipdevpoll/db.py, eventengine/engine.py): ported
    to psycopg3's pgconn.notifies() via a shared nav.db.get_pg_notifications
    helper (psycopg3 dropped psycopg2's poll() + .notifies).
  • escape() reimplemented on psycopg3 sql.Literal(...).as_string().
  • Raw psycopg3 sites (small fixes, no Django): pgsync.py,
    bin/radiusparser.py, statemon/db.py. statemon registers text loaders for
    inet/cidr — psycopg3 loads those as ipaddress objects by default, which
    broke megaping (it expects the strings psycopg2 returned).

Deferred to follow-up PRs (avoids overlap)

Testing

  • Full unit + integration suite green on py3.11 and py3.13.
  • ruff check + ruff format clean.

Opened as draft — incremental; coordinates with #4111 and #4113.

@codecov

codecov Bot commented Jul 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 47.66355% with 112 lines in your changes missing coverage. Please review.
✅ Project coverage is 64.97%. Comparing base (4c69c47) to head (3d2f8fc).
⚠️ Report is 46 commits behind head on master.

Files with missing lines Patch % Lines
python/nav/smsd/navdbqueue.py 35.71% 27 Missing ⚠️
python/nav/event.py 42.42% 19 Missing ⚠️
python/nav/db/__init__.py 25.00% 15 Missing ⚠️
python/nav/bin/navclean.py 68.00% 8 Missing ⚠️
python/nav/snmptrapd/handlers/linkupdown.py 42.85% 8 Missing ⚠️
python/nav/logengine.py 58.82% 7 Missing ⚠️
python/nav/snmptrapd/handlers/handlertemplate.py 0.00% 6 Missing ⚠️
python/nav/statemon/db.py 60.00% 6 Missing ⚠️
python/nav/bin/radiusparser.py 0.00% 5 Missing ⚠️
python/nav/ipdevpoll/db.py 33.33% 4 Missing ⚠️
... and 4 more
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #4114      +/-   ##
==========================================
- Coverage   64.99%   64.97%   -0.02%     
==========================================
  Files         627      627              
  Lines       47096    47101       +5     
==========================================
- Hits        30609    30603       -6     
- Misses      16487    16498      +11     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

adobloug and others added 10 commits July 9, 2026 08:45
psycopg2 is in maintenance mode; NAV must move to psycopg3 to stay on
current Python and Django and to gain access to Django's native
connection pooling. psycopg3 does not support PostgreSQL's ±infinity
timestamps out of the box, which NAV relies on for unresolved events
(end_time stored as 'infinity'). Use the django-psycopg-infinity
backend, which registers psycopg3 loaders mapping ±infinity to
datetime.max/min while otherwise behaving as Django's standard
PostgreSQL backend.

psycopg2 is kept as a transitional dependency so the legacy nav.db
callers that still import it keep working while they are migrated one
by one; it is removed in the final migration commit.

hstore is still registered automatically by django.contrib.postgres
on the psycopg3 backend, so no manual registration is needed.

Refs Uninett#3190.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EventQ used the legacy nav.db connection cache (getConnection) and
psycopg2's set_isolation_level to run event posting in a single
transaction. That connection cache is the shared, non-thread-safe path
behind the connection-leak races (Uninett#4104), and set_isolation_level does
not exist in psycopg3.

Use django.db.connection (thread-local, pooled via CONN_MAX_AGE) and
wrap the multi-statement post in transaction.atomic() instead of manual
isolation-level and commit handling. Cursors are now managed with
context managers so they are always closed.

While here, load all event variables in consume_events with fetchall();
the previous fetchmany() call loaded only a single variable per event.

Refs Uninett#3190.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
psycopg3 drops the psycopg2 async API (conn.poll() and the
conn.notifies list) that the eventengine and ipdevpoll notification
readers relied on. Under the psycopg3 backend both daemons crashed at
runtime with "AttributeError: 'Connection' object has no attribute
'poll'".

Add nav.db.get_pg_notifications(), a non-blocking drain built on the
libpq primitives consume_input()/notifies(), and route both readers
through it. consume_input() raises OperationalError on a dropped
connection, preserving the reconnect behaviour poll() used to trigger.

Also fix a pre-existing format-string bug in ipdevpoll's
empty-connection log message (two %r, one argument).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
navclean and mailin used nav.db.getConnection with manual
commit/rollback; move them onto django.db.connection and
transaction.atomic(). navclean's dry-run now rolls back via
transaction.set_rollback() instead of an explicit rollback, and
catches django.db errors since the Django cursor wraps the
underlying psycopg exceptions.

Reimplement nav.db.escape() on top of psycopg3's sql.Literal, so
navsynctypes and navclean can keep embedding literals in generated
SQL text after psycopg2 is removed.

Refs Uninett#3190

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
snmptrapd, its trap handlers, smsd and logengine used
nav.db.getConnection with manual isolation, commit and rollback.
Move them onto django.db.connection and transaction.atomic().

Django connections are autocommit by default, so the rollback hacks
that kept now() fresh (smsd) and the per-handler rollback in
snmptrapd become unnecessary; multi-statement writes are wrapped in
transaction.atomic() instead. Legacy psycopg exception catches
(nav.db.driver.*) are replaced with their django.db equivalents.

smsd's _connect() now resets an unusable Django connection via
close_if_unusable_or_obsolete() and retries, preserving the
reconnect behaviour it previously got from retry_on_db_loss. After
daemonizing, snmptrapd drops the inherited connection so Django
opens a fresh one in the forked process.

Refs Uninett#3190

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
statemon keeps its own threaded connection (a single connection
shared across threads under a lock), which does not fit Django's
thread-local connection model, so it stays on a raw psycopg
connection rather than moving to django.db.

Swap the psycopg2 API for psycopg3: psycopg.connect() (READ
COMMITTED is the driver default, so the explicit
set_isolation_level call is dropped), connection.closed instead of
.status, psycopg.errors.InFailedSqlTransaction instead of decoding
InternalError.pgcode, and psycopg.IntegrityError/InterfaceError.
psycopg3 cursors expose no .query attribute, so log the statement
being run instead.

Refs Uninett#3190

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pgsync (schema bootstrap, runs before the ORM is usable) and
radiusparser (runs on the radius server without any NAV libraries)
keep their own raw connections. Swap psycopg2 for psycopg3:
psycopg.connect(), and the psycopg2 DataError/ProgrammingError/
OperationalError catches for their psycopg3 equivalents.

pgsync drops the explicit set_isolation_level(READ COMMITTED) call
since that is already psycopg3's default for a non-autocommit
connection, which is what its explicit commits rely on.
radiusparser's set_isolation_level(0) becomes autocommit = True.

The radius web report's server-side named cursor already goes
through django.db and needs no change: psycopg3 creates a server
cursor from the same connection.connection.cursor(name=...) call.

Refs Uninett#3190

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
psycopg3 loads PostgreSQL inet and cidr columns as Python
ipaddress objects by default, whereas psycopg2 returned plain
strings. statemon feeds the netbox ip column straight into
megaping, whose is_valid_ipv6() calls socket.inet_pton() and
requires a str, so pinging crashed with "inet_pton() argument 2
must be str, not IPv4Address".

Register psycopg3's TextLoader for inet and cidr on statemon's raw
connection to restore the string values its callers expect. Django
connections are unaffected; the Django backend already maps these
types for its IP fields.

Refs Uninett#3190

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three localized cleanups following review of the psycopg2->psycopg3
migration; no behavioral change to the happy path.

- statemon: log query parameters alongside the statement on the debug
  lines, restoring the visibility psycopg2's cursor.query gave
  (psycopg3's binary cursor drops it).
- statemon: document why psycopg2's InternalError pgcode re-raise
  branch does not port to psycopg3's per-SQLSTATE exception classes.
- navclean: move sys.exit out of the transaction.atomic() block so
  SystemExit no longer unwinds it on an aborted transaction; the
  error is captured, rolled back, and reported after the block.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
psycopg3 registers loaders per wire format. The existing statemon fix
overrode only the text-format inet/cidr loaders, so results returned
over the binary protocol still arrived as ipaddress objects. Under
load, pping's first query could use the binary protocol and leak an
IPv4Address into megaping, which crashed in inet_pton()/str operations
that expect a plain string.

Register binary-format loaders that stringify inet/cidr as well,
restoring the psycopg2-compatible string contract regardless of wire
format.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@adobloug
adobloug force-pushed the feature/3190-psycopg3-migration branch from 3d2f8fc to e5f58ac Compare July 9, 2026 06:45
@sonarqubecloud

sonarqubecloud Bot commented Jul 9, 2026

Copy link
Copy Markdown

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.

Use the new psycopg backend that supports ±infinity datetimes

2 participants