FreedomBox/plinth/db/__init__.py
Sunil Mohan Adapa b99ead7aa6
db: Add more utilities for managing PostgreSQL databases
- Add methods for creating/dropping user/database.

Tests:

- ttrss and miniflux functional tests work which check for backup/restore.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
2025-01-26 10:50:19 -05:00

84 lines
3.2 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-or-later
"""Common utilities to help with handling a database."""
import threading
from typing import ClassVar
class DBLock:
"""A re-entrant lock with a fixed timeout (not -1) by default."""
TIMEOUT: ClassVar[float] = 30
def __init__(self, *args, **kwargs):
"""Create an RLock object."""
self._lock = threading.RLock(*args, **kwargs)
self.timeout = DBLock.TIMEOUT
def __getattr__(self, name):
"""Return RLock attributes."""
return getattr(self._lock, name)
def __enter__(self):
"""Use RLock context management."""
return self._lock.acquire(timeout=self.timeout)
def __exit__(self, exc_type, exc_val, exc_tb):
"""Use RLock context management."""
try:
self._lock.release()
except RuntimeError:
# Lock was not acquired
pass
# The Problem
# -----------
# Since this is a small application (in terms of data stored and requests per
# second handled), we use sqlite3 as the backend database. This gives us more
# reliability. However, sqlite3 has limited capability to handle parallel
# requests. When we try to use multiple Model.update_or_create() calls
# simultaneously, only the first one succeeds and the remaining fail with
# 'Database is locked' errors.
#
# The 'timeout' value passed during connection creation means that queries will
# wait for a maximum of given timeout period when trying to acquire locks.
# However, in some cases, to prevent deadlocks caused by threads waiting on
# each other, sqlite3 will not wait and immediately throw an exception.
#
# If we set the isolation level to 'EXCLUSIVE' or 'IMMEDIATE' instead of the
# default 'DEFERRED', each transaction should acquire a write lock immediately
# after transaction is started. This reduces the situations in which threads
# deadlock on each other. This improves the situation somewhat. However, this
# is still causing immediate timeout errors due to the way Django does its
# update_or_create() queries.
#
# This is true even in 'WAL' (write-ahead-log) journaling mode. While WAL mode
# makes it easier to have many read transactions while one write query is
# happening, it does not prevent this situation.
#
# Django should ideally provide a way to serialize these queries specifically
# to work with sqlite3 locking behavior. There seems to be no such way
# currently.
#
# Workaround
# ----------
# To workaround the problem, use a simple lock to serialize all database
# queries. Like this:
#
# with db.lock:
# # do some short database operation
#
# Queries usually have short execution time (unless stuck on disk I/O). A lot
# of parallel request processing is not expected from this service. We want
# more reliability (not failing on DB locks) over the ability to run parallel
# requests. Typically, requests waiting to perform DB queries will only have to
# wait < 1 second to get their turn. In the worst case, the database lock will
# not be acquired and the code continues to anyway.
#
# This locking can't be done in all situations. For example, queries made
# within Django framework are not locked. Still, this approach should prevent
# most of the significant cases where we have seen database lock issues.
lock = DBLock()