Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add lock to PerspectiveManager #999

Merged
merged 2 commits into from
Apr 6, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This language simplifies things a bit :)

"""
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