Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion azurefunctions/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ plugins {
}

group 'com.microsoft'
version = '1.8.0'
version = '1.9.0'
archivesBaseName = 'durabletask-azure-functions'

def protocVersion = '3.25.8'
Expand Down
2 changes: 1 addition & 1 deletion azuremanaged/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ plugins {

archivesBaseName = 'durabletask-azuremanaged'
group 'com.microsoft'
version = '1.8.0'
version = '1.9.0'

def grpcVersion = '1.78.0'
def azureCoreVersion = '1.57.1'
Expand Down
2 changes: 1 addition & 1 deletion client/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ plugins {
}

group 'com.microsoft'
version = '1.8.0'
version = '1.9.0'
archivesBaseName = 'durabletask-client'

def grpcVersion = '1.78.0'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,17 @@
// Licensed under the MIT License.
package com.microsoft.durabletask;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;

import javax.annotation.Nonnull;
import java.io.IOException;
import java.util.Locale;
import java.util.Objects;

Expand All @@ -11,7 +21,12 @@
* <p>
* The name typically corresponds to the entity class/type name, and the key identifies the specific
* entity instance (e.g., a user ID or account number).
* <p>
* Serializes to and deserializes from a compact string format {@code @{name}@{key}},
* matching the .NET SDK's {@code EntityInstanceId} JSON representation.
*/
@JsonSerialize(using = EntityInstanceId.Serializer.class)
@JsonDeserialize(using = EntityInstanceId.Deserializer.class)
public final class EntityInstanceId implements Comparable<EntityInstanceId> {
private final String name;
private final String key;
Expand Down Expand Up @@ -116,4 +131,26 @@ public int compareTo(@Nonnull EntityInstanceId other) {
}
return this.key.compareTo(other.key);
}

/**
* Jackson serializer that writes an {@code EntityInstanceId} as a compact {@code "@name@key"} string.
*/
static class Serializer extends JsonSerializer<EntityInstanceId> {
@Override
public void serialize(EntityInstanceId value, JsonGenerator gen, SerializerProvider serializers)
throws IOException {
gen.writeString(value.toString());
}
}

/**
* Jackson deserializer that reads an {@code EntityInstanceId} from a compact {@code "@name@key"} string.
*/
static class Deserializer extends JsonDeserializer<EntityInstanceId> {
@Override
public EntityInstanceId deserialize(JsonParser p, DeserializationContext ctxt)
throws IOException {
return EntityInstanceId.fromString(p.getText());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
// Licensed under the MIT License.
package com.microsoft.durabletask;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;

import javax.annotation.Nullable;
import java.time.Instant;

Expand All @@ -18,8 +21,10 @@ public class EntityMetadata {
private final Instant lastModifiedTime;
private final int backlogQueueSize;
private final String lockedBy;
@JsonIgnore
private final String serializedState;
private final boolean includesState;
@JsonIgnore
private final DataConverter dataConverter;
private volatile EntityInstanceId cachedEntityInstanceId;

Expand Down Expand Up @@ -56,6 +61,7 @@ public class EntityMetadata {
*
* @return the instance ID
*/
@JsonIgnore
public String getInstanceId() {
return this.instanceId;
}
Expand All @@ -65,6 +71,7 @@ public String getInstanceId() {
*
* @return the parsed entity instance ID
*/
@JsonProperty("entityId")
public EntityInstanceId getEntityInstanceId() {
if (this.cachedEntityInstanceId == null) {
this.cachedEntityInstanceId = EntityInstanceId.fromString(this.instanceId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1066,6 +1066,30 @@ private void handleEventRaised(HistoryEvent e) {
rawResult != null ? rawResult : "(null)"));
}
this.handleEntityResponseFromEventRaised(matchingTaskRecord, rawResult);
} else if (matchingTaskRecord.getDataType() == AutoCloseable.class) {
// In the Azure Functions trigger binding code path, entity lock grants arrive as
// EventRaised events (not EntityLockGranted proto events). The lock task's data type
// is AutoCloseable, which Jackson cannot instantiate because it's an interface.
// The lock handle carries no meaningful state — the actual AutoCloseable is created
// via thenApply in lockEntities() — so we complete with null and set critical section
// state here, mirroring handleEntityLockGranted().
String criticalSectionId = eventName;
this.isInCriticalSection = true;
this.lockedEntityIds = this.pendingLockSets.remove(criticalSectionId);
if (this.lockedEntityIds == null) {
throw new NonDeterministicOrchestratorException(
"Lock granted via EventRaised for criticalSectionId=" + criticalSectionId
+ " but no pending lock set was found. This indicates a non-deterministic orchestration.");
}

if (!this.isReplaying) {
this.logger.fine(() -> String.format(
"%s: Entity lock granted via EventRaised for criticalSectionId=%s",
this.instanceId,
criticalSectionId));
}

task.complete(null);
} else {
try {
Object result = this.dataConverter.deserialize(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
// Licensed under the MIT License.
package com.microsoft.durabletask;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;

import javax.annotation.Nullable;

/**
Expand Down Expand Up @@ -60,6 +63,7 @@ public final class TypedEntityMetadata<T> extends EntityMetadata {
* @throws IllegalStateException if state was not included in this metadata
* (i.e., {@link #isIncludesState()} returns {@code false})
*/
@JsonProperty("state")
@Nullable
public T getState() {
if (!this.isIncludesState()) {
Expand All @@ -75,6 +79,7 @@ public T getState() {
*
* @return the state type class
*/
@JsonIgnore
public Class<T> getStateType() {
return this.stateType;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT License.
package com.microsoft.durabletask;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
Expand Down Expand Up @@ -212,4 +213,61 @@ void compareTo_sortsList() {
assertEquals("counter", ids.get(3).getName());
assertEquals("3", ids.get(3).getKey());
}

// region Jackson serialization tests

@Test
void jacksonSerialization_serializesToCompactString() throws Exception {
EntityInstanceId id = new EntityInstanceId("Counter", "myKey");
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(id);
assertEquals("\"@counter@myKey\"", json);
}

@Test
void jacksonDeserialization_deserializesFromCompactString() throws Exception {
ObjectMapper mapper = new ObjectMapper();
EntityInstanceId id = mapper.readValue("\"@counter@myKey\"", EntityInstanceId.class);
assertEquals("counter", id.getName());
assertEquals("myKey", id.getKey());
}

@Test
void jacksonRoundTrip_preservesIdentity() throws Exception {
EntityInstanceId original = new EntityInstanceId("BankAccount", "acct-123");
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(original);
EntityInstanceId deserialized = mapper.readValue(json, EntityInstanceId.class);
assertEquals(original, deserialized);
}

@Test
void jacksonDeserialization_inPojo_works() throws Exception {
// Simulates the CounterPayload scenario where EntityInstanceId is a field
String json = "{\"entityId\":\"@counter@c1\",\"value\":42}";
ObjectMapper mapper = new ObjectMapper();
TestPayload payload = mapper.readValue(json, TestPayload.class);
assertEquals("counter", payload.entityId.getName());
assertEquals("c1", payload.entityId.getKey());
assertEquals(42, payload.value);
}

@Test
void jacksonSerialization_inPojo_works() throws Exception {
TestPayload payload = new TestPayload();
payload.entityId = new EntityInstanceId("Counter", "c1");
payload.value = 42;
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(payload);
assertTrue(json.contains("\"@counter@c1\""));
assertTrue(json.contains("\"value\":42"));
}

/** Test POJO that embeds an EntityInstanceId, mirroring CounterPayload. */
public static class TestPayload {
public EntityInstanceId entityId;
public int value;
}

// endregion
}
Original file line number Diff line number Diff line change
Expand Up @@ -1331,6 +1331,73 @@ void getLockedEntities_outsideCriticalSection_returnsEmpty() {
assertTrue(hasComplete, "Expected orchestration to complete");
}

/**
* Regression test: In the Azure Functions trigger binding code path, entity lock grants
* arrive as EventRaised events (not EntityLockGranted proto events). The lock task's data
* type is AutoCloseable, which Jackson cannot instantiate because it's an interface.
* This test verifies that the orchestration completes successfully when the lock grant
* arrives via EventRaised (simulating the Azure Functions path).
*/
@Test
void lockEntities_lockGrantedViaEventRaised_succeeds() {
final String orchestratorName = "LockGrantedViaEventRaisedTest";
EntityInstanceId entityId = new EntityInstanceId("Counter", "c1");

TaskOrchestrationExecutor executor = createExecutor(orchestratorName, ctx -> {
AutoCloseable lock = ctx.lockEntities(Arrays.asList(entityId)).await();
assertTrue(ctx.isInCriticalSection());
assertFalse(ctx.getLockedEntities().isEmpty());
try {
lock.close();
} catch (Exception e) {
throw new RuntimeException(e);
}
ctx.complete("lock-via-event-raised");
});

// First execution: orchestrator calls lockEntities, which produces a lock request action
List<HistoryEvent> pastEvents1 = Arrays.asList(
orchestratorStarted(),
executionStarted(orchestratorName, "null"));
List<HistoryEvent> newEvents1 = Collections.singletonList(orchestratorCompleted());

TaskOrchestratorResult result1 = executor.execute(pastEvents1, newEvents1, null);

// Extract the criticalSectionId from the lock request action
String criticalSectionId = null;
try {
criticalSectionId = extractLockCriticalSectionId(result1.getActions());
} catch (Exception e) {
fail("Failed to extract criticalSectionId: " + e.getMessage());
}
assertNotNull(criticalSectionId, "Expected a lock request action with criticalSectionId");

// Second execution: replay with lock request in past, lock grant arrives as EventRaised
// (simulating the Azure Functions trigger binding path where DTFx sends lock grants
// as named events rather than proto EntityLockGranted events)
List<HistoryEvent> pastEvents2 = Arrays.asList(
orchestratorStarted(),
executionStarted(orchestratorName, "null"),
eventSentEvent(0),
orchestratorCompleted());
List<HistoryEvent> newEvents2 = Arrays.asList(
orchestratorStarted(),
eventRaisedEvent(criticalSectionId, "null"),
orchestratorCompleted());

TaskOrchestratorResult result2 = executor.execute(pastEvents2, newEvents2, null);

boolean hasComplete = false;
for (OrchestratorAction action : result2.getActions()) {
if (action.hasCompleteOrchestration()) {
String output = action.getCompleteOrchestration().getResult().getValue();
assertEquals("\"lock-via-event-raised\"", output);
hasComplete = true;
}
}
assertTrue(hasComplete, "Expected orchestration to complete after lock granted via EventRaised");
}

@Test
void lockEntities_varargs_producesLockAction() {
final String orchestratorName = "VarargsLockTest";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// Licensed under the MIT License.
package com.microsoft.durabletask;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;

import java.time.Instant;
Expand Down Expand Up @@ -108,4 +110,63 @@ void readStateAs_stillWorksOnTypedInstance() {
Integer state = typed.readStateAs(Integer.class);
assertEquals(42, state);
}

// region Jackson serialization tests

@Test
void jacksonSerialization_typedEntityMetadata_hidesInternalFields() throws Exception {
Instant now = Instant.parse("2026-01-15T10:30:00Z");
EntityMetadata base = new EntityMetadata(
"@counter@myKey", now, 3, "orch-lock-123", "42", true, dataConverter);

TypedEntityMetadata<Integer> typed = new TypedEntityMetadata<>(base, Integer.class);

ObjectMapper mapper = new ObjectMapper();
mapper.findAndRegisterModules();
String json = mapper.writeValueAsString(typed);
JsonNode root = mapper.readTree(json);

// Should include public API fields
assertTrue(root.has("entityId"), "Should have entityId field");
assertTrue(root.has("lastModifiedTime"), "Should have lastModifiedTime field");
assertTrue(root.has("backlogQueueSize"), "Should have backlogQueueSize field");
assertTrue(root.has("lockedBy"), "Should have lockedBy field");
assertTrue(root.has("includesState"), "Should have includesState field");
assertTrue(root.has("state"), "Should have state field");

// Should NOT include internal fields
assertFalse(root.has("serializedState"), "Should not expose serializedState");
assertFalse(root.has("dataConverter"), "Should not expose dataConverter");
assertFalse(root.has("stateType"), "Should not expose stateType");
assertFalse(root.has("instanceId"), "Should not expose raw instanceId (use entityId instead)");

// Verify field values
assertEquals("@counter@myKey", root.get("entityId").asText());
assertEquals(3, root.get("backlogQueueSize").asInt());
assertEquals("orch-lock-123", root.get("lockedBy").asText());
assertTrue(root.get("includesState").asBoolean());
assertEquals(42, root.get("state").asInt());
}

@Test
void jacksonSerialization_entityMetadata_hidesInternalFields() throws Exception {
Comment thread
bachuv marked this conversation as resolved.
EntityMetadata base = new EntityMetadata(
"@counter@c1", Instant.EPOCH, 0, null, "99", true, dataConverter);

ObjectMapper mapper = new ObjectMapper();
mapper.findAndRegisterModules();
String json = mapper.writeValueAsString(base);
JsonNode root = mapper.readTree(json);

// Should include public API fields
assertTrue(root.has("entityId"), "Should have entityId field");
assertTrue(root.has("lastModifiedTime"), "Should have lastModifiedTime field");

// Should NOT include internal fields
assertFalse(root.has("serializedState"), "Should not expose serializedState");
assertFalse(root.has("dataConverter"), "Should not expose dataConverter");
assertFalse(root.has("instanceId"), "Should not expose raw instanceId");
}

// endregion
}
Loading
Loading