OpenSearch XContentBuilder – “cannot write xcontent for unknown value of type java.time.LocalDate” appears randomly on Tomcat.
Context:
What I understood ?
public class XContentBuilder implements Closeable {
/** Map: Java type ➜ lambda that writes that value */
private static final Map<Class<?>, Writer> WRITERS = new HashMap<>();
static {
/* ── 1 a. built-in primitives ───────────────────────────── */
WRITERS.put(String.class, (b, v) -> b.value((String) v));
WRITERS.put(Integer.class, (b, v) -> b.value((Integer) v));
/* … Boolean, BigInteger, Date, byte[] … */
/* ── 1 b. ask every SPI extension to extend the map ─────── */
/*
* This is the SPI hook. It is executed ONCE, the first time
* XContentBuilder is referenced inside a given class-loader.
*/
for (XContentBuilderExtension ext :
ServiceLoader.load(XContentBuilderExtension.class)) {
ext.accept(WRITERS); // registers extra writers
}
}
…
}
Everything happens once per class-loader, the result is cached in the static WRITERS map.
Somehow, for an obscure reason, ServiceLoader.load(XContentBuilderExtension.class)
does not find XContentOpenSearchExtension.class
and when calling this method with a LocalDate
object
private void unknownValue(Object value, boolean ensureNoSelfReferences) throws IOException {
if (value == null) {
nullValue();
return;
}
Writer writer = WRITERS.get(value.getClass());
if (writer != null) {
writer.write(this, value);
} else if (value instanceof Path) {
// Path implements Iterable<Path> and causes endless recursion and a StackOverFlow if treated as an Iterable here
value((Path) value);
} else if (value instanceof Map) {
@SuppressWarnings("unchecked")
final Map<String, ?> valueMap = (Map<String, ?>) value;
map(valueMap, ensureNoSelfReferences, true);
} else if (value instanceof Iterable) {
value((Iterable<?>) value, ensureNoSelfReferences);
} else if (value instanceof Object[]) {
values((Object[]) value, ensureNoSelfReferences);
} else if (value instanceof ToXContent) {
value((ToXContent) value);
} else if (value instanceof Enum<?>) {
// Write out the Enum toString
value(Objects.toString(value));
} else {
throw new IllegalArgumentException("cannot write xcontent for unknown value of type " + value.getClass());
}
}
The IllegalArgumentException is throw :
java.lang.IllegalArgumentException: cannot write xcontent for unknown value of type class java.time.LocalDate
at org.opensearch.core.xcontent.XContentBuilder.unknownValue(XContentBuilder.java:866)
at org.opensearch.core.xcontent.XContentBuilder.value(XContentBuilder.java:837)
at org.opensearch.core.xcontent.XContentBuilder.field(XContentBuilder.java:822)
at org.opensearch.index.query.RangeQueryBuilder.doXContent(RangeQueryBuilder.java:334)
What can lead ServiceLoader.load(XContentBuilderExtension.class)
to not find XContentOpenSearchExtension
class ?
When using OpenSearch's XContentBuilder
to serialize Java date/time objects (like LocalDate
, Date
, LocalDateTime
), I encountered this error:
java.lang.IllegalArgumentException: cannot write time value xcontent for unknown value of type class java.util.Date
This happens because the internal WRITERS
map in XContentBuilder
is not correctly populated for date types. This map is initialized in a static block using:
for (XContentBuilderExtension ext :ServiceLoader.load(XContentBuilderExtension.class)) { ..... }
But in some cases, ServiceLoader.load(...)
silently fails to find any implementation, causing OpenSearch client to be unable to serialize common types like LocalDate
.
In my case, this issue occurred only under certain multithreaded conditions:
I was calling a method that triggered the first use of XContentBuilder
inside a stream().parallel()
chain.
That method also used CompletableFuture.supplyAsync(...)
.
Both of these approaches run code in threads from the ForkJoinPool, where the Thread Context ClassLoader (TCCL) is not necessarily the one that has access to application .jar
s (like the one containing XContentOpenSearchExtension
).
As a result, ServiceLoader.load(...)
was using the wrong class loader and failed to find any extensions.
I resolved the issue using two combined changes:
stream().parallel()
and CompletableFuture
I replaced:
list.stream().parallel().map(...).collect(...)
with:
list.stream().map(...).collect(...)
and:
CompletableFuture.supplyAsync(() -> callOpenSearch())
with a Spring-managed ThreadPoolTaskExecutor
, ensuring that threads used for async work inherit the correct class loader:
@Autowired
private ThreadPoolTaskExecutor openSearchExecutor;
public void callOpensearchAsync() {
openSearchExecutor.submit(() -> { callOpenSearch(); });
}
This ensures the context classloader is consistent with the application and has access to XContentBuilderExtension
implementations.
Note: This only happens on Tomcat, Works fine on Embedded Tomcat.