-
Notifications
You must be signed in to change notification settings - Fork 2.2k
Description
Describe the bug
CosmosAsyncContainer.replaceItem does not use the customItemSerializer provided via CosmosClientBuilder.customItemSerializer() for serialising the item POJO. Instead, it eagerly converts the POJO to a Document using InternalObjectNode.fromObject(item), which serialises via the SDK's internal Utils.getSimpleObjectMapper() singleton (with Jackson defaults). The resulting Document extends JsonSerializable, which then short-circuits the custom serialiser's serialize() method.
This means any ObjectMapper configuration applied to the custom serialiser (e.g. WRITE_DATES_AS_TIMESTAMPS = false, custom JsonInclude, naming strategies, additional Jackson modules) is silently ignored for all replaceItem calls.
In contrast, createItem and upsertItem correctly pass the raw POJO through to the custom serialiser, so createItem/upsertItem and replaceItem produce different JSON for the same object.
Related: #45521 (custom serializer also bypassed for queryItems with ORDER BY/GROUP BY and SqlParameter serialization).
Exception or Stack Trace
The exception surfaces on read after a replaceItem has written incorrectly-serialised data. For example, java.time.Instant fields are written as numeric epoch seconds instead of ISO-8601 strings, and the application's deserialiser fails:
java.lang.IllegalStateException: Unable to parse JSON {
"id": "...",
"metadata": {"created": 1.774266255081534E9, "last_modified": 1.774266256614417E9},
"expiration": null,
...
}
Caused by: java.time.format.DateTimeParseException: Text '1.774266255081534E9' could not be parsed at index 0
Note "expiration": null is also present -- our mapper uses JsonInclude.Include.NON_ABSENT which would omit null fields, confirming the custom serialiser was not used for this write.
To Reproduce
-
Create a
CosmosClientwith a custom serialiser that uses a configuredObjectMapper:ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); mapper.setSerializationInclusion(JsonInclude.Include.NON_ABSENT); // DefaultCosmosItemSerializer is in the implementation package -- we use it here for // brevity CosmosClient client = new CosmosClientBuilder() .endpoint(endpoint) .key(key) .customItemSerializer(new DefaultCosmosItemSerializer(mapper)) .buildClient();
-
Create an item containing an
Instantfield usingcontainer.createItem(item, partitionKey, options)-- this works correctly, theInstantis serialised as an ISO-8601 string. -
Read the item back and verify the
Instantfield is a string -- this succeeds. -
Modify the item and call
container.replaceItem(item, itemId, partitionKey, options). -
Read the item back -- the
Instantfield is now a numeric value (e.g.1.774266255E9) becausereplaceItemserialised it withUtils.getSimpleObjectMapper()which hasWRITE_DATES_AS_TIMESTAMPS = true(Jackson default).
Code Snippet
The root cause is in CosmosAsyncContainer.replaceItem (L1737-1740):
public <T> Mono<CosmosItemResponse<T>> replaceItem(
T item, String itemId, PartitionKey partitionKey, CosmosItemRequestOptions options) {
Document doc = InternalObjectNode.fromObject(item); // <-- bypasses custom serializerInternalObjectNode.fromObject uses a static MAPPER field (L21) initialised from Utils.getSimpleObjectMapper():
private static final ObjectMapper MAPPER = Utils.getSimpleObjectMapper();For a POJO (not byte[], ObjectNode, or InternalObjectNode), it serialises via MAPPER.writeValueAsString (L104):
return new Document(InternalObjectNode.MAPPER.writeValueAsString(cosmosItem));The resulting Document (which extends JsonSerializable) then short-circuits the custom serialiser in DefaultCosmosItemSerializer.serialize() (L63-64):
if (item instanceof JsonSerializable) {
return ((JsonSerializable) item).getMap();
}In contrast, createItemInternal (L551) and upsertItemInternal (L2362) both correctly set the effective item serialiser and pass the raw POJO through:
effectiveOptions.setEffectiveItemSerializer(
this.database.getClient().getEffectiveItemSerializer(
effectiveOptions.getEffectiveItemSerializer()));Expected behavior
replaceItem should use the customItemSerializer (or the effective item serialiser) to serialise the POJO, consistent with how createItem and upsertItem work. The custom serialiser's ObjectMapper configuration should be respected for all point write operations.
Screenshots
N/A
Setup (please complete the following information):
- OS: macOS 15
- IDE: IntelliJ
- Library/Libraries:
com.azure:azure-cosmos:4.78.0 - Java version: 25
- App Server/Environment: Quarkus application deployed in Kubernetes
- Frameworks: Quarkus
Additional context
The current workaround is to also apply your ObjectMapper configuration to the SDK's shared singleton (Utils.getSimpleObjectMapper()) so that InternalObjectNode.fromObject() produces compatible output:
ObjectMapper sdkMapper = Utils.getSimpleObjectMapper();
sdkMapper.registerModule(new JavaTimeModule());
sdkMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
sdkMapper.setSerializationInclusion(JsonInclude.Include.NON_ABSENT);This is fragile and mutates an implementation singleton 😬
Information Checklist
- Bug Description Added
- Repro Steps Added
- Setup information Added