Wiki source code of Performing Asynchronous Tasks

Version 3.1 by Paul Libbrecht on 2016/08/18

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

Get Connected