fix(file-move): implement transaction management for file moves and rollback on failure (#2588)

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
This commit is contained in:
Balázs Szücs
2026-02-05 22:01:34 +01:00
committed by GitHub
parent 539734a3cf
commit c7dd23ed85
4 changed files with 120 additions and 40 deletions

View File

@@ -1,5 +1,8 @@
package org.booklore.service.file;
import jakarta.persistence.EntityManager;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.booklore.config.AppProperties;
import org.booklore.mapper.BookMapper;
import org.booklore.mapper.LibraryMapper;
@@ -15,11 +18,8 @@ import org.booklore.repository.BookRepository;
import org.booklore.repository.LibraryRepository;
import org.booklore.service.NotificationService;
import org.booklore.service.monitoring.MonitoringRegistrationService;
import jakarta.persistence.EntityManager;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -42,9 +42,9 @@ public class FileMoveService {
private final BookMapper bookMapper;
private final NotificationService notificationService;
private final EntityManager entityManager;
private final TransactionTemplate transactionTemplate;
@Transactional
public void bulkMoveFiles(FileMoveRequest request) {
List<FileMoveRequest.Move> moves = request.getMoves();
@@ -171,25 +171,42 @@ public class FileMoveService {
return;
}
List<PlannedMove> committedMoves = new ArrayList<>();
// Commit file moves FIRST before updating database
for (PlannedMove planned : plannedMovesByBookFileId.values()) {
fileMoveHelper.commitMove(planned.temp(), planned.target());
committedMoves.add(planned);
}
plannedMovesByBookFileId.clear();
// Only update database after all file commits succeed
for (var bookFile : bookEntity.getBookFiles()) {
String newFileName;
if (bookFile.isBook()) {
Path targetPath = fileMoveHelper.generateNewFilePath(bookEntity, bookFile, libraryPathEntity, pattern);
newFileName = targetPath.getFileName().toString();
} else {
newFileName = bookFile.getFileName();
}
bookFileRepository.updateFileNameAndSubPath(bookFile.getId(), newFileName, newFileSubPath);
}
try {
transactionTemplate.executeWithoutResult(status -> {
for (var bookFile : bookEntity.getBookFiles()) {
String newFileName;
if (bookFile.isBook()) {
Path targetPath = fileMoveHelper.generateNewFilePath(bookEntity, bookFile, libraryPathEntity, pattern);
newFileName = targetPath.getFileName().toString();
} else {
newFileName = bookFile.getFileName();
}
bookFileRepository.updateFileNameAndSubPath(bookFile.getId(), newFileName, newFileSubPath);
}
bookRepository.updateLibrary(bookEntity.getId(), targetLibrary.getId(), libraryPathEntity);
bookRepository.updateLibrary(bookEntity.getId(), targetLibrary.getId(), libraryPathEntity);
});
} catch (Exception e) {
log.error("Database update failed after files were moved. Attempting to rollback file moves for book ID {}", bookId, e);
for (PlannedMove committed : committedMoves) {
try {
fileMoveHelper.moveFile(committed.target(), committed.source());
} catch (Exception rollbackEx) {
log.error("Failed to rollback file move (Target -> Source) for book ID {}: {} -> {}", bookId, committed.target(), committed.source(), rollbackEx);
}
}
throw e;
}
Path libraryRoot = Paths.get(libraryPathEntity.getPath()).toAbsolutePath().normalize();
for (Path sourceParent : sourceParentsToCleanup) {
@@ -211,7 +228,6 @@ public class FileMoveService {
}
}
@Transactional
public FileMoveResult moveSingleFile(BookEntity bookEntity) {
record PlannedMove(Path source, Path temp, Path target) {}
@@ -302,22 +318,40 @@ public class FileMoveService {
return FileMoveResult.builder().moved(false).build();
}
List<PlannedMove> committedMoves = new ArrayList<>();
// Commit all file moves FIRST before updating database
for (PlannedMove planned : plannedMovesByBookFileId.values()) {
fileMoveHelper.commitMove(planned.temp(), planned.target());
committedMoves.add(planned);
}
plannedMovesByBookFileId.clear();
// Update database for ALL BookFileEntity records (only after all commits succeed)
for (var bookFile : bookWithFiles.getBookFiles()) {
String newFileName;
if (bookFile.isBook()) {
Path targetPath = fileMoveHelper.generateNewFilePath(bookWithFiles, bookFile, bookWithFiles.getLibraryPath(), pattern);
newFileName = targetPath.getFileName().toString();
} else {
newFileName = bookFile.getFileName();
BookEntity finalBookWithFiles = bookWithFiles;
try {
transactionTemplate.executeWithoutResult(status -> {
for (var bookFile : finalBookWithFiles.getBookFiles()) {
String newFileName;
if (bookFile.isBook()) {
Path targetPath = fileMoveHelper.generateNewFilePath(finalBookWithFiles, bookFile, finalBookWithFiles.getLibraryPath(), pattern);
newFileName = targetPath.getFileName().toString();
} else {
newFileName = bookFile.getFileName();
}
bookFileRepository.updateFileNameAndSubPath(bookFile.getId(), newFileName, newFileSubPath);
}
});
} catch (Exception e) {
log.error("Database update failed after files were moved. Attempting to rollback file moves for book ID {}", bookEntity.getId(), e);
for (PlannedMove committed : committedMoves) {
try {
fileMoveHelper.moveFile(committed.target(), committed.source());
} catch (Exception rollbackEx) {
log.error("Failed to rollback file move (Target -> Source) for book ID {}: {} -> {}", bookEntity.getId(), committed.target(), committed.source(), rollbackEx);
}
}
bookFileRepository.updateFileNameAndSubPath(bookFile.getId(), newFileName, newFileSubPath);
throw e;
}
// Clean up empty parent directories

View File

@@ -1,5 +1,6 @@
package org.booklore.service.file;
import jakarta.persistence.EntityManager;
import org.booklore.config.AppProperties;
import org.booklore.mapper.BookMapper;
import org.booklore.mapper.LibraryMapper;
@@ -15,7 +16,6 @@ import org.booklore.repository.BookRepository;
import org.booklore.repository.LibraryRepository;
import org.booklore.service.NotificationService;
import org.booklore.service.monitoring.MonitoringRegistrationService;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -25,6 +25,8 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionTemplate;
import java.io.IOException;
import java.nio.file.Path;
@@ -33,6 +35,7 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
@@ -51,7 +54,9 @@ class FileMoveServiceOrderingTest {
@Mock private LibraryMapper libraryMapper;
@Mock private BookMapper bookMapper;
@Mock private NotificationService notificationService;
@Mock private EntityManager entityManager;
@Mock private TransactionTemplate transactionTemplate;
private FileMoveService service;
private LibraryEntity library;
@@ -61,9 +66,10 @@ class FileMoveServiceOrderingTest {
TestableFileMoveService(AppProperties appProperties, BookRepository bookRepository, BookAdditionalFileRepository bookFileRepository,
LibraryRepository libraryRepository, FileMoveHelper fileMoveHelper,
MonitoringRegistrationService monitoringRegistrationService, LibraryMapper libraryMapper,
BookMapper bookMapper, NotificationService notificationService, EntityManager entityManager) {
BookMapper bookMapper, NotificationService notificationService, EntityManager entityManager,
TransactionTemplate transactionTemplate) {
super(appProperties, bookRepository, bookFileRepository, libraryRepository, fileMoveHelper,
monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager);
monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager, transactionTemplate);
}
@Override
@@ -75,8 +81,15 @@ class FileMoveServiceOrderingTest {
@BeforeEach
void setUp() {
when(appProperties.getDiskType()).thenReturn("LOCAL");
doAnswer(invocation -> {
Consumer<TransactionStatus> action = invocation.getArgument(0);
action.accept(mock(TransactionStatus.class));
return null;
}).when(transactionTemplate).executeWithoutResult(any());
service = spy(new TestableFileMoveService(appProperties, bookRepository, bookFileRepository, libraryRepository,
fileMoveHelper, monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager));
fileMoveHelper, monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager, transactionTemplate));
library = new LibraryEntity();
library.setId(1L);
@@ -308,4 +321,31 @@ class FileMoveServiceOrderingTest {
verify(fileMoveHelper).unregisterLibrary(1L);
verify(monitoringRegistrationService).registerLibrary(dto);
}
@Test
@DisplayName("moveSingleFile: rolls back committed files on database failure")
void filesRolledBackOnDbFailure() throws IOException {
BookEntity book = createBook();
Path target = Paths.get("/library/new/path/NewName.epub");
mockStandardBehavior(book, target);
// Mock DB failure
doThrow(new RuntimeException("DB Error")).when(transactionTemplate).executeWithoutResult(any());
when(monitoringRegistrationService.isLibraryMonitored(anyLong())).thenReturn(false);
when(monitoringRegistrationService.getPathsForLibraries(anySet())).thenReturn(Collections.emptySet());
try {
service.moveSingleFile(book);
} catch (RuntimeException e) {
// Expected
}
InOrder inOrder = inOrder(fileMoveHelper);
inOrder.verify(fileMoveHelper).moveFileWithBackup(any());
inOrder.verify(fileMoveHelper).commitMove(any(), any()); // Committed
// Verify manual rollback (Target -> Source)
inOrder.verify(fileMoveHelper).moveFile(any(), any());
}
}

View File

@@ -1,5 +1,6 @@
package org.booklore.service.file;
import jakarta.persistence.EntityManager;
import org.booklore.config.AppProperties;
import org.booklore.mapper.BookMapper;
import org.booklore.mapper.LibraryMapper;
@@ -17,7 +18,6 @@ import org.booklore.repository.BookRepository;
import org.booklore.repository.LibraryRepository;
import org.booklore.service.NotificationService;
import org.booklore.service.monitoring.MonitoringRegistrationService;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
@@ -31,11 +31,7 @@ import org.mockito.quality.Strictness;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
@@ -55,6 +51,7 @@ class FileMoveServiceTest {
@Mock private BookMapper bookMapper;
@Mock private NotificationService notificationService;
@Mock private EntityManager entityManager;
@Mock private org.springframework.transaction.support.TransactionTemplate transactionTemplate;
private FileMoveService service;
private LibraryEntity library;
@@ -64,9 +61,10 @@ class FileMoveServiceTest {
TestableFileMoveService(AppProperties appProperties, BookRepository bookRepository, BookAdditionalFileRepository bookFileRepository,
LibraryRepository libraryRepository, FileMoveHelper fileMoveHelper,
MonitoringRegistrationService monitoringRegistrationService, LibraryMapper libraryMapper,
BookMapper bookMapper, NotificationService notificationService, EntityManager entityManager) {
BookMapper bookMapper, NotificationService notificationService, EntityManager entityManager,
org.springframework.transaction.support.TransactionTemplate transactionTemplate) {
super(appProperties, bookRepository, bookFileRepository, libraryRepository, fileMoveHelper,
monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager);
monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager, transactionTemplate);
}
@Override
@@ -78,8 +76,16 @@ class FileMoveServiceTest {
@BeforeEach
void setUp() {
when(appProperties.getDiskType()).thenReturn("LOCAL");
// Mock simple execution for transaction template
doAnswer(invocation -> {
java.util.function.Consumer<org.springframework.transaction.TransactionStatus> action = invocation.getArgument(0);
action.accept(null);
return null;
}).when(transactionTemplate).executeWithoutResult(any());
service = spy(new TestableFileMoveService(appProperties, bookRepository, bookFileRepository, libraryRepository,
fileMoveHelper, monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager));
fileMoveHelper, monitoringRegistrationService, libraryMapper, bookMapper, notificationService, entityManager, transactionTemplate));
library = new LibraryEntity();
library.setId(1L);