Skip to content

Commit 8ffae7b

Browse files
committed
stop inactive rooms instead of deleting
1 parent 734a109 commit 8ffae7b

File tree

2 files changed

+63
-94
lines changed

2 files changed

+63
-94
lines changed

jupyter_server_documents/rooms/yroom.py

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,6 @@ class YRoom:
7676
_ydoc_subscription: pycrdt.Subscription
7777
"""Subscription to YDoc changes."""
7878

79-
_on_stopping: callable[[], Any] | None
80-
"""
81-
Callback to run as soon as `stop()` or `stop_immediately()` are called. Only
82-
set in the constructor.
83-
"""
84-
8579
_stopped: bool = False
8680
"""
8781
Whether the YRoom is stopped. Set to `True` when `stop()` is called and set
@@ -101,15 +95,13 @@ def __init__(
10195
fileid_manager: BaseFileIdManager,
10296
contents_manager: AsyncContentsManager | ContentsManager,
10397
event_logger: EventLogger,
104-
on_stopping: callable[[], Any] | None = None,
10598
):
10699
# Bind instance attributes
107100
self.room_id = room_id
108101
self.log = log
109102
self._loop = loop
110103
self._fileid_manager = fileid_manager
111104
self._contents_manager = contents_manager
112-
self._on_stopping = on_stopping
113105
self._stopped = False
114106

115107
# Initialize YjsClientGroup, YDoc, and Awareness
@@ -671,11 +663,6 @@ def stop(self, close_code: int = 1001, immediately: bool = False):
671663
- Clears the YDoc, Awareness, and JupyterYDoc, freeing their memory to
672664
the server. This deletes the YDoc history.
673665
"""
674-
675-
# TODO: delete dis?
676-
if self._on_stopping:
677-
self._on_stopping()
678-
679666
# Disconnect all clients with the given close code
680667
self.clients.stop(close_code=close_code)
681668

@@ -733,7 +720,7 @@ async def _save_then_clear_ydoc(self):
733720
"""
734721
Saves the JupyterYDoc, then calls `self._clear_ydoc()`.
735722
736-
This can be run safely in the background because the FileAPI uses an
723+
This can be run safely in the background because the FileAPI uses a
737724
lock to prevent overlapping reads & writes to a single file.
738725
"""
739726
await self.file_api.save(self._jupyter_ydoc)

jupyter_server_documents/rooms/yroom_manager.py

Lines changed: 62 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -13,28 +13,25 @@
1313
class YRoomManager():
1414
"""
1515
A singleton that manages all `YRoom` instances in the server extension. This
16-
automatically deletes `YRoom` instances if they have had no connected
17-
clients or active kernel for >10 seconds.
18-
19-
Because rooms may be deleted due to inactivity, consumers should only store
20-
a reference to the room ID and call `get_room(room_id)` each time a
21-
reference to the room is needed. See `get_room()` for more details.
16+
automatically stops `YRoom` instances if they have had no connected clients
17+
or active kernel for >10 seconds.
2218
"""
2319

2420
_rooms_by_id: dict[str, YRoom]
2521
"""
26-
Dictionary of active `YRoom` instances, keyed by room ID.
22+
Dictionary of active `YRoom` instances, keyed by room ID. Rooms are never
23+
deleted from this dictionary, even if stopped due to inactivity.
2724
28-
It is guaranteed that if a room is present in the dictionary, the room is
29-
not currently stopping. This is ensured by `_handle_yroom_stopping()`.
25+
TODO: Delete a room if its file was deleted in/out-of-band or moved
26+
out-of-band. See #116.
3027
"""
3128

3229
_inactive_rooms: set[str]
3330
"""
3431
Set of room IDs that were marked inactive on the last iteration of
3532
`_watch_rooms()`. If a room is inactive and its ID is present in this set,
36-
then the room has been inactive for >10 seconds, and the room should be
37-
deleted in `_watch_rooms()`.
33+
then the room the room should be stopped as it has been inactive for >10
34+
seconds.
3835
"""
3936

