77
88import ch .rasc .sse .eventbus .SseEvent ;
99import ch .rasc .sse .eventbus .SseEventBus ;
10+ import jakarta .annotation .PostConstruct ;
1011import java .io .File ;
1112import java .io .IOException ;
13+ import java .io .UncheckedIOException ;
1214import java .lang .invoke .MethodHandles ;
1315import java .nio .charset .StandardCharsets ;
1416import java .nio .file .Files ;
5658import org .springframework .scheduling .annotation .Scheduled ;
5759import org .springframework .stereotype .Service ;
5860import org .springframework .transaction .annotation .Transactional ;
59- import org .springframework .web .server .ResponseStatusException ;
6061import org .tailormap .api .controller .LayerExtractController ;
6162import org .tailormap .api .geotools .collection .ProgressReportingFeatureCollection ;
6263import 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 ) {
0 commit comments