Merge lp:~ben-hutchings/ensoft-sextant/render_timeout into lp:ensoft-sextant

Proposed by Ben Hutchings
Status: Merged
Approved by: Robert
Approved revision: 29
Merged at revision: 29
Proposed branch: lp:~ben-hutchings/ensoft-sextant/render_timeout
Merge into: lp:ensoft-sextant
Diff against target: 383 lines (+177/-131)
4 files modified
etc/sextant.conf (+4/-0)
src/sextant/__main__.py (+1/-0)
src/sextant/environment.py (+13/-3)
src/sextant/web/server.py (+159/-128)
To merge this branch: bzr merge lp:~ben-hutchings/ensoft-sextant/render_timeout
Reviewer Review Type Date Requested Status
Robert Approve
Review via email: mp+237046@code.launchpad.net

This proposal supersedes a proposal from 2014-10-01.

Commit message

Refactoring of the _render_plot method - now has a more obvious flow of logic (i.e. will always get to the bottom of the function rather than dropping out in one of the many if/elses).

It should be more straightforward to add more query functions - they are now stored in a dictionary which can be easily added to without changing any of the logic.

Description of the change

Refactoring of the _render_plot method - now has a more obvious flow of logic (i.e. will always get to the bottom of the function rather than dropping out in one of the many if/elses).

It should be more straightforward to add more query functions - they are now stored in a dictionary which can be easily added to without changing any of the logic.

The bulk of the new code is in a block at the bottom of the diff - the whole function has been re-written.