4037
_get_fileid_manager: callable[[], BaseFileIdManager]
@@ -80,23 +77,16 @@ def get_room(self, room_id: str) -> YRoom | None:
8077
"""
8178
Returns the `YRoom` instance for a given room ID. If the instance does
8279
not exist, this method will initialize one and return it. Otherwise,
83-
this method returns the instance from its cache, ensuring that this
84-
method is fast in almost all cases.
85-
86-
Consumers should always call this method each time a reference to the
87-
`YRoom` is needed, since rooms may be deleted due to inactivity.
88-
89-
This method also ensures that the returned room will be alive for >10
90-
seconds. This prevents the room from being deleted shortly after the
91-
consumer receives it via this method, even if it was inactive.
80+
this method returns the instance from its cache.
9281
"""
9382
# First, ensure this room stays open for >10 seconds by removing it from
9483
# the inactive set of rooms if it is present.
9584
self._inactive_rooms.discard(room_id)
9685

9786
# If room exists, return the room
98-
if room_id in self._rooms_by_id:
99-
return self._rooms_by_id[room_id]
87+
yroom = self._rooms_by_id.get(room_id, None)
88+
if yroom:
89+
return yroom
10090

10191
# Otherwise, create a new room
10292
try:
@@ -107,7 +97,6 @@ def get_room(self, room_id: str) -> YRoom | None:
10797
loop=self.loop,
10898
fileid_manager=self.fileid_manager,
10999
contents_manager=self.contents_manager,
110-
on_stopping=lambda: self._handle_yroom_stopping(room_id),
111100
event_logger=self.event_logger,
112101
)
113102
self._rooms_by_id[room_id] = yroom
@@ -119,31 +108,21 @@ def get_room(self, room_id: str) -> YRoom | None:
119108
)
120109
return None
121110

111+
122112
def has_room(self, room_id: str) -> bool:
123113
"""
124114
Returns whether a `YRoom` instance with a matching `room_id` already
125115
exists.
126116
"""
127117
return room_id in self._rooms_by_id
128118

