On-demand downloading: Placeholder-file based prototype
authorChristian Kamm <mail@ckamm.de>
Wed, 13 Dec 2017 17:04:58 +0000 (18:04 +0100)
committerKevin Ottens <kevin.ottens@nextcloud.com>
Tue, 15 Dec 2020 09:57:43 +0000 (10:57 +0100)
- Controled by an option.
- New remote files start out as ItemTypePlaceholder, are created with a
  .owncloud extension.
- When their db entry is set to ItemTypePlaceholderDownload the next
  sync run will download them.
- Files that aren't in the placeholder state sync as usual.
- See test cases in testsyncplaceholders.

Missing:
- User ui for triggering placeholder file download
- Maybe: Going back from file to placeholder?

12 files changed:
src/csync/csync.h
src/csync/csync_private.h
src/csync/csync_reconcile.cpp
src/csync/csync_update.cpp
src/libsync/account.h
src/libsync/owncloudpropagator.cpp
src/libsync/owncloudpropagator.h
src/libsync/propagatedownload.cpp
src/libsync/propagatorjobs.cpp
src/libsync/syncengine.cpp
test/CMakeLists.txt
test/testsyncplaceholders.cpp [new file with mode: 0644]

index fb9f4682d180cdcdfe2f6346217167a9a055e59e..db21803ee64f017b908a9775bcb83411b80c869b 100644 (file)
@@ -135,7 +135,9 @@ enum ItemType {
     ItemTypeFile = 0,
     ItemTypeSoftLink = 1,
     ItemTypeDirectory = 2,
-    ItemTypeSkip = 3
+    ItemTypeSkip = 3,
+    ItemTypePlaceholder = 4,
+    ItemTypePlaceholderDownload = 5
 };
 
 
index 2345025d21d57e5f34ab88a3f99a3582a77d44b4..2ba22035884b1010366d1564712e6478de8eb45c 100644 (file)
@@ -207,6 +207,11 @@ struct OCSYNC_EXPORT csync_s {
 
   bool upload_conflict_files = false;
 
+  /**
+   * Whether new remote files should start out as placeholders.
+   */
+  bool new_files_are_placeholders = false;
+
   csync_s(const char *localUri, OCC::SyncJournalDb *statedb);
   ~csync_s();
   int reinitialize();
index 0681ce4ae794b6f13bb26f1bdc6ebc7cad930992..ee0c9b72bba2298581a725e447764b86cb90bf9a 100644 (file)
@@ -150,6 +150,12 @@ static void _csync_merge_algorithm_visitor(csync_file_stat_t *cur, CSYNC * ctx)
                 cur->instruction = CSYNC_INSTRUCTION_NEW;
                 break;
             }
+            if (cur->type == ItemTypePlaceholder && ctx->current == REMOTE_REPLICA) {
+                /* Do not remove on the server if the local placeholder is gone:
+                 * instead reestablish the local placeholder */
+                cur->instruction = CSYNC_INSTRUCTION_NEW;
+                break;
+            }
             cur->instruction = CSYNC_INSTRUCTION_REMOVE;
             break;
         case CSYNC_INSTRUCTION_EVAL_RENAME: {
@@ -397,7 +403,10 @@ static void _csync_merge_algorithm_visitor(csync_file_stat_t *cur, CSYNC * ctx)
                     cur->instruction = CSYNC_INSTRUCTION_UPDATE_METADATA;
                     other->instruction = CSYNC_INSTRUCTION_NONE;
                 } else {
-                    cur->instruction = CSYNC_INSTRUCTION_SYNC;
+                    if (cur->instruction != CSYNC_INSTRUCTION_NEW
+                        && cur->instruction != CSYNC_INSTRUCTION_SYNC) {
+                        cur->instruction = CSYNC_INSTRUCTION_SYNC;
+                    }
                     other->instruction = CSYNC_INSTRUCTION_NONE;
                 }
                 break;
