/* 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.android; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.Map; import java.util.Set; import org.mozilla.gecko.sync.Logger; import org.mozilla.gecko.sync.Utils; import org.mozilla.gecko.sync.repositories.domain.BookmarkRecord; /** * Queue up insertions: * <ul> * <li>Folder inserts where the parent is known. Do these immediately, because * they allow other records to be inserted. Requires bookkeeping updates. On * insert, flush the next set.</li> * <li>Regular inserts where the parent is known. These can happen whenever. * Batch for speed.</li> * <li>Records where the parent is not known. These can be flushed out when the * parent is known, or entered as orphans. This can be a queue earlier in the * process, so they don't get assigned to Unsorted. Feed into the main batch * when the parent arrives.</li> * </ul> * <p> * Deletions are always done at the end so that orphaning is minimized, and * that's why we are batching folders and non-folders separately. * <p> * Updates are always applied as they arrive. * <p> * Note that this class is not thread safe. This should be fine: call it only * from within a store runnable. */ public class BookmarksInsertionManager { public static final String LOG_TAG = "BookmarkInsert"; public static boolean DEBUG = false; protected final int flushThreshold; protected final BookmarkInserter inserter; /** * Folders that have been successfully inserted. */ private final Set<String> insertedFolders = new HashSet<String>(); /** * Non-folders waiting for bulk insertion. * <p> * We write in insertion order to keep things easy to debug. */ private final Set<BookmarkRecord> nonFoldersToWrite = new LinkedHashSet<BookmarkRecord>(); /** * Map from parent folder GUID to child records (folders and non-folders) * waiting to be enqueued after parent folder is inserted. */ private final Map<String, Set<BookmarkRecord>> recordsWaitingForParent = new HashMap<String, Set<BookmarkRecord>>(); /** * Create an instance to be used for tracking insertions in a bookmarks * repository session. * * @param flushThreshold * When this many non-folder records have been stored for insertion, * an incremental flush occurs. * @param insertedFolders * The GUIDs of all the folders already inserted into the database. * @param inserter * The <code>BookmarkInsert</code> to use. */ public BookmarksInsertionManager(int flushThreshold, Collection<String> insertedFolders, BookmarkInserter inserter) { this.flushThreshold = flushThreshold; this.insertedFolders.addAll(insertedFolders); this.inserter = inserter; } protected void addRecordWithUnwrittenParent(BookmarkRecord record) { Set<BookmarkRecord> destination = recordsWaitingForParent.get(record.parentID); if (destination == null) { destination = new LinkedHashSet<BookmarkRecord>(); recordsWaitingForParent.put(record.parentID, destination); } destination.add(record); } /** * If <code>record</code> is a folder, insert it immediately; if it is a * non-folder, enqueue it. Then do the same for any records waiting for this record. * * @param record * the <code>BookmarkRecord</code> to enqueue. */ protected void recursivelyEnqueueRecordAndChildren(BookmarkRecord record) { if (record.isFolder()) { if (!inserter.insertFolder(record)) { Logger.warn(LOG_TAG, "Folder with known parent with guid " + record.parentID + " failed to insert!"); return; } Logger.debug(LOG_TAG, "Folder with known parent with guid " + record.parentID + " inserted; adding to inserted folders."); insertedFolders.add(record.guid); } else { Logger.debug(LOG_TAG, "Non-folder has known parent with guid " + record.parentID + "; adding to insertion queue."); nonFoldersToWrite.add(record); } // Now process record's children. Set<BookmarkRecord> waiting = recordsWaitingForParent.remove(record.guid); if (waiting == null) { return; } for (BookmarkRecord waiter : waiting) { recursivelyEnqueueRecordAndChildren(waiter); } } /** * Enqueue a folder. * * @param record * the folder to enqueue. */ protected void enqueueFolder(BookmarkRecord record) { Logger.debug(LOG_TAG, "Inserting folder with guid " + record.guid); if (!insertedFolders.contains(record.parentID)) { Logger.debug(LOG_TAG, "Folder has unknown parent with guid " + record.parentID + "; keeping until we see the parent."); addRecordWithUnwrittenParent(record); return; } // Parent is known; add as much of the tree as this roots. recursivelyEnqueueRecordAndChildren(record); flushNonFoldersIfNecessary(); } /** * Enqueue a non-folder. * * @param record * the non-folder to enqueue. */ protected void enqueueNonFolder(BookmarkRecord record) { Logger.debug(LOG_TAG, "Inserting non-folder with guid " + record.guid); if (!insertedFolders.contains(record.parentID)) { Logger.debug(LOG_TAG, "Non-folder has unknown parent with guid " + record.parentID + "; keeping until we see the parent."); addRecordWithUnwrittenParent(record); return; } // Parent is known; add to insertion queue and maybe write. Logger.debug(LOG_TAG, "Non-folder has known parent with guid " + record.parentID + "; adding to insertion queue."); nonFoldersToWrite.add(record); flushNonFoldersIfNecessary(); } /** * Enqueue a bookmark record for eventual insertion. * * @param record * the <code>BookmarkRecord</code> to enqueue. */ public void enqueueRecord(BookmarkRecord record) { if (record.isFolder()) { enqueueFolder(record); } else { enqueueNonFolder(record); } if (DEBUG) { dumpState(); } } /** * Flush non-folders; empties the insertion queue entirely. */ protected void flushNonFolders() { inserter.bulkInsertNonFolders(nonFoldersToWrite); // All errors are handled in bulkInsertNonFolders. nonFoldersToWrite.clear(); } /** * Flush non-folder insertions if there are many of them; empties the * insertion queue entirely. */ protected void flushNonFoldersIfNecessary() { int num = nonFoldersToWrite.size(); if (num < flushThreshold) { Logger.debug(LOG_TAG, "Incremental flush called with " + num + " < " + flushThreshold + " non-folders; not flushing."); return; } Logger.debug(LOG_TAG, "Incremental flush called with " + num + " non-folders; flushing."); flushNonFolders(); } /** * Insert all remaining folders followed by all remaining non-folders, * regardless of whether parent records have been successfully inserted. */ public void finishUp() { // Iterate through all waiting records, writing the folders and collecting // the non-folders for bulk insertion. int numFolders = 0; int numNonFolders = 0; for (Set<BookmarkRecord> records : recordsWaitingForParent.values()) { for (BookmarkRecord record : records) { if (!record.isFolder()) { numNonFolders += 1; nonFoldersToWrite.add(record); continue; } numFolders += 1; if (!inserter.insertFolder(record)) { Logger.warn(LOG_TAG, "Folder with known parent with guid " + record.parentID + " failed to insert!"); continue; } Logger.debug(LOG_TAG, "Folder with known parent with guid " + record.parentID + " inserted; adding to inserted folders."); insertedFolders.add(record.guid); } } recordsWaitingForParent.clear(); flushNonFolders(); Logger.debug(LOG_TAG, "finishUp inserted " + numFolders + " folders without known parents and " + numNonFolders + " non-folders without known parents."); if (DEBUG) { dumpState(); } } public void clear() { this.insertedFolders.clear(); this.nonFoldersToWrite.clear(); this.recordsWaitingForParent.clear(); } // For debugging. public boolean isClear() { return nonFoldersToWrite.isEmpty() && recordsWaitingForParent.isEmpty(); } // For debugging. public void dumpState() { ArrayList<String> readies = new ArrayList<String>(); for (BookmarkRecord record : nonFoldersToWrite) { readies.add(record.guid); } String ready = Utils.toCommaSeparatedString(new ArrayList<String>(readies)); ArrayList<String> waits = new ArrayList<String>(); for (Set<BookmarkRecord> recs : recordsWaitingForParent.values()) { for (BookmarkRecord rec : recs) { waits.add(rec.guid); } } String waiting = Utils.toCommaSeparatedString(waits); String known = Utils.toCommaSeparatedString(insertedFolders); Logger.debug(LOG_TAG, "Q=(" + ready + "), W = (" + waiting + "), P=(" + known + ")"); } public interface BookmarkInserter { /** * Insert a single folder. * <p> * All exceptions should be caught and all delegate callbacks invoked here. * * @param record * the record to insert. * @return * <code>true</code> if the folder was inserted; <code>false</code> otherwise. */ public boolean insertFolder(BookmarkRecord record); /** * Insert many non-folders. Each non-folder's parent was already present in * the database before this <code>BookmarkInsertionsManager</code> was * created, or had <code>insertFolder</code> called with it as argument (and * possibly was not inserted). * <p> * All exceptions should be caught and all delegate callbacks invoked here. * * @param records * the records to insert. */ public void bulkInsertNonFolders(Collection<BookmarkRecord> records); } }