Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 {
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