Skip to content

Commit 7b3d91a

Browse files
committed
Add ability to cull terminals and track last activity
This adds functionality to track a terminal's last activity and optionally cull terminals that have been inactive for some specified duration.
1 parent 36218db commit 7b3d91a

File tree

6 files changed

+185
-45
lines changed

6 files changed

+185
-45
lines changed

jupyter_server/serverapp.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
from .auth.login import LoginHandler
8484
from .auth.logout import LogoutHandler
8585
from .base.handlers import FileFindHandler
86+
from .terminal import TerminalManager
8687

8788
from traitlets.config import Config
8889
from traitlets.config.application import catch_config_error, boolean_flag
@@ -587,7 +588,7 @@ class ServerApp(JupyterApp):
587588
classes = [
588589
KernelManager, Session, MappingKernelManager, KernelSpecManager, AsyncMappingKernelManager,
589590
ContentsManager, FileContentsManager, AsyncContentsManager, AsyncFileContentsManager, NotebookNotary,
590-
GatewayKernelManager, GatewayKernelSpecManager, GatewaySessionManager, GatewayClient
591+
TerminalManager, GatewayKernelSpecManager, GatewaySessionManager, GatewayClient
591592
]
592593

593594
subcommands = dict(
@@ -1546,7 +1547,7 @@ def init_terminals(self):
15461547

15471548
try:
15481549
from .terminal import initialize
1549-
initialize(self.web_app, self.root_dir, self.connection_url, self.terminado_settings)
1550+
initialize(self.web_app, self.root_dir, self.connection_url, self.terminado_settings, self)
15501551
self.web_app.settings['terminals_available'] = True
15511552
except ImportError as e:
15521553
self.log.warning(_i18n("Terminals not available (error was %s)"), e)

jupyter_server/services/api/api.yaml

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -568,7 +568,7 @@ paths:
568568
schema:
569569
type: array
570570
items:
571-
$ref: '#/definitions/Terminal_ID'
571+
$ref: '#/definitions/Terminal'
572572
403:
573573
description: Forbidden to access
574574
404:
@@ -582,7 +582,7 @@ paths:
582582
200:
583583
description: Succesfully created a new terminal
584584
schema:
585-
$ref: '#/definitions/Terminal_ID'
585+
$ref: '#/definitions/Terminal'
586586
403:
587587
description: Forbidden to access
588588
404:
@@ -599,7 +599,7 @@ paths:
599599
200:
600600
description: Terminal session with given id
601601
schema:
602-
$ref: '#/definitions/Terminal_ID'
602+
$ref: '#/definitions/Terminal'
603603
403:
604604
description: Forbidden to access
605605
404:
@@ -845,12 +845,18 @@ definitions:
845845
type: string
846846
description: Last modified timestamp
847847
format: dateTime
848-
Terminal_ID:
849-
description: A Terminal_ID object
848+
Terminal:
849+
description: A Terminal object
850850
type: object
851851
required:
852852
- name
853853
properties:
854854
name:
855855
type: string
856-
description: name of terminal ID
856+
description: name of terminal
857+
last_activity:
858+
type: string
859+
description: |
860+
ISO 8601 timestamp for the last-seen activity on this terminal. Use
861+
this to identify which terminals have been inactive since a given time.
862+
Timestamps will be UTC, indicated 'Z' suffix.

jupyter_server/terminal/__init__.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@
88
raise ImportError("terminado >= 0.8.3 required, found %s" % terminado.__version__)
99

1010
from ipython_genutils.py3compat import which
11-
from terminado import NamedTermManager
1211
from tornado.log import app_log
1312
from jupyter_server.utils import url_path_join as ujoin
1413
from . import api_handlers
15-
from .handlers import TermSocket
14+
from .handlers import TerminalHandler, TermSocket
15+
from .terminalmanager import TerminalManager
1616

1717

18-
def initialize(webapp, root_dir, connection_url, settings):
18+
def initialize(webapp, root_dir, connection_url, settings, parent):
1919
if os.name == 'nt':
2020
default_shell = 'powershell.exe'
2121
else:
@@ -33,11 +33,12 @@ def initialize(webapp, root_dir, connection_url, settings):
3333
# the user has specifically set a preferred shell command.
3434
if os.name != 'nt' and shell_override is None and not sys.stdout.isatty():
3535
shell.append('-l')
36-
terminal_manager = webapp.settings['terminal_manager'] = NamedTermManager(
36+
terminal_manager = webapp.settings['terminal_manager'] = TerminalManager(
3737
shell_command=shell,
3838
extra_env={'JUPYTER_SERVER_ROOT': root_dir,
3939
'JUPYTER_SERVER_URL': connection_url,
4040
},
41+
parent=parent,
4142
)
4243
terminal_manager.log = app_log
4344
base_url = webapp.settings['base_url']
Lines changed: 9 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,35 @@
11
import json
22
from tornado import web
33
from ..base.handlers import APIHandler
4-
from ..prometheus.metrics import TERMINAL_CURRENTLY_RUNNING_TOTAL
54

65

76

87
class TerminalRootHandler(APIHandler):
98

109
@web.authenticated
1110
def get(self):
12-
tm = self.terminal_manager
13-
terms = [{'name': name} for name in tm.terminals]
14-
self.finish(json.dumps(terms))
15-
16-
# Update the metric below to the length of the list 'terms'
17-
TERMINAL_CURRENTLY_RUNNING_TOTAL.set(
18-
len(terms)
19-
)
11+
models = self.terminal_manager.list()
12+
self.finish(json.dumps(models))
2013

2114
@web.authenticated
2215
def post(self):
2316
"""POST /terminals creates a new terminal and redirects to it"""
2417
data = self.get_json_body() or {}
2518

26-
name, _ = self.terminal_manager.new_named_terminal(**data)
27-
self.finish(json.dumps({'name': name}))
28-
29-
# Increase the metric by one because a new terminal was created
30-
TERMINAL_CURRENTLY_RUNNING_TOTAL.inc()
19+
model = self.terminal_manager.create(**data)
20+
self.finish(json.dumps(model))
3121

3222

3323
class TerminalHandler(APIHandler):
3424
SUPPORTED_METHODS = ('GET', 'DELETE')
3525

3626
@web.authenticated
3727
def get(self, name):
38-
tm = self.terminal_manager
39-
if name in tm.terminals:
40-
self.finish(json.dumps({'name': name}))
41-
else:
42-
raise web.HTTPError(404, "Terminal not found: %r" % name)
28+
model = self.terminal_manager.get(name)
29+
self.finish(json.dumps(model))
4330

4431
@web.authenticated
4532
async def delete(self, name):
46-
tm = self.terminal_manager
47-
if name in tm.terminals:
48-
await tm.terminate(name, force=True)
49-
self.set_status(204)
50-
self.finish()
51-
52-
# Decrease the metric below by one
53-
# because a terminal has been shutdown
54-
TERMINAL_CURRENTLY_RUNNING_TOTAL.dec()
55-
56-
else:
57-
raise web.HTTPError(404, "Terminal not found: %r" % name)
33+
await self.terminal_manager.terminate(name, force=True)
34+
self.set_status(204)
35+
self.finish()

jupyter_server/terminal/handlers.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@
1111
from ..base.zmqhandlers import WebSocketMixin
1212

1313

14+
class TerminalHandler(JupyterHandler):
15+
"""Render the terminal interface."""
16+
@web.authenticated
17+
def get(self, term_name):
18+
self.write(self.render_template('terminal.html',
19+
ws_path="terminals/websocket/%s" % term_name))
20+
21+
1422
class TermSocket(WebSocketMixin, JupyterHandler, terminado.TermSocket):
1523

1624
def origin_check(self):
@@ -26,8 +34,14 @@ def get(self, *args, **kwargs):
2634

2735
def on_message(self, message):
2836
super(TermSocket, self).on_message(message)
29-
self.application.settings['terminal_last_activity'] = utcnow()
37+
self._update_activity()
3038

3139
def write_message(self, message, binary=False):
3240
super(TermSocket, self).write_message(message, binary=binary)
33-
self.application.settings['terminal_last_activity'] = utcnow()
41+
self._update_activity()
42+
43+
def _update_activity(self):
44+
self.application.settings['terminal_last_activity'] = utcnow()
45+
# terminal may not be around on deletion/cull
46+
if self.term_name in self.terminal_manager.terminals:
47+
self.terminal_manager.terminals[self.term_name].last_activity = utcnow()
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""A MultiTerminalManager for use in the notebook webserver
2+
- raises HTTPErrors
3+
- creates REST API models
4+
"""
5+
6+
# Copyright (c) Jupyter Development Team.
7+
# Distributed under the terms of the Modified BSD License.
8+
9+
10+
from datetime import timedelta
11+
from notebook._tz import utcnow, isoformat
12+
from terminado import NamedTermManager
13+
from tornado import web
14+
from tornado.ioloop import IOLoop, PeriodicCallback
15+
from traitlets import Integer
16+
from traitlets.config import Configurable
17+
from ..prometheus.metrics import TERMINAL_CURRENTLY_RUNNING_TOTAL
18+
19+
20+
class TerminalManager(Configurable, NamedTermManager):
21+
""" """
22+
23+
_culler_callback = None
24+
25+
_initialized_culler = False
26+
27+
cull_inactive_timeout = Integer(0, config=True,
28+
help="""Timeout (in seconds) in which a terminal has been inactive and ready to be culled.
29+
Values of 0 or lower disable culling."""
30+
)
31+
32+
cull_interval_default = 300 # 5 minutes
33+
cull_interval = Integer(cull_interval_default, config=True,
34+
help="""The interval (in seconds) on which to check for terminals exceeding the inactive timeout value."""
35+
)
36+
37+
# -------------------------------------------------------------------------
38+
# Methods for managing terminals
39+
# -------------------------------------------------------------------------
40+
def __init__(self, *args, **kwargs):
41+
super(TerminalManager, self).__init__(*args, **kwargs)
42+
43+
def create(self):
44+
name, term = self.new_named_terminal()
45+
# Monkey-patch last-activity, similar to kernels. Should we need
46+
# more functionality per terminal, we can look into possible sub-
47+
# classing or containment then.
48+
term.last_activity = utcnow()
49+
model = self.terminal_model(name)
50+
# Increase the metric by one because a new terminal was created
51+
TERMINAL_CURRENTLY_RUNNING_TOTAL.inc()
52+
# Ensure culler is initialized
53+
self.initialize_culler()
54+
return model
55+
56+
def get(self, name):
57+
model = self.terminal_model(name)
58+
return model
59+
60+
def list(self):
61+
models = [self.terminal_model(name) for name in self.terminals]
62+
63+
# Update the metric below to the length of the list 'terms'
64+
TERMINAL_CURRENTLY_RUNNING_TOTAL.set(
65+
len(models)
66+
)
67+
return models
68+
69+
async def terminate(self, name, force=False):
70+
self._check_terminal(name)
71+
await super(TerminalManager, self).terminate(name, force=force)
72+
73+
# Decrease the metric below by one
74+
# because a terminal has been shutdown
75+
TERMINAL_CURRENTLY_RUNNING_TOTAL.dec()
76+
77+
def terminal_model(self, name):
78+
"""Return a JSON-safe dict representing a terminal.
79+
For use in representing terminals in the JSON APIs.
80+
"""
81+
self._check_terminal(name)
82+
term = self.terminals[name]
83+
model = {
84+
"name": name,
85+
"last_activity": isoformat(term.last_activity),
86+
}
87+
return model
88+
89+
def _check_terminal(self, name):
90+
"""Check a that terminal 'name' exists and raise 404 if not."""
91+
if name not in self.terminals:
92+
raise web.HTTPError(404, u'Terminal not found: %s' % name)
93+
94+
def initialize_culler(self):
95+
"""Start culler if 'cull_inactive_timeout' is greater than zero.
96+
Regardless of that value, set flag that we've been here.
97+
"""
98+
if not self._initialized_culler and self.cull_inactive_timeout > 0:
99+
if self._culler_callback is None:
100+
loop = IOLoop.current()
101+
if self.cull_interval <= 0: # handle case where user set invalid value
102+
self.log.warning("Invalid value for 'cull_interval' detected (%s) - using default value (%s).",
103+
self.cull_interval, self.cull_interval_default)
104+
self.cull_interval = self.cull_interval_default
105+
self._culler_callback = PeriodicCallback(
106+
self.cull_terminals, 1000*self.cull_interval)
107+
self.log.info("Culling terminals with inactivity > %s seconds at %s second intervals ...",
108+
self.cull_inactive_timeout, self.cull_interval)
109+
self._culler_callback.start()
110+
111+
self._initialized_culler = True
112+
113+
async def cull_terminals(self):
114+
self.log.debug("Polling every %s seconds for terminals inactive for > %s seconds...",
115+
self.cull_interval, self.cull_inactive_timeout)
116+
# Create a separate list of terminals to avoid conflicting updates while iterating
117+
for name in list(self.terminals):
118+
try:
119+
await self.cull_inactive_terminal(name)
120+
except Exception as e:
121+
self.log.exception("The following exception was encountered while checking the "
122+
"activity of terminal {}: {}".format(name, e))
123+
124+
async def cull_inactive_terminal(self, name):
125+
try:
126+
term = self.terminals[name]
127+
except KeyError:
128+
return # KeyErrors are somewhat expected since the terminal can be terminated as the culling check is made.
129+
130+
self.log.debug("name=%s, last_activity=%s", name, term.last_activity)
131+
if hasattr(term, 'last_activity'):
132+
dt_now = utcnow()
133+
dt_inactive = dt_now - term.last_activity
134+
# Compute idle properties
135+
is_time = dt_inactive > timedelta(seconds=self.cull_inactive_timeout)
136+
# Cull the kernel if all three criteria are met
137+
if (is_time):
138+
inactivity = int(dt_inactive.total_seconds())
139+
self.log.warning("Culling terminal '%s' due to %s seconds of inactivity.", name, inactivity)
140+
await self.terminate(name, force=True)

0 commit comments

Comments
 (0)