Skip to content

Commit

Permalink
Emit events for collaborative sessions (#139)
Browse files Browse the repository at this point in the history
* Register a new event schema

* Emit events

* Subscribe to events

* Fixes string format

* Fix dep versions

* Review: Adds log level property

* Review: wording

* Review: suggestions for log level

* Review: use lab console log

* Update dependencies

* Update jupyter_collaboration/events/session.yaml

Co-authored-by: Afshin Taylor Darian <git@darian.email>

* Update jupyter_collaboration/events/session.yaml

Co-authored-by: Afshin Taylor Darian <git@darian.email>

* Update jupyter_collaboration/events/session.yaml

Co-authored-by: Afshin Taylor Darian <git@darian.email>

* Review

---------

Co-authored-by: Afshin Taylor Darian <git@darian.email>
  • Loading branch information
hbcarlos and afshin authored Apr 28, 2023
1 parent b46809d commit 3797752
Show file tree
Hide file tree
Showing 14 changed files with 918 additions and 502 deletions.
5 changes: 5 additions & 0 deletions jupyter_collaboration/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from .handlers import DocSessionHandler, YDocWebSocketHandler
from .stores import SQLiteYStore
from .utils import EVENTS_SCHEMA_PATH


class YDocExtension(ExtensionApp):
Expand Down Expand Up @@ -45,6 +46,10 @@ class YDocExtension(ExtensionApp):
directory.""",
)

def initialize(self):
super().initialize()
self.serverapp.event_logger.register_event_schema(EVENTS_SCHEMA_PATH)

def initialize_settings(self):
self.settings.update(
{
Expand Down
60 changes: 60 additions & 0 deletions jupyter_collaboration/events/session.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"$id": https://schema.jupyter.org/jupyter_collaboration/session/v1
"$schema": "http://json-schema.org/draft-07/schema"
version: 1
title: Collaborative session events
personal-data: true
description: |
Events emitted server-side during a collaborative session.
type: object
required:
- level
- room
- path
properties:
level:
enum:
- INFO
- DEBUG
- WARNING
- ERROR
- CRITICAL
description: |
Message type.
room:
type: string
description: |
Room ID. Usually composed by the file type, format and ID.
path:
type: string
description: |
File path.
store:
type: string
description: |
The store used to track the document history.
action:
enum:
- initialize
- load
- save
- overwrite
- clean
description: |
Action performed in a room during a collaborative session.
Possible values:
1. initialize
Initialize a room by loading the content from the contents manager or a store.
2. load
Load the content from the contents manager.
3. save
Save the content with the contents manager.
4. overwrite
Overwrite the content in a room with content from the contents manager.
This can happen when multiple rooms access the same file or when a user
modifies the file outside Jupyter Server (e.g. using a different app).
5. clean
Clean the room once is empty (aka there is no more users connected to it).
msg:
type: string
description: |
Event message.
32 changes: 28 additions & 4 deletions jupyter_collaboration/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

from .loaders import FileLoader
from .rooms import DocumentRoom, TransientRoom
from .utils import decode_file_path
from .utils import JUPYTER_COLLABORATION_EVENTS_URI, LogLevel, decode_file_path

YFILE = YDOCS["file"]

Expand Down Expand Up @@ -131,7 +131,7 @@ def __init__(
super().__init__(app, request, **kwargs)

# CONFIG
file_id_manager = self.settings["file_id_manager"]
self._file_id_manager = self.settings["file_id_manager"]
ystore_class = self.settings["collaborative_ystore_class"]
self._cleanup_delay = self.settings["collaborative_document_cleanup_delay"]
# self.settings["collaborative_file_poll_interval"]
Expand Down Expand Up @@ -163,7 +163,7 @@ def __init__(
if self._room_id.count(":") >= 2:
# DocumentRoom
file_format, file_type, file_id = decode_file_path(self._room_id)
path = file_id_manager.get_path(file_id)
path = self._file_id_manager.get_path(file_id)

# Instantiate the FileLoader if it doesn't exist yet
file = YDocWebSocketHandler.files.get(file_id)
Expand All @@ -173,13 +173,20 @@ def __init__(
file_id,
file_format,
file_type,
file_id_manager,
self._file_id_manager,
self.contents_manager,
self.log,
self.settings["collaborative_file_poll_interval"],
)
self.files[file_id] = file

else:
self._emit(
LogLevel.WARNING,
None,
"There is another collaborative session accessing the same file.\nThe synchronization between rooms is not supported and you might lose some of your changes.",
)

path = Path(path)
updates_file_path = str(path.parent / f".{file_type}:{path.name}.y")
ystore = ystore_class(path=updates_file_path, log=self.log)
Expand All @@ -188,6 +195,7 @@ def __init__(
file_format,
file_type,
file,
self.event_logger,
ystore,
self.log,
self.settings["collaborative_document_save_delay"],
Expand Down Expand Up @@ -258,6 +266,8 @@ async def open(self, room_id):
# Initialize the room
await self.room.initialize()

self._emit(LogLevel.INFO, "initialize", "New client connected.")

async def send(self, message):
"""
Send a message to the client.
Expand Down Expand Up @@ -321,6 +331,18 @@ def on_close(self) -> None:
self.log.info("Cleaning room: %s", self._room_id)
self.room.cleaner = asyncio.create_task(self._clean_room())

def _emit(self, level: LogLevel, action: str | None = None, msg: str | None = None) -> None:
_, _, file_id = decode_file_path(self._room_id)
path = self._file_id_manager.get_path(file_id)

data = {"level": level.value, "room": self._room_id, "path": path}
if action:
data["action"] = action
if msg:
data["msg"] = msg

self.event_logger.emit(schema_id=JUPYTER_COLLABORATION_EVENTS_URI, data=data)

async def _clean_room(self) -> None:
"""
Async task for cleaning up the resources.
Expand Down Expand Up @@ -348,6 +370,7 @@ async def _clean_room(self) -> None:
# Clean room
del self.room
self.log.info("Room %s deleted", self._room_id)
self._emit(LogLevel.INFO, "clean", "Room deleted.")

# Clean the file loader if there are not rooms using it
_, _, file_id = decode_file_path(self._room_id)
Expand All @@ -356,6 +379,7 @@ async def _clean_room(self) -> None:
self.log.info("Deleting file %s", file.path)
file.clean()
del self.files[file_id]
self._emit(LogLevel.INFO, "clean", "Loader deleted.")

def check_origin(self, origin):
"""
Expand Down
30 changes: 30 additions & 0 deletions jupyter_collaboration/rooms.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
from logging import Logger
from typing import Any

from jupyter_events import EventLogger
from jupyter_ydoc import ydocs as YDOCS
from ypy_websocket.websocket_server import YRoom
from ypy_websocket.ystore import BaseYStore, YDocNotFound

from .loaders import FileLoader, OutOfBandChanges
from .utils import JUPYTER_COLLABORATION_EVENTS_URI, LogLevel

YFILE = YDOCS["file"]

Expand All @@ -22,6 +24,7 @@ def __init__(
file_format: str,
file_type: str,
file: FileLoader,
logger: EventLogger,
ystore: BaseYStore | None,
log: Logger | None,
save_delay: int | None = None,
Expand All @@ -35,6 +38,7 @@ def __init__(
self._file: FileLoader = file
self._document = YDOCS.get(self._file_type, YFILE)(self.ydoc)

self._logger = logger
self._save_delay = save_delay

self._update_lock = asyncio.Lock()
Expand Down Expand Up @@ -95,6 +99,13 @@ async def initialize(self) -> None:
if self.ystore is not None:
try:
await self.ystore.apply_updates(self.ydoc)
self._emit(
LogLevel.INFO,
"load",
"Content loaded from the store {}".format(
self.ystore.__class__.__qualname__
),
)
self.log.info(
"Content in room %s loaded from the ystore %s",
self._room_id,
Expand All @@ -109,6 +120,9 @@ async def initialize(self) -> None:
# if YStore updates and source file are out-of-sync, resync updates with source
if self._document.source != model["content"]:
# TODO: Delete document from the store.
self._emit(
LogLevel.INFO, "initialize", "The file is out-of-sync with the ystore."
)
self.log.info(
"Content in file %s is out-of-sync with the ystore %s",
self._file.path,
Expand All @@ -117,6 +131,7 @@ async def initialize(self) -> None:
read_from_source = True

if read_from_source:
self._emit(LogLevel.INFO, "load", "Content loaded from disk.")
self.log.info(
"Content in room %s loaded from file %s", self._room_id, self._file.path
)
Expand All @@ -128,6 +143,16 @@ async def initialize(self) -> None:
self._last_modified = model["last_modified"]
self._document.dirty = False
self.ready = True
self._emit(LogLevel.INFO, "initialize", "Room initialized")

def _emit(self, level: LogLevel, action: str | None = None, msg: str | None = None) -> None:
data = {"level": level.value, "room": self._room_id, "path": self._file.path}
if action:
data["action"] = action
if msg:
data["msg"] = msg

self._logger.emit(schema_id=JUPYTER_COLLABORATION_EVENTS_URI, data=data)

def _clean(self) -> None:
"""
Expand Down Expand Up @@ -155,6 +180,7 @@ async def _on_content_change(self, event: str, args: dict[str, Any]) -> None:
model = await self._file.load_content(self._file_format, self._file_type, True)

self.log.info("Out-of-band changes. Overwriting the content in room %s", self._room_id)
self._emit(LogLevel.INFO, "overwrite", "Out-of-band changes. Overwriting the room.")

async with self._update_lock:
self._document.source = model["content"]
Expand Down Expand Up @@ -215,6 +241,8 @@ async def _maybe_save_document(self) -> None:
async with self._update_lock:
self._document.dirty = False

self._emit(LogLevel.INFO, "save", "Content saved.")

except OutOfBandChanges:
self.log.info("Out-of-band changes. Overwriting the content in room %s", self._room_id)
model = await self._file.load_content(self._file_format, self._file_type, True)
Expand All @@ -223,6 +251,8 @@ async def _maybe_save_document(self) -> None:
self._last_modified = model["last_modified"]
self._document.dirty = False

self._emit(LogLevel.INFO, "overwrite", "Out-of-band changes while saving.")


class TransientRoom(YRoom):
"""A Y room for sharing state (e.g. awareness)."""
Expand Down
13 changes: 13 additions & 0 deletions jupyter_collaboration/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import pathlib
from enum import Enum
from typing import Tuple

JUPYTER_COLLABORATION_EVENTS_URI = "https://schema.jupyter.org/jupyter_collaboration/session/v1"
EVENTS_SCHEMA_PATH = pathlib.Path(__file__).parent / "events" / "session.yaml"


class LogLevel(Enum):
INFO = "INFO"
DEBUG = "DEBUG"
WARNING = "WARNING"
ERROR = "ERROR"
CRITICAL = "CRITICAL"


def decode_file_path(path: str) -> Tuple[str, str, str]:
"""
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
"stylelint-config-standard": "^30.0.1",
"stylelint-prettier": "^3.0.0",
"typedoc": "~0.23.28",
"typescript": "~5.0.2"
"typescript": "~5.0.4"
},
"packageManager": "yarn@3.5.0"
}
30 changes: 16 additions & 14 deletions packages/collaboration-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,28 +55,30 @@
"dependencies": {
"@jupyter/collaboration": "^1.0.0-alpha.8",
"@jupyter/docprovider": "^1.0.0-alpha.8",
"@jupyterlab/application": "^4.0.0-beta.0",
"@jupyterlab/apputils": "^4.0.0-beta.0",
"@jupyterlab/codemirror": "^4.0.0-beta.0",
"@jupyterlab/coreutils": "^6.0.0-beta.0",
"@jupyterlab/filebrowser": "^4.0.0-beta.0",
"@jupyterlab/services": "^7.0.0-beta.0",
"@jupyterlab/settingregistry": "^4.0.0-beta.0",
"@jupyterlab/statedb": "^4.0.0-beta.0",
"@jupyterlab/translation": "^4.0.0-beta.0",
"@jupyterlab/ui-components": "^4.0.0-beta.0",
"@lumino/commands": "^2.0.0",
"@lumino/widgets": "^2.0.0",
"@jupyterlab/application": "^4.0.0-beta.2",
"@jupyterlab/apputils": "^4.0.0-beta.2",
"@jupyterlab/codemirror": "^4.0.0-beta.2",
"@jupyterlab/coreutils": "^6.0.0-beta.2",
"@jupyterlab/filebrowser": "^4.0.0-beta.2",
"@jupyterlab/logconsole": "^4.0.0-beta.2",
"@jupyterlab/notebook": "^4.0.0-beta.2",
"@jupyterlab/services": "^7.0.0-beta.2",
"@jupyterlab/settingregistry": "^4.0.0-beta.2",
"@jupyterlab/statedb": "^4.0.0-beta.2",
"@jupyterlab/translation": "^4.0.0-beta.2",
"@jupyterlab/ui-components": "^4.0.0-beta.2",
"@lumino/commands": "^2.1.0",
"@lumino/widgets": "^2.1.0",
"y-protocols": "^1.0.5",
"y-websocket": "^1.3.15",
"yjs": "^13.5.40"
},
"devDependencies": {
"@jupyterlab/builder": "^4.0.0-beta.0",
"@jupyterlab/builder": "^4.0.0-beta.2",
"@types/react": "^18.0.27",
"npm-run-all": "^4.1.5",
"rimraf": "^4.1.2",
"typescript": "~5.0.2"
"typescript": "~5.0.4"
},
"publishConfig": {
"access": "public"
Expand Down
Loading

0 comments on commit 3797752

Please sign in to comment.