Skip to content

Commit 84cdc37

Browse files
authored
Fix entity locking deserialization and add Jackson support for EntityInstanceId/EntityMetadata (#281)
1 parent b047247 commit 84cdc37

33 files changed

Lines changed: 1053 additions & 24 deletions

azurefunctions/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ plugins {
66
}
77

88
group 'com.microsoft'
9-
version = '1.8.0'
9+
version = '1.9.0'
1010
archivesBaseName = 'durabletask-azure-functions'
1111

1212
def protocVersion = '3.25.8'

azuremanaged/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ plugins {
1717

1818
archivesBaseName = 'durabletask-azuremanaged'
1919
group 'com.microsoft'
20-
version = '1.8.0'
20+
version = '1.9.0'
2121

2222
def grpcVersion = '1.78.0'
2323
def azureCoreVersion = '1.57.1'

client/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ plugins {
1010
}
1111

1212
group 'com.microsoft'
13-
version = '1.8.0'
13+
version = '1.9.0'
1414
archivesBaseName = 'durabletask-client'
1515

1616
def grpcVersion = '1.78.0'

client/src/main/java/com/microsoft/durabletask/EntityInstanceId.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,17 @@
22
// Licensed under the MIT License.
33
package com.microsoft.durabletask;
44

5+
import com.fasterxml.jackson.core.JsonGenerator;
6+
import com.fasterxml.jackson.core.JsonParser;
7+
import com.fasterxml.jackson.databind.DeserializationContext;
8+
import com.fasterxml.jackson.databind.JsonDeserializer;
9+
import com.fasterxml.jackson.databind.JsonSerializer;
10+
import com.fasterxml.jackson.databind.SerializerProvider;
11+
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
12+
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
13+
514
import javax.annotation.Nonnull;
15+
import java.io.IOException;
616
import java.util.Locale;
717
import java.util.Objects;
818

@@ -11,7 +21,12 @@
1121
* <p>
1222
* The name typically corresponds to the entity class/type name, and the key identifies the specific
1323
* entity instance (e.g., a user ID or account number).
24+
* <p>
25+
* Serializes to and deserializes from a compact string format {@code @{name}@{key}},
26+
* matching the .NET SDK's {@code EntityInstanceId} JSON representation.
1427
*/
28+
@JsonSerialize(using = EntityInstanceId.Serializer.class)
29+
@JsonDeserialize(using = EntityInstanceId.Deserializer.class)
1530
public final class EntityInstanceId implements Comparable<EntityInstanceId> {
1631
private final String name;
1732
private final String key;
@@ -116,4 +131,26 @@ public int compareTo(@Nonnull EntityInstanceId other) {
116131
}
117132
return this.key.compareTo(other.key);
118133
}
134+
135+
/**
136+
* Jackson serializer that writes an {@code EntityInstanceId} as a compact {@code "@name@key"} string.
137+
*/
138+
static class Serializer extends JsonSerializer<EntityInstanceId> {
139+
@Override
140+
public void serialize(EntityInstanceId value, JsonGenerator gen, SerializerProvider serializers)
141+
throws IOException {
142+
gen.writeString(value.toString());
143+
}
144+
}
145+
146+
/**
147+
* Jackson deserializer that reads an {@code EntityInstanceId} from a compact {@code "@name@key"} string.
148+
*/
149+
static class Deserializer extends JsonDeserializer<EntityInstanceId> {
150+
@Override
151+
public EntityInstanceId deserialize(JsonParser p, DeserializationContext ctxt)
152+
throws IOException {
153+
return EntityInstanceId.fromString(p.getText());
154+
}
155+
}
119156
}

client/src/main/java/com/microsoft/durabletask/EntityMetadata.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
// Licensed under the MIT License.
33
package com.microsoft.durabletask;
44

5+
import com.fasterxml.jackson.annotation.JsonIgnore;
6+
import com.fasterxml.jackson.annotation.JsonProperty;
7+
58
import javax.annotation.Nullable;
69
import java.time.Instant;
710

@@ -18,8 +21,10 @@ public class EntityMetadata {
1821
private final Instant lastModifiedTime;
1922
private final int backlogQueueSize;
2023
private final String lockedBy;
24+
@JsonIgnore
2125
private final String serializedState;
2226
private final boolean includesState;
27+
@JsonIgnore
2328
private final DataConverter dataConverter;
2429
private volatile EntityInstanceId cachedEntityInstanceId;
2530

@@ -56,6 +61,7 @@ public class EntityMetadata {
5661
*
5762
* @return the instance ID
5863
*/
64+
@JsonIgnore
5965
public String getInstanceId() {
6066
return this.instanceId;
6167
}
@@ -65,6 +71,7 @@ public String getInstanceId() {
6571
*
6672
* @return the parsed entity instance ID
6773
*/
74+
@JsonProperty("entityId")
6875
public EntityInstanceId getEntityInstanceId() {
6976
if (this.cachedEntityInstanceId == null) {
7077
this.cachedEntityInstanceId = EntityInstanceId.fromString(this.instanceId);

client/src/main/java/com/microsoft/durabletask/TaskOrchestrationExecutor.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1066,6 +1066,30 @@ private void handleEventRaised(HistoryEvent e) {
10661066
rawResult != null ? rawResult : "(null)"));
10671067
}
10681068
this.handleEntityResponseFromEventRaised(matchingTaskRecord, rawResult);
1069+
} else if (matchingTaskRecord.getDataType() == AutoCloseable.class) {
1070+
// In the Azure Functions trigger binding code path, entity lock grants arrive as
1071+
// EventRaised events (not EntityLockGranted proto events). The lock task's data type
1072+
// is AutoCloseable, which Jackson cannot instantiate because it's an interface.
1073+
// The lock handle carries no meaningful state — the actual AutoCloseable is created
1074+
// via thenApply in lockEntities() — so we complete with null and set critical section
1075+
// state here, mirroring handleEntityLockGranted().
1076+
String criticalSectionId = eventName;
1077+
this.isInCriticalSection = true;
1078+
this.lockedEntityIds = this.pendingLockSets.remove(criticalSectionId);
1079+
if (this.lockedEntityIds == null) {
1080+
throw new NonDeterministicOrchestratorException(
1081+
"Lock granted via EventRaised for criticalSectionId=" + criticalSectionId
1082+
+ " but no pending lock set was found. This indicates a non-deterministic orchestration.");
1083+
}
1084+
1085+
if (!this.isReplaying) {
1086+
this.logger.fine(() -> String.format(
1087+
"%s: Entity lock granted via EventRaised for criticalSectionId=%s",
1088+
this.instanceId,
1089+
criticalSectionId));
1090+
}
1091+
1092+
task.complete(null);
10691093
} else {
10701094
try {
10711095
Object result = this.dataConverter.deserialize(

client/src/main/java/com/microsoft/durabletask/TypedEntityMetadata.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
// Licensed under the MIT License.
33
package com.microsoft.durabletask;
44

5+
import com.fasterxml.jackson.annotation.JsonIgnore;
6+
import com.fasterxml.jackson.annotation.JsonProperty;
7+
58
import javax.annotation.Nullable;
69

710
/**
@@ -60,6 +63,7 @@ public final class TypedEntityMetadata<T> extends EntityMetadata {
6063
* @throws IllegalStateException if state was not included in this metadata
6164
* (i.e., {@link #isIncludesState()} returns {@code false})
6265
*/
66+
@JsonProperty("state")
6367
@Nullable
6468
public T getState() {
6569
if (!this.isIncludesState()) {
@@ -75,6 +79,7 @@ public T getState() {
7579
*
7680
* @return the state type class
7781
*/
82+
@JsonIgnore
7883
public Class<T> getStateType() {
7984
return this.stateType;
8085
}

client/src/test/java/com/microsoft/durabletask/EntityInstanceIdTest.java

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT License.
33
package com.microsoft.durabletask;
44

5+
import com.fasterxml.jackson.databind.ObjectMapper;
56
import org.junit.jupiter.api.Test;
67
import org.junit.jupiter.params.ParameterizedTest;
78
import org.junit.jupiter.params.provider.ValueSource;
@@ -212,4 +213,61 @@ void compareTo_sortsList() {
212213
assertEquals("counter", ids.get(3).getName());
213214
assertEquals("3", ids.get(3).getKey());
214215
}
216+
217+
// region Jackson serialization tests
218+
219+
@Test
220+
void jacksonSerialization_serializesToCompactString() throws Exception {
221+
EntityInstanceId id = new EntityInstanceId("Counter", "myKey");
222+
ObjectMapper mapper = new ObjectMapper();
223+
String json = mapper.writeValueAsString(id);
224+
assertEquals("\"@counter@myKey\"", json);
225+
}
226+
227+
@Test
228+
void jacksonDeserialization_deserializesFromCompactString() throws Exception {
229+
ObjectMapper mapper = new ObjectMapper();
230+
EntityInstanceId id = mapper.readValue("\"@counter@myKey\"", EntityInstanceId.class);
231+
assertEquals("counter", id.getName());
232+
assertEquals("myKey", id.getKey());
233+
}
234+
235+
@Test
236+
void jacksonRoundTrip_preservesIdentity() throws Exception {
237+
EntityInstanceId original = new EntityInstanceId("BankAccount", "acct-123");
238+
ObjectMapper mapper = new ObjectMapper();
239+
String json = mapper.writeValueAsString(original);
240+
EntityInstanceId deserialized = mapper.readValue(json, EntityInstanceId.class);
241+
assertEquals(original, deserialized);
242+
}
243+
244+
@Test
245+
void jacksonDeserialization_inPojo_works() throws Exception {
246+
// Simulates the CounterPayload scenario where EntityInstanceId is a field
247+
String json = "{\"entityId\":\"@counter@c1\",\"value\":42}";
248+
ObjectMapper mapper = new ObjectMapper();
249+
TestPayload payload = mapper.readValue(json, TestPayload.class);
250+
assertEquals("counter", payload.entityId.getName());
251+
assertEquals("c1", payload.entityId.getKey());
252+
assertEquals(42, payload.value);
253+
}
254+
255+
@Test
256+
void jacksonSerialization_inPojo_works() throws Exception {
257+
TestPayload payload = new TestPayload();
258+
payload.entityId = new EntityInstanceId("Counter", "c1");
259+
payload.value = 42;
260+
ObjectMapper mapper = new ObjectMapper();
261+
String json = mapper.writeValueAsString(payload);
262+
assertTrue(json.contains("\"@counter@c1\""));
263+
assertTrue(json.contains("\"value\":42"));
264+
}
265+
266+
/** Test POJO that embeds an EntityInstanceId, mirroring CounterPayload. */
267+
public static class TestPayload {
268+
public EntityInstanceId entityId;
269+
public int value;
270+
}
271+
272+
// endregion
215273
}

client/src/test/java/com/microsoft/durabletask/TaskOrchestrationEntityEventTest.java

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1331,6 +1331,73 @@ void getLockedEntities_outsideCriticalSection_returnsEmpty() {
13311331
assertTrue(hasComplete, "Expected orchestration to complete");
13321332
}
13331333

1334+
/**
1335+
* Regression test: In the Azure Functions trigger binding code path, entity lock grants
1336+
* arrive as EventRaised events (not EntityLockGranted proto events). The lock task's data
1337+
* type is AutoCloseable, which Jackson cannot instantiate because it's an interface.
1338+
* This test verifies that the orchestration completes successfully when the lock grant
1339+
* arrives via EventRaised (simulating the Azure Functions path).
1340+
*/
1341+
@Test
1342+
void lockEntities_lockGrantedViaEventRaised_succeeds() {
1343+
final String orchestratorName = "LockGrantedViaEventRaisedTest";
1344+
EntityInstanceId entityId = new EntityInstanceId("Counter", "c1");
1345+
1346+
TaskOrchestrationExecutor executor = createExecutor(orchestratorName, ctx -> {
1347+
AutoCloseable lock = ctx.lockEntities(Arrays.asList(entityId)).await();
1348+
assertTrue(ctx.isInCriticalSection());
1349+
assertFalse(ctx.getLockedEntities().isEmpty());
1350+
try {
1351+
lock.close();
1352+
} catch (Exception e) {
1353+
throw new RuntimeException(e);
1354+
}
1355+
ctx.complete("lock-via-event-raised");
1356+
});
1357+
1358+
// First execution: orchestrator calls lockEntities, which produces a lock request action
1359+
List<HistoryEvent> pastEvents1 = Arrays.asList(
1360+
orchestratorStarted(),
1361+
executionStarted(orchestratorName, "null"));
1362+
List<HistoryEvent> newEvents1 = Collections.singletonList(orchestratorCompleted());
1363+
1364+
TaskOrchestratorResult result1 = executor.execute(pastEvents1, newEvents1, null);
1365+
1366+
// Extract the criticalSectionId from the lock request action
1367+
String criticalSectionId = null;
1368+
try {
1369+
criticalSectionId = extractLockCriticalSectionId(result1.getActions());
1370+
} catch (Exception e) {
1371+
fail("Failed to extract criticalSectionId: " + e.getMessage());
1372+
}
1373+
assertNotNull(criticalSectionId, "Expected a lock request action with criticalSectionId");
1374+
1375+
// Second execution: replay with lock request in past, lock grant arrives as EventRaised
1376+
// (simulating the Azure Functions trigger binding path where DTFx sends lock grants
1377+
// as named events rather than proto EntityLockGranted events)
1378+
List<HistoryEvent> pastEvents2 = Arrays.asList(
1379+
orchestratorStarted(),
1380+
executionStarted(orchestratorName, "null"),
1381+
eventSentEvent(0),
1382+
orchestratorCompleted());
1383+
List<HistoryEvent> newEvents2 = Arrays.asList(
1384+
orchestratorStarted(),
1385+
eventRaisedEvent(criticalSectionId, "null"),
1386+
orchestratorCompleted());
1387+
1388+
TaskOrchestratorResult result2 = executor.execute(pastEvents2, newEvents2, null);
1389+
1390+
boolean hasComplete = false;
1391+
for (OrchestratorAction action : result2.getActions()) {
1392+
if (action.hasCompleteOrchestration()) {
1393+
String output = action.getCompleteOrchestration().getResult().getValue();
1394+
assertEquals("\"lock-via-event-raised\"", output);
1395+
hasComplete = true;
1396+
}
1397+
}
1398+
assertTrue(hasComplete, "Expected orchestration to complete after lock granted via EventRaised");
1399+
}
1400+
13341401
@Test
13351402
void lockEntities_varargs_producesLockAction() {
13361403
final String orchestratorName = "VarargsLockTest";

0 commit comments

Comments
 (0)