Skip to content

Commit 8b3efdc

Browse files
committed
Address code review
1 parent 3563960 commit 8b3efdc

5 files changed

Lines changed: 92 additions & 48 deletions

File tree

pom.xml

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -322,11 +322,14 @@ SPDX-License-Identifier: MIT
322322
<dependency>
323323
<groupId>org.geotools</groupId>
324324
<artifactId>gt-excel-writer</artifactId>
325-
<version>[35-SNAPSHOT,)</version>
325+
<!-- gt-excel-writer is not in the gt-bom, so we need to specify the version here -->
326+
<version>${geotools.version}</version>
326327
</dependency>
327328
<dependency>
328329
<groupId>org.geotools</groupId>
329330
<artifactId>gt-geojson-store</artifactId>
331+
<!-- override version to get latest bug fixes, see: https://osgeo-org.atlassian.net/browse/GEOT-7894 -->
332+
<version>35-SNAPSHOT</version>
330333
</dependency>
331334
<dependency>
332335
<groupId>org.geotools</groupId>
@@ -701,20 +704,21 @@ SPDX-License-Identifier: MIT
701704
<url>https://repo.osgeo.org/repository/release/</url>
702705
</repository>
703706
<repository>
707+
<!-- TODO remove this repository once gt-geojson-store 35.0 is released with the latest bug fixes -->
704708
<snapshots>
705709
<enabled>true</enabled>
706710
</snapshots>
707-
<id>repo.b3p.nl</id>
708-
<name>B3Partners public repository</name>
709-
<url>https://repo.b3p.nl/nexus/repository/public/</url>
711+
<id>OSGeo-snapshots</id>
712+
<name>Snapshots hosted by OSGeo</name>
713+
<url>https://repo.osgeo.org/repository/snapshot/</url>
710714
</repository>
711715
<repository>
712716
<snapshots>
713717
<enabled>true</enabled>
714718
</snapshots>
715-
<id>OSGeo-snapshots</id>
716-
<name>Snapshots hosted by OSGeo</name>
717-
<url>https://repo.osgeo.org/repository/snapshot/</url>
719+
<id>repo.b3p.nl</id>
720+
<name>B3Partners public repository</name>
721+
<url>https://repo.b3p.nl/nexus/repository/public/</url>
718722
</repository>
719723
</repositories>
720724
<pluginRepositories />
@@ -1011,14 +1015,14 @@ SPDX-License-Identifier: MIT
10111015
alternatively, use environment variable BPL_JVM_CLASS_ADJUSTMENT when deploying the docker container
10121016
-->
10131017
<BPE_DEFAULT_BPL_JVM_CLASS_ADJUSTMENT>120%</BPE_DEFAULT_BPL_JVM_CLASS_ADJUSTMENT>
1014-
<!-- JVM default is the same as -Xmx. However, the Paketo Java Buildpack memory calculator sets it to
1015-
10M by default, which is too low causing OOM in our application (Netty Solr client and Hikari use more
1016-
than 10MB in direct buffer pools after some time), set it to 256M
1017-
See https://github.com/orgs/paketo-buildpacks/discussions/241
1018-
-->
1019-
<BPE_APPEND_JAVA_TOOL_OPTIONS xml:space="preserve"> -XX:MaxDirectMemorySize=256M</BPE_APPEND_JAVA_TOOL_OPTIONS>
1020-
<!-- for GeoPackage support which uses a native driver -->
1021-
<BPE_APPEND_JAVA_TOOL_OPTIONS xml:space="preserve"> --enable-native-access=ALL-UNNAMED</BPE_APPEND_JAVA_TOOL_OPTIONS>
1018+
<!--
1019+
- JVM default is the same as -Xmx. However, the Paketo Java Buildpack memory calculator sets it to
1020+
10M by default, which is too low causing OOM in our application (Netty Solr client and Hikari use more
1021+
than 10MB in direct buffer pools after some time), set it to 256M
1022+
See https://github.com/orgs/paketo-buildpacks/discussions/241
1023+
1024+
- Enable native access for GeoPackage support which uses a native driver -->
1025+
<BPE_APPEND_JAVA_TOOL_OPTIONS xml:space="preserve"> -XX:MaxDirectMemorySize=256M --enable-native-access=ALL-UNNAMED</BPE_APPEND_JAVA_TOOL_OPTIONS>
10221026
<!-- Headroom is used by the memory calculator to reduce the max total memory limit. The default is 0%,
10231027
but since Tailormap is usually run with unconstrained container memory, set it to 10% to prevent taking
10241028
too much host memory. Although Tailormap should not exhaust heap memory, reduce it as a preventive safety

