Source code for qcodes.dataset.sqlite.connection

"""
This module provides a subclass class :class:`AtomicConnection` of
:class:`sqlite3.Connection` together with functions around it which allow
performing nested atomic transactions on an SQLite database.
"""

from __future__ import annotations

import atexit
import logging
import sqlite3
import threading
import weakref
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any

from qcodes.utils import DelayedKeyboardInterrupt

if TYPE_CHECKING:
    from collections.abc import Generator

log = logging.getLogger(__name__)

_open_connections: weakref.WeakSet[AtomicConnection] = weakref.WeakSet()
_open_connections_lock = threading.RLock()


[docs] class AtomicConnection(sqlite3.Connection): """ A class to extend the sqlite3.Connection object. This extends Connection to allow addition operations to be performed atomically. It is recommended to create an AtomicConnection using the function :func:`connect` """ atomic_in_progress: bool = False """ a bool describing whether the connection is currently in the middle of an atomic block of transactions, thus allowing to nest `atomic` context managers """ path_to_dbfile: str = "" """ Path to the database file of the connection. """ owner_thread_id: int """ Identifier (as returned by :func:`threading.get_ident`) of the thread that created the connection. """ def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.path_to_dbfile = path_to_dbfile(self) self.owner_thread_id = threading.get_ident() with _open_connections_lock: _open_connections.add(self)
[docs] def close(self) -> None: super().close() with _open_connections_lock: _open_connections.discard(self)
[docs] def get_open_connections() -> tuple[AtomicConnection, ...]: """ Return all :class:`AtomicConnection` objects that have been opened in this process and have not yet been closed or garbage collected. """ with _open_connections_lock: return tuple(_open_connections)
[docs] def close_all_connections() -> None: """ Close all :class:`AtomicConnection` objects that are currently open and were created by the calling thread, e.g. to release the file handles on database files. Connections owned by other threads are left open, since sqlite connections are by default bound to the thread that created them. Any uncommitted changes on these connections are discarded, and objects holding one of them (such as a ``DataSet``) can no longer use it to access the database. This function is registered to run automatically when the Python interpreter exits. """ thread_id = threading.get_ident() for conn in get_open_connections(): if conn.owner_thread_id == thread_id: conn.close()
atexit.register(close_all_connections) @contextmanager def atomic(conn: AtomicConnection) -> Generator[AtomicConnection, None, None]: """ Guard a series of transactions as atomic. If one transaction fails, all the previous transactions are rolled back and no more transactions are performed. A KeyboardInterrupt rolls the transaction back and is re-raised. NB: 'BEGIN' is by default only inserted before INSERT/UPDATE/DELETE/REPLACE but we want to guard any transaction that modifies the database (e.g. also ALTER) Args: conn: connection to guard """ with DelayedKeyboardInterrupt(context={"reason": "sqlite atomic operation"}): if not isinstance(conn, AtomicConnection): raise ValueError( # noqa: TRY004 "atomic context manager only accepts " "AtomicConnection database connection objects." ) is_outmost = not (conn.atomic_in_progress) if conn.in_transaction and is_outmost: raise RuntimeError( "SQLite connection has uncommitted " "transactions. " "Please commit those before starting an atomic " "transaction." ) old_atomic_in_progress = conn.atomic_in_progress conn.atomic_in_progress = True old_level = conn.isolation_level try: if is_outmost: conn.isolation_level = None conn.cursor().execute("BEGIN") yield conn except KeyboardInterrupt: conn.rollback() log.exception("Rolling back due to unhandled exception") raise except Exception as e: conn.rollback() log.exception("Rolling back due to unhandled exception") raise RuntimeError("Rolling back due to unhandled exception") from e else: if is_outmost: conn.commit() finally: if is_outmost: conn.isolation_level = old_level conn.atomic_in_progress = old_atomic_in_progress def transaction(conn: AtomicConnection, sql: str, *args: Any) -> sqlite3.Cursor: """Perform a transaction. The transaction needs to be committed or rolled back. Args: conn: database connection sql: formatted string *args: arguments to use for parameter substitution Returns: sqlite cursor """ c = conn.cursor() if len(args) > 0: c.execute(sql, args) else: c.execute(sql) return c def atomic_transaction(conn: AtomicConnection, sql: str, *args: Any) -> sqlite3.Cursor: """Perform an **atomic** transaction. The transaction is committed if there are no exceptions else the transaction is rolled back. NB: 'BEGIN' is by default only inserted before INSERT/UPDATE/DELETE/REPLACE but we want to guard any transaction that modifies the database (e.g. also ALTER). 'BEGIN' marks a place to commit from/roll back to Args: conn: database connection sql: formatted string *args: arguments to use for parameter substitution Returns: sqlite cursor """ with atomic(conn) as atomic_conn: c = transaction(atomic_conn, sql, *args) return c def path_to_dbfile(conn: AtomicConnection | sqlite3.Connection) -> str: """ Return the path of the database file that the conn object is connected to """ # according to https://www.sqlite.org/pragma.html#pragma_database_list # the 3th element (1 indexed) is the path cursor = conn.cursor() cursor.execute("PRAGMA database_list") row = cursor.fetchall()[0] return row[2]