Changeset 36145 in osm
- Timestamp:
- 2023-09-18T21:17:28+02:00 (14 months ago)
- Location:
- applications/editors/josm/plugins/pmtiles
- Files:
-
- 3 edited
Legend:
- Unmodified
- Added
- Removed
-
applications/editors/josm/plugins/pmtiles/build.xml
r36144 r36145 8 8 <property name="commit.message" value="Commit message"/> 9 9 <!-- enter the *lowest* JOSM version this plugin is currently compatible with --> 10 <property name="plugin.main.version" value="1883 0"/>10 <property name="plugin.main.version" value="18831"/> 11 11 12 12 <!-- Configure these properties (replace "..." accordingly). -
applications/editors/josm/plugins/pmtiles/src/main/java/org/openstreetmap/josm/plugins/pmtiles/gui/layers/JCSCachedTileLoaderJob.java
r36112 r36145 1 // License: GPL. For details, see LICENSE file.2 package org.openstreetmap.josm.plugins.pmtiles.gui.layers;3 4 import java.io.File;5 import java.io.FileNotFoundException;6 import java.io.IOException;7 import java.io.InputStream;8 import java.lang.annotation.Documented;9 import java.lang.annotation.ElementType;10 import java.lang.annotation.Target;11 import java.net.HttpURLConnection;12 import java.net.URL;13 import java.nio.file.Files;14 import java.security.SecureRandom;15 import java.util.Collections;16 import java.util.List;17 import java.util.Map;18 import java.util.Set;19 import java.util.concurrent.ConcurrentHashMap;20 import java.util.concurrent.ConcurrentMap;21 import java.util.concurrent.LinkedBlockingDeque;22 import java.util.concurrent.ThreadPoolExecutor;23 import java.util.concurrent.TimeUnit;24 import java.util.regex.Matcher;25 26 import org.openstreetmap.josm.data.cache.CacheEntry;27 import org.openstreetmap.josm.data.cache.CacheEntryAttributes;28 import org.openstreetmap.josm.data.cache.ICachedLoaderJob;29 import org.openstreetmap.josm.data.cache.ICachedLoaderListener;30 import org.openstreetmap.josm.data.cache.ICachedLoaderListener.LoadResult;31 import org.openstreetmap.josm.data.imagery.TileJobOptions;32 import org.openstreetmap.josm.data.preferences.IntegerProperty;33 import org.openstreetmap.josm.tools.CheckParameterUtil;34 import org.openstreetmap.josm.tools.HttpClient;35 import org.openstreetmap.josm.tools.Logging;36 import org.openstreetmap.josm.tools.Utils;37 38 import org.apache.commons.jcs3.access.behavior.ICacheAccess;39 import org.apache.commons.jcs3.engine.behavior.ICacheElement;40 41 /**42 * Generic loader for HTTP based tiles. Uses custom attribute, to check, if entry has expired43 * according to HTTP headers sent with tile. If so, it tries to verify using Etags44 * or If-Modified-Since / Last-Modified.45 * <p>46 * If the tile is not valid, it will try to download it from remote service and put it47 * to cache. If remote server will fail it will try to use stale entry.48 * <p>49 * This class will keep only one Job running for specified tile. All others will just finish, but50 * listeners will be gathered and notified, once download job will be finished51 *52 * @author Wiktor Niesiobędzki53 * @param <K> cache entry key type54 * @param <V> cache value type55 * @since 8168 (in JOSM). Copied to PMTilesPlugin to make some methods overridable. Methods modified are annotated with56 * {@link #ModifiedFromJosm}57 */58 public abstract class JCSCachedTileLoaderJob<K, V extends CacheEntry> implements ICachedLoaderJob<K> {59 @Documented60 @Target({ElementType.METHOD, ElementType.FIELD})61 @interface ModifiedFromJosm {62 /**63 * What changed from JOSM64 * @return The reason or changes65 */66 String value() default "";67 }68 protected static final long DEFAULT_EXPIRE_TIME = TimeUnit.DAYS.toMillis(7);69 // Limit for the max-age value send by the server.70 protected static final long EXPIRE_TIME_SERVER_LIMIT = TimeUnit.DAYS.toMillis(28);71 // Absolute expire time limit. Cached tiles that are older will not be used,72 // even if the refresh from the server fails.73 protected static final long ABSOLUTE_EXPIRE_TIME_LIMIT = TimeUnit.DAYS.toMillis(365);74 75 /**76 * maximum download threads that will be started77 */78 public static final IntegerProperty THREAD_LIMIT = new IntegerProperty("cache.jcs.max_threads", 10);79 80 /*81 * ThreadPoolExecutor starts new threads, until THREAD_LIMIT is reached. Then it puts tasks into LinkedBlockingDeque.82 *83 * The queue works FIFO, so one needs to take care about ordering of the entries submitted84 *85 * There is no point in canceling tasks, that are already taken by worker threads (if we made so much effort, we can at least cache86 * the response, so later it could be used). We could actually cancel what is in LIFOQueue, but this is a tradeoff between simplicity87 * and performance (we do want to have something to offer to worker threads before tasks will be resubmitted by class consumer)88 */89 90 private static final ThreadPoolExecutor DEFAULT_DOWNLOAD_JOB_DISPATCHER = new ThreadPoolExecutor(91 1, // we have a small queue, so threads will be quickly started (threads are started only, when queue is full)92 THREAD_LIMIT.get(), // do not this number of threads93 30, // keepalive for thread94 TimeUnit.SECONDS,95 // make queue of LIFO type - so recently requested tiles will be loaded first (assuming that these are which user is waiting to see)96 new LinkedBlockingDeque<>(),97 Utils.newThreadFactory("JCS-downloader-%d", Thread.NORM_PRIORITY)98 );99 100 private static final ConcurrentMap<String, Set<ICachedLoaderListener>> inProgress = new ConcurrentHashMap<>();101 private static final ConcurrentMap<String, Boolean> useHead = new ConcurrentHashMap<>();102 103 protected final long now; // when the job started104 105 @ModifiedFromJosm("Visibility")106 protected final ICacheAccess<K, V> cache;107 private ICacheElement<K, V> cacheElement;108 protected V cacheData;109 protected CacheEntryAttributes attributes;110 111 // HTTP connection parameters112 private final int connectTimeout;113 private final int readTimeout;114 private final Map<String, String> headers;115 private final ThreadPoolExecutor downloadJobExecutor;116 private Runnable finishTask;117 private boolean force;118 private final long minimumExpiryTime;119 120 /**121 * @param cache cache instance that we will work on122 * @param options options of the request123 * @param downloadJobExecutor that will be executing the jobs124 */125 protected JCSCachedTileLoaderJob(ICacheAccess<K, V> cache,126 TileJobOptions options,127 ThreadPoolExecutor downloadJobExecutor) {128 CheckParameterUtil.ensureParameterNotNull(cache, "cache");129 this.cache = cache;130 this.now = System.currentTimeMillis();131 this.connectTimeout = options.getConnectionTimeout();132 this.readTimeout = options.getReadTimeout();133 this.headers = options.getHeaders();134 this.downloadJobExecutor = downloadJobExecutor;135 this.minimumExpiryTime = TimeUnit.SECONDS.toMillis(options.getMinimumExpiryTime());136 }137 138 /**139 * @param cache cache instance that we will work on140 * @param options of the request141 */142 protected JCSCachedTileLoaderJob(ICacheAccess<K, V> cache,143 TileJobOptions options) {144 this(cache, options, DEFAULT_DOWNLOAD_JOB_DISPATCHER);145 }146 147 private void ensureCacheElement() {148 if (cacheElement == null && getCacheKey() != null) {149 cacheElement = cache.getCacheElement(getCacheKey());150 if (cacheElement != null) {151 attributes = (CacheEntryAttributes) cacheElement.getElementAttributes();152 cacheData = cacheElement.getVal();153 }154 }155 }156 157 @Override158 public V get() {159 ensureCacheElement();160 return cacheData;161 }162 163 @Override164 public void submit(ICachedLoaderListener listener, boolean force) throws IOException {165 this.force = force;166 boolean first = false;167 URL url = getUrl();168 String deduplicationKey = null;169 if (url != null) {170 // url might be null, for example when Bing Attribution is not loaded yet171 deduplicationKey = url.toString();172 }173 if (deduplicationKey == null) {174 Logging.warn("No url returned for: {0}, skipping", getCacheKey());175 throw new IllegalArgumentException("No url returned");176 }177 synchronized (this) {178 first = !inProgress.containsKey(deduplicationKey);179 }180 inProgress.computeIfAbsent(deduplicationKey, k -> ConcurrentHashMap.newKeySet()).add(listener);181 182 if (first || force) {183 // submit all jobs to separate thread, so calling thread is not blocked with IO when loading from disk184 Logging.debug("JCS - Submitting job for execution for url: {0}", getUrlNoException());185 downloadJobExecutor.execute(this);186 }187 }188 189 /**190 * This method is run when job has finished191 */192 protected void executionFinished() {193 if (finishTask != null) {194 finishTask.run();195 }196 }197 198 /**199 * Checks if object from cache has sufficient data to be returned.200 * @return {@code true} if object from cache has sufficient data to be returned201 */202 protected boolean isObjectLoadable() {203 if (cacheData == null) {204 return false;205 }206 return cacheData.getContent().length > 0;207 }208 209 /**210 * Simple implementation. All errors should be cached as empty. Though some JDK (JDK8 on Windows for example)211 * doesn't return 4xx error codes, instead they do throw an FileNotFoundException or IOException212 * @param headerFields headers sent by server213 * @param responseCode http status code214 *215 * @return true if we should put empty object into cache, regardless of what remote resource has returned216 */217 protected boolean cacheAsEmpty(Map<String, List<String>> headerFields, int responseCode) {218 return attributes.getResponseCode() < 500;219 }220 221 /**222 * Returns key under which discovered server settings will be kept.223 * @return key under which discovered server settings will be kept224 */225 protected String getServerKey() {226 try {227 return getUrl().getHost();228 } catch (IOException e) {229 Logging.trace(e);230 return null;231 }232 }233 234 @Override235 public void run() {236 final Thread currentThread = Thread.currentThread();237 final String oldName = currentThread.getName();238 currentThread.setName("JCS Downloading: " + getUrlNoException());239 Logging.debug("JCS - starting fetch of url: {0} ", getUrlNoException());240 ensureCacheElement();241 try {242 // try to fetch from cache243 if (!force && cacheElement != null && isCacheElementValid() && isObjectLoadable()) {244 // we got something in cache, and it's valid, so lets return it245 Logging.debug("JCS - Returning object from cache: {0}", getCacheKey());246 finishLoading(LoadResult.SUCCESS);247 return;248 }249 250 // try to load object from remote resource251 if (loadObject()) {252 finishLoading(LoadResult.SUCCESS);253 } else {254 // if loading failed - check if we can return stale entry255 if (isObjectLoadable()) {256 // try to get stale entry in cache257 finishLoading(LoadResult.SUCCESS);258 Logging.debug("JCS - found stale object in cache: {0}", getUrlNoException());259 } else {260 // failed completely261 finishLoading(LoadResult.FAILURE);262 }263 }264 } finally {265 executionFinished();266 currentThread.setName(oldName);267 }268 }269 270 private void finishLoading(LoadResult result) {271 Set<ICachedLoaderListener> listeners;272 try {273 listeners = inProgress.remove(getUrl().toString());274 } catch (IOException e) {275 listeners = null;276 Logging.trace(e);277 }278 if (listeners == null) {279 Logging.warn("Listener not found for URL: {0}. Listener not notified!", getUrlNoException());280 return;281 }282 for (ICachedLoaderListener l: listeners) {283 l.loadingFinished(cacheData, attributes, result);284 }285 }286 287 protected boolean isCacheElementValid() {288 long expires = attributes.getExpirationTime();289 290 // check by expire date set by server291 if (expires != 0L) {292 // put a limit to the expire time (some servers send a value293 // that is too large)294 expires = Math.min(expires, attributes.getCreateTime() + Math.max(EXPIRE_TIME_SERVER_LIMIT, minimumExpiryTime));295 if (now > expires) {296 Logging.debug("JCS - Object {0} has expired -> valid to {1}, now is: {2}",297 getUrlNoException(), Long.toString(expires), Long.toString(now));298 return false;299 }300 } else if (attributes.getLastModification() > 0 &&301 now - attributes.getLastModification() > Math.max(DEFAULT_EXPIRE_TIME, minimumExpiryTime)) {302 // check by file modification date303 Logging.debug("JCS - Object has expired, maximum file age reached {0}", getUrlNoException());304 return false;305 } else if (now - attributes.getCreateTime() > Math.max(DEFAULT_EXPIRE_TIME, minimumExpiryTime)) {306 Logging.debug("JCS - Object has expired, maximum time since object creation reached {0}", getUrlNoException());307 return false;308 }309 return true;310 }311 312 /**313 * @return true if object was successfully downloaded, false, if there was a loading failure314 */315 @ModifiedFromJosm("visibility")316 protected boolean loadObject() {317 if (attributes == null) {318 attributes = new CacheEntryAttributes();319 }320 final URL url = this.getUrlNoException();321 if (url == null) {322 return false;323 }324 325 if (url.getProtocol().contains("http")) {326 return loadObjectHttp();327 }328 if (url.getProtocol().contains("file")) {329 return loadObjectFile(url);330 }331 332 return false;333 }334 335 private boolean loadObjectFile(URL url) {336 String fileName = url.toExternalForm();337 File file = new File(fileName.substring("file:/".length() - 1));338 if (!file.exists()) {339 file = new File(fileName.substring("file://".length() - 1));340 }341 try (InputStream fileInputStream = Files.newInputStream(file.toPath())) {342 cacheData = createCacheEntry(Utils.readBytesFromStream(fileInputStream));343 cache.put(getCacheKey(), cacheData, attributes);344 return true;345 } catch (IOException e) {346 Logging.error(e);347 attributes.setError(e);348 attributes.setException(e);349 }350 return false;351 }352 353 /**354 * @return true if object was successfully downloaded via http, false, if there was a loading failure355 */356 private boolean loadObjectHttp() {357 try {358 // if we have object in cache, and host doesn't support If-Modified-Since nor If-None-Match359 // then just use HEAD request and check returned values360 if (isObjectLoadable() &&361 Boolean.TRUE.equals(useHead.get(getServerKey())) &&362 isCacheValidUsingHead()) {363 Logging.debug("JCS - cache entry verified using HEAD request: {0}", getUrl());364 return true;365 }366 367 Logging.debug("JCS - starting HttpClient GET request for URL: {0}", getUrl());368 final HttpClient request = getRequest("GET");369 370 if (isObjectLoadable() &&371 (now - attributes.getLastModification()) <= ABSOLUTE_EXPIRE_TIME_LIMIT) {372 request.setIfModifiedSince(attributes.getLastModification());373 }374 if (isObjectLoadable() && attributes.getEtag() != null) {375 request.setHeader("If-None-Match", attributes.getEtag());376 }377 378 final HttpClient.Response urlConn = request.connect();379 380 if (urlConn.getResponseCode() == 304) {381 // If isModifiedSince or If-None-Match has been set382 // and the server answers with a HTTP 304 = "Not Modified"383 Logging.debug("JCS - If-Modified-Since/ETag test: local version is up to date: {0}", getUrl());384 // update cache attributes385 attributes = parseHeaders(urlConn);386 cache.put(getCacheKey(), cacheData, attributes);387 return true;388 } else if (isObjectLoadable() // we have an object in cache, but we haven't received 304 response code389 && (390 (attributes.getEtag() != null && attributes.getEtag().equals(urlConn.getHeaderField("ETag"))) ||391 attributes.getLastModification() == urlConn.getLastModified())392 ) {393 // we sent ETag or If-Modified-Since, but didn't get 304 response code394 // for further requests - use HEAD395 String serverKey = getServerKey();396 Logging.info("JCS - Host: {0} found not to return 304 codes for If-Modified-Since or If-None-Match headers",397 serverKey);398 useHead.put(serverKey, Boolean.TRUE);399 }400 401 attributes = parseHeaders(urlConn);402 403 for (int i = 0; i < 5; ++i) {404 if (urlConn.getResponseCode() == HttpURLConnection.HTTP_UNAVAILABLE) {405 Thread.sleep(5000L+new SecureRandom().nextInt(5000));406 continue;407 }408 409 attributes.setResponseCode(urlConn.getResponseCode());410 byte[] raw;411 if (urlConn.getResponseCode() == HttpURLConnection.HTTP_OK) {412 raw = Utils.readBytesFromStream(urlConn.getContent());413 } else {414 raw = new byte[]{};415 try {416 String data = urlConn.fetchContent();417 if (!data.isEmpty()) {418 String detectErrorMessage = detectErrorMessage(data);419 if (detectErrorMessage != null) {420 attributes.setErrorMessage(detectErrorMessage);421 }422 }423 } catch (IOException e) {424 Logging.warn(e);425 }426 }427 428 if (isResponseLoadable(urlConn.getHeaderFields(), urlConn.getResponseCode(), raw)) {429 // we need to check cacheEmpty, so for cases, when data is returned, but we want to store430 // as empty (eg. empty tile images) to save some space431 cacheData = createCacheEntry(raw);432 cache.put(getCacheKey(), cacheData, attributes);433 Logging.debug("JCS - downloaded key: {0}, length: {1}, url: {2}",434 getCacheKey(), raw.length, getUrl());435 return true;436 } else if (cacheAsEmpty(urlConn.getHeaderFields(), urlConn.getResponseCode())) {437 cacheData = createCacheEntry(new byte[]{});438 cache.put(getCacheKey(), cacheData, attributes);439 Logging.debug("JCS - Caching empty object {0}", getUrl());440 return true;441 } else {442 Logging.debug("JCS - failure during load - response is not loadable nor cached as empty");443 return false;444 }445 }446 } catch (FileNotFoundException e) {447 Logging.debug("JCS - Caching empty object as server returned 404 for: {0}", getUrlNoException());448 attributes.setResponseCode(404);449 attributes.setError(e);450 attributes.setException(e);451 boolean doCache = isResponseLoadable(null, 404, null) || cacheAsEmpty(Collections.emptyMap(), 404);452 if (doCache) {453 cacheData = createCacheEntry(new byte[]{});454 cache.put(getCacheKey(), cacheData, attributes);455 }456 return doCache;457 } catch (IOException e) {458 Logging.debug("JCS - IOException during communication with server for: {0}", getUrlNoException());459 if (isObjectLoadable()) {460 return true;461 } else {462 attributes.setError(e);463 attributes.setException(e);464 attributes.setResponseCode(599); // set dummy error code, greater than 500 so it will be not cached465 return false;466 }467 468 } catch (InterruptedException e) {469 attributes.setError(e);470 attributes.setException(e);471 Logging.logWithStackTrace(Logging.LEVEL_WARN, e, "JCS - Exception during download {0}", getUrlNoException());472 Thread.currentThread().interrupt();473 }474 Logging.warn("JCS - Silent failure during download: {0}", getUrlNoException());475 return false;476 }477 478 /**479 * Tries do detect an error message from given string.480 * @param data string to analyze481 * @return error message if detected, or null482 * @since 14535483 */484 public String detectErrorMessage(String data) {485 Matcher m = HttpClient.getTomcatErrorMatcher(data);486 return m.matches() ? m.group(1).replace("'", "''") : null;487 }488 489 /**490 * Check if the object is loadable. This means, if the data will be parsed, and if this response491 * will finish as successful retrieve.492 *493 * This simple implementation doesn't load empty response, nor client (4xx) and server (5xx) errors494 *495 * @param headerFields headers sent by server496 * @param responseCode http status code497 * @param raw data read from server498 * @return true if object should be cached and returned to listener499 */500 protected boolean isResponseLoadable(Map<String, List<String>> headerFields, int responseCode, byte[] raw) {501 return raw != null && raw.length != 0 && responseCode < 400;502 }503 504 protected abstract V createCacheEntry(byte[] content);505 506 protected CacheEntryAttributes parseHeaders(HttpClient.Response urlConn) {507 CacheEntryAttributes ret = new CacheEntryAttributes();508 509 /*510 * according to https://www.ietf.org/rfc/rfc2616.txt Cache-Control takes precedence over max-age511 * max-age is for private caches, s-max-age is for shared caches. We take any value that is larger512 */513 Long expiration = 0L;514 String cacheControl = urlConn.getHeaderField("Cache-Control");515 if (cacheControl != null) {516 for (String token: cacheControl.split(",", -1)) {517 try {518 if (token.startsWith("max-age=")) {519 expiration = Math.max(expiration,520 TimeUnit.SECONDS.toMillis(Long.parseLong(token.substring("max-age=".length())))521 + System.currentTimeMillis()522 );523 }524 if (token.startsWith("s-max-age=")) {525 expiration = Math.max(expiration,526 TimeUnit.SECONDS.toMillis(Long.parseLong(token.substring("s-max-age=".length())))527 + System.currentTimeMillis()528 );529 }530 } catch (NumberFormatException e) {531 // ignore malformed Cache-Control headers532 Logging.trace(e);533 }534 }535 }536 537 if (expiration.equals(0L)) {538 expiration = urlConn.getExpiration();539 }540 541 // if nothing is found - set default542 if (expiration.equals(0L)) {543 expiration = System.currentTimeMillis() + DEFAULT_EXPIRE_TIME;544 }545 546 ret.setExpirationTime(Math.max(minimumExpiryTime + System.currentTimeMillis(), expiration));547 ret.setLastModification(now);548 ret.setEtag(urlConn.getHeaderField("ETag"));549 550 return ret;551 }552 553 private HttpClient getRequest(String requestMethod) throws IOException {554 final HttpClient urlConn = HttpClient.create(getUrl(), requestMethod);555 urlConn.setAccept("text/html, image/png, image/jpeg, image/gif, */*");556 urlConn.setReadTimeout(readTimeout); // 30 seconds read timeout557 urlConn.setConnectTimeout(connectTimeout);558 if (headers != null) {559 urlConn.setHeaders(headers);560 }561 562 final boolean noCache = force563 // To remove when switching to Java 11564 // Workaround for https://bugs.openjdk.java.net/browse/JDK-8146450565 || (Utils.getJavaVersion() == 8 && Utils.isRunningJavaWebStart());566 urlConn.useCache(!noCache);567 568 return urlConn;569 }570 571 private boolean isCacheValidUsingHead() throws IOException {572 final HttpClient.Response urlConn = getRequest("HEAD").connect();573 long lastModified = urlConn.getLastModified();574 boolean ret = (attributes.getEtag() != null && attributes.getEtag().equals(urlConn.getHeaderField("ETag"))) ||575 (lastModified != 0 && lastModified <= attributes.getLastModification());576 if (ret) {577 // update attributes578 attributes = parseHeaders(urlConn);579 cache.put(getCacheKey(), cacheData, attributes);580 }581 return ret;582 }583 584 /**585 * TODO: move to JobFactory586 * cancels all outstanding tasks in the queue.587 */588 public void cancelOutstandingTasks() {589 for (Runnable r: downloadJobExecutor.getQueue()) {590 if (downloadJobExecutor.remove(r) && r instanceof org.openstreetmap.josm.data.cache.JCSCachedTileLoaderJob) {591 ((org.openstreetmap.josm.data.cache.JCSCachedTileLoaderJob<?, ?>) r).handleJobCancellation();592 }593 }594 }595 596 /**597 * Sets a job, that will be run, when job will finish execution598 * @param runnable that will be executed599 */600 public void setFinishedTask(Runnable runnable) {601 this.finishTask = runnable;602 603 }604 605 /**606 * Marks this job as canceled607 */608 public void handleJobCancellation() {609 finishLoading(LoadResult.CANCELED);610 }611 612 private URL getUrlNoException() {613 try {614 return getUrl();615 } catch (IOException e) {616 Logging.trace(e);617 return null;618 }619 }620 } -
applications/editors/josm/plugins/pmtiles/src/main/java/org/openstreetmap/josm/plugins/pmtiles/gui/layers/PMTileJob.java
r36112 r36145 19 19 import org.openstreetmap.josm.data.cache.CacheEntryAttributes; 20 20 import org.openstreetmap.josm.data.cache.ICachedLoaderListener; 21 import org.openstreetmap.josm.data.cache.JCSCachedTileLoaderJob; 21 22 import org.openstreetmap.josm.data.imagery.TileJobOptions; 22 23 import org.openstreetmap.josm.plugins.pmtiles.lib.DirectoryCache;
Note:
See TracChangeset
for help on using the changeset viewer.