package name.abuchen.portfolio.ui; import java.net.URI; import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.EnumSet; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.core.runtime.IStatus; import org.eclipse.core.runtime.MultiStatus; import org.eclipse.core.runtime.Status; import org.eclipse.core.runtime.jobs.ISchedulingRule; import org.eclipse.core.runtime.jobs.Job; import org.eclipse.core.runtime.jobs.JobGroup; import name.abuchen.portfolio.model.Client; import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.online.Factory; import name.abuchen.portfolio.online.QuoteFeed; import name.abuchen.portfolio.online.impl.HTMLTableQuoteFeed; public final class UpdateQuotesJob extends AbstractClientJob { public enum Target { LATEST, HISTORIC } /** * Keeps dirty state of parallel jobs and marks the client file dirty after * 5th dirty result. Background: marking the client dirty after every job * sends too many update events to the GUI. */ private static class Dirtyable { private static final int THRESHOLD = 5; private final Client client; private AtomicInteger counter; public Dirtyable(Client client) { this.client = client; this.counter = new AtomicInteger(); } public void markDirty() { int count = counter.incrementAndGet(); if (count % THRESHOLD == 0) client.markDirty(); } public boolean isDirty() { return counter.get() % THRESHOLD != 0; } } /** * Ensure that the HTMLTableQuoteFeed retrieves quotes from one host * sequentially. #478 */ private static class HostSchedulingRule implements ISchedulingRule { private final String host; private HostSchedulingRule(String host) { this.host = host; } @Override public boolean contains(ISchedulingRule rule) { return isConflicting(rule); } @Override public boolean isConflicting(ISchedulingRule rule) { return rule instanceof HostSchedulingRule && ((HostSchedulingRule) rule).host.equals(this.host); } public static ISchedulingRule createFor(String url) { try { final String hostname = new URI(url).getHost(); return hostname != null ? new HostSchedulingRule(hostname) : null; } catch (URISyntaxException e) // NOSONAR { // ignore syntax exception -> quote feed provide will also // complain but with a better error message return null; } } } private final Set<Target> target; private final List<Security> securities; private long repeatPeriod; public UpdateQuotesJob(Client client, Set<Target> target) { this(client, client.getSecurities(), target); } public UpdateQuotesJob(Client client, Security security) { this(client, Arrays.asList(security), EnumSet.allOf(Target.class)); } public UpdateQuotesJob(Client client, List<Security> securities, Set<Target> target) { super(client, Messages.JobLabelUpdateQuotes); this.target = target; this.securities = new ArrayList<>(securities); } public UpdateQuotesJob repeatEvery(long milliseconds) { this.repeatPeriod = milliseconds; return this; } @Override protected IStatus run(IProgressMonitor monitor) { monitor.beginTask(Messages.JobLabelUpdating, IProgressMonitor.UNKNOWN); Dirtyable dirtyable = new Dirtyable(getClient()); List<Job> jobs = new ArrayList<>(); // include latest quotes if (target.contains(Target.LATEST)) addLatestQuotesJobs(dirtyable, jobs); // include historical quotes if (target.contains(Target.HISTORIC)) addHistoricalQuotesJobs(dirtyable, jobs); if (monitor.isCanceled()) return Status.CANCEL_STATUS; if (!jobs.isEmpty()) runJobs(monitor, jobs); if (!monitor.isCanceled() && dirtyable.isDirty()) getClient().markDirty(); if (repeatPeriod > 0) schedule(repeatPeriod); return Status.OK_STATUS; } private void runJobs(IProgressMonitor monitor, List<Job> jobs) { JobGroup group = new JobGroup(Messages.JobLabelUpdating, 10, jobs.size()); for (Job job : jobs) { job.setJobGroup(group); job.schedule(); } try { group.join(0, monitor); } catch (InterruptedException ignore) // NOSONAR { // ignore } } private void addLatestQuotesJobs(Dirtyable dirtyable, List<Job> jobs) { Map<QuoteFeed, List<Security>> feed2securities = new HashMap<>(); for (Security s : securities) { // if configured, use feed for latest quotes // otherwise use the default feed used by historical quotes as well String feedId = s.getLatestFeed(); if (feedId == null) feedId = s.getFeed(); QuoteFeed feed = Factory.getQuoteFeedProvider(feedId); if (feed == null) continue; // the HTML download makes request per URL (per security) -> execute // as parallel jobs (although the scheduling rule ensures that only // one request is made per host at a given time) if (HTMLTableQuoteFeed.ID.equals(feedId)) { Job job = createLatestQuoteJob(dirtyable, feed, Arrays.asList(s)); job.setRule(HostSchedulingRule .createFor(s.getLatestFeedURL() == null ? s.getFeedURL() : s.getLatestFeedURL())); jobs.add(job); } else { feed2securities.computeIfAbsent(feed, key -> new ArrayList<>()).add(s); } } for (Entry<QuoteFeed, List<Security>> entry : feed2securities.entrySet()) jobs.add(createLatestQuoteJob(dirtyable, entry.getKey(), entry.getValue())); } private Job createLatestQuoteJob(Dirtyable dirtyable, QuoteFeed feed, List<Security> securities) { return new Job(feed.getName()) { @Override protected IStatus run(IProgressMonitor monitor) { ArrayList<Exception> exceptions = new ArrayList<>(); if (feed.updateLatestQuotes(securities, exceptions)) dirtyable.markDirty(); if (!exceptions.isEmpty()) PortfolioPlugin.log(createErrorStatus(feed.getName(), exceptions)); return Status.OK_STATUS; } }; } private void addHistoricalQuotesJobs(Dirtyable dirtyable, List<Job> jobs) { // randomize list in case LRU cache size of HTMLTableQuote feed is too // small; otherwise entries would be evicted in order Collections.shuffle(securities); for (Security security : securities) { Job job = new Job(security.getName()) { @Override protected IStatus run(IProgressMonitor monitor) { QuoteFeed feed = Factory.getQuoteFeedProvider(security.getFeed()); if (feed == null) return Status.OK_STATUS; ArrayList<Exception> exceptions = new ArrayList<>(); if (feed.updateHistoricalQuotes(security, exceptions)) dirtyable.markDirty(); if (!exceptions.isEmpty()) PortfolioPlugin.log(createErrorStatus(security.getName(), exceptions)); return Status.OK_STATUS; } }; if (HTMLTableQuoteFeed.ID.equals(security.getFeed())) job.setRule(HostSchedulingRule.createFor(security.getFeedURL())); jobs.add(job); } } private IStatus createErrorStatus(String label, List<Exception> exceptions) { MultiStatus status = new MultiStatus(PortfolioPlugin.PLUGIN_ID, IStatus.ERROR, label, null); for (Exception exception : exceptions) status.add(new Status(IStatus.ERROR, PortfolioPlugin.PLUGIN_ID, exception.getMessage(), exception)); return status; } }