Wiki source code of Performing Asynchronous Tasks

Last modified by Thomas Mortagne on 2020/04/10

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

Get Connected