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

Proposed by Ben Hutchings
Status: Superseded
Proposed branch: lp:~ben-hutchings/ensoft-sextant/render_timeout
Merge into: lp:ensoft-sextant
Diff against target: 365 lines (+159/-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 (+141/-128)
To merge this branch: bzr merge lp:~ben-hutchings/ensoft-sextant/render_timeout
Reviewer Review Type Date Requested Status
Robert Needs Fixing
Review via email: mp+236300@code.launchpad.net

This proposal has been superseded by a proposal from 2014-10-01.

Commit message

Added a timeout option in the (new) [Server] section of the config file. If a graph request takes longer than this value to complete, it will be cancelled and a timeout error message displayed.

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

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
27. By Ben Hutchings

Merged from parent - double request fix and audit error message fix

28. By Ben Hutchings

Initial refactoring of _render_plot

29. By Ben Hutchings

Diff comments - fitted _render_plot into 80 columns

Unmerged revisions

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-01 10:37:14 +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 = 10
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-01 10:37:14 +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-01 10:37:14 +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-01 10:37:14 +0000
83@@ -28,6 +28,27 @@
84
85 database_url = None # the URL to access the database instance
86
87+
88+def setTimeout(deferred, timeout=5.0):
89+ # cancel the given deferred instance after timeout seconds
90+
91+ timeout = reactor.callLater(timeout, deferred.cancel)
92+
93+ def gotResult(result):
94+ # if the deferred returns before the timeout calls, cancel the timeout
95+ # and return the result of the deferred
96+ if timeout.active():
97+ timeout.cancel()
98+
99+ return result
100+
101+ return deferred.addBoth(gotResult)
102+
103+def deferToThreadWithTimeout(timeout, function, *args, **kwargs):
104+ result = deferToThread(function, *args, **kwargs)
105+ return setTimeout(result, timeout)
106+
107+
108 class Echoer(Resource):
109 # designed to take one name argument
110
111@@ -78,134 +99,126 @@
112
113 @defer.inlineCallbacks
114 def _render_plot(self, request):
115- if "program_name" not in request.args:
116- request.setResponseCode(400)
117- request.write("Supply 'program_name' parameter.")
118- request.finish()
119- defer.returnValue(None)
120-
121- name = request.args["program_name"][0]
122-
123- try:
124- suppress_common = request.args["suppress_common"][0]
125- except KeyError:
126- suppress_common = False
127-
128- if suppress_common == 'null' or suppress_common == 'true':
129- suppress_common = True
130- else:
131- suppress_common = False
132-
133- try:
134- neo4jconnection = yield deferToThread(self.create_neo4j_connection)
135- except requests.exceptions.ConnectionError:
136- request.setResponseCode(502) # Bad Gateway
137- request.write("Could not reach Neo4j server at {}".format(database_url))
138- request.finish()
139- defer.returnValue(None)
140- neo4jconnection = None # to silence the "referenced before assignment" warnings later
141-
142- exists = yield deferToThread(self.check_program_exists, neo4jconnection, name)
143- if not exists:
144- request.setResponseCode(404)
145- logging.debug('returning nonexistent')
146- request.write("Name %s not found." % (escape(name)))
147- request.finish()
148- defer.returnValue(None)
149-
150- allowed_queries = ("whole_program", "functions_calling", "functions_called_by", "all_call_paths", "shortest_call_path")
151-
152- if "query" not in request.args:
153- query = "whole_program"
154- else:
155- query = request.args["query"][0]
156-
157- if query not in allowed_queries:
158- # raise 400 Bad Request error
159- request.setResponseCode(400)
160- request.write("Supply 'query' parameter, default is whole_program, allowed %s." % str(allowed_queries))
161- request.finish()
162- defer.returnValue(None)
163-
164- if query == 'whole_program':
165- program = yield deferToThread(self.get_whole_program, neo4jconnection, name)
166- elif query == 'functions_calling':
167- if 'func1' not in request.args:
168- # raise 400 Bad Request error
169- request.setResponseCode(400)
170- request.write("Supply 'func1' parameter to functions_calling.")
171- request.finish()
172- defer.returnValue(None)
173- func1 = request.args['func1'][0]
174- program = yield deferToThread(self.get_functions_calling, neo4jconnection, name, func1)
175- elif query == 'functions_called_by':
176- if 'func1' not in request.args:
177- # raise 400 Bad Request error
178- request.setResponseCode(400)
179- request.write("Supply 'func1' parameter to functions_called_by.")
180- request.finish()
181- defer.returnValue(None)
182- func1 = request.args['func1'][0]
183- program = yield deferToThread(neo4jconnection.get_all_functions_called, name, func1)
184- elif query == 'all_call_paths':
185- if 'func1' not in request.args:
186- # raise 400 Bad Request error
187- request.setResponseCode(400)
188- request.write("Supply 'func1' parameter to all_call_paths.")
189- request.finish()
190- defer.returnValue(None)
191- if 'func2' not in request.args:
192- # raise 400 Bad Request error
193- request.setResponseCode(400)
194- request.write("Supply 'func2' parameter to call_paths.")
195- request.finish()
196- defer.returnValue(None)
197-
198- func1 = request.args['func1'][0]
199- func2 = request.args['func2'][0]
200- program = yield deferToThread(neo4jconnection.get_call_paths, name, func1, func2)
201- elif query == 'shortest_call_path':
202- if 'func1' not in request.args:
203- # raise 400 Bad Request error
204- request.setResponseCode(400)
205- request.write("Supply 'func1' parameter to shortest_call_path.")
206- request.finish()
207- defer.returnValue(None)
208- if 'func2' not in request.args:
209- # raise 400 Bad Request error
210- request.setResponseCode(400)
211- request.write("Supply 'func2' parameter to shortest_call_path.")
212- request.finish()
213- defer.returnValue(None)
214-
215- func1 = request.args['func1'][0]
216- func2 = request.args['func2'][0]
217- program = yield deferToThread(neo4jconnection.get_shortest_path_between_functions, name, func1, func2)
218-
219- else: # unrecognised query, so we need to raise a Bad Request response
220- request.setResponseCode(400)
221- request.write("Query %s not recognised." % escape(query))
222- request.finish()
223- defer.returnValue(None)
224- program = None # silences the "referenced before assignment" warnings
225-
226- if program is None:
227- request.setResponseCode(404)
228- request.write("At least one of the input functions was not found in program %s." % (escape(name)))
229- request.finish()
230- defer.returnValue(None)
231-
232- logging.debug('getting plot')
233- if not program.functions: # we got an empty program back: the program is in the Sextant but has no functions
234- request.setResponseCode(204)
235- request.finish()
236- defer.returnValue(None)
237- output = yield deferToThread(self.get_plot, program, suppress_common,
238- remove_self_calls=False)
239- request.setHeader("content-type", "image/svg+xml")
240-
241- logging.debug('SVG: return')
242- request.write(output)
243+ # the items in the args dict are lists - so use .get()[0] to retrieve
244+ args = request.args
245+
246+ response_code = 200 # default to okay
247+ response_msg = None # set this in the logic
248+
249+ #
250+ # Get the program name and the database connection, see if the program exists
251+ #
252+
253+ name = args.get('program_name', [None])[0]
254+
255+ if "program_name" is None:
256+ response_code = 400
257+ response_msg = "Supply 'program_name' parameter."
258+
259+ if response_code is 200:
260+ try:
261+ neo4jconnection = yield deferToThread(self.create_neo4j_connection)
262+ except requests.exceptions.ConnectionError:
263+ response_code = 502 # Bad Gateway
264+ response_msg = "Could not reach Neo4j server at {}".format(database_url)
265+ neo4jconnection = None
266+
267+ if response_code is 200:
268+ exists = yield deferToThread(self.check_program_exists, neo4jconnection, name)
269+ if not exists:
270+ response_code = 404
271+ response_msg = "Program {} not found in database.".format(escape(name))
272+
273+ #
274+ # We have a connection and a valid program - now setup the query
275+ #
276+
277+ # We store query info as:
278+ # <query_name>: (<function>, (<known args>), (<kwargs>)
279+ # where <known args> are local variables and <kwargs> are the keys we
280+ # look for in request.args, both tuples
281+ queries = {
282+ 'whole_program': (
283+ self.get_whole_program,
284+ (neo4jconnection, name),
285+ ()
286+ ),
287+ 'functions_calling': (
288+ self.get_functions_calling,
289+ (neo4jconnection, name),
290+ ('func1',)
291+ ),
292+ 'functions_called_by': (
293+ neo4jconnection.get_all_functions_called,
294+ (name,),
295+ ('func1',)
296+ ),
297+ 'all_call_paths': (
298+ neo4jconnection.get_call_paths,
299+ (name,),
300+ ('func1', 'func2')
301+ ),
302+ 'shortest_call_path': (
303+ neo4jconnection.get_shortest_path_between_functions,
304+ (name,),
305+ ('func1', 'func2')
306+ )
307+ }
308+
309+ # default to whole_program if query is in request.args but has no value
310+ query_name = args.get('query', ['whole_program'])[0]
311+
312+ # check for an invalid query
313+ query = queries.get(query_name, None)
314+
315+ if query is None:
316+ # we do not have a query function for this query name
317+ response_code = 400 # Bad request
318+ response_msg = "Invalid 'query' parameter, allowed: {}".format(queries.keys())
319+
320+ # extract any required keyword arguments from request.args
321+ if response_code is 200:
322+ fn, known_args, kwargs = query
323+
324+ # all found args will be strings - use None to indicate a missing argument
325+ req_args = tuple(args.get(kwarg, [None])[0] for kwarg in kwargs)
326+ missing_args = [kwarg for (kwarg, req_arg) in zip(kwargs, req_args) if req_arg is None]
327+
328+ if missing_args:
329+ # missing a kwarg from request.args
330+ response_code = 400 # Bad request
331+ response_msg = "Missing arguments {} to {}.".format(', '.join(missing_args), name)
332+
333+ # if we are okay here we have a valid query with all required arguments
334+ if response_code is 200:
335+ try:
336+ program = yield deferToThreadWithTimeout(render_timeout, fn, *(known_args + req_args))
337+ except defer.CancelledError:
338+ # the timeout has fired and cancelled the request
339+ response_code = 400 # Bad request - took too long
340+ response_msg = "The request timed out after {} seconds.".format(render_timeout)
341+
342+ if response_code is 200:
343+ # we have received a response to our request
344+ if program is None:
345+ response_code = 404
346+ response_msg = ("At least one of the input functions '{}' was not "
347+ "found in program {}.").format(', '.join(req_args), escape(name))
348+ elif not program.functions:
349+ response_code = 204 # No content
350+ response_msg = "The program {} is in the database but has no functions.".format(name)
351+
352+ if response_code is 200:
353+ # True if we get 'null' or 'true' as the string value back, otherwise False
354+ suppress_common = args.get('suppress_common', ['false'])[0] in ['null', 'true']
355+
356+ # we have a non-empty return - render it
357+ response_msg = yield deferToThread(self.get_plot, program,
358+ suppress_common, remove_self_calls=False)
359+ request.setHeader('content-type', 'image/svg+xml')
360+
361+ request.setResponseCode(response_code)
362+ request.write(response_msg)
363 request.finish()
364
365 def render_GET(self, request):

Subscribers

People subscribed via source and target branches