001/*
002 * Syncany, www.syncany.org
003 * Copyright (C) 2011-2016 Philipp C. Heckel <philipp.heckel@gmail.com>
004 *
005 * This program is free software: you can redistribute it and/or modify
006 * it under the terms of the GNU General Public License as published by
007 * the Free Software Foundation, either version 3 of the License, or
008 * (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
013 * GNU General Public License for more details.
014 *
015 * You should have received a copy of the GNU General Public License
016 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
017 */
018package org.syncany.plugins.transfer.features;
019
020import java.io.File;
021import java.io.IOException;
022import java.nio.charset.Charset;
023import java.nio.file.Files;
024import java.util.ArrayList;
025import java.util.Collection;
026import java.util.Collections;
027import java.util.HashMap;
028import java.util.HashSet;
029import java.util.List;
030import java.util.Map;
031import java.util.Objects;
032import java.util.Set;
033import java.util.logging.Level;
034import java.util.logging.Logger;
035
036import org.syncany.chunk.Transformer;
037import org.syncany.config.Config;
038import org.syncany.operations.up.BlockingTransfersException;
039import org.syncany.plugins.transfer.StorageException;
040import org.syncany.plugins.transfer.StorageFileNotFoundException;
041import org.syncany.plugins.transfer.StorageMoveException;
042import org.syncany.plugins.transfer.StorageTestResult;
043import org.syncany.plugins.transfer.TransferManager;
044import org.syncany.plugins.transfer.files.RemoteFile;
045import org.syncany.plugins.transfer.files.TempRemoteFile;
046import org.syncany.plugins.transfer.files.TransactionRemoteFile;
047import org.syncany.plugins.transfer.to.ActionTO;
048import org.syncany.plugins.transfer.to.ActionTO.ActionType;
049import org.syncany.plugins.transfer.to.TransactionTO;
050
051/**
052 * The TransactionAwareTransferManager adds all functionality regarding transactions
053 * to existing transfer managers.
054 *
055 * @author Pim Otte
056 */
057public class TransactionAwareFeatureTransferManager implements FeatureTransferManager {
058        private static final Logger logger = Logger.getLogger(TransactionAwareFeatureTransferManager.class.getSimpleName());
059
060        private final TransferManager underlyingTransferManager;
061        private final Config config;
062
063        public TransactionAwareFeatureTransferManager(TransferManager originalTransferManager, TransferManager underlyingTransferManager, Config config, TransactionAware transactionAwareAnnotation) {
064                this.underlyingTransferManager = underlyingTransferManager;
065                this.config = config;
066        }
067
068        @Override
069        public void connect() throws StorageException {
070                underlyingTransferManager.connect();
071        }
072
073        @Override
074        public void disconnect() throws StorageException {
075                underlyingTransferManager.disconnect();
076        }
077
078        @Override
079        public void init(final boolean createIfRequired) throws StorageException {
080                underlyingTransferManager.init(createIfRequired);
081        }
082
083        @Override
084        public void download(final RemoteFile remoteFile, final File localFile) throws StorageException {
085                try {
086                        underlyingTransferManager.download(remoteFile, localFile);
087                }
088                catch (StorageFileNotFoundException e) {
089                        logger.log(Level.FINE, "Could not find the Storage file", e);
090                        downloadDeletedTempFileInTransaction(remoteFile, localFile);
091                }
092        }
093
094        /**
095         * Downloads all transaction files and looks for the corresponding temporary file
096         * for the given remote file. If there is a temporary file, the file is downloaded
097         * instead of the original file.
098         *
099         * <p>This method is <b>expensive</b>, but it is only called by {@link #download(RemoteFile, File) download()}
100         * if a file does not exist.
101         */
102        private void downloadDeletedTempFileInTransaction(RemoteFile remoteFile, File localFile) throws StorageException {
103                logger.log(Level.INFO, "File {0} not found, checking if it is being deleted ...", remoteFile.getName());
104
105                Set<TransactionTO> transactions = retrieveRemoteTransactions().keySet();
106                TempRemoteFile tempRemoteFile = null;
107
108                // Find file: If the file is being deleted and the name matches, download temporary file instead.
109                for (TransactionTO transaction : transactions) {
110                        for (ActionTO action : transaction.getActions()) {
111                                if (action.getType().equals(ActionType.DELETE) && action.getRemoteFile().equals(remoteFile)) {
112                                        tempRemoteFile = action.getTempRemoteFile();
113                                        break;
114                                }
115                        }
116                }
117
118                // Download file, or throw exception
119                if (tempRemoteFile != null) {
120                        logger.log(Level.INFO, "-> File {0} in process of being deleted; downloading corresponding temp. file {1} ...",
121                                        new Object[] { remoteFile.getName(), tempRemoteFile.getName() });
122
123                        underlyingTransferManager.download(tempRemoteFile, localFile);
124                }
125                else {
126                        logger.log(Level.WARNING, "-> File {0} does not exist and is not in any transaction. Throwing exception.", remoteFile.getName());
127                        throw new StorageFileNotFoundException("File " + remoteFile.getName() + " does not exist and is not in any transaction");
128                }
129        }
130
131        @Override
132        public void move(final RemoteFile sourceFile, final RemoteFile targetFile) throws StorageException {
133                underlyingTransferManager.move(sourceFile, targetFile);
134        }
135
136        @Override
137        public void upload(final File localFile, final RemoteFile remoteFile) throws StorageException {
138                underlyingTransferManager.upload(localFile, remoteFile);
139        }
140
141        @Override
142        public boolean delete(final RemoteFile remoteFile) throws StorageException {
143                return underlyingTransferManager.delete(remoteFile);
144        }
145
146        @Override
147        public <T extends RemoteFile> Map<String, T> list(final Class<T> remoteFileClass) throws StorageException {
148                return addAndFilterFilesInTransaction(remoteFileClass, underlyingTransferManager.list(remoteFileClass));
149        }
150
151        @Override
152        public String getRemoteFilePath(Class<? extends RemoteFile> remoteFileClass) {
153                return underlyingTransferManager.getRemoteFilePath(remoteFileClass);
154        }
155
156        /**
157         * Checks if any transactions of the local machine were not completed and performs
158         * a rollback if any transactions were found. The rollback itself is performed in
159         * a transaction.
160         *
161         * <p>The method uses {@link #retrieveRemoteTransactions()} to download all transaction
162         * files and then rolls back the local machines's transactions:
163         *
164         * <ul>
165         *  <li>Files in the transaction marked "UPLOAD" are deleted.</li>
166         *  <li>Files in the transaction marked "DELETE" are moved back to their original place.</li>
167         * </ul>
168         *
169         * @throws BlockingTransfersException if we cannot proceed (Deleting transaction by another client exists).
170         */
171        public void cleanTransactions() throws StorageException, BlockingTransfersException {
172                Objects.requireNonNull(config, "Cannot clean transactions if config is null.");
173
174                Map<TransactionTO, TransactionRemoteFile> transactions = retrieveRemoteTransactions();
175                boolean noBlockingTransactionsExist = true;
176
177                for (TransactionTO potentiallyCancelledTransaction : transactions.keySet()) {
178                        boolean isCancelledOwnTransaction = potentiallyCancelledTransaction.getMachineName().equals(config.getMachineName());
179
180                        // If this transaction is from our machine, delete all permanent or temporary files in this transaction.
181                        if (isCancelledOwnTransaction) {
182                                rollbackSingleTransaction(potentiallyCancelledTransaction, transactions.get(potentiallyCancelledTransaction));
183                        }
184                        else if (noBlockingTransactionsExist) {
185                                // Only check if we have not yet found deleting transactions by others
186                                for (ActionTO action : potentiallyCancelledTransaction.getActions()) {
187                                        if (action.getType().equals(ActionType.DELETE)) {
188                                                noBlockingTransactionsExist = false;
189                                        }
190                                }
191                        }
192                }
193
194                logger.log(Level.INFO, "Done rolling back previous transactions.");
195
196                if (!noBlockingTransactionsExist) {
197                        throw new BlockingTransfersException();
198                }
199        }
200
201        /**
202         * This function returns a list of all remote transaction files that belong to the client. If blocking transactions exist,
203         * this methods returns null, because we are not allowed to proceed.
204         */
205        public List<TransactionRemoteFile> getTransactionsByClient(String client) throws StorageException {
206                Objects.requireNonNull(config, "Cannot get transactions if config is null.");
207                
208                Map<TransactionTO, TransactionRemoteFile> transactions = retrieveRemoteTransactions();
209                List<TransactionRemoteFile> transactionsByClient = new ArrayList<TransactionRemoteFile>();
210                
211                for (TransactionTO potentiallyResumableTransaction : transactions.keySet()) {
212                        boolean isCancelledOwnTransaction = potentiallyResumableTransaction.getMachineName().equals(config.getMachineName());
213
214                        if (isCancelledOwnTransaction) {
215                                transactionsByClient.add(transactions.get(potentiallyResumableTransaction));
216                        }
217                        else {
218                                // Check for blocking transactions
219                                for (ActionTO action : potentiallyResumableTransaction.getActions()) {
220                                        if (action.getType().equals(ActionType.DELETE)) {
221                                                return null;
222                                        }
223                                }
224                        }
225                }
226
227                return transactionsByClient;
228        }
229
230        /**
231         * This methods deletes local copies of transactions that might be resumed. This is done when
232         * a transaction is successfully resumed, or some other operations is performed, which implies that resuming is
233         * no longer an option.
234         */
235        public void clearResumableTransactions() {
236                Objects.requireNonNull(config, "Cannot delete resumable transactions if config is null.");
237                
238                File transactionFile = config.getTransactionFile();
239                
240                if (transactionFile.exists()) {
241                        transactionFile.delete();
242                }
243
244                File transactionDatabaseFile = config.getTransactionDatabaseFile();
245                
246                if (transactionDatabaseFile.exists()) {
247                        transactionFile.delete();
248                }
249        }
250
251        /**
252         * clearPendingTransactions provides a way to delete the multiple transactions
253         * that might be queued after an interrupted up.
254         */
255        public void clearPendingTransactions() throws IOException {
256                Objects.requireNonNull(config, "Cannot delete resumable transaction backlog if config is null.");
257                Collection<Long> resumableTransactionList = loadPendingTransactionList();
258
259                for (long resumableTransactionId : resumableTransactionList) {
260                        File transactionFile = config.getTransactionFile(resumableTransactionId);
261                        if (transactionFile.exists()) {
262                                transactionFile.delete();
263                        }
264
265                        File transactionDatabaseFile = config.getTransactionDatabaseFile(resumableTransactionId);
266                        if (transactionDatabaseFile.exists()) {
267                                transactionDatabaseFile.delete();
268                        }
269                }
270
271                File transactionListFile = config.getTransactionListFile();
272                if (transactionListFile.exists()) {
273                        transactionListFile.delete();
274                }
275        }
276
277        /**
278         * Check if we have transactions from an Up operation that we can resume.
279         */
280        public Collection<Long> loadPendingTransactionList() throws IOException {
281                Objects.requireNonNull(config, "Cannot read pending transaction list if config is null.");
282                
283                Collection<Long> databaseVersionNumbers = new ArrayList<>();
284                File transactionListFile = config.getTransactionListFile();
285                
286                if (!transactionListFile.exists()) {
287                        return Collections.emptyList();
288                }
289
290                Collection<String> transactionLines = Files.readAllLines(transactionListFile.toPath(), Charset.forName("UTF-8"));
291                
292                for (String transactionLine : transactionLines) {
293                        try {
294                                databaseVersionNumbers.add(Long.parseLong(transactionLine));    
295                        } catch (NumberFormatException e) {
296                                logger.log(Level.WARNING, "Cannot parse line in transaction list: " + transactionLine + ". Cannot resume.");
297                                return Collections.emptyList(); 
298                        }                       
299                }
300                
301                return databaseVersionNumbers;
302        }
303
304        /**
305         * Removes temporary files on the offsite storage that are not listed in any
306         * of the {@link TransactionRemoteFile}s available remotely.
307         *
308         * <p>Temporary files might be left over from unfinished transactions.
309         */
310        public void removeUnreferencedTemporaryFiles() throws StorageException {
311                // Retrieve all transactions
312                Map<TransactionTO, TransactionRemoteFile> transactions = retrieveRemoteTransactions();
313                Collection<TempRemoteFile> tempRemoteFiles = list(TempRemoteFile.class).values();
314
315                // Find all remoteFiles that are referenced in a transaction
316                Set<TempRemoteFile> tempRemoteFilesInTransactions = new HashSet<TempRemoteFile>();
317
318                for (TransactionTO transaction : transactions.keySet()) {
319                        for (ActionTO action : transaction.getActions()) {
320                                tempRemoteFilesInTransactions.add(action.getTempRemoteFile());
321                        }
322                }
323
324                // Consider just those files that are not referenced and delete them.
325                tempRemoteFiles.removeAll(tempRemoteFilesInTransactions);
326
327                for (TempRemoteFile unreferencedTempRemoteFile : tempRemoteFiles) {
328                        logger.log(Level.INFO, "Unreferenced temporary file found. Deleting {0}", unreferencedTempRemoteFile);
329                        underlyingTransferManager.delete(unreferencedTempRemoteFile);
330                }
331
332        }
333
334        /**
335         * This method is called when the machine wants to rollback one of their own transactions.
336         *
337         * @param rollbackTransaction is the transaction that composes the rollback.
338         * @param cancelledTransaction is the transaction that is cancelled.
339         * @param remoteCancelledTransaction is the remote file location of the cancelled transaction.
340         *        This file will be deleted as part of the rollback.
341         */
342        private void rollbackSingleTransaction(TransactionTO cancelledTransaction,
343                        TransactionRemoteFile remoteCancelledTransaction) throws StorageException {
344
345                logger.log(Level.INFO, "Unfinished transaction " + remoteCancelledTransaction + ". Rollback necessary!");
346                rollbackActions(cancelledTransaction.getActions());
347
348                // Get corresponding remote file of transaction and delete it.
349                delete(remoteCancelledTransaction);
350                logger.log(Level.INFO, "Successfully rolled back transaction " + remoteCancelledTransaction);
351        }
352
353        /**
354         * Adds the opposite actions (rollback actions) for the given unfinished actions
355         * to the rollback transaction.
356         */
357        private void rollbackActions(List<ActionTO> unfinishedActions) throws StorageException {
358                for (ActionTO action : unfinishedActions) {
359                        logger.log(Level.INFO, "- Needs to be rolled back: " + action);
360                        
361                        switch (action.getType()) {
362                        case UPLOAD:
363                                delete(action.getRemoteFile());
364                                delete(action.getTempRemoteFile());
365
366                                break;
367
368                        case DELETE:
369                                try {
370                                        logger.log(Level.INFO, "- Rollback action: Moving " + action.getTempRemoteFile().getName() + " to "
371                                                        + action.getRemoteFile().getName());
372                                        move(action.getTempRemoteFile(), action.getRemoteFile());
373                                }
374                                catch (StorageMoveException e) {
375                                        logger.log(Level.WARNING, "Restoring deleted file failed. This might be a problem if the original: " + action.getRemoteFile()
376                                                        + " also does not exist.", e);
377                                }
378
379                                break;
380
381                        default:
382                                throw new RuntimeException("Transaction contains invalid type: " + action.getType() + ". This should not happen.");
383                        }
384                }
385        }
386
387        @Override
388        public StorageTestResult test(boolean testCreateTarget) {
389                return underlyingTransferManager.test(testCreateTarget);
390        }
391
392        @Override
393        public boolean testTargetExists() throws StorageException {
394                return underlyingTransferManager.testTargetExists();
395        }
396
397        @Override
398        public boolean testTargetCanWrite() throws StorageException {
399                return underlyingTransferManager.testTargetCanWrite();
400        }
401
402        @Override
403        public boolean testTargetCanCreate() throws StorageException {
404                return underlyingTransferManager.testTargetCanCreate();
405        }
406
407        @Override
408        public boolean testRepoFileExists() throws StorageException {
409                return underlyingTransferManager.testRepoFileExists();
410        }
411
412        /**
413         * Returns a list of remote files, excluding the files in transactions.
414         * The method is used to hide unfinished transactions from other clients.
415         */
416        protected <T extends RemoteFile> Map<String, T> addAndFilterFilesInTransaction(Class<T> remoteFileClass, Map<String, T> remoteFiles)
417                        throws StorageException {
418
419                Map<String, T> filteredFiles = new HashMap<String, T>();
420
421                Set<TransactionTO> transactions = new HashSet<TransactionTO>();
422                Set<RemoteFile> dummyDeletedFiles = new HashSet<RemoteFile>();
423                Set<RemoteFile> filesToIgnore = new HashSet<RemoteFile>();
424
425                // Ignore files currently listed in a transaction,
426                // unless we are listing transaction files
427
428                boolean ignoreFilesInTransactions = !remoteFileClass.equals(TransactionRemoteFile.class);
429
430                if (ignoreFilesInTransactions) {
431                        transactions = retrieveRemoteTransactions().keySet();
432                        filesToIgnore = getFilesInTransactions(transactions);
433                        dummyDeletedFiles = getDummyDeletedFiles(transactions);
434                }
435
436                for (RemoteFile deletedFile : dummyDeletedFiles) {
437                        if (deletedFile.getClass().equals(remoteFileClass)) {
438                                T concreteDeletedFile = remoteFileClass.cast(deletedFile);
439                                filteredFiles.put(concreteDeletedFile.getName(), concreteDeletedFile);
440                        }
441                }
442
443                for (String fileName : remoteFiles.keySet()) {
444                        if (!filesToIgnore.contains(remoteFiles.get(fileName))) {
445                                filteredFiles.put(fileName, remoteFiles.get(fileName));
446                        }
447                }
448
449                return filteredFiles;
450        }
451
452        /**
453         * Returns a Set of all files that are not temporary, but are listed in a
454         * transaction file. These belong to an unfinished transaction and should be ignored.
455         */
456        protected Set<RemoteFile> getFilesInTransactions(Set<TransactionTO> transactions) throws StorageException {
457                Set<RemoteFile> filesInTransaction = new HashSet<RemoteFile>();
458
459                for (TransactionTO transaction : transactions) {
460                        for (ActionTO action : transaction.getActions()) {
461                                if (action.getType().equals(ActionType.UPLOAD)) {
462                                        filesInTransaction.add(action.getRemoteFile());
463                                }
464                        }
465                }
466
467                return filesInTransaction;
468        }
469
470        private Set<RemoteFile> getDummyDeletedFiles(Set<TransactionTO> transactions) throws StorageException {
471                Set<RemoteFile> dummyDeletedFiles = new HashSet<RemoteFile>();
472
473                for (TransactionTO transaction : transactions) {
474                        for (ActionTO action : transaction.getActions()) {
475                                if (action.getType().equals(ActionType.DELETE)) {
476                                        dummyDeletedFiles.add(action.getRemoteFile());
477                                }
478                        }
479                }
480
481                return dummyDeletedFiles;
482        }
483
484        private Map<TransactionTO, TransactionRemoteFile> retrieveRemoteTransactions() throws StorageException {
485                Map<String, TransactionRemoteFile> transactionFiles = list(TransactionRemoteFile.class);
486                Map<TransactionTO, TransactionRemoteFile> transactions = new HashMap<TransactionTO, TransactionRemoteFile>();
487
488                for (TransactionRemoteFile transaction : transactionFiles.values()) {
489                        try {
490                                File transactionFile = createTempFile("transaction");
491                                try {
492                                        // Download transaction file
493                                        download(transaction, transactionFile);
494                                }
495                                catch (StorageFileNotFoundException e) {
496                                        // This happens if the file is deleted between listing and downloading. It is now final, so we skip it.
497                                        logger.log(Level.INFO, "Could not find transaction file: " + transaction);
498                                        continue;
499                                }
500
501                                Transformer transformer = config == null ? null : config.getTransformer();
502                                TransactionTO transactionTO = TransactionTO.load(transformer, transactionFile);
503
504                                // Extract final locations
505                                transactions.put(transactionTO, transaction);
506                                transactionFile.delete();
507                        }
508                        catch (Exception e) {
509                                throw new StorageException("Failed to read transactionFile", e);
510                        }
511                }
512
513                return transactions;
514        }
515
516        /**
517         * Creates a temporary file, either using the config (if initialized) or
518         * using the global temporary directory.
519         */
520        protected File createTempFile(String name) throws IOException {
521                // TODO [low] duplicate code with AbstractTransferManager
522
523                if (config == null) {
524                        return File.createTempFile(String.format("temp-%s-", name), ".tmp");
525                }
526                else {
527                        return config.getCache().createTempFile(name);
528                }
529        }
530}