Skip to content

Durable Entities#268

Open
bachuv wants to merge 51 commits intomainfrom
vabachu/entities
Open

Durable Entities#268
bachuv wants to merge 51 commits intomainfrom
vabachu/entities

Conversation

@bachuv
Copy link
Copy Markdown
Contributor

@bachuv bachuv commented Mar 4, 2026

Issue describing the changes in this PR

Adding Durable Entities support for the Java SDK.

resolves #31

Pull request checklist

  • My changes do not require documentation changes
    • Otherwise: Documentation issue linked to PR
  • My changes are added to the CHANGELOG.md
  • I have added all required tests (Unit tests, E2E tests)

@bachuv bachuv requested a review from a team as a code owner March 4, 2026 17:58
Copilot AI review requested due to automatic review settings March 4, 2026 17:58
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds Durable Entities support to the Java SDK, including worker-side entity execution, orchestration↔entity APIs, client management/query APIs, Azure Functions integration, and samples/tests demonstrating the feature set.

Changes:

  • Add core entity programming model APIs (entities, entity context/state/operation abstractions, entity client, options, query/paging, storage cleanup).
  • Integrate entity messaging + locking into orchestration execution and worker-side work-item processing.
  • Add Azure Functions entity trigger + middleware, plus multiple new samples and extensive unit/integration tests.

Reviewed changes

