Skip to content

Commit 3ec00ad

Browse files
authored
Merge pull request #5372 from kevin-bates/cull-terminals
Add ability to cull terminals and track last activity
2 parents e9ce1b7 + 9d80001 commit 3ec00ad

File tree

9 files changed

+382
-52
lines changed

9 files changed

+382
-52
lines changed

notebook/notebookapp.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
from .auth.login import LoginHandler
9292
from .auth.logout import LogoutHandler
9393
from .base.handlers import FileFindHandler
94+
from .terminal import TerminalManager
9495

9596
from traitlets.config import Config
9697
from traitlets.config.application import catch_config_error, boolean_flag
@@ -659,7 +660,7 @@ class NotebookApp(JupyterApp):
659660

660661
classes = [
661662
KernelManager, Session, MappingKernelManager, KernelSpecManager,
662-
ContentsManager, FileContentsManager, NotebookNotary,
663+
ContentsManager, FileContentsManager, NotebookNotary, TerminalManager,
663664
GatewayKernelManager, GatewayKernelSpecManager, GatewaySessionManager, GatewayClient,
664665
]
665666
flags = Dict(flags)
@@ -1757,7 +1758,7 @@ def init_terminals(self):
17571758

17581759
try:
17591760
from .terminal import initialize
1760-
initialize(self.web_app, self.notebook_dir, self.connection_url, self.terminado_settings)
1761+
initialize(nb_app=self)
17611762
self.web_app.settings['terminals_available'] = True
17621763
except ImportError as e:
17631764
self.log.warning(_("Terminals not available (error was %s)"), e)
@@ -1993,6 +1994,22 @@ def cleanup_kernels(self):
19931994
self.log.info(kernel_msg % n_kernels)
19941995
run_sync(self.kernel_manager.shutdown_all())
19951996

1997+
def cleanup_terminals(self):
1998+
"""Shutdown all terminals.
1999+
2000+
The terminals will shutdown themselves when this process no longer exists,
2001+
but explicit shutdown allows the TerminalManager to cleanup.
2002+
"""
2003+
try:
2004+
terminal_manager = self.web_app.settings['terminal_manager']
2005+
except KeyError:
2006+
return # Terminals not enabled
2007+
2008+
n_terminals = len(terminal_manager.list())
2009+
terminal_msg = trans.ngettext('Shutting down %d terminal', 'Shutting down %d terminals', n_terminals)
2010+
self.log.info(terminal_msg % n_terminals)
2011+
run_sync(terminal_manager.terminate_all())
2012+
19962013
def notebook_info(self, kernel_count=True):
19972014
"Return the current working directory and the server url information"
19982015
info = self.contents_manager.info_string() + "\n"
@@ -2183,6 +2200,7 @@ def start(self):
21832200
self.remove_server_info_file()
21842201
self.remove_browser_open_file()
21852202
self.cleanup_kernels()
2203+
self.cleanup_terminals()
21862204

21872205
def stop(self):
21882206
def _stop():

notebook/services/api/api.yaml

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,7 @@ paths:
563563
schema:
564564
type: array
565565
items:
566-
$ref: '#/definitions/Terminal_ID'
566+
$ref: '#/definitions/Terminal'
567567
403:
568568
description: Forbidden to access
569569
404:
@@ -577,7 +577,7 @@ paths:
577577
200:
578578
description: Succesfully created a new terminal
579579
schema:
580-
$ref: '#/definitions/Terminal_ID'
580+
$ref: '#/definitions/Terminal'
581581
403:
582582
description: Forbidden to access
583583
404:
@@ -594,7 +594,7 @@ paths:
594594
200:
595595
description: Terminal session with given id
596596
schema:
597-
$ref: '#/definitions/Terminal_ID'
597+
$ref: '#/definitions/Terminal'
598598
403:
599599
description: Forbidden to access
600600
404:
@@ -840,12 +840,18 @@ definitions:
840840
type: string
841841
description: Last modified timestamp
842842
format: dateTime
843-
Terminal_ID:
844-
description: A Terminal_ID object
843+
Terminal:
844+
description: A Terminal object
845845
type: object
846846
required:
847847
- name
848848
properties:
849849
name:
850850
type: string
851-
description: name of terminal ID
851+
description: name of terminal
852+
last_activity:
853+
type: string
854+
description: |
855+
ISO 8601 timestamp for the last-seen activity on this terminal. Use
856+
this to identify which terminals have been inactive since a given time.
857+
Timestamps will be UTC, indicated 'Z' suffix.

notebook/terminal/__init__.py

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,36 +7,38 @@
77
raise ImportError("terminado >= 0.8.1 required, found %s" % terminado.__version__)
88

99
from ipython_genutils.py3compat import which
10-
from terminado import NamedTermManager
1110
from tornado.log import app_log
1211
from notebook.utils import url_path_join as ujoin
12+
from .terminalmanager import TerminalManager
1313
from .handlers import TerminalHandler, TermSocket
1414
from . import api_handlers
1515

