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}