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()