Skip to content

Commit

Permalink
Merge pull request #999 from finos/tornado-lock
Browse files Browse the repository at this point in the history
Add `lock` to PerspectiveManager
  • Loading branch information
texodus authored Apr 6, 2020
2 parents 761d47d + 93f61ee commit e0cf84c
Show file tree
Hide file tree
Showing 4 changed files with 328 additions and 7 deletions.
89 changes: 89 additions & 0 deletions python/perspective/examples/perspective_manager_lock.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<!--
Copyright (c) 2017, the Perspective Authors.
This file is part of the Perspective library, distributed under the terms of
the Apache License 2.0. The full license can be found in the LICENSE file.
-->

<!DOCTYPE html>
<html>

<head>

<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no">

<script src="https://unpkg.com/@finos/perspective-viewer"></script>
<script src="https://unpkg.com/@finos/perspective-viewer-datagrid"></script>
<script src="https://unpkg.com/@finos/perspective-viewer-d3fc"></script>
<script src="https://unpkg.com/@finos/perspective"></script>>

<link rel='stylesheet' href="https://unpkg.com/@finos/perspective-viewer/dist/umd/material.dark.css">

<style>
perspective-viewer{position:absolute;top:0;left:0;right:0;bottom:0;}
</style>

</head>

<body>

<perspective-viewer id="viewer"></perspective-viewer>

<script>

window.addEventListener('WebComponentsReady', async function() {
const viewer = document.getElementById('viewer');

// Create a client that expects a Perspective server to accept connections at the specified URL.
const websocket = perspective.websocket("ws://localhost:8888/websocket");

/* `table` is a proxy for the `Table` we created on the server.
All operations that are possible through the Javascript API are possible on the Python API as well,
thus calling `view()`, `schema()`, `update()` etc on `const table` will pass those operations to the
Python `Table`, execute the commands, and return the result back to Javascript.
*/
const table = websocket.open_table('data_source_one');

// Load this in the `<perspective-viewer>`.
viewer.load(table);
viewer.toggleConfig();

console.log("before update", await table.size());

// Attempt to update the table with new data - it should be
// blocked because the Manager is locked
table.update({
"name": "test name",
"client": "test client",
"open": 100,
"high": 120,
"low": 90,
"close": 110,
"lastUpdate": new Date()
})

console.log("after update", await table.size());

// `clear` should be blocked
await table.clear();

// so should `replace`
await table.replace({
"name": "test name",
"client": "test client",
"open": 100,
"high": 120,
"low": 90,
"close": 110,
"lastUpdate": new Date()
})
});

</script>

</body>

</html>
78 changes: 78 additions & 0 deletions python/perspective/examples/perspective_manager_lock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import os
import os.path
import random
import sys
import logging
import tornado.websocket
import tornado.web
import tornado.ioloop
from datetime import datetime

sys.path.insert(1, os.path.join(os.path.dirname(__file__), '..'))
from perspective import Table, PerspectiveManager, PerspectiveTornadoHandler


class MainHandler(tornado.web.RequestHandler):

def set_default_headers(self):
self.set_header("Access-Control-Allow-Origin", "*")
self.set_header("Access-Control-Allow-Headers", "x-requested-with")
self.set_header('Access-Control-Allow-Methods', 'POST, GET, OPTIONS')

def get(self):
self.render("perspective_manager_lock.html")


def data_source(n=5):
rows = []
modifier = random.random() * random.randint(1, 50)
for i in range(n):
rows.append({
"name": SECURITIES[random.randint(0, len(SECURITIES) - 1)],
"client": CLIENTS[random.randint(0, len(CLIENTS) - 1)],
"open": (random.random() * 75 + random.randint(0, 9)) * modifier,
"high": (random.random() * 105 + random.randint(1, 3)) * modifier,
"low": (random.random() * 85 + random.randint(1, 3)) * modifier,
"close": (random.random() * 90 + random.randint(1, 3)) * modifier,
"lastUpdate": datetime.now()
})
return rows