16-
def initialize(webapp, notebook_dir, connection_url, settings):
16+
17+
def initialize(nb_app):
1718
if os.name == 'nt':
1819
default_shell = 'powershell.exe'
1920
else:
2021
default_shell = which('sh')
21-
shell = settings.get('shell_command',
22-
[os.environ.get('SHELL') or default_shell]
23-
)
22+
shell = nb_app.terminado_settings.get('shell_command',
23+
[os.environ.get('SHELL') or default_shell]
24+
)
2425
# Enable login mode - to automatically source the /etc/profile script
2526
if os.name != 'nt':
2627
shell.append('-l')
27-
terminal_manager = webapp.settings['terminal_manager'] = NamedTermManager(
28+
terminal_manager = nb_app.web_app.settings['terminal_manager'] = TerminalManager(
2829
shell_command=shell,
29-
extra_env={'JUPYTER_SERVER_ROOT': notebook_dir,
30-
'JUPYTER_SERVER_URL': connection_url,
30+
extra_env={'JUPYTER_SERVER_ROOT': nb_app.notebook_dir,
31+
'JUPYTER_SERVER_URL': nb_app.connection_url,
3132
},
33+
parent=nb_app,
3234
)
33-
terminal_manager.log = app_log
34-
base_url = webapp.settings['base_url']
35+
terminal_manager.log = nb_app.log
36+
base_url = nb_app.web_app.settings['base_url']
3537
handlers = [
3638
(ujoin(base_url, r"/terminals/(\w+)"), TerminalHandler),
3739
(ujoin(base_url, r"/terminals/websocket/(\w+)"), TermSocket,
3840
{'term_manager': terminal_manager}),
3941
(ujoin(base_url, r"/api/terminals"), api_handlers.TerminalRootHandler),
4042
(ujoin(base_url, r"/api/terminals/(\w+)"), api_handlers.TerminalHandler),
4143
]
42-
webapp.add_handlers(".*$", handlers)
44+
nb_app.web_app.add_handlers(".*$", handlers)

notebook/terminal/api_handlers.py

Lines changed: 9 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,32 @@
11
import json
22
from tornado import web, gen
33
from ..base.handlers import APIHandler
4-
from ..prometheus.metrics import TERMINAL_CURRENTLY_RUNNING_TOTAL
54

65

76
class TerminalRootHandler(APIHandler):
87
@web.authenticated
98
def get(self):
10-
tm = self.terminal_manager
11-
terms = [{'name': name} for name in tm.terminals]
12-
self.finish(json.dumps(terms))
13-
14-
# Update the metric below to the length of the list 'terms'
15-
TERMINAL_CURRENTLY_RUNNING_TOTAL.set(
16-
len(terms)
17-
)
9+
models = self.terminal_manager.list()
10+
self.finish(json.dumps(models))
1811

1912
@web.authenticated
2013
def post(self):
2114
"""POST /terminals creates a new terminal and redirects to it"""
22-
name, _ = self.terminal_manager.new_named_terminal()
23-
self.finish(json.dumps({'name': name}))
24-
25-
# Increase the metric by one because a new terminal was created
26-
TERMINAL_CURRENTLY_RUNNING_TOTAL.inc()
15+
model = self.terminal_manager.create()
16+
self.finish(json.dumps(model))
2717

2818

2919
class TerminalHandler(APIHandler):
3020
SUPPORTED_METHODS = ('GET', 'DELETE')
3121

3222
@web.authenticated
3323
def get(self, name):
34-
tm = self.terminal_manager
35-
if name in tm.terminals:
36-
self.finish(json.dumps({'name': name}))
37-
else:
38-
raise web.HTTPError(404, "Terminal not found: %r" % name)
24+
model = self.terminal_manager.get(name)
25+
self.finish(json.dumps(model))
3926

4027
@web.authenticated
4128
@gen.coroutine
4229
def delete(self, name):
43-
tm = self.terminal_manager
44-
if name in tm.terminals:
45-
yield tm.terminate(name, force=True)
46-
self.set_status(204)
47-
self.finish()
48-
49-
# Decrease the metric below by one
50-
# because a terminal has been shutdown
51-
TERMINAL_CURRENTLY_RUNNING_TOTAL.dec()
52-
53-
else:
54-
raise web.HTTPError(404, "Terminal not found: %r" % name)
30+
yield self.terminal_manager.terminate(name, force=True)
31+
self.set_status(204)
32+
self.finish()

notebook/terminal/handlers.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,14 @@ def get(self, *args, **kwargs):
3535

3636
def on_message(self, message):
3737
super(TermSocket, self).on_message(message)
38-
self.application.settings['terminal_last_activity'] = utcnow()
38+
self._update_activity()
3939

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

notebook/terminal/tests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)