Skip to content

Commit 3a9d49f

Browse files
Use a context manager for all write actions to prevent indefinite database locks (#77)
* Add unit test that demonstrates database lock when copy fails * Handle and log all exceptions when writing to databaes to free up the lock * Update the unit test's comments with pointers to the relevant issues * remove (deprecated) install-minimums action from workflows * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent ab2ee68 commit 3a9d49f

File tree

3 files changed

+132
-93
lines changed

3 files changed

+132
-93
lines changed

.github/workflows/test-python.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,9 @@ jobs:
8484
uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1
8585
with:
8686
python_version: "3.7"
87-
- name: Install miniumum versions
88-
uses: jupyterlab/maintainer-tools/.github/actions/install-minimums@v1
87+
dependency_type: minimum
88+
- name: Install
89+
run: pip install -e ".[test]"
8990
- name: Run the unit tests
9091
run: |
9192
pytest -vv -W default || pytest -vv -W default --lf

jupyter_server_fileid/manager.py

Lines changed: 89 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -317,17 +317,17 @@ def _create(self, path: str) -> str:
317317
return id
318318

319319
def index(self, path: str) -> str:
320-
path = self._normalize_path(path)
321-
row = self.con.execute("SELECT id FROM Files WHERE path = ?", (path,)).fetchone()
322-
existing_id = row and row[0]
320+
with self.con:
321+
path = self._normalize_path(path)
322+
row = self.con.execute("SELECT id FROM Files WHERE path = ?", (path,)).fetchone()
323+
existing_id = row and row[0]
323324

324-
if existing_id:
325-
return existing_id
325+
if existing_id:
326+
return existing_id
326327

327-
# create new record
328-
id = self._create(path)
329-
self.con.commit()
330-
return id
328+
# create new record
329+
id = self._create(path)
330+
return id
331331

332332
def get_id(self, path: str) -> Optional[str]:
333333
path = self._normalize_path(path)
@@ -340,37 +340,36 @@ def get_path(self, id: str) -> Optional[str]:
340340
return self._from_normalized_path(path)
341341

342342
def move(self, old_path: str, new_path: str) -> None:
343-
old_path = self._normalize_path(old_path)
344-
new_path = self._normalize_path(new_path)
345-
row = self.con.execute("SELECT id FROM Files WHERE path = ?", (old_path,)).fetchone()
346-
id = row and row[0]
347-
348-
if id:
349-
self.con.execute("UPDATE Files SET path = ? WHERE path = ?", (new_path, old_path))
350-
self._move_recursive(old_path, new_path, posixpath)
351-
else:
352-
id = self._create(new_path)
343+
with self.con:
344+
old_path = self._normalize_path(old_path)
345+
new_path = self._normalize_path(new_path)
346+
row = self.con.execute("SELECT id FROM Files WHERE path = ?", (old_path,)).fetchone()
347+
id = row and row[0]
348+
349+
if id:
350+
self.con.execute("UPDATE Files SET path = ? WHERE path = ?", (new_path, old_path))
351+
self._move_recursive(old_path, new_path, posixpath)
352+
else:
353+
id = self._create(new_path)
353354

354-
self.con.commit()
355-
return id
355+
return id
356356

357357
def copy(self, from_path: str, to_path: str) -> Optional[str]:
358-
from_path = self._normalize_path(from_path)
359-
to_path = self._normalize_path(to_path)
358+
with self.con:
359+
from_path = self._normalize_path(from_path)
360+
to_path = self._normalize_path(to_path)
360361

361-
id = self._create(to_path)
362-
self._copy_recursive(from_path, to_path, posixpath)
362+
id = self._create(to_path)
363+
self._copy_recursive(from_path, to_path, posixpath)
363364

364-
self.con.commit()
365-
return id
365+
return id
366366

367367
def delete(self, path: str) -> None:
368-
path = self._normalize_path(path)
369-
370-
self.con.execute("DELETE FROM Files WHERE path = ?", (path,))
371-
self._delete_recursive(path, posixpath)
368+
with self.con:
369+
path = self._normalize_path(path)
372370

373-
self.con.commit()
371+
self.con.execute("DELETE FROM Files WHERE path = ?", (path,))
372+
self._delete_recursive(path, posixpath)
374373

375374
def save(self, path: str) -> None:
376375
return
@@ -718,39 +717,38 @@ def _update(self, id, stat_info=None, path=None):
718717
def index(self, path, stat_info=None, commit=True):
719718
"""Returns the file ID for the file at `path`, creating a new file ID if
720719
one does not exist. Returns None only if file does not exist at path."""
721-
path = self._normalize_path(path)
722-
stat_info = stat_info or self._stat(path)
723-
if not stat_info:
724-
return None
720+
with self.con:
721+
path = self._normalize_path(path)
722+
stat_info = stat_info or self._stat(path)
723+
if not stat_info:
724+
return None
725725

726-
# if file is symlink, then index the path it refers to instead
727-
if stat_info.is_symlink:
728-
return self.index(os.path.realpath(path))
726+
# if file is symlink, then index the path it refers to instead
727+
if stat_info.is_symlink:
728+
return self.index(os.path.realpath(path))
729729

730-
# sync file at path and return file ID if it exists
731-
id = self._sync_file(path, stat_info)
732-
if id is not None:
733-
return id
730+
# sync file at path and return file ID if it exists
731+
id = self._sync_file(path, stat_info)
732+
if id is not None:
733+
return id
734734

735-
# otherwise, create a new record and return the file ID
736-
id = self._create(path, stat_info)
737-
if commit:
738-
self.con.commit()
739-
return id
735+
# otherwise, create a new record and return the file ID
736+
id = self._create(path, stat_info)
737+
return id
740738

741739
def get_id(self, path):
742740
"""Retrieves the file ID associated with a file path. Returns None if
743741
the file has not yet been indexed or does not exist at the given
744742
path."""
745-
path = self._normalize_path(path)
746-
stat_info = self._stat(path)
747-
if not stat_info:
748-
return None
743+
with self.con:
744+
path = self._normalize_path(path)
745+
stat_info = self._stat(path)
746+
if not stat_info:
747+
return None
749748

750-
# then sync file at path and retrieve id, if any
751-
id = self._sync_file(path, stat_info)
752-
self.con.commit()
753-
return id
749+
# then sync file at path and retrieve id, if any
750+
id = self._sync_file(path, stat_info)
751+
return id
754752

755753
def get_path(self, id):
756754
"""Retrieves the file path associated with a file ID. The file path is
@@ -795,26 +793,26 @@ def get_path(self, id):
795793
def move(self, old_path, new_path):
796794
"""Handles file moves by updating the file path of the associated file
797795
ID. Returns the file ID. Returns None if file does not exist at new_path."""
798-
old_path = self._normalize_path(old_path)
799-
new_path = self._normalize_path(new_path)
796+
with self.con:
797+
old_path = self._normalize_path(old_path)
798+
new_path = self._normalize_path(new_path)
800799

801-
# verify file exists at new_path
802-
stat_info = self._stat(new_path)
803-
if stat_info is None:
804-
return None
800+
# verify file exists at new_path
801+
stat_info = self._stat(new_path)
802+
if stat_info is None:
803+
return None
805804

806-
# sync the file and see if it was already indexed
807-
#
808-
# originally this method did not call _sync_file() for performance
809-
# reasons, but this is needed to handle an edge case:
810-
# https://github.com/jupyter-server/jupyter_server_fileid/issues/62
811-
id = self._sync_file(new_path, stat_info)
812-
if id is None:
813-
# if no existing record, create a new one
814-
id = self._create(new_path, stat_info)
805+
# sync the file and see if it was already indexed
806+
#
807+
# originally this method did not call _sync_file() for performance
808+
# reasons, but this is needed to handle an edge case:
809+
# https://github.com/jupyter-server/jupyter_server_fileid/issues/62
810+
id = self._sync_file(new_path, stat_info)
811+
if id is None:
812+
# if no existing record, create a new one
813+
id = self._create(new_path, stat_info)
815814

816-
self.con.commit()
817-
return id
815+
return id
818816

819817
def _copy_recursive(self, from_path: str, to_path: str, _: str = "") -> None:
820818
"""Copy all children of a given directory at `from_path` to a new
@@ -859,13 +857,13 @@ def copy(self, from_path, to_path):
859857
def delete(self, path):
860858
"""Handles file deletions by deleting the associated record in the File
861859
table. Returns None."""
862-
path = self._normalize_path(path)
860+
with self.con:
861+
path = self._normalize_path(path)
863862

864-
if os.path.isdir(path):
865-
self._delete_recursive(path)
863+
if os.path.isdir(path):
864+
self._delete_recursive(path)
866865

867-
self.con.execute("DELETE FROM Files WHERE path = ?", (path,))
868-
self.con.commit()
866+
self.con.execute("DELETE FROM Files WHERE path = ?", (path,))
869867

870868
def save(self, path):
871869
"""Handles file saves (edits) by updating recorded stat info.
@@ -878,21 +876,21 @@ def save(self, path):
878876
JupyterLab. This would (wrongly) preserve the association b/w the old
879877
file ID and the current path rather than create a new file ID.
880878
"""
881-
path = self._normalize_path(path)
879+
with self.con:
880+
path = self._normalize_path(path)
882881

883-
# look up record by ino and path
884-
stat_info = self._stat(path)
885-
row = self.con.execute(
886-
"SELECT id FROM Files WHERE ino = ? AND path = ?", (stat_info.ino, path)
887-
).fetchone()
888-
# if no record exists, return early
889-
if row is None:
890-
return
882+
# look up record by ino and path
883+
stat_info = self._stat(path)
884+
row = self.con.execute(
885+
"SELECT id FROM Files WHERE ino = ? AND path = ?", (stat_info.ino, path)
886+
).fetchone()
887+
# if no record exists, return early
888+
if row is None:
889+
return
891890

892-
# otherwise, update the stat info
893-
(id,) = row
894-
self._update(id, stat_info)
895-
self.con.commit()
891+
# otherwise, update the stat info
892+
(id,) = row
893+
self._update(id, stat_info)
896894

897895
def get_handlers_by_action(self) -> Dict[str, Optional[Callable[[Dict[str, Any]], Any]]]:
898896
return {

tests/test_manager.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import ntpath
22
import os
33
import posixpath
4+
import sqlite3
45
import sys
56
from unittest.mock import patch
67

@@ -617,3 +618,42 @@ def test_db_journal_mode(any_fid_manager_class, fid_db_path, jp_root_dir, db_jou
617618
cursor = fid_manager.con.execute("PRAGMA journal_mode")
618619
actual_journal_mode = cursor.fetchone()
619620
assert actual_journal_mode[0].upper() == expected_journal_mode
621+
622+
623+
# This test demonstrates an issue raised in
624+
# https://github.com/jupyter-server/jupyter_server_fileid/pull/76
625+
# which was later fixed in
626+
# https://github.com/jupyter-server/jupyter_server_fileid/pull/77
627+
#
628+
# We use this unit test to catch this edge case and ensure
629+
# its covered going forward.
630+
def test_multiple_fileIdManager_connections_after_exception(fid_db_path):
631+
original_file_path = "/path/to/file"
632+
copy_location = "/path/to/copy"
633+
another_copy_location = "/path/to/other"
634+
635+
# Setup an initial file ID manager connected to a sqlite database.
636+
manager_1 = ArbitraryFileIdManager(db_path=fid_db_path)
637+
638+
# Create an initial ID for this file
639+
manager_1.index(original_file_path)
640+
# Copy the file
641+
manager_1.copy(original_file_path, copy_location)
642+
# Try copying the file again.
643+
excepted = False
644+
try:
645+
manager_1.copy(original_file_path, copy_location)
646+
# We expect this to fail because the file is already in the database.
647+
except sqlite3.IntegrityError:
648+
excepted = True
649+
pass
650+
651+
assert excepted, "Copying to the same location should raise an exception, but it did not here."
652+
653+
# Previously, these actions locked the database for future connections.
654+
# This was fixed in: https://github.com/jupyter-server/jupyter_server_fileid/pull/77
655+
656+
# Try making a second connection that writes to the DB and
657+
# make sure no exceptions were raised.
658+
manager_2 = ArbitraryFileIdManager(db_path=fid_db_path)
659+
manager_2.copy(original_file_path, another_copy_location)

0 commit comments

Comments
 (0)