'''Set up our data for this example.'''
SECURITIES = ["AAPL.N", "AMZN.N", "QQQ.N", "NVDA.N", "TSLA.N", "FB.N", "MSFT.N", "TLT.N", "XIV.N", "YY.N", "CSCO.N", "GOOGL.N", "PCLN.N"]
CLIENTS = ["Homer", "Marge", "Bart", "Lisa", "Maggie", "Moe", "Lenny", "Carl", "Krusty"]


def make_app():
# Create an instance of `PerspectiveManager` and a table.
MANAGER = PerspectiveManager(lock=True)
TABLE = Table({
"name": str,
"client": str,
"open": float,
"high": float,
"low": float,
"close": float,
"lastUpdate": datetime,
})

TABLE.update(data_source(100))

# Track the table with the name "data_source_one", which will be used in
# the front-end to access the Table.
MANAGER.host_table("data_source_one", TABLE)

return tornado.web.Application([
(r"/", MainHandler),
# create a websocket endpoint that the client Javascript can access
(r"/websocket", PerspectiveTornadoHandler, {"manager": MANAGER, "check_origin": True})
])


if __name__ == "__main__":
app = make_app()
app.listen(8888)
logging.critical("Listening on http://localhost:8888")
loop = tornado.ioloop.IOLoop.current()
loop.start()
53 changes: 47 additions & 6 deletions python/perspective/perspective/manager/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,6 @@
from ..table.view import View
from .session import PerspectiveSession


def gen_name(size=10, chars=string.ascii_uppercase + string.digits):
return "".join(random.choice(chars) for x in range(size))


_date_validator = _PerspectiveDateValidator()


Expand All @@ -36,6 +31,10 @@ def default(self, obj):
return super(DateTimeEncoder, self).default(obj)


def gen_name(size=10, chars=string.ascii_uppercase + string.digits):
return "".join(random.choice(chars) for x in range(size))


class PerspectiveManager(object):
'''PerspectiveManager is an orchestrator for running Perspective on the
server side.
Expand All @@ -57,11 +56,31 @@ class PerspectiveManager(object):
clean up associated resources.
'''

def __init__(self):
# Commands that should be blocked from execution when the manager is in
# `locked` mode, i.e. its tables and views made immutable from remote
# modification.
LOCKED_COMMANDS = ["table", "update", "remove", "replace", "clear"]

def __init__(self, lock=False):
self._tables = {}
self._views = {}
self._callback_cache = _PerspectiveCallBackCache()
self._queue_process_callback = None
self._lock = lock

def lock(self):
"""Block messages that can mutate the state of `Table`s and `View`s
under management.
All `PerspectiveManager`s exposed over the internet should be locked to
prevent content from being mutated by clients.
"""
self._lock = True

def unlock(self):
"""Unblock messages that can mutate the state of `Table`s and `View`s
under management."""
self._lock = False

def host(self, item, name=None):
"""Given a :obj:`~perspective.Table` or :obj:`~perspective.View`,
Expand Down Expand Up @@ -154,6 +173,13 @@ def _process(self, msg, post_callback, client_id=None):

cmd = msg["cmd"]

if self._is_locked_command(msg) is True:
error_message = "`{0}` failed - access denied".format(
msg["cmd"] + (("." + msg["method"]) if msg.get("method", None) is not None else ""))
post_callback(json.dumps(self._make_error_message(
msg["id"], error_message), cls=DateTimeEncoder))
return

try:
if cmd == "init":
# return empty response
Expand Down Expand Up @@ -362,3 +388,18 @@ def _make_error_message(self, id, error):
"id": id,
"error": error
}

def _is_locked_command(self, msg):
'''Returns `True` if the manager instance is locked and the command
is in `PerspectiveManager.LOCKED_COMMANDS`, and `False` otherwise.'''
if not self._lock:
return False

cmd = msg["cmd"]
method = msg.get("method", None)

if cmd == "table_method" and method == "delete":
# table.delete is blocked, but view.delete is not
return True

return cmd == "table" or method in PerspectiveManager.LOCKED_COMMANDS
Loading

0 comments on commit e0cf84c

Please sign in to comment.