/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ package org.mozilla.gecko.sync.repositories; import java.util.ArrayList; import java.util.Collection; import java.util.Iterator; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import org.mozilla.gecko.sync.Logger; import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionBeginDelegate; import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate; import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate; import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionGuidsSinceDelegate; import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionStoreDelegate; import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionWipeDelegate; import org.mozilla.gecko.sync.repositories.domain.Record; /** * A <code>RepositorySession</code> is created and used thusly: * *<ul> * <li>Construct, with a reference to its parent {@link Repository}, by calling * {@link Repository#createSession(RepositorySessionCreationDelegate, android.content.Context)}.</li> * <li>Populate with saved information by calling {@link #unbundle(RepositorySessionBundle)}.</li> * <li>Begin a sync by calling {@link #begin(RepositorySessionBeginDelegate)}. <code>begin()</code> * is an appropriate place to initialize expensive resources.</li> * <li>Perform operations such as {@link #fetchSince(long, RepositorySessionFetchRecordsDelegate)} and * {@link #store(Record)}.</li> * <li>Finish by calling {@link #finish(RepositorySessionFinishDelegate)}, retrieving and storing * the current bundle.</li> *</ul> * * If <code>finish()</code> is not called, {@link #abort()} must be called. These calls must * <em>always</em> be paired with <code>begin()</code>. * */ public abstract class RepositorySession { public enum SessionStatus { UNSTARTED, ACTIVE, ABORTED, DONE } private static final String LOG_TAG = "RepositorySession"; protected static void trace(String message) { Logger.trace(LOG_TAG, message); } private SessionStatus status = SessionStatus.UNSTARTED; protected Repository repository; protected RepositorySessionStoreDelegate delegate; /** * A queue of Runnables which call out into delegates. */ protected ExecutorService delegateQueue = Executors.newSingleThreadExecutor(); /** * A queue of Runnables which effect storing. * This includes actual store work, and also the consequences of storeDone. * This provides strict ordering. */ protected ExecutorService storeWorkQueue = Executors.newSingleThreadExecutor(); // The time that the last sync on this collection completed, in milliseconds since epoch. public long lastSyncTimestamp; public static long now() { return System.currentTimeMillis(); } public RepositorySession(Repository repository) { this.repository = repository; } public abstract void guidsSince(long timestamp, RepositorySessionGuidsSinceDelegate delegate); public abstract void fetchSince(long timestamp, RepositorySessionFetchRecordsDelegate delegate); public abstract void fetch(String[] guids, RepositorySessionFetchRecordsDelegate delegate) throws InactiveSessionException; public abstract void fetchAll(RepositorySessionFetchRecordsDelegate delegate); /** * Override this if you wish to short-circuit a sync when you know -- * e.g., by inspecting the database or info/collections -- that no new * data are available. * * @return true if a sync should proceed. */ public boolean dataAvailable() { return true; } /** * @return true if we cannot safely sync from this <code>RepositorySession</code>. */ public boolean shouldSkip() { return false; } /* * Store operations proceed thusly: * * * Set a delegate * * Store an arbitrary number of records. At any time the delegate can be * notified of an error. * * Call storeDone to notify the session that no more items are forthcoming. * * The store delegate will be notified of error or completion. * * This arrangement of calls allows for batching at the session level. * * Store success calls are not guaranteed. */ public void setStoreDelegate(RepositorySessionStoreDelegate delegate) { Logger.debug(LOG_TAG, "Setting store delegate to " + delegate); this.delegate = delegate; } public abstract void store(Record record) throws NoStoreDelegateException; public void storeDone() { // Our default behavior will be to assume that the Runnable is // executed as soon as all the stores synchronously finish, so // our end timestamp can just be… now. storeDone(now()); } public void storeDone(final long end) { Logger.debug(LOG_TAG, "Scheduling onStoreCompleted for after storing is done: " + end); Runnable command = new Runnable() { @Override public void run() { delegate.onStoreCompleted(end); } }; storeWorkQueue.execute(command); } public abstract void wipe(RepositorySessionWipeDelegate delegate); public void unbundle(RepositorySessionBundle bundle) { this.lastSyncTimestamp = 0; if (bundle == null) { return; } if (bundle.containsKey("timestamp")) { try { this.lastSyncTimestamp = bundle.getLong("timestamp"); } catch (Exception e) { // Defaults to 0 above. } } } /** * Synchronously perform the shared work of beginning. Throws on failure. * @throws InvalidSessionTransitionException * */ protected void sharedBegin() throws InvalidSessionTransitionException { Logger.debug(LOG_TAG, "Shared begin."); if (delegateQueue.isShutdown()) { throw new InvalidSessionTransitionException(null); } if (storeWorkQueue.isShutdown()) { throw new InvalidSessionTransitionException(null); } this.transitionFrom(SessionStatus.UNSTARTED, SessionStatus.ACTIVE); } /** * Start the session. This is an appropriate place to initialize * data access components such as database handles. * * @param delegate * @throws InvalidSessionTransitionException */ public void begin(RepositorySessionBeginDelegate delegate) throws InvalidSessionTransitionException { sharedBegin(); delegate.deferredBeginDelegate(delegateQueue).onBeginSucceeded(this); } protected RepositorySessionBundle getBundle() { return this.getBundle(null); } /** * Override this in your subclasses to return values to save between sessions. * Note that RepositorySession automatically bumps the timestamp to the time * the last sync began. If unbundled but not begun, this will be the same as the * value in the input bundle. * * The Synchronizer most likely wants to bump the bundle timestamp to be a value * return from a fetch call. */ protected RepositorySessionBundle getBundle(RepositorySessionBundle optional) { Logger.debug(LOG_TAG, "RepositorySession.getBundle(optional)."); // Why don't we just persist the old bundle? RepositorySessionBundle bundle = (optional == null) ? new RepositorySessionBundle() : optional; bundle.put("timestamp", this.lastSyncTimestamp); Logger.debug(LOG_TAG, "Setting bundle timestamp to " + this.lastSyncTimestamp); return bundle; } /** * Just like finish(), but doesn't do any work that should only be performed * at the end of a successful sync, and can be called any time. */ public void abort(RepositorySessionFinishDelegate delegate) { this.abort(); delegate.deferredFinishDelegate(delegateQueue).onFinishSucceeded(this, this.getBundle(null)); } /** * Abnormally terminate the repository session, freeing or closing * any resources that were opened during the lifetime of the session. */ public void abort() { // TODO: do something here. this.setStatus(SessionStatus.ABORTED); try { storeWorkQueue.shutdownNow(); } catch (Exception e) { Logger.error(LOG_TAG, "Caught exception shutting down store work queue.", e); } try { delegateQueue.shutdown(); } catch (Exception e) { Logger.error(LOG_TAG, "Caught exception shutting down delegate queue.", e); } } /** * End the repository session, freeing or closing any resources * that were opened during the lifetime of the session. * * @param delegate notified of success or failure. * @throws InactiveSessionException */ public void finish(final RepositorySessionFinishDelegate delegate) throws InactiveSessionException { try { this.transitionFrom(SessionStatus.ACTIVE, SessionStatus.DONE); delegate.deferredFinishDelegate(delegateQueue).onFinishSucceeded(this, this.getBundle(null)); } catch (InvalidSessionTransitionException e) { Logger.error(LOG_TAG, "Tried to finish() an unstarted or already finished session"); throw new InactiveSessionException(e); } Logger.trace(LOG_TAG, "Shutting down work queues."); storeWorkQueue.shutdown(); delegateQueue.shutdown(); } /** * Run the provided command if we're active and our delegate queue * is not shut down. */ protected synchronized void executeDelegateCommand(Runnable command) throws InactiveSessionException { if (!isActive() || delegateQueue.isShutdown()) { throw new InactiveSessionException(null); } delegateQueue.execute(command); } public synchronized void ensureActive() throws InactiveSessionException { if (!isActive()) { throw new InactiveSessionException(null); } } public synchronized boolean isActive() { return status == SessionStatus.ACTIVE; } public synchronized SessionStatus getStatus() { return status; } public synchronized void setStatus(SessionStatus status) { this.status = status; } public synchronized void transitionFrom(SessionStatus from, SessionStatus to) throws InvalidSessionTransitionException { if (from == null || this.status == from) { Logger.trace(LOG_TAG, "Successfully transitioning from " + this.status + " to " + to); this.status = to; return; } Logger.warn(LOG_TAG, "Wanted to transition from " + from + " but in state " + this.status); throw new InvalidSessionTransitionException(null); } /** * Produce a record that is some combination of the remote and local records * provided. * * The returned record must be produced without mutating either remoteRecord * or localRecord. It is acceptable to return either remoteRecord or localRecord * if no modifications are to be propagated. * * The returned record *should* have the local androidID and the remote GUID, * and some optional merge of data from the two records. * * This method can be called with records that are identical, or differ in * any regard. * * This method will not be called if: * * * either record is marked as deleted, or * * there is no local mapping for a new remote record. * * Otherwise, it will be called precisely once. * * Side-effects (e.g., for transactional storage) can be hooked in here. * * @param remoteRecord * The record retrieved from upstream, already adjusted for clock skew. * @param localRecord * The record retrieved from local storage. * @param lastRemoteRetrieval * The timestamp of the last retrieved set of remote records, adjusted for * clock skew. * @param lastLocalRetrieval * The timestamp of the last retrieved set of local records. * @return * A Record instance to apply, or null to apply nothing. */ protected Record reconcileRecords(final Record remoteRecord, final Record localRecord, final long lastRemoteRetrieval, final long lastLocalRetrieval) { Logger.debug(LOG_TAG, "Reconciling remote " + remoteRecord.guid + " against local " + localRecord.guid); if (localRecord.equalPayloads(remoteRecord)) { if (remoteRecord.lastModified > localRecord.lastModified) { Logger.debug(LOG_TAG, "Records are equal. No record application needed."); return null; } // Local wins. return null; } // TODO: Decide what to do based on: // * Which of the two records is modified; // * Whether they are equal or congruent; // * The modified times of each record (interpreted through the lens of clock skew); // * ... boolean localIsMoreRecent = localRecord.lastModified > remoteRecord.lastModified; Logger.debug(LOG_TAG, "Local record is more recent? " + localIsMoreRecent); Record donor = localIsMoreRecent ? localRecord : remoteRecord; // Modify the local record to match the remote record's GUID and values. // Preserve the local Android ID, and merge data where possible. // It sure would be nice if copyWithIDs didn't give a shit about androidID, mm? Record out = donor.copyWithIDs(remoteRecord.guid, localRecord.androidID); // We don't want to upload the record if the remote record was // applied without changes. // This logic will become more complicated as reconciling becomes smarter. if (!localIsMoreRecent) { trackGUID(out.guid); } return out; } /** * Depending on the RepositorySession implementation, track * that a record — most likely a brand-new record that has been * applied unmodified — should be tracked so as to not be uploaded * redundantly. * * The default implementations do nothing. */ protected void trackGUID(String guid) { } protected synchronized void untrackGUIDs(Collection<String> guids) { } protected void untrackGUID(String guid) { } // Ah, Java. You wretched creature. public Iterator<String> getTrackedRecordIDs() { return new ArrayList<String>().iterator(); } }