JAL-3878 Build interactive services menu
[jalview.git] / src / jalview / ws2 / actions / annotation / AnnotationTask.java
1 package jalview.ws2.actions.annotation;
2
3 import java.io.IOException;
4 import java.util.ArrayList;
5 import java.util.Collections;
6 import java.util.HashMap;
7 import java.util.List;
8 import java.util.Map;
9
10 import jalview.analysis.AlignmentAnnotationUtils;
11 import jalview.api.AlignCalcManagerI2;
12 import jalview.api.AlignCalcWorkerI;
13 import jalview.api.AlignViewportI;
14 import jalview.api.FeatureColourI;
15 import jalview.api.PollableAlignCalcWorkerI;
16 import jalview.bin.Cache;
17 import jalview.datamodel.AlignmentAnnotation;
18 import jalview.datamodel.AlignmentI;
19 import jalview.datamodel.AnnotatedCollectionI;
20 import jalview.datamodel.Annotation;
21 import jalview.datamodel.ContiguousI;
22 import jalview.datamodel.Mapping;
23 import jalview.datamodel.SequenceI;
24 import jalview.datamodel.features.FeatureMatcherSetI;
25 import jalview.schemes.FeatureSettingsAdapter;
26 import jalview.util.ArrayUtils;
27 import jalview.util.MapList;
28 import jalview.util.MathUtils;
29 import jalview.util.Pair;
30 import jalview.workers.AlignCalcWorker;
31 import jalview.ws.params.ArgumentI;
32 import jalview.ws2.actions.AbstractPollableTask;
33 import jalview.ws2.actions.BaseJob;
34 import jalview.ws2.actions.ServiceInputInvalidException;
35 import jalview.ws2.actions.api.JobI;
36 import jalview.ws2.actions.api.TaskEventListener;
37 import jalview.ws2.actions.api.TaskI;
38 import jalview.ws2.api.Credentials;
39 import jalview.ws2.api.JobStatus;
40 import jalview.ws2.api.WebServiceJobHandle;
41 import jalview.ws2.client.api.AnnotationWebServiceClientI;
42 import jalview.ws2.helpers.DelegateJobEventListener;
43 import jalview.ws2.helpers.TaskEventSupport;
44
45 import static java.util.Objects.requireNonNullElse;
46
47 public class AnnotationTask implements TaskI<AnnotationResult>
48 {
49   private final long uid = MathUtils.getUID();
50
51   private AnnotationWebServiceClientI client;
52
53   private final AnnotationAction action;
54
55   private final List<ArgumentI> args;
56
57   private final Credentials credentials;
58
59   private final AlignViewportI viewport;
60
61   private final TaskEventSupport<AnnotationResult> eventHandler;
62
63   private JobStatus taskStatus = null;
64
65   private AlignCalcWorkerAdapter worker = null;
66
67   private List<AnnotationJob> jobs = Collections.emptyList();
68
69   private AnnotationResult result = null;
70
71   private DelegateJobEventListener<AnnotationResult> jobEventHandler;
72
73   private class AlignCalcWorkerAdapter extends AlignCalcWorker
74       implements PollableAlignCalcWorkerI
75   {
76     private boolean restarting = false;
77
78     AlignCalcWorkerAdapter(AlignCalcManagerI2 calcMan)
79     {
80       super(viewport, null);
81       this.calcMan = calcMan;
82     }
83
84     String getServiceName()
85     {
86       return action.getWebService().getName();
87     }
88
89     @Override
90     public void startUp() throws Throwable
91     {
92       if (alignViewport.isClosed())
93       {
94         stop();
95         throw new IllegalStateException("Starting annotation for closed viewport");
96       }
97       if (restarting)
98         eventHandler.fireTaskRestarted();
99       else
100         restarting = true;
101       jobs = Collections.emptyList();
102       try
103       {
104         jobs = prepare();
105       } catch (ServiceInputInvalidException e)
106       {
107         setStatus(JobStatus.INVALID);
108         eventHandler.fireTaskException(e);
109         throw e;
110       }
111       setStatus(JobStatus.READY);
112       eventHandler.fireTaskStarted(jobs);
113       for (var job : jobs)
114       {
115         job.addPropertyChagneListener(jobEventHandler);
116       }
117       try
118       {
119         startJobs();
120       } catch (IOException e)
121       {
122         eventHandler.fireTaskException(e);
123         cancelJobs();
124         setStatus(JobStatus.SERVER_ERROR);
125         throw e;
126       }
127       setStatus(JobStatus.SUBMITTED);
128     }
129
130     @Override
131     public boolean poll() throws Throwable
132     {
133       boolean done = AnnotationTask.this.poll();
134       updateGlobalStatus();
135       if (done)
136       {
137         retrieveAndProcessResult();
138         eventHandler.fireTaskCompleted(result);
139       }
140       return done;
141     }
142
143     private void retrieveAndProcessResult() throws IOException
144     {
145       result = retrieveResult();
146       updateOurAnnots(result.annotations);
147       if (result.transferFeatures)
148       {
149         final var featureColours = result.featureColours;
150         final var featureFilters = result.featureFilters;
151         viewport.applyFeaturesStyle(new FeatureSettingsAdapter()
152         {
153           @Override
154           public FeatureColourI getFeatureColour(String type)
155           {
156             return featureColours.get(type);
157           }
158
159           @Override
160           public FeatureMatcherSetI getFeatureFilters(String type)
161           {
162             return featureFilters.get(type);
163           }
164
165           @Override
166           public boolean isFeatureDisplayed(String type)
167           {
168             return featureColours.containsKey(type);
169           }
170         });
171       }
172     }
173
174     @Override
175     public void updateAnnotation()
176     {
177       var job = jobs.size() > 0 ? jobs.get(0) : null;
178       if (!calcMan.isWorking(this) && job != null)
179       {
180         var ret = updateResultAnnotation(job, job.returnedAnnotations);
181         updateOurAnnots(ret.get0());
182       }
183     }
184
185     private void updateOurAnnots(List<AlignmentAnnotation> newAnnots)
186     {
187       List<AlignmentAnnotation> oldAnnots = requireNonNullElse(ourAnnots, Collections.emptyList());
188       ourAnnots = newAnnots;
189       AlignmentI alignment = viewport.getAlignment();
190       for (AlignmentAnnotation an : oldAnnots)
191       {
192         if (!newAnnots.contains(an))
193         {
194           alignment.deleteAnnotation(an);
195         }
196       }
197       oldAnnots.clear();
198       for (AlignmentAnnotation an : ourAnnots)
199       {
200         viewport.getAlignment().validateAnnotation(an);
201       }
202     }
203
204     @Override
205     public void cancel()
206     {
207       cancelJobs();
208     }
209
210     void stop()
211     {
212       calcMan.disableWorker(this);
213       super.abortAndDestroy();
214     }
215
216     @Override
217     public void done()
218     {
219       for (var job : jobs)
220       {
221         if (job.isInputValid() && !job.isCompleted())
222         {
223           /* if done was called but job is not completed then it
224            * must have been stopped by an exception */
225           job.setStatus(JobStatus.SERVER_ERROR);
226         }
227       }
228       updateGlobalStatus();
229       // dispose of unfinished jobs just in case
230       cancelJobs();
231     }
232
233     @Override
234     public String toString()
235     {
236       return AnnotationTask.this.toString() + "$AlignCalcWorker@"
237           + Integer.toHexString(hashCode());
238     }
239   }
240
241   public AnnotationTask(AnnotationWebServiceClientI client,
242       AnnotationAction action, List<ArgumentI> args, Credentials credentials,
243       AlignViewportI viewport,
244       TaskEventListener<AnnotationResult> eventListener)
245   {
246     this.client = client;
247     this.action = action;
248     this.args = args;
249     this.credentials = credentials;
250     this.viewport = viewport;
251     this.eventHandler = new TaskEventSupport<>(this, eventListener);
252     this.jobEventHandler = new DelegateJobEventListener<>(this.eventHandler);
253   }
254
255   @Override
256   public long getUid()
257   {
258     return uid;
259   }
260
261   public void start(AlignCalcManagerI2 calcManager)
262   {
263     if (this.worker != null)
264       throw new IllegalStateException("task already started");
265     this.worker = new AlignCalcWorkerAdapter(calcManager);
266     if (taskStatus != JobStatus.CANCELLED)
267     {
268       List<AlignCalcWorkerI> oldWorkers = calcManager.getWorkersOfClass(
269           AlignCalcWorkerAdapter.class);
270       for (var worker : oldWorkers)
271       {
272         if (action.getWebService().getName().equalsIgnoreCase(
273             ((AlignCalcWorkerAdapter) worker).getServiceName()))
274         {
275           // remove interactive workers for the same service.
276           calcManager.removeWorker(worker);
277           calcManager.cancelWorker(worker);
278         }
279       }
280       if (action.getWebService().isInteractive())
281         calcManager.registerWorker(worker);
282       else
283         calcManager.startWorker(worker);
284     }
285   }
286
287   /*
288    * The following methods are mostly copied from the {@link AbstractPollableTask}
289    * TODO: move common functionality to a base class
290    */
291   @Override
292   public JobStatus getStatus()
293   {
294     return taskStatus;
295   }
296
297   private void setStatus(JobStatus status)
298   {
299     if (this.taskStatus != status)
300     {
301       Cache.log.debug(String.format("%s status change to %s", this, status.name()));
302       this.taskStatus = status;
303       eventHandler.fireTaskStatusChanged(status);
304     }
305   }
306
307   private void updateGlobalStatus()
308   {
309     int precedence = -1;
310     for (BaseJob job : jobs)
311     {
312       JobStatus status = job.getStatus();
313       int jobPrecedence = ArrayUtils.indexOf(JobStatus.statusPrecedence, status);
314       if (precedence < jobPrecedence)
315         precedence = jobPrecedence;
316     }
317     if (precedence >= 0)
318     {
319       setStatus(JobStatus.statusPrecedence[precedence]);
320     }
321   }
322
323   @Override
324   public List<? extends JobI> getSubJobs()
325   {
326     return jobs;
327   }
328
329   /**
330    * Create and return a list of annotation jobs from the current state of the
331    * viewport. Returned job are not started by this method and should be stored
332    * in a field and started separately.
333    * 
334    * @return list of annotation jobs
335    * @throws ServiceInputInvalidException
336    *           input data is not valid
337    */
338   private List<AnnotationJob> prepare() throws ServiceInputInvalidException
339   {
340     AlignmentI alignment = viewport.getAlignment();
341     if (alignment == null || alignment.getWidth() <= 0 ||
342         alignment.getSequences() == null)
343       throw new ServiceInputInvalidException("Alignment does not contain sequences");
344     if (alignment.isNucleotide() && !action.doAllowNucleotide())
345       throw new ServiceInputInvalidException(
346           action.getFullName() + " does not allow nucleotide sequences");
347     if (!alignment.isNucleotide() && !action.doAllowProtein())
348       throw new ServiceInputInvalidException(
349           action.getFullName() + " does not allow protein sequences");
350     boolean bySequence = !action.isAlignmentAnalysis();
351     AnnotatedCollectionI inputSeqs = bySequence ? viewport.getSelectionGroup() : null;
352     if (inputSeqs == null || inputSeqs.getWidth() <= 0 ||
353         inputSeqs.getSequences() == null || inputSeqs.getSequences().size() < 1)
354       inputSeqs = alignment;
355     boolean submitGaps = action.isAlignmentAnalysis();
356     boolean requireAligned = action.getRequireAlignedSequences();
357     boolean filterSymbols = action.getFilterSymbols();
358     int minSize = action.getMinSequences();
359     AnnotationJob job = AnnotationJob.create(inputSeqs, bySequence,
360         submitGaps, requireAligned, filterSymbols, minSize);
361     if (!job.isInputValid())
362     {
363       job.setStatus(JobStatus.INVALID);
364       throw new ServiceInputInvalidException("Annotation job has invalid input");
365     }
366     job.setStatus(JobStatus.READY);
367     return List.of(job);
368   }
369
370   private void startJobs() throws IOException
371   {
372     for (BaseJob job : jobs)
373     {
374       if (job.isInputValid() && job.getStatus() == JobStatus.READY)
375       {
376         var serverJob = client.submit(job.getInputSequences(),
377             args, credentials);
378         job.setServerJob(serverJob);
379         job.setStatus(JobStatus.SUBMITTED);
380       }
381     }
382   }
383
384   private boolean poll() throws IOException
385   {
386     boolean allDone = true;
387     for (BaseJob job : jobs)
388     {
389       if (job.isInputValid() && !job.getStatus().isDone())
390       {
391         WebServiceJobHandle serverJob = job.getServerJob();
392         job.setStatus(client.getStatus(serverJob));
393         job.setLog(client.getLog(serverJob));
394         job.setErrorLog(client.getErrorLog(serverJob));
395       }
396       allDone &= job.isCompleted();
397     }
398     return allDone;
399   }
400
401   private AnnotationResult retrieveResult() throws IOException
402   {
403     final Map<String, FeatureColourI> featureColours = new HashMap<>();
404     final Map<String, FeatureMatcherSetI> featureFilters = new HashMap<>();
405     var job = jobs.get(0);
406     List<AlignmentAnnotation> returnedAnnot = client.attachAnnotations(
407         job.getServerJob(), job.getInputSequences(), featureColours,
408         featureFilters);
409     /* TODO
410      * copy over each annotation row returned and also defined on each
411      * sequence, excluding regions not annotated due to gapMap/column
412      * visibility */
413
414     // update calcId if it is not already set on returned annotation
415     for (AlignmentAnnotation annot : returnedAnnot)
416     {
417       if (annot.getCalcId() == null || annot.getCalcId().isEmpty())
418       {
419         annot.setCalcId(action.getFullName());
420       }
421       annot.autoCalculated = action.isAlignmentAnalysis() &&
422           action.getWebService().isInteractive();
423     }
424     job.returnedAnnotations = returnedAnnot;
425     job.featureColours = featureColours;
426     job.featureFilters = featureFilters;
427     var ret = updateResultAnnotation(job, returnedAnnot);
428     var annotations = ret.get0();
429     var transferFeatures = ret.get1();
430     return new AnnotationResult(annotations, transferFeatures, featureColours,
431         featureFilters);
432   }
433
434   private Pair<List<AlignmentAnnotation>, Boolean> updateResultAnnotation(
435       AnnotationJob job, List<AlignmentAnnotation> annotations)
436   {
437     List<AlignmentAnnotation> newAnnots = new ArrayList<>();
438     // update graphGroup for all annotation
439     /* find a graphGroup greater than any existing one, could be moved
440      * to Alignment#getNewGraphGroup() - returns next unused graph group */
441     int graphGroup = 1;
442     if (viewport.getAlignment().getAlignmentAnnotation() != null)
443     {
444       for (var ala : viewport.getAlignment().getAlignmentAnnotation())
445       {
446         graphGroup = Math.max(graphGroup, ala.graphGroup);
447       }
448     }
449     // update graphGroup in the annotation rows returned form service'
450     /* TODO: look at sequence annotation rows and update graph groups in the
451      * case of reference annotation */
452     for (AlignmentAnnotation ala : annotations)
453     {
454       if (ala.graphGroup > 0)
455         ala.graphGroup += graphGroup;
456       SequenceI aseq = null;
457       // transfer sequence refs and adjust gapMap
458       if (ala.sequenceRef != null)
459       {
460         aseq = job.seqNames.get(ala.sequenceRef.getName());
461       }
462       ala.sequenceRef = aseq;
463
464       Annotation[] resAnnot = ala.annotations;
465       boolean[] gapMap = job.gapMap;
466       Annotation[] gappedAnnot = new Annotation[Math.max(
467           viewport.getAlignment().getWidth(), gapMap.length)];
468       for (int p = 0, ap = job.start; ap < gappedAnnot.length; ap++)
469       {
470         if (gapMap.length > ap && !gapMap[ap])
471           gappedAnnot[ap] = new Annotation("", "", ' ', Float.NaN);
472         else if (p < resAnnot.length)
473           gappedAnnot[ap] = resAnnot[p++];
474         // is this loop exhaustive of resAnnot?
475       }
476       ala.annotations = gappedAnnot;
477
478       AlignmentAnnotation newAnnot = viewport.getAlignment()
479           .updateFromOrCopyAnnotation(ala);
480       if (aseq != null)
481       {
482         aseq.addAlignmentAnnotation(newAnnot);
483         newAnnot.adjustForAlignment();
484         AlignmentAnnotationUtils.replaceAnnotationOnAlignmentWith(
485             newAnnot, newAnnot.label, newAnnot.getCalcId());
486       }
487       newAnnots.add(newAnnot);
488     }
489
490     boolean transferFeatures = false;
491     for (SequenceI sq : job.getInputSequences())
492     {
493       if (!sq.getFeatures().hasFeatures() &&
494           (sq.getDBRefs() == null || sq.getDBRefs().size() == 0))
495         continue;
496       transferFeatures = true;
497       SequenceI seq = job.seqNames.get(sq.getName());
498       SequenceI dseq;
499       int start = job.start, end = job.end;
500       boolean[] gapMap = job.gapMap;
501       ContiguousI seqRange = seq.findPositions(start, end);
502       while ((dseq = seq).getDatasetSequence() != null)
503       {
504         seq = seq.getDatasetSequence();
505       }
506       List<ContiguousI> sourceRange = new ArrayList<>();
507       if (gapMap.length >= end)
508       {
509         int lastcol = start, col = start;
510         do
511         {
512           if (col == end || !gapMap[col])
513           {
514             if (lastcol <= (col - 1))
515             {
516               seqRange = seq.findPositions(lastcol, col);
517               sourceRange.add(seqRange);
518             }
519             lastcol = col + 1;
520           }
521         } while (col++ < end);
522       }
523       else
524       {
525         sourceRange.add(seq.findPositions(start, end));
526       }
527
528       int i = 0;
529       int sourceStartEnd[] = new int[sourceRange.size() * 2];
530       for (ContiguousI range : sourceRange)
531       {
532         sourceStartEnd[i++] = range.getBegin();
533         sourceStartEnd[i++] = range.getEnd();
534       }
535       Mapping mp = new Mapping(new MapList(
536           sourceStartEnd, new int[]
537           { seq.getStart(), seq.getEnd() }, 1, 1));
538       dseq.transferAnnotation(sq, mp);
539     }
540
541     return new Pair<>(newAnnots, transferFeatures);
542   }
543
544   @Override
545   public AnnotationResult getResult()
546   {
547     return result;
548   }
549
550   @Override
551   public void cancel()
552   {
553     setStatus(JobStatus.CANCELLED);
554     if (worker != null)
555     {
556       worker.stop();
557     }
558     cancelJobs();
559   }
560
561   public void cancelJobs()
562   {
563     for (BaseJob job : jobs)
564     {
565       if (!job.isCompleted())
566       {
567         try
568         {
569           if (job.getServerJob() != null)
570           {
571             client.cancel(job.getServerJob());
572           }
573           job.setStatus(JobStatus.CANCELLED);
574         } catch (IOException e)
575         {
576           Cache.log.error(String.format(
577               "failed to cancel job %s", job.getServerJob()), e);
578         }
579       }
580     }
581   }
582
583   @Override
584   public String toString()
585   {
586     var status = taskStatus != null ? taskStatus.name() : "UNSET";
587     return String.format("%s(%x, %s)", getClass().getSimpleName(), uid, status);
588   }
589 }