src/main/java/org/tailormap/api/service/CreateLayerExtractService.java

Lines changed: 64 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77

88
import ch.rasc.sse.eventbus.SseEvent;
99
import ch.rasc.sse.eventbus.SseEventBus;
10+
import jakarta.annotation.PostConstruct;
1011
import java.io.File;
1112
import java.io.IOException;
13+
import java.io.UncheckedIOException;
1214
import java.lang.invoke.MethodHandles;
1315
import java.nio.charset.StandardCharsets;
1416
import java.nio.file.Files;
@@ -56,7 +58,6 @@
5658
import org.springframework.scheduling.annotation.Scheduled;
5759
import org.springframework.stereotype.Service;
5860
import org.springframework.transaction.annotation.Transactional;
59-
import org.springframework.web.server.ResponseStatusException;
6061
import org.tailormap.api.controller.LayerExtractController;
6162
import org.tailormap.api.geotools.collection.ProgressReportingFeatureCollection;
6263
import org.tailormap.api.geotools.data.excel.ExcelDataStore;
@@ -77,9 +78,13 @@ public class CreateLayerExtractService {
7778
private final FeatureSourceFactoryHelper featureSourceFactoryHelper;
7879
private final FilterFactory ff = CommonFactoryFinder.getFilterFactory(GeoTools.getDefaultHints());
7980

81+
private static final String EXTRACT_SUBDIRECTORY = "tm-extracts";
8082
// we can safely use the tmp dir as a default here because we are running in a docker container without a shell so
8183
// access is limited
84+
// Base directory from config; actual export dir is <base>/tm-extracts
8285
@Value("${tailormap-api.extract.location:#{systemProperties['java.io.tmpdir']}}")
86+
private String exportFilesBaseLocation;
87+
8388
private String exportFilesLocation;
8489

8590
@Value("${tailormap-api.extract.cleanup-minutes:120}")
@@ -91,6 +96,19 @@ public class CreateLayerExtractService {
9196
@Value("${tailormap-api.features.wfs_count_exact:false}")
9297
private boolean exactWfsCounts;
9398

99+
@PostConstruct
100+
void initializeExtractDirectory() {
101+
try {
102+
Path exportRoot = Path.of(exportFilesBaseLocation, EXTRACT_SUBDIRECTORY);
103+
Files.createDirectories(exportRoot);
104+
this.exportFilesLocation = exportRoot.toRealPath().toString();
105+
logger.info("Using extract output directory: {}", this.exportFilesLocation);
106+
} catch (IOException e) {
107+
throw new UncheckedIOException(
108+
"Failed to initialize extract directory under base path: " + exportFilesBaseLocation, e);
109+
}
110+
}
111+
94112
public CreateLayerExtractService(
95113
@Qualifier("viewerSseEventBus") SseEventBus eventBus,
96114
JsonMapper jsonMapper,
@@ -249,13 +267,12 @@ private void handleGeoPackage(
249267
@NonNull String outputFileName) {
250268

251269
SimpleFeatureSource inputFeatureSource = null;
252-
File outputFile = null;
270+
File outputFile;
253271
try {
254272
outputFile = getValidatedOutputFile(outputFileName);
255273
if (!logger.isDebugEnabled()) {
256274
// delete in production after JVM exit because the event bus will be reset when the JVM exits, and then
257-
// we
258-
// are unlikely to have a reference to the file anymore.
275+
// we are unlikely to have a reference to the file anymore.
259276
// In debug/development mode we want to keep the file for inspection.
260277
outputFile.deleteOnExit();
261278
}
@@ -347,10 +364,15 @@ private void handleSingleFileFormats(
347364
clientId,
348365
"Extract result contains %d features, which exceeds the maximum of %d for Excel output format. Please refine your filter or choose a different output format."
349366
.formatted(featCount, ExcelDataStore.getMaxRows()));
350-
throw new ResponseStatusException(
351-
org.springframework.http.HttpStatus.BAD_REQUEST,
352-
"Extract result contains %d features, which exceeds the maximum of %d for Excel output format. Please refine your filter or choose a different output format."
353-
.formatted(featCount, ExcelDataStore.getMaxRows()));
367+
logger.error(
368+
"Extract result contains {} features, which exceeds the maximum of {} for Excel output format. Please refine your filter or choose a different output format.",
369+
featCount,
370+
ExcelDataStore.getMaxRows());
371+
// nothing we can do now as we are in a background/async process, so we just return without creating an
372+
// extract file.
373+
// The client will receive no extract completed event, and we have already emitted an error message with
374+
// details.
375+
return;
354376
}
355377

356378
outputDataStore = this.getExtractDataStore(
@@ -390,7 +412,7 @@ private void handleSingleFileFormats(
390412
this.emitError(clientId, "Output datastore is not a SimpleFeatureStore, cannot write features");
391413
logger.error("Output datastore is not a SimpleFeatureStore, cannot write features");
392414
}
393-
} catch (IOException | SchemaException | IllegalArgumentException e) {
415+
} catch (IOException | SchemaException | IllegalArgumentException | NullPointerException e) {
394416
emitError(clientId, e.getMessage());
395417
logger.error("Creating extract failed", e);
396418
} finally {
@@ -527,10 +549,9 @@ private void handleWithShapeDumper(
527549
.resolve(baseName)
528550
.toFile()
529551
.getCanonicalFile();
530-
if (logger.isDebugEnabled()) {
552+
if (!logger.isDebugEnabled()) {
531553
// delete in production after JVM exit because the event bus will be reset when the JVM exits, and then
532-
// we
533-
// are unlikely to have a reference to the file anymore.
554+
// we are unlikely to have a reference to the file anymore.
534555
// In debug/development mode we want to keep the directory for inspection.
535556
outputDirectory.deleteOnExit();
536557
}
@@ -616,7 +637,7 @@ private Query createQuery(
616637
@Scheduled(fixedDelay = 5, timeUnit = TimeUnit.MINUTES, initialDelay = 15)
617638
public void cleanupExpiredExtracts() {
618639
logger.debug("Running expired extracts cleanup...");
619-
List<FileWithAttributes> clientFilesOnDisk = new ArrayList<>();
640+
List<FileWithAttributes> oldDownloadFilesOnDisk = new ArrayList<>();
620641
Set<String> validClientIds = eventBus.getAllClientIds();
621642

622643
// list download files in export location and delete those that are not bound to an active sse stream client
@@ -635,8 +656,12 @@ public void cleanupExpiredExtracts() {
635656
logger.error("Failed to delete unattached extract file {}", filename);
636657
}
637658
} else {
638-
Instant timestampPart = UUIDv7.timestampAsInstant(UUIDv7.fromString(parts[2]));
639-
clientFilesOnDisk.add(new FileWithAttributes(file, timestampPart, clientId));
659+
try {
660+
Instant timestampPart = UUIDv7.timestampAsInstant(UUIDv7.fromString(parts[2]));
661+
oldDownloadFilesOnDisk.add(new FileWithAttributes(file, timestampPart, clientId));
662+
} catch (IllegalArgumentException ignored) {
663+
// not a valid v7 uuid
664+
}
640665
}
641666
});
642667

@@ -651,25 +676,39 @@ public void cleanupExpiredExtracts() {
651676
}
652677
String clientId = parts[1];
653678
if (!validClientIds.contains(clientId)) {
654-
if (!file.delete()) {
655-
logger.error("Failed to delete unattached extract file {}", filename);
679+
try {
680+
deleteDirectoryRecursively(file.toPath());
681+
} catch (IOException e) {
682+
logger.error("Failed to delete unattached extract directory {}", filename);
656683
}
657684
} else {
658-
Instant timestampPart = UUIDv7.timestampAsInstant(UUIDv7.fromString(parts[2]));
659-
clientFilesOnDisk.add(new FileWithAttributes(file, timestampPart, clientId));
685+
try {
686+
Instant timestampPart = UUIDv7.timestampAsInstant(UUIDv7.fromString(parts[2]));
687+
oldDownloadFilesOnDisk.add(new FileWithAttributes(file, timestampPart, clientId));
688+
} catch (IllegalArgumentException ignored) {
689+
// not a valid v7 uuid
690+
}
660691
}
661692
});
662693
}
663694

664-
// delete any files are older than the cutoff
665-
clientFilesOnDisk.stream()
695+
// delete any files/directories are older than the cutoff
696+
oldDownloadFilesOnDisk.stream()
666697
.filter(f -> f.timestamp()
667698
.isBefore(Instant.now().minusSeconds(TimeUnit.MINUTES.toSeconds(cleanupIntervalMinutes))))
668699
.forEach(f -> {
669-
if (!f.file().delete()) {
670-
logger.error(
671-
"Failed to delete expired extract file {}",
672-
f.file().getName());
700+
if (f.file.isDirectory()) {
701+
try {
702+
deleteDirectoryRecursively(f.file().toPath());
703+
} catch (IOException ignored) {
704+
logger.warn("Failed to delete directory {}", f.file());
705+
}
706+
} else {
707+
if (!f.file().delete()) {
708+
logger.error(
709+
"Failed to delete expired extract file {}",
710+
f.file().getName());
711+
}
673712
}
674713
});
675714
} catch (IOException e) {

src/main/resources/application.properties

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,18 @@ tailormap-api.feature.info.maxitems=30
3232
tailormap-api.extract.allowed-outputformats=csv,geojson,xlsx,shape,geopackage
3333
# any files older than this (in minutes) in the extract output directory will be deleted by a scheduled job, to prevent filling up the disk
3434
# tailormap-api.extract.cleanup-minutes=120
35-
# the directory where the extract output files are stored, should be writable by the application
35+
# the (base) directory where the extract output files are stored, should be writable by the application
36+
# a subdirectory "tm-extracs" will be created to be managed by the application
3637
# tailormap-api.extract.location=/tmp
37-
# the number of features after which a progress report is sent back to the viewer, to update the progress bar
38+
# the number of features after which a progress report is sent back to the viewer, to e.g. update a progress bar
3839
# tailormap-api.extract.progress-report-interval=100
3940

4041
# proxy passthrough regex patterns for layer names, when empty no additional layers are allowed to be proxied
4142
# eg. use vw_t_gi_%s_[a-fA-F0-9]{32} to match `vw_t_gi_layername_70cae9814c6144808f1c9bb921099794` as a sub-layer of layername
4243
# %s is replaced with the layer name from the configuration (this uses String.format() syntax, so be aware of the escaping rules for % and \)
4344
# for regex help see eg: https://regex101.com/ or https://www.regexplanet.com/advanced/java/index.html or https://regexr.com/
4445
tailormap-api.proxy.passthrough.layerpatterns=
45-
## list of allowed host names eg. test.com,localhost (no spaces) to validate the layer name patterns, can be empty to allow any host name
46+
## list of allowed host names e.g. test.com,localhost (no spaces) to validate the layer name patterns, can be empty to allow any host name
4647
tailormap-api.proxy.passthrough.hostnames=
4748

4849
# whether the API should use GeoTools "Unique Collection" (use DISTINCT in SQL statements) or just

src/main/resources/openapi/viewer-api.yaml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1871,7 +1871,7 @@ paths:
18711871
type: array
18721872
items:
18731873
type: string
1874-
example: '["csv","shape"]'
1874+
example: ["csv","shape"]
18751875

18761876
/{viewerKind}/{viewerName}/layer/{appLayerId}/extract/{clientId}:
18771877
description: 'Export the attributes as shown in the attribute list for a layer.'
@@ -1954,6 +1954,9 @@ paths:
19541954
properties:
19551955
message:
19561956
type: string
1957+
downloadId:
1958+
type: string
1959+
description: 'The id to use to download the file once the extract is completed.'
19571960
required:
19581961
- message
19591962
example:
@@ -2012,7 +2015,6 @@ paths:
20122015
required: true
20132016
schema:
20142017
type: string
2015-
format: uuid
20162018
responses:
20172019
'200':
20182020
description: 'OK'

src/test/resources/application.properties

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,7 @@ tailormap-api.extract.allowed-outputformats=csv,geojson,xlsx,shape,geopackage
77
# the number of features after which a progress report is sent back to the viewer, to update the progress bar
88
tailormap-api.extract.progress-report-interval=10
99
# any files older than this (in minutes) in the extract output directory will be deleted by a scheduled job, to prevent filling up the disk
10-
tailormap-api.extract.cleanup-minutes=15
11-
# the directory where the extract output files are stored, should be writable by the application
12-
# tailormap-api.extract.location=/tmp
10+
tailormap-api.extract.cleanup-minutes=5
1311

1412
tailormap-api.timeout=5000
1513
tailormap-api.management.hashed-password=#{null}

0 commit comments

Comments
 (0)