From b472c55d9b6f297abe51749074afb9d55b5aa650 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 11 Oct 2016 15:58:49 +0200 Subject: [PATCH 1/9] add activity watching to kernels adds to kernel REST model: - last_activity: datetime-aware timestamp - execution_state: from last recorded status message --- notebook/services/api/api.yaml | 12 ++++ notebook/services/kernels/handlers.py | 8 +-- notebook/services/kernels/kernelmanager.py | 49 +++++++++++++-- .../sessions/tests/test_sessionmanager.py | 62 ++++++++++++++++--- 4 files changed, 113 insertions(+), 18 deletions(-) diff --git a/notebook/services/api/api.yaml b/notebook/services/api/api.yaml index 02429ce096..1fad58f151 100644 --- a/notebook/services/api/api.yaml +++ b/notebook/services/api/api.yaml @@ -697,6 +697,18 @@ definitions: name: type: string description: kernel spec name + last_activity: + type: string + description: | + ISO 8601 timestamp for the last-seen activity on this kernel. + Use this in combination with execution_state == 'idle' to identify + which kernels have been idle since a given time. + Added in notebook server 5.0. + execution_state: + type: string + description: | + Current execution state of the kernel (typically 'idle' or 'busy', but may be other values, such as 'starting'). + Added in notebook server 5.0. Session: description: A session type: object diff --git a/notebook/services/kernels/handlers.py b/notebook/services/kernels/handlers.py index 86b1d789a9..f72ee11490 100644 --- a/notebook/services/kernels/handlers.py +++ b/notebook/services/kernels/handlers.py @@ -31,7 +31,7 @@ class MainKernelHandler(APIHandler): def get(self): km = self.kernel_manager kernels = yield gen.maybe_future(km.list_kernels()) - self.finish(json.dumps(kernels)) + self.finish(json.dumps(kernels, default=date_default)) @json_errors @web.authenticated @@ -51,7 +51,7 @@ def post(self): location = url_path_join(self.base_url, 'api', 'kernels', url_escape(kernel_id)) self.set_header('Location', location) self.set_status(201) - self.finish(json.dumps(model)) + self.finish(json.dumps(model, default=date_default)) class KernelHandler(APIHandler): @@ -62,7 +62,7 @@ def get(self, kernel_id): km = self.kernel_manager km._check_kernel_id(kernel_id) model = km.kernel_model(kernel_id) - self.finish(json.dumps(model)) + self.finish(json.dumps(model, default=date_default)) @json_errors @web.authenticated @@ -93,7 +93,7 @@ def post(self, kernel_id, action): self.set_status(500) else: model = km.kernel_model(kernel_id) - self.write(json.dumps(model)) + self.write(json.dumps(model, default=date_default)) self.finish() diff --git a/notebook/services/kernels/kernelmanager.py b/notebook/services/kernels/kernelmanager.py index 2577737f44..02c849359b 100644 --- a/notebook/services/kernels/kernelmanager.py +++ b/notebook/services/kernels/kernelmanager.py @@ -17,6 +17,7 @@ from traitlets import List, Unicode, TraitError, default, validate from notebook.utils import to_os_path +from notebook.services.contents.tz import utcnow from ipython_genutils.py3compat import getcwd @@ -90,6 +91,7 @@ def start_kernel(self, kernel_id=None, path=None, **kwargs): kernel_id = yield gen.maybe_future( super(MappingKernelManager, self).start_kernel(**kwargs) ) + self.start_watching_activity(kernel_id) self.log.info("Kernel started: %s" % kernel_id) self.log.debug("Kernel args: %r" % kwargs) # register callback for failed auto-restart @@ -102,10 +104,11 @@ def start_kernel(self, kernel_id=None, path=None, **kwargs): self.log.info("Using existing kernel: %s" % kernel_id) # py2-compat raise gen.Return(kernel_id) - + def shutdown_kernel(self, kernel_id, now=False): """Shutdown a kernel by kernel_id""" self._check_kernel_id(kernel_id) + self._kernels[kernel_id]._activity_stream.close() return super(MappingKernelManager, self).shutdown_kernel(kernel_id, now=now) def restart_kernel(self, kernel_id): @@ -150,11 +153,19 @@ def on_restart_failed(): return future def kernel_model(self, kernel_id): - """Return a dictionary of kernel information described in the - JSON standard model.""" + """Return a JSON-safe dict representing a kernel + + For use in representing kernels in the JSON APIs. + """ self._check_kernel_id(kernel_id) - model = {"id":kernel_id, - "name": self._kernels[kernel_id].kernel_name} + kernel = self._kernels[kernel_id] + + model = { + "id":kernel_id, + "name": kernel.kernel_name, + "last_activity": kernel.last_activity, + "execution_state": kernel.execution_state, + } return model def list_kernels(self): @@ -171,3 +182,31 @@ def _check_kernel_id(self, kernel_id): """Check a that a kernel_id exists and raise 404 if not.""" if kernel_id not in self: raise web.HTTPError(404, u'Kernel does not exist: %s' % kernel_id) + + # monitoring activity: + + def start_watching_activity(self, kernel_id): + """Start watching IOPub messages on a kernel for activity. + + - update last_activity on every message + - record execution_state from status messages + """ + kernel = self._kernels[kernel_id] + # add busy/activity markers: + kernel.execution_state = 'starting' + kernel.last_activity = utcnow() + kernel._activity_stream = kernel.connect_iopub() + + def record_activity(msg_list): + """Record an IOPub message arriving from a kernel""" + kernel.last_activity = utcnow() + + idents, fed_msg_list = kernel.session.feed_identities(msg_list) + msg = kernel.session.deserialize(fed_msg_list) + msg_type = msg['header']['msg_type'] + self.log.info("activity on %s: %s", kernel_id, msg_type) + if msg_type == 'status': + kernel.execution_state = msg['content']['execution_state'] + + kernel._activity_stream.on_recv(record_activity) + diff --git a/notebook/services/sessions/tests/test_sessionmanager.py b/notebook/services/sessions/tests/test_sessionmanager.py index f45024e56c..1e02026158 100644 --- a/notebook/services/sessions/tests/test_sessionmanager.py +++ b/notebook/services/sessions/tests/test_sessionmanager.py @@ -9,11 +9,15 @@ from ..sessionmanager import SessionManager from notebook.services.kernels.kernelmanager import MappingKernelManager from notebook.services.contents.manager import ContentsManager +from notebook.services.contents.tz import utcnow class DummyKernel(object): def __init__(self, kernel_name='python'): self.kernel_name = kernel_name +dummy_date = utcnow() +dummy_date_s = dummy_date.isoformat() + class DummyMKM(MappingKernelManager): """MappingKernelManager interface that doesn't start kernels, for testing""" def __init__(self, *args, **kwargs): @@ -25,7 +29,9 @@ def _new_id(self): def start_kernel(self, kernel_id=None, path=None, kernel_name='python', **kwargs): kernel_id = kernel_id or self._new_id() - self._kernels[kernel_id] = DummyKernel(kernel_name=kernel_name) + k = self._kernels[kernel_id] = DummyKernel(kernel_name=kernel_name) + k.last_activity = dummy_date + k.execution_state = 'idle' return kernel_id def shutdown_kernel(self, kernel_id, now=False): @@ -65,7 +71,12 @@ def test_get_session(self): 'notebook': {'path': u'/path/to/test.ipynb', 'name': None}, 'type': 'notebook', 'name': None, - 'kernel': {'id':u'A', 'name': 'bar'}} + 'kernel': { + 'id': 'A', + 'name': 'bar', + 'last_activity': dummy_date, + 'execution_state': 'idle', + }} self.assertEqual(model, expected) def test_bad_get_session(self): @@ -102,19 +113,34 @@ def test_list_sessions(self): 'type': 'notebook', 'notebook': {'path': u'/path/to/1/test1.ipynb', 'name': None}, 'name': None, - 'kernel':{'id':u'A', 'name':'python'} + 'kernel': { + 'id': 'A', + 'name':'python', + 'last_activity': dummy_date, + 'execution_state': 'idle', + } }, { 'id':sessions[1]['id'], 'path': u'/path/to/2/test2.py', 'type': 'file', 'name': None, - 'kernel':{'id':u'B', 'name':'python'} + 'kernel': { + 'id': 'B', + 'name':'python', + 'last_activity': dummy_date, + 'execution_state': 'idle', + } }, { 'id':sessions[2]['id'], 'path': u'/path/to/3', 'type': 'console', 'name': 'foo', - 'kernel':{'id':u'C', 'name':'python'} + 'kernel': { + 'id': 'C', + 'name':'python', + 'last_activity': dummy_date, + 'execution_state': 'idle', + } } ] self.assertEqual(sessions, expected) @@ -136,8 +162,10 @@ def test_list_sessions_dead_kernel(self): 'name': None, 'notebook': {'path': u'/path/to/2/test2.ipynb', 'name': None}, 'kernel': { - 'id': u'B', + 'id': 'B', 'name':'python', + 'last_activity': dummy_date, + 'execution_state': 'idle', } } ] @@ -154,7 +182,13 @@ def test_update_session(self): 'type': 'notebook', 'name': None, 'notebook': {'path': u'/path/to/new_name.ipynb', 'name': None}, - 'kernel':{'id':u'A', 'name':'julia'}} + 'kernel': { + 'id': 'A', + 'name':'julia', + 'last_activity': dummy_date, + 'execution_state': 'idle', + } + } self.assertEqual(model, expected) def test_bad_update_session(self): @@ -179,13 +213,23 @@ def test_delete_session(self): 'type': 'notebook', 'name': None, 'notebook': {'path': u'/path/to/1/test1.ipynb', 'name': None}, - 'kernel': {'id':u'A', 'name':'python'} + 'kernel': { + 'id': 'A', + 'name':'python', + 'last_activity': dummy_date, + 'execution_state': 'idle', + } }, { 'id': sessions[2]['id'], 'type': 'console', 'path': u'/path/to/3', 'name': 'foo', - 'kernel': {'id':u'C', 'name':'python'} + 'kernel': { + 'id': 'C', + 'name':'python', + 'last_activity': dummy_date, + 'execution_state': 'idle', + } } ] self.assertEqual(new_sessions, expected) From d45cb2ad80593de619406019bf2ea117d0229296 Mon Sep 17 00:00:00 2001 From: Min RK Date: Sat, 12 Nov 2016 16:33:19 -0800 Subject: [PATCH 2/9] track active kernel connections --- notebook/services/kernels/handlers.py | 2 ++ notebook/services/kernels/kernelmanager.py | 19 +++++++++++++-- .../kernels/tests/test_kernels_api.py | 23 +++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/notebook/services/kernels/handlers.py b/notebook/services/kernels/handlers.py index f72ee11490..4b35f5a14c 100644 --- a/notebook/services/kernels/handlers.py +++ b/notebook/services/kernels/handlers.py @@ -260,6 +260,7 @@ def _register_session(self): def open(self, kernel_id): super(ZMQChannelsHandler, self).open() + self.kernel_manager.notify_connect(kernel_id) try: self.create_stream() except web.HTTPError as e: @@ -401,6 +402,7 @@ def on_close(self): self._open_sessions.pop(self.session_key) km = self.kernel_manager if self.kernel_id in km: + km.notify_disconnect(self.kernel_id) km.remove_restart_callback( self.kernel_id, self.on_kernel_restarted, ) diff --git a/notebook/services/kernels/kernelmanager.py b/notebook/services/kernels/kernelmanager.py index 02c849359b..87656e2af3 100644 --- a/notebook/services/kernels/kernelmanager.py +++ b/notebook/services/kernels/kernelmanager.py @@ -14,7 +14,7 @@ from tornado.ioloop import IOLoop from jupyter_client.multikernelmanager import MultiKernelManager -from traitlets import List, Unicode, TraitError, default, validate +from traitlets import Dict, List, Unicode, TraitError, default, validate from notebook.utils import to_os_path from notebook.services.contents.tz import utcnow @@ -31,6 +31,8 @@ def _default_kernel_manager_class(self): kernel_argv = List(Unicode()) root_dir = Unicode(config=True) + + _kernel_connections = Dict() @default('root_dir') def _default_root_dir(self): @@ -91,6 +93,7 @@ def start_kernel(self, kernel_id=None, path=None, **kwargs): kernel_id = yield gen.maybe_future( super(MappingKernelManager, self).start_kernel(**kwargs) ) + self._kernel_connections[kernel_id] = 0 self.start_watching_activity(kernel_id) self.log.info("Kernel started: %s" % kernel_id) self.log.debug("Kernel args: %r" % kwargs) @@ -109,6 +112,7 @@ def shutdown_kernel(self, kernel_id, now=False): """Shutdown a kernel by kernel_id""" self._check_kernel_id(kernel_id) self._kernels[kernel_id]._activity_stream.close() + self._kernel_connections.pop(kernel_id, None) return super(MappingKernelManager, self).shutdown_kernel(kernel_id, now=now) def restart_kernel(self, kernel_id): @@ -152,6 +156,16 @@ def on_restart_failed(): timeout = loop.add_timeout(loop.time() + 30, on_timeout) return future + def notify_connect(self, kernel_id): + """Notice a new connection to a kernel""" + if kernel_id in self._kernel_connections: + self._kernel_connections[kernel_id] += 1 + + def notify_disconnect(self, kernel_id): + """Notice a disconnection from a kernel""" + if kernel_id in self._kernel_connections: + self._kernel_connections[kernel_id] -= 1 + def kernel_model(self, kernel_id): """Return a JSON-safe dict representing a kernel @@ -165,6 +179,7 @@ def kernel_model(self, kernel_id): "name": kernel.kernel_name, "last_activity": kernel.last_activity, "execution_state": kernel.execution_state, + "connections": self._kernel_connections[kernel_id], } return model @@ -204,7 +219,7 @@ def record_activity(msg_list): idents, fed_msg_list = kernel.session.feed_identities(msg_list) msg = kernel.session.deserialize(fed_msg_list) msg_type = msg['header']['msg_type'] - self.log.info("activity on %s: %s", kernel_id, msg_type) + self.log.debug("activity on %s: %s", kernel_id, msg_type) if msg_type == 'status': kernel.execution_state = msg['content']['execution_state'] diff --git a/notebook/services/kernels/tests/test_kernels_api.py b/notebook/services/kernels/tests/test_kernels_api.py index ee35b9731f..4548d6c953 100644 --- a/notebook/services/kernels/tests/test_kernels_api.py +++ b/notebook/services/kernels/tests/test_kernels_api.py @@ -1,7 +1,10 @@ """Test the kernels service API.""" import json + import requests +from tornado.websocket import websocket_connect +from tornado.ioloop import IOLoop from jupyter_client.kernelspec import NATIVE_KERNEL_NAME @@ -45,6 +48,14 @@ def interrupt(self, id): def restart(self, id): return self._req('POST', url_path_join(id, 'restart')) + def websocket(self, id): + loop = IOLoop() + f = websocket_connect(url_path_join( + self.base_url.replace('http', 'ws', 1), 'api/kernels', id, 'channels'), + io_loop=loop) + return loop.run_sync(lambda : f) + + class KernelAPITest(NotebookTestBase): """Test the kernels web service API""" def setUp(self): @@ -144,3 +155,15 @@ def test_kernel_handler(self): bad_id = '111-111-111-111-111' with assert_http_error(404, 'Kernel does not exist: ' + bad_id): self.kern_api.shutdown(bad_id) + + def test_connections(self): + kid = self.kern_api.start().json()['id'] + model = self.kern_api.get(kid).json() + assert model['connections'] == 0 + + ws = self.kern_api.websocket(kid) + model = self.kern_api.get(kid).json() + assert model['connections'] == 1 + ws.close() + model = self.kern_api.get(kid).json() + assert model['connections'] == 0 From 9f55a857f32e9ec484d3faf1d449dfb40ff54d28 Mon Sep 17 00:00:00 2001 From: Min RK Date: Sat, 12 Nov 2016 16:45:56 -0800 Subject: [PATCH 3/9] use Z isoformat in UTC timestamps instead of +00:00 --- notebook/services/contents/tz.py | 22 ++++++++----------- notebook/services/kernels/kernelmanager.py | 4 ++-- .../sessions/tests/test_sessionmanager.py | 20 ++++++++--------- .../sessions/tests/test_sessions_api.py | 5 +++++ 4 files changed, 26 insertions(+), 25 deletions(-) diff --git a/notebook/services/contents/tz.py b/notebook/services/contents/tz.py index b315d532d1..1df39e51d4 100644 --- a/notebook/services/contents/tz.py +++ b/notebook/services/contents/tz.py @@ -5,22 +5,11 @@ Just UTC-awareness right now """ -#----------------------------------------------------------------------------- -# Copyright (C) 2013 The IPython Development Team -# -# Distributed under the terms of the BSD License. The full license is in -# the file COPYING, distributed as part of this software. -#----------------------------------------------------------------------------- - -#----------------------------------------------------------------------------- -# Imports -#----------------------------------------------------------------------------- +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. from datetime import tzinfo, timedelta, datetime -#----------------------------------------------------------------------------- -# Code -#----------------------------------------------------------------------------- # constant for zero offset ZERO = timedelta(0) @@ -44,3 +33,10 @@ def utc_method(*args, **kwargs): utcfromtimestamp = utc_aware(datetime.utcfromtimestamp) utcnow = utc_aware(datetime.utcnow) + +def isoformat(dt): + """Return iso-formatted timestamp + + Like .isoformat(), but uses Z for UTC instead of +00:00 + """ + return dt.isoformat().replace('+00:00', 'Z') diff --git a/notebook/services/kernels/kernelmanager.py b/notebook/services/kernels/kernelmanager.py index 87656e2af3..e561d39832 100644 --- a/notebook/services/kernels/kernelmanager.py +++ b/notebook/services/kernels/kernelmanager.py @@ -17,7 +17,7 @@ from traitlets import Dict, List, Unicode, TraitError, default, validate from notebook.utils import to_os_path -from notebook.services.contents.tz import utcnow +from notebook.services.contents.tz import utcnow, isoformat from ipython_genutils.py3compat import getcwd @@ -177,7 +177,7 @@ def kernel_model(self, kernel_id): model = { "id":kernel_id, "name": kernel.kernel_name, - "last_activity": kernel.last_activity, + "last_activity": isoformat(kernel.last_activity), "execution_state": kernel.execution_state, "connections": self._kernel_connections[kernel_id], } diff --git a/notebook/services/sessions/tests/test_sessionmanager.py b/notebook/services/sessions/tests/test_sessionmanager.py index 1e02026158..d8c8a28e38 100644 --- a/notebook/services/sessions/tests/test_sessionmanager.py +++ b/notebook/services/sessions/tests/test_sessionmanager.py @@ -9,14 +9,14 @@ from ..sessionmanager import SessionManager from notebook.services.kernels.kernelmanager import MappingKernelManager from notebook.services.contents.manager import ContentsManager -from notebook.services.contents.tz import utcnow +from notebook.services.contents.tz import utcnow, isoformat class DummyKernel(object): def __init__(self, kernel_name='python'): self.kernel_name = kernel_name dummy_date = utcnow() -dummy_date_s = dummy_date.isoformat() +dummy_date_s = isoformat(dummy_date) class DummyMKM(MappingKernelManager): """MappingKernelManager interface that doesn't start kernels, for testing""" @@ -74,7 +74,7 @@ def test_get_session(self): 'kernel': { 'id': 'A', 'name': 'bar', - 'last_activity': dummy_date, + 'last_activity': dummy_date_s, 'execution_state': 'idle', }} self.assertEqual(model, expected) @@ -116,7 +116,7 @@ def test_list_sessions(self): 'kernel': { 'id': 'A', 'name':'python', - 'last_activity': dummy_date, + 'last_activity': dummy_date_s, 'execution_state': 'idle', } }, { @@ -127,7 +127,7 @@ def test_list_sessions(self): 'kernel': { 'id': 'B', 'name':'python', - 'last_activity': dummy_date, + 'last_activity': dummy_date_s, 'execution_state': 'idle', } }, { @@ -138,7 +138,7 @@ def test_list_sessions(self): 'kernel': { 'id': 'C', 'name':'python', - 'last_activity': dummy_date, + 'last_activity': dummy_date_s, 'execution_state': 'idle', } } @@ -164,7 +164,7 @@ def test_list_sessions_dead_kernel(self): 'kernel': { 'id': 'B', 'name':'python', - 'last_activity': dummy_date, + 'last_activity': dummy_date_s, 'execution_state': 'idle', } } @@ -185,7 +185,7 @@ def test_update_session(self): 'kernel': { 'id': 'A', 'name':'julia', - 'last_activity': dummy_date, + 'last_activity': dummy_date_s, 'execution_state': 'idle', } } @@ -216,7 +216,7 @@ def test_delete_session(self): 'kernel': { 'id': 'A', 'name':'python', - 'last_activity': dummy_date, + 'last_activity': dummy_date_s, 'execution_state': 'idle', } }, { @@ -227,7 +227,7 @@ def test_delete_session(self): 'kernel': { 'id': 'C', 'name':'python', - 'last_activity': dummy_date, + 'last_activity': dummy_date_s, 'execution_state': 'idle', } } diff --git a/notebook/services/sessions/tests/test_sessions_api.py b/notebook/services/sessions/tests/test_sessions_api.py index dba1417d6b..a947316df9 100644 --- a/notebook/services/sessions/tests/test_sessions_api.py +++ b/notebook/services/sessions/tests/test_sessions_api.py @@ -225,6 +225,8 @@ def test_modify_kernel_name(self): r = self.request('GET', 'api/kernels') r.raise_for_status() kernel_list = r.json() + after['kernel'].pop('last_activity') + [ k.pop('last_activity') for k in kernel_list ] self.assertEqual(kernel_list, [after['kernel']]) def test_modify_kernel_id(self): @@ -248,4 +250,7 @@ def test_modify_kernel_id(self): r = self.request('GET', 'api/kernels') r.raise_for_status() kernel_list = r.json() + + kernel.pop('last_activity') + [ k.pop('last_activity') for k in kernel_list ] self.assertEqual(kernel_list, [kernel]) From bec396304bb17a0ab8e1f1744b9066794cf26d03 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 18 Nov 2016 11:59:48 +0100 Subject: [PATCH 4/9] add connections to sessions API tests --- notebook/services/sessions/tests/test_sessionmanager.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/notebook/services/sessions/tests/test_sessionmanager.py b/notebook/services/sessions/tests/test_sessionmanager.py index d8c8a28e38..860cf4a928 100644 --- a/notebook/services/sessions/tests/test_sessionmanager.py +++ b/notebook/services/sessions/tests/test_sessionmanager.py @@ -30,6 +30,7 @@ def _new_id(self): def start_kernel(self, kernel_id=None, path=None, kernel_name='python', **kwargs): kernel_id = kernel_id or self._new_id() k = self._kernels[kernel_id] = DummyKernel(kernel_name=kernel_name) + self._kernel_connections[kernel_id] = 0 k.last_activity = dummy_date k.execution_state = 'idle' return kernel_id @@ -74,6 +75,7 @@ def test_get_session(self): 'kernel': { 'id': 'A', 'name': 'bar', + 'connections': 0, 'last_activity': dummy_date_s, 'execution_state': 'idle', }} @@ -116,6 +118,7 @@ def test_list_sessions(self): 'kernel': { 'id': 'A', 'name':'python', + 'connections': 0, 'last_activity': dummy_date_s, 'execution_state': 'idle', } @@ -127,6 +130,7 @@ def test_list_sessions(self): 'kernel': { 'id': 'B', 'name':'python', + 'connections': 0, 'last_activity': dummy_date_s, 'execution_state': 'idle', } @@ -138,6 +142,7 @@ def test_list_sessions(self): 'kernel': { 'id': 'C', 'name':'python', + 'connections': 0, 'last_activity': dummy_date_s, 'execution_state': 'idle', } @@ -164,6 +169,7 @@ def test_list_sessions_dead_kernel(self): 'kernel': { 'id': 'B', 'name':'python', + 'connections': 0, 'last_activity': dummy_date_s, 'execution_state': 'idle', } @@ -185,6 +191,7 @@ def test_update_session(self): 'kernel': { 'id': 'A', 'name':'julia', + 'connections': 0, 'last_activity': dummy_date_s, 'execution_state': 'idle', } @@ -216,6 +223,7 @@ def test_delete_session(self): 'kernel': { 'id': 'A', 'name':'python', + 'connections': 0, 'last_activity': dummy_date_s, 'execution_state': 'idle', } @@ -227,6 +235,7 @@ def test_delete_session(self): 'kernel': { 'id': 'C', 'name':'python', + 'connections': 0, 'last_activity': dummy_date_s, 'execution_state': 'idle', } From 8b76de80beb8ada4a6df60cc86d20544eecfbba4 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 18 Nov 2016 12:19:06 +0100 Subject: [PATCH 5/9] move tz utils to top-level private module ._tz --- notebook/{services/contents/tz.py => _tz.py} | 0 notebook/services/contents/filecheckpoints.py | 3 ++- notebook/services/contents/filemanager.py | 3 ++- notebook/services/kernels/kernelmanager.py | 2 +- notebook/services/sessions/tests/test_sessionmanager.py | 2 +- 5 files changed, 6 insertions(+), 4 deletions(-) rename notebook/{services/contents/tz.py => _tz.py} (100%) diff --git a/notebook/services/contents/tz.py b/notebook/_tz.py similarity index 100% rename from notebook/services/contents/tz.py rename to notebook/_tz.py diff --git a/notebook/services/contents/filecheckpoints.py b/notebook/services/contents/filecheckpoints.py index 46e32eadc8..1cf83cfe08 100644 --- a/notebook/services/contents/filecheckpoints.py +++ b/notebook/services/contents/filecheckpoints.py @@ -12,11 +12,12 @@ ) from .fileio import FileManagerMixin -from . import tz from ipython_genutils.path import ensure_dir_exists from ipython_genutils.py3compat import getcwd from traitlets import Unicode +from notebook import _tz as tz + class FileCheckpoints(FileManagerMixin, Checkpoints): """ diff --git a/notebook/services/contents/filemanager.py b/notebook/services/contents/filemanager.py index cd86343139..d978d553f0 100644 --- a/notebook/services/contents/filemanager.py +++ b/notebook/services/contents/filemanager.py @@ -22,7 +22,8 @@ from ipython_genutils.importstring import import_item from traitlets import Any, Unicode, Bool, TraitError, observe, default, validate from ipython_genutils.py3compat import getcwd, string_types -from . import tz + +from notebook import _tz as tz from notebook.utils import ( is_hidden, is_file_hidden, to_api_path, diff --git a/notebook/services/kernels/kernelmanager.py b/notebook/services/kernels/kernelmanager.py index e561d39832..b5c4c9f5d2 100644 --- a/notebook/services/kernels/kernelmanager.py +++ b/notebook/services/kernels/kernelmanager.py @@ -17,7 +17,7 @@ from traitlets import Dict, List, Unicode, TraitError, default, validate from notebook.utils import to_os_path -from notebook.services.contents.tz import utcnow, isoformat +from notebook._tz import utcnow, isoformat from ipython_genutils.py3compat import getcwd diff --git a/notebook/services/sessions/tests/test_sessionmanager.py b/notebook/services/sessions/tests/test_sessionmanager.py index 860cf4a928..96847a868a 100644 --- a/notebook/services/sessions/tests/test_sessionmanager.py +++ b/notebook/services/sessions/tests/test_sessionmanager.py @@ -9,7 +9,7 @@ from ..sessionmanager import SessionManager from notebook.services.kernels.kernelmanager import MappingKernelManager from notebook.services.contents.manager import ContentsManager -from notebook.services.contents.tz import utcnow, isoformat +from notebook._tz import utcnow, isoformat class DummyKernel(object): def __init__(self, kernel_name='python'): From 32a353378ae64f06a7a663d1849b55550540cea4 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 18 Nov 2016 13:22:15 +0100 Subject: [PATCH 6/9] track REST API activity add /api/status endpoint for retrieving current status includes - started: start time of the server - last_activity: latest activity across all endpoints - connections: number of current connections - kernels: number of current kernels --- notebook/base/handlers.py | 13 ++++++++- notebook/notebookapp.py | 6 +++- notebook/services/api/api.yaml | 34 +++++++++++++++++++++ notebook/services/api/handlers.py | 39 +++++++++++++++++++++++-- notebook/services/api/tests/__init__.py | 0 notebook/services/api/tests/test_api.py | 32 ++++++++++++++++++++ notebook/services/security/handlers.py | 2 ++ 7 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 notebook/services/api/tests/__init__.py create mode 100644 notebook/services/api/tests/test_api.py diff --git a/notebook/base/handlers.py b/notebook/base/handlers.py index 5082ac6eee..2c48d5cc14 100755 --- a/notebook/base/handlers.py +++ b/notebook/base/handlers.py @@ -30,6 +30,7 @@ from ipython_genutils.py3compat import string_types import notebook +from notebook._tz import utcnow from notebook.utils import is_hidden, url_path_join, url_is_absolute, url_escape from notebook.services.security import csp_report_uri @@ -432,8 +433,18 @@ def content_security_policy(self): "default-src 'none'", ]) return csp - + + # set _track_activity = False on API handlers that shouldn't track activity + _track_activity = True + + def update_api_activity(self): + """Update last_activity of API requests""" + # record activity of authenticated requests + if self._track_activity and self.get_current_user(): + self.settings['api_last_activity'] = utcnow() + def finish(self, *args, **kwargs): + self.update_api_activity() self.set_header('Content-Type', 'application/json') return super(APIHandler, self).finish(*args, **kwargs) diff --git a/notebook/notebookapp.py b/notebook/notebookapp.py index ecc36608f6..5c754e2a0f 100755 --- a/notebook/notebookapp.py +++ b/notebook/notebookapp.py @@ -97,6 +97,7 @@ from jupyter_core.paths import jupyter_runtime_dir, jupyter_path from notebook._sysinfo import get_sys_info +from ._tz import utcnow from .utils import url_path_join, check_pid, url_escape #----------------------------------------------------------------------------- @@ -198,6 +199,8 @@ def init_settings(self, jupyter_app, kernel_manager, contents_manager, working on the notebook's Javascript and LESS""") warnings.warn("The `ignore_minified_js` flag is deprecated and will be removed in Notebook 6.0", DeprecationWarning) + now = utcnow() + settings = dict( # basics log_function=log_request, @@ -236,7 +239,8 @@ def init_settings(self, jupyter_app, kernel_manager, contents_manager, kernel_spec_manager=kernel_spec_manager, config_manager=config_manager, - # IPython stuff + # Jupyter stuff + started=now, jinja_template_vars=jupyter_app.jinja_template_vars, nbextensions_path=jupyter_app.nbextensions_path, websocket_url=jupyter_app.websocket_url, diff --git a/notebook/services/api/api.yaml b/notebook/services/api/api.yaml index 1fad58f151..a0bdbb2dc8 100644 --- a/notebook/services/api/api.yaml +++ b/notebook/services/api/api.yaml @@ -617,7 +617,36 @@ paths: + /status: + get: + summary: Get the current status / activity of the server + responses: + 200: + description: The current status of the server + $ref: '#/definitions/APIStatus' definitions: + APIStatus: + description: | + Notebook server API status. + Added in notebook 5.0. + properties: + started: + type: string + description: | + ISO8601 timestamp indicating when the notebook server started. + last_activity: + type: string + description: | + ISO8601 timestamp indicating the last activity on the server, + either on the REST API or kernel activity. + connections: + type: number + description: | + The total number of currently open connections to kernels. + kernels: + type: number + description: | + The total number of running kernels. KernelSpec: description: Kernel spec (contents of kernel.json) properties: @@ -703,7 +732,12 @@ definitions: ISO 8601 timestamp for the last-seen activity on this kernel. Use this in combination with execution_state == 'idle' to identify which kernels have been idle since a given time. + Timestamps will be UTC, indicated 'Z' suffix. Added in notebook server 5.0. + connections: + type: number + description: | + The number of active connections to this kernel. execution_state: type: string description: | diff --git a/notebook/services/api/handlers.py b/notebook/services/api/handlers.py index f092632030..6847348aa4 100644 --- a/notebook/services/api/handlers.py +++ b/notebook/services/api/handlers.py @@ -3,8 +3,14 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. -from tornado import web -from ...base.handlers import IPythonHandler +from itertools import chain +import json + +from tornado import gen, web + +from ...base.handlers import IPythonHandler, APIHandler, json_errors +from notebook._tz import utcfromtimestamp, isoformat + import os class APISpecHandler(web.StaticFileHandler, IPythonHandler): @@ -18,6 +24,33 @@ def get(self): self.set_header('Content-Type', 'text/x-yaml') return web.StaticFileHandler.get(self, 'api.yaml') +class APIStatusHandler(APIHandler): + + _track_activity = False + + @json_errors + @web.authenticated + @gen.coroutine + def get(self): + # if started was missing, use unix epoch + started = self.settings.get('started', utcfromtimestamp(0)) + # if we've never seen API activity, use started date + api_last_activity = self.settings.get('api_last_activity', started) + started = isoformat(started) + api_last_activity = isoformat(api_last_activity) + + kernels = yield gen.maybe_future(self.kernel_manager.list_kernels()) + total_connections = sum(k['connections'] for k in kernels) + last_activity = max(chain([api_last_activity], [k['last_activity'] for k in kernels])) + model = { + 'started': started, + 'last_activity': last_activity, + 'kernels': len(kernels), + 'connections': total_connections, + } + self.finish(json.dumps(model, sort_keys=True)) + default_handlers = [ - (r"/api/spec.yaml", APISpecHandler) + (r"/api/spec.yaml", APISpecHandler), + (r"/api/status", APIStatusHandler), ] diff --git a/notebook/services/api/tests/__init__.py b/notebook/services/api/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/notebook/services/api/tests/test_api.py b/notebook/services/api/tests/test_api.py new file mode 100644 index 0000000000..5f06076720 --- /dev/null +++ b/notebook/services/api/tests/test_api.py @@ -0,0 +1,32 @@ +"""Test the basic /api endpoints""" + +import requests + +from notebook._tz import isoformat +from notebook.utils import url_path_join +from notebook.tests.launchnotebook import NotebookTestBase + + +class KernelAPITest(NotebookTestBase): + """Test the kernels web service API""" + + def _req(self, verb, path, **kwargs): + r = requests.request(verb, url_path_join(self.base_url(), 'api', path)) + r.raise_for_status() + return r + + def get(self, path, **kwargs): + return self._req('GET', path) + + def test_get_spec(self): + r = self.get('spec.yaml') + assert r.text + + def test_get_status(self): + r = self.get('status') + data = r.json() + assert data['connections'] == 0 + assert data['kernels'] == 0 + assert data['last_activity'].endswith('Z') + assert data['started'].endswith('Z') + assert data['started'] == isoformat(self.notebook.web_app.settings['started']) diff --git a/notebook/services/security/handlers.py b/notebook/services/security/handlers.py index 36da227dd9..f20e18058e 100644 --- a/notebook/services/security/handlers.py +++ b/notebook/services/security/handlers.py @@ -11,6 +11,8 @@ class CSPReportHandler(APIHandler): '''Accepts a content security policy violation report''' + _track_activity = False + def skip_origin_check(self): """Don't check origin when reporting origin-check violations!""" return True From 75a0d71c28a3d66e99c5d3b8b7038d6d28885a65 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 18 Nov 2016 14:21:53 +0100 Subject: [PATCH 7/9] give the server a second to notice that the websocket closed because travis is slow --- .../services/kernels/tests/test_kernels_api.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/notebook/services/kernels/tests/test_kernels_api.py b/notebook/services/kernels/tests/test_kernels_api.py index 4548d6c953..3b97ec7303 100644 --- a/notebook/services/kernels/tests/test_kernels_api.py +++ b/notebook/services/kernels/tests/test_kernels_api.py @@ -1,6 +1,7 @@ """Test the kernels service API.""" import json +import time import requests from tornado.websocket import websocket_connect @@ -159,11 +160,18 @@ def test_kernel_handler(self): def test_connections(self): kid = self.kern_api.start().json()['id'] model = self.kern_api.get(kid).json() - assert model['connections'] == 0 + self.assertEqual(model['connections'], 0) ws = self.kern_api.websocket(kid) model = self.kern_api.get(kid).json() - assert model['connections'] == 1 + self.assertEqual(model['connections'], 1) ws.close() + # give it some time to close on the other side: + for i in range(10): + model = self.kern_api.get(kid).json() + if model['connections'] > 0: + time.sleep(0.1) + else: + break model = self.kern_api.get(kid).json() - assert model['connections'] == 0 + self.assertEqual(model['connections'], 0) From 2a5f8d2e175e1d1341c83dcebd82be1191b1f556 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 20 Jan 2017 13:02:49 -1000 Subject: [PATCH 8/9] add token auth to kernel websocket test --- .../kernels/tests/test_kernels_api.py | 21 ++++++++++++------- notebook/tests/launchnotebook.py | 14 +++++++++---- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/notebook/services/kernels/tests/test_kernels_api.py b/notebook/services/kernels/tests/test_kernels_api.py index 3b97ec7303..5358a20947 100644 --- a/notebook/services/kernels/tests/test_kernels_api.py +++ b/notebook/services/kernels/tests/test_kernels_api.py @@ -3,9 +3,9 @@ import json import time -import requests -from tornado.websocket import websocket_connect +from tornado.httpclient import HTTPRequest from tornado.ioloop import IOLoop +from tornado.websocket import websocket_connect from jupyter_client.kernelspec import NATIVE_KERNEL_NAME @@ -14,8 +14,10 @@ class KernelAPI(object): """Wrapper for kernel REST API requests""" - def __init__(self, request): + def __init__(self, request, base_url, headers): self.request = request + self.base_url = base_url + self.headers = headers def _req(self, verb, path, body=None): response = self.request(verb, @@ -51,16 +53,21 @@ def restart(self, id): def websocket(self, id): loop = IOLoop() - f = websocket_connect(url_path_join( - self.base_url.replace('http', 'ws', 1), 'api/kernels', id, 'channels'), - io_loop=loop) + req = HTTPRequest( + url_path_join(self.base_url.replace('http', 'ws', 1), 'api/kernels', id, 'channels'), + headers=self.headers, + ) + f = websocket_connect(req, io_loop=loop) return loop.run_sync(lambda : f) class KernelAPITest(NotebookTestBase): """Test the kernels web service API""" def setUp(self): - self.kern_api = KernelAPI(self.request) + self.kern_api = KernelAPI(self.request, + base_url=self.base_url(), + headers=self.auth_headers(), + ) def tearDown(self): for k in self.kern_api.list().json(): diff --git a/notebook/tests/launchnotebook.py b/notebook/tests/launchnotebook.py index 9ffe34989b..146f633144 100644 --- a/notebook/tests/launchnotebook.py +++ b/notebook/tests/launchnotebook.py @@ -72,16 +72,22 @@ def wait_until_dead(cls): raise TimeoutError("Undead notebook server") @classmethod - def request(self, verb, path, **kwargs): + def auth_headers(cls): + headers = {} + if cls.token: + headers['Authorization'] = 'token %s' % cls.token + return headers + + @classmethod + def request(cls, verb, path, **kwargs): """Send a request to my server with authentication and everything. """ headers = kwargs.setdefault('headers', {}) - # kwargs.setdefault('allow_redirects', False) - headers.setdefault('Authorization', 'token %s' % self.token) + headers.update(cls.auth_headers()) response = requests.request(verb, - url_path_join(self.base_url(), path), + url_path_join(cls.base_url(), path), **kwargs) return response From dc873f12908decedcbea30e8dc5813d36b20f4e4 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 20 Jan 2017 13:08:57 -1000 Subject: [PATCH 9/9] use self.request in status test --- notebook/services/api/tests/test_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notebook/services/api/tests/test_api.py b/notebook/services/api/tests/test_api.py index 5f06076720..0a48b793e6 100644 --- a/notebook/services/api/tests/test_api.py +++ b/notebook/services/api/tests/test_api.py @@ -11,7 +11,7 @@ class KernelAPITest(NotebookTestBase): """Test the kernels web service API""" def _req(self, verb, path, **kwargs): - r = requests.request(verb, url_path_join(self.base_url(), 'api', path)) + r = self.request(verb, url_path_join('api', path)) r.raise_for_status() return r