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