129-
def _handle_yroom_stopping(self, room_id: str) -> None:
130-
"""
131-
Callback that is run when the YRoom starts stopping. This callback:
132-
133-
- Ensures the room is removed from the dictionary, even if the room was
134-
stopped directly without `YRoomManager.delete_room()`.
135119

136-
- Prevents future connections to the stopping room and allows its memory
137-
to be freed once complete.
138-
"""
139-
self._rooms_by_id.pop(room_id, None)
140-
141-
142120
def delete_room(self, room_id: str) -> None:
143121
"""
144122
Gracefully deletes a YRoom given a room ID. This stops the YRoom,
145-
closing all Websockets, applying remaining updates, and saves the final
146-
content of the YDoc in a background task.
123+
closing all Websockets with close code 1001 (server shutting down),
124+
applying remaining updates, and saving the final content of the YDoc in
125+
a background task.
147126
148127
Returns `True` if the room was deleted successfully. Returns `False` if
149128
an exception was raised.
@@ -165,65 +144,68 @@ def delete_room(self, room_id: str) -> None:
165144
async def _watch_rooms(self) -> None:
166145
"""
167146
Background task that checks all `YRoom` instances every 10 seconds,
168-
deleting any rooms that have been inactive for >10 seconds.
147+
stopping any rooms that have been inactive for >10 seconds.
169148
170-
- For rooms providing notebooks: This task deletes the room if it has no
149+
- For rooms providing notebooks: This task stops the room if it has no
171150
connected clients and its kernel execution status is either 'idle' or
172151
'dead'.
173152
174-
- For all other rooms: This task deletes the room if it has no connected
153+
- For all other rooms: This task stops the room if it has no connected
175154
clients.
176155
"""
177156
while True:
178157
# Check every 10 seconds
179158
await asyncio.sleep(10)
180159

181-
# Get all room IDs from the room dictionary in advance, as the
182-
# dictionary will mutate if rooms are deleted.
160+
# Get all room IDs, except for the global awareness room
183161
room_ids = set(self._rooms_by_id.keys())
184-
185-
# Remove the global awareness room ID from this set, as that room
186-
# should not be stopped until the server extension is stopped.
187162
room_ids.discard("JupyterLab:globalAwareness")
188163

189-
# Iterate through all rooms. If any rooms are empty, stop the rooms.
164+
# Iterate through all rooms. If any rooms are empty, stop the room.
190165
for room_id in room_ids:
191-
# Continue if `room_id` is not in the rooms dictionary. This
192-
# happens if the room was stopped by something else while this
193-
# `for` loop is still running, so we must check.
194-
if room_id not in self._rooms_by_id:
195-
self._inactive_rooms.discard(room_id)
196-
continue
197-
198-
# Continue if the room has any connected clients.
199-
room = self._rooms_by_id[room_id]
200-
if room.clients.count != 0:
201-
self._inactive_rooms.discard(room_id)
202-
continue
203-
204-
# Continue if the room contains a notebook with kernel execution
205-
# state neither 'idle' nor 'dead'.
206-
# In this case, the notebook kernel may still be running code
207-
# cells, so the room should not be closed.
208-
awareness = room.get_awareness().get_local_state() or {}
209-
execution_state = awareness.get("kernel", {}).get("execution_state", None)
210-
if execution_state not in { "idle", "dead", None }:
211-
self._inactive_rooms.discard(room_id)
212-
continue
213-
214-
# The room is inactive if this statement is reached
215-
# Delete the room if was marked as inactive in the last
216-
# iteration, otherwise mark it as inactive.
217-
if room_id in self._inactive_rooms:
218-
self.log.info(
219-
f"YRoom '{room_id}' has been inactive for >10 seconds. "
220-
)
221-
self.loop.create_task(self.delete_room(room_id))
222-
self._inactive_rooms.discard(room_id)
223-
else:
224-
self._inactive_rooms.add(room_id)
166+
self._check_room(room_id)
225167

226168

169+
def _check_room(self, room_id: str) -> None:
170+
"""
171+
Checks a room for inactivity.
172+
173+
- If a room is inactive and not in `_inactive_rooms`, this method adds
174+
the room to `_inactive_rooms`.
175+
176+
- If a room is inactive and is listed in `_inactive_rooms`, this method
177+
stops the room, as it has been inactive for 2 consecutive iterations of
178+
`_watch_rooms()`.
179+
"""
180+
# Do nothing if the room has any connected clients.
181+
room = self._rooms_by_id[room_id]
182+
if room.clients.count != 0:
183+
self._inactive_rooms.discard(room_id)
184+
return
185+
186+
# Do nothing if the room contains a notebook with kernel execution state
187+
# neither 'idle' nor 'dead'.
188+
# In this case, the notebook kernel may still be running code cells, so
189+
# the room should not be closed.
190+
awareness = room.get_awareness().get_local_state() or {}
191+
execution_state = awareness.get("kernel", {}).get("execution_state", None)
192+
if execution_state not in { "idle", "dead", None }:
193+
self._inactive_rooms.discard(room_id)
194+
return
195+
196+
# The room is inactive if this line is reached.
197+
# Stop the room if was marked as inactive in the last iteration,
198+
# otherwise mark it as inactive.
199+
if room_id in self._inactive_rooms:
200+
self.log.info(
201+
f"YRoom '{room_id}' has been inactive for >10 seconds. "
202+
)
203+
room.stop()
204+
self._inactive_rooms.discard(room_id)
205+
else:
206+
self._inactive_rooms.add(room_id)
207+
208+
227209
def stop(self) -> None:
228210
"""
229211
Gracefully deletes each `YRoom`. See `delete_room()` for more info.

0 commit comments

Comments
 (0)