Merge lp:~ben-hutchings/ensoft-sextant/render_timeout into lp:ensoft-sextant
- render_timeout
- Merge into whiteline
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 |
Related bugs: |
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.
Description of the change
To post a comment you must log in.
- 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): |
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.