Timing out the neo4j query has proved difficult - it apparently needs a header to
be set on the http request with 'max-execution-time': <milliseconds>
(http://www.markhneedham.com/blog/2013/10/17/neo4j-setting-query-timeout/),
but attempts to modify the neo4j restclient library to allow this have been thus
far unsuccessful.

To post a comment you must log in.
Revision history for this message
Robert (rjwills) wrote : Posted in a previous version of this proposal

This is a bigger fix. In the description, you should explain how you've tested it.

Also, have you tested to see what happens if the Neo4J query takes ages? Ideally we want to abort the query if that is what is taking the time.

review: Needs Fixing
Revision history for this message
Robert (rjwills) wrote : Posted in a previous version of this proposal

Just some small stylistic improvements.

review: Needs Fixing
Revision history for this message
Robert (rjwills) :
review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'etc/sextant.conf'
2--- etc/sextant.conf 2014-09-04 13:04:53 +0000
3+++ etc/sextant.conf 2014-10-03 11:50:56 +0000
4@@ -17,3 +17,7 @@
5 server_bind = 127.0.0.1
6 # Port on which to serve Sextant Web
7 #port = 2905
8+
9+[Server]
10+# How long to wait before timing out the render
11+#render_timeout = 15
12
13=== modified file 'src/sextant/__main__.py'
14--- src/sextant/__main__.py 2014-09-30 11:01:00 +0000
15+++ src/sextant/__main__.py 2014-10-03 11:50:56 +0000
16@@ -61,6 +61,7 @@
17 # allowing non-web functionality to work with Python 3.
18 if sys.version_info[0] == 2:
19 from .web import server
20+ server.render_timeout = config.render_timeout
21 else: # twisted won't be available - Python 2 required
22 logging.error('Web server must be run on Python 2.')
23 return
24
25=== modified file 'src/sextant/environment.py'
26--- src/sextant/environment.py 2014-09-03 16:38:00 +0000
27+++ src/sextant/environment.py 2014-10-03 11:50:56 +0000
28@@ -53,7 +53,7 @@
29
30 Config = collections.namedtuple('Config', ['web_port', 'remote_neo4j',
31 'common_cutoff', 'use_ssh_tunnel',
32- 'ssh_user'])
33+ 'ssh_user', 'render_timeout'])
34
35
36 def load_config(check_here=''):
37@@ -109,7 +109,8 @@
38 'port': '2905',
39 'common_function_calls': '10',
40 'use_ssh_tunnel': 'False',
41- 'ssh_username': ''})
42+ 'ssh_username': '',
43+ 'render_timeout': '15'})
44 # It turns out that getint, getboolean etc want the defaults to be strings.
45 # When getboolean is called and the default is not a string, and the
46 # default is used, it complains that it can't work with non-strings.
47@@ -123,6 +124,7 @@
48 common_def = 0 # definition of a 'common' node
49 use_ssh_tunnel = True
50 ssh_user = ''
51+ render_timeout = 0;
52 try:
53 conf.options('Neo4j')
54 except ConfigParser.NoSectionError:
55@@ -132,6 +134,7 @@
56 common_def = conf.getint('Neo4j', 'common_function_calls')
57 use_ssh_tunnel = conf.getboolean('Neo4j', 'use_ssh_tunnel')
58 ssh_user = conf.get('Neo4j', 'ssh_username')
59+
60 try:
61 global SERVER_BIND
62 SERVER_BIND = conf.get('Web', 'server_bind')
63@@ -139,7 +142,14 @@
64 except ConfigParser.NoSectionError:
65 pass
66
67+ try:
68+ conf.options('Server')
69+ except ConfigParser.NoSectionError:
70+ pass
71+ else:
72+ render_timeout = conf.getint('Server', 'render_timeout')
73+
74 return Config(remote_neo4j=remote_neo4j, web_port=web_port,
75 common_cutoff=common_def, use_ssh_tunnel=use_ssh_tunnel,
76- ssh_user=ssh_user)
77+ ssh_user=ssh_user, render_timeout=render_timeout)
78
79
80=== modified file 'src/sextant/web/server.py'
81--- src/sextant/web/server.py 2014-09-05 12:25:37 +0000
82+++ src/sextant/web/server.py 2014-10-03 11:50:56 +0000
83@@ -28,6 +28,32 @@
84
85 database_url = None # the URL to access the database instance
86
87+RESPONSE_CODE_OK = 200
88+RESPONSE_CODE_BAD_REQUEST = 400
89+RESPONSE_CODE_NOT_FOUND = 404
90+RESPONSE_CODE_BAD_GATEWAY = 502
91+RESPONSE_CODE_NO_CONTENT = 204
92+
93+def set_timeout(deferred, timeout=5.0):
94+ # cancel the given deferred instance after timeout seconds
95+
96+ timeout = reactor.callLater(timeout, deferred.cancel)
97+
98+ def got_result(result):
99+ # if the deferred returns before the timeout calls, cancel the timeout
100+ # and return the result of the deferred
101+ if timeout.active():
102+ timeout.cancel()
103+
104+ return result
105+
106+ return deferred.addBoth(got_result)
107+
108+def defer_to_thread_with_timeout(timeout, function, *args, **kwargs):
109+ result = deferToThread(function, *args, **kwargs)
110+ return set_timeout(result, timeout)
111+
112+
113 class Echoer(Resource):
114 # designed to take one name argument
115
116@@ -78,134 +104,139 @@
117
118 @defer.inlineCallbacks
119 def _render_plot(self, request):
120- if "program_name" not in request.args:
121- request.setResponseCode(400)
122- request.write("Supply 'program_name' parameter.")
123- request.finish()
124- defer.returnValue(None)
125-
126- name = request.args["program_name"][0]
127-
128- try:
129- suppress_common = request.args["suppress_common"][0]
130- except KeyError:
131- suppress_common = False
132-
133- if suppress_common == 'null' or suppress_common == 'true':
134- suppress_common = True
135- else:
136- suppress_common = False
137-
138- try:
139- neo4jconnection = yield deferToThread(self.create_neo4j_connection)
140- except requests.exceptions.ConnectionError:
141- request.setResponseCode(502) # Bad Gateway
142- request.write("Could not reach Neo4j server at {}".format(database_url))
143- request.finish()
144- defer.returnValue(None)
145- neo4jconnection = None # to silence the "referenced before assignment" warnings later
146-
147- exists = yield deferToThread(self.check_program_exists, neo4jconnection, name)
148- if not exists:
149- request.setResponseCode(404)
150- logging.debug('returning nonexistent')
151- request.write("Name %s not found." % (escape(name)))
152- request.finish()
153- defer.returnValue(None)
154-
155- allowed_queries = ("whole_program", "functions_calling", "functions_called_by", "all_call_paths", "shortest_call_path")
156-
157- if "query" not in request.args:
158- query = "whole_program"
159- else:
160- query = request.args["query"][0]
161-
162- if query not in allowed_queries:
163- # raise 400 Bad Request error
164- request.setResponseCode(400)
165- request.write("Supply 'query' parameter, default is whole_program, allowed %s." % str(allowed_queries))
166- request.finish()
167- defer.returnValue(None)
168-
169- if query == 'whole_program':
170- program = yield deferToThread(self.get_whole_program, neo4jconnection, name)
171- elif query == 'functions_calling':
172- if 'func1' not in request.args:
173- # raise 400 Bad Request error
174- request.setResponseCode(400)
175- request.write("Supply 'func1' parameter to functions_calling.")
176- request.finish()
177- defer.returnValue(None)
178- func1 = request.args['func1'][0]
179- program = yield deferToThread(self.get_functions_calling, neo4jconnection, name, func1)
180- elif query == 'functions_called_by':
181- if 'func1' not in request.args:
182- # raise 400 Bad Request error
183- request.setResponseCode(400)
184- request.write("Supply 'func1' parameter to functions_called_by.")
185- request.finish()
186- defer.returnValue(None)
187- func1 = request.args['func1'][0]
188- program = yield deferToThread(neo4jconnection.get_all_functions_called, name, func1)
189- elif query == 'all_call_paths':
190- if 'func1' not in request.args:
191- # raise 400 Bad Request error
192- request.setResponseCode(400)
193- request.write("Supply 'func1' parameter to all_call_paths.")
194- request.finish()
195- defer.returnValue(None)
196- if 'func2' not in request.args:
197- # raise 400 Bad Request error
198- request.setResponseCode(400)
199- request.write("Supply 'func2' parameter to call_paths.")
200- request.finish()
201- defer.returnValue(None)
202-
203- func1 = request.args['func1'][0]
204- func2 = request.args['func2'][0]
205- program = yield deferToThread(neo4jconnection.get_call_paths, name, func1, func2)
206- elif query == 'shortest_call_path':
207- if 'func1' not in request.args:
208- # raise 400 Bad Request error
209- request.setResponseCode(400)
210- request.write("Supply 'func1' parameter to shortest_call_path.")
211- request.finish()
212- defer.returnValue(None)
213- if 'func2' not in request.args:
214- # raise 400 Bad Request error
215- request.setResponseCode(400)
216- request.write("Supply 'func2' parameter to shortest_call_path.")
217- request.finish()
218- defer.returnValue(None)
219-
220- func1 = request.args['func1'][0]
221- func2 = request.args['func2'][0]
222- program = yield deferToThread(neo4jconnection.get_shortest_path_between_functions, name, func1, func2)
223-
224- else: # unrecognised query, so we need to raise a Bad Request response
225- request.setResponseCode(400)
226- request.write("Query %s not recognised." % escape(query))
227- request.finish()
228- defer.returnValue(None)
229- program = None # silences the "referenced before assignment" warnings
230-
231- if program is None:
232- request.setResponseCode(404)
233- request.write("At least one of the input functions was not found in program %s." % (escape(name)))
234- request.finish()
235- defer.returnValue(None)
236-
237- logging.debug('getting plot')
238- if not program.functions: # we got an empty program back: the program is in the Sextant but has no functions
239- request.setResponseCode(204)
240- request.finish()
241- defer.returnValue(None)
242- output = yield deferToThread(self.get_plot, program, suppress_common,
243- remove_self_calls=False)
244- request.setHeader("content-type", "image/svg+xml")
245-
246- logging.debug('SVG: return')
247- request.write(output)
248+ # the items in the args dict are lists - so use .get()[0] to retrieve
249+ args = request.args
250+
251+ res_code = RESPONSE_CODE_OK
252+ res_msg = None # set this in the logic
253+
254+ #
255+ # Get program name and database connection, check if program exists
256+ #
257+
258+ name = args.get('program_name', [None])[0]
259+
260+ if "program_name" is None:
261+ res_code = RESPONSE_CODE_BAD_REQUEST
262+ res_msg = "Supply 'program_name' parameter."
263+
264+ if res_code is RESPONSE_CODE_OK:
265+ try:
266+ conn = yield deferToThread(self.create_neo4j_connection)
267+ except requests.exceptions.ConnectionError:
268+ res_code = RESPONSE_CODE_BAD_GATEWAY
269+ res_fmt = "Could not reach Neo4j server at {}"
270+ res_msg = res_fmt.format(database_url)
271+ conn = None
272+
273+ if res_code is RESPONSE_CODE_OK:
274+ exists = yield deferToThread(self.check_program_exists, conn, name)
275+ if not exists:
276+ res_code = RESPONSE_CODE_NOT_FOUND
277+ res_fmt = "Program {} not found in database."
278+ res_msg = res_fmt.format(escape(name))
279+
280+ #
281+ # We have a connection and a valid program - now setup the query
282+ #
283+
284+ # We store query info as:
285+ # <query_name>: (<function>, (<known args>), (<kwargs>)
286+ # where <known args> are local variables and <kwargs> are the keys we
287+ # look for in request.args, both tuples
288+ queries = {
289+ 'whole_program': (
290+ self.get_whole_program,
291+ (conn, name),
292+ ()
293+ ),
294+ 'functions_calling': (
295+ self.get_functions_calling,
296+ (conn, name),
297+ ('func1',)
298+ ),
299+ 'functions_called_by': (
300+ conn.get_all_functions_called,
301+ (name,),
302+ ('func1',)
303+ ),
304+ 'all_call_paths': (
305+ conn.get_call_paths,
306+ (name,),
307+ ('func1', 'func2')
308+ ),
309+ 'shortest_call_path': (
310+ conn.get_shortest_path_between_functions,
311+ (name,),
312+ ('func1', 'func2')
313+ )
314+ }
315+
316+ # default to whole_program if query is in request.args but has no value
317+ query_name = args.get('query', ['whole_program'])[0]
318+
319+ # check for an invalid query
320+ query = queries.get(query_name, None)
321+
322+ if query is None:
323+ # we do not have a query function for this query name
324+ res_code = RESPONSE_CODE_BAD_REQUEST
325+ res_fmt = "Invalid 'query' parameter, allowed: {}"
326+ res_msg = res_fmt.format(queries.keys())
327+
328+ # extract any required keyword arguments from request.args
329+ if res_code is RESPONSE_CODE_OK:
330+ fn, known_args, kwargs = query
331+
332+ # all args will be strings - use None to indicate missing argument
333+ req_args = tuple(args.get(kwarg, [None])[0] for kwarg in kwargs)
334+ missing_args = [kwarg for (kwarg, req_arg) in zip(kwargs, req_args)
335+ if req_arg is None]
336+
337+ if missing_args:
338+ # missing a kwarg from request.args
339+ res_code = RESPONSE_CODE_BAD_REQUEST
340+ res_fmt = "Missing arguments {} to {}."
341+ res_msg = res_fmt.format(', '.join(missing_args), name)
342+
343+ # if we are okay here we have a valid query with all required arguments
344+ if res_code is RESPONSE_CODE_OK:
345+ try:
346+ all_args = known_args + req_args
347+ program = yield defer_to_thread_with_timeout(render_timeout, fn,
348+ *all_args)
349+ except defer.CancelledError:
350+ # the timeout has fired and cancelled the request
351+ res_code = RESPONSE_CODE_BAD_REQUEST
352+ res_fmt = "The request timed out after {} seconds."
353+ res_msg = res_fmt.format(render_timeout)
354+
355+ if res_code is RESPONSE_CODE_OK:
356+ # we have received a response to our request
357+ if program is None:
358+ res_code = RESPONSE_CODE_NOT_FOUND
359+ res_fmt = ("At least one of the input functions '{}' was not "
360+ "found in program {}.")
361+ res_msg = res_fmt.format(', '.join(req_args), escape(name))
362+ elif not program.functions:
363+ res_code = RESPONSE_CODE_NO_CONTENT
364+ res_fmt = ("The program {} is in the database but has no "
365+ "functions.")
366+ res_msg = res_fmt.format(name)
367+
368+ if res_code is RESPONSE_CODE_OK:
369+ # True if 'null' or 'true', otherwise False
370+ suppress_common_arg = args.get('suppress_common', ['false'])[0]
371+ suppress_common = suppress_common_arg in ('null', 'true')
372+
373+ # we have a non-empty return - render it
374+ res_msg = yield deferToThread(self.get_plot, program,
375+ suppress_common,
376+ remove_self_calls=False)
377+ request.setHeader('content-type', 'image/svg+xml')
378+
379+ request.setResponseCode(res_code)
380+ request.write(res_msg)
381 request.finish()
382
383 def render_GET(self, request):

Subscribers

People subscribed via source and target branches