/* This program is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. */ package org.opentripplanner.routing.edgetype; import java.util.*; import java.util.Map.Entry; import org.onebusaway.gtfs.model.calendar.ServiceDate; import org.opentripplanner.routing.trippattern.TripTimes; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.google.common.base.Preconditions; // this is only currently in edgetype because that's where Trippattern is. // move these classes elsewhere. /** * Part of concurrency control for stoptime updates. * * All updates should be performed on a snapshot before it is handed off to any searches. * A single snapshot should be used for an entire search, and should remain unchanged * for that duration to provide a consistent view not only of trips that have been boarded, but of * relative arrival and departure times of other trips that have not necessarily been boarded. * * At this point, only one writing thread at a time is supported. */ public class TimetableSnapshot { protected static class SortedTimetableComparator implements Comparator<Timetable> { @Override public int compare(Timetable t1, Timetable t2) { return t1.serviceDate.compareTo(t2.serviceDate); } } /** * Class to use as key in HashMap containing feed id, trip id and service date */ protected class TripIdAndServiceDate { private final String feedId; private final String tripId; private final ServiceDate serviceDate; public TripIdAndServiceDate(final String feedId, final String tripId, final ServiceDate serviceDate) { this.feedId = feedId; this.tripId = tripId; this.serviceDate = serviceDate; } public String getFeedId() { return feedId; } public String getTripId() { return tripId; } public ServiceDate getServiceDate() { return serviceDate; } @Override public int hashCode() { int result = Objects.hash(tripId, serviceDate, feedId); return result; } @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (getClass() != obj.getClass()) return false; TripIdAndServiceDate other = (TripIdAndServiceDate) obj; boolean result = Objects.equals(this.tripId, other.tripId) && Objects.equals(this.serviceDate, other.serviceDate) && Objects.equals(this.feedId, other.feedId); return result; } } private static final Logger LOG = LoggerFactory.getLogger(TimetableSnapshot.class); // Use HashMap not Map so we can clone. // if this turns out to be slow/spacious we can use an array with integer pattern indexes // The SortedSet members are copy-on-write // FIXME: this could be made into a flat hashtable with compound keys. private HashMap<TripPattern, SortedSet<Timetable>> timetables = new HashMap<TripPattern, SortedSet<Timetable>>(); /** * <p> * Map containing the last <b>added</b> trip pattern given a trip id (without agency) and a * service date as a result of a call to {@link #update(String feedId, TripPattern, TripTimes, ServiceDate)} * with trip times of a trip that didn't exist yet in the trip pattern. * </p> * <p> * This is a HashMap and not a Map so the clone function is available. * </p> */ private HashMap<TripIdAndServiceDate, TripPattern> lastAddedTripPattern = new HashMap<>(); /** * Boolean value indicating that timetable snapshot is read only if true. Once it is true, it shouldn't * be possible to change it to false anymore. */ private boolean readOnly = false; /** * Boolean value indicating that this timetable snapshot contains changes compared to the state * of the last commit if true. */ private boolean dirty = false; /** * A set of all timetables which have been modified and are waiting to be indexed. When * <code>dirty</code> is <code>null</code>, it indicates that the snapshot is read-only. */ private Set<Timetable> dirtyTimetables = new HashSet<Timetable>(); /** * Returns an updated timetable for the specified pattern if one is available in this snapshot, * or the originally scheduled timetable if there are no updates in this snapshot. */ public Timetable resolve(TripPattern pattern, ServiceDate serviceDate) { SortedSet<Timetable> sortedTimetables = timetables.get(pattern); if(sortedTimetables != null && serviceDate != null) { for(Timetable timetable : sortedTimetables) { if (timetable != null && timetable.isValidFor(serviceDate)) { LOG.trace("returning modified timetable"); return timetable; } } } return pattern.scheduledTimetable; } /** * Get the last <b>added</b> trip pattern given a trip id (without agency) and a service date as * a result of a call to {@link #update(String feedId, TripPattern, TripTimes, ServiceDate)} with trip times of * a trip that didn't exist yet in the trip pattern. * * @param feedId feed id the trip id belongs to * @param tripId trip id (without agency) * @param serviceDate service date * @return last added trip pattern; null if trip never was added to a trip pattern */ public TripPattern getLastAddedTripPattern(String feedId, String tripId, ServiceDate serviceDate) { TripIdAndServiceDate tripIdAndServiceDate = new TripIdAndServiceDate(feedId, tripId, serviceDate); TripPattern pattern = lastAddedTripPattern.get(tripIdAndServiceDate); return pattern; } /** * Update the trip times of one trip in a timetable of a trip pattern. If the trip of the trip * times does not exist yet in the timetable, add it. * * @param pattern trip pattern * @param updatedTripTimes updated trip times * @param serviceDate service day for which this update is valid * @return whether or not the update was actually applied */ public boolean update(String feedId, TripPattern pattern, TripTimes updatedTripTimes, ServiceDate serviceDate) { // Preconditions Preconditions.checkNotNull(pattern); Preconditions.checkNotNull(serviceDate); if (readOnly) { throw new ConcurrentModificationException("This TimetableSnapshot is read-only."); } Timetable tt = resolve(pattern, serviceDate); // we need to perform the copy of Timetable here rather than in Timetable.update() // to avoid repeatedly copying in case several updates are applied to the same timetable if ( ! dirtyTimetables.contains(tt)) { Timetable old = tt; tt = new Timetable(tt, serviceDate); SortedSet<Timetable> sortedTimetables = timetables.get(pattern); if(sortedTimetables == null) { sortedTimetables = new TreeSet<Timetable>(new SortedTimetableComparator()); } else { SortedSet<Timetable> temp = new TreeSet<Timetable>(new SortedTimetableComparator()); temp.addAll(sortedTimetables); sortedTimetables = temp; } if(old.serviceDate != null) sortedTimetables.remove(old); sortedTimetables.add(tt); timetables.put(pattern, sortedTimetables); dirtyTimetables.add(tt); dirty = true; } // Assume all trips in a pattern are from the same feed, which should be the case. // Find trip index int tripIndex = tt.getTripIndex(updatedTripTimes.trip.getId()); if (tripIndex == -1) { // Trip not found, add it tt.addTripTimes(updatedTripTimes); // Remember this pattern for the added trip id and service date String tripId = updatedTripTimes.trip.getId().getId(); TripIdAndServiceDate tripIdAndServiceDate = new TripIdAndServiceDate(feedId, tripId, serviceDate); lastAddedTripPattern.put(tripIdAndServiceDate, pattern); } else { // Set updated trip times of trip tt.setTripTimes(tripIndex, updatedTripTimes); } // The time tables are finished during the commit return true; } /** * This produces a small delay of typically around 50ms, which is almost entirely due to * the indexing step. Cloning the map is much faster (2ms). * It is perhaps better to index timetables as they are changed to avoid experiencing all * this lag at once, but we want to avoid re-indexing when receiving multiple updates for * the same timetable in rapid succession. This compromise is expressed by the * maxSnapshotFrequency property of StoptimeUpdater. The indexing could be made much more * efficient as well. * @return an immutable copy of this TimetableSnapshot with all updates applied */ public TimetableSnapshot commit() { return commit(false); } @SuppressWarnings("unchecked") public TimetableSnapshot commit(boolean force) { if (readOnly) { throw new ConcurrentModificationException("This TimetableSnapshot is read-only."); } TimetableSnapshot ret = new TimetableSnapshot(); if (!force && !this.isDirty()) return null; for (Timetable tt : dirtyTimetables) { tt.finish(); // summarize, index, etc. the new timetables } ret.timetables = (HashMap<TripPattern, SortedSet<Timetable>>) this.timetables.clone(); ret.lastAddedTripPattern = (HashMap<TripIdAndServiceDate, TripPattern>) this.lastAddedTripPattern.clone(); this.dirtyTimetables.clear(); this.dirty = false; ret.readOnly = true; // mark the snapshot as henceforth immutable return ret; } /** * Clear all data of snapshot for the provided feed id * * @param feedId feed id to clear the snapshop for */ public void clear(String feedId) { if (readOnly) { throw new ConcurrentModificationException("This TimetableSnapshot is read-only."); } // Clear all data from snapshot. boolean timetableWasModified = clearTimetable(feedId); boolean lastAddedWasModified = clearLastAddedTripPattern(feedId); // If this snapshot was modified, it will be dirty after the clear actions. if (timetableWasModified || lastAddedWasModified) { dirty = true; } } /** * Clear timetable for all patterns matching the provided feed id. * * @param feedId feed id to clear out * @return true if the timetable changed as a result of the call */ protected boolean clearTimetable(String feedId) { return timetables.keySet().removeIf(tripPattern -> feedId.equals(tripPattern.getFeedId())); } /** * Clear all last added trip patterns matching the provided feed id. * * @param feedId feed id to clear out * @return true if the lastAddedTripPattern changed as a result of the call */ protected boolean clearLastAddedTripPattern(String feedId) { return lastAddedTripPattern.keySet().removeIf(lastAddedTripPattern -> feedId.equals(lastAddedTripPattern.getFeedId())); } /** * Removes all Timetables which are valid for a ServiceDate on-or-before the one supplied. */ public boolean purgeExpiredData(ServiceDate serviceDate) { if (readOnly) { throw new ConcurrentModificationException("This TimetableSnapshot is read-only."); } boolean modified = false; for (Iterator<TripPattern> it = timetables.keySet().iterator(); it.hasNext();){ TripPattern pattern = it.next(); SortedSet<Timetable> sortedTimetables = timetables.get(pattern); SortedSet<Timetable> toKeepTimetables = new TreeSet<Timetable>(new SortedTimetableComparator()); for(Timetable timetable : sortedTimetables) { if(serviceDate.compareTo(timetable.serviceDate) < 0) { toKeepTimetables.add(timetable); } else { modified = true; } } if(toKeepTimetables.isEmpty()) { it.remove(); } else { timetables.put(pattern, toKeepTimetables); } } // Also remove last added trip pattern for days that are purged for (Iterator<Entry<TripIdAndServiceDate, TripPattern>> iterator = lastAddedTripPattern .entrySet().iterator(); iterator.hasNext();) { TripIdAndServiceDate tripIdAndServiceDate = iterator.next().getKey(); if (serviceDate.compareTo(tripIdAndServiceDate.getServiceDate()) >= 0) { iterator.remove(); modified = true; } } return modified; } public boolean isDirty() { if (readOnly) return false; return dirty; } public String toString() { String d = readOnly ? "committed" : String.format("%d dirty", dirtyTimetables.size()); return String.format("Timetable snapshot: %d timetables (%s)", timetables.size(), d); } }