Skip to content

Commit b51969f

Browse files
authored
Persistent session storage (#614)
* allow the session manager to persist on disk * validate the filepath given to a persistent session manager and add unit tests * update help string * finish comments in unit test * precommit cleanup * allow empty session datbase file
1 parent 2f3f628 commit b51969f

File tree

2 files changed

+136
-2
lines changed

2 files changed

+136
-2
lines changed

jupyter_server/services/sessions/sessionmanager.py

+37-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""A base class session manager."""
22
# Copyright (c) Jupyter Development Team.
33
# Distributed under the terms of the Modified BSD License.
4+
import pathlib
45
import uuid
56

67
try:
@@ -13,13 +14,46 @@
1314

1415
from traitlets.config.configurable import LoggingConfigurable
1516
from traitlets import Instance
17+
from traitlets import Unicode
18+
from traitlets import validate
19+
from traitlets import TraitError
1620

1721
from jupyter_server.utils import ensure_async
1822
from jupyter_server.traittypes import InstanceFromClasses
1923

2024

2125
class SessionManager(LoggingConfigurable):
2226

27+
database_filepath = Unicode(
28+
default_value=":memory:",
29+
help=(
30+
"Th filesystem path to SQLite Database file "
31+
"(e.g. /path/to/session_database.db). By default, the session "
32+
"database is stored in-memory (i.e. `:memory:` setting from sqlite3) "
33+
"and does not persist when the current Jupyter Server shuts down."
34+
),
35+
).tag(config=True)
36+
37+
@validate("database_filepath")
38+
def _validate_database_filepath(self, proposal):
39+
value = proposal["value"]
40+
if value == ":memory:":
41+
return value
42+
path = pathlib.Path(value)
43+
if path.exists():
44+
# Verify that the database path is not a directory.
45+
if path.is_dir():
46+
raise TraitError(
47+
"`database_filepath` expected a file path, but the given path is a directory."
48+
)
49+
# Verify that database path is an SQLite 3 Database by checking its header.
50+
with open(value, "rb") as f:
51+
header = f.read(100)
52+
53+
if not header.startswith(b"SQLite format 3") and not header == b"":
54+
raise TraitError("The given file is not an SQLite database file.")
55+
return value
56+
2357
kernel_manager = Instance("jupyter_server.services.kernels.kernelmanager.MappingKernelManager")
2458
contents_manager = InstanceFromClasses(
2559
[
@@ -39,7 +73,7 @@ def cursor(self):
3973
if self._cursor is None:
4074
self._cursor = self.connection.cursor()
4175
self._cursor.execute(
42-
"""CREATE TABLE session
76+
"""CREATE TABLE IF NOT EXISTS session
4377
(session_id, path, name, type, kernel_id)"""
4478
)
4579
return self._cursor
@@ -48,7 +82,8 @@ def cursor(self):
4882
def connection(self):
4983
"""Start a database connection"""
5084
if self._connection is None:
51-
self._connection = sqlite3.connect(":memory:")
85+
# Set isolation level to None to autocommit all changes to the database.
86+
self._connection = sqlite3.connect(self.database_filepath, isolation_level=None)
5287
self._connection.row_factory = sqlite3.Row
5388
return self._connection
5489

jupyter_server/tests/services/sessions/test_manager.py

+99
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pytest
22
from tornado import web
3+
from traitlets import TraitError
34

45
from jupyter_server._tz import isoformat
56
from jupyter_server._tz import utcnow
@@ -264,3 +265,101 @@ async def test_bad_delete_session(session_manager):
264265
await session_manager.delete_session(bad_kwarg="23424") # Bad keyword
265266
with pytest.raises(web.HTTPError):
266267
await session_manager.delete_session(session_id="23424") # nonexistent
268+
269+
270+
async def test_bad_database_filepath(jp_runtime_dir):
271+
kernel_manager = DummyMKM()
272+
273+
# Try to write to a path that's a directory, not a file.
274+
path_id_directory = str(jp_runtime_dir)
275+
# Should raise an error because the path is a directory.
276+
with pytest.raises(TraitError) as err:
277+
SessionManager(
278+
kernel_manager=kernel_manager,
279+
contents_manager=ContentsManager(),
280+
database_filepath=str(path_id_directory),
281+
)
282+
283+
# Try writing to file that's not a valid SQLite 3 database file.
284+
non_db_file = jp_runtime_dir.joinpath("non_db_file.db")
285+
non_db_file.write_bytes(b"this is a bad file")
286+
287+
# Should raise an error because the file doesn't
288+
# start with an SQLite database file header.
289+
with pytest.raises(TraitError) as err:
290+
SessionManager(
291+
kernel_manager=kernel_manager,
292+
contents_manager=ContentsManager(),
293+
database_filepath=str(non_db_file),
294+
)
295+
296+
297+
async def test_good_database_filepath(jp_runtime_dir):
298+
kernel_manager = DummyMKM()
299+
300+
# Try writing to an empty file.
301+
empty_file = jp_runtime_dir.joinpath("empty.db")
302+
empty_file.write_bytes(b"")
303+
304+
session_manager = SessionManager(
305+
kernel_manager=kernel_manager,
306+
contents_manager=ContentsManager(),
307+
database_filepath=str(empty_file),
308+
)
309+
310+
await session_manager.create_session(
311+
path="/path/to/test.ipynb", kernel_name="python", type="notebook"
312+
)
313+
# Assert that the database file exists
314+
assert empty_file.exists()
315+
316+
# Close the current session manager
317+
del session_manager
318+
319+
# Try writing to a file that already exists.
320+
session_manager = SessionManager(
321+
kernel_manager=kernel_manager,
322+
contents_manager=ContentsManager(),
323+
database_filepath=str(empty_file),
324+
)
325+
326+
assert session_manager.database_filepath == str(empty_file)
327+
328+
329+
async def test_session_persistence(jp_runtime_dir):
330+
session_db_path = jp_runtime_dir.joinpath("test-session.db")
331+
# Kernel manager needs to persist.
332+
kernel_manager = DummyMKM()
333+
334+
# Initialize a session and start a connection.
335+
# This should create the session database the first time.
336+
session_manager = SessionManager(
337+
kernel_manager=kernel_manager,
338+
contents_manager=ContentsManager(),
339+
database_filepath=str(session_db_path),
340+
)
341+
342+
session = await session_manager.create_session(
343+
path="/path/to/test.ipynb", kernel_name="python", type="notebook"
344+
)
345+
346+
# Assert that the database file exists
347+
assert session_db_path.exists()
348+
349+
with open(session_db_path, "rb") as f:
350+
header = f.read(100)
351+
352+
assert header.startswith(b"SQLite format 3")
353+
354+
# Close the current session manager
355+
del session_manager
356+
357+
# Get a new session_manager
358+
session_manager = SessionManager(
359+
kernel_manager=kernel_manager,
360+
contents_manager=ContentsManager(),
361+
database_filepath=str(session_db_path),
362+
)
363+
364+
# Assert that the session database persists.
365+
session = await session_manager.get_session(session_id=session["id"])

0 commit comments

Comments
 (0)