SQLite, несколько процессов и почему WAL сам по себе не спасает

У нас есть небольшой агент, который использует несколько процессов и один файл SQLite. Внутри некоторых процессов еще и используются потоки.

Исторически этот проект работал через multiprocessing.Lock, что является POSIX-семафором, у которого нет владельца. Если процесс умирал с захваченной блокировкой, то она не отпускалась, и все остальные обработчики висли. Все было бы хорошо, пока это не выстрелило нам в ногу.

Пришлось смотреть какие best practice есть по работе с SQLite. Оказалось, она поддерживает WAL, что снимает блокировки с читателей, но не с писателей. Для конкурентной записи блокировка все равно нужна, но такая, чтобы снималась при смерти процесса автоматически. Тут на помощь пришла блокировка над файловым дескриптором, в python есть стандартная библиотека fcntl.flock для этого.

Ситуация усугубляется, когда в одном из процессов могут использоваться потоки. В таком случае нужно еще и threading.Lock использовать.

В результате, для писателей порядок блокировок был такой - поток, файл, транзакция. Запросы на чтение работают без этого. _THREAD_LOCK = threading.Lock()

@contextmanager def db_write_lock(*, sqlite_path: Path, timeout_s: float) -> Generator[None, None, None]: lock_path = sqlite_path.with_name(f"{sqlite_path.name}.lock") deadline = time.monotonic() + timeout_s

#получаем блокировку для потоков

if not _THREAD_LOCK.acquire(timeout=timeout_s): raise DbWriteLockTimeoutError

fd: int | None = None try:

#Создаем и открываем файл для блокировки

lock_path.parent.mkdir(parents=True, exist_ok=True) fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, _LOCK_FILE_MODE)

#получаем файловую блокировку (пытаемся пока не выйдет таймаут)

_acquire_file_lock(fd=fd, deadline=deadline) yield finally:

#отпускаем

if fd is not None: fcntl.flock(fd, fcntl.LOCK_UN) os.close(fd) _THREAD_LOCK.release()