index d90a1059a5bed864630c0e5977ae7adb7b48571e..d72a0ec5159511845dd38061c9bb2653852316b2 100644 (file)
@@ -214,16 +214,28 @@ static int _csync_detect_update(CSYNC *ctx, std::unique_ptr<csync_file_stat_t> f
       qCInfo(lcUpdate, "Database entry found for %s, compare: %" PRId64 " <-> %" PRId64
                                           ", etag: %s <-> %s, inode: %" PRId64 " <-> %" PRId64
                                           ", size: %" PRId64 " <-> %" PRId64 ", perms: %x <-> %x"
-                                          ", checksum: %s <-> %s , ignore: %d,  e2e: %s",
+                                          ", checksum: %s <-> %s, type: %d <-> %d, ignore: %d,  e2e: %s",
                 base._path.constData(), ((int64_t) fs->modtime), ((int64_t) base._modtime),
                 fs->etag.constData(), base._etag.constData(), (uint64_t) fs->inode, (uint64_t) base._inode,
                 (uint64_t) fs->size, (uint64_t) base._fileSize, *reinterpret_cast<short*>(&fs->remotePerm), *reinterpret_cast<short*>(&base._remotePerm),
-                fs->checksumHeader.constData(), base._checksumHeader.constData(), base._serverHasIgnoredFiles, base._e2eMangledName.constData());
+                fs->checksumHeader.constData(), base._checksumHeader.constData(),
+                fs->type, base._type,
+                base._serverHasIgnoredFiles, base._e2eMangledName.constData());
+      if (ctx->current == REMOTE_REPLICA && base._type == ItemTypePlaceholderDownload) {
+          fs->instruction = CSYNC_INSTRUCTION_NEW;
+          fs->type = ItemTypePlaceholderDownload;
+          goto out;
+      }
+
       if (ctx->current == REMOTE_REPLICA && fs->etag != base._etag) {
           fs->instruction = CSYNC_INSTRUCTION_EVAL;
 
-          // Preserve the EVAL flag later on if the type has changed.
-          if (base._type != fs->type) {
+          if (base._type == ItemTypePlaceholder && fs->type == ItemTypeFile) {
+              // If the local thing is a placeholder, we just update the metadata
+              fs->instruction = CSYNC_INSTRUCTION_UPDATE_METADATA;
+              fs->type = ItemTypePlaceholder; // retain the PLACEHOLDER type in the db
+          } else if (base._type != fs->type) {
+              // Preserve the EVAL flag later on if the type has changed.
               fs->child_modified = true;
           }
 
@@ -346,10 +358,19 @@ static int _csync_detect_update(CSYNC *ctx, std::unique_ptr<csync_file_stat_t> f
               if (!base.isValid())
                   return;
 
+              if (base._type == ItemTypePlaceholderDownload) {
+                  // Remote rename of a placeholder file we have locally scheduled
+                  // for download. We just consider this NEW but mark it for download.
+                  fs->type = ItemTypePlaceholderDownload;
+                  done = true;
+                  return;
+              }
+
               // Some things prohibit rename detection entirely.
               // Since we don't do the same checks again in reconcile, we can't
               // just skip the candidate, but have to give up completely.
-              if (base._type != fs->type) {
+              if (base._type != fs->type
+                  && base._type != ItemTypePlaceholder) {
                   qCWarning(lcUpdate, "file types different, not a rename");
                   done = true;
                   return;
@@ -400,6 +421,14 @@ static int _csync_detect_update(CSYNC *ctx, std::unique_ptr<csync_file_stat_t> f
                   return 1;
               }
           }
+
+          // Potentially turn new remote files into placeholders
+          if (ctx->new_files_are_placeholders
+              && fs->instruction == CSYNC_INSTRUCTION_NEW
+              && fs->type == ItemTypeFile) {
+              fs->type = ItemTypePlaceholder;
+          }
+
           goto out;
       }
   }
@@ -502,7 +531,7 @@ int csync_walker(CSYNC *ctx, std::unique_ptr<csync_file_stat_t> fs) {
   return rc;
 }
 
-static bool fill_tree_from_db(CSYNC *ctx, const char *uri)
+static bool fill_tree_from_db(CSYNC *ctx, const char *uri, bool singleFile = false)
 {
     int64_t count = 0;
     QByteArray skipbase;
@@ -557,9 +586,19 @@ static bool fill_tree_from_db(CSYNC *ctx, const char *uri)
         ++count;
     };
 
-    if (!ctx->statedb->getFilesBelowPath(uri, rowCallback)) {
-        ctx->status_code = CSYNC_STATUS_STATEDB_LOAD_ERROR;
-        return false;
+    if (singleFile) {
+        OCC::SyncJournalFileRecord record;
+        if (ctx->statedb->getFileRecord(QByteArray(uri), &record) && record.isValid()) {
+            rowCallback(record);
+        } else {
+            ctx->status_code = CSYNC_STATUS_STATEDB_LOAD_ERROR;
+            return false;
+        }
+    } else {
+        if (!ctx->statedb->getFilesBelowPath(uri, rowCallback)) {
+            ctx->status_code = CSYNC_STATUS_STATEDB_LOAD_ERROR;
+            return false;
+        }
     }
     qInfo(lcUpdate, "%" PRId64 " entries read below path %s from db.", count, uri);
 
@@ -710,6 +749,22 @@ int csync_ftw(CSYNC *ctx, const char *uri, csync_walker_fn fn,
         fullpath = QByteArray() % uri % '/' % filename;
     }
 
+    // When encountering placeholder files, read the relevant
+    // entry from the db instead.
+    if (ctx->current == LOCAL_REPLICA
+            && dirent->type == ItemTypeFile
+            && filename.endsWith(".owncloud")) {
+        QByteArray db_uri = fullpath.mid(strlen(ctx->local.uri) + 1);
+        db_uri = db_uri.left(db_uri.size() - 9);
+        if( ! fill_tree_from_db(ctx, db_uri.constData(), true) ) {
+          errno = ENOENT;
+          ctx->status_code = CSYNC_STATUS_OPENDIR_ERROR;
+          goto error;
+        }
+
+        continue;
+    }
+
     /* if the filename starts with a . we consider it a hidden file
      * For windows, the hidden state is also discovered within the vio
      * local stat function.
index 92008ff0824f14330a036ffb292a2c165eefbd0d..1f7d9608f8afb0b6a549faf979ef5fb8ada8d672 100644 (file)
@@ -249,6 +249,9 @@ public:
     // Check for the directEditing capability
     void fetchDirectEditors(const QUrl &directEditingURL, const QString &directEditingETag);
 
+    bool usePlaceholders() const { return _usePlaceholders; }
+    void setUsePlaceholders(bool use) { _usePlaceholders = use; }
+
 public slots:
     /// Used when forgetting credentials
     void clearQNAMCache();
@@ -345,6 +348,8 @@ private:
     private:
         bool _isRemoteWipeRequested_HACK = false;
     // <-- FIXME MS@2019-12-07
+
+    bool _usePlaceholders = false;
 };
 }
 
index ef41bd00481b94b4d82864aabb9b93f6039aa9f0..5d815aa43aec39ccd282fcce8af9e931de75f190 100644 (file)
@@ -605,6 +605,11 @@ QString OwncloudPropagator::getFilePath(const QString &tmp_file_name) const
     return _localDir + tmp_file_name;
 }
 
+QString OwncloudPropagator::placeholderFilePath(const QString &fileName) const
+{
+    return getFilePath(fileName) + QLatin1String(".owncloud");
+}
+
 void OwncloudPropagator::scheduleNextJob()
 {
     QTimer::singleShot(0, this, &OwncloudPropagator::scheduleNextJobImpl);
index f9ebf1938c25d37daa33a4b3439b3d2569f5dc5a..c6a52db131cbe6e95183c480ff37c2cd53cf3d02 100644 (file)
@@ -453,6 +453,7 @@ public:
 
     /* returns the local file path for the given tmp_file_name */
     QString getFilePath(const QString &tmp_file_name) const;
+    QString placeholderFilePath(const QString &fileName) const;
 
     /** Creates the job for an item.
      */
index 473c5d70b416c374e14d5d7c3ec8c524258161ca..bf907b2ee390e70a23ae63a9206db7eabcaa7123 100644 (file)
@@ -387,6 +387,27 @@ void PropagateDownloadFile::startAfterIsEncryptedIsChecked()
 {
     _stopwatch.start();
 
+    // For placeholder files just create the file and be done
+    if (_item->_type == ItemTypePlaceholder) {
+        auto fn = propagator()->placeholderFilePath(_item->_file);
+        qCDebug(lcPropagateDownload) << "creating placeholder file" << fn;
+        QFile file(fn);
+        file.open(QFile::ReadWrite);
+        file.write("stub");
+        file.close();
+        updateMetadata(false);
+        return;
+    }
+
+    // If we want to download something that used to be a placeholder,
+    // wipe the placeholder and proceed with a normal download
+    if (_item->_type == ItemTypePlaceholderDownload) {
+        auto fn = propagator()->placeholderFilePath(_item->_file);
+        qCDebug(lcPropagateDownload) << "Downloading file that used to be a placeholder" << fn;
+        QFile::remove(fn);
+        _item->_type = ItemTypeFile;
+    }
+
     if (_deleteExisting) {
         deleteExistingFolder();
 
index 36c198c4da7a49f12781d12dd0c8b4a1b6a1214c..0b1aa6194736c429f485a2034415268dfb101579 100644 (file)
@@ -96,6 +96,8 @@ void PropagateLocalRemove::start()
         return;
 
     QString filename = propagator()->_localDir + _item->_file;
+    if (_item->_type == ItemTypePlaceholder || _item->_type == ItemTypePlaceholderDownload)
+        filename = propagator()->placeholderFilePath(_item->_file);
 
     qCDebug(lcPropagateLocalRemove) << filename;
 
@@ -251,6 +253,11 @@ void PropagateLocalRename::start()
     QString existingFile = propagator()->getFilePath(_item->_file);
     QString targetFile = propagator()->getFilePath(_item->_renameTarget);
 
+    if (_item->_type == ItemTypePlaceholder || _item->_type == ItemTypePlaceholderDownload) {
+        existingFile = propagator()->placeholderFilePath(_item->_file);
+        targetFile = propagator()->placeholderFilePath(_item->_renameTarget);
+    }
+
     // if the file is a file underneath a moved dir, the _item->file is equal
     // to _item->renameTarget and the file is not moved as a result.
     if (_item->_file != _item->_renameTarget) {
index fd980e15d84d0c09667931776be015e371b3f1a4..9bd03bc0cc9a78ee6f1a6e2823f1aaddf7e31204 100644 (file)
@@ -621,7 +621,7 @@ int SyncEngine::treewalkFile(csync_file_stat_t *file, csync_file_stat_t *other,
             if (remote) {
                 QString filePath = _localPath + item->_file;
 
-                if (other) {
+                if (other && other->type != ItemTypePlaceholder && other->type != ItemTypePlaceholderDownload) {
                     // Even if the mtime is different on the server, we always want to keep the mtime from
                     // the file system in the DB, this is to avoid spurious upload on the next sync
                     item->_modtime = other->modtime;
@@ -858,6 +858,8 @@ void SyncEngine::startSync()
         return shouldDiscoverLocally(path);
     };
 
+    _csync_ctx->new_files_are_placeholders = account()->usePlaceholders();
+
     // If needed, make sure we have up to date E2E information before the
     // discovery phase, otherwise we start right away
     if (_account->capabilities().clientSideEncryptionAvailable()) {
index 864a99bb48a08b39d9d2048800b1fc1d530b4432..95f1cfc8006337a2b5f3d5cc7eaff2eb59ecb837 100644 (file)
@@ -46,6 +46,7 @@ nextcloud_add_test(ExcludedFiles "")
 nextcloud_add_test(FileSystem "")
 nextcloud_add_test(Utility "")
 nextcloud_add_test(SyncEngine "syncenginetestutils.h")
+nextcloud_add_test(SyncPlaceholders "syncenginetestutils.h")
 nextcloud_add_test(SyncMove "syncenginetestutils.h")
 nextcloud_add_test(SyncConflict "syncenginetestutils.h")
 nextcloud_add_test(SyncFileStatusTracker "syncenginetestutils.h")
diff --git a/test/testsyncplaceholders.cpp b/test/testsyncplaceholders.cpp
new file mode 100644 (file)
index 0000000..7d94ee8
--- /dev/null
@@ -0,0 +1,222 @@
+/*
+ *    This software is in the public domain, furnished "as is", without technical
+ *    support, and with no warranty, express or implied, as to its usefulness for
+ *    any purpose.
+ *
+ */
+
+#include <QtTest>
+#include "syncenginetestutils.h"
+#include <syncengine.h>
+
+using namespace OCC;
+
+SyncFileItemPtr findItem(const QSignalSpy &spy, const QString &path)
+{
+    for (const QList<QVariant> &args : spy) {
+        auto item = args[0].value<SyncFileItemPtr>();
+        if (item->destination() == path)
+            return item;
+    }
+    return SyncFileItemPtr(new SyncFileItem);
+}
+
+bool itemInstruction(const QSignalSpy &spy, const QString &path, const csync_instructions_e instr)
+{
+    auto item = findItem(spy, path);
+    return item->_instruction == instr;
+}
+
+SyncJournalFileRecord dbRecord(FakeFolder &folder, const QString &path)
+{
+    SyncJournalFileRecord record;
+    folder.syncJournal().getFileRecord(path, &record);
+    return record;
+}
+
+class TestSyncPlaceholders : public QObject
+{
+    Q_OBJECT
+
+private slots:
+    void testPlaceholderLifecycle_data()
+    {
+        QTest::addColumn<bool>("doLocalDiscovery");
+
+        QTest::newRow("full local discovery") << true;
+        QTest::newRow("skip local discovery") << false;
+    }
+
+    void testPlaceholderLifecycle()
+    {
+        QFETCH(bool, doLocalDiscovery);
+
+        FakeFolder fakeFolder{FileInfo()};
+        fakeFolder.syncEngine().account()->setUsePlaceholders(true);
+        QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
+        QSignalSpy completeSpy(&fakeFolder.syncEngine(), SIGNAL(itemCompleted(const SyncFileItemPtr &)));
+
+        auto cleanup = [&]() {
+            completeSpy.clear();
+            if (!doLocalDiscovery)
+                fakeFolder.syncEngine().setLocalDiscoveryOptions(LocalDiscoveryStyle::DatabaseAndFilesystem);
+        };
+        cleanup();
+
+        // Create a placeholder for a new remote file
+        fakeFolder.remoteModifier().mkdir("A");
+        fakeFolder.remoteModifier().insert("A/a1", 64);
+        QVERIFY(fakeFolder.syncOnce());
+        QVERIFY(!fakeFolder.currentLocalState().find("A/a1"));
+        QVERIFY(fakeFolder.currentLocalState().find("A/a1.owncloud"));
+        QVERIFY(fakeFolder.currentRemoteState().find("A/a1"));
+        QVERIFY(itemInstruction(completeSpy, "A/a1", CSYNC_INSTRUCTION_NEW));
+        QCOMPARE(dbRecord(fakeFolder, "A/a1")._type, ItemTypePlaceholder);
+        cleanup();
+
+        // Another sync doesn't actually lead to changes
+        QVERIFY(fakeFolder.syncOnce());
+        QVERIFY(!fakeFolder.currentLocalState().find("A/a1"));
+        QVERIFY(fakeFolder.currentLocalState().find("A/a1.owncloud"));
+        QVERIFY(fakeFolder.currentRemoteState().find("A/a1"));
+        QVERIFY(completeSpy.isEmpty());
+        cleanup();
+
+        // Neither does a remote change
+        fakeFolder.remoteModifier().appendByte("A/a1");
+        QVERIFY(fakeFolder.syncOnce());
+        QVERIFY(!fakeFolder.currentLocalState().find("A/a1"));
+        QVERIFY(fakeFolder.currentLocalState().find("A/a1.owncloud"));
+        QVERIFY(fakeFolder.currentRemoteState().find("A/a1"));
+        QVERIFY(itemInstruction(completeSpy, "A/a1", CSYNC_INSTRUCTION_UPDATE_METADATA));
+        QCOMPARE(dbRecord(fakeFolder, "A/a1")._type, ItemTypePlaceholder);
+        QCOMPARE(dbRecord(fakeFolder, "A/a1")._fileSize, 65);
+        cleanup();
+
+        // If the local placeholder file is removed, it'll just be recreated
+        if (!doLocalDiscovery)
+            fakeFolder.syncEngine().setLocalDiscoveryOptions(LocalDiscoveryStyle::DatabaseAndFilesystem, { "A" });
+        fakeFolder.localModifier().remove("A/a1.owncloud");
+        QVERIFY(fakeFolder.syncOnce());
+        QVERIFY(!fakeFolder.currentLocalState().find("A/a1"));
+        QVERIFY(fakeFolder.currentLocalState().find("A/a1.owncloud"));
+        QVERIFY(fakeFolder.currentRemoteState().find("A/a1"));
+        QVERIFY(itemInstruction(completeSpy, "A/a1", CSYNC_INSTRUCTION_NEW));
+        QCOMPARE(dbRecord(fakeFolder, "A/a1")._type, ItemTypePlaceholder);
+        QCOMPARE(dbRecord(fakeFolder, "A/a1")._fileSize, 65);
+        cleanup();
+
+        // Remote rename is propagated
+        fakeFolder.remoteModifier().rename("A/a1", "A/a1m");
+        QVERIFY(fakeFolder.syncOnce());
+        QVERIFY(!fakeFolder.currentLocalState().find("A/a1"));
+        QVERIFY(!fakeFolder.currentLocalState().find("A/a1m"));
+        QVERIFY(!fakeFolder.currentLocalState().find("A/a1.owncloud"));
+        QVERIFY(fakeFolder.currentLocalState().find("A/a1m.owncloud"));
+        QVERIFY(!fakeFolder.currentRemoteState().find("A/a1"));
+        QVERIFY(fakeFolder.currentRemoteState().find("A/a1m"));
+        QVERIFY(itemInstruction(completeSpy, "A/a1m", CSYNC_INSTRUCTION_RENAME));
+        QCOMPARE(dbRecord(fakeFolder, "A/a1m")._type, ItemTypePlaceholder);
+        cleanup();
+
+        // Remote remove is propagated
+        fakeFolder.remoteModifier().remove("A/a1m");
+        QVERIFY(fakeFolder.syncOnce());
+        QVERIFY(!fakeFolder.currentLocalState().find("A/a1m.owncloud"));
+        QVERIFY(!fakeFolder.currentRemoteState().find("A/a1m"));
+        QVERIFY(itemInstruction(completeSpy, "A/a1m", CSYNC_INSTRUCTION_REMOVE));
+        QVERIFY(!dbRecord(fakeFolder, "A/a1m").isValid());
+        cleanup();
+    }
+
+    void testWithNormalSync()
+    {
+        FakeFolder fakeFolder{FileInfo::A12_B12_C12_S12()};
+        fakeFolder.syncEngine().account()->setUsePlaceholders(true);
+        QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
+        QSignalSpy completeSpy(&fakeFolder.syncEngine(), SIGNAL(itemCompleted(const SyncFileItemPtr &)));
+
+        auto cleanup = [&]() {
+            completeSpy.clear();
+        };
+        cleanup();
+
+        // No effect sync
+        QVERIFY(fakeFolder.syncOnce());
+        QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
+        cleanup();
+
+        // Existing files are propagated just fine in both directions
+        fakeFolder.localModifier().appendByte("A/a1");
+        fakeFolder.localModifier().insert("A/a3");
+        fakeFolder.remoteModifier().appendByte("A/a2");
+        QVERIFY(fakeFolder.syncOnce());
+        QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
+        cleanup();
+
+        // New files on the remote create placeholders
+        fakeFolder.remoteModifier().insert("A/new");
+        QVERIFY(fakeFolder.syncOnce());
+        QVERIFY(!fakeFolder.currentLocalState().find("A/new"));
+        QVERIFY(fakeFolder.currentLocalState().find("A/new.owncloud"));
+        QVERIFY(fakeFolder.currentRemoteState().find("A/new"));
+        QVERIFY(itemInstruction(completeSpy, "A/new", CSYNC_INSTRUCTION_NEW));
+        QCOMPARE(dbRecord(fakeFolder, "A/new")._type, ItemTypePlaceholder);
+        cleanup();
+    }
+
+    void testPlaceholderDownload()
+    {
+        FakeFolder fakeFolder{FileInfo()};
+        fakeFolder.syncEngine().account()->setUsePlaceholders(true);
+        QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
+        QSignalSpy completeSpy(&fakeFolder.syncEngine(), SIGNAL(itemCompleted(const SyncFileItemPtr &)));
+
+        auto cleanup = [&]() {
+            completeSpy.clear();
+        };
+        cleanup();
+
+        auto triggerDownload = [&](const QByteArray &path) {
+            auto &journal = fakeFolder.syncJournal();
+            SyncJournalFileRecord record;
+            journal.getFileRecord(path, &record);
+            if (!record.isValid())
+                return;
+            record._type = ItemTypePlaceholderDownload;
+            journal.setFileRecord(record);
+        };
+
+        // Create a placeholder for remote files
+        fakeFolder.remoteModifier().mkdir("A");
+        fakeFolder.remoteModifier().insert("A/a1");
+        fakeFolder.remoteModifier().insert("A/a2");
+        fakeFolder.remoteModifier().insert("A/a3");
+        fakeFolder.remoteModifier().insert("A/a4");
+        QVERIFY(fakeFolder.syncOnce());
+        QVERIFY(fakeFolder.currentLocalState().find("A/a1.owncloud"));
+        QVERIFY(fakeFolder.currentLocalState().find("A/a2.owncloud"));
+        QVERIFY(fakeFolder.currentLocalState().find("A/a3.owncloud"));
+        QVERIFY(fakeFolder.currentLocalState().find("A/a4.owncloud"));
+        cleanup();
+
+        // Download by changing the db entry
+        triggerDownload("A/a1");
+        triggerDownload("A/a2");
+        triggerDownload("A/a3");
+        triggerDownload("A/a4");
+        fakeFolder.remoteModifier().appendByte("A/a2");
+        fakeFolder.remoteModifier().remove("A/a3");
+        fakeFolder.remoteModifier().rename("A/a4", "A/a4m");
+        QVERIFY(fakeFolder.syncOnce());
+        QVERIFY(itemInstruction(completeSpy, "A/a1", CSYNC_INSTRUCTION_NEW));
+        QVERIFY(itemInstruction(completeSpy, "A/a2", CSYNC_INSTRUCTION_NEW));
+        QVERIFY(itemInstruction(completeSpy, "A/a3", CSYNC_INSTRUCTION_REMOVE));
+        QVERIFY(itemInstruction(completeSpy, "A/a4", CSYNC_INSTRUCTION_REMOVE));
+        QVERIFY(itemInstruction(completeSpy, "A/a4m", CSYNC_INSTRUCTION_NEW));
+        QCOMPARE(fakeFolder.currentLocalState(), fakeFolder.currentRemoteState());
+    }
+};
+
+QTEST_GUILESS_MAIN(TestSyncPlaceholders)
+#include "testsyncplaceholders.moc"