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

Last change on this file since 8326 was 8326, checked in by Don-vip, 9 years ago

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