Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MimeTypeUtils.parseMimeType returns null MIME type in case of high concurrency #23211

Closed
hdeadman opened this issue Jun 28, 2019 · 2 comments
Closed
Assignees
Labels
in: core Issues in core modules (aop, beans, core, context, expression) type: enhancement A general enhancement
Milestone

Comments

@hdeadman
Copy link

Affects: 5.5.0.M2 + (Problem observed in 5.5.0.M2, probably in any release after 2/5/2019)

I am getting an NPE on line MediaType.java:550 of spring-web-5.2.0.M2.jar (currently line 563 in master) because the cache is apparently returning a null type from MimeTypeUtils.parseMimeType().

	public static MimeType parseMimeType(String mimeType) {
		return cachedMimeTypes.get(mimeType);
	}

and the method below doesn't account for a null return from the LRU cache cachedMimeTypes

	public static MediaType parseMediaType(String mediaType) {
		MimeType type;
		try {
			type = MimeTypeUtils.parseMimeType(mediaType);
		}
		catch (InvalidMimeTypeException ex) {
			throw new InvalidMediaTypeException(ex);
		}
		try {
			return new MediaType(type.getType(), type.getSubtype(), type.getParameters()); //NPE
		}
		catch (IllegalArgumentException ex) {
			throw new InvalidMediaTypeException(mediaType, ex.getMessage());
		}
	}

The ConcurrentLruCache in MimeTypeUtils must have a bug b/c that is the only way null type could be getting returned. The ConcurrentLruCache.get(key) method does returns in two places and one of them is returning a null. The return in the write lock block looks safe but the return in the read lock block could return null if the internal queue has the same key in it twice at which point the internal cache map wouldn't have the value anymore and then a null could be returned.

		public V get(K key) {
			this.lock.readLock().lock();
			try {
				if (this.queue.remove(key)) {
					this.queue.add(key);
					return this.cache.get(key);
				}
			}
			finally {
				this.lock.readLock().unlock();
			}
			this.lock.writeLock().lock();
			try {
				if (this.queue.size() == this.maxSize) {
					K leastUsed = this.queue.poll();
					if (leastUsed != null) {
						this.cache.remove(leastUsed);
					}
				}
				V value = this.generator.apply(key);
				this.queue.add(key);
				this.cache.put(key, value);
				return value;
			}
			finally {
				this.lock.writeLock().unlock();
			}
		}

Assume two threads go through read lock block at same time, one of them will remove the key and add it back to front of queue, the other thread will fall through and wait for the write lock. Once the thread gets the write lock it will also add the key to the queue and now the key will be in the queue twice. Eventually the duplicate key might work its way to least used and get removed from the cache. At that point, all subsequent requests for that key will return null because the queue still has the key but the hash map doesn't have the value.

This is pretty serious b/c once it starts happening for a particular mime type, I think the application needs to be restarted.

@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged or decided on label Jun 28, 2019
@hdeadman hdeadman changed the title MimeTypeUtils.ConcurrentLruCache concurrency issue, returns null MimeTypeUtils.ConcurrentLruCache concurrency issue, get() method returns null Jun 28, 2019
@bclozel bclozel self-assigned this Jun 28, 2019
@bclozel bclozel added this to the 5.2 RC1 milestone Jun 28, 2019
@bclozel
Copy link
Member

bclozel commented Jun 28, 2019

Reading your analysis, it seems we could fix that problem and even improve the performance by doing this:

public V get(K key) {
	this.lock.readLock().lock();
	try {
		if (this.queue.remove(key)) {
			this.queue.add(key);
			return this.cache.get(key);
		}
	}
	finally {
		this.lock.readLock().unlock();
	}
	this.lock.writeLock().lock();
	try {
		// retrying in case of concurrent reads on the same key
		if (this.queue.remove(key)) {
			this.queue.add(key);
			return this.cache.get(key);
		}
		if (this.queue.size() == this.maxSize) {
			K leastUsed = this.queue.poll();
			if (leastUsed != null) {
				this.cache.remove(leastUsed);
			}
		}
		V value = this.generator.apply(key);
		this.queue.add(key);
		this.cache.put(key, value);
		return value;
	}
	finally {
		this.lock.writeLock().unlock();
	}
}

In this case, we avoid adding again the same key and thus returning null when trying to fetch the value from the cache. As a consequence, we avoid re-generating and caching again the same MIME type (which should save CPU cycles).
It is true that concurrent reads like this are likely to trigger more and more write locks and increase contention. I'm wondering if we should replace the LRU strategy with a Least Frequently Used strategy to improve throughput.

Thanks for the analysis, it's really useful - more importantly, thanks for using our milestones in a traffic-heavy environment, this is really helping the Spring community, especially for that type of issues.

bclozel added a commit that referenced this issue Jun 28, 2019
This commit improves the cache implementation by skipping the ordering
of most recently used cached keys when the cache is at least half empty.

See gh-23211
@bclozel
Copy link
Member

bclozel commented Jun 28, 2019

I've reproduced the issue in a JMH benchmark (increasing the thread count is essential).
When concurrency is high, the LRU cache might yield lower throughput when compared to a parser without any cache. On the other hand, the allocation rate is much lower. When the concurrency is low, the cached version is at least 4x faster.

In the various web benchmarks that we previously ran, the cached version had a positive impact on the performance (throughput and especially latency); the contention and concurrency on this specific part is probably not as high as the JMH benchmark.

We could improve this even more by switching to a different parser implementation. I've tested a different parser implementation that's approx. +50% faster. When combined with a cache, there's not much difference anymore.

Since the LRU cache implementation is internal, we can still reconsider using a LFU cache instead.

@bclozel bclozel added in: core Issues in core modules (aop, beans, core, context, expression) type: enhancement A general enhancement and removed status: waiting-for-triage An issue we've not yet triaged or decided on labels Jun 28, 2019
@bclozel bclozel changed the title MimeTypeUtils.ConcurrentLruCache concurrency issue, get() method returns null MimeTypeUtils.parseMimeType returns null MIME type in case of high concurrency Jun 28, 2019
philwebb added a commit that referenced this issue Jun 30, 2019
Update `MimeTypeUtils` so that the  StringUtils.hasLength check is
performed immediately on the incoming argument, rather than in
`parseMimeTypeInternal`. This restores the `IllegalArgumentException`
rather than the `NullPointerException` which is thrown by the
`ConcurrentHashMap`.

Closes gh-23215
See gh-23211
hdeadman added a commit to apereo/cas that referenced this issue Jul 7, 2019
Pull in fix for thread safety bug in mimetype caching (until next tagged release)
spring-projects/spring-framework#23211
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
in: core Issues in core modules (aop, beans, core, context, expression) type: enhancement A general enhancement
Projects
None yet
Development

No branches or pull requests

3 participants