Wiki source code of Performing Asynchronous Tasks
Version 2.2 by Marius Dumitru Florea on 2015/07/28
Show last authors
author | version | line-number | content |
---|---|---|---|
1 | == Agenda == | ||
2 | |||
3 | {{toc depth="1" /}} | ||
4 | |||
5 | = Use Case = | ||
6 | |||
7 | Implement space rename taking into account that: | ||
8 | * a space can have many pages | ||
9 | * each page can have many back-links | ||
10 | * some pages can have large content and we want to update the relative links inside that content | ||
11 | |||
12 | This operation can take a lot of time so we need to display the progress. This means we cannot block the HTTP request that triggers the operation. In other words the operation should be asynchronous. | ||
13 | |||
14 | = API Design = | ||
15 | |||
16 | Before we start doing the implementation we need to decide what the rename API would look like. There are two main ways to implement asynchronous tasks: | ||
17 | 1. **push**: you start the task and then you wait to be notified of the task progress, success or failure. In order to be notified you: | ||
18 | 1*. either pass a **callback** to the API | ||
19 | 1*. or the API returns a **promise** that you can use to register a callback | ||
20 | 1. **pull**: you start the task and then you **ask for updates** regularly until the task is done (with success or failure). In this case the API needs to provide some method to access the status of the task | ||
21 | |||
22 | The first option (push) is nice but it requires a two-way connection between the code that triggers the task and the code that executes the task. This is not the case with (standard) HTTP where the server (normally) doesn't push data to the client. It's the client who pulls the data from the server. So we're going to use the second option. | ||
23 | |||
24 | {{code language="none"}} | ||
25 | ## Start the task. | ||
26 | #set ($taskId = $services.space.rename($spaceReference, $newSpaceName)) | ||
27 | ... | ||
28 | ## Pull the task status. | ||
29 | #set ($taskStatus = $services.space.getRenameStatus($taskId)) | ||
30 | {{/code}} | ||
31 | |||
32 | Let's see how we can implement this API using the [[Job Module>>extensions:Extension.Job Module]]. | ||
33 | |||
34 | = Request = | ||
35 | |||
36 | The request represents the input for the task. It includes: | ||
37 | * the data needed by the task (e.g. the space reference and the new space name) | ||
38 | * context information (e.g. the user that triggered the task) | ||
39 | * configuration options for the task. For instance: | ||
40 | ** whether to check access rights or not | ||
41 | ** whether the task is interactive (may require user input during the task execution) or not | ||
42 | |||
43 | Each request has an identifier that is used to access the task status. | ||
44 | |||
45 | {{code language="java"}} | ||
46 | public class RenameRequest extends org.xwiki.job.AbstractRequest | ||
47 | { | ||
48 | private static final String PROPERTY_SPACE_REFERENCE = "spaceReference"; | ||
49 | |||
50 | private static final String PROPERTY_NEW_SPACE_NAME = "newSpaceName"; | ||
51 | |||
52 | private static final String PROPERTY_CHECK_RIGHTS = "checkrights"; | ||
53 | |||
54 | private static final String PROPERTY_USER_REFERENCE = "user.reference"; | ||
55 | |||
56 | public SpaceReference getSpaceReference() | ||
57 | { | ||
58 | return getProperty(PROPERTY_SPACE_REFERENCE); | ||
59 | } | ||
60 | |||
61 | public void setSpaceReference(SpaceReference spaceReference) | ||
62 | { | ||
63 | setProperty(PROPERTY_SPACE_REFERENCE, spaceReference); | ||
64 | } | ||
65 | |||
66 | public String getNewSpaceName() | ||
67 | { | ||
68 | return getProperty(PROPERTY_NEW_SPACE_NAME); | ||
69 | } | ||
70 | |||
71 | public void setNewSpaceName(String newSpaceName) | ||
72 | { | ||
73 | setProperty(PROPERTY_NEW_SPACE_NAME, newSpaceName); | ||
74 | } | ||
75 | |||
76 | public boolean isCheckRights() | ||
77 | { | ||
78 | return getProperty(PROPERTY_CHECK_RIGHTS, true); | ||
79 | } | ||
80 | |||
81 | public void setCheckRights(boolean checkRights) | ||
82 | { | ||
83 | setProperty(PROPERTY_CHECK_RIGHTS, checkRights); | ||
84 | } | ||
85 | |||
86 | public DocumentReference getUserReference() | ||
87 | { | ||
88 | return getProperty(PROPERTY_USER_REFERENCE); | ||
89 | } | ||
90 | |||
91 | public void setUserReference(DocumentReference userReference) | ||
92 | { | ||
93 | setProperty(PROPERTY_USER_REFERENCE, userReference); | ||
94 | } | ||
95 | } | ||
96 | {{/code}} | ||
97 | |||
98 | = Questions = | ||
99 | |||
100 | As we mentioned, jobs can be interactive by asking questions during the job execution. For instance, if there is already a space with the specified new name then we have to decide whether to: | ||
101 | * stop the rename | ||
102 | * or merge the two spaces | ||
103 | |||
104 | If we decide to merge the two spaces then there may be documents with the same name in both spaces, in which case we have to decide whether to overwrite the destination document or not. | ||
105 | |||
106 | To keep the example simple we're going to always merge the two spaces but we'll ask the user to confirm the overwrite. | ||
107 | |||
108 | {{code language="java"}} | ||
109 | public class OverwriteQuestion | ||
110 | { | ||
111 | private final DocumentReference source; | ||
112 | |||
113 | private final DocumentReference destination; | ||
114 | |||
115 | private boolean overwrite = true; | ||
116 | |||
117 | private boolean askAgain = true; | ||
118 | |||
119 | public OverwriteQuestion(DocumentReference source, DocumentReference destination) | ||
120 | { | ||
121 | this.source = source; | ||
122 | this.destination = destination; | ||
123 | } | ||
124 | |||
125 | public EntityReference getSource() | ||
126 | { | ||
127 | return source; | ||
128 | } | ||
129 | |||
130 | public EntityReference getDestination() | ||
131 | { | ||
132 | return destination; | ||
133 | } | ||
134 | |||
135 | public boolean isOverwrite() | ||
136 | { | ||
137 | return overwrite; | ||
138 | } | ||
139 | |||
140 | public void setOverwrite(boolean overwrite) | ||
141 | { | ||
142 | this.overwrite = overwrite; | ||
143 | } | ||
144 | |||
145 | public boolean isAskAgain() | ||
146 | { | ||
147 | return askAgain; | ||
148 | } | ||
149 | |||
150 | public void setAskAgain(boolean askAgain) | ||
151 | { | ||
152 | this.askAgain = askAgain; | ||
153 | } | ||
154 | } | ||
155 | {{/code}} | ||
156 | |||
157 | = Job Status = | ||
158 | |||
159 | The job status provides, by default, access to: | ||
160 | * the job **state** (e.g. NONE, RUNNING, WAITING, FINISHED) | ||
161 | * the job **request** | ||
162 | * the job **log** ("INFO: Document X.Y has been renamed to A.B") | ||
163 | * the job **progress** (72% completed) | ||
164 | |||
165 | Most of the time you don't need to extend the ##DefaultJobStatus## provided by the Job Module, unless you want to store: | ||
166 | * more progress information (e.g. the list of documents that have been renamed so far) | ||
167 | * the task result / output | ||
168 | |||
169 | Note that both the request and the job status must be **serializable** so be careful with what kind of information your store in your custom job status. For instance, for the task output, it's probably better to store a reference, path or URL to the output instead of storing the output itself. | ||
170 | |||
171 | The job status is also your communication channel with the job: | ||
172 | * if the job asks a question we | ||
173 | ** access the question from the job status | ||
174 | ** answer the question through the job status | ||
175 | * if you want to cancel the job you have to do it through the job status | ||
176 | |||
177 | {{code language="java"}} | ||
178 | public class RenameJobStatus extends DefaultJobStatus<RenameRequest> | ||
179 | { | ||
180 | private boolean canceled; | ||
181 | |||
182 | private List<DocumentReference> renamedDocumentReferences = new ArrayList<>(); | ||
183 | |||
184 | public RenameJobStatus(RenameRequest request, ObservationManager observationManager, | ||
185 | LoggerManager loggerManager, JobStatus parentJobStatus) | ||
186 | { | ||
187 | super(request, observationManager, loggerManager, parentJobStatus); | ||
188 | } | ||
189 | |||
190 | public void cancel() | ||
191 | { | ||
192 | this.canceled = true; | ||
193 | } | ||
194 | |||
195 | public boolean isCanceled() | ||
196 | { | ||
197 | return this.canceled; | ||
198 | } | ||
199 | |||
200 | public List<DocumentReference> getRenamedDocumentReferences() | ||
201 | { | ||
202 | return this.renamedDocumentReferences; | ||
203 | } | ||
204 | } | ||
205 | {{/code}} | ||
206 | |||
207 | = Script Service = | ||
208 | |||
209 | We now have everything we need to implement a ##ScriptService## that will allow us to trigger the rename from Velocity and to get the rename status. | ||
210 | |||
211 | {{code language="java"}} | ||
212 | @Component | ||
213 | @Named(SpaceScriptService.ROLE_HINT) | ||
214 | @Singleton | ||
215 | public class SpaceScriptService implements ScriptService | ||
216 | { | ||
217 | public static final String ROLE_HINT = "space"; | ||
218 | |||
219 | public static final String RENAME = "rename"; | ||
220 | |||
221 | @Inject | ||
222 | private JobExecutor jobExecutor; | ||
223 | |||
224 | @Inject | ||
225 | private JobStatusStore jobStatusStore; | ||
226 | |||
227 | @Inject | ||
228 | private DocumentAccessBridge documentAccessBridge; | ||
229 | |||
230 | public String rename(SpaceReference spaceReference, String newSpaceName) | ||
231 | { | ||
232 | setError(null); | ||
233 | |||
234 | RenameRequest renameRequest = createRenameRequest(spaceReference, newSpaceName); | ||
235 | |||
236 | try { | ||
237 | this.jobExecutor.execute(RENAME, renameRequest); | ||
238 | |||
239 | List<String> renameId = renameRequest.getId(); | ||
240 | return renameId.get(renameId.size() - 1); | ||
241 | } catch (Exception e) { | ||
242 | setError(e); | ||
243 | return null; | ||
244 | } | ||
245 | } | ||
246 | |||
247 | public RenameJobStatus getRenameStatus(String renameJobId) | ||
248 | { | ||
249 | return (RenameJobStatus) this.jobStatusStore.getJobStatus(getJobId(renameJobId)); | ||
250 | } | ||
251 | |||
252 | private RenameRequest createRenameRequest(SpaceReference spaceReference, String newSpaceName) | ||
253 | { | ||
254 | RenameRequest renameRequest = new RenameRequest(); | ||
255 | renameRequest.setId(getNewJobId()); | ||
256 | renameRequest.setSpaceReference(spaceReference); | ||
257 | renameRequest.setNewSpaceName(newSpaceName); | ||
258 | renameRequest.setInteractive(true); | ||
259 | renameRequest.setCheckRights(true); | ||
260 | renameRequest.setUserReference(this.documentAccessBridge.getCurrentUserReference()); | ||
261 | return renameRequest; | ||
262 | } | ||
263 | |||
264 | private List<String> getNewJobId() | ||
265 | { | ||
266 | return getJobId(UUID.randomUUID().toString()); | ||
267 | } | ||
268 | |||
269 | private List<String> getJobId(String suffix) | ||
270 | { | ||
271 | return Arrays.asList(ROLE_HINT, RENAME, suffix); | ||
272 | } | ||
273 | } | ||
274 | {{/code}} | ||
275 | |||
276 | = Job Implementation = | ||
277 | |||
278 | Jobs are components. Let's see how we can implement them. | ||
279 | |||
280 | {{code language="java"}} | ||
281 | @Component | ||
282 | @Named(SpaceScriptService.RENAME) | ||
283 | public class RenameJob extends AbstractJob<RenameRequest, RenameJobStatus> implements GroupedJob | ||
284 | { | ||
285 | @Inject | ||
286 | private AuthorizationManager authorization; | ||
287 | |||
288 | @Inject | ||
289 | private DocumentAccessBridge documentAccessBridge; | ||
290 | |||
291 | private Boolean overwriteAll; | ||
292 | |||
293 | @Override | ||
294 | public String getType() | ||
295 | { | ||
296 | return SpaceScriptService.RENAME; | ||
297 | } | ||
298 | |||
299 | @Override | ||
300 | public JobGroupPath getGroupPath() | ||
301 | { | ||
302 | String wiki = this.request.getSpaceReference().getWikiReference().getName(); | ||
303 | return new JobGroupPath(Arrays.asList(SpaceScriptService.RENAME, wiki)); | ||
304 | } | ||
305 | |||
306 | @Override | ||
307 | protected void runInternal() throws Exception | ||
308 | { | ||
309 | List<DocumentReference> documentReferences = getDocumentReferences(this.request.getSpaceReference()); | ||
310 | this.progressManager.pushLevelProgress(documentReferences.size(), this); | ||
311 | |||
312 | try { | ||
313 | for (DocumentReference documentReference : documentReferences) { | ||
314 | if (this.status.isCanceled()) { | ||
315 | break; | ||
316 | } else { | ||
317 | this.progressManager.startStep(this); | ||
318 | if (hasAccess(Right.DELETE, documentReference)) { | ||
319 | move(documentReference, this.request.getNewSpaceName()); | ||
320 | this.status.getRenamedDocumentReferences().add(documentReference); | ||
321 | this.logger.info("Document [{}] has been moved to [{}].", documentReference, | ||
322 | this.request.getNewSpaceName()); | ||
323 | } | ||
324 | } | ||
325 | } | ||
326 | } finally { | ||
327 | this.progressManager.popLevelProgress(this); | ||
328 | } | ||
329 | } | ||
330 | |||
331 | private boolean hasAccess(Right right, EntityReference reference) | ||
332 | { | ||
333 | return !this.request.isCheckRights() | ||
334 | || this.authorization.hasAccess(right, this.request.getUserReference(), reference); | ||
335 | } | ||
336 | |||
337 | private void move(DocumentReference documentReference, String newSpaceName) | ||
338 | { | ||
339 | SpaceReference newSpaceReference = new SpaceReference(newSpaceName, documentReference.getWikiReference()); | ||
340 | DocumentReference newDocumentReference = | ||
341 | documentReference.replaceParent(documentReference.getLastSpaceReference(), newSpaceReference); | ||
342 | if (!this.documentAccessBridge.exists(newDocumentReference) | ||
343 | || confirmOverwrite(documentReference, newDocumentReference)) { | ||
344 | move(documentReference, newDocumentReference); | ||
345 | } | ||
346 | } | ||
347 | |||
348 | private boolean confirmOverwrite(DocumentReference source, DocumentReference destination) | ||
349 | { | ||
350 | if (this.overwriteAll == null) { | ||
351 | OverwriteQuestion question = new OverwriteQuestion(source, destination); | ||
352 | try { | ||
353 | this.status.ask(question); | ||
354 | if (!question.isAskAgain()) { | ||
355 | // Use the same answer for the following overwrite questions. | ||
356 | this.overwriteAll = question.isOverwrite(); | ||
357 | } | ||
358 | return question.isOverwrite(); | ||
359 | } catch (InterruptedException e) { | ||
360 | this.logger.warn("Overwrite question has been interrupted."); | ||
361 | return false; | ||
362 | } | ||
363 | } else { | ||
364 | return this.overwriteAll; | ||
365 | } | ||
366 | } | ||
367 | } | ||
368 | {{/code}} | ||
369 | |||
370 | = Server Controller = | ||
371 | |||
372 | We need to be able to trigger the rename operation and to get status updates remotely, from JavaScript. This means the rename API should be accessible through some URLs: | ||
373 | |||
374 | * ##?action=rename## -> redirects to ##?data=jobStatus## | ||
375 | * ##?data=jobStatus&jobId=xyz## -> return the job status serialized as JSON | ||
376 | * ##?action=continue&jobId=xyz## -> redirects to ##?data=jobStatus## | ||
377 | * ##?action=cancel&jobId=xyz## -> redirects to ##?data=jobStatus## | ||
378 | |||
379 | {{code language="none"}} | ||
380 | {{velocity}} | ||
381 | #if ($request.action == 'rename') | ||
382 | #set ($spaceReference = $services.model.resolveSpace($request.spaceReference)) | ||
383 | #set ($renameJobId = $services.space.rename($spaceReference, $request.newSpaceName)) | ||
384 | $response.sendRedirect($doc.getURL('get', $escapetool.url({ | ||
385 | 'outputSyntax': 'plain', | ||
386 | 'jobId': $renameJobId | ||
387 | }))) | ||
388 | #elseif ($request.action == 'continue') | ||
389 | #set ($renameJobStatus = $services.space.getRenameStatus($request.jobId)) | ||
390 | #set ($overwrite = $request.overwrite == 'true') | ||
391 | #set ($discard = $renameJobStatus.question.setOverwrite($overwrite)) | ||
392 | #set ($discard = $renameJobStatus..answered()) | ||
393 | #elseif ($request.action == 'cancel') | ||
394 | #set ($renameJobStatus = $services.space.getRenameStatus($request.jobId)) | ||
395 | #set ($discard = $renameJobStatus.cancel()) | ||
396 | $response.sendRedirect($doc.getURL('get', $escapetool.url({ | ||
397 | 'outputSyntax': 'plain', | ||
398 | 'jobId': $renameJobId | ||
399 | }))) | ||
400 | #elseif ($request.data == 'jobStatus') | ||
401 | #set ($renameJobStatus = $services.space.getRenameStatus($request.jobId)) | ||
402 | #buildRenameStatusJSON($renameJobStatus) | ||
403 | $response.setContentType('application/json') | ||
404 | $jsontool.serialize($renameJobStatusJSON) | ||
405 | #end | ||
406 | {{/velocity}} | ||
407 | {{/code}} | ||
408 | |||
409 | = Client Controller = | ||
410 | |||
411 | On the client side, the JavaScript code is responsible for: | ||
412 | * triggering the task with an AJAX request to the server controller | ||
413 | * retrieve task status updates regularly and update the displayed progress | ||
414 | * pass the job questions to the user and pass the user answers to the server controller | ||
415 | |||
416 | {{code language="js"}} | ||
417 | var onStatusUpdate = function(status) { | ||
418 | updateProgressBar(status); | ||
419 | if (status.state == 'WAITING') { | ||
420 | // Display the question to the user. | ||
421 | displayQuestion(status); | ||
422 | } else if (status.state != 'FINISHED') { | ||
423 | // Pull task status update. | ||
424 | setTimeout(function() { | ||
425 | requestStatusUpdate(status.request.id).success(onStatusUpdate); | ||
426 | }, 1000); | ||
427 | } | ||
428 | }; | ||
429 | |||
430 | // Trigger the rename task. | ||
431 | rename(parameters).success(onStatusUpdate); | ||
432 | |||
433 | // Continue the rename after the user answers the question. | ||
434 | continueRename(parameters).success(onStatusUpdate); | ||
435 | |||
436 | // Cancel the rename. | ||
437 | cancelRename(parameters).success(onStatusUpdate); | ||
438 | {{/code}} | ||
439 | |||
440 | = General Flow = | ||
441 | |||
442 | [[image:jobFlow.png||style="max-width:100%"]] |