source: josm/trunk/src/org/openstreetmap/josm/data/cache/JCSCachedTileLoaderJob.java@ 8344

Last change on this file since 8344 was 8344, checked in by bastiK, 9 years ago

applied #10454 - Mapbox "empty" tile (imagery with zoom level > 17) (patch by wiktorn)

File size: 18.6 KB
Line 
1// License: GPL. For details, see LICENSE file.
2package org.openstreetmap.josm.data.cache;
3
4import java.io.ByteArrayOutputStream;
5import java.io.FileNotFoundException;
6import java.io.IOException;
7import java.io.InputStream;
8import java.net.HttpURLConnection;
9import java.net.URL;
10import java.net.URLConnection;
11import java.util.HashSet;
12import java.util.List;
13import java.util.Map;
14import java.util.Random;
15import java.util.Set;
16import java.util.concurrent.ConcurrentHashMap;
17import java.util.concurrent.ConcurrentMap;
18import java.util.concurrent.Executor;
19import java.util.concurrent.LinkedBlockingDeque;
20import java.util.concurrent.RejectedExecutionException;
21import java.util.concurrent.ThreadPoolExecutor;
22import java.util.concurrent.TimeUnit;
23import java.util.logging.Level;
24import java.util.logging.Logger;
25
26import org.apache.commons.jcs.access.behavior.ICacheAccess;
27import org.apache.commons.jcs.engine.behavior.ICacheElement;
28import org.openstreetmap.gui.jmapviewer.FeatureAdapter;
29import org.openstreetmap.josm.Main;
30import org.openstreetmap.josm.data.cache.ICachedLoaderListener.LoadResult;
31import org.openstreetmap.josm.data.preferences.IntegerProperty;
32
33/**
34 * @author Wiktor Niesiobędzki
35 *
36 * @param <K> cache entry key type
37 *
38 * Generic loader for HTTP based tiles. Uses custom attribute, to check, if entry has expired
39 * according to HTTP headers sent with tile. If so, it tries to verify using Etags
40 * or If-Modified-Since / Last-Modified.
41 *
42 * If the tile is not valid, it will try to download it from remote service and put it
43 * to cache. If remote server will fail it will try to use stale entry.
44 *
45 * This class will keep only one Job running for specified tile. All others will just finish, but
46 * listeners will be gathered and notified, once download job will be finished
47 *
48 * @since 8168
49 */
50public abstract class JCSCachedTileLoaderJob<K, V extends CacheEntry> implements ICachedLoaderJob<K>, Runnable {
51 private static final Logger log = FeatureAdapter.getLogger(JCSCachedTileLoaderJob.class.getCanonicalName());
52 protected static final long DEFAULT_EXPIRE_TIME = 1000L * 60 * 60 * 24 * 7; // 7 days
53 // Limit for the max-age value send by the server.
54 protected static final long EXPIRE_TIME_SERVER_LIMIT = 1000L * 60 * 60 * 24 * 28; // 4 weeks
55 // Absolute expire time limit. Cached tiles that are older will not be used,
56 // even if the refresh from the server fails.
57 protected static final long ABSOLUTE_EXPIRE_TIME_LIMIT = Long.MAX_VALUE; // unlimited
58
59 /**
60 * maximum download threads that will be started
61 */
62 public static final IntegerProperty THREAD_LIMIT = new IntegerProperty("cache.jcs.max_threads", 10);
63
64 public static class LIFOQueue extends LinkedBlockingDeque<Runnable> {
65 public LIFOQueue(int capacity) {
66 super(capacity);
67 }
68
69 @Override
70 public boolean offer(Runnable t) {
71 return super.offerFirst(t);
72 }
73
74 @Override
75 public Runnable remove() {
76 return super.removeFirst();
77 }
78 }
79
80
81 /*
82 * ThreadPoolExecutor starts new threads, until THREAD_LIMIT is reached. Then it puts tasks into LIFOQueue, which is fairly
83 * small, but we do not want a lot of outstanding tasks queued, but rather prefer the class consumer to resubmit the task, which are
84 * important right now.
85 *
86 * This way, if some task gets outdated (for example - user paned the map, and we do not want to download this tile any more),
87 * the task will not be resubmitted, and thus - never queued.
88 *
89 * There is no point in canceling tasks, that are already taken by worker threads (if we made so much effort, we can at least cache
90 * the response, so later it could be used). We could actually cancel what is in LIFOQueue, but this is a tradeoff between simplicity
91 * and performance (we do want to have something to offer to worker threads before tasks will be resubmitted by class consumer)
92 */
93 private static Executor DOWNLOAD_JOB_DISPATCHER = new ThreadPoolExecutor(
94 2, // we have a small queue, so threads will be quickly started (threads are started only, when queue is full)
95 THREAD_LIMIT.get().intValue(), // do not this number of threads
96 30, // keepalive for thread
97 TimeUnit.SECONDS,
98 // make queue of LIFO type - so recently requested tiles will be loaded first (assuming that these are which user is waiting to see)
99 new LIFOQueue(5)
100 );
101
102 private static ConcurrentMap<String,Set<ICachedLoaderListener>> inProgress = new ConcurrentHashMap<>();
103 private static ConcurrentMap<String, Boolean> useHead = new ConcurrentHashMap<>();
104
105 private long now; // when the job started
106
107 private ICacheAccess<K, V> cache;
108 private ICacheElement<K, V> cacheElement;
109 protected V cacheData = null;
110 protected CacheEntryAttributes attributes = null;
111
112 // HTTP connection parameters
113 private int connectTimeout;
114 private int readTimeout;
115 private Map<String, String> headers;
116
117 /**
118 * @param cache cache instance that we will work on
119 * @param headers
120 * @param readTimeout
121 * @param connectTimeout
122 */
123 public JCSCachedTileLoaderJob(ICacheAccess<K,V> cache,
124 int connectTimeout, int readTimeout,
125 Map<String, String> headers) {
126
127 this.cache = cache;
128 this.now = System.currentTimeMillis();
129 this.connectTimeout = connectTimeout;
130 this.readTimeout = readTimeout;
131 this.headers = headers;
132 }
133
134 private void ensureCacheElement() {
135 if (cacheElement == null && getCacheKey() != null) {
136 cacheElement = cache.getCacheElement(getCacheKey());
137 if (cacheElement != null) {
138 attributes = (CacheEntryAttributes) cacheElement.getElementAttributes();
139 cacheData = cacheElement.getVal();
140 }
141 }
142 }
143
144 public V get() {
145 ensureCacheElement();
146 return cacheData;
147 }
148
149 @Override
150 public void submit(ICachedLoaderListener listener) {
151 boolean first = false;
152 URL url = getUrl();
153 String deduplicationKey = null;
154 if (url != null) {
155 // url might be null, for example when Bing Attribution is not loaded yet
156 deduplicationKey = url.toString();
157 }
158 if (deduplicationKey == null) {
159 log.log(Level.WARNING, "No url returned for: {0}, skipping", getCacheKey());
160 return;
161 }
162 synchronized (inProgress) {
163 Set<ICachedLoaderListener> newListeners = inProgress.get(deduplicationKey);
164 if (newListeners == null) {
165 newListeners = new HashSet<>();
166 inProgress.put(deduplicationKey, newListeners);
167 first = true;
168 }
169 newListeners.add(listener);
170 }
171
172 if (first) {
173 ensureCacheElement();
174 if (cacheElement != null && isCacheElementValid() && (isObjectLoadable())) {
175 // we got something in cache, and it's valid, so lets return it
176 log.log(Level.FINE, "JCS - Returning object from cache: {0}", getCacheKey());
177 finishLoading(LoadResult.SUCCESS);
178 return;
179 }
180 // object not in cache, so submit work to separate thread
181 try {
182 if (executionGuard()) {
183 // use getter method, so subclasses may override executors, to get separate thread pool
184 getDownloadExecutor().execute(this);
185 } else {
186 log.log(Level.FINE, "JCS - guard rejected job for: {0}", getCacheKey());
187 finishLoading(LoadResult.REJECTED);
188 }
189 } catch (RejectedExecutionException e) {
190 // queue was full, try again later
191 log.log(Level.FINE, "JCS - rejected job for: {0}", getCacheKey());
192 finishLoading(LoadResult.REJECTED);
193 }
194 }
195 }
196
197 /**
198 * Guard method for execution. If guard returns true, the execution of download task will commence
199 * otherwise, execution will finish with result LoadResult.REJECTED
200 *
201 * It is responsibility of the overriding class, to handle properly situation in finishLoading class
202 * @return
203 */
204 protected boolean executionGuard() {
205 return true;
206 }
207
208 /**
209 * This method is run when job has finished
210 */
211 protected void executionFinished() {
212 }
213
214 /**
215 *
216 * @return checks if object from cache has sufficient data to be returned
217 */
218 protected boolean isObjectLoadable() {
219 byte[] content = cacheData.getContent();
220 return content != null && content.length > 0;
221 }
222
223 /**
224 *
225 * @return cache object as empty, regardless of what remote resource has returned (ex. based on headers)
226 */
227 protected boolean cacheAsEmpty(Map<String, List<String>> headers, int statusCode, byte[] content) {
228 return false;
229 }
230
231 /**
232 * @return key under which discovered server settings will be kept
233 */
234 protected String getServerKey() {
235 return getUrl().getHost();
236 }
237
238 /**
239 * this needs to be non-static, so it can be overridden by subclasses
240 */
241 protected Executor getDownloadExecutor() {
242 return DOWNLOAD_JOB_DISPATCHER;
243 }
244
245
246 public void run() {
247 final Thread currentThread = Thread.currentThread();
248 final String oldName = currentThread.getName();
249 currentThread.setName("JCS Downloading: " + getUrl());
250 try {
251 // try to load object from remote resource
252 if (loadObject()) {
253 finishLoading(LoadResult.SUCCESS);
254 } else {
255 // if loading failed - check if we can return stale entry
256 if (isObjectLoadable()) {
257 // try to get stale entry in cache
258 finishLoading(LoadResult.SUCCESS);
259 log.log(Level.FINE, "JCS - found stale object in cache: {0}", getUrl());
260 } else {
261 // failed completely
262 finishLoading(LoadResult.FAILURE);
263 }
264 }
265 } finally {
266 executionFinished();
267 currentThread.setName(oldName);
268 }
269 }
270
271
272 private void finishLoading(LoadResult result) {
273 Set<ICachedLoaderListener> listeners = null;
274 synchronized (inProgress) {
275 listeners = inProgress.remove(getUrl().toString());
276 }
277 if (listeners == null) {
278 log.log(Level.WARNING, "Listener not found for URL: {0}. Listener not notified!", getUrl());
279 return;
280 }
281 try {
282 for (ICachedLoaderListener l: listeners) {
283 l.loadingFinished(cacheData, attributes, result);
284 }
285 } catch (Exception e) {
286 log.log(Level.WARNING, "JCS - Error while loading object from cache: {0}; {1}", new Object[]{e.getMessage(), getUrl()});
287 Main.warn(e);
288 for (ICachedLoaderListener l: listeners) {
289 l.loadingFinished(cacheData, attributes, LoadResult.FAILURE);
290 }
291
292 }
293
294 }
295
296 private boolean isCacheElementValid() {
297 long expires = attributes.getExpirationTime();
298
299 // check by expire date set by server
300 if (expires != 0L) {
301 // put a limit to the expire time (some servers send a value
302 // that is too large)
303 expires = Math.min(expires, attributes.getCreateTime() + EXPIRE_TIME_SERVER_LIMIT);
304 if (now > expires) {
305 log.log(Level.FINE, "JCS - Object {0} has expired -> valid to {1}, now is: {2}", new Object[]{getUrl(), Long.toString(expires), Long.toString(now)});
306 return false;
307 }
308 } else {
309 // check by file modification date
310 if (now - attributes.getLastModification() > DEFAULT_EXPIRE_TIME) {
311 log.log(Level.FINE, "JCS - Object has expired, maximum file age reached {0}", getUrl());
312 return false;
313 }
314 }
315 return true;
316 }
317
318 /**
319 * @return true if object was successfully downloaded, false, if there was a loading failure
320 */
321
322 private boolean loadObject() {
323 try {
324 // if we have object in cache, and host doesn't support If-Modified-Since nor If-None-Match
325 // then just use HEAD request and check returned values
326 if (isObjectLoadable() &&
327 Boolean.TRUE.equals(useHead.get(getServerKey())) &&
328 isCacheValidUsingHead()) {
329 log.log(Level.FINE, "JCS - cache entry verified using HEAD request: {0}", getUrl());
330 return true;
331 }
332 HttpURLConnection urlConn = getURLConnection();
333
334 if (isObjectLoadable() &&
335 (now - attributes.getLastModification()) <= ABSOLUTE_EXPIRE_TIME_LIMIT) {
336 urlConn.setIfModifiedSince(attributes.getLastModification());
337 }
338 if (isObjectLoadable() && attributes.getEtag() != null) {
339 urlConn.addRequestProperty("If-None-Match", attributes.getEtag());
340 }
341 if (urlConn.getResponseCode() == 304) {
342 // If isModifiedSince or If-None-Match has been set
343 // and the server answers with a HTTP 304 = "Not Modified"
344 log.log(Level.FINE, "JCS - IfModifiedSince/Etag test: local version is up to date: {0}", getUrl());
345 return true;
346 } else if (isObjectLoadable()) {
347 // we have an object in cache, but we haven't received 304 resposne code
348 // check if we should use HEAD request to verify
349 if((attributes.getEtag() != null && attributes.getEtag().equals(urlConn.getRequestProperty("ETag"))) ||
350 attributes.getLastModification() == urlConn.getLastModified()) {
351 // we sent ETag or If-Modified-Since, but didn't get 304 response code
352 // for further requests - use HEAD
353 String serverKey = getServerKey();
354 log.log(Level.INFO, "JCS - Host: {0} found not to return 304 codes for If-Modifed-Since or If-None-Match headers", serverKey);
355 useHead.put(serverKey, Boolean.TRUE);
356 }
357 }
358
359 attributes = parseHeaders(urlConn);
360
361 for (int i = 0; i < 5; ++i) {
362 if (urlConn.getResponseCode() == 503) {
363 Thread.sleep(5000+(new Random()).nextInt(5000));
364 continue;
365 }
366 byte[] raw = read(urlConn);
367
368 if (!cacheAsEmpty(urlConn.getHeaderFields(), urlConn.getResponseCode(), raw) &&
369 raw != null && raw.length > 0) {
370 cacheData = createCacheEntry(raw);
371 cache.put(getCacheKey(), cacheData, attributes);
372 log.log(Level.FINE, "JCS - downloaded key: {0}, length: {1}, url: {2}",
373 new Object[] {getCacheKey(), raw.length, getUrl()});
374 return true;
375 } else {
376 cacheData = createCacheEntry(new byte[]{});
377 cache.put(getCacheKey(), cacheData, attributes);
378 log.log(Level.FINE, "JCS - Caching empty object {0}", getUrl());
379 return true;
380 }
381 }
382 } catch (FileNotFoundException e) {
383 log.log(Level.FINE, "JCS - Caching empty object as server returned 404 for: {0}", getUrl());
384 cache.put(getCacheKey(), createCacheEntry(new byte[]{}), attributes);
385 return handleNotFound();
386 } catch (Exception e) {
387 log.log(Level.WARNING, "JCS - Exception during download {0}", getUrl());
388 Main.warn(e);
389 }
390 log.log(Level.WARNING, "JCS - Silent failure during download: {0}", getUrl());
391 return false;
392
393 }
394
395 /**
396 * @return if we should treat this object as properly loaded
397 */
398 protected abstract boolean handleNotFound();
399
400 protected abstract V createCacheEntry(byte[] content);
401
402 private CacheEntryAttributes parseHeaders(URLConnection urlConn) {
403 CacheEntryAttributes ret = new CacheEntryAttributes();
404
405 Long lng = urlConn.getExpiration();
406 if (lng.equals(0L)) {
407 try {
408 String str = urlConn.getHeaderField("Cache-Control");
409 if (str != null) {
410 for (String token: str.split(",")) {
411 if (token.startsWith("max-age=")) {
412 lng = Long.parseLong(token.substring(8)) * 1000 +
413 System.currentTimeMillis();
414 }
415 }
416 }
417 } catch (NumberFormatException e) {} //ignore malformed Cache-Control headers
418 }
419
420 ret.setExpirationTime(lng);
421 ret.setLastModification(now);
422 ret.setEtag(urlConn.getHeaderField("ETag"));
423 return ret;
424 }
425
426 private HttpURLConnection getURLConnection() throws IOException {
427 HttpURLConnection urlConn = (HttpURLConnection) getUrl().openConnection();
428 urlConn.setRequestProperty("Accept", "text/html, image/png, image/jpeg, image/gif, */*");
429 urlConn.setReadTimeout(readTimeout); // 30 seconds read timeout
430 urlConn.setConnectTimeout(connectTimeout);
431 for(Map.Entry<String, String> e: headers.entrySet()) {
432 urlConn.setRequestProperty(e.getKey(), e.getValue());
433 }
434 return urlConn;
435 }
436
437 private boolean isCacheValidUsingHead() throws IOException {
438 HttpURLConnection urlConn = (HttpURLConnection) getUrl().openConnection();
439 urlConn.setRequestMethod("HEAD");
440 long lastModified = urlConn.getLastModified();
441 return (
442 (attributes.getEtag() != null && attributes.getEtag().equals(urlConn.getRequestProperty("ETag"))) ||
443 (lastModified != 0 && lastModified <= attributes.getLastModification())
444 );
445 }
446
447 private static byte[] read(URLConnection urlConn) throws IOException {
448 InputStream input = urlConn.getInputStream();
449 try {
450 ByteArrayOutputStream bout = new ByteArrayOutputStream(input.available());
451 byte[] buffer = new byte[2048];
452 boolean finished = false;
453 do {
454 int read = input.read(buffer);
455 if (read >= 0) {
456 bout.write(buffer, 0, read);
457 } else {
458 finished = true;
459 }
460 } while (!finished);
461 if (bout.size() == 0)
462 return null;
463 return bout.toByteArray();
464 } finally {
465 input.close();
466 }
467 }
468}
Note: See TracBrowser for help on using the repository browser.