Skip to content

Commit 1470987

Browse files
Implement entity file caching mechanism with local storage
1 parent d6f1af0 commit 1470987

3 files changed

Lines changed: 142 additions & 25 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package tools.dynamia.modules.entityfile;
2+
3+
import org.springframework.core.io.Resource;
4+
import org.springframework.scheduling.annotation.Scheduled;
5+
6+
import java.nio.file.Path;
7+
import java.time.Duration;
8+
import java.util.Optional;
9+
10+
11+
public interface EntityFileCache {
12+
Optional<Resource> get(String uuid, String etag);
13+
14+
Resource put(String uuid, String etag, Resource resource);
15+
16+
Path resolvePath(String uuid, String etag);
17+
18+
void evict();
19+
20+
Duration resolveTtl();
21+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package tools.dynamia.modules.entityfile;
2+
3+
import org.springframework.core.env.Environment;
4+
import org.springframework.core.io.FileSystemResource;
5+
import org.springframework.core.io.Resource;
6+
import org.springframework.scheduling.annotation.Scheduled;
7+
import tools.dynamia.commons.logger.LoggingService;
8+
import tools.dynamia.integration.sterotypes.Component;
9+
10+
import java.io.IOException;
11+
import java.io.InputStream;
12+
import java.nio.file.Files;
13+
import java.nio.file.Path;
14+
import java.nio.file.Paths;
15+
import java.nio.file.StandardCopyOption;
16+
import java.time.Duration;
17+
import java.time.Instant;
18+
import java.util.Optional;
19+
20+
@Component
21+
public class EntityFileLocalCache implements EntityFileCache {
22+
23+
private final LoggingService logger = LoggingService.get(EntityFileLocalCache.class);
24+
private final Path cacheDirectory;
25+
private final Duration defaultTtl;
26+
27+
public EntityFileLocalCache(Environment env) {
28+
if (env != null) {
29+
String ttlStr = env.getProperty("entityfile.cache.ttl", Duration.ofHours(12).toString());
30+
String dirStr = env.getProperty("entityfile.cache.dir", Paths.get(System.getProperty("java.io.tmpdir"), "entityfile-cache").toString());
31+
defaultTtl = Duration.parse(ttlStr);
32+
cacheDirectory = Paths.get(dirStr);
33+
} else {
34+
defaultTtl = Duration.ofHours(12);
35+
cacheDirectory = Paths.get(System.getProperty("java.io.tmpdir"), "entityfile-cache");
36+
}
37+
}
38+
39+
40+
@Override
41+
public Optional<Resource> get(String uuid, String etag) {
42+
Path file = resolvePath(uuid, etag);
43+
if (Files.exists(file)) {
44+
return Optional.of(new FileSystemResource(file));
45+
}
46+
return Optional.empty();
47+
}
48+
49+
@Override
50+
public Resource put(String uuid, String etag, Resource resource) {
51+
try {
52+
if (uuid != null && etag != null && resource != null) {
53+
Files.createDirectories(cacheDirectory);
54+
Path dest = resolvePath(uuid, etag);
55+
try (InputStream in = resource.getInputStream()) {
56+
Files.copy(in, dest, StandardCopyOption.REPLACE_EXISTING);
57+
}
58+
return new FileSystemResource(dest);
59+
}
60+
} catch (IOException e) {
61+
logger.error("Failed to cache entity file for uuid: " + uuid + ", etag: " + etag, e);
62+
}
63+
return resource;
64+
}
65+
66+
@Override
67+
public Path resolvePath(String uuid, String etag) {
68+
return cacheDirectory.resolve(uuid + "-" + etag + ".cache");
69+
}
70+
71+
@Scheduled(fixedDelay = 60 * 60 * 1000)
72+
@Override
73+
public void evict() {
74+
if (!Files.exists(cacheDirectory)) return;
75+
try (var stream = Files.list(cacheDirectory)) {
76+
stream.forEach(p -> {
77+
try {
78+
Duration ttl = resolveTtl();
79+
Instant cutoff = Instant.now().minus(ttl);
80+
if (Files.getLastModifiedTime(p).toInstant().isBefore(cutoff)) {
81+
Files.delete(p);
82+
}
83+
} catch (IOException e) {
84+
logger.error("Failed to evict cache file: " + p, e);
85+
}
86+
});
87+
} catch (IOException ignored) {
88+
}
89+
}
90+
91+
@Override
92+
public Duration resolveTtl() {
93+
return defaultTtl;
94+
}
95+
}

extensions/entity-files/sources/core/src/main/java/tools/dynamia/modules/entityfile/controller/EntityFileStorageController.java

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import jakarta.servlet.http.HttpServletRequest;
44
import jakarta.servlet.http.HttpServletResponse;
55
import org.jspecify.annotations.NonNull;
6+
import org.springframework.core.io.FileSystemResource;
67
import org.springframework.core.io.Resource;
78
import org.springframework.http.*;
89
import org.springframework.web.bind.annotation.GetMapping;
@@ -11,11 +12,13 @@
1112
import org.springframework.web.bind.annotation.RequestBody;
1213
import org.springframework.web.bind.annotation.RequestParam;
1314
import org.springframework.web.multipart.MultipartFile;
15+
import tools.dynamia.commons.AtomicString;
1416
import tools.dynamia.commons.MapBuilder;
1517
import tools.dynamia.domain.services.CrudService;
1618
import tools.dynamia.domain.util.DomainUtils;
1719
import tools.dynamia.integration.Containers;
1820
import tools.dynamia.integration.sterotypes.Controller;
21+
import tools.dynamia.modules.entityfile.EntityFileCache;
1922
import tools.dynamia.modules.entityfile.UploadedFileInfo;
2023
import tools.dynamia.modules.entityfile.EntityFileAccountProvider;
2124
import tools.dynamia.modules.entityfile.EntityFileSecurityProvider;
@@ -48,15 +51,18 @@ public class EntityFileStorageController {
4851
private final EntityFileService entityFileService;
4952
private final CrudService crudService;
5053

54+
private final EntityFileCache entityFileCache;
55+
5156
/**
5257
* Creates a new controller instance.
5358
*
5459
* @param entityFileService service used to create, resolve and download entity files
5560
* @param crudService service used to resolve target entities dynamically by class name and ID
5661
*/
57-
public EntityFileStorageController(EntityFileService entityFileService, CrudService crudService) {
62+
public EntityFileStorageController(EntityFileService entityFileService, CrudService crudService, EntityFileCache entityFileCache) {
5863
this.entityFileService = entityFileService;
5964
this.crudService = crudService;
65+
this.entityFileCache = entityFileCache;
6066
}
6167

6268
/**
@@ -224,18 +230,23 @@ public ResponseEntity<Resource> get(@PathVariable("uuid") String uuid, @PathVari
224230
}
225231
}
226232

227-
Resource resource = null;
228-
var storedEntityFile = entityFile.getStoredEntityFile();
229-
var etag = "v" + entityFile.currentVersion();
230233

231-
if (entityFile.getType() == EntityFileType.IMAGE && isThumbnail(request)) {
232-
String w = getParam(request, "w", "200");
233-
String h = getParam(request, "h", "200");
234-
etag += "-thumb-" + w + "x" + h;
235-
resource = storedEntityFile.toThumbnailResource(safeSize(w, 200), safeSize(h, 200));
236-
} else {
237-
resource = storedEntityFile.toResource();
238-
}
234+
var storedEntityFile = entityFile.getStoredEntityFile();
235+
var etag = AtomicString.of("v" + entityFile.currentVersion());
236+
Resource resource = entityFileCache.get(entityFile.getUuid(), etag.get())
237+
.orElseGet(() -> {
238+
Resource remoteResource;
239+
if (entityFile.getType() == EntityFileType.IMAGE && isThumbnail(request)) {
240+
String w = getParam(request, "w", "200");
241+
String h = getParam(request, "h", "200");
242+
etag.append("-thumb-" + w + "x" + h);
243+
remoteResource = storedEntityFile.toThumbnailResource(safeSize(w, 200), safeSize(h, 200));
244+
} else {
245+
remoteResource = storedEntityFile.toResource();
246+
}
247+
entityFileCache.put(entityFile.getUuid(), etag.get(), remoteResource);
248+
return remoteResource;
249+
});
239250

240251

241252
if (resource != null && resource.exists() && resource.isReadable()) {
@@ -254,20 +265,10 @@ public ResponseEntity<Resource> get(@PathVariable("uuid") String uuid, @PathVari
254265
.immutable();
255266
}
256267

257-
String ifNoneMatch = request.getHeader(HttpHeaders.IF_NONE_MATCH);
258-
259-
if (etag.equals(ifNoneMatch)) {
260-
return ResponseEntity
261-
.status(HttpStatus.NOT_MODIFIED)
262-
.eTag(etag)
263-
.cacheControl(cacheControl)
264-
.build();
265-
}
266-
267268
return ResponseEntity.ok()
268269
.contentType(contentType)
269270
.cacheControl(cacheControl)
270-
.header(HttpHeaders.ETAG, etag)
271+
.header(HttpHeaders.ETAG, etag.get())
271272
.body(resource);
272273
} else {
273274
return ResponseEntity.notFound().build();
@@ -615,11 +616,11 @@ private boolean isValidAccount(EntityFile entityFile) {
615616
*
616617
* @param value requested size value
617618
* @param def fallback size when parsing fails
618-
* @return a value between 1 and 2000
619+
* @return a value between 1 and 1000
619620
*/
620621
private int safeSize(String value, int def) {
621622
try {
622-
return Math.min(Math.max(Integer.parseInt(value), 1), 2000);
623+
return Math.clamp(Integer.parseInt(value), 1, 1000);
623624
} catch (Exception e) {
624625
return def;
625626
}

0 commit comments

Comments
 (0)