TileCache.java
/*
* *************************************************************************************************************************************************************
*
* MapView: a JavaFX map renderer for tile-based servers
* http://tidalwave.it/projects/mapview
*
* Copyright (C) 2024 - 2026 by Tidalwave s.a.s. (http://tidalwave.it)
*
* *************************************************************************************************************************************************************
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
* CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
*
* *************************************************************************************************************************************************************
*
* git clone https://bitbucket.org/tidalwave/mapview-src
* git clone https://github.com/tidalwave-it/mapview-src
*
* *************************************************************************************************************************************************************
*/
package it.tidalwave.mapview.impl;
import java.lang.ref.SoftReference;
import jakarta.annotation.Nonnull;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.IntStream;
import java.time.Duration;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import it.tidalwave.mapview.javafx.MapView;
import lombok.extern.slf4j.Slf4j;
import static it.tidalwave.mapview.impl.NameMangler.mangle;
import static java.util.Objects.requireNonNull;
import static java.net.http.HttpClient.Redirect.ALWAYS;
import static java.nio.file.StandardCopyOption.ATOMIC_MOVE;
import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
/***************************************************************************************************************************************************************
* A cache for tiles.
* @author Fabrizio Giudici
**************************************************************************************************************************************************************/
@Slf4j
public final class TileCache
{
/** The timeout for connecting to a tile server. */
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
/** The timeout for a tile download request. */
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30);
/** The timeout for the HTTP client to shut down. */
public static final Duration SHUTDOWN_TIMEOUT = Duration.ofSeconds(10);
/** The name of the folder, inside the cache folder, where tiles are written before being moved to their final place. */
private static final String TEMP_FOLDER_NAME = "tmp";
/** Whether the warning about the missing support for atomic moves has been already logged. */
private static final AtomicBoolean ATOMIC_MOVE_WARNING_LOGGED = new AtomicBoolean();
/** The queue of tiles to be downloaded. */
@Nonnull
/* visible for testing */ final BlockingQueue<AbstractTile> tileQueue;
/** Options of the map view. */
@Nonnull
private final MapView.Options options;
/** The thread pool for downloading tiles. */
@Nonnull
private final ExecutorService executorService;
/** The HTTP client for downloading tiles, shared by all the loaders so that connections are reused. */
@Nonnull
/* visible for testing */ final HttpClient httpClient;
/** This is important to avoid flickering then the TileGrid recreates tiles. */
/* visible for testing */ final Map<URI, SoftReference<Object>> memoryImageCache = new ConcurrentHashMap<>();
/** Whether {@link #dispose()} has been called. */
private final AtomicBoolean disposed = new AtomicBoolean();
/** The unterminated runnables still in execution after {@link #dispose()} - should be empty. */
/* visible for testing */ final List<Runnable> unterminatedRunnables = new ArrayList<>();
/** The folder where tiles are written before being moved to their final place. */
@Nonnull
/* visible for testing */ final Path tempFolder;
/***********************************************************************************************************************************************************
*
**********************************************************************************************************************************************************/
public TileCache (@Nonnull final MapView.Options options)
{
this.options = options;
tileQueue = new LinkedBlockingQueue<>(options.tileQueueCapacity());
httpClient = HttpClient.newBuilder().followRedirects(ALWAYS).connectTimeout(CONNECT_TIMEOUT).build();
tempFolder = options.cacheFolder().resolve(TEMP_FOLDER_NAME);
cleanUpTempFolder(); // before starting the loaders, that write into it - FIXME: this runs in the JavaFX thread, but it should be fast
final var poolSize = options.poolSize();
executorService = options.executorService().apply(poolSize);
IntStream.range(0, poolSize).forEach(i -> executorService.execute(this::tileLoader));
}
/***********************************************************************************************************************************************************
* {@return the number of tiles in the download queue}.
**********************************************************************************************************************************************************/
public int getPendingTileCount()
{
return tileQueue.size();
}
/***********************************************************************************************************************************************************
* Loads a tile in background.
* @param tile the tile to download
**********************************************************************************************************************************************************/
public final void loadTileInBackground (@Nonnull final AbstractTile tile)
{
log.debug("loadTileInBackground({})", tile);
final var imageRef = memoryImageCache.get(tile.getUri());
final var image = (imageRef == null) ? null : imageRef.get();
if (image != null)
{
log.debug("loading tile from memory cache...");
tile.setImageByBitmap(image);
}
else
{
final var localPath = resolveCachedTilePath(tile);
log.debug("looking in disk cache {} ...", localPath);
if (Files.exists(localPath))
{
loadImageFromCache(tile, localPath);
}
else if (disposed.get())
{
log.debug("disposed, not downloading {}", tile.getUri());
}
else
{
tile.setImageByBitmap(options.waitingImage().get());
if (tileQueue.removeIf(queued -> queued.getUri().equals(tile.getUri())))
{
log.debug("replaced queued tile with the same URI: {}", tile.getUri());
}
if (tileQueue.offer(tile))
{
log.debug("added tile {} to download queue - tiles in queue: {}", tile.getUri(), tileQueue.size());
}
else
{
log.warn("download queue full, discarding: {}", tile);
}
}
}
}
/***********************************************************************************************************************************************************
* Clears the queue of pending tiles, retaining only those for the given zoom level.
* @param zoom the zoom level to retain
**********************************************************************************************************************************************************/
public void retainPendingTiles (final int zoom)
{
log.debug("retainPendingTiles({})", zoom);
tileQueue.removeIf(tile -> tile.getZoom() != zoom);
}
/***********************************************************************************************************************************************************
* Stops the tile loaders and shuts down the HTTP client. After that, tiles not in the cache are no more queued for download. Calling this method more
* than once has no further effect.
**********************************************************************************************************************************************************/
public void dispose()
{
log.debug("dispose()");
if (!disposed.compareAndSet(false, true))
{
log.debug("already disposed");
return;
}
unterminatedRunnables.addAll(executorService.shutdownNow());
try
{
if (!executorService.awaitTermination(10, TimeUnit.SECONDS))
{
log.warn("The following threads were not terminated: {}", unterminatedRunnables);
}
httpClient.shutdownNow();
if (!httpClient.awaitTermination(SHUTDOWN_TIMEOUT))
{
log.warn("The HTTP client was not terminated");
}
}
catch (InterruptedException e)
{
log.warn("Interrupted while shutting down.");
Thread.currentThread().interrupt();
}
}
/***********************************************************************************************************************************************************
* The main loop that downloads the tiles.
**********************************************************************************************************************************************************/
private void tileLoader()
{
while (!Thread.interrupted())
{
try
{
log.debug("waiting for next tile to load... queue size = {}", tileQueue.size());
final var tile = tileQueue.take();
final var uri = tile.getUri();
final var localPath = resolveCachedTilePath(tile);
if (!Files.exists(localPath) && options.downloadAllowed())
{
downloadTile(httpClient, tempFolder, localPath, uri, options.userAgent());
}
if (!Files.exists(localPath))
{
tile.setImageByPath(null);
}
else
{
loadImageFromCache(tile, localPath);
}
}
catch (InterruptedException ignored)
{
log.info("tileLoader interrupted");
Thread.currentThread().interrupt();
break;
}
catch (Exception e) // defensive
{
log.error("", e);
}
}
log.info("tileLoader terminated");
}
/***********************************************************************************************************************************************************
* Loads an image from the cache.
* @param tile the tile
* @param path the path of the cache file
**********************************************************************************************************************************************************/
private void loadImageFromCache (@Nonnull final AbstractTile tile, @Nonnull final Path path)
{
log.debug("loadImageFromCache({}, {})", tile, path);
tile.setImageByPath(path).ifPresentOrElse(image -> memoryImageCache.put(tile.getUri(), new SoftReference<>(image)), () -> deleteBrokenFile(path));
}
/***********************************************************************************************************************************************************
* Deletes a cached file that can't be decoded, so it will be downloaded again the next time it is needed.
* @param path the path of the file
**********************************************************************************************************************************************************/
private static void deleteBrokenFile (@Nonnull final Path path)
{
try
{
log.warn("Deleting broken file from the cache: {}", path);
Files.deleteIfExists(path);
}
catch (IOException e)
{
log.warn("Cannot delete {}: {}", path, e.toString());
}
}
/***********************************************************************************************************************************************************
* {@return the path of the cached tile}.
* @param tile the tile
**********************************************************************************************************************************************************/
@Nonnull
private Path resolveCachedTilePath (@Nonnull final AbstractTile tile)
{
return options.cacheFolder().resolve(tile.getSource().getCachePrefix()).resolve(mangle(tile.getUri().toString()));
}
/***********************************************************************************************************************************************************
* Downloads a tile and stores it.
* @param httpClient the HTTP client
* @param tempFolder the folder where the tile is written before being moved to {@code localPath}
* @param localPath the file to store the tile into
* @param uri the uri of the tile
* @param userAgent the {@code User-Agent} to send
**********************************************************************************************************************************************************/
@SuppressFBWarnings("REC_CATCH_EXCEPTION")
/* visible for testing */ static void downloadTile (@Nonnull final HttpClient httpClient,
@Nonnull final Path tempFolder,
@Nonnull final Path localPath,
@Nonnull final URI uri,
@Nonnull final String userAgent)
{
try
{
Files.createDirectories(requireNonNull(localPath.getParent(), localPath + " has no parent"));
final var request = HttpRequest.newBuilder()
.GET()
.header("User-Agent", userAgent)
.header("Accept", "*/*")
.uri(uri)
.timeout(REQUEST_TIMEOUT)
.build();
final var response = sendWithRetry(httpClient, request);
switch (response.statusCode())
{
case 200:
final var bytes = response.body();
writeAtomically(tempFolder, localPath, bytes);
log.debug("written {} bytes to {}", bytes.length, localPath);
break;
case 503:
log.warn("status code 503 for {}, should re-schedule; {}", uri, response.headers().map());
getErrorBody(response).ifPresent(log::warn);
// TODO: should reschedule, but not immediately, and also count for a max number of attempts
// TOOD: could use a different placeholder image?
break;
default:
log.error("status code {} for {}; {}", response.statusCode(), uri, response.headers().map());
getErrorBody(response).ifPresent(log::error);
}
}
catch (InterruptedException e)
{
log.error("", e);
Thread.currentThread().interrupt();
}
catch (Exception e) // defensive
{
log.error("", e);
}
}
/***********************************************************************************************************************************************************
* Writes a file so that other threads never see it partially written: it is first written in the temporary folder and then moved to its final place.
* If the filesystem doesn't support atomic moves, a regular move is performed and a warning is logged.
* @param tempFolder the temporary folder
* @param path the final path of the file
* @param bytes the contents of the file
* @throws IOException in case of I/O error
**********************************************************************************************************************************************************/
private static void writeAtomically (@Nonnull final Path tempFolder, @Nonnull final Path path, @Nonnull final byte[] bytes)
throws IOException
{
Files.createDirectories(tempFolder);
final var tempFile = Files.createTempFile(tempFolder, "tile-", ".tmp");
try
{
Files.write(tempFile, bytes);
try
{
Files.move(tempFile, path, ATOMIC_MOVE, REPLACE_EXISTING);
}
catch (AtomicMoveNotSupportedException e)
{
if (ATOMIC_MOVE_WARNING_LOGGED.compareAndSet(false, true))
{
log.warn("Atomic move not supported in {}, partially written tiles might be read", tempFolder);
}
Files.move(tempFile, path, REPLACE_EXISTING);
}
}
finally
{
Files.deleteIfExists(tempFile);
}
}
/***********************************************************************************************************************************************************
* Deletes the contents of the temporary folder, that might contain partially written files left by a previous run that didn't terminate properly.
**********************************************************************************************************************************************************/
/* visible for testing */ final void cleanUpTempFolder()
{
if (Files.isDirectory(tempFolder))
{
try (final var paths = Files.walk(tempFolder))
{
// reverse order, so that contents of folders are deleted before them
final var toDelete = paths.filter(p -> !p.equals(tempFolder)).sorted(Comparator.reverseOrder()).toList();
log.debug("deleting {} files from {}", toDelete.size(), tempFolder);
for (final var path : toDelete)
{
try
{
Files.delete(path);
}
catch (IOException e)
{
log.warn("Cannot delete {}: {}", path, e.toString());
}
}
}
catch (IOException | UncheckedIOException e)
{
log.warn("Cannot clean up {}: {}", tempFolder, e.toString());
}
}
}
/***********************************************************************************************************************************************************
* Sends a request, retrying once in case of I/O error. This covers, for instance, a connection that has been closed by the server (e.g. with an
* HTTP/2 {@code GOAWAY} frame) while the request was in progress: the client opens a new connection for the retry. Responses with an error status
* code are not retried.
* @param httpClient the HTTP client
* @param request the request
* @return the response
* @throws IOException if also the retry failed
* @throws InterruptedException if interrupted
**********************************************************************************************************************************************************/
@Nonnull
private static HttpResponse<byte[]> sendWithRetry (@Nonnull final HttpClient httpClient, @Nonnull final HttpRequest request)
throws IOException, InterruptedException
{
try
{
return httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
}
catch (IOException e)
{
log.warn("Retrying {} after: {}", request.uri(), e.toString());
return httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
}
}
/***********************************************************************************************************************************************************
*
**********************************************************************************************************************************************************/
@Nonnull
private static Optional<String> getErrorBody (@Nonnull final HttpResponse<byte[]> response)
{
return response.headers()
.firstValue("Content-type")
.filter(ct -> ct.startsWith("text/"))
.map(r -> new String(response.body(), StandardCharsets.UTF_8)); // TODO: charset should be got from response
}
}