feat(files): expire stale upload sessions
This commit is contained in:
@@ -6,7 +6,9 @@ import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
import com.yoyuzh.config.FileStorageProperties;
|
||||
import com.yoyuzh.files.storage.FileContentStorage;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -23,12 +25,18 @@ public class UploadSessionService {
|
||||
|
||||
private static final long DEFAULT_CHUNK_SIZE = 8L * 1024 * 1024;
|
||||
private static final long SESSION_TTL_HOURS = 24;
|
||||
private static final List<UploadSessionStatus> EXPIRABLE_STATUSES = List.of(
|
||||
UploadSessionStatus.CREATED,
|
||||
UploadSessionStatus.UPLOADING,
|
||||
UploadSessionStatus.COMPLETING
|
||||
);
|
||||
private static final TypeReference<List<UploadedPart>> UPLOADED_PARTS_TYPE = new TypeReference<>() {
|
||||
};
|
||||
|
||||
private final UploadSessionRepository uploadSessionRepository;
|
||||
private final StoredFileRepository storedFileRepository;
|
||||
private final FileService fileService;
|
||||
private final FileContentStorage fileContentStorage;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final long maxFileSize;
|
||||
private final Clock clock;
|
||||
@@ -37,18 +45,21 @@ public class UploadSessionService {
|
||||
public UploadSessionService(UploadSessionRepository uploadSessionRepository,
|
||||
StoredFileRepository storedFileRepository,
|
||||
FileService fileService,
|
||||
FileContentStorage fileContentStorage,
|
||||
FileStorageProperties properties) {
|
||||
this(uploadSessionRepository, storedFileRepository, fileService, properties, Clock.systemUTC());
|
||||
this(uploadSessionRepository, storedFileRepository, fileService, fileContentStorage, properties, Clock.systemUTC());
|
||||
}
|
||||
|
||||
UploadSessionService(UploadSessionRepository uploadSessionRepository,
|
||||
StoredFileRepository storedFileRepository,
|
||||
FileService fileService,
|
||||
FileContentStorage fileContentStorage,
|
||||
FileStorageProperties properties,
|
||||
Clock clock) {
|
||||
this.uploadSessionRepository = uploadSessionRepository;
|
||||
this.storedFileRepository = storedFileRepository;
|
||||
this.fileService = fileService;
|
||||
this.fileContentStorage = fileContentStorage;
|
||||
this.maxFileSize = properties.getMaxFileSize();
|
||||
this.clock = clock;
|
||||
}
|
||||
@@ -166,6 +177,29 @@ public class UploadSessionService {
|
||||
}
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 60 * 60 * 1000L)
|
||||
@Transactional
|
||||
public int pruneExpiredSessions() {
|
||||
LocalDateTime now = LocalDateTime.ofInstant(clock.instant(), clock.getZone());
|
||||
List<UploadSession> expiredSessions = uploadSessionRepository.findByStatusInAndExpiresAtBefore(
|
||||
EXPIRABLE_STATUSES,
|
||||
now
|
||||
);
|
||||
for (UploadSession session : expiredSessions) {
|
||||
try {
|
||||
fileContentStorage.deleteBlob(session.getObjectKey());
|
||||
} catch (RuntimeException ignored) {
|
||||
// Expiration is authoritative in the database even if remote object cleanup fails.
|
||||
}
|
||||
session.setStatus(UploadSessionStatus.EXPIRED);
|
||||
session.setUpdatedAt(now);
|
||||
}
|
||||
if (!expiredSessions.isEmpty()) {
|
||||
uploadSessionRepository.saveAll(expiredSessions);
|
||||
}
|
||||
return expiredSessions.size();
|
||||
}
|
||||
|
||||
private void validateTarget(User user, String normalizedPath, String filename, long size) {
|
||||
long effectiveMaxUploadSize = Math.min(maxFileSize, user.getMaxUploadSizeBytes());
|
||||
if (size > effectiveMaxUploadSize) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.yoyuzh.files;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.config.FileStorageProperties;
|
||||
import com.yoyuzh.files.storage.FileContentStorage;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -14,11 +15,13 @@ import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -32,6 +35,8 @@ class UploadSessionServiceTest {
|
||||
private StoredFileRepository storedFileRepository;
|
||||
@Mock
|
||||
private FileService fileService;
|
||||
@Mock
|
||||
private FileContentStorage fileContentStorage;
|
||||
|
||||
private UploadSessionService uploadSessionService;
|
||||
|
||||
@@ -43,6 +48,7 @@ class UploadSessionServiceTest {
|
||||
uploadSessionRepository,
|
||||
storedFileRepository,
|
||||
fileService,
|
||||
fileContentStorage,
|
||||
properties,
|
||||
Clock.fixed(Instant.parse("2026-04-08T06:00:00Z"), ZoneOffset.UTC)
|
||||
);
|
||||
@@ -179,6 +185,25 @@ class UploadSessionServiceTest {
|
||||
)).isInstanceOf(BusinessException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExpireUnfinishedSessionsAndDeleteTemporaryBlobs() {
|
||||
User user = createUser(7L);
|
||||
UploadSession session = createSession(user);
|
||||
session.setStatus(UploadSessionStatus.UPLOADING);
|
||||
session.setObjectKey("blobs/expired-session");
|
||||
session.setExpiresAt(LocalDateTime.of(2026, 4, 8, 5, 0));
|
||||
when(uploadSessionRepository.findByStatusInAndExpiresAtBefore(anyList(), eq(LocalDateTime.of(2026, 4, 8, 6, 0))))
|
||||
.thenReturn(List.of(session));
|
||||
|
||||
int expiredCount = uploadSessionService.pruneExpiredSessions();
|
||||
|
||||
assertThat(expiredCount).isEqualTo(1);
|
||||
assertThat(session.getStatus()).isEqualTo(UploadSessionStatus.EXPIRED);
|
||||
assertThat(session.getUpdatedAt()).isEqualTo(LocalDateTime.of(2026, 4, 8, 6, 0));
|
||||
verify(fileContentStorage).deleteBlob("blobs/expired-session");
|
||||
verify(uploadSessionRepository).saveAll(List.of(session));
|
||||
}
|
||||
|
||||
private User createUser(Long id) {
|
||||
User user = new User();
|
||||
user.setId(id);
|
||||
|
||||
Reference in New Issue
Block a user