Wiki source code of Performing Asynchronous Tasks

Version 2.2 by Marius Dumitru Florea on 2015/07/28

Show last authors
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%"]]

Get Connected