Skip to content

Commit 8879800

Browse files
Migrate to latest stores (#200)
* Migrate to latest stores * Move the stores temporarily * Automatic application of license header --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 9365912 commit 8879800

File tree

11 files changed

+839
-48
lines changed

11 files changed

+839
-48
lines changed

jupyter_collaboration/app.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,10 @@
66

77
from jupyter_server.extension.application import ExtensionApp
88
from traitlets import Bool, Float, Type
9-
from ypy_websocket.ystore import BaseYStore
109

1110
from .handlers import DocSessionHandler, YDocWebSocketHandler
1211
from .loaders import FileLoaderMapping
13-
from .stores import SQLiteYStore
12+
from .stores import BaseYStore, SQLiteYStore
1413
from .utils import EVENTS_SCHEMA_PATH
1514
from .websocketserver import JupyterWebsocketServer
1615

@@ -22,6 +21,8 @@ class YDocExtension(ExtensionApp):
2221
Enables Real Time Collaboration in JupyterLab
2322
"""
2423

24+
_store: BaseYStore = None
25+
2526
disable_rtc = Bool(False, config=True, help="Whether to disable real time collaboration.")
2627

2728
file_poll_interval = Float(
@@ -80,10 +81,12 @@ def initialize_handlers(self):
8081
for k, v in self.config.get(self.ystore_class.__name__, {}).items():
8182
setattr(self.ystore_class, k, v)
8283

84+
# Instantiate the store
85+
self._store = self.ystore_class(log=self.log)
86+
8387
self.ywebsocket_server = JupyterWebsocketServer(
8488
rooms_ready=False,
8589
auto_clean_rooms=False,
86-
ystore_class=self.ystore_class,
8790
log=self.log,
8891
)
8992

@@ -103,7 +106,7 @@ def initialize_handlers(self):
103106
"document_cleanup_delay": self.document_cleanup_delay,
104107
"document_save_delay": self.document_save_delay,
105108
"file_loaders": self.file_loaders,
106-
"ystore_class": self.ystore_class,
109+
"store": self._store,
107110
"ywebsocket_server": self.ywebsocket_server,
108111
},
109112
),

jupyter_collaboration/handlers.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@
1515
from tornado import web
1616
from tornado.websocket import WebSocketHandler
1717
from ypy_websocket.websocket_server import YRoom
18-
from ypy_websocket.ystore import BaseYStore
1918
from ypy_websocket.yutils import YMessageType, write_var_uint
2019

2120
from .loaders import FileLoaderMapping
2221
from .rooms import DocumentRoom, TransientRoom
22+
from .stores import BaseYStore
2323
from .utils import (
2424
JUPYTER_COLLABORATION_EVENTS_URI,
2525
LogLevel,
@@ -62,6 +62,14 @@ def create_task(self, aw):
6262
task.add_done_callback(self._background_tasks.discard)
6363

6464
async def prepare(self):
65+
# NOTE: Initialize in the ExtensionApp.start_extension once
66+
# https://github.com/jupyter-server/jupyter_server/issues/1329
67+
# is done.
68+
# We are temporarily initializing the store here because `start``
69+
# is an async function
70+
if self._store is not None and not self._store.initialized:
71+
await self._store.initialize()
72+
6573
if not self._websocket_server.started.is_set():
6674
self.create_task(self._websocket_server.start())
6775
await self._websocket_server.started.wait()
@@ -84,15 +92,13 @@ async def prepare(self):
8492
)
8593

8694
file = self._file_loaders[file_id]
87-
updates_file_path = f".{file_type}:{file_id}.y"
88-
ystore = self._ystore_class(path=updates_file_path, log=self.log)
8995
self.room = DocumentRoom(
9096
self._room_id,
9197
file_format,
9298
file_type,
9399
file,
94100
self.event_logger,
95-
ystore,
101+
self._store,
96102
self.log,
97103
self._document_save_delay,
98104
)
@@ -111,15 +117,15 @@ def initialize(
111117
self,
112118
ywebsocket_server: JupyterWebsocketServer,
113119
file_loaders: FileLoaderMapping,
114-
ystore_class: type[BaseYStore],
120+
store: BaseYStore,
115121
document_cleanup_delay: float | None = 60.0,
116122
document_save_delay: float | None = 1.0,
117123
) -> None:
118124
self._background_tasks = set()
119125
# File ID manager cannot be passed as argument as the extension may load after this one
120126
self._file_id_manager = self.settings["file_id_manager"]
121127
self._file_loaders = file_loaders
122-
self._ystore_class = ystore_class
128+
self._store = store
123129
self._cleanup_delay = document_cleanup_delay
124130
self._document_save_delay = document_save_delay
125131
self._websocket_server = ywebsocket_server

jupyter_collaboration/rooms.py

Lines changed: 29 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010

1111
from jupyter_events import EventLogger
1212
from jupyter_ydoc import ydocs as YDOCS
13+
from ypy_websocket.stores import BaseYStore
1314
from ypy_websocket.websocket_server import YRoom
14-
from ypy_websocket.ystore import BaseYStore, YDocNotFound
1515
from ypy_websocket.yutils import write_var_uint
1616

1717
from .loaders import FileLoader
@@ -104,36 +104,28 @@ async def initialize(self) -> None:
104104
return
105105

106106
self.log.info("Initializing room %s", self._room_id)
107-
108107
model = await self._file.load_content(self._file_format, self._file_type, True)
109108

110109
async with self._update_lock:
111110
# try to apply Y updates from the YStore for this document
112-
read_from_source = True
113-
if self.ystore is not None:
114-
try:
115-
await self.ystore.apply_updates(self.ydoc)
116-
self._emit(
117-
LogLevel.INFO,
118-
"load",
119-
"Content loaded from the store {}".format(
120-
self.ystore.__class__.__qualname__
121-
),
122-
)
123-
self.log.info(
124-
"Content in room %s loaded from the ystore %s",
125-
self._room_id,
126-
self.ystore.__class__.__name__,
127-
)
128-
read_from_source = False
129-
except YDocNotFound:
130-
# YDoc not found in the YStore, create the document from the source file (no change history)
131-
pass
111+
if self.ystore is not None and await self.ystore.exists(self._room_id):
112+
# Load the content from the store
113+
await self.ystore.apply_updates(self._room_id, self.ydoc)
114+
self._emit(
115+
LogLevel.INFO,
116+
"load",
117+
"Content loaded from the store {}".format(
118+
self.ystore.__class__.__qualname__
119+
),
120+
)
121+
self.log.info(
122+
"Content in room %s loaded from the ystore %s",
123+
self._room_id,
124+
self.ystore.__class__.__name__,
125+
)
132126

133-
if not read_from_source:
134127
# if YStore updates and source file are out-of-sync, resync updates with source
135128
if self._document.source != model["content"]:
136-
# TODO: Delete document from the store.
137129
self._emit(
138130
LogLevel.INFO, "initialize", "The file is out-of-sync with the ystore."
139131
)
@@ -142,17 +134,26 @@ async def initialize(self) -> None:
142134
self._file.path,
143135
self.ystore.__class__.__name__,
144136
)
145-
read_from_source = True
146137

147-
if read_from_source:
138+
doc = await self.ystore.get(self._room_id)
139+
await self.ystore.remove(self._room_id)
140+
version = 0
141+
if "version" in doc:
142+
version = doc["version"] + 1
143+
144+
await self.ystore.create(self._room_id, version)
145+
await self.ystore.encode_state_as_update(self._room_id, self.ydoc)
146+
147+
else:
148148
self._emit(LogLevel.INFO, "load", "Content loaded from disk.")
149149
self.log.info(
150150
"Content in room %s loaded from file %s", self._room_id, self._file.path
151151
)
152152
self._document.source = model["content"]
153153

154-
if self.ystore:
155-
await self.ystore.encode_state_as_update(self.ydoc)
154+
if self.ystore is not None:
155+
await self.ystore.create(self._room_id, 0)
156+
await self.ystore.encode_state_as_update(self._room_id, self.ydoc)
156157

157158
self._last_modified = model["last_modified"]
158159
self._document.dirty = False
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Copyright (c) Jupyter Development Team.
2+
# Distributed under the terms of the Modified BSD License.
3+
4+
from .base_store import BaseYStore # noqa
5+
from .stores import SQLiteYStore, TempFileYStore # noqa
6+
from .utils import YDocExists, YDocNotFound # noqa
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
# Copyright (c) Jupyter Development Team.
2+
# Distributed under the terms of the Modified BSD License.
3+
4+
from __future__ import annotations
5+
6+
from abc import ABC, abstractmethod
7+
from inspect import isawaitable
8+
from typing import AsyncIterator, Awaitable, Callable, cast
9+
10+
import y_py as Y
11+
from anyio import Event
12+
13+
14+
class BaseYStore(ABC):
15+
"""
16+
Base class for the stores.
17+
"""
18+
19+
version = 3
20+
metadata_callback: Callable[[], Awaitable[bytes] | bytes] | None = None
21+
22+
_store_path: str
23+
_initialized: Event | None = None
24+
25+
@abstractmethod
26+
def __init__(
27+
self, path: str, metadata_callback: Callable[[], Awaitable[bytes] | bytes] | None = None
28+
):
29+
"""
30+
Initialize the object.
31+
32+
Arguments:
33+
path: The path where the store will be located.
34+
metadata_callback: An optional callback to call to get the metadata.
35+
log: An optional logger.
36+
"""
37+
...
38+
39+
@abstractmethod
40+
async def initialize(self) -> None:
41+
"""
42+
Initializes the store.
43+
"""
44+
...
45+
46+
@abstractmethod
47+
async def exists(self, path: str) -> bool:
48+
"""
49+
Returns True if the document exists, else returns False.
50+
51+
Arguments:
52+
path: The document name/path.
53+
"""
54+
...
55+
56+
@abstractmethod
57+
async def list(self) -> AsyncIterator[str]:
58+
"""
59+
Returns a list with the name/path of the documents stored.
60+
"""
61+
...
62+
63+
@abstractmethod
64+
async def get(self, path: str, updates: bool = False) -> dict | None:
65+
"""
66+
Returns the document's metadata or None if the document does't exist.
67+
68+
Arguments:
69+
path: The document name/path.
70+
updates: Whether to return document's content or only the metadata.
71+
"""
72+
...
73+
74+
@abstractmethod
75+
async def create(self, path: str, version: int) -> None:
76+
"""
77+
Creates a new document.
78+
79+
Arguments:
80+
path: The document name/path.
81+
version: Document version.
82+
"""
83+
...
84+
85+
@abstractmethod
86+
async def remove(self, path: str) -> dict | None:
87+
"""
88+
Removes a document.
89+
90+
Arguments:
91+
path: The document name/path.
92+
"""
93+
...
94+
95+
@abstractmethod
96+
async def write(self, path: str, data: bytes) -> None:
97+
"""
98+
Store a document update.
99+
100+
Arguments:
101+
path: The document name/path.
102+
data: The update to store.
103+
"""
104+
...
105+
106+
@abstractmethod
107+
async def read(self, path: str) -> AsyncIterator[tuple[bytes, bytes]]:
108+
"""
109+
Async iterator for reading document's updates.
110+
111+
Arguments:
112+
path: The document name/path.
113+
114+
Returns:
115+
A tuple of (update, metadata, timestamp) for each update.
116+
"""
117+
...
118+
119+
@property
120+
def initialized(self) -> bool:
121+
if self._initialized is not None:
122+
return self._initialized.is_set()
123+
return False
124+
125+
async def get_metadata(self) -> bytes:
126+
"""
127+
Returns:
128+
The metadata.
129+
"""
130+
if self.metadata_callback is None:
131+
return b""
132+
133+
metadata = self.metadata_callback()
134+
if isawaitable(metadata):
135+
metadata = await metadata
136+
metadata = cast(bytes, metadata)
137+
return metadata
138+
139+
async def encode_state_as_update(self, path: str, ydoc: Y.YDoc) -> None:
140+
"""Store a YDoc state.
141+
142+
Arguments:
143+
path: The document name/path.
144+
ydoc: The YDoc from which to store the state.
145+
"""
146+
update = Y.encode_state_as_update(ydoc) # type: ignore
147+
await self.write(path, update)
148+
149+
async def apply_updates(self, path: str, ydoc: Y.YDoc) -> None:
150+
"""Apply all stored updates to the YDoc.
151+
152+
Arguments:
153+
path: The document name/path.
154+
ydoc: The YDoc on which to apply the updates.
155+
"""
156+
async for update, *rest in self.read(path): # type: ignore
157+
Y.apply_update(ydoc, update) # type: ignore

0 commit comments

Comments
 (0)