Copilot reviewed 56 out of 56 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
samples/src/main/java/io/durabletask/samples/LowLevelEntitySample.java Sample: low-level entity + state dispatch + implicit delete
samples/src/main/java/io/durabletask/samples/EntityTimeoutSample.java Sample: callEntity timeouts + scheduled signals
samples/src/main/java/io/durabletask/samples/EntityQuerySample.java Sample: entity query + storage cleanup
samples/src/main/java/io/durabletask/samples/EntityCommunicationSample.java Sample: entity→entity signaling + entity-started orchestrations
samples/src/main/java/io/durabletask/samples/CounterEntitySample.java Sample: basic counter entity usage
samples/src/main/java/io/durabletask/samples/BankAccountSample.java Sample: entity locking + atomic transfer orchestration
internal/durabletask-protobuf/protos/orchestrator_service.proto Proto updates for worker request filtering
internal/durabletask-protobuf/PROTO_SOURCE_COMMIT_HASH Tracks upstream proto sync commit
client/src/test/java/com/microsoft/durabletask/TypedEntityMetadataTest.java Tests for typed entity metadata wrapper
client/src/test/java/com/microsoft/durabletask/TaskEntityTest.java Tests for reflection dispatch, implicit delete, state dispatch behavior
client/src/test/java/com/microsoft/durabletask/TaskEntityExecutorTest.java Tests for batch execution semantics, rollback, actions
client/src/test/java/com/microsoft/durabletask/IntegrationTestBase.java Test worker builder support for entity registration
client/src/test/java/com/microsoft/durabletask/EntityRegistrationTest.java Tests for worker builder addEntity overloads
client/src/test/java/com/microsoft/durabletask/EntityQueryTest.java Tests for query normalization/defaults/chaining
client/src/test/java/com/microsoft/durabletask/EntityQueryResultTest.java Tests for query result container
client/src/test/java/com/microsoft/durabletask/EntityQueryPageableTest.java Tests for auto-pagination behavior
client/src/test/java/com/microsoft/durabletask/EntityOptionsTest.java Tests for signal/call entity options
client/src/test/java/com/microsoft/durabletask/EntityMetadataTest.java Tests for entity metadata parsing/state deserialization
client/src/test/java/com/microsoft/durabletask/EntityIntegrationTests.java Sidecar-backed integration tests for entities
client/src/test/java/com/microsoft/durabletask/EntityInstanceIdTest.java Tests for entity ID parsing/format/ordering
client/src/test/java/com/microsoft/durabletask/CleanEntityStorageResultTest.java Tests for cleanup result model
client/src/test/java/com/microsoft/durabletask/CleanEntityStorageRequestTest.java Tests for cleanup request model
client/src/main/java/com/microsoft/durabletask/TypedEntityMetadata.java Typed state wrapper for entity metadata
client/src/main/java/com/microsoft/durabletask/TaskOrchestrationExecutor.java Orchestrator integration: entity signaling/calling/locking + event handling
client/src/main/java/com/microsoft/durabletask/TaskOrchestrationContext.java Public orchestration APIs for entity operations + locking
client/src/main/java/com/microsoft/durabletask/TaskEntityState.java Entity state wrapper with commit/rollback semantics
client/src/main/java/com/microsoft/durabletask/TaskEntityOperation.java Entity operation input/state/context container
client/src/main/java/com/microsoft/durabletask/TaskEntityFactory.java Functional factory for entity instantiation
client/src/main/java/com/microsoft/durabletask/TaskEntityExecutor.java Worker-side executor for entity batches + transactional semantics
client/src/main/java/com/microsoft/durabletask/TaskEntityContext.java Entity context for signaling/starting orchestrations
client/src/main/java/com/microsoft/durabletask/TaskEntity.java Reflection-based entity dispatch base class
client/src/main/java/com/microsoft/durabletask/SignalEntityOptions.java Options for scheduled entity signals
client/src/main/java/com/microsoft/durabletask/ITaskEntity.java Entity interface contract
client/src/main/java/com/microsoft/durabletask/GrpcDurableEntityClient.java gRPC implementation of DurableEntityClient management APIs
client/src/main/java/com/microsoft/durabletask/EntityRunner.java Helper for executing entity batches from encoded protobuf payloads
client/src/main/java/com/microsoft/durabletask/EntityQueryResult.java Entity query result model
client/src/main/java/com/microsoft/durabletask/EntityQueryPageable.java Auto-pagination support for entity queries
client/src/main/java/com/microsoft/durabletask/EntityQuery.java Entity query model + prefix normalization
client/src/main/java/com/microsoft/durabletask/EntityOperationFailedException.java Exception type for failed two-way entity calls
client/src/main/java/com/microsoft/durabletask/EntityMetadata.java Entity metadata model + state deserialization
client/src/main/java/com/microsoft/durabletask/EntityInstanceId.java Entity instance identifier type and parsing
client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcWorkerBuilder.java Worker builder support for registering entities + concurrency option
client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcWorker.java Worker runtime support for entity work items
client/src/main/java/com/microsoft/durabletask/DurableTaskGrpcClient.java Expose entity client from gRPC client
client/src/main/java/com/microsoft/durabletask/DurableTaskClient.java Add entity API surface + deprecated forwarding helpers
client/src/main/java/com/microsoft/durabletask/DurableEntityClient.java Durable entity management client abstraction
client/src/main/java/com/microsoft/durabletask/CleanEntityStorageResult.java Model for cleanup results
client/src/main/java/com/microsoft/durabletask/CleanEntityStorageRequest.java Model for cleanup requests
client/src/main/java/com/microsoft/durabletask/CallEntityOptions.java Options for callEntity timeouts
client/build.gradle Windows-friendly java/javac path handling for tests
azuremanaged/build.gradle Windows-friendly javac path handling for tests
azurefunctions/src/main/resources/META-INF/services/com.microsoft.azure.functions.internal.spi.middleware.Middleware Register entity middleware
azurefunctions/src/main/java/com/microsoft/durabletask/azurefunctions/internal/middleware/EntityMiddleware.java Azure Functions middleware for entity triggers
azurefunctions/src/main/java/com/microsoft/durabletask/azurefunctions/DurableEntityTrigger.java Azure Functions entity trigger annotation
azurefunctions/src/main/java/com/microsoft/durabletask/azurefunctions/DurableClientContext.java Entity management APIs surfaced via DurableClientContext

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Copy Markdown
Member

@YunchuWang YunchuWang left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: Durable Entities for Java SDK

Thorough review of the 78-file PR adding Durable Entities support. The overall design is solid and well-documented, with good alignment to the .NET SDK patterns. However, I found several issues ranging from a Java 8 compatibility breakage to unused concurrency controls and excessive logging. Details in inline comments below.

Summary of Findings

Critical (must fix):

  1. Class.getPackageName() breaks Java 8 compatibility (project targets Java 8)
  2. maxConcurrentEntityWorkItems is accepted but never enforced
  3. Activity execution silently changed from synchronous to unbounded-concurrent

Medium:
4. EntityMetadata.includesState incorrectly derived from proto field presence
5. EntityQueryPageable.cloneQuery() duplicates EntityQuery.copy() and double-normalizes
6. TaskEntityOperation constructor should be package-private

Low / Code Quality:
7. INFO-level logging on orchestration replay hot path will flood production logs
8. Inconsistent indentation in ENTITYREQUESTV2 handler and findMethodUncached
9. Field declared after methods that reference it in CleanEntityStorageRequest
10. Missing newline at end of several files

Copy link
Copy Markdown
Member

@YunchuWang YunchuWang left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Strict Code Review — Durable Entities (Java SDK)

This is a thorough review of the 12,394-line, 78-file Durable Entities feature addition. The overall architecture is solid and well-aligned with the .NET SDK design. However, there are several correctness, safety, and API-design issues that should be addressed before merge.

I've organized findings into tiers — please prioritize the Critical/High items.


🔴 Critical / High Priority

1. Lock release close() is not idempotent — can cause non-deterministic replay (TaskOrchestrationExecutor.java)
The AutoCloseable returned from lockEntities() sends unlock messages and increments sequenceNumber every time close() is called. If a user calls close() twice (very common with try-with-resources + explicit close, or exception handling double-close), this will: (a) send duplicate unlock messages corrupting entity state, and (b) increment sequenceNumber again, causing non-deterministic replay failures on subsequent executions. Fix: Add a boolean guard to make close() idempotent.

2. Critical section enforcement bypassed when lockedEntityIds is null (TaskOrchestrationExecutor.java)
In callEntity():

if (this.isInCriticalSection && this.lockedEntityIds != null && !this.lockedEntityIds.contains(...))

handleEntityLockGranted() sets this.lockedEntityIds = this.pendingLockSets.remove(criticalSectionId), which returns null if the key is missing. Combined with isInCriticalSection = true, the null short-circuits the safety check, allowing calls to any entity — defeating deadlock prevention. Fix: Throw if pendingLockSets.remove() returns null.

3. Activity completion failures silently lost (DurableTaskGrpcWorker.java)
Activities are now dispatched to workItemExecutor, but sidecarClient.completeActivityTask() inside the Runnable has no outer try-catch for the gRPC call itself. If the gRPC call fails (network issue, sidecar crash), the exception hits only the thread pool's uncaught exception handler — the activity result is permanently lost with no retry or abandon. Entity work items have an abandon fallback, but activities do not.

4. setState() silently deletes entity state on serialization failure (TaskEntityState.java)

this.serializedState = this.dataConverter.serialize(state);
if (this.serializedState == null) {
    deleteState(); // <-- silent data loss!
}

Since the outer if already guarantees state != null, serialize() returning null means a broken DataConverter. Silently calling deleteState() is a data-loss bug. Should throw IllegalStateException.

5. Unbounded thread pool (DurableTaskGrpcWorker.java)
Executors.newCachedThreadPool() has no upper bound. Despite maxConcurrentEntityWorkItems being sent to the sidecar, the local thread pool is unbounded. Activities AND entities both use this pool. A burst of work items can create unbounded threads. Use a bounded ThreadPoolExecutor or separate pools.

6. Activity execution model silently changed from sync to async (DurableTaskGrpcWorker.java)
Activities were previously executed synchronously on the main dispatch thread. Moving them to the thread pool is a silent behavioral breaking change: (a) activities using ThreadLocal state will break, (b) activities relying on sequential execution relative to dispatch will break, (c) gRPC context propagation may be lost. This deserves a CHANGELOG entry.

7. gRPC exceptions not wrapped in GrpcDurableEntityClient.java
Existing DurableTaskGrpcClient methods catch StatusRuntimeException and translate to domain exceptions. All new entity gRPC calls let raw gRPC exceptions escape. This breaks the DurableEntityClient abstraction — callers shouldn't need to catch gRPC types.

8. Excessive INFO-level logging will flood production logs (TaskOrchestrationExecutor.java)
Multiple log statements changed from FINE to INFO: every orchestrator yield, every entity call/response, every new event processed, every buffered event. In production with hundreds of orchestrations, this will flood logs. Most should remain at FINE/DEBUG.


🟡 Medium Priority

9. getCommittedActions() iterates ALL pending actions, not committed count (TaskEntityExecutor.java)
The method iterates all pendingActions rather than bounding by committedActionCount. It relies on a fragile invariant that both are equal at call time. If that invariant is ever violated (e.g., future refactoring), uncommitted actions leak into the result. Bound the loop by committedActionCount.

10. resultNode.asText() loses data for non-string JSON nodes (TaskOrchestrationExecutor.java)
JsonNode.asText() returns "" for object/array nodes. If the DTFx ResponseMessage "result" field ever contains a raw JSON object, the result is silently lost. Use resultNode.isTextual() ? resultNode.asText() : resultNode.toString().

11. Legacy callEntity path omits "signal" field
Legacy signalEntity JSON explicitly sets "signal": true, but callEntity does not set "signal": false. If DTFx defaults absence to true, all calls would be misrouted as fire-and-forget signals.

12. EntityQueryPageablehasNext() triggers network calls (side-effect violation)
Both the constructor and hasNext() eagerly call fetchNextPage(), which executes gRPC queries. The Iterator.hasNext() contract should be a pure observation. Defer first page fetch to first hasNext() call.

13. TypedEntityMetadata eagerly deserializes — single bad entity poisons entire query
If readStateAs() throws for one entity, TypedEntityMetadata construction fails, halting iteration over the entire result set. No way to skip corrupt entities.

14. Javadoc says "excluding state" but behavior includes state (DurableTaskClient.java)
The deprecated getEntityMetadata(entityId) doc says "excluding its state" but delegates to getEntities().getEntityMetadata(entityId) which defaults to includeState = true.

15. EntityRunner silently swallows malformed instance IDs

try {
    entityName = EntityInstanceId.fromString(instanceId).getName();
} catch (Exception e) {
    entityName = instanceId; // Silent fallback
}

This masks the real problem and produces a confusing "No entity named '...' is registered" error later.

16. EntityProxy.getRawClass() doesn't handle WildcardType
Task<? extends Foo> silently falls back to Object.class, losing the upper-bound type.

17. EntityQueryResult.entities list not defensively copied
The constructor stores the list reference directly. Callers can mutate via getEntities().add(...). Use Collections.unmodifiableList.

18. Missing interrupt flag restoration in close() (DurableTaskGrpcWorker.java)
When InterruptedException is caught, Thread.currentThread().interrupt() is not called, violating Java's interrupt contract.

19. operationInfos/results size mismatch silently truncated (DurableTaskGrpcWorker.java, V2 entity path)
If sizes differ, excess infos are quietly dropped or results have no routing info. Neither case is logged. Can cause silent data loss or mis-routed responses.

20. Lazy cachedEntityInstanceId without volatile (EntityMetadata.java)
The field is lazily initialized in getEntityInstanceId(). Without volatile, it's not safe for cross-thread visibility under the Java Memory Model.


🟠 Low Priority / Design

21. ITaskEntity.runAsync() is synchronous — The method returns Object, not CompletableFuture. The Async suffix is misleading per universal Java convention. Rename to run().

22. ITaskEntity uses I prefix — Inconsistent with existing SDK naming (DataConverter, TaskOrchestrationContext).

23. TaskOrchestrationEntityFeature.lockEntities(EntityInstanceId...) is needlessly abstract — Should be a concrete convenience method delegating to the List overload.

24. getEntities() allocates a new object on every call — The .NET SDK caches this. Cache or document.

25. currentCriticalSectionId is set but never read — Dead state. Remove or use.

26. lockEntities doesn't validate duplicate entity IDs or null elements — Same ID twice = double-lock. Null element = cryptic NPE from Collections.sort.

27. No timeout mechanism for lockEntities — Unlike callEntity with CallEntityOptions.getTimeout(), locks can block forever.

28. Worker threads all share the same name "durabletask-worker" — Thread dumps become useless. Use a counter.

29. EntityQuery.setInstanceIdStartsWith("@") produces degenerate prefix — Matches ALL entities.

30. EntityQueryPageable.cloneQuery() duplicates EntityQuery.copy() logic — Maintenance hazard if a field is added.

31. TaskEntityOperation and TaskEntityState constructors are public — These are internal types that users should never construct. Make package-private.

32. cleanEntityStorage in GrpcDurableEntityClient has no max-iteration guard — If the sidecar has a bug returning non-null tokens forever, this is an unbounded blocking call.


PR Checklist

  • ⚠️ CHANGELOG.md is NOT updated (PR checklist item unchecked)
  • ⚠️ Merge state is "dirty" — rebase needed
  • ✅ Extensive unit tests added
  • ✅ E2E tests added

Questions for @bachuv

  1. Re: #1 (lock close): Is there a reason the lock release wasn't guarded with a boolean flag? try (AutoCloseable lock = ...) makes double-close very likely.

  2. Re: #6 (activity model change): Was the sync→async activity execution change intentional? This is a behavioral breaking change that should at minimum be in the CHANGELOG.

  3. Re: #8 (logging): Were the FINE → INFO logging changes intentional for debugging, or should they be reverted before merge?

  4. Re: #11 (missing "signal": false): Can you confirm DTFx extension behavior when the "signal" field is absent from the RequestMessage JSON?

  5. Re: EVENTSENT uncommented: handleEventSent() now removes pending actions for ALL EventSent events, not just entity-related ones. Has this been regression-tested against existing orchestrations using sendEvent?

  6. Re: #27 (lock timeout): Is lock timeout deferred to a follow-up? The .NET SDK has this capability.

@bachuv
Copy link
Copy Markdown
Contributor Author

bachuv commented Mar 26, 2026

First set of feedback:

Critical (must fix):

  1. Class.getPackageName() breaks Java 8 compatibility (project targets Java 8) - Fixed
  2. maxConcurrentEntityWorkItems is accepted but never enforced - Skipped. The .NET SDK does the same thing with no client-side enforcement.
  3. Activity execution silently changed from synchronous to unbounded-concurrent - Fixed

Medium:
4. EntityMetadata.includesState incorrectly derived from proto field presence - Fixed
5. EntityQueryPageable.cloneQuery() duplicates EntityQuery.copy() and double-normalizes - Fixed
6. TaskEntityOperation constructor should be package-private - Fixed

Low / Code Quality:
7. INFO-level logging on orchestration replay hot path will flood production logs - Fixed
8. Inconsistent indentation in ENTITYREQUESTV2 handler and findMethodUncached - Fixed
9. Field declared after methods that reference it in CleanEntityStorageRequest - Fixed
10. Missing newline at end of several files - Fixed

Second set of feedback:

🔴 Critical / High Priority

  1. Lock release close() is not idempotent - Fixed
  2. Critical section enforcement bypassed when lockedEntityIds is null (TaskOrchestrationExecutor.java)- Fixed
  3. Activity completion failures silently lost (DurableTaskGrpcWorker.java) - No longer applicable. Activities were reverted to synchronous execution (matching main). They run inline on the dispatch thread so no thread pool, no swallowed exceptions. If completeActivityTask fails, the exception propagates naturally up the call stack, same as before this PR.
  4. setState() silently deletes entity state on serialization failure (TaskEntityState.java) - Fixed
  5. Unbounded thread pool (DurableTaskGrpcWorker.java)
    Executors.newCachedThreadPool() has no upper bound. - Fixed
  6. Activity execution model silently changed from sync to async - Fixed
  7. gRPC exceptions not wrapped in GrpcDurableEntityClient.java - Opened GitHub issue Wrap gRPC StatusRuntimeException across all client and worker gRPC calls #274 as this was a preexisting issue. Will address in a separate PR.
  8. Excessive INFO-level logging will flood production logs (TaskOrchestrationExecutor.java) - Fixed

🟡 Medium Priority
9. getCommittedActions() iterates ALL pending actions, not committed count (TaskEntityExecutor.java) - Fixed
10. resultNode.asText() loses data for non-string JSON nodes (TaskOrchestrationExecutor.java) - Fixed
11. Legacy callEntity path omits "signal" field - Fixed
12. EntityQueryPageable — hasNext() triggers network calls (side-effect violation) - Skipped, matches .NET behavior
13. TypedEntityMetadata eagerly deserializes — single bad entity poisons entire query - Skipped, matches .NET behavior
14. Javadoc says "excluding state" but behavior includes state (DurableTaskClient.java) - Fixed
15. EntityRunner silently swallows malformed instance IDs - fixed
16. EntityProxy.getRawClass() doesn't handle WildcardType - Fixed
17. EntityQueryResult.entities list not defensively copied - Fixed
18. Missing interrupt flag restoration in close() (DurableTaskGrpcWorker.java) - Fixed
19. operationInfos/results size mismatch silently truncated (DurableTaskGrpcWorker.java, V2 entity path) - Fixed
20. Lazy cachedEntityInstanceId without volatile (EntityMetadata.java) - Fixed

🟠 Low Priority / Design
21. ITaskEntity.runAsync() is synchronous — The method returns Object, not CompletableFuture. The Async suffix is misleading per universal Java convention. Rename to run(). - Fixed
22. ITaskEntity uses I prefix — Inconsistent with existing SDK naming (DataConverter, TaskOrchestrationContext) - Fixed
23. TaskOrchestrationEntityFeature.lockEntities(EntityInstanceId...) is needlessly abstract — Should be a concrete convenience method delegating to the List overload. - Fixed
24. getEntities() allocates a new object on every call — The .NET SDK caches this. Cache or document. - Fixed
25. currentCriticalSectionId is set but never read — Dead state. Remove or use. - Fixed
26. lockEntities doesn't validate duplicate entity IDs or null elements — Same ID twice = double-lock. Null element = cryptic NPE from Collections.sort. - Fixed
27. No timeout mechanism for lockEntities — Unlike callEntity with CallEntityOptions.getTimeout(), locks can block forever. - Skipped, .NET SDK also has no lock timeout
28. Worker threads all share the same name "durabletask-worker" — Thread dumps become useless. Use a counter. - Fixed
29. EntityQuery.setInstanceIdStartsWith("@") produces degenerate prefix — Matches ALL entities. - Skipped. matches .NET SDK behavior
30. EntityQueryPageable.cloneQuery() duplicates EntityQuery.copy() logic — Maintenance hazard if a field is added. - Fixed
31. TaskEntityOperation and TaskEntityState constructors are public — These are internal types that users should never construct. Make package-private. - Fixed
32. cleanEntityStorage in GrpcDurableEntityClient has no max-iteration guard — If the sidecar has a bug returning non-null tokens forever, this is an unbounded blocking call. - Skipped, matches .NET SDK behavior

Questions

Re: #1 (lock close): This was an oversight. The boolean guard has now been added. Double-close is idempotent: first call emits unlocks, subsequent calls are no-ops.

Re: #6 (activity model change): This has been reverted so activities are back to synchronous execution on the dispatch thread, identical to main. No behavioral change, no CHANGELOG entry needed. The thread pool is now used exclusively for entity work items.

Re: #8 (logging): The FINE → INFO changes were not intentional for production use. They were debug-time changes that should have been reverted. All hot-path logs have been restored to FINE. Only entity operation failures remain at INFO since those are genuinely noteworthy.

Re: #11 (missing "signal": false): DTFx RequestMessage.IsSignal is a C# bool property with [DataMember(Name = "signal", EmitDefaultValue = false)]. It defaults to false, and EmitDefaultValue = false means the .NET serializer omits the field when the value is false. So absent and false are functionally identical. DTFx treats both as "this is a call, not a signal." We now explicitly set "signal": false for clarity.

Re: EVENTSENT uncommented: Could you point to the specific lines? I want to verify the exact change before answering. The handleEventSent handler should only process entity-related events. If it's broader than that, it needs to be scoped.

Re: #27 (lock timeout): Yes, deferred. The .NET SDK's LockEntitiesAsync does not have a timeout parameter. It returns Task with no timeout overload. This would be a new feature for both SDKs, not a parity gap.

@bachuv bachuv requested a review from YunchuWang March 26, 2026 15:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Entity support

3 participants