Skip to content

Cache pollution from high-cardinality FieldError default messages in MessageSourceSupport #36609

@msqr

Description

@msqr

I am experiencing a memory exhaustion event in a Spring Boot 4.0.5 web app that I have traced back to the rendering of FieldError default message values after a BindException occurs on a field with high cardinality input values. When messageSource.getMessage(fieldError, locale) is invoked on the exception's errors, the FieldError has a defaultMessage populated like

Failed to convert property value of type 'java.lang.String' to required type
'java.time.LocalDateTime' for property 'dt'; Failed to convert from type
[java.lang.String] to type [java.time.LocalDateTime] for value [asdbcsdfasdfasdf]

and eventually the MessageSourceSupport.renderDefaultMessage() method is invoked with that message and anargs value as a single element array with a DefaultMessageSourceResolvable instance in it. This eventually leads to the formatMessage() method getting called, and the logic then caches an entry in the messageFormatsPerMessage map:

	protected String formatMessage(String msg, Object @Nullable [] args, @Nullable Locale locale) {
		if (!isAlwaysUseMessageFormat() && ObjectUtils.isEmpty(args)) {
			return msg;
		}
		Map<Locale, MessageFormat> messageFormatsPerLocale = this.messageFormatsPerMessage
				.computeIfAbsent(msg, key -> new ConcurrentHashMap<>());
		MessageFormat messageFormat = messageFormatsPerLocale.computeIfAbsent(locale, key -> {

Because the default message in my case is effectively unique per request (i.e. the current date/time) this map gets filled up with entries, until memory is exhausted.

Image

The following Spring Boot demo app demonstrates this scenario (call http://localhost:8080/req?dt=BADLY_FORMATTED_TIMESTAMP to trigger the effect):

package com.example.demo;

import java.time.LocalDateTime;
import java.util.Locale;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.MessageSource;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindException;
import org.springframework.validation.Errors;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.WebRequest;

@SpringBootApplication
@RestController
public class DemoApplication {

	public static void main(String[] args) {
		SpringApplication.run(DemoApplication.class, args);
	}

	@Autowired
	private MessageSource messageSource;

	public static final class Criteria {

		private LocalDateTime dt;

		public final LocalDateTime getDt() {
			return dt;
		}

		public final void setDt(LocalDateTime dt) {
			this.dt = dt;
		}

	}

	// bind the request to a bean with a LocalDateTime field
	@GetMapping("/req")
	public Map<String, Object> req(Criteria criteria) {
		return Map.of("success", true, "dt", criteria.getDt() != null ? criteria.getDt() : "N/A");
	}

	// return a JSON error message based on the bind error
	@ExceptionHandler(BindException.class)
	@ResponseBody
	@ResponseStatus(HttpStatus.UNPROCESSABLE_CONTENT)
	public Map<String, Object> handleBindException(BindException e, WebRequest request, Locale locale) {
		return Map.of("success", false, "message", generateErrorsMessage(e, locale, messageSource));
	}

	public static String generateErrorsMessage(Errors e, Locale locale, MessageSource msgSrc) {
		String msg = "Bind error";
		if ( msgSrc != null && e != null && e.hasErrors() ) {
			StringBuilder buf = new StringBuilder();
			for ( ObjectError error : e.getAllErrors() ) {
				if ( !buf.isEmpty() ) {
					buf.append(" ");
				}
				// the next getMessage() call caches unique default message values based on the request
				// parameter value (in MessageSourceSupport messageFormatsPerMessage field) causing
				// an OOM error eventually when the request parameter cardinality is high
				buf.append(msgSrc.getMessage(error, locale));
			}
			msg = buf.toString();
		}
		return msg;
	}

}

I was looking for a way to avoid having the FieldError default messages not end up in the cache.

Metadata

Metadata

Assignees

Labels

in: coreIssues in core modules (aop, beans, core, context, expression)status: backportedAn issue that has been backported to maintenance branchestype: bugA general bug

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions