From 2082235fceb69329138c9ac50de69d3804b3a682 Mon Sep 17 00:00:00 2001 From: qmuntal Date: Tue, 24 Mar 2026 11:52:06 +0100 Subject: [PATCH 01/13] feat: implement durable entities Port the durable entities feature from the .NET durabletask-dotnet SDK. Entities are stateful actors that process operations one at a time with automatic state persistence. This implementation includes: Core entity types and execution: - EntityID type (api package) with @name@key format - Entity function type and EntityContext with state management - Entity batch execution with per-operation rollback semantics - Auto-dispatch via NewEntityDispatcher[T]() for struct-based entities - Deterministic NewGuid() on OrchestrationContext (.NET-compatible UUID v5) Orchestration integration: - CallEntity (request/response via SendEvent + WaitForSingleEvent) - SignalEntity (fire-and-forget) - LockEntities with unlock function for critical sections Client API: - SignalEntity with auto-creation of entity instances - FetchEntityMetadata, QueryEntities, CleanEntityStorage Optional interfaces (no breaking changes): - EntityBackend extends Backend with entity storage queries - EntityExecutor extends Executor with entity batch execution - EntityTaskHubClient extends TaskHubClient with entity operations Infrastructure: - Regenerated protobuf with WorkItem.EntityRequest support - gRPC executor: full entity work item dispatch and completion - In-process entity worker: orchestration processor routes @-prefixed instance IDs to EntityExecutor automatically - Entity sample demonstrating raw function and auto-dispatch patterns - 85 entity-related tests including ports from .NET SDK Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/entity.go | 125 ++++ api/entity_test.go | 39 ++ backend/backend.go | 19 + backend/client.go | 97 +++ backend/executor.go | 105 +++ backend/orchestration.go | 124 +++- client/client_grpc.go | 133 ++++ client/worker_grpc.go | 33 + internal/protos/orchestrator_service.pb.go | 538 +++++++-------- .../protos/orchestrator_service_grpc.pb.go | 18 +- samples/entity/entity.go | 163 +++++ task/entity.go | 202 ++++++ task/entity_dispatch.go | 158 +++++ task/entity_dispatch_test.go | 511 +++++++++++++++ task/entity_test.go | 125 ++++ task/executor.go | 122 ++++ task/orchestrator.go | 164 +++++ task/orchestrator_test.go | 41 ++ task/registry.go | 20 +- tests/entity_client_test.go | 260 ++++++++ tests/entity_executor_test.go | 618 ++++++++++++++++++ tests/entity_integration_test.go | 129 ++++ tests/mocks/EntityBackend.go | 144 ++++ 23 files changed, 3606 insertions(+), 282 deletions(-) create mode 100644 api/entity.go create mode 100644 api/entity_test.go create mode 100644 samples/entity/entity.go create mode 100644 task/entity.go create mode 100644 task/entity_dispatch.go create mode 100644 task/entity_dispatch_test.go create mode 100644 task/entity_test.go create mode 100644 tests/entity_client_test.go create mode 100644 tests/entity_executor_test.go create mode 100644 tests/entity_integration_test.go create mode 100644 tests/mocks/EntityBackend.go diff --git a/api/entity.go b/api/entity.go new file mode 100644 index 00000000..ca71a837 --- /dev/null +++ b/api/entity.go @@ -0,0 +1,125 @@ +package api + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/microsoft/durabletask-go/internal/protos" + "google.golang.org/protobuf/types/known/timestamppb" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +// EntityID uniquely identifies an entity by its name and key. +type EntityID struct { + Name string + Key string +} + +// NewEntityID creates a new EntityID with the specified name and key. +func NewEntityID(name string, key string) EntityID { + return EntityID{Name: name, Key: key} +} + +// String returns the entity instance ID in the format "@@". +func (e EntityID) String() string { + return fmt.Sprintf("@%s@%s", strings.ToLower(e.Name), e.Key) +} + +// EntityIDFromString parses an entity instance ID string in the format "@@". +func EntityIDFromString(s string) (EntityID, error) { + if !strings.HasPrefix(s, "@") { + return EntityID{}, fmt.Errorf("invalid entity instance ID format: %q", s) + } + s = s[1:] // trim leading '@' + before, after, ok := strings.Cut(s, "@") + if !ok { + return EntityID{}, fmt.Errorf("invalid entity instance ID format: missing second '@'") + } + return EntityID{Name: before, Key: after}, nil +} + +// EntityMetadata contains metadata about an entity instance. +type EntityMetadata struct { + InstanceID EntityID + LastModifiedTime time.Time + BacklogQueueSize int32 + LockedBy string + SerializedState string +} + +// SignalEntityOptions is a functional option type for signaling an entity. +type SignalEntityOptions func(*protos.SignalEntityRequest) error + +// WithSignalInput configures the input for an entity signal. +func WithSignalInput(input any) SignalEntityOptions { + return func(req *protos.SignalEntityRequest) error { + bytes, err := json.Marshal(input) + if err != nil { + return err + } + req.Input = wrapperspb.String(string(bytes)) + return nil + } +} + +// WithRawSignalInput configures a raw string input for an entity signal. +func WithRawSignalInput(input string) SignalEntityOptions { + return func(req *protos.SignalEntityRequest) error { + req.Input = wrapperspb.String(input) + return nil + } +} + +// WithSignalScheduledTime configures a scheduled time for the entity signal. +func WithSignalScheduledTime(t time.Time) SignalEntityOptions { + return func(req *protos.SignalEntityRequest) error { + req.ScheduledTime = timestamppb.New(t) + return nil + } +} + +// EntityQuery defines filter criteria for querying entities. +type EntityQuery struct { + // InstanceIDStartsWith filters entities whose instance ID starts with this prefix. + InstanceIDStartsWith string + // LastModifiedFrom filters entities modified on or after this time. + LastModifiedFrom time.Time + // LastModifiedTo filters entities modified before this time. + LastModifiedTo time.Time + // IncludeState whether to include entity state in the results. + IncludeState bool + // IncludeTransient whether to include transient (stateless) entities. + IncludeTransient bool + // PageSize limits the number of entities returned per page. + PageSize int32 + // ContinuationToken for fetching the next page of results. + ContinuationToken string +} + +// EntityQueryResults contains the results of an entity query. +type EntityQueryResults struct { + Entities []*EntityMetadata + ContinuationToken string +} + +// CleanEntityStorageRequest contains options for cleaning entity storage. +type CleanEntityStorageRequest struct { + // ContinuationToken for resuming a previous cleanup operation. + ContinuationToken string + // RemoveEmptyEntities removes entities with no state and no locks. + RemoveEmptyEntities bool + // ReleaseOrphanedLocks releases locks held by non-running orchestrations. + ReleaseOrphanedLocks bool +} + +// CleanEntityStorageResult contains the results of a cleanup operation. +type CleanEntityStorageResult struct { + // EmptyEntitiesRemoved is the number of empty entities removed. + EmptyEntitiesRemoved int32 + // OrphanedLocksReleased is the number of orphaned locks released. + OrphanedLocksReleased int32 + // ContinuationToken for resuming cleanup. Empty if complete. + ContinuationToken string +} diff --git a/api/entity_test.go b/api/entity_test.go new file mode 100644 index 00000000..fffdb365 --- /dev/null +++ b/api/entity_test.go @@ -0,0 +1,39 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_API_EntityID_String(t *testing.T) { + id := NewEntityID("Counter", "myCounter") + assert.Equal(t, "@counter@myCounter", id.String()) +} + +func Test_API_EntityIDFromString(t *testing.T) { + tests := []struct { + name string + input string + want EntityID + wantErr bool + }{ + {name: "valid", input: "@counter@key1", want: EntityID{Name: "counter", Key: "key1"}}, + {name: "empty key", input: "@entity@", want: EntityID{Name: "entity", Key: ""}}, + {name: "invalid no prefix", input: "no-at-sign", wantErr: true}, + {name: "invalid no second @", input: "@onlyone", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := EntityIDFromString(tt.input) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/backend/backend.go b/backend/backend.go index f5c77758..778a63c4 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -110,6 +110,25 @@ type Backend interface { PurgeOrchestrationState(context.Context, api.InstanceID) error } +// EntityBackend is an optional interface that backends can implement to support +// entity-specific storage operations like querying and cleanup. +// If a backend does not implement this interface, entity queries and cleanup +// operations will not be available through the in-process client. +type EntityBackend interface { + Backend + + // GetEntityMetadata retrieves metadata for a specific entity instance. + // Returns nil if the entity doesn't exist. + GetEntityMetadata(context.Context, api.EntityID, bool) (*api.EntityMetadata, error) + + // QueryEntities queries entity instances matching the specified filter criteria. + QueryEntities(context.Context, api.EntityQuery) (*api.EntityQueryResults, error) + + // CleanEntityStorage performs garbage collection on entity storage, removing + // empty entities and releasing orphaned locks. + CleanEntityStorage(context.Context, api.CleanEntityStorageRequest) (*api.CleanEntityStorageResult, error) +} + // MarshalHistoryEvent serializes the [HistoryEvent] into a protobuf byte array. func MarshalHistoryEvent(e *HistoryEvent) ([]byte, error) { if bytes, err := proto.Marshal(e); err != nil { diff --git a/backend/client.go b/backend/client.go index aff39184..4712683d 100644 --- a/backend/client.go +++ b/backend/client.go @@ -2,6 +2,7 @@ package backend import ( "context" + "errors" "fmt" "time" @@ -27,6 +28,17 @@ type TaskHubClient interface { PurgeOrchestrationState(ctx context.Context, id api.InstanceID, opts ...api.PurgeOptions) error } +// EntityTaskHubClient is an optional extension of [TaskHubClient] that adds entity-specific +// operations. Clients returned by [NewTaskHubClient] always implement this interface. +// The gRPC client ([TaskHubGrpcClient]) also implements this interface. +type EntityTaskHubClient interface { + TaskHubClient + SignalEntity(ctx context.Context, entityID api.EntityID, operationName string, opts ...api.SignalEntityOptions) error + FetchEntityMetadata(ctx context.Context, entityID api.EntityID, includeState bool) (*api.EntityMetadata, error) + QueryEntities(ctx context.Context, query api.EntityQuery) (*api.EntityQueryResults, error) + CleanEntityStorage(ctx context.Context, req api.CleanEntityStorageRequest) (*api.CleanEntityStorageResult, error) +} + type backendClient struct { be Backend } @@ -205,3 +217,88 @@ func (c *backendClient) PurgeOrchestrationState(ctx context.Context, id api.Inst } return nil } + +// SignalEntity sends a fire-and-forget signal to an entity, triggering the specified operation. +// +// If the entity doesn't exist, it will be created automatically when the signal is processed. +func (c *backendClient) SignalEntity(ctx context.Context, entityID api.EntityID, operationName string, opts ...api.SignalEntityOptions) error { + req := &protos.SignalEntityRequest{ + InstanceId: entityID.String(), + Name: operationName, + } + for _, configure := range opts { + if err := configure(req); err != nil { + return fmt.Errorf("failed to configure signal entity request: %w", err) + } + } + + // Ensure the entity orchestration instance exists. Create with IGNORE policy + // so it's a no-op if the instance already exists. + startEvent := helpers.NewExecutionStartedEvent(entityID.Name, req.InstanceId, nil, nil, nil, nil) + createErr := c.be.CreateOrchestrationInstance(ctx, startEvent, WithOrchestrationIdReusePolicy(&protos.OrchestrationIdReusePolicy{ + Action: protos.CreateOrchestrationAction_IGNORE, + OperationStatus: []protos.OrchestrationStatus{protos.OrchestrationStatus_ORCHESTRATION_STATUS_RUNNING}, + })) + if createErr != nil && !errors.Is(createErr, api.ErrDuplicateInstance) && !errors.Is(createErr, api.ErrIgnoreInstance) { + return fmt.Errorf("failed to create entity instance: %w", createErr) + } + + e := helpers.NewEventRaisedEvent(req.Name, req.Input) + if err := c.be.AddNewOrchestrationEvent(ctx, api.InstanceID(req.InstanceId), e); err != nil { + return fmt.Errorf("failed to signal entity: %w", err) + } + return nil +} + +// FetchEntityMetadata retrieves metadata about an entity instance. +// +// Returns nil if the entity doesn't exist. +// If the backend implements [EntityBackend], its native entity storage is used. +// Otherwise, falls back to orchestration metadata. +func (c *backendClient) FetchEntityMetadata(ctx context.Context, entityID api.EntityID, includeState bool) (*api.EntityMetadata, error) { + if eb, ok := c.be.(EntityBackend); ok { + return eb.GetEntityMetadata(ctx, entityID, includeState) + } + + // Fallback: entities are backed by orchestrations + iid := api.InstanceID(entityID.String()) + metadata, err := c.be.GetOrchestrationMetadata(ctx, iid) + if err != nil { + if errors.Is(err, api.ErrInstanceNotFound) { + return nil, nil + } + return nil, fmt.Errorf("failed to get entity metadata: %w", err) + } + if metadata == nil { + return nil, nil + } + + result := &api.EntityMetadata{ + InstanceID: entityID, + LastModifiedTime: metadata.LastUpdatedAt, + } + if includeState { + result.SerializedState = metadata.SerializedCustomStatus + } + return result, nil +} + +// QueryEntities queries entities matching the specified filter criteria. +// +// Requires the backend to implement [EntityBackend]. +func (c *backendClient) QueryEntities(ctx context.Context, query api.EntityQuery) (*api.EntityQueryResults, error) { + if eb, ok := c.be.(EntityBackend); ok { + return eb.QueryEntities(ctx, query) + } + return nil, fmt.Errorf("QueryEntities requires the backend to implement EntityBackend") +} + +// CleanEntityStorage performs garbage collection on entity storage. +// +// Requires the backend to implement [EntityBackend]. +func (c *backendClient) CleanEntityStorage(ctx context.Context, req api.CleanEntityStorageRequest) (*api.CleanEntityStorageResult, error) { + if eb, ok := c.be.(EntityBackend); ok { + return eb.CleanEntityStorage(ctx, req) + } + return nil, fmt.Errorf("CleanEntityStorage requires the backend to implement EntityBackend") +} diff --git a/backend/executor.go b/backend/executor.go index 570988cd..fc2affc7 100644 --- a/backend/executor.go +++ b/backend/executor.go @@ -39,6 +39,12 @@ type activityExecutionResult struct { pending chan string } +type entityExecutionResult struct { + response *protos.EntityBatchResult + complete chan struct{} + pending chan string +} + type Executor interface { ExecuteOrchestrator(ctx context.Context, iid api.InstanceID, oldEvents []*protos.HistoryEvent, newEvents []*protos.HistoryEvent) (*ExecutionResults, error) ExecuteActivity(context.Context, api.InstanceID, *protos.HistoryEvent) (*protos.HistoryEvent, error) @@ -50,6 +56,8 @@ type grpcExecutor struct { workItemQueue chan *protos.WorkItem pendingOrchestrators *sync.Map // map[api.InstanceID]*ExecutionResults pendingActivities *sync.Map // map[string]*activityExecutionResult + pendingEntities *sync.Map // map[string]*entityExecutionResult + entityQueue chan string backend Backend logger Logger onWorkItemConnection func(context.Context) error @@ -87,6 +95,8 @@ func NewGrpcExecutor(be Backend, logger Logger, opts ...grpcExecutorOptions) (ex logger: logger, pendingOrchestrators: &sync.Map{}, pendingActivities: &sync.Map{}, + pendingEntities: &sync.Map{}, + entityQueue: make(chan string, 100), } for _, opt := range opts { @@ -208,10 +218,51 @@ func (g *grpcExecutor) Shutdown(ctx context.Context) error { } return true }) + g.pendingEntities.Range(func(_, value any) bool { + p, ok := value.(*entityExecutionResult) + if ok { + close(p.complete) + } + return true + }) return nil } +// ExecuteEntity implements Executor +func (executor *grpcExecutor) ExecuteEntity(ctx context.Context, iid api.InstanceID, req *protos.EntityBatchRequest) (*protos.EntityBatchResult, error) { + key := req.InstanceId + result := &entityExecutionResult{complete: make(chan struct{})} + executor.pendingEntities.Store(key, result) + executor.entityQueue <- key + + workItem := &protos.WorkItem{ + Request: &protos.WorkItem_EntityRequest{ + EntityRequest: req, + }, + } + + select { + case <-ctx.Done(): + executor.logger.Warnf("%s: context canceled before dispatching entity work item", iid) + return nil, ctx.Err() + case executor.workItemQueue <- workItem: + } + + select { + case <-ctx.Done(): + executor.logger.Warnf("%s: context canceled before receiving entity result", iid) + return nil, ctx.Err() + case <-result.complete: + executor.logger.Debugf("%s: entity got result", key) + if result.response == nil { + return nil, ErrOperationAborted + } + } + + return result.response, nil +} + // Hello implements protos.TaskHubSidecarServiceServer func (grpcExecutor) Hello(ctx context.Context, empty *emptypb.Empty) (*emptypb.Empty, error) { return empty, nil @@ -240,6 +291,8 @@ func (g *grpcExecutor) GetWorkItems(req *protos.GetWorkItemsRequest, stream prot pendingActivityCh := make(chan string, 1) pendingOrchestrators := make(map[string]struct{}) pendingOrchestratorCh := make(chan string, 1) + pendingEntities := make(map[string]struct{}) + pendingEntityCh := make(chan string, 1) defer func() { // If there's any pending activity left, remove them for key := range pendingActivities { @@ -258,6 +311,14 @@ func (g *grpcExecutor) GetWorkItems(req *protos.GetWorkItemsRequest, stream prot close(pending.complete) } } + for key := range pendingEntities { + g.logger.Debugf("cleaning up pending entity: %s", key) + p, ok := g.pendingEntities.LoadAndDelete(key) + if ok { + pending := p.(*entityExecutionResult) + close(pending.complete) + } + } }() // The worker client invokes this method, which streams back work-items as they arrive. @@ -285,6 +346,13 @@ func (g *grpcExecutor) GetWorkItems(req *protos.GetWorkItemsRequest, stream prot if ok { p.(*activityExecutionResult).pending = pendingActivityCh } + case *protos.WorkItem_EntityRequest: + key := x.EntityRequest.GetInstanceId() + pendingEntities[key] = struct{}{} + p, ok := g.pendingEntities.Load(key) + if ok { + p.(*entityExecutionResult).pending = pendingEntityCh + } } if err := stream.Send(wi); err != nil { @@ -295,6 +363,8 @@ func (g *grpcExecutor) GetWorkItems(req *protos.GetWorkItemsRequest, stream prot delete(pendingActivities, key) case key := <-pendingOrchestratorCh: delete(pendingOrchestrators, key) + case key := <-pendingEntityCh: + delete(pendingEntities, key) case <-g.streamShutdownChan: return errShuttingDown } @@ -357,6 +427,32 @@ func getActivityExecutionKey(iid string, taskID int32) string { return iid + "/" + strconv.FormatInt(int64(taskID), 10) } +// CompleteEntityTask implements protos.TaskHubSidecarServiceServer +func (g *grpcExecutor) CompleteEntityTask(ctx context.Context, res *protos.EntityBatchResult) (*protos.CompleteTaskResponse, error) { + // EntityBatchResult doesn't include instance ID (unlike OrchestratorResponse/ActivityResponse). + // We use a FIFO queue to correlate completions with dispatched entity work items, since the + // worker processes them in order. + var key string + select { + case key = <-g.entityQueue: + default: + return emptyCompleteTaskResponse, fmt.Errorf("no pending entity found for completion") + } + + p, ok := g.pendingEntities.LoadAndDelete(key) + if !ok { + return emptyCompleteTaskResponse, fmt.Errorf("pending entity '%s' was already completed", key) + } + + pending := p.(*entityExecutionResult) + pending.response = res + if pending.pending != nil { + pending.pending <- key + } + close(pending.complete) + return emptyCompleteTaskResponse, nil +} + // CreateTaskHub implements protos.TaskHubSidecarServiceServer func (grpcExecutor) CreateTaskHub(context.Context, *protos.CreateTaskHubRequest) (*protos.CreateTaskHubResponse, error) { return nil, status.Error(codes.Unimplemented, "CreateTaskHub is not implemented") @@ -412,6 +508,15 @@ func (g *grpcExecutor) RaiseEvent(ctx context.Context, req *protos.RaiseEventReq return &protos.RaiseEventResponse{}, nil } +// SignalEntity implements protos.TaskHubSidecarServiceServer +func (g *grpcExecutor) SignalEntity(ctx context.Context, req *protos.SignalEntityRequest) (*protos.SignalEntityResponse, error) { + e := helpers.NewEventRaisedEvent(req.Name, req.Input) + if err := g.backend.AddNewOrchestrationEvent(ctx, api.InstanceID(req.InstanceId), e); err != nil { + return nil, fmt.Errorf("failed to signal entity: %w", err) + } + return &protos.SignalEntityResponse{}, nil +} + // StartInstance implements protos.TaskHubSidecarServiceServer func (g *grpcExecutor) StartInstance(ctx context.Context, req *protos.CreateInstanceRequest) (*protos.CreateInstanceResponse, error) { instanceID := req.InstanceId diff --git a/backend/orchestration.go b/backend/orchestration.go index 567108c5..3b61310f 100644 --- a/backend/orchestration.go +++ b/backend/orchestration.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "time" "go.opentelemetry.io/otel/attribute" @@ -24,10 +25,19 @@ type OrchestratorExecutor interface { newEvents []*protos.HistoryEvent) (*ExecutionResults, error) } +// EntityExecutor is an optional extension of [Executor] that adds entity execution support. +// If the executor passed to [NewOrchestrationWorker] implements this interface, +// entity work items will be automatically dispatched. +type EntityExecutor interface { + Executor + ExecuteEntity(context.Context, api.InstanceID, *protos.EntityBatchRequest) (*protos.EntityBatchResult, error) +} + type orchestratorProcessor struct { - be Backend - executor OrchestratorExecutor - logger Logger + be Backend + executor OrchestratorExecutor + entityExecutor EntityExecutor + logger Logger } func NewOrchestrationWorker(be Backend, executor OrchestratorExecutor, logger Logger, opts ...NewTaskWorkerOptions) TaskWorker { @@ -36,6 +46,10 @@ func NewOrchestrationWorker(be Backend, executor OrchestratorExecutor, logger Lo executor: executor, logger: logger, } + // If the executor also implements EntityExecutor, use it for entity dispatch + if ee, ok := executor.(EntityExecutor); ok { + processor.entityExecutor = ee + } return NewTaskWorker(processor, logger, opts...) } @@ -54,6 +68,11 @@ func (w *orchestratorProcessor) ProcessWorkItem(ctx context.Context, cwi WorkIte wi := cwi.(*OrchestrationWorkItem) w.logger.Debugf("%v: received work item with %d new event(s): %v", wi.InstanceID, len(wi.NewEvents), helpers.HistoryListSummary(wi.NewEvents)) + // Detect entity instances by their "@name@key" prefix and route to entity executor + if w.entityExecutor != nil && strings.HasPrefix(string(wi.InstanceID), "@") { + return w.processEntityWorkItem(ctx, wi) + } + // TODO: Caching // In the fullness of time, we should consider caching executors and runtime state // so that we can skip the loading of state and/or the creation of executors. A cached @@ -142,6 +161,105 @@ func (p *orchestratorProcessor) AbandonWorkItem(ctx context.Context, wi WorkItem return p.be.AbandonOrchestrationWorkItem(ctx, owi) } +// processEntityWorkItem handles orchestration work items that represent entity instances. +// Entity instances are identified by their "@name@key" instance ID format. +// This method converts incoming orchestration events into entity operations, +// executes them via the EntityExecutor, and writes the results back as orchestration state. +func (w *orchestratorProcessor) processEntityWorkItem(ctx context.Context, wi *OrchestrationWorkItem) error { + iid := string(wi.InstanceID) + w.logger.Debugf("%v: processing as entity work item", wi.InstanceID) + + // Load existing state if needed + if wi.State == nil { + state, err := w.be.GetOrchestrationRuntimeState(ctx, wi) + if err != nil { + return fmt.Errorf("failed to load entity state: %w", err) + } + wi.State = state + } + + // Extract entity state from the orchestration metadata (stored as CustomStatus) + var entityState *wrapperspb.StringValue + meta, err := w.be.GetOrchestrationMetadata(ctx, wi.InstanceID) + if err == nil && meta != nil && meta.SerializedCustomStatus != "" { + entityState = wrapperspb.String(meta.SerializedCustomStatus) + } + + // Convert new EventRaised events into entity OperationRequests + var operations []*protos.OperationRequest + for _, e := range wi.NewEvents { + if er := e.GetEventRaised(); er != nil { + operations = append(operations, &protos.OperationRequest{ + Operation: er.Name, + RequestId: fmt.Sprintf("%d", e.EventId), + Input: er.Input, + }) + } + } + + if len(operations) == 0 { + w.logger.Debugf("%v: no entity operations to process", wi.InstanceID) + return nil + } + + // Build and execute the entity batch + batchReq := &protos.EntityBatchRequest{ + InstanceId: iid, + EntityState: entityState, + Operations: operations, + } + + batchResult, err := w.entityExecutor.ExecuteEntity(ctx, wi.InstanceID, batchReq) + if err != nil { + return fmt.Errorf("failed to execute entity: %w", err) + } + if batchResult.FailureDetails != nil { + return fmt.Errorf("entity execution failed: %s", batchResult.FailureDetails.ErrorMessage) + } + + // Ensure the entity orchestration instance exists in state + if wi.State.startEvent == nil { + entityID, _ := api.EntityIDFromString(iid) + startEvent := helpers.NewExecutionStartedEvent(entityID.Name, iid, nil, nil, nil, nil) + if err := wi.State.AddEvent(helpers.NewOrchestratorStartedEvent()); err != nil { + return fmt.Errorf("failed to add orchestrator started event: %w", err) + } + if err := wi.State.AddEvent(startEvent); err != nil { + return fmt.Errorf("failed to initialize entity state: %w", err) + } + } + + // Add incoming events to state history + for _, e := range wi.NewEvents { + _ = wi.State.AddEvent(e) + } + + // Save entity state as the orchestration's custom status + wi.State.CustomStatus = batchResult.EntityState + + // Process actions from the entity batch result (signals to other entities, new orchestrations) + for _, action := range batchResult.Actions { + if signal := action.GetSendSignal(); signal != nil { + e := helpers.NewEventRaisedEvent(signal.Name, signal.Input) + if err := w.be.AddNewOrchestrationEvent(ctx, api.InstanceID(signal.InstanceId), e); err != nil { + w.logger.Warnf("%v: failed to send entity signal to %s: %v", wi.InstanceID, signal.InstanceId, err) + } + } else if startOrch := action.GetStartNewOrchestration(); startOrch != nil { + orchInstanceID := startOrch.InstanceId + if orchInstanceID == "" { + orchInstanceID = fmt.Sprintf("%s:%04x", iid, action.Id) + } + e := helpers.NewExecutionStartedEvent(startOrch.Name, orchInstanceID, startOrch.Input, nil, nil, nil) + if err := w.be.CreateOrchestrationInstance(ctx, e); err != nil { + w.logger.Warnf("%v: failed to start orchestration %s: %v", wi.InstanceID, orchInstanceID, err) + } + } + } + + w.logger.Debugf("%v: entity processed %d operation(s)", wi.InstanceID, len(operations)) + return nil +} + func (w *orchestratorProcessor) applyWorkItem(ctx context.Context, wi *OrchestrationWorkItem) (context.Context, trace.Span, bool) { // Ignore work items for orchestrations that are completed or are in a corrupted state. switch { diff --git a/client/client_grpc.go b/client/client_grpc.go index 996fd985..7cf40b05 100644 --- a/client/client_grpc.go +++ b/client/client_grpc.go @@ -7,6 +7,7 @@ import ( "github.com/cenkalti/backoff/v4" "github.com/google/uuid" "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/wrapperspb" "github.com/microsoft/durabletask-go/api" @@ -212,6 +213,138 @@ func (c *TaskHubGrpcClient) PurgeOrchestrationState(ctx context.Context, id api. return nil } +// SignalEntity sends a fire-and-forget signal to an entity, triggering the specified operation. +// +// If the entity doesn't exist, it will be created automatically when the signal is processed. +func (c *TaskHubGrpcClient) SignalEntity(ctx context.Context, entityID api.EntityID, operationName string, opts ...api.SignalEntityOptions) error { + req := &protos.SignalEntityRequest{ + InstanceId: entityID.String(), + Name: operationName, + } + for _, configure := range opts { + if err := configure(req); err != nil { + return fmt.Errorf("failed to configure signal entity request: %w", err) + } + } + + if _, err := c.client.SignalEntity(ctx, req); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("failed to signal entity: %w", err) + } + return nil +} + +// FetchEntityMetadata retrieves metadata about an entity instance. +// +// Returns nil if the entity doesn't exist. +func (c *TaskHubGrpcClient) FetchEntityMetadata(ctx context.Context, entityID api.EntityID, includeState bool) (*api.EntityMetadata, error) { + req := &protos.GetEntityRequest{ + InstanceId: entityID.String(), + IncludeState: includeState, + } + + resp, err := c.client.GetEntity(ctx, req) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("failed to get entity metadata: %w", err) + } + if !resp.Exists || resp.Entity == nil { + return nil, nil + } + + result := &api.EntityMetadata{ + InstanceID: entityID, + BacklogQueueSize: resp.Entity.BacklogQueueSize, + LockedBy: resp.Entity.LockedBy.GetValue(), + SerializedState: resp.Entity.SerializedState.GetValue(), + } + if resp.Entity.LastModifiedTime != nil { + result.LastModifiedTime = resp.Entity.LastModifiedTime.AsTime() + } + return result, nil +} + +// QueryEntities queries entities matching the specified filter criteria. +func (c *TaskHubGrpcClient) QueryEntities(ctx context.Context, query api.EntityQuery) (*api.EntityQueryResults, error) { + protoQuery := &protos.EntityQuery{ + IncludeState: query.IncludeState, + IncludeTransient: query.IncludeTransient, + } + if query.InstanceIDStartsWith != "" { + protoQuery.InstanceIdStartsWith = wrapperspb.String(query.InstanceIDStartsWith) + } + if !query.LastModifiedFrom.IsZero() { + protoQuery.LastModifiedFrom = timestamppb.New(query.LastModifiedFrom) + } + if !query.LastModifiedTo.IsZero() { + protoQuery.LastModifiedTo = timestamppb.New(query.LastModifiedTo) + } + if query.PageSize > 0 { + protoQuery.PageSize = wrapperspb.Int32(query.PageSize) + } + if query.ContinuationToken != "" { + protoQuery.ContinuationToken = wrapperspb.String(query.ContinuationToken) + } + + resp, err := c.client.QueryEntities(ctx, &protos.QueryEntitiesRequest{Query: protoQuery}) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("failed to query entities: %w", err) + } + + results := &api.EntityQueryResults{ + ContinuationToken: resp.ContinuationToken.GetValue(), + } + for _, e := range resp.Entities { + entityID, parseErr := api.EntityIDFromString(e.InstanceId) + if parseErr != nil { + continue + } + meta := &api.EntityMetadata{ + InstanceID: entityID, + BacklogQueueSize: e.BacklogQueueSize, + LockedBy: e.LockedBy.GetValue(), + SerializedState: e.SerializedState.GetValue(), + } + if e.LastModifiedTime != nil { + meta.LastModifiedTime = e.LastModifiedTime.AsTime() + } + results.Entities = append(results.Entities, meta) + } + return results, nil +} + +// CleanEntityStorage performs garbage collection on entity storage. +func (c *TaskHubGrpcClient) CleanEntityStorage(ctx context.Context, req api.CleanEntityStorageRequest) (*api.CleanEntityStorageResult, error) { + protoReq := &protos.CleanEntityStorageRequest{ + RemoveEmptyEntities: req.RemoveEmptyEntities, + ReleaseOrphanedLocks: req.ReleaseOrphanedLocks, + } + if req.ContinuationToken != "" { + protoReq.ContinuationToken = wrapperspb.String(req.ContinuationToken) + } + + resp, err := c.client.CleanEntityStorage(ctx, protoReq) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("failed to clean entity storage: %w", err) + } + + return &api.CleanEntityStorageResult{ + EmptyEntitiesRemoved: resp.EmptyEntitiesRemoved, + OrphanedLocksReleased: resp.OrphanedLocksReleased, + ContinuationToken: resp.ContinuationToken.GetValue(), + }, nil +} + func makeGetInstanceRequest(id api.InstanceID, opts []api.FetchOrchestrationMetadataOptions) *protos.GetInstanceRequest { req := &protos.GetInstanceRequest{ InstanceId: string(id), diff --git a/client/worker_grpc.go b/client/worker_grpc.go index 69c1bea0..8e4f732e 100644 --- a/client/worker_grpc.go +++ b/client/worker_grpc.go @@ -121,6 +121,8 @@ func (c *TaskHubGrpcClient) StartWorkItemListener(ctx context.Context, r *task.T go c.processOrchestrationWorkItem(ctx, executor, orchReq) } else if actReq := workItem.GetActivityRequest(); actReq != nil { go c.processActivityWorkItem(ctx, executor, actReq) + } else if entityReq := workItem.GetEntityRequest(); entityReq != nil { + go c.processEntityWorkItem(ctx, executor, entityReq) } else { c.logger.Warnf("received unknown work item type: %v", workItem) } @@ -201,6 +203,37 @@ func (c *TaskHubGrpcClient) processActivityWorkItem( } } +func (c *TaskHubGrpcClient) processEntityWorkItem( + ctx context.Context, + executor backend.Executor, + req *protos.EntityBatchRequest, +) { + ee, ok := executor.(backend.EntityExecutor) + if !ok { + c.logger.Errorf("executor does not support entity execution") + return + } + + result, err := ee.ExecuteEntity(ctx, api.InstanceID(req.InstanceId), req) + + if err != nil { + result = &protos.EntityBatchResult{ + FailureDetails: &protos.TaskFailureDetails{ + ErrorType: fmt.Sprintf("%T", err), + ErrorMessage: err.Error(), + }, + } + } + + if _, err = c.client.CompleteEntityTask(ctx, result); err != nil { + if ctx.Err() != nil { + c.logger.Warn("failed to complete entity task: context canceled") + } else { + c.logger.Errorf("failed to complete entity task: %v", err) + } + } +} + func newInfiniteRetries() *backoff.ExponentialBackOff { b := backoff.NewExponentialBackOff() // max wait of 15 seconds between retries diff --git a/internal/protos/orchestrator_service.pb.go b/internal/protos/orchestrator_service.pb.go index 52eb6d21..68a89204 100644 --- a/internal/protos/orchestrator_service.pb.go +++ b/internal/protos/orchestrator_service.pb.go @@ -3,19 +3,19 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.31.0 -// protoc v3.12.4 +// protoc-gen-go v1.30.0 +// protoc v7.34.0 // source: orchestrator_service.proto package protos import ( - duration "google.golang.org/protobuf/types/known/durationpb" - empty "google.golang.org/protobuf/types/known/emptypb" - timestamp "google.golang.org/protobuf/types/known/timestamppb" - wrappers "google.golang.org/protobuf/types/known/wrapperspb" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + durationpb "google.golang.org/protobuf/types/known/durationpb" + emptypb "google.golang.org/protobuf/types/known/emptypb" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + wrapperspb "google.golang.org/protobuf/types/known/wrapperspb" reflect "reflect" sync "sync" ) @@ -145,8 +145,8 @@ type OrchestrationInstance struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` - ExecutionId *wrappers.StringValue `protobuf:"bytes,2,opt,name=executionId,proto3" json:"executionId,omitempty"` + InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` + ExecutionId *wrapperspb.StringValue `protobuf:"bytes,2,opt,name=executionId,proto3" json:"executionId,omitempty"` } func (x *OrchestrationInstance) Reset() { @@ -188,7 +188,7 @@ func (x *OrchestrationInstance) GetInstanceId() string { return "" } -func (x *OrchestrationInstance) GetExecutionId() *wrappers.StringValue { +func (x *OrchestrationInstance) GetExecutionId() *wrapperspb.StringValue { if x != nil { return x.ExecutionId } @@ -200,11 +200,11 @@ type ActivityRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Version *wrappers.StringValue `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` - Input *wrappers.StringValue `protobuf:"bytes,3,opt,name=input,proto3" json:"input,omitempty"` - OrchestrationInstance *OrchestrationInstance `protobuf:"bytes,4,opt,name=orchestrationInstance,proto3" json:"orchestrationInstance,omitempty"` - TaskId int32 `protobuf:"varint,5,opt,name=taskId,proto3" json:"taskId,omitempty"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Version *wrapperspb.StringValue `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` + Input *wrapperspb.StringValue `protobuf:"bytes,3,opt,name=input,proto3" json:"input,omitempty"` + OrchestrationInstance *OrchestrationInstance `protobuf:"bytes,4,opt,name=orchestrationInstance,proto3" json:"orchestrationInstance,omitempty"` + TaskId int32 `protobuf:"varint,5,opt,name=taskId,proto3" json:"taskId,omitempty"` } func (x *ActivityRequest) Reset() { @@ -246,14 +246,14 @@ func (x *ActivityRequest) GetName() string { return "" } -func (x *ActivityRequest) GetVersion() *wrappers.StringValue { +func (x *ActivityRequest) GetVersion() *wrapperspb.StringValue { if x != nil { return x.Version } return nil } -func (x *ActivityRequest) GetInput() *wrappers.StringValue { +func (x *ActivityRequest) GetInput() *wrapperspb.StringValue { if x != nil { return x.Input } @@ -279,10 +279,10 @@ type ActivityResponse struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` - TaskId int32 `protobuf:"varint,2,opt,name=taskId,proto3" json:"taskId,omitempty"` - Result *wrappers.StringValue `protobuf:"bytes,3,opt,name=result,proto3" json:"result,omitempty"` - FailureDetails *TaskFailureDetails `protobuf:"bytes,4,opt,name=failureDetails,proto3" json:"failureDetails,omitempty"` + InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` + TaskId int32 `protobuf:"varint,2,opt,name=taskId,proto3" json:"taskId,omitempty"` + Result *wrapperspb.StringValue `protobuf:"bytes,3,opt,name=result,proto3" json:"result,omitempty"` + FailureDetails *TaskFailureDetails `protobuf:"bytes,4,opt,name=failureDetails,proto3" json:"failureDetails,omitempty"` } func (x *ActivityResponse) Reset() { @@ -331,7 +331,7 @@ func (x *ActivityResponse) GetTaskId() int32 { return 0 } -func (x *ActivityResponse) GetResult() *wrappers.StringValue { +func (x *ActivityResponse) GetResult() *wrapperspb.StringValue { if x != nil { return x.Result } @@ -350,11 +350,11 @@ type TaskFailureDetails struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - ErrorType string `protobuf:"bytes,1,opt,name=errorType,proto3" json:"errorType,omitempty"` - ErrorMessage string `protobuf:"bytes,2,opt,name=errorMessage,proto3" json:"errorMessage,omitempty"` - StackTrace *wrappers.StringValue `protobuf:"bytes,3,opt,name=stackTrace,proto3" json:"stackTrace,omitempty"` - InnerFailure *TaskFailureDetails `protobuf:"bytes,4,opt,name=innerFailure,proto3" json:"innerFailure,omitempty"` - IsNonRetriable bool `protobuf:"varint,5,opt,name=isNonRetriable,proto3" json:"isNonRetriable,omitempty"` + ErrorType string `protobuf:"bytes,1,opt,name=errorType,proto3" json:"errorType,omitempty"` + ErrorMessage string `protobuf:"bytes,2,opt,name=errorMessage,proto3" json:"errorMessage,omitempty"` + StackTrace *wrapperspb.StringValue `protobuf:"bytes,3,opt,name=stackTrace,proto3" json:"stackTrace,omitempty"` + InnerFailure *TaskFailureDetails `protobuf:"bytes,4,opt,name=innerFailure,proto3" json:"innerFailure,omitempty"` + IsNonRetriable bool `protobuf:"varint,5,opt,name=isNonRetriable,proto3" json:"isNonRetriable,omitempty"` } func (x *TaskFailureDetails) Reset() { @@ -403,7 +403,7 @@ func (x *TaskFailureDetails) GetErrorMessage() string { return "" } -func (x *TaskFailureDetails) GetStackTrace() *wrappers.StringValue { +func (x *TaskFailureDetails) GetStackTrace() *wrapperspb.StringValue { if x != nil { return x.StackTrace } @@ -429,10 +429,10 @@ type ParentInstanceInfo struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - TaskScheduledId int32 `protobuf:"varint,1,opt,name=taskScheduledId,proto3" json:"taskScheduledId,omitempty"` - Name *wrappers.StringValue `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` - Version *wrappers.StringValue `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` - OrchestrationInstance *OrchestrationInstance `protobuf:"bytes,4,opt,name=orchestrationInstance,proto3" json:"orchestrationInstance,omitempty"` + TaskScheduledId int32 `protobuf:"varint,1,opt,name=taskScheduledId,proto3" json:"taskScheduledId,omitempty"` + Name *wrapperspb.StringValue `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Version *wrapperspb.StringValue `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` + OrchestrationInstance *OrchestrationInstance `protobuf:"bytes,4,opt,name=orchestrationInstance,proto3" json:"orchestrationInstance,omitempty"` } func (x *ParentInstanceInfo) Reset() { @@ -474,14 +474,14 @@ func (x *ParentInstanceInfo) GetTaskScheduledId() int32 { return 0 } -func (x *ParentInstanceInfo) GetName() *wrappers.StringValue { +func (x *ParentInstanceInfo) GetName() *wrapperspb.StringValue { if x != nil { return x.Name } return nil } -func (x *ParentInstanceInfo) GetVersion() *wrappers.StringValue { +func (x *ParentInstanceInfo) GetVersion() *wrapperspb.StringValue { if x != nil { return x.Version } @@ -502,8 +502,8 @@ type TraceContext struct { TraceParent string `protobuf:"bytes,1,opt,name=traceParent,proto3" json:"traceParent,omitempty"` // Deprecated: Marked as deprecated in orchestrator_service.proto. - SpanID string `protobuf:"bytes,2,opt,name=spanID,proto3" json:"spanID,omitempty"` - TraceState *wrappers.StringValue `protobuf:"bytes,3,opt,name=traceState,proto3" json:"traceState,omitempty"` + SpanID string `protobuf:"bytes,2,opt,name=spanID,proto3" json:"spanID,omitempty"` + TraceState *wrapperspb.StringValue `protobuf:"bytes,3,opt,name=traceState,proto3" json:"traceState,omitempty"` } func (x *TraceContext) Reset() { @@ -553,7 +553,7 @@ func (x *TraceContext) GetSpanID() string { return "" } -func (x *TraceContext) GetTraceState() *wrappers.StringValue { +func (x *TraceContext) GetTraceState() *wrapperspb.StringValue { if x != nil { return x.TraceState } @@ -565,14 +565,14 @@ type ExecutionStartedEvent struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Version *wrappers.StringValue `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` - Input *wrappers.StringValue `protobuf:"bytes,3,opt,name=input,proto3" json:"input,omitempty"` - OrchestrationInstance *OrchestrationInstance `protobuf:"bytes,4,opt,name=orchestrationInstance,proto3" json:"orchestrationInstance,omitempty"` - ParentInstance *ParentInstanceInfo `protobuf:"bytes,5,opt,name=parentInstance,proto3" json:"parentInstance,omitempty"` - ScheduledStartTimestamp *timestamp.Timestamp `protobuf:"bytes,6,opt,name=scheduledStartTimestamp,proto3" json:"scheduledStartTimestamp,omitempty"` - ParentTraceContext *TraceContext `protobuf:"bytes,7,opt,name=parentTraceContext,proto3" json:"parentTraceContext,omitempty"` - OrchestrationSpanID *wrappers.StringValue `protobuf:"bytes,8,opt,name=orchestrationSpanID,proto3" json:"orchestrationSpanID,omitempty"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Version *wrapperspb.StringValue `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` + Input *wrapperspb.StringValue `protobuf:"bytes,3,opt,name=input,proto3" json:"input,omitempty"` + OrchestrationInstance *OrchestrationInstance `protobuf:"bytes,4,opt,name=orchestrationInstance,proto3" json:"orchestrationInstance,omitempty"` + ParentInstance *ParentInstanceInfo `protobuf:"bytes,5,opt,name=parentInstance,proto3" json:"parentInstance,omitempty"` + ScheduledStartTimestamp *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=scheduledStartTimestamp,proto3" json:"scheduledStartTimestamp,omitempty"` + ParentTraceContext *TraceContext `protobuf:"bytes,7,opt,name=parentTraceContext,proto3" json:"parentTraceContext,omitempty"` + OrchestrationSpanID *wrapperspb.StringValue `protobuf:"bytes,8,opt,name=orchestrationSpanID,proto3" json:"orchestrationSpanID,omitempty"` } func (x *ExecutionStartedEvent) Reset() { @@ -614,14 +614,14 @@ func (x *ExecutionStartedEvent) GetName() string { return "" } -func (x *ExecutionStartedEvent) GetVersion() *wrappers.StringValue { +func (x *ExecutionStartedEvent) GetVersion() *wrapperspb.StringValue { if x != nil { return x.Version } return nil } -func (x *ExecutionStartedEvent) GetInput() *wrappers.StringValue { +func (x *ExecutionStartedEvent) GetInput() *wrapperspb.StringValue { if x != nil { return x.Input } @@ -642,7 +642,7 @@ func (x *ExecutionStartedEvent) GetParentInstance() *ParentInstanceInfo { return nil } -func (x *ExecutionStartedEvent) GetScheduledStartTimestamp() *timestamp.Timestamp { +func (x *ExecutionStartedEvent) GetScheduledStartTimestamp() *timestamppb.Timestamp { if x != nil { return x.ScheduledStartTimestamp } @@ -656,7 +656,7 @@ func (x *ExecutionStartedEvent) GetParentTraceContext() *TraceContext { return nil } -func (x *ExecutionStartedEvent) GetOrchestrationSpanID() *wrappers.StringValue { +func (x *ExecutionStartedEvent) GetOrchestrationSpanID() *wrapperspb.StringValue { if x != nil { return x.OrchestrationSpanID } @@ -668,9 +668,9 @@ type ExecutionCompletedEvent struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - OrchestrationStatus OrchestrationStatus `protobuf:"varint,1,opt,name=orchestrationStatus,proto3,enum=OrchestrationStatus" json:"orchestrationStatus,omitempty"` - Result *wrappers.StringValue `protobuf:"bytes,2,opt,name=result,proto3" json:"result,omitempty"` - FailureDetails *TaskFailureDetails `protobuf:"bytes,3,opt,name=failureDetails,proto3" json:"failureDetails,omitempty"` + OrchestrationStatus OrchestrationStatus `protobuf:"varint,1,opt,name=orchestrationStatus,proto3,enum=OrchestrationStatus" json:"orchestrationStatus,omitempty"` + Result *wrapperspb.StringValue `protobuf:"bytes,2,opt,name=result,proto3" json:"result,omitempty"` + FailureDetails *TaskFailureDetails `protobuf:"bytes,3,opt,name=failureDetails,proto3" json:"failureDetails,omitempty"` } func (x *ExecutionCompletedEvent) Reset() { @@ -712,7 +712,7 @@ func (x *ExecutionCompletedEvent) GetOrchestrationStatus() OrchestrationStatus { return OrchestrationStatus_ORCHESTRATION_STATUS_RUNNING } -func (x *ExecutionCompletedEvent) GetResult() *wrappers.StringValue { +func (x *ExecutionCompletedEvent) GetResult() *wrapperspb.StringValue { if x != nil { return x.Result } @@ -731,8 +731,8 @@ type ExecutionTerminatedEvent struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Input *wrappers.StringValue `protobuf:"bytes,1,opt,name=input,proto3" json:"input,omitempty"` - Recurse bool `protobuf:"varint,2,opt,name=recurse,proto3" json:"recurse,omitempty"` + Input *wrapperspb.StringValue `protobuf:"bytes,1,opt,name=input,proto3" json:"input,omitempty"` + Recurse bool `protobuf:"varint,2,opt,name=recurse,proto3" json:"recurse,omitempty"` } func (x *ExecutionTerminatedEvent) Reset() { @@ -767,7 +767,7 @@ func (*ExecutionTerminatedEvent) Descriptor() ([]byte, []int) { return file_orchestrator_service_proto_rawDescGZIP(), []int{8} } -func (x *ExecutionTerminatedEvent) GetInput() *wrappers.StringValue { +func (x *ExecutionTerminatedEvent) GetInput() *wrapperspb.StringValue { if x != nil { return x.Input } @@ -786,10 +786,10 @@ type TaskScheduledEvent struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Version *wrappers.StringValue `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` - Input *wrappers.StringValue `protobuf:"bytes,3,opt,name=input,proto3" json:"input,omitempty"` - ParentTraceContext *TraceContext `protobuf:"bytes,4,opt,name=parentTraceContext,proto3" json:"parentTraceContext,omitempty"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Version *wrapperspb.StringValue `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` + Input *wrapperspb.StringValue `protobuf:"bytes,3,opt,name=input,proto3" json:"input,omitempty"` + ParentTraceContext *TraceContext `protobuf:"bytes,4,opt,name=parentTraceContext,proto3" json:"parentTraceContext,omitempty"` } func (x *TaskScheduledEvent) Reset() { @@ -831,14 +831,14 @@ func (x *TaskScheduledEvent) GetName() string { return "" } -func (x *TaskScheduledEvent) GetVersion() *wrappers.StringValue { +func (x *TaskScheduledEvent) GetVersion() *wrapperspb.StringValue { if x != nil { return x.Version } return nil } -func (x *TaskScheduledEvent) GetInput() *wrappers.StringValue { +func (x *TaskScheduledEvent) GetInput() *wrapperspb.StringValue { if x != nil { return x.Input } @@ -857,8 +857,8 @@ type TaskCompletedEvent struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - TaskScheduledId int32 `protobuf:"varint,1,opt,name=taskScheduledId,proto3" json:"taskScheduledId,omitempty"` - Result *wrappers.StringValue `protobuf:"bytes,2,opt,name=result,proto3" json:"result,omitempty"` + TaskScheduledId int32 `protobuf:"varint,1,opt,name=taskScheduledId,proto3" json:"taskScheduledId,omitempty"` + Result *wrapperspb.StringValue `protobuf:"bytes,2,opt,name=result,proto3" json:"result,omitempty"` } func (x *TaskCompletedEvent) Reset() { @@ -900,7 +900,7 @@ func (x *TaskCompletedEvent) GetTaskScheduledId() int32 { return 0 } -func (x *TaskCompletedEvent) GetResult() *wrappers.StringValue { +func (x *TaskCompletedEvent) GetResult() *wrapperspb.StringValue { if x != nil { return x.Result } @@ -967,11 +967,11 @@ type SubOrchestrationInstanceCreatedEvent struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` - Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` - Version *wrappers.StringValue `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` - Input *wrappers.StringValue `protobuf:"bytes,4,opt,name=input,proto3" json:"input,omitempty"` - ParentTraceContext *TraceContext `protobuf:"bytes,5,opt,name=parentTraceContext,proto3" json:"parentTraceContext,omitempty"` + InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Version *wrapperspb.StringValue `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` + Input *wrapperspb.StringValue `protobuf:"bytes,4,opt,name=input,proto3" json:"input,omitempty"` + ParentTraceContext *TraceContext `protobuf:"bytes,5,opt,name=parentTraceContext,proto3" json:"parentTraceContext,omitempty"` } func (x *SubOrchestrationInstanceCreatedEvent) Reset() { @@ -1020,14 +1020,14 @@ func (x *SubOrchestrationInstanceCreatedEvent) GetName() string { return "" } -func (x *SubOrchestrationInstanceCreatedEvent) GetVersion() *wrappers.StringValue { +func (x *SubOrchestrationInstanceCreatedEvent) GetVersion() *wrapperspb.StringValue { if x != nil { return x.Version } return nil } -func (x *SubOrchestrationInstanceCreatedEvent) GetInput() *wrappers.StringValue { +func (x *SubOrchestrationInstanceCreatedEvent) GetInput() *wrapperspb.StringValue { if x != nil { return x.Input } @@ -1046,8 +1046,8 @@ type SubOrchestrationInstanceCompletedEvent struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - TaskScheduledId int32 `protobuf:"varint,1,opt,name=taskScheduledId,proto3" json:"taskScheduledId,omitempty"` - Result *wrappers.StringValue `protobuf:"bytes,2,opt,name=result,proto3" json:"result,omitempty"` + TaskScheduledId int32 `protobuf:"varint,1,opt,name=taskScheduledId,proto3" json:"taskScheduledId,omitempty"` + Result *wrapperspb.StringValue `protobuf:"bytes,2,opt,name=result,proto3" json:"result,omitempty"` } func (x *SubOrchestrationInstanceCompletedEvent) Reset() { @@ -1089,7 +1089,7 @@ func (x *SubOrchestrationInstanceCompletedEvent) GetTaskScheduledId() int32 { return 0 } -func (x *SubOrchestrationInstanceCompletedEvent) GetResult() *wrappers.StringValue { +func (x *SubOrchestrationInstanceCompletedEvent) GetResult() *wrapperspb.StringValue { if x != nil { return x.Result } @@ -1156,7 +1156,7 @@ type TimerCreatedEvent struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - FireAt *timestamp.Timestamp `protobuf:"bytes,1,opt,name=fireAt,proto3" json:"fireAt,omitempty"` + FireAt *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=fireAt,proto3" json:"fireAt,omitempty"` } func (x *TimerCreatedEvent) Reset() { @@ -1191,7 +1191,7 @@ func (*TimerCreatedEvent) Descriptor() ([]byte, []int) { return file_orchestrator_service_proto_rawDescGZIP(), []int{15} } -func (x *TimerCreatedEvent) GetFireAt() *timestamp.Timestamp { +func (x *TimerCreatedEvent) GetFireAt() *timestamppb.Timestamp { if x != nil { return x.FireAt } @@ -1203,8 +1203,8 @@ type TimerFiredEvent struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - FireAt *timestamp.Timestamp `protobuf:"bytes,1,opt,name=fireAt,proto3" json:"fireAt,omitempty"` - TimerId int32 `protobuf:"varint,2,opt,name=timerId,proto3" json:"timerId,omitempty"` + FireAt *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=fireAt,proto3" json:"fireAt,omitempty"` + TimerId int32 `protobuf:"varint,2,opt,name=timerId,proto3" json:"timerId,omitempty"` } func (x *TimerFiredEvent) Reset() { @@ -1239,7 +1239,7 @@ func (*TimerFiredEvent) Descriptor() ([]byte, []int) { return file_orchestrator_service_proto_rawDescGZIP(), []int{16} } -func (x *TimerFiredEvent) GetFireAt() *timestamp.Timestamp { +func (x *TimerFiredEvent) GetFireAt() *timestamppb.Timestamp { if x != nil { return x.FireAt } @@ -1334,9 +1334,9 @@ type EventSentEvent struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` - Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` - Input *wrappers.StringValue `protobuf:"bytes,3,opt,name=input,proto3" json:"input,omitempty"` + InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Input *wrapperspb.StringValue `protobuf:"bytes,3,opt,name=input,proto3" json:"input,omitempty"` } func (x *EventSentEvent) Reset() { @@ -1385,7 +1385,7 @@ func (x *EventSentEvent) GetName() string { return "" } -func (x *EventSentEvent) GetInput() *wrappers.StringValue { +func (x *EventSentEvent) GetInput() *wrapperspb.StringValue { if x != nil { return x.Input } @@ -1397,8 +1397,8 @@ type EventRaisedEvent struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Input *wrappers.StringValue `protobuf:"bytes,2,opt,name=input,proto3" json:"input,omitempty"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Input *wrapperspb.StringValue `protobuf:"bytes,2,opt,name=input,proto3" json:"input,omitempty"` } func (x *EventRaisedEvent) Reset() { @@ -1440,7 +1440,7 @@ func (x *EventRaisedEvent) GetName() string { return "" } -func (x *EventRaisedEvent) GetInput() *wrappers.StringValue { +func (x *EventRaisedEvent) GetInput() *wrapperspb.StringValue { if x != nil { return x.Input } @@ -1452,7 +1452,7 @@ type GenericEvent struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Data *wrappers.StringValue `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + Data *wrapperspb.StringValue `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` } func (x *GenericEvent) Reset() { @@ -1487,7 +1487,7 @@ func (*GenericEvent) Descriptor() ([]byte, []int) { return file_orchestrator_service_proto_rawDescGZIP(), []int{21} } -func (x *GenericEvent) GetData() *wrappers.StringValue { +func (x *GenericEvent) GetData() *wrapperspb.StringValue { if x != nil { return x.Data } @@ -1546,7 +1546,7 @@ type ContinueAsNewEvent struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Input *wrappers.StringValue `protobuf:"bytes,1,opt,name=input,proto3" json:"input,omitempty"` + Input *wrapperspb.StringValue `protobuf:"bytes,1,opt,name=input,proto3" json:"input,omitempty"` } func (x *ContinueAsNewEvent) Reset() { @@ -1581,7 +1581,7 @@ func (*ContinueAsNewEvent) Descriptor() ([]byte, []int) { return file_orchestrator_service_proto_rawDescGZIP(), []int{23} } -func (x *ContinueAsNewEvent) GetInput() *wrappers.StringValue { +func (x *ContinueAsNewEvent) GetInput() *wrapperspb.StringValue { if x != nil { return x.Input } @@ -1593,7 +1593,7 @@ type ExecutionSuspendedEvent struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Input *wrappers.StringValue `protobuf:"bytes,1,opt,name=input,proto3" json:"input,omitempty"` + Input *wrapperspb.StringValue `protobuf:"bytes,1,opt,name=input,proto3" json:"input,omitempty"` } func (x *ExecutionSuspendedEvent) Reset() { @@ -1628,7 +1628,7 @@ func (*ExecutionSuspendedEvent) Descriptor() ([]byte, []int) { return file_orchestrator_service_proto_rawDescGZIP(), []int{24} } -func (x *ExecutionSuspendedEvent) GetInput() *wrappers.StringValue { +func (x *ExecutionSuspendedEvent) GetInput() *wrapperspb.StringValue { if x != nil { return x.Input } @@ -1640,7 +1640,7 @@ type ExecutionResumedEvent struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Input *wrappers.StringValue `protobuf:"bytes,1,opt,name=input,proto3" json:"input,omitempty"` + Input *wrapperspb.StringValue `protobuf:"bytes,1,opt,name=input,proto3" json:"input,omitempty"` } func (x *ExecutionResumedEvent) Reset() { @@ -1675,7 +1675,7 @@ func (*ExecutionResumedEvent) Descriptor() ([]byte, []int) { return file_orchestrator_service_proto_rawDescGZIP(), []int{25} } -func (x *ExecutionResumedEvent) GetInput() *wrappers.StringValue { +func (x *ExecutionResumedEvent) GetInput() *wrapperspb.StringValue { if x != nil { return x.Input } @@ -1687,8 +1687,8 @@ type HistoryEvent struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - EventId int32 `protobuf:"varint,1,opt,name=eventId,proto3" json:"eventId,omitempty"` - Timestamp *timestamp.Timestamp `protobuf:"bytes,2,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + EventId int32 `protobuf:"varint,1,opt,name=eventId,proto3" json:"eventId,omitempty"` + Timestamp *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=timestamp,proto3" json:"timestamp,omitempty"` // Types that are assignable to EventType: // // *HistoryEvent_ExecutionStarted @@ -1753,7 +1753,7 @@ func (x *HistoryEvent) GetEventId() int32 { return 0 } -func (x *HistoryEvent) GetTimestamp() *timestamp.Timestamp { +func (x *HistoryEvent) GetTimestamp() *timestamppb.Timestamp { if x != nil { return x.Timestamp } @@ -2036,9 +2036,9 @@ type ScheduleTaskAction struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Version *wrappers.StringValue `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` - Input *wrappers.StringValue `protobuf:"bytes,3,opt,name=input,proto3" json:"input,omitempty"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Version *wrapperspb.StringValue `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` + Input *wrapperspb.StringValue `protobuf:"bytes,3,opt,name=input,proto3" json:"input,omitempty"` } func (x *ScheduleTaskAction) Reset() { @@ -2080,14 +2080,14 @@ func (x *ScheduleTaskAction) GetName() string { return "" } -func (x *ScheduleTaskAction) GetVersion() *wrappers.StringValue { +func (x *ScheduleTaskAction) GetVersion() *wrapperspb.StringValue { if x != nil { return x.Version } return nil } -func (x *ScheduleTaskAction) GetInput() *wrappers.StringValue { +func (x *ScheduleTaskAction) GetInput() *wrapperspb.StringValue { if x != nil { return x.Input } @@ -2099,10 +2099,10 @@ type CreateSubOrchestrationAction struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` - Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` - Version *wrappers.StringValue `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` - Input *wrappers.StringValue `protobuf:"bytes,4,opt,name=input,proto3" json:"input,omitempty"` + InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Version *wrapperspb.StringValue `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` + Input *wrapperspb.StringValue `protobuf:"bytes,4,opt,name=input,proto3" json:"input,omitempty"` } func (x *CreateSubOrchestrationAction) Reset() { @@ -2151,14 +2151,14 @@ func (x *CreateSubOrchestrationAction) GetName() string { return "" } -func (x *CreateSubOrchestrationAction) GetVersion() *wrappers.StringValue { +func (x *CreateSubOrchestrationAction) GetVersion() *wrapperspb.StringValue { if x != nil { return x.Version } return nil } -func (x *CreateSubOrchestrationAction) GetInput() *wrappers.StringValue { +func (x *CreateSubOrchestrationAction) GetInput() *wrapperspb.StringValue { if x != nil { return x.Input } @@ -2170,7 +2170,7 @@ type CreateTimerAction struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - FireAt *timestamp.Timestamp `protobuf:"bytes,1,opt,name=fireAt,proto3" json:"fireAt,omitempty"` + FireAt *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=fireAt,proto3" json:"fireAt,omitempty"` } func (x *CreateTimerAction) Reset() { @@ -2205,7 +2205,7 @@ func (*CreateTimerAction) Descriptor() ([]byte, []int) { return file_orchestrator_service_proto_rawDescGZIP(), []int{29} } -func (x *CreateTimerAction) GetFireAt() *timestamp.Timestamp { +func (x *CreateTimerAction) GetFireAt() *timestamppb.Timestamp { if x != nil { return x.FireAt } @@ -2217,9 +2217,9 @@ type SendEventAction struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Instance *OrchestrationInstance `protobuf:"bytes,1,opt,name=instance,proto3" json:"instance,omitempty"` - Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` - Data *wrappers.StringValue `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"` + Instance *OrchestrationInstance `protobuf:"bytes,1,opt,name=instance,proto3" json:"instance,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Data *wrapperspb.StringValue `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"` } func (x *SendEventAction) Reset() { @@ -2268,7 +2268,7 @@ func (x *SendEventAction) GetName() string { return "" } -func (x *SendEventAction) GetData() *wrappers.StringValue { +func (x *SendEventAction) GetData() *wrapperspb.StringValue { if x != nil { return x.Data } @@ -2280,12 +2280,12 @@ type CompleteOrchestrationAction struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - OrchestrationStatus OrchestrationStatus `protobuf:"varint,1,opt,name=orchestrationStatus,proto3,enum=OrchestrationStatus" json:"orchestrationStatus,omitempty"` - Result *wrappers.StringValue `protobuf:"bytes,2,opt,name=result,proto3" json:"result,omitempty"` - Details *wrappers.StringValue `protobuf:"bytes,3,opt,name=details,proto3" json:"details,omitempty"` - NewVersion *wrappers.StringValue `protobuf:"bytes,4,opt,name=newVersion,proto3" json:"newVersion,omitempty"` - CarryoverEvents []*HistoryEvent `protobuf:"bytes,5,rep,name=carryoverEvents,proto3" json:"carryoverEvents,omitempty"` - FailureDetails *TaskFailureDetails `protobuf:"bytes,6,opt,name=failureDetails,proto3" json:"failureDetails,omitempty"` + OrchestrationStatus OrchestrationStatus `protobuf:"varint,1,opt,name=orchestrationStatus,proto3,enum=OrchestrationStatus" json:"orchestrationStatus,omitempty"` + Result *wrapperspb.StringValue `protobuf:"bytes,2,opt,name=result,proto3" json:"result,omitempty"` + Details *wrapperspb.StringValue `protobuf:"bytes,3,opt,name=details,proto3" json:"details,omitempty"` + NewVersion *wrapperspb.StringValue `protobuf:"bytes,4,opt,name=newVersion,proto3" json:"newVersion,omitempty"` + CarryoverEvents []*HistoryEvent `protobuf:"bytes,5,rep,name=carryoverEvents,proto3" json:"carryoverEvents,omitempty"` + FailureDetails *TaskFailureDetails `protobuf:"bytes,6,opt,name=failureDetails,proto3" json:"failureDetails,omitempty"` } func (x *CompleteOrchestrationAction) Reset() { @@ -2327,21 +2327,21 @@ func (x *CompleteOrchestrationAction) GetOrchestrationStatus() OrchestrationStat return OrchestrationStatus_ORCHESTRATION_STATUS_RUNNING } -func (x *CompleteOrchestrationAction) GetResult() *wrappers.StringValue { +func (x *CompleteOrchestrationAction) GetResult() *wrapperspb.StringValue { if x != nil { return x.Result } return nil } -func (x *CompleteOrchestrationAction) GetDetails() *wrappers.StringValue { +func (x *CompleteOrchestrationAction) GetDetails() *wrapperspb.StringValue { if x != nil { return x.Details } return nil } -func (x *CompleteOrchestrationAction) GetNewVersion() *wrappers.StringValue { +func (x *CompleteOrchestrationAction) GetNewVersion() *wrapperspb.StringValue { if x != nil { return x.NewVersion } @@ -2367,9 +2367,9 @@ type TerminateOrchestrationAction struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` - Reason *wrappers.StringValue `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` - Recurse bool `protobuf:"varint,3,opt,name=recurse,proto3" json:"recurse,omitempty"` + InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` + Reason *wrapperspb.StringValue `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` + Recurse bool `protobuf:"varint,3,opt,name=recurse,proto3" json:"recurse,omitempty"` } func (x *TerminateOrchestrationAction) Reset() { @@ -2411,7 +2411,7 @@ func (x *TerminateOrchestrationAction) GetInstanceId() string { return "" } -func (x *TerminateOrchestrationAction) GetReason() *wrappers.StringValue { +func (x *TerminateOrchestrationAction) GetReason() *wrapperspb.StringValue { if x != nil { return x.Reason } @@ -2576,7 +2576,7 @@ type OrchestratorRequest struct { unknownFields protoimpl.UnknownFields InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` - ExecutionId *wrappers.StringValue `protobuf:"bytes,2,opt,name=executionId,proto3" json:"executionId,omitempty"` + ExecutionId *wrapperspb.StringValue `protobuf:"bytes,2,opt,name=executionId,proto3" json:"executionId,omitempty"` PastEvents []*HistoryEvent `protobuf:"bytes,3,rep,name=pastEvents,proto3" json:"pastEvents,omitempty"` NewEvents []*HistoryEvent `protobuf:"bytes,4,rep,name=newEvents,proto3" json:"newEvents,omitempty"` EntityParameters *OrchestratorEntityParameters `protobuf:"bytes,5,opt,name=entityParameters,proto3" json:"entityParameters,omitempty"` @@ -2621,7 +2621,7 @@ func (x *OrchestratorRequest) GetInstanceId() string { return "" } -func (x *OrchestratorRequest) GetExecutionId() *wrappers.StringValue { +func (x *OrchestratorRequest) GetExecutionId() *wrapperspb.StringValue { if x != nil { return x.ExecutionId } @@ -2654,9 +2654,9 @@ type OrchestratorResponse struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` - Actions []*OrchestratorAction `protobuf:"bytes,2,rep,name=actions,proto3" json:"actions,omitempty"` - CustomStatus *wrappers.StringValue `protobuf:"bytes,3,opt,name=customStatus,proto3" json:"customStatus,omitempty"` + InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` + Actions []*OrchestratorAction `protobuf:"bytes,2,rep,name=actions,proto3" json:"actions,omitempty"` + CustomStatus *wrapperspb.StringValue `protobuf:"bytes,3,opt,name=customStatus,proto3" json:"customStatus,omitempty"` } func (x *OrchestratorResponse) Reset() { @@ -2705,7 +2705,7 @@ func (x *OrchestratorResponse) GetActions() []*OrchestratorAction { return nil } -func (x *OrchestratorResponse) GetCustomStatus() *wrappers.StringValue { +func (x *OrchestratorResponse) GetCustomStatus() *wrapperspb.StringValue { if x != nil { return x.CustomStatus } @@ -2719,9 +2719,9 @@ type CreateInstanceRequest struct { InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` - Version *wrappers.StringValue `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` - Input *wrappers.StringValue `protobuf:"bytes,4,opt,name=input,proto3" json:"input,omitempty"` - ScheduledStartTimestamp *timestamp.Timestamp `protobuf:"bytes,5,opt,name=scheduledStartTimestamp,proto3" json:"scheduledStartTimestamp,omitempty"` + Version *wrapperspb.StringValue `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` + Input *wrapperspb.StringValue `protobuf:"bytes,4,opt,name=input,proto3" json:"input,omitempty"` + ScheduledStartTimestamp *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=scheduledStartTimestamp,proto3" json:"scheduledStartTimestamp,omitempty"` OrchestrationIdReusePolicy *OrchestrationIdReusePolicy `protobuf:"bytes,6,opt,name=orchestrationIdReusePolicy,proto3" json:"orchestrationIdReusePolicy,omitempty"` } @@ -2771,21 +2771,21 @@ func (x *CreateInstanceRequest) GetName() string { return "" } -func (x *CreateInstanceRequest) GetVersion() *wrappers.StringValue { +func (x *CreateInstanceRequest) GetVersion() *wrapperspb.StringValue { if x != nil { return x.Version } return nil } -func (x *CreateInstanceRequest) GetInput() *wrappers.StringValue { +func (x *CreateInstanceRequest) GetInput() *wrapperspb.StringValue { if x != nil { return x.Input } return nil } -func (x *CreateInstanceRequest) GetScheduledStartTimestamp() *timestamp.Timestamp { +func (x *CreateInstanceRequest) GetScheduledStartTimestamp() *timestamppb.Timestamp { if x != nil { return x.ScheduledStartTimestamp } @@ -3016,8 +3016,8 @@ type RewindInstanceRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` - Reason *wrappers.StringValue `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` + InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` + Reason *wrapperspb.StringValue `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` } func (x *RewindInstanceRequest) Reset() { @@ -3059,7 +3059,7 @@ func (x *RewindInstanceRequest) GetInstanceId() string { return "" } -func (x *RewindInstanceRequest) GetReason() *wrappers.StringValue { +func (x *RewindInstanceRequest) GetReason() *wrapperspb.StringValue { if x != nil { return x.Reason } @@ -3109,17 +3109,17 @@ type OrchestrationState struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` - Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` - Version *wrappers.StringValue `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` - OrchestrationStatus OrchestrationStatus `protobuf:"varint,4,opt,name=orchestrationStatus,proto3,enum=OrchestrationStatus" json:"orchestrationStatus,omitempty"` - ScheduledStartTimestamp *timestamp.Timestamp `protobuf:"bytes,5,opt,name=scheduledStartTimestamp,proto3" json:"scheduledStartTimestamp,omitempty"` - CreatedTimestamp *timestamp.Timestamp `protobuf:"bytes,6,opt,name=createdTimestamp,proto3" json:"createdTimestamp,omitempty"` - LastUpdatedTimestamp *timestamp.Timestamp `protobuf:"bytes,7,opt,name=lastUpdatedTimestamp,proto3" json:"lastUpdatedTimestamp,omitempty"` - Input *wrappers.StringValue `protobuf:"bytes,8,opt,name=input,proto3" json:"input,omitempty"` - Output *wrappers.StringValue `protobuf:"bytes,9,opt,name=output,proto3" json:"output,omitempty"` - CustomStatus *wrappers.StringValue `protobuf:"bytes,10,opt,name=customStatus,proto3" json:"customStatus,omitempty"` - FailureDetails *TaskFailureDetails `protobuf:"bytes,11,opt,name=failureDetails,proto3" json:"failureDetails,omitempty"` + InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Version *wrapperspb.StringValue `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` + OrchestrationStatus OrchestrationStatus `protobuf:"varint,4,opt,name=orchestrationStatus,proto3,enum=OrchestrationStatus" json:"orchestrationStatus,omitempty"` + ScheduledStartTimestamp *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=scheduledStartTimestamp,proto3" json:"scheduledStartTimestamp,omitempty"` + CreatedTimestamp *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=createdTimestamp,proto3" json:"createdTimestamp,omitempty"` + LastUpdatedTimestamp *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=lastUpdatedTimestamp,proto3" json:"lastUpdatedTimestamp,omitempty"` + Input *wrapperspb.StringValue `protobuf:"bytes,8,opt,name=input,proto3" json:"input,omitempty"` + Output *wrapperspb.StringValue `protobuf:"bytes,9,opt,name=output,proto3" json:"output,omitempty"` + CustomStatus *wrapperspb.StringValue `protobuf:"bytes,10,opt,name=customStatus,proto3" json:"customStatus,omitempty"` + FailureDetails *TaskFailureDetails `protobuf:"bytes,11,opt,name=failureDetails,proto3" json:"failureDetails,omitempty"` } func (x *OrchestrationState) Reset() { @@ -3168,7 +3168,7 @@ func (x *OrchestrationState) GetName() string { return "" } -func (x *OrchestrationState) GetVersion() *wrappers.StringValue { +func (x *OrchestrationState) GetVersion() *wrapperspb.StringValue { if x != nil { return x.Version } @@ -3182,42 +3182,42 @@ func (x *OrchestrationState) GetOrchestrationStatus() OrchestrationStatus { return OrchestrationStatus_ORCHESTRATION_STATUS_RUNNING } -func (x *OrchestrationState) GetScheduledStartTimestamp() *timestamp.Timestamp { +func (x *OrchestrationState) GetScheduledStartTimestamp() *timestamppb.Timestamp { if x != nil { return x.ScheduledStartTimestamp } return nil } -func (x *OrchestrationState) GetCreatedTimestamp() *timestamp.Timestamp { +func (x *OrchestrationState) GetCreatedTimestamp() *timestamppb.Timestamp { if x != nil { return x.CreatedTimestamp } return nil } -func (x *OrchestrationState) GetLastUpdatedTimestamp() *timestamp.Timestamp { +func (x *OrchestrationState) GetLastUpdatedTimestamp() *timestamppb.Timestamp { if x != nil { return x.LastUpdatedTimestamp } return nil } -func (x *OrchestrationState) GetInput() *wrappers.StringValue { +func (x *OrchestrationState) GetInput() *wrapperspb.StringValue { if x != nil { return x.Input } return nil } -func (x *OrchestrationState) GetOutput() *wrappers.StringValue { +func (x *OrchestrationState) GetOutput() *wrapperspb.StringValue { if x != nil { return x.Output } return nil } -func (x *OrchestrationState) GetCustomStatus() *wrappers.StringValue { +func (x *OrchestrationState) GetCustomStatus() *wrapperspb.StringValue { if x != nil { return x.CustomStatus } @@ -3236,9 +3236,9 @@ type RaiseEventRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` - Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` - Input *wrappers.StringValue `protobuf:"bytes,3,opt,name=input,proto3" json:"input,omitempty"` + InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Input *wrapperspb.StringValue `protobuf:"bytes,3,opt,name=input,proto3" json:"input,omitempty"` } func (x *RaiseEventRequest) Reset() { @@ -3287,7 +3287,7 @@ func (x *RaiseEventRequest) GetName() string { return "" } -func (x *RaiseEventRequest) GetInput() *wrappers.StringValue { +func (x *RaiseEventRequest) GetInput() *wrapperspb.StringValue { if x != nil { return x.Input } @@ -3337,9 +3337,9 @@ type TerminateRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` - Output *wrappers.StringValue `protobuf:"bytes,2,opt,name=output,proto3" json:"output,omitempty"` - Recursive bool `protobuf:"varint,3,opt,name=recursive,proto3" json:"recursive,omitempty"` + InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` + Output *wrapperspb.StringValue `protobuf:"bytes,2,opt,name=output,proto3" json:"output,omitempty"` + Recursive bool `protobuf:"varint,3,opt,name=recursive,proto3" json:"recursive,omitempty"` } func (x *TerminateRequest) Reset() { @@ -3381,7 +3381,7 @@ func (x *TerminateRequest) GetInstanceId() string { return "" } -func (x *TerminateRequest) GetOutput() *wrappers.StringValue { +func (x *TerminateRequest) GetOutput() *wrapperspb.StringValue { if x != nil { return x.Output } @@ -3438,8 +3438,8 @@ type SuspendRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` - Reason *wrappers.StringValue `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` + InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` + Reason *wrapperspb.StringValue `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` } func (x *SuspendRequest) Reset() { @@ -3481,7 +3481,7 @@ func (x *SuspendRequest) GetInstanceId() string { return "" } -func (x *SuspendRequest) GetReason() *wrappers.StringValue { +func (x *SuspendRequest) GetReason() *wrapperspb.StringValue { if x != nil { return x.Reason } @@ -3531,8 +3531,8 @@ type ResumeRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` - Reason *wrappers.StringValue `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` + InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` + Reason *wrapperspb.StringValue `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` } func (x *ResumeRequest) Reset() { @@ -3574,7 +3574,7 @@ func (x *ResumeRequest) GetInstanceId() string { return "" } -func (x *ResumeRequest) GetReason() *wrappers.StringValue { +func (x *ResumeRequest) GetReason() *wrapperspb.StringValue { if x != nil { return x.Reason } @@ -3671,14 +3671,14 @@ type InstanceQuery struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - RuntimeStatus []OrchestrationStatus `protobuf:"varint,1,rep,packed,name=runtimeStatus,proto3,enum=OrchestrationStatus" json:"runtimeStatus,omitempty"` - CreatedTimeFrom *timestamp.Timestamp `protobuf:"bytes,2,opt,name=createdTimeFrom,proto3" json:"createdTimeFrom,omitempty"` - CreatedTimeTo *timestamp.Timestamp `protobuf:"bytes,3,opt,name=createdTimeTo,proto3" json:"createdTimeTo,omitempty"` - TaskHubNames []*wrappers.StringValue `protobuf:"bytes,4,rep,name=taskHubNames,proto3" json:"taskHubNames,omitempty"` - MaxInstanceCount int32 `protobuf:"varint,5,opt,name=maxInstanceCount,proto3" json:"maxInstanceCount,omitempty"` - ContinuationToken *wrappers.StringValue `protobuf:"bytes,6,opt,name=continuationToken,proto3" json:"continuationToken,omitempty"` - InstanceIdPrefix *wrappers.StringValue `protobuf:"bytes,7,opt,name=instanceIdPrefix,proto3" json:"instanceIdPrefix,omitempty"` - FetchInputsAndOutputs bool `protobuf:"varint,8,opt,name=fetchInputsAndOutputs,proto3" json:"fetchInputsAndOutputs,omitempty"` + RuntimeStatus []OrchestrationStatus `protobuf:"varint,1,rep,packed,name=runtimeStatus,proto3,enum=OrchestrationStatus" json:"runtimeStatus,omitempty"` + CreatedTimeFrom *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=createdTimeFrom,proto3" json:"createdTimeFrom,omitempty"` + CreatedTimeTo *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=createdTimeTo,proto3" json:"createdTimeTo,omitempty"` + TaskHubNames []*wrapperspb.StringValue `protobuf:"bytes,4,rep,name=taskHubNames,proto3" json:"taskHubNames,omitempty"` + MaxInstanceCount int32 `protobuf:"varint,5,opt,name=maxInstanceCount,proto3" json:"maxInstanceCount,omitempty"` + ContinuationToken *wrapperspb.StringValue `protobuf:"bytes,6,opt,name=continuationToken,proto3" json:"continuationToken,omitempty"` + InstanceIdPrefix *wrapperspb.StringValue `protobuf:"bytes,7,opt,name=instanceIdPrefix,proto3" json:"instanceIdPrefix,omitempty"` + FetchInputsAndOutputs bool `protobuf:"varint,8,opt,name=fetchInputsAndOutputs,proto3" json:"fetchInputsAndOutputs,omitempty"` } func (x *InstanceQuery) Reset() { @@ -3720,21 +3720,21 @@ func (x *InstanceQuery) GetRuntimeStatus() []OrchestrationStatus { return nil } -func (x *InstanceQuery) GetCreatedTimeFrom() *timestamp.Timestamp { +func (x *InstanceQuery) GetCreatedTimeFrom() *timestamppb.Timestamp { if x != nil { return x.CreatedTimeFrom } return nil } -func (x *InstanceQuery) GetCreatedTimeTo() *timestamp.Timestamp { +func (x *InstanceQuery) GetCreatedTimeTo() *timestamppb.Timestamp { if x != nil { return x.CreatedTimeTo } return nil } -func (x *InstanceQuery) GetTaskHubNames() []*wrappers.StringValue { +func (x *InstanceQuery) GetTaskHubNames() []*wrapperspb.StringValue { if x != nil { return x.TaskHubNames } @@ -3748,14 +3748,14 @@ func (x *InstanceQuery) GetMaxInstanceCount() int32 { return 0 } -func (x *InstanceQuery) GetContinuationToken() *wrappers.StringValue { +func (x *InstanceQuery) GetContinuationToken() *wrapperspb.StringValue { if x != nil { return x.ContinuationToken } return nil } -func (x *InstanceQuery) GetInstanceIdPrefix() *wrappers.StringValue { +func (x *InstanceQuery) GetInstanceIdPrefix() *wrapperspb.StringValue { if x != nil { return x.InstanceIdPrefix } @@ -3774,8 +3774,8 @@ type QueryInstancesResponse struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - OrchestrationState []*OrchestrationState `protobuf:"bytes,1,rep,name=orchestrationState,proto3" json:"orchestrationState,omitempty"` - ContinuationToken *wrappers.StringValue `protobuf:"bytes,2,opt,name=continuationToken,proto3" json:"continuationToken,omitempty"` + OrchestrationState []*OrchestrationState `protobuf:"bytes,1,rep,name=orchestrationState,proto3" json:"orchestrationState,omitempty"` + ContinuationToken *wrapperspb.StringValue `protobuf:"bytes,2,opt,name=continuationToken,proto3" json:"continuationToken,omitempty"` } func (x *QueryInstancesResponse) Reset() { @@ -3817,7 +3817,7 @@ func (x *QueryInstancesResponse) GetOrchestrationState() []*OrchestrationState { return nil } -func (x *QueryInstancesResponse) GetContinuationToken() *wrappers.StringValue { +func (x *QueryInstancesResponse) GetContinuationToken() *wrapperspb.StringValue { if x != nil { return x.ContinuationToken } @@ -3918,9 +3918,9 @@ type PurgeInstanceFilter struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - CreatedTimeFrom *timestamp.Timestamp `protobuf:"bytes,1,opt,name=createdTimeFrom,proto3" json:"createdTimeFrom,omitempty"` - CreatedTimeTo *timestamp.Timestamp `protobuf:"bytes,2,opt,name=createdTimeTo,proto3" json:"createdTimeTo,omitempty"` - RuntimeStatus []OrchestrationStatus `protobuf:"varint,3,rep,packed,name=runtimeStatus,proto3,enum=OrchestrationStatus" json:"runtimeStatus,omitempty"` + CreatedTimeFrom *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=createdTimeFrom,proto3" json:"createdTimeFrom,omitempty"` + CreatedTimeTo *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=createdTimeTo,proto3" json:"createdTimeTo,omitempty"` + RuntimeStatus []OrchestrationStatus `protobuf:"varint,3,rep,packed,name=runtimeStatus,proto3,enum=OrchestrationStatus" json:"runtimeStatus,omitempty"` } func (x *PurgeInstanceFilter) Reset() { @@ -3955,14 +3955,14 @@ func (*PurgeInstanceFilter) Descriptor() ([]byte, []int) { return file_orchestrator_service_proto_rawDescGZIP(), []int{56} } -func (x *PurgeInstanceFilter) GetCreatedTimeFrom() *timestamp.Timestamp { +func (x *PurgeInstanceFilter) GetCreatedTimeFrom() *timestamppb.Timestamp { if x != nil { return x.CreatedTimeFrom } return nil } -func (x *PurgeInstanceFilter) GetCreatedTimeTo() *timestamp.Timestamp { +func (x *PurgeInstanceFilter) GetCreatedTimeTo() *timestamppb.Timestamp { if x != nil { return x.CreatedTimeTo } @@ -4189,11 +4189,11 @@ type SignalEntityRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` - Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` - Input *wrappers.StringValue `protobuf:"bytes,3,opt,name=input,proto3" json:"input,omitempty"` - RequestId string `protobuf:"bytes,4,opt,name=requestId,proto3" json:"requestId,omitempty"` - ScheduledTime *timestamp.Timestamp `protobuf:"bytes,5,opt,name=scheduledTime,proto3" json:"scheduledTime,omitempty"` + InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Input *wrapperspb.StringValue `protobuf:"bytes,3,opt,name=input,proto3" json:"input,omitempty"` + RequestId string `protobuf:"bytes,4,opt,name=requestId,proto3" json:"requestId,omitempty"` + ScheduledTime *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=scheduledTime,proto3" json:"scheduledTime,omitempty"` } func (x *SignalEntityRequest) Reset() { @@ -4242,7 +4242,7 @@ func (x *SignalEntityRequest) GetName() string { return "" } -func (x *SignalEntityRequest) GetInput() *wrappers.StringValue { +func (x *SignalEntityRequest) GetInput() *wrapperspb.StringValue { if x != nil { return x.Input } @@ -4256,7 +4256,7 @@ func (x *SignalEntityRequest) GetRequestId() string { return "" } -func (x *SignalEntityRequest) GetScheduledTime() *timestamp.Timestamp { +func (x *SignalEntityRequest) GetScheduledTime() *timestamppb.Timestamp { if x != nil { return x.ScheduledTime } @@ -4416,13 +4416,13 @@ type EntityQuery struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - InstanceIdStartsWith *wrappers.StringValue `protobuf:"bytes,1,opt,name=instanceIdStartsWith,proto3" json:"instanceIdStartsWith,omitempty"` - LastModifiedFrom *timestamp.Timestamp `protobuf:"bytes,2,opt,name=lastModifiedFrom,proto3" json:"lastModifiedFrom,omitempty"` - LastModifiedTo *timestamp.Timestamp `protobuf:"bytes,3,opt,name=lastModifiedTo,proto3" json:"lastModifiedTo,omitempty"` - IncludeState bool `protobuf:"varint,4,opt,name=includeState,proto3" json:"includeState,omitempty"` - IncludeTransient bool `protobuf:"varint,5,opt,name=includeTransient,proto3" json:"includeTransient,omitempty"` - PageSize *wrappers.Int32Value `protobuf:"bytes,6,opt,name=pageSize,proto3" json:"pageSize,omitempty"` - ContinuationToken *wrappers.StringValue `protobuf:"bytes,7,opt,name=continuationToken,proto3" json:"continuationToken,omitempty"` + InstanceIdStartsWith *wrapperspb.StringValue `protobuf:"bytes,1,opt,name=instanceIdStartsWith,proto3" json:"instanceIdStartsWith,omitempty"` + LastModifiedFrom *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=lastModifiedFrom,proto3" json:"lastModifiedFrom,omitempty"` + LastModifiedTo *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=lastModifiedTo,proto3" json:"lastModifiedTo,omitempty"` + IncludeState bool `protobuf:"varint,4,opt,name=includeState,proto3" json:"includeState,omitempty"` + IncludeTransient bool `protobuf:"varint,5,opt,name=includeTransient,proto3" json:"includeTransient,omitempty"` + PageSize *wrapperspb.Int32Value `protobuf:"bytes,6,opt,name=pageSize,proto3" json:"pageSize,omitempty"` + ContinuationToken *wrapperspb.StringValue `protobuf:"bytes,7,opt,name=continuationToken,proto3" json:"continuationToken,omitempty"` } func (x *EntityQuery) Reset() { @@ -4457,21 +4457,21 @@ func (*EntityQuery) Descriptor() ([]byte, []int) { return file_orchestrator_service_proto_rawDescGZIP(), []int{66} } -func (x *EntityQuery) GetInstanceIdStartsWith() *wrappers.StringValue { +func (x *EntityQuery) GetInstanceIdStartsWith() *wrapperspb.StringValue { if x != nil { return x.InstanceIdStartsWith } return nil } -func (x *EntityQuery) GetLastModifiedFrom() *timestamp.Timestamp { +func (x *EntityQuery) GetLastModifiedFrom() *timestamppb.Timestamp { if x != nil { return x.LastModifiedFrom } return nil } -func (x *EntityQuery) GetLastModifiedTo() *timestamp.Timestamp { +func (x *EntityQuery) GetLastModifiedTo() *timestamppb.Timestamp { if x != nil { return x.LastModifiedTo } @@ -4492,14 +4492,14 @@ func (x *EntityQuery) GetIncludeTransient() bool { return false } -func (x *EntityQuery) GetPageSize() *wrappers.Int32Value { +func (x *EntityQuery) GetPageSize() *wrapperspb.Int32Value { if x != nil { return x.PageSize } return nil } -func (x *EntityQuery) GetContinuationToken() *wrappers.StringValue { +func (x *EntityQuery) GetContinuationToken() *wrapperspb.StringValue { if x != nil { return x.ContinuationToken } @@ -4558,8 +4558,8 @@ type QueryEntitiesResponse struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Entities []*EntityMetadata `protobuf:"bytes,1,rep,name=entities,proto3" json:"entities,omitempty"` - ContinuationToken *wrappers.StringValue `protobuf:"bytes,2,opt,name=continuationToken,proto3" json:"continuationToken,omitempty"` + Entities []*EntityMetadata `protobuf:"bytes,1,rep,name=entities,proto3" json:"entities,omitempty"` + ContinuationToken *wrapperspb.StringValue `protobuf:"bytes,2,opt,name=continuationToken,proto3" json:"continuationToken,omitempty"` } func (x *QueryEntitiesResponse) Reset() { @@ -4601,7 +4601,7 @@ func (x *QueryEntitiesResponse) GetEntities() []*EntityMetadata { return nil } -func (x *QueryEntitiesResponse) GetContinuationToken() *wrappers.StringValue { +func (x *QueryEntitiesResponse) GetContinuationToken() *wrapperspb.StringValue { if x != nil { return x.ContinuationToken } @@ -4613,11 +4613,11 @@ type EntityMetadata struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` - LastModifiedTime *timestamp.Timestamp `protobuf:"bytes,2,opt,name=lastModifiedTime,proto3" json:"lastModifiedTime,omitempty"` - BacklogQueueSize int32 `protobuf:"varint,3,opt,name=backlogQueueSize,proto3" json:"backlogQueueSize,omitempty"` - LockedBy *wrappers.StringValue `protobuf:"bytes,4,opt,name=lockedBy,proto3" json:"lockedBy,omitempty"` - SerializedState *wrappers.StringValue `protobuf:"bytes,5,opt,name=serializedState,proto3" json:"serializedState,omitempty"` + InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` + LastModifiedTime *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=lastModifiedTime,proto3" json:"lastModifiedTime,omitempty"` + BacklogQueueSize int32 `protobuf:"varint,3,opt,name=backlogQueueSize,proto3" json:"backlogQueueSize,omitempty"` + LockedBy *wrapperspb.StringValue `protobuf:"bytes,4,opt,name=lockedBy,proto3" json:"lockedBy,omitempty"` + SerializedState *wrapperspb.StringValue `protobuf:"bytes,5,opt,name=serializedState,proto3" json:"serializedState,omitempty"` } func (x *EntityMetadata) Reset() { @@ -4659,7 +4659,7 @@ func (x *EntityMetadata) GetInstanceId() string { return "" } -func (x *EntityMetadata) GetLastModifiedTime() *timestamp.Timestamp { +func (x *EntityMetadata) GetLastModifiedTime() *timestamppb.Timestamp { if x != nil { return x.LastModifiedTime } @@ -4673,14 +4673,14 @@ func (x *EntityMetadata) GetBacklogQueueSize() int32 { return 0 } -func (x *EntityMetadata) GetLockedBy() *wrappers.StringValue { +func (x *EntityMetadata) GetLockedBy() *wrapperspb.StringValue { if x != nil { return x.LockedBy } return nil } -func (x *EntityMetadata) GetSerializedState() *wrappers.StringValue { +func (x *EntityMetadata) GetSerializedState() *wrapperspb.StringValue { if x != nil { return x.SerializedState } @@ -4692,9 +4692,9 @@ type CleanEntityStorageRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - ContinuationToken *wrappers.StringValue `protobuf:"bytes,1,opt,name=continuationToken,proto3" json:"continuationToken,omitempty"` - RemoveEmptyEntities bool `protobuf:"varint,2,opt,name=removeEmptyEntities,proto3" json:"removeEmptyEntities,omitempty"` - ReleaseOrphanedLocks bool `protobuf:"varint,3,opt,name=releaseOrphanedLocks,proto3" json:"releaseOrphanedLocks,omitempty"` + ContinuationToken *wrapperspb.StringValue `protobuf:"bytes,1,opt,name=continuationToken,proto3" json:"continuationToken,omitempty"` + RemoveEmptyEntities bool `protobuf:"varint,2,opt,name=removeEmptyEntities,proto3" json:"removeEmptyEntities,omitempty"` + ReleaseOrphanedLocks bool `protobuf:"varint,3,opt,name=releaseOrphanedLocks,proto3" json:"releaseOrphanedLocks,omitempty"` } func (x *CleanEntityStorageRequest) Reset() { @@ -4729,7 +4729,7 @@ func (*CleanEntityStorageRequest) Descriptor() ([]byte, []int) { return file_orchestrator_service_proto_rawDescGZIP(), []int{70} } -func (x *CleanEntityStorageRequest) GetContinuationToken() *wrappers.StringValue { +func (x *CleanEntityStorageRequest) GetContinuationToken() *wrapperspb.StringValue { if x != nil { return x.ContinuationToken } @@ -4755,9 +4755,9 @@ type CleanEntityStorageResponse struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - ContinuationToken *wrappers.StringValue `protobuf:"bytes,1,opt,name=continuationToken,proto3" json:"continuationToken,omitempty"` - EmptyEntitiesRemoved int32 `protobuf:"varint,2,opt,name=emptyEntitiesRemoved,proto3" json:"emptyEntitiesRemoved,omitempty"` - OrphanedLocksReleased int32 `protobuf:"varint,3,opt,name=orphanedLocksReleased,proto3" json:"orphanedLocksReleased,omitempty"` + ContinuationToken *wrapperspb.StringValue `protobuf:"bytes,1,opt,name=continuationToken,proto3" json:"continuationToken,omitempty"` + EmptyEntitiesRemoved int32 `protobuf:"varint,2,opt,name=emptyEntitiesRemoved,proto3" json:"emptyEntitiesRemoved,omitempty"` + OrphanedLocksReleased int32 `protobuf:"varint,3,opt,name=orphanedLocksReleased,proto3" json:"orphanedLocksReleased,omitempty"` } func (x *CleanEntityStorageResponse) Reset() { @@ -4792,7 +4792,7 @@ func (*CleanEntityStorageResponse) Descriptor() ([]byte, []int) { return file_orchestrator_service_proto_rawDescGZIP(), []int{71} } -func (x *CleanEntityStorageResponse) GetContinuationToken() *wrappers.StringValue { +func (x *CleanEntityStorageResponse) GetContinuationToken() *wrapperspb.StringValue { if x != nil { return x.ContinuationToken } @@ -4818,7 +4818,7 @@ type OrchestratorEntityParameters struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - EntityMessageReorderWindow *duration.Duration `protobuf:"bytes,1,opt,name=entityMessageReorderWindow,proto3" json:"entityMessageReorderWindow,omitempty"` + EntityMessageReorderWindow *durationpb.Duration `protobuf:"bytes,1,opt,name=entityMessageReorderWindow,proto3" json:"entityMessageReorderWindow,omitempty"` } func (x *OrchestratorEntityParameters) Reset() { @@ -4853,7 +4853,7 @@ func (*OrchestratorEntityParameters) Descriptor() ([]byte, []int) { return file_orchestrator_service_proto_rawDescGZIP(), []int{72} } -func (x *OrchestratorEntityParameters) GetEntityMessageReorderWindow() *duration.Duration { +func (x *OrchestratorEntityParameters) GetEntityMessageReorderWindow() *durationpb.Duration { if x != nil { return x.EntityMessageReorderWindow } @@ -4865,9 +4865,9 @@ type EntityBatchRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` - EntityState *wrappers.StringValue `protobuf:"bytes,2,opt,name=entityState,proto3" json:"entityState,omitempty"` - Operations []*OperationRequest `protobuf:"bytes,3,rep,name=operations,proto3" json:"operations,omitempty"` + InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` + EntityState *wrapperspb.StringValue `protobuf:"bytes,2,opt,name=entityState,proto3" json:"entityState,omitempty"` + Operations []*OperationRequest `protobuf:"bytes,3,rep,name=operations,proto3" json:"operations,omitempty"` } func (x *EntityBatchRequest) Reset() { @@ -4909,7 +4909,7 @@ func (x *EntityBatchRequest) GetInstanceId() string { return "" } -func (x *EntityBatchRequest) GetEntityState() *wrappers.StringValue { +func (x *EntityBatchRequest) GetEntityState() *wrapperspb.StringValue { if x != nil { return x.EntityState } @@ -4928,10 +4928,10 @@ type EntityBatchResult struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Results []*OperationResult `protobuf:"bytes,1,rep,name=results,proto3" json:"results,omitempty"` - Actions []*OperationAction `protobuf:"bytes,2,rep,name=actions,proto3" json:"actions,omitempty"` - EntityState *wrappers.StringValue `protobuf:"bytes,3,opt,name=entityState,proto3" json:"entityState,omitempty"` - FailureDetails *TaskFailureDetails `protobuf:"bytes,4,opt,name=failureDetails,proto3" json:"failureDetails,omitempty"` + Results []*OperationResult `protobuf:"bytes,1,rep,name=results,proto3" json:"results,omitempty"` + Actions []*OperationAction `protobuf:"bytes,2,rep,name=actions,proto3" json:"actions,omitempty"` + EntityState *wrapperspb.StringValue `protobuf:"bytes,3,opt,name=entityState,proto3" json:"entityState,omitempty"` + FailureDetails *TaskFailureDetails `protobuf:"bytes,4,opt,name=failureDetails,proto3" json:"failureDetails,omitempty"` } func (x *EntityBatchResult) Reset() { @@ -4980,7 +4980,7 @@ func (x *EntityBatchResult) GetActions() []*OperationAction { return nil } -func (x *EntityBatchResult) GetEntityState() *wrappers.StringValue { +func (x *EntityBatchResult) GetEntityState() *wrapperspb.StringValue { if x != nil { return x.EntityState } @@ -4999,9 +4999,9 @@ type OperationRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Operation string `protobuf:"bytes,1,opt,name=operation,proto3" json:"operation,omitempty"` - RequestId string `protobuf:"bytes,2,opt,name=requestId,proto3" json:"requestId,omitempty"` - Input *wrappers.StringValue `protobuf:"bytes,3,opt,name=input,proto3" json:"input,omitempty"` + Operation string `protobuf:"bytes,1,opt,name=operation,proto3" json:"operation,omitempty"` + RequestId string `protobuf:"bytes,2,opt,name=requestId,proto3" json:"requestId,omitempty"` + Input *wrapperspb.StringValue `protobuf:"bytes,3,opt,name=input,proto3" json:"input,omitempty"` } func (x *OperationRequest) Reset() { @@ -5050,7 +5050,7 @@ func (x *OperationRequest) GetRequestId() string { return "" } -func (x *OperationRequest) GetInput() *wrappers.StringValue { +func (x *OperationRequest) GetInput() *wrapperspb.StringValue { if x != nil { return x.Input } @@ -5143,7 +5143,7 @@ type OperationResultSuccess struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Result *wrappers.StringValue `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` + Result *wrapperspb.StringValue `protobuf:"bytes,1,opt,name=result,proto3" json:"result,omitempty"` } func (x *OperationResultSuccess) Reset() { @@ -5178,7 +5178,7 @@ func (*OperationResultSuccess) Descriptor() ([]byte, []int) { return file_orchestrator_service_proto_rawDescGZIP(), []int{77} } -func (x *OperationResultSuccess) GetResult() *wrappers.StringValue { +func (x *OperationResultSuccess) GetResult() *wrapperspb.StringValue { if x != nil { return x.Result } @@ -5326,10 +5326,10 @@ type SendSignalAction struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` - Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` - Input *wrappers.StringValue `protobuf:"bytes,3,opt,name=input,proto3" json:"input,omitempty"` - ScheduledTime *timestamp.Timestamp `protobuf:"bytes,4,opt,name=scheduledTime,proto3" json:"scheduledTime,omitempty"` + InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Input *wrapperspb.StringValue `protobuf:"bytes,3,opt,name=input,proto3" json:"input,omitempty"` + ScheduledTime *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=scheduledTime,proto3" json:"scheduledTime,omitempty"` } func (x *SendSignalAction) Reset() { @@ -5378,14 +5378,14 @@ func (x *SendSignalAction) GetName() string { return "" } -func (x *SendSignalAction) GetInput() *wrappers.StringValue { +func (x *SendSignalAction) GetInput() *wrapperspb.StringValue { if x != nil { return x.Input } return nil } -func (x *SendSignalAction) GetScheduledTime() *timestamp.Timestamp { +func (x *SendSignalAction) GetScheduledTime() *timestamppb.Timestamp { if x != nil { return x.ScheduledTime } @@ -5397,11 +5397,11 @@ type StartNewOrchestrationAction struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` - Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` - Version *wrappers.StringValue `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` - Input *wrappers.StringValue `protobuf:"bytes,4,opt,name=input,proto3" json:"input,omitempty"` - ScheduledTime *timestamp.Timestamp `protobuf:"bytes,5,opt,name=scheduledTime,proto3" json:"scheduledTime,omitempty"` + InstanceId string `protobuf:"bytes,1,opt,name=instanceId,proto3" json:"instanceId,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Version *wrapperspb.StringValue `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` + Input *wrapperspb.StringValue `protobuf:"bytes,4,opt,name=input,proto3" json:"input,omitempty"` + ScheduledTime *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=scheduledTime,proto3" json:"scheduledTime,omitempty"` } func (x *StartNewOrchestrationAction) Reset() { @@ -5450,21 +5450,21 @@ func (x *StartNewOrchestrationAction) GetName() string { return "" } -func (x *StartNewOrchestrationAction) GetVersion() *wrappers.StringValue { +func (x *StartNewOrchestrationAction) GetVersion() *wrapperspb.StringValue { if x != nil { return x.Version } return nil } -func (x *StartNewOrchestrationAction) GetInput() *wrappers.StringValue { +func (x *StartNewOrchestrationAction) GetInput() *wrapperspb.StringValue { if x != nil { return x.Input } return nil } -func (x *StartNewOrchestrationAction) GetScheduledTime() *timestamp.Timestamp { +func (x *StartNewOrchestrationAction) GetScheduledTime() *timestamppb.Timestamp { if x != nil { return x.ScheduledTime } @@ -6796,11 +6796,11 @@ var file_orchestrator_service_proto_goTypes = []interface{}{ (*GetWorkItemsRequest)(nil), // 84: GetWorkItemsRequest (*WorkItem)(nil), // 85: WorkItem (*CompleteTaskResponse)(nil), // 86: CompleteTaskResponse - (*wrappers.StringValue)(nil), // 87: google.protobuf.StringValue - (*timestamp.Timestamp)(nil), // 88: google.protobuf.Timestamp - (*wrappers.Int32Value)(nil), // 89: google.protobuf.Int32Value - (*duration.Duration)(nil), // 90: google.protobuf.Duration - (*empty.Empty)(nil), // 91: google.protobuf.Empty + (*wrapperspb.StringValue)(nil), // 87: google.protobuf.StringValue + (*timestamppb.Timestamp)(nil), // 88: google.protobuf.Timestamp + (*wrapperspb.Int32Value)(nil), // 89: google.protobuf.Int32Value + (*durationpb.Duration)(nil), // 90: google.protobuf.Duration + (*emptypb.Empty)(nil), // 91: google.protobuf.Empty } var file_orchestrator_service_proto_depIdxs = []int32{ 87, // 0: OrchestrationInstance.executionId:type_name -> google.protobuf.StringValue diff --git a/internal/protos/orchestrator_service_grpc.pb.go b/internal/protos/orchestrator_service_grpc.pb.go index 0bc36435..7524f982 100644 --- a/internal/protos/orchestrator_service_grpc.pb.go +++ b/internal/protos/orchestrator_service_grpc.pb.go @@ -4,17 +4,17 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.3.0 -// - protoc v3.12.4 +// - protoc v7.34.0 // source: orchestrator_service.proto package protos import ( context "context" - empty "google.golang.org/protobuf/types/known/emptypb" grpc "google.golang.org/grpc" codes "google.golang.org/grpc/codes" status "google.golang.org/grpc/status" + emptypb "google.golang.org/protobuf/types/known/emptypb" ) // This is a compile-time assertion to ensure that this generated file @@ -52,7 +52,7 @@ const ( // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type TaskHubSidecarServiceClient interface { // Sends a hello request to the sidecar service. - Hello(ctx context.Context, in *empty.Empty, opts ...grpc.CallOption) (*empty.Empty, error) + Hello(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) // Starts a new orchestration instance. StartInstance(ctx context.Context, in *CreateInstanceRequest, opts ...grpc.CallOption) (*CreateInstanceResponse, error) // Gets the status of an existing orchestration instance. @@ -99,8 +99,8 @@ func NewTaskHubSidecarServiceClient(cc grpc.ClientConnInterface) TaskHubSidecarS return &taskHubSidecarServiceClient{cc} } -func (c *taskHubSidecarServiceClient) Hello(ctx context.Context, in *empty.Empty, opts ...grpc.CallOption) (*empty.Empty, error) { - out := new(empty.Empty) +func (c *taskHubSidecarServiceClient) Hello(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*emptypb.Empty, error) { + out := new(emptypb.Empty) err := c.cc.Invoke(ctx, TaskHubSidecarService_Hello_FullMethodName, in, out, opts...) if err != nil { return nil, err @@ -325,7 +325,7 @@ func (c *taskHubSidecarServiceClient) CleanEntityStorage(ctx context.Context, in // for forward compatibility type TaskHubSidecarServiceServer interface { // Sends a hello request to the sidecar service. - Hello(context.Context, *empty.Empty) (*empty.Empty, error) + Hello(context.Context, *emptypb.Empty) (*emptypb.Empty, error) // Starts a new orchestration instance. StartInstance(context.Context, *CreateInstanceRequest) (*CreateInstanceResponse, error) // Gets the status of an existing orchestration instance. @@ -369,7 +369,7 @@ type TaskHubSidecarServiceServer interface { type UnimplementedTaskHubSidecarServiceServer struct { } -func (UnimplementedTaskHubSidecarServiceServer) Hello(context.Context, *empty.Empty) (*empty.Empty, error) { +func (UnimplementedTaskHubSidecarServiceServer) Hello(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { return nil, status.Errorf(codes.Unimplemented, "method Hello not implemented") } func (UnimplementedTaskHubSidecarServiceServer) StartInstance(context.Context, *CreateInstanceRequest) (*CreateInstanceResponse, error) { @@ -449,7 +449,7 @@ func RegisterTaskHubSidecarServiceServer(s grpc.ServiceRegistrar, srv TaskHubSid } func _TaskHubSidecarService_Hello_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(empty.Empty) + in := new(emptypb.Empty) if err := dec(in); err != nil { return nil, err } @@ -461,7 +461,7 @@ func _TaskHubSidecarService_Hello_Handler(srv interface{}, ctx context.Context, FullMethod: TaskHubSidecarService_Hello_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(TaskHubSidecarServiceServer).Hello(ctx, req.(*empty.Empty)) + return srv.(TaskHubSidecarServiceServer).Hello(ctx, req.(*emptypb.Empty)) } return interceptor(ctx, in, info, handler) } diff --git a/samples/entity/entity.go b/samples/entity/entity.go new file mode 100644 index 00000000..793dfd4f --- /dev/null +++ b/samples/entity/entity.go @@ -0,0 +1,163 @@ +// This sample demonstrates how to use durable entities with the Durable Task Go SDK. +// It shows two patterns: +// 1. A raw entity function (Counter) with manual operation dispatch +// 2. An auto-dispatch entity (BankAccount) where operations map to methods on a struct +package main + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/microsoft/durabletask-go/api" + "github.com/microsoft/durabletask-go/backend" + "github.com/microsoft/durabletask-go/backend/sqlite" + "github.com/microsoft/durabletask-go/task" +) + +func main() { + r := task.NewTaskRegistry() + + // Pattern 1: Register a raw entity function with manual dispatch + if err := r.AddEntityN("counter", CounterEntity); err != nil { + log.Fatalf("Failed to register counter entity: %v", err) + } + + // Pattern 2: Register an auto-dispatch entity backed by a struct + if err := r.AddEntityN("bankaccount", task.NewEntityFor[BankAccount]()); err != nil { + log.Fatalf("Failed to register bank account entity: %v", err) + } + + ctx := context.Background() + client, worker, err := Init(ctx, r) + if err != nil { + log.Fatalf("Failed to initialize: %v", err) + } + defer func() { + if err := worker.Shutdown(ctx); err != nil { + log.Printf("Failed to shutdown: %v", err) + } + }() + + // --- Demo 1: Counter entity (raw function) --- + fmt.Println("=== Counter Entity Demo ===") + counterID := api.NewEntityID("counter", "myCounter") + + // Signal the entity to perform operations + if err := client.SignalEntity(ctx, counterID, "add", api.WithSignalInput(10)); err != nil { + log.Fatalf("Failed to signal entity: %v", err) + } + if err := client.SignalEntity(ctx, counterID, "add", api.WithSignalInput(5)); err != nil { + log.Fatalf("Failed to signal entity: %v", err) + } + if err := client.SignalEntity(ctx, counterID, "add", api.WithSignalInput(-3)); err != nil { + log.Fatalf("Failed to signal entity: %v", err) + } + + // Wait for processing + time.Sleep(3 * time.Second) + + // Query the entity state + meta, err := client.FetchEntityMetadata(ctx, counterID, true) + if err != nil { + log.Fatalf("Failed to fetch entity: %v", err) + } + fmt.Printf("Counter state: %s\n", meta.SerializedState) // Expected: 12 + + // --- Demo 2: BankAccount entity (auto-dispatch) --- + fmt.Println("\n=== Bank Account Entity Demo ===") + accountID := api.NewEntityID("bankaccount", "checking-001") + + if err := client.SignalEntity(ctx, accountID, "Deposit", api.WithSignalInput(1000)); err != nil { + log.Fatalf("Failed to signal entity: %v", err) + } + if err := client.SignalEntity(ctx, accountID, "Deposit", api.WithSignalInput(500)); err != nil { + log.Fatalf("Failed to signal entity: %v", err) + } + if err := client.SignalEntity(ctx, accountID, "Withdraw", api.WithSignalInput(200)); err != nil { + log.Fatalf("Failed to signal entity: %v", err) + } + + time.Sleep(3 * time.Second) + + meta, err = client.FetchEntityMetadata(ctx, accountID, true) + if err != nil { + log.Fatalf("Failed to fetch entity: %v", err) + } + fmt.Printf("Bank account state: %s\n", meta.SerializedState) // Expected: {"balance":1300} + + fmt.Println("\nDone!") +} + +// Init creates and initializes an in-memory client and worker pair. +func Init(ctx context.Context, r *task.TaskRegistry) (backend.EntityTaskHubClient, backend.TaskHubWorker, error) { + logger := backend.DefaultLogger() + be := sqlite.NewSqliteBackend(sqlite.NewSqliteOptions(""), logger) + executor := task.NewTaskExecutor(r) + orchestrationWorker := backend.NewOrchestrationWorker(be, executor, logger) + activityWorker := backend.NewActivityTaskWorker(be, executor, logger) + taskHubWorker := backend.NewTaskHubWorker(be, orchestrationWorker, activityWorker, logger) + if err := taskHubWorker.Start(ctx); err != nil { + return nil, nil, err + } + taskHubClient := backend.NewTaskHubClient(be) + return taskHubClient.(backend.EntityTaskHubClient), taskHubWorker, nil +} + +// --- Pattern 1: Raw entity function --- + +// CounterEntity is a simple counter entity that supports "add", "get", and "reset" operations. +func CounterEntity(ctx *task.EntityContext) (any, error) { + var count int + if ctx.HasState() { + if err := ctx.GetState(&count); err != nil { + return nil, err + } + } + + switch ctx.Operation { + case "add": + var amount int + if err := ctx.GetInput(&amount); err != nil { + return nil, err + } + count += amount + case "get": + // just return current value + case "reset": + count = 0 + default: + return nil, fmt.Errorf("unknown operation: %s", ctx.Operation) + } + + if err := ctx.SetState(count); err != nil { + return nil, err + } + return count, nil +} + +// --- Pattern 2: Auto-dispatch entity --- + +// BankAccount is a struct-based entity. Public methods are automatically +// dispatched by operation name (case-insensitive). +type BankAccount struct { + Balance int `json:"balance"` +} + +func (a *BankAccount) Deposit(amount int) (any, error) { + a.Balance += amount + return a.Balance, nil +} + +func (a *BankAccount) Withdraw(amount int) (any, error) { + if amount > a.Balance { + return nil, fmt.Errorf("insufficient funds: balance=%d, withdrawal=%d", a.Balance, amount) + } + a.Balance -= amount + return a.Balance, nil +} + +func (a *BankAccount) Get() (any, error) { + return a.Balance, nil +} diff --git a/task/entity.go b/task/entity.go new file mode 100644 index 00000000..9f61b9ea --- /dev/null +++ b/task/entity.go @@ -0,0 +1,202 @@ +package task + +import ( + "encoding/json" + "fmt" + + "github.com/microsoft/durabletask-go/api" + "github.com/microsoft/durabletask-go/internal/protos" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +// Entity is the functional interface for entity implementations. +// An entity function receives an EntityContext and returns a result and error. +type Entity func(ctx *EntityContext) (any, error) + +// EntityContext provides the execution context for an entity operation. +type EntityContext struct { + ID api.EntityID + Operation string + + rawInput []byte + state entityState + actions []*protos.OperationAction + actionIDSeq int32 +} + +type entityState struct { + value []byte + hasValue bool +} + +// GetInput unmarshals the serialized entity operation input and saves the result into [v]. +func (ctx *EntityContext) GetInput(v any) error { + return unmarshalData(ctx.rawInput, v) +} + +// HasState returns true if the entity has state set. +func (ctx *EntityContext) HasState() bool { + return ctx.state.hasValue +} + +// GetState unmarshals the entity state and saves the result into [v]. +func (ctx *EntityContext) GetState(v any) error { + if !ctx.state.hasValue { + return fmt.Errorf("entity has no state") + } + return unmarshalData(ctx.state.value, v) +} + +// SetState sets the entity state. The state must be JSON-serializable. +// Passing nil deletes the entity state. +func (ctx *EntityContext) SetState(state any) error { + if state == nil { + ctx.state.value = nil + ctx.state.hasValue = false + return nil + } + bytes, err := json.Marshal(state) + if err != nil { + return fmt.Errorf("failed to marshal entity state: %w", err) + } + ctx.state.value = bytes + ctx.state.hasValue = true + return nil +} + +// SignalEntity sends a fire-and-forget signal to another entity. +func (ctx *EntityContext) SignalEntity(entityID api.EntityID, operationName string, input any) error { + var rawInput *wrapperspb.StringValue + if input != nil { + bytes, err := json.Marshal(input) + if err != nil { + return fmt.Errorf("failed to marshal signal input: %w", err) + } + rawInput = wrapperspb.String(string(bytes)) + } + + action := &protos.OperationAction{ + Id: ctx.nextActionID(), + OperationActionType: &protos.OperationAction_SendSignal{ + SendSignal: &protos.SendSignalAction{ + InstanceId: entityID.String(), + Name: operationName, + Input: rawInput, + }, + }, + } + ctx.actions = append(ctx.actions, action) + return nil +} + +// StartNewOrchestration schedules a new orchestration from within an entity operation. +func (ctx *EntityContext) StartNewOrchestration(name string, opts ...entityStartOrchestrationOption) error { + options := &entityStartOrchestrationOptions{} + for _, configure := range opts { + if err := configure(options); err != nil { + return err + } + } + + action := &protos.OperationAction{ + Id: ctx.nextActionID(), + OperationActionType: &protos.OperationAction_StartNewOrchestration{ + StartNewOrchestration: &protos.StartNewOrchestrationAction{ + InstanceId: options.instanceID, + Name: name, + Input: options.rawInput, + }, + }, + } + ctx.actions = append(ctx.actions, action) + return nil +} + +func (ctx *EntityContext) nextActionID() int32 { + id := ctx.actionIDSeq + ctx.actionIDSeq++ + return id +} + +// entityStartOrchestrationOptions holds options for starting orchestrations from entities. +type entityStartOrchestrationOptions struct { + instanceID string + rawInput *wrapperspb.StringValue +} + +// entityStartOrchestrationOption is a functional option for StartNewOrchestration. +type entityStartOrchestrationOption func(*entityStartOrchestrationOptions) error + +// WithEntityStartOrchestrationInput sets the input for the new orchestration. +func WithEntityStartOrchestrationInput(input any) entityStartOrchestrationOption { + return func(opts *entityStartOrchestrationOptions) error { + bytes, err := json.Marshal(input) + if err != nil { + return fmt.Errorf("failed to marshal orchestration input: %w", err) + } + opts.rawInput = wrapperspb.String(string(bytes)) + return nil + } +} + +// WithEntityStartOrchestrationInstanceID sets the instance ID for the new orchestration. +func WithEntityStartOrchestrationInstanceID(instanceID string) entityStartOrchestrationOption { + return func(opts *entityStartOrchestrationOptions) error { + opts.instanceID = instanceID + return nil + } +} + +// callEntityOption is a functional option type for the CallEntity orchestrator method. +type callEntityOption func(*callEntityOptions) error + +type callEntityOptions struct { + rawInput *wrapperspb.StringValue +} + +// WithEntityInput configures an input for an entity operation invocation. +func WithEntityInput(input any) callEntityOption { + return func(opt *callEntityOptions) error { + data, err := marshalData(input) + if err != nil { + return err + } + opt.rawInput = wrapperspb.String(string(data)) + return nil + } +} + +// WithRawEntityInput configures a raw input for an entity operation invocation. +func WithRawEntityInput(input string) callEntityOption { + return func(opt *callEntityOptions) error { + opt.rawInput = wrapperspb.String(input) + return nil + } +} + +// signalEntityOption is a functional option type for the SignalEntity orchestrator method. +type signalEntityOption func(*signalEntityOptions) error + +type signalEntityOptions struct { + rawInput *wrapperspb.StringValue +} + +// WithSignalEntityInput configures an input for a signal entity invocation. +func WithSignalEntityInput(input any) signalEntityOption { + return func(opt *signalEntityOptions) error { + data, err := marshalData(input) + if err != nil { + return err + } + opt.rawInput = wrapperspb.String(string(data)) + return nil + } +} + +// WithRawSignalEntityInput configures a raw input for a signal entity invocation. +func WithRawSignalEntityInput(input string) signalEntityOption { + return func(opt *signalEntityOptions) error { + opt.rawInput = wrapperspb.String(input) + return nil + } +} diff --git a/task/entity_dispatch.go b/task/entity_dispatch.go new file mode 100644 index 00000000..5fa28f83 --- /dev/null +++ b/task/entity_dispatch.go @@ -0,0 +1,158 @@ +package task + +import ( + "fmt" + "reflect" + "strings" + + "github.com/microsoft/durabletask-go/api" +) + +// EntityDispatcher provides automatic operation dispatch for entities. +// It maps entity operations to methods on a state struct. +// +// Methods on the state struct are matched by name (case-insensitive). +// Each method can have one of these signatures: +// +// func (s *State) OperationName() (any, error) +// func (s *State) OperationName(input InputType) (any, error) +// func (s *State) OperationName(ctx *EntityContext) (any, error) +// func (s *State) OperationName(ctx *EntityContext, input InputType) (any, error) +// +// Example usage: +// +// type Counter struct { +// Value int `json:"value"` +// } +// +// func (c *Counter) Add(amount int) (any, error) { +// c.Value += amount +// return c.Value, nil +// } +// +// func (c *Counter) Get() (any, error) { +// return c.Value, nil +// } +// +// func (c *Counter) Reset() (any, error) { +// c.Value = 0 +// return nil, nil +// } +// +// // Register entity: +// r.AddEntityN("counter", task.NewEntityFor[Counter]()) + +// NewEntityFor creates an entity function that automatically dispatches +// operations to methods on a state struct of type S. +// +// The state is automatically loaded from and saved to the entity context. +// If the entity has no state, a zero-value S is used. +// +// The special operation "delete" resets the entity state (unless a Delete method exists). +func NewEntityFor[S any]() Entity { + return func(ctx *EntityContext) (any, error) { + // Load state + var state S + if ctx.HasState() { + if err := ctx.GetState(&state); err != nil { + return nil, fmt.Errorf("failed to deserialize entity state: %w", err) + } + } + + // Handle implicit "delete" operation + if strings.EqualFold(ctx.Operation, "delete") { + // Check if a user-defined Delete method exists first + if _, found := findMethod(reflect.TypeOf(&state), "delete"); !found { + return nil, ctx.SetState(nil) + } + } + + // Dispatch to method + result, err := dispatchToMethod(ctx, &state) + if err != nil { + return nil, err + } + + // Save state back + if err := ctx.SetState(state); err != nil { + return nil, fmt.Errorf("failed to save entity state: %w", err) + } + return result, nil + } +} + +func findMethod(t reflect.Type, name string) (reflect.Method, bool) { + for i := 0; i < t.NumMethod(); i++ { + m := t.Method(i) + if strings.EqualFold(m.Name, name) { + return m, true + } + } + return reflect.Method{}, false +} + +func dispatchToMethod[S any](ctx *EntityContext, state *S) (any, error) { + stateVal := reflect.ValueOf(state) + method, found := findMethod(stateVal.Type(), ctx.Operation) + if !found { + return nil, fmt.Errorf("entity does not support operation '%s'", ctx.Operation) + } + + methodType := method.Type + // numIn includes the receiver + numIn := methodType.NumIn() + + var args []reflect.Value + args = append(args, stateVal) // receiver + + for i := 1; i < numIn; i++ { + paramType := methodType.In(i) + + // Check if it's *EntityContext + if paramType == reflect.TypeOf((*EntityContext)(nil)) { + args = append(args, reflect.ValueOf(ctx)) + continue + } + + // Check if it's api.EntityID + if paramType == reflect.TypeFor[api.EntityID]() { + args = append(args, reflect.ValueOf(ctx.ID)) + continue + } + + // Otherwise, treat as input parameter + inputPtr := reflect.New(paramType) + if err := ctx.GetInput(inputPtr.Interface()); err != nil { + return nil, fmt.Errorf("failed to deserialize input for operation '%s': %w", ctx.Operation, err) + } + args = append(args, inputPtr.Elem()) + } + + results := method.Func.Call(args) + + // Parse return values: expect (any, error) or (error) or () + switch len(results) { + case 0: + return nil, nil + case 1: + // Could be just error + if results[0].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) { + if results[0].IsNil() { + return nil, nil + } + return nil, results[0].Interface().(error) + } + return results[0].Interface(), nil + case 2: + var retErr error + if !results[1].IsNil() { + retErr = results[1].Interface().(error) + } + if results[0].IsNil() { + return nil, retErr + } + return results[0].Interface(), retErr + default: + return nil, fmt.Errorf("method '%s' has unsupported number of return values: %d", ctx.Operation, len(results)) + } +} diff --git a/task/entity_dispatch_test.go b/task/entity_dispatch_test.go new file mode 100644 index 00000000..6119e39d --- /dev/null +++ b/task/entity_dispatch_test.go @@ -0,0 +1,511 @@ +package task + +import ( + "fmt" + "testing" + + "github.com/microsoft/durabletask-go/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testCounter struct { + Value int `json:"value"` +} + +func (c *testCounter) Add(amount int) (any, error) { + c.Value += amount + return c.Value, nil +} + +func (c *testCounter) Get() (any, error) { + return c.Value, nil +} + +func (c *testCounter) Reset() (any, error) { + c.Value = 0 + return nil, nil +} + +func Test_EntityDispatcher_BasicOperations(t *testing.T) { + entity := NewEntityFor[testCounter]() + + // Test "Add" operation + ctx := &EntityContext{ + ID: api.NewEntityID("counter", "test"), + Operation: "Add", + rawInput: []byte("5"), + } + result, err := entity(ctx) + require.NoError(t, err) + assert.Equal(t, 5, result) + assert.True(t, ctx.HasState()) + + // Verify state was saved + var state testCounter + require.NoError(t, ctx.GetState(&state)) + assert.Equal(t, 5, state.Value) +} + +func Test_EntityDispatcher_CaseInsensitive(t *testing.T) { + entity := NewEntityFor[testCounter]() + + ctx := &EntityContext{ + ID: api.NewEntityID("counter", "test"), + Operation: "add", // lowercase + rawInput: []byte("10"), + } + result, err := entity(ctx) + require.NoError(t, err) + assert.Equal(t, 10, result) +} + +func Test_EntityDispatcher_WithExistingState(t *testing.T) { + entity := NewEntityFor[testCounter]() + + ctx := &EntityContext{ + ID: api.NewEntityID("counter", "test"), + Operation: "Add", + rawInput: []byte("3"), + state: entityState{value: []byte(`{"value":7}`), hasValue: true}, + } + result, err := entity(ctx) + require.NoError(t, err) + assert.Equal(t, 10, result) +} + +func Test_EntityDispatcher_Get(t *testing.T) { + entity := NewEntityFor[testCounter]() + + ctx := &EntityContext{ + ID: api.NewEntityID("counter", "test"), + Operation: "Get", + state: entityState{value: []byte(`{"value":42}`), hasValue: true}, + } + result, err := entity(ctx) + require.NoError(t, err) + assert.Equal(t, 42, result) +} + +func Test_EntityDispatcher_Reset(t *testing.T) { + entity := NewEntityFor[testCounter]() + + ctx := &EntityContext{ + ID: api.NewEntityID("counter", "test"), + Operation: "Reset", + state: entityState{value: []byte(`{"value":42}`), hasValue: true}, + } + _, err := entity(ctx) + require.NoError(t, err) + + var state testCounter + require.NoError(t, ctx.GetState(&state)) + assert.Equal(t, 0, state.Value) +} + +func Test_EntityDispatcher_ImplicitDelete(t *testing.T) { + entity := NewEntityFor[testCounter]() + + ctx := &EntityContext{ + ID: api.NewEntityID("counter", "test"), + Operation: "delete", + state: entityState{value: []byte(`{"value":42}`), hasValue: true}, + } + _, err := entity(ctx) + require.NoError(t, err) + assert.False(t, ctx.HasState()) +} + +func Test_EntityDispatcher_UnknownOperation(t *testing.T) { + entity := NewEntityFor[testCounter]() + + ctx := &EntityContext{ + ID: api.NewEntityID("counter", "test"), + Operation: "unknown", + } + _, err := entity(ctx) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not support operation") +} + +// Test with EntityContext parameter +type contextAwareEntity struct { + LastOp string `json:"lastOp"` +} + +func (e *contextAwareEntity) Info(ctx *EntityContext) (any, error) { + e.LastOp = ctx.Operation + return ctx.ID.String(), nil +} + +func Test_EntityDispatcher_WithEntityContext(t *testing.T) { + entity := NewEntityFor[contextAwareEntity]() + + ctx := &EntityContext{ + ID: api.NewEntityID("myentity", "key1"), + Operation: "Info", + } + result, err := entity(ctx) + require.NoError(t, err) + assert.Equal(t, "@myentity@key1", result) + + var state contextAwareEntity + require.NoError(t, ctx.GetState(&state)) + assert.Equal(t, "Info", state.LastOp) +} + +// OperationNotSupported_Fails: tests rejection of non-existent methods +func Test_EntityDispatcher_OperationNotSupported(t *testing.T) { + entity := NewEntityFor[testCounter]() + + tests := []struct { + name string + op string + }{ + {"non-existent method", "doesNotExist"}, + {"special chars", "add!"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &EntityContext{ + ID: api.NewEntityID("counter", "k"), + Operation: tt.op, + } + _, err := entity(ctx) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not support operation") + }) + } +} + +// Add_Success with case-insensitive matching (lowercase, uppercase, mixed) +func Test_EntityDispatcher_CaseInsensitiveMethodMatching(t *testing.T) { + entity := NewEntityFor[testCounter]() + + cases := []string{"add", "Add", "ADD", "aDd"} + for _, op := range cases { + t.Run(op, func(t *testing.T) { + ctx := &EntityContext{ + ID: api.NewEntityID("counter", "k"), + Operation: op, + rawInput: []byte("7"), + } + result, err := entity(ctx) + require.NoError(t, err) + assert.Equal(t, 7, result) + }) + } +} + +// Get_Success: state retrieval from existing state +func Test_EntityDispatcher_GetFromExistingState(t *testing.T) { + entity := NewEntityFor[testCounter]() + + ctx := &EntityContext{ + ID: api.NewEntityID("counter", "k"), + Operation: "Get", + state: entityState{value: []byte(`{"value":42}`), hasValue: true}, + } + result, err := entity(ctx) + require.NoError(t, err) + assert.Equal(t, 42, result) +} + +// Add_NoInput: in Go, missing input for int results in zero value +func Test_EntityDispatcher_MissingInputUsesZeroValue(t *testing.T) { + entity := NewEntityFor[testCounter]() + + ctx := &EntityContext{ + ID: api.NewEntityID("counter", "k"), + Operation: "Add", + // no rawInput — int will default to 0 + } + result, err := entity(ctx) + require.NoError(t, err) + assert.Equal(t, 0, result) // 0 + 0 = 0 +} + +// ImplicitDelete_ClearsState: default delete operation clears state +func Test_EntityDispatcher_ImplicitDeleteClearsState(t *testing.T) { + entity := NewEntityFor[testCounter]() + + // "delete" and "Delete" both work (case-insensitive) + for _, op := range []string{"delete", "Delete", "DELETE"} { + t.Run(op, func(t *testing.T) { + ctx := &EntityContext{ + ID: api.NewEntityID("counter", "k"), + Operation: op, + state: entityState{value: []byte(`{"value":42}`), hasValue: true}, + } + _, err := entity(ctx) + require.NoError(t, err) + assert.False(t, ctx.HasState(), "state should be cleared after delete") + }) + } +} + +// ExplicitDelete_Overridden: custom delete method takes precedence +type entityWithDelete struct { + Value int `json:"value"` + Deleted bool `json:"deleted"` +} + +func (e *entityWithDelete) Delete() (any, error) { + e.Deleted = true + return "custom delete", nil +} + +func (e *entityWithDelete) Get() (any, error) { + return e.Value, nil +} + +func Test_EntityDispatcher_ExplicitDeleteOverridesImplicit(t *testing.T) { + entity := NewEntityFor[entityWithDelete]() + + ctx := &EntityContext{ + ID: api.NewEntityID("e", "k"), + Operation: "delete", + state: entityState{value: []byte(`{"value":42,"deleted":false}`), hasValue: true}, + } + result, err := entity(ctx) + require.NoError(t, err) + assert.Equal(t, "custom delete", result) + // State should still exist (not implicitly cleared) since custom Delete ran + assert.True(t, ctx.HasState()) + + var state entityWithDelete + require.NoError(t, ctx.GetState(&state)) + assert.True(t, state.Deleted) + assert.Equal(t, 42, state.Value) +} + +// Throws_ExceptionPreserved: error propagation from entity methods +type errorEntity struct{} + +func (e *errorEntity) Fail() (any, error) { + return nil, fmt.Errorf("entity operation failed: %w", assert.AnError) +} + +func (e *errorEntity) Get() (any, error) { + return "ok", nil +} + +func Test_EntityDispatcher_ErrorPreserved(t *testing.T) { + entity := NewEntityFor[errorEntity]() + + ctx := &EntityContext{ + ID: api.NewEntityID("e", "k"), + Operation: "Fail", + } + _, err := entity(ctx) + require.Error(t, err) + assert.Contains(t, err.Error(), "entity operation failed") + assert.ErrorIs(t, err, assert.AnError) +} + +// State machine pattern: complex state transitions (ported from ExportJobTests) +type jobState struct { + Status string `json:"status"` + Name string `json:"name"` +} + +func (j *jobState) Create(name string) (any, error) { + if j.Status != "" { + return nil, fmt.Errorf("job already exists with status %s", j.Status) + } + j.Status = "active" + j.Name = name + return j.Status, nil +} + +func (j *jobState) Complete() (any, error) { + if j.Status != "active" { + return nil, fmt.Errorf("invalid transition: cannot complete job with status %s", j.Status) + } + j.Status = "completed" + return j.Status, nil +} + +func (j *jobState) Fail() (any, error) { + if j.Status != "active" { + return nil, fmt.Errorf("invalid transition: cannot fail job with status %s", j.Status) + } + j.Status = "failed" + return j.Status, nil +} + +func (j *jobState) Get() (any, error) { + return *j, nil +} + +func Test_EntityDispatcher_StateMachine(t *testing.T) { + entity := NewEntityFor[jobState]() + + t.Run("create then complete", func(t *testing.T) { + ctx := &EntityContext{ + ID: api.NewEntityID("job", "1"), Operation: "Create", + rawInput: []byte(`"myJob"`), + } + result, err := entity(ctx) + require.NoError(t, err) + assert.Equal(t, "active", result) + + ctx2 := &EntityContext{ + ID: api.NewEntityID("job", "1"), Operation: "Complete", + state: ctx.state, + } + result2, err := entity(ctx2) + require.NoError(t, err) + assert.Equal(t, "completed", result2) + }) + + t.Run("complete without create fails", func(t *testing.T) { + ctx := &EntityContext{ + ID: api.NewEntityID("job", "2"), Operation: "Complete", + } + _, err := entity(ctx) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid transition") + }) + + t.Run("create twice fails", func(t *testing.T) { + ctx := &EntityContext{ + ID: api.NewEntityID("job", "3"), Operation: "Create", + rawInput: []byte(`"first"`), + } + _, err := entity(ctx) + require.NoError(t, err) + + ctx2 := &EntityContext{ + ID: api.NewEntityID("job", "3"), Operation: "Create", + rawInput: []byte(`"second"`), state: ctx.state, + } + _, err = entity(ctx2) + require.Error(t, err) + assert.Contains(t, err.Error(), "already exists") + }) + + t.Run("create then fail then recreate", func(t *testing.T) { + ctx := &EntityContext{ + ID: api.NewEntityID("job", "4"), Operation: "Create", + rawInput: []byte(`"v1"`), + } + _, err := entity(ctx) + require.NoError(t, err) + + ctx2 := &EntityContext{ + ID: api.NewEntityID("job", "4"), Operation: "Fail", + state: ctx.state, + } + _, err = entity(ctx2) + require.NoError(t, err) + + // After failure, delete state and re-create + ctx3 := &EntityContext{ + ID: api.NewEntityID("job", "4"), Operation: "delete", + state: ctx2.state, + } + _, err = entity(ctx3) + require.NoError(t, err) + assert.False(t, ctx3.HasState()) + + ctx4 := &EntityContext{ + ID: api.NewEntityID("job", "4"), Operation: "Create", + rawInput: []byte(`"v2"`), + } + result, err := entity(ctx4) + require.NoError(t, err) + assert.Equal(t, "active", result) + }) +} + +// Multiple return type support +type multiReturnEntity struct{} + +func (e *multiReturnEntity) NoReturn() { + // void method +} + +func (e *multiReturnEntity) ErrorOnly() error { + return nil +} + +func (e *multiReturnEntity) ErrorOnlyFail() error { + return fmt.Errorf("error only") +} + +func (e *multiReturnEntity) ResultOnly() any { + return 42 +} + +func Test_EntityDispatcher_ReturnTypeVariations(t *testing.T) { + entity := NewEntityFor[multiReturnEntity]() + + t.Run("void return", func(t *testing.T) { + ctx := &EntityContext{ID: api.NewEntityID("e", "k"), Operation: "NoReturn"} + result, err := entity(ctx) + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("error-only success", func(t *testing.T) { + ctx := &EntityContext{ID: api.NewEntityID("e", "k"), Operation: "ErrorOnly"} + result, err := entity(ctx) + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("error-only failure", func(t *testing.T) { + ctx := &EntityContext{ID: api.NewEntityID("e", "k"), Operation: "ErrorOnlyFail"} + _, err := entity(ctx) + require.Error(t, err) + assert.Contains(t, err.Error(), "error only") + }) + + t.Run("result-only", func(t *testing.T) { + ctx := &EntityContext{ID: api.NewEntityID("e", "k"), Operation: "ResultOnly"} + result, err := entity(ctx) + require.NoError(t, err) + assert.Equal(t, 42, result) + }) +} + +// Entity with context + input parameter binding +type fullBindingEntity struct { + Log []string `json:"log"` +} + +func (e *fullBindingEntity) Process(ctx *EntityContext, msg string) (any, error) { + entry := fmt.Sprintf("%s:%s:%s", ctx.ID.String(), ctx.Operation, msg) + e.Log = append(e.Log, entry) + return len(e.Log), nil +} + +func Test_EntityDispatcher_ContextAndInputBinding(t *testing.T) { + entity := NewEntityFor[fullBindingEntity]() + + ctx := &EntityContext{ + ID: api.NewEntityID("logger", "main"), Operation: "Process", + rawInput: []byte(`"hello world"`), + } + result, err := entity(ctx) + require.NoError(t, err) + assert.Equal(t, 1, result) + + var state fullBindingEntity + require.NoError(t, ctx.GetState(&state)) + require.Len(t, state.Log, 1) + assert.Equal(t, "@logger@main:Process:hello world", state.Log[0]) +} + +func Test_EntityDispatcher_ZeroValueInitialization(t *testing.T) { + entity := NewEntityFor[testCounter]() + + // No initial state — should start with zero-value testCounter{Value: 0} + ctx := &EntityContext{ + ID: api.NewEntityID("counter", "new"), Operation: "Get", + } + result, err := entity(ctx) + require.NoError(t, err) + assert.Equal(t, 0, result) // zero-value int +} diff --git a/task/entity_test.go b/task/entity_test.go new file mode 100644 index 00000000..bcddb82f --- /dev/null +++ b/task/entity_test.go @@ -0,0 +1,125 @@ +package task + +import ( + "testing" + + "github.com/microsoft/durabletask-go/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_EntityContext_State(t *testing.T) { + t.Run("no state initially", func(t *testing.T) { + ctx := &EntityContext{ + ID: api.NewEntityID("test", "key1"), + Operation: "op", + } + assert.False(t, ctx.HasState()) + assert.Error(t, ctx.GetState(new(int))) + }) + + t.Run("set and get state", func(t *testing.T) { + ctx := &EntityContext{ + ID: api.NewEntityID("test", "key1"), + Operation: "op", + } + require.NoError(t, ctx.SetState(42)) + assert.True(t, ctx.HasState()) + + var val int + require.NoError(t, ctx.GetState(&val)) + assert.Equal(t, 42, val) + }) + + t.Run("delete state with nil", func(t *testing.T) { + ctx := &EntityContext{ + ID: api.NewEntityID("test", "key1"), + Operation: "op", + state: entityState{value: []byte("42"), hasValue: true}, + } + assert.True(t, ctx.HasState()) + require.NoError(t, ctx.SetState(nil)) + assert.False(t, ctx.HasState()) + }) + + t.Run("set struct state", func(t *testing.T) { + type MyState struct { + Count int `json:"count"` + Name string `json:"name"` + } + ctx := &EntityContext{ + ID: api.NewEntityID("test", "key1"), + Operation: "op", + } + require.NoError(t, ctx.SetState(MyState{Count: 5, Name: "hello"})) + assert.True(t, ctx.HasState()) + + var result MyState + require.NoError(t, ctx.GetState(&result)) + assert.Equal(t, 5, result.Count) + assert.Equal(t, "hello", result.Name) + }) +} + +func Test_EntityContext_GetInput(t *testing.T) { + ctx := &EntityContext{ + ID: api.NewEntityID("test", "key1"), + Operation: "op", + rawInput: []byte(`"hello"`), + } + + var input string + require.NoError(t, ctx.GetInput(&input)) + assert.Equal(t, "hello", input) +} + +func Test_EntityContext_SignalEntity(t *testing.T) { + ctx := &EntityContext{ + ID: api.NewEntityID("test", "key1"), + Operation: "op", + } + + err := ctx.SignalEntity(api.NewEntityID("other", "key2"), "increment", 5) + require.NoError(t, err) + require.Len(t, ctx.actions, 1) + + action := ctx.actions[0] + signal := action.GetSendSignal() + require.NotNil(t, signal) + assert.Equal(t, "@other@key2", signal.InstanceId) + assert.Equal(t, "increment", signal.Name) + assert.Equal(t, "5", signal.Input.GetValue()) +} + +func Test_EntityContext_StartNewOrchestration(t *testing.T) { + ctx := &EntityContext{ + ID: api.NewEntityID("test", "key1"), + Operation: "op", + } + + err := ctx.StartNewOrchestration("MyOrchestrator", + WithEntityStartOrchestrationInput("hello"), + WithEntityStartOrchestrationInstanceID("my-instance"), + ) + require.NoError(t, err) + require.Len(t, ctx.actions, 1) + + action := ctx.actions[0] + startOrch := action.GetStartNewOrchestration() + require.NotNil(t, startOrch) + assert.Equal(t, "MyOrchestrator", startOrch.Name) + assert.Equal(t, "my-instance", startOrch.InstanceId) + assert.Equal(t, `"hello"`, startOrch.Input.GetValue()) +} + +func Test_EntityRegistry(t *testing.T) { + r := NewTaskRegistry() + + myEntity := func(ctx *EntityContext) (any, error) { return nil, nil } + require.NoError(t, r.AddEntityN("counter", myEntity)) + + // Duplicate registration should fail + err := r.AddEntityN("counter", myEntity) + require.Error(t, err) + assert.Contains(t, err.Error(), "already registered") +} diff --git a/task/executor.go b/task/executor.go index c210e9be..36b2a60e 100644 --- a/task/executor.go +++ b/task/executor.go @@ -96,6 +96,128 @@ func (te taskExecutor) Shutdown(ctx context.Context) error { return nil } +// ExecuteEntity implements backend.Executor and executes an entity batch in the current goroutine. +func (te *taskExecutor) ExecuteEntity(ctx context.Context, id api.InstanceID, req *protos.EntityBatchRequest) (result *protos.EntityBatchResult, err error) { + entityID, parseErr := api.EntityIDFromString(req.InstanceId) + if parseErr != nil { + return nil, fmt.Errorf("invalid entity instance ID: %w", parseErr) + } + + invoker, ok := te.Registry.entities[entityID.Name] + if !ok { + // try the wildcard match + invoker, ok = te.Registry.entities["*"] + if !ok { + return &protos.EntityBatchResult{ + FailureDetails: &protos.TaskFailureDetails{ + ErrorType: "EntityNotRegistered", + ErrorMessage: fmt.Sprintf("no entity named '%s' was registered", entityID.Name), + }, + }, nil + } + } + + // Initialize entity state from the batch request + var state entityState + if req.EntityState != nil { + state = entityState{ + value: []byte(req.EntityState.GetValue()), + hasValue: true, + } + } + + results := make([]*protos.OperationResult, 0, len(req.Operations)) + var allActions []*protos.OperationAction + + for _, op := range req.Operations { + entityCtx := &EntityContext{ + ID: entityID, + Operation: op.Operation, + rawInput: []byte(op.Input.GetValue()), + state: state, + } + + // Execute the entity function, converting panics to failures + opResult := func() (opResult *protos.OperationResult) { + defer func() { + panicVal := recover() + if panicVal != nil { + opResult = &protos.OperationResult{ + ResultType: &protos.OperationResult_Failure{ + Failure: &protos.OperationResultFailure{ + FailureDetails: &protos.TaskFailureDetails{ + ErrorType: "EntityOperationPanic", + ErrorMessage: fmt.Sprintf("panic: %v", panicVal), + }, + }, + }, + } + } + }() + + output, opErr := invoker(entityCtx) + if opErr != nil { + // Operation failed - rollback state + return &protos.OperationResult{ + ResultType: &protos.OperationResult_Failure{ + Failure: &protos.OperationResultFailure{ + FailureDetails: &protos.TaskFailureDetails{ + ErrorType: fmt.Sprintf("%T", opErr), + ErrorMessage: fmt.Sprintf("%+v", opErr), + }, + }, + }, + } + } + + // Operation succeeded - commit state + state = entityCtx.state + allActions = append(allActions, entityCtx.actions...) + + var rawResult *wrapperspb.StringValue + if output != nil { + bytes, marshalErr := marshalData(output) + if marshalErr != nil { + return &protos.OperationResult{ + ResultType: &protos.OperationResult_Failure{ + Failure: &protos.OperationResultFailure{ + FailureDetails: &protos.TaskFailureDetails{ + ErrorType: fmt.Sprintf("%T", marshalErr), + ErrorMessage: fmt.Sprintf("failed to marshal entity result: %+v", marshalErr), + }, + }, + }, + } + } + if len(bytes) > 0 { + rawResult = wrapperspb.String(string(bytes)) + } + } + + return &protos.OperationResult{ + ResultType: &protos.OperationResult_Success{ + Success: &protos.OperationResultSuccess{ + Result: rawResult, + }, + }, + } + }() + + results = append(results, opResult) + } + + batchResult := &protos.EntityBatchResult{ + Results: results, + Actions: allActions, + } + + if state.hasValue { + batchResult.EntityState = wrapperspb.String(string(state.value)) + } + + return batchResult, nil +} + func unmarshalData(data []byte, v any) error { switch { case v == nil: diff --git a/task/orchestrator.go b/task/orchestrator.go index 8afc3ac6..4c680331 100644 --- a/task/orchestrator.go +++ b/task/orchestrator.go @@ -2,6 +2,7 @@ package task import ( "container/list" + "crypto/sha1" "encoding/json" "errors" "fmt" @@ -40,6 +41,7 @@ type OrchestrationContext struct { continuedAsNew bool continuedAsNewInput any customStatus string + newGuidCounter int bufferedExternalEvents map[string]*list.List pendingExternalEventTasks map[string]*list.List @@ -227,6 +229,8 @@ func (ctx *OrchestrationContext) processEvent(e *backend.HistoryEvent) error { err = ctx.onExecutionResumed(er) } else if et := e.GetExecutionTerminated(); et != nil { err = ctx.onExecutionTerminated(et) + } else if ev := e.GetEventSent(); ev != nil { + err = ctx.onEventSent(e.EventId, ev) } else if oc := e.GetOrchestratorCompleted(); oc != nil { // Nothing to do } else { @@ -239,6 +243,47 @@ func (octx *OrchestrationContext) SetCustomStatus(cs string) { octx.customStatus = cs } +// NewGuid generates a deterministic UUID v5 that is safe for use in orchestrator functions. +// The generated UUID is based on the orchestration instance ID, the current orchestrator +// timestamp, and an internal counter, making it deterministic across replays. +// +// This is compatible with the .NET Durable Task SDK's NewGuid implementation. +func (ctx *OrchestrationContext) NewGuid() string { + // Namespace UUID: 9e952958-5e33-4daf-827f-2fa12937b875 + // This matches the .NET SDK's DnsNamespaceValue + namespaceBytes := [16]byte{ + 0x9e, 0x95, 0x29, 0x58, + 0x5e, 0x33, + 0x4d, 0xaf, + 0x82, 0x7f, + 0x2f, 0xa1, 0x29, 0x37, 0xb8, 0x75, + } + + // Format: instanceID_timestamp_counter + // The timestamp uses .NET's 'o' format (7 fractional digits) + ts := ctx.CurrentTimeUtc.UTC().Format("2006-01-02T15:04:05.0000000Z") + name := fmt.Sprintf("%s_%s_%d", ctx.ID, ts, ctx.newGuidCounter) + ctx.newGuidCounter++ + + // SHA1(namespace + name) per UUID v5 spec + h := sha1.New() + h.Write(namespaceBytes[:]) + h.Write([]byte(name)) + hash := h.Sum(nil) + + // Take first 16 bytes + guid := hash[:16] + + // Set version to 5 + guid[6] = (guid[6] & 0x0F) | 0x50 + // Set variant to RFC 4122 + guid[8] = (guid[8] & 0x3F) | 0x80 + + // Format as UUID string + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + guid[0:4], guid[4:6], guid[6:8], guid[8:10], guid[10:16]) +} + // GetInput unmarshals the serialized orchestration input and stores it in [v]. func (octx *OrchestrationContext) GetInput(v any) error { return unmarshalData(octx.rawInput, v) @@ -429,6 +474,113 @@ func (ctx *OrchestrationContext) WaitForSingleEvent(eventName string, timeout ti return task } +// CallEntity sends an operation request to an entity and waits for a response. +// The [entityID] parameter identifies the target entity. The [operationName] parameter +// specifies the operation to invoke on the entity. +// +// This method returns a [Task] that completes when the entity operation finishes. +// The result of the entity operation can be obtained by calling [Await] on the returned task. +func (ctx *OrchestrationContext) CallEntity(entityID api.EntityID, operationName string, opts ...callEntityOption) Task { + options := new(callEntityOptions) + for _, configure := range opts { + if err := configure(options); err != nil { + failedTask := newTask(ctx) + failedTask.fail(helpers.NewTaskFailureDetails(err)) + return failedTask + } + } + + // Generate a deterministic request ID for response correlation. + requestID := ctx.NewGuid() + + // Send the operation request to the entity via a SendEvent action. + sendEventAction := helpers.NewSendEventAction( + entityID.String(), + operationName, + options.rawInput, + ) + sendEventAction.Id = ctx.getNextSequenceNumber() + ctx.pendingActions[sendEventAction.Id] = sendEventAction + + // Wait for the entity's response via an external event keyed on the request ID. + return ctx.WaitForSingleEvent(requestID, -1) +} + +// SignalEntity sends a fire-and-forget signal to an entity. +// The [entityID] parameter identifies the target entity. The [operationName] parameter +// specifies the operation to invoke on the entity. +// +// Unlike [CallEntity], this method does not wait for a response. The signal is +// processed asynchronously by the target entity. +func (ctx *OrchestrationContext) SignalEntity(entityID api.EntityID, operationName string, opts ...signalEntityOption) error { + options := new(signalEntityOptions) + for _, configure := range opts { + if err := configure(options); err != nil { + return err + } + } + + sendEventAction := helpers.NewSendEventAction( + entityID.String(), + operationName, + options.rawInput, + ) + sendEventAction.Id = ctx.getNextSequenceNumber() + ctx.pendingActions[sendEventAction.Id] = sendEventAction + return nil +} + +// LockEntities acquires locks on the specified entities, ensuring exclusive access. +// The locks are acquired by sending lock-acquisition events to each entity and waiting +// for confirmation. The returned unlock function must be called to release the locks. +// +// While holding locks, the orchestration is in a "critical section" and can safely +// call entity operations without the risk of conflicts from other orchestrations. +// +// Example usage: +// +// unlock, err := ctx.LockEntities(entityID1, entityID2) +// if err != nil { return nil, err } +// defer unlock() +// // ... perform entity operations safely ... +func (ctx *OrchestrationContext) LockEntities(entityIDs ...api.EntityID) (unlock func(), err error) { + if len(entityIDs) == 0 { + return func() {}, nil + } + + // Generate a deterministic ID for this critical section + criticalSectionID := ctx.NewGuid() + + // Send lock acquisition events to each entity and wait for confirmation + for _, entityID := range entityIDs { + sendEventAction := helpers.NewSendEventAction( + entityID.String(), + "lock:"+criticalSectionID, + nil, + ) + sendEventAction.Id = ctx.getNextSequenceNumber() + ctx.pendingActions[sendEventAction.Id] = sendEventAction + + // Wait for the lock confirmation + if err := ctx.WaitForSingleEvent("lock:"+entityID.String(), -1).Await(nil); err != nil { + return nil, fmt.Errorf("failed to acquire lock on entity %s: %w", entityID, err) + } + } + + // Return an unlock function that releases all locks + return func() { + for _, entityID := range entityIDs { + releaseAction := helpers.NewSendEventAction( + entityID.String(), + "unlock:"+criticalSectionID, + nil, + ) + releaseAction.Id = ctx.getNextSequenceNumber() + ctx.pendingActions[releaseAction.Id] = releaseAction + } + }, nil +} + func (ctx *OrchestrationContext) ContinueAsNew(newInput any, options ...ContinueAsNewOption) { ctx.continuedAsNew = true ctx.continuedAsNewInput = newInput @@ -594,6 +746,18 @@ func (ctx *OrchestrationContext) onTimerFired(tf *protos.TimerFiredEvent) error return nil } +func (ctx *OrchestrationContext) onEventSent(eventID int32, es *protos.EventSentEvent) error { + if a, ok := ctx.pendingActions[eventID]; !ok || a.GetSendEvent() == nil { + return fmt.Errorf( + "a previous execution sent an event to '%s' with sequence number %d at this point in the orchestration logic, but the current execution doesn't have this action with this sequence number", + es.InstanceId, + eventID, + ) + } + delete(ctx.pendingActions, eventID) + return nil +} + func (ctx *OrchestrationContext) onExternalEventRaised(e *protos.HistoryEvent) error { er := e.GetEventRaised() key := strings.ToUpper(er.GetName()) diff --git a/task/orchestrator_test.go b/task/orchestrator_test.go index f4842b53..72640b24 100644 --- a/task/orchestrator_test.go +++ b/task/orchestrator_test.go @@ -131,3 +131,44 @@ func Test_computeNextDelay(t *testing.T) { }) } } + +func Test_NewGuid_Deterministic(t *testing.T) { + ctx := &OrchestrationContext{ + ID: "test-instance-123", + CurrentTimeUtc: time.Date(2024, 1, 15, 10, 30, 45, 0, time.UTC), + } + + // Generate two GUIDs and verify they're different + guid1 := ctx.NewGuid() + guid2 := ctx.NewGuid() + if guid1 == guid2 { + t.Errorf("expected different GUIDs, got same: %s", guid1) + } + + // Verify determinism by resetting the counter + ctx.newGuidCounter = 0 + guid1Again := ctx.NewGuid() + if guid1 != guid1Again { + t.Errorf("expected deterministic GUID, got %s vs %s", guid1, guid1Again) + } +} + +func Test_NewGuid_Format(t *testing.T) { + ctx := &OrchestrationContext{ + ID: "test-instance", + CurrentTimeUtc: time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC), + } + + guid := ctx.NewGuid() + // UUID format: 8-4-4-4-12 hex chars + if len(guid) != 36 { + t.Errorf("expected UUID length 36, got %d: %s", len(guid), guid) + } + if guid[8] != '-' || guid[13] != '-' || guid[18] != '-' || guid[23] != '-' { + t.Errorf("expected UUID format with dashes, got: %s", guid) + } + // Version should be 5 (char at position 14) + if guid[14] != '5' { + t.Errorf("expected version 5 at position 14, got: %c in %s", guid[14], guid) + } +} diff --git a/task/registry.go b/task/registry.go index f3469dc2..96461876 100644 --- a/task/registry.go +++ b/task/registry.go @@ -6,10 +6,11 @@ import ( "github.com/microsoft/durabletask-go/internal/helpers" ) -// TaskRegistry contains maps of names to corresponding orchestrator and activity functions. +// TaskRegistry contains maps of names to corresponding orchestrator, activity, and entity functions. type TaskRegistry struct { orchestrators map[string]Orchestrator activities map[string]Activity + entities map[string]Entity } // NewTaskRegistry returns a new [TaskRegistry] struct. @@ -17,6 +18,7 @@ func NewTaskRegistry() *TaskRegistry { r := &TaskRegistry{ orchestrators: make(map[string]Orchestrator), activities: make(map[string]Activity), + entities: make(map[string]Entity), } return r } @@ -52,3 +54,19 @@ func (r *TaskRegistry) AddActivityN(name string, a Activity) error { r.activities[name] = a return nil } + +// AddEntity adds an entity function to the registry. The name of the entity +// function is determined using reflection. +func (r *TaskRegistry) AddEntity(e Entity) error { + name := helpers.GetTaskFunctionName(e) + return r.AddEntityN(name, e) +} + +// AddEntityN adds an entity function to the registry with a specified name. +func (r *TaskRegistry) AddEntityN(name string, e Entity) error { + if _, ok := r.entities[name]; ok { + return fmt.Errorf("entity named '%s' is already registered", name) + } + r.entities[name] = e + return nil +} diff --git a/tests/entity_client_test.go b/tests/entity_client_test.go new file mode 100644 index 00000000..aa9af1e4 --- /dev/null +++ b/tests/entity_client_test.go @@ -0,0 +1,260 @@ +// Tests for entity client operations, ported from the .NET SDK's ShimDurableEntityClientTests. +package tests + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/microsoft/durabletask-go/api" + "github.com/microsoft/durabletask-go/backend" + "github.com/microsoft/durabletask-go/tests/mocks" +) + +func newEntityClient(be backend.Backend) backend.EntityTaskHubClient { + return backend.NewTaskHubClient(be).(backend.EntityTaskHubClient) +} + +func Test_EntityClient_CleanEntityStorage_Defaults(t *testing.T) { + be := &mocks.EntityBackend{} + client := newEntityClient(be) + ctx := context.Background() + + req := api.CleanEntityStorageRequest{ + RemoveEmptyEntities: true, + ReleaseOrphanedLocks: true, + } + + be.EXPECT().CleanEntityStorage(ctx, req).Return(&api.CleanEntityStorageResult{ + EmptyEntitiesRemoved: 5, + OrphanedLocksReleased: 3, + }, nil).Once() + + result, err := client.CleanEntityStorage(ctx, req) + require.NoError(t, err) + assert.Equal(t, int32(5), result.EmptyEntitiesRemoved) + assert.Equal(t, int32(3), result.OrphanedLocksReleased) + assert.Empty(t, result.ContinuationToken) +} + +func Test_EntityClient_CleanEntityStorage_WithOptions(t *testing.T) { + tests := []struct { + name string + removeEmpty bool + releaseLocks bool + hasContinuationToken bool + }{ + {"remove only", true, false, false}, + {"release only", false, true, false}, + {"both", true, true, false}, + {"with continuation", true, true, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + be := &mocks.EntityBackend{} + client := newEntityClient(be) + ctx := context.Background() + + req := api.CleanEntityStorageRequest{ + RemoveEmptyEntities: tt.removeEmpty, + ReleaseOrphanedLocks: tt.releaseLocks, + } + if tt.hasContinuationToken { + req.ContinuationToken = "token123" + } + + expectedResult := &api.CleanEntityStorageResult{ + EmptyEntitiesRemoved: 2, + OrphanedLocksReleased: 1, + } + if tt.hasContinuationToken { + expectedResult.ContinuationToken = "nextToken" + } + + be.EXPECT().CleanEntityStorage(ctx, req).Return(expectedResult, nil).Once() + + result, err := client.CleanEntityStorage(ctx, req) + require.NoError(t, err) + assert.Equal(t, expectedResult.EmptyEntitiesRemoved, result.EmptyEntitiesRemoved) + assert.Equal(t, expectedResult.OrphanedLocksReleased, result.OrphanedLocksReleased) + assert.Equal(t, expectedResult.ContinuationToken, result.ContinuationToken) + }) + } +} + +func Test_EntityClient_FetchEntityMetadata(t *testing.T) { + tests := []struct { + name string + includeState bool + }{ + {"with state", true}, + {"without state", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + be := &mocks.EntityBackend{} + client := newEntityClient(be) + ctx := context.Background() + + entityID := api.NewEntityID("counter", "myCounter") + now := time.Now().Truncate(time.Second) + + expected := &api.EntityMetadata{ + InstanceID: entityID, + LastModifiedTime: now, + BacklogQueueSize: 2, + LockedBy: "some-orchestration", + } + if tt.includeState { + expected.SerializedState = `{"value":42}` + } + + be.EXPECT().GetEntityMetadata(ctx, entityID, tt.includeState).Return(expected, nil).Once() + + result, err := client.FetchEntityMetadata(ctx, entityID, tt.includeState) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, entityID, result.InstanceID) + assert.Equal(t, now, result.LastModifiedTime) + assert.Equal(t, int32(2), result.BacklogQueueSize) + assert.Equal(t, "some-orchestration", result.LockedBy) + if tt.includeState { + assert.Equal(t, `{"value":42}`, result.SerializedState) + } + }) + } +} + +func Test_EntityClient_FetchEntityMetadata_NotFound(t *testing.T) { + be := &mocks.EntityBackend{} + client := newEntityClient(be) + ctx := context.Background() + + entityID := api.NewEntityID("counter", "missing") + be.EXPECT().GetEntityMetadata(ctx, entityID, true).Return(nil, nil).Once() + + result, err := client.FetchEntityMetadata(ctx, entityID, true) + require.NoError(t, err) + assert.Nil(t, result) +} + +func Test_EntityClient_QueryEntities_NoFilter(t *testing.T) { + be := &mocks.EntityBackend{} + client := newEntityClient(be) + ctx := context.Background() + + query := api.EntityQuery{IncludeState: true} + + be.EXPECT().QueryEntities(ctx, query).Return(&api.EntityQueryResults{ + Entities: []*api.EntityMetadata{ + {InstanceID: api.NewEntityID("counter", "a"), SerializedState: `1`}, + {InstanceID: api.NewEntityID("counter", "b"), SerializedState: `2`}, + {InstanceID: api.NewEntityID("counter", "c"), SerializedState: `3`}, + }, + }, nil).Once() + + result, err := client.QueryEntities(ctx, query) + require.NoError(t, err) + require.Len(t, result.Entities, 3) + assert.Equal(t, "a", result.Entities[0].InstanceID.Key) + assert.Equal(t, "b", result.Entities[1].InstanceID.Key) + assert.Equal(t, "c", result.Entities[2].InstanceID.Key) +} + +func Test_EntityClient_QueryEntities_WithFilter(t *testing.T) { + be := &mocks.EntityBackend{} + client := newEntityClient(be) + ctx := context.Background() + + now := time.Now().Truncate(time.Second) + query := api.EntityQuery{ + InstanceIDStartsWith: "@counter@", + LastModifiedFrom: now.Add(-1 * time.Hour), + LastModifiedTo: now, + IncludeState: true, + IncludeTransient: false, + PageSize: 10, + ContinuationToken: "page1", + } + + be.EXPECT().QueryEntities(ctx, query).Return(&api.EntityQueryResults{ + Entities: []*api.EntityMetadata{ + {InstanceID: api.NewEntityID("counter", "x"), SerializedState: `99`}, + }, + ContinuationToken: "page2", + }, nil).Once() + + result, err := client.QueryEntities(ctx, query) + require.NoError(t, err) + require.Len(t, result.Entities, 1) + assert.Equal(t, "x", result.Entities[0].InstanceID.Key) + assert.Equal(t, "page2", result.ContinuationToken) +} + +// Tests that backend without EntityBackend returns errors for query/clean operations +func Test_EntityClient_NonEntityBackend_Fallbacks(t *testing.T) { + be := &mocks.Backend{} + client := newEntityClient(be) + ctx := context.Background() + + t.Run("QueryEntities returns error", func(t *testing.T) { + _, err := client.QueryEntities(ctx, api.EntityQuery{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "EntityBackend") + }) + + t.Run("CleanEntityStorage returns error", func(t *testing.T) { + _, err := client.CleanEntityStorage(ctx, api.CleanEntityStorageRequest{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "EntityBackend") + }) + + t.Run("FetchEntityMetadata falls back to orchestration metadata", func(t *testing.T) { + entityID := api.NewEntityID("counter", "fallback") + now := time.Now().Truncate(time.Second) + + be.EXPECT().GetOrchestrationMetadata(ctx, api.InstanceID("@counter@fallback")).Return( + &api.OrchestrationMetadata{ + InstanceID: api.InstanceID("@counter@fallback"), + LastUpdatedAt: now, + SerializedCustomStatus: `{"value":7}`, + }, nil).Once() + + result, err := client.FetchEntityMetadata(ctx, entityID, true) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, entityID, result.InstanceID) + assert.Equal(t, now, result.LastModifiedTime) + assert.Equal(t, `{"value":7}`, result.SerializedState) + }) +} + +func Test_EntityClient_SignalEntity(t *testing.T) { + be := &mocks.Backend{} + client := newEntityClient(be) + ctx := context.Background() + + entityID := api.NewEntityID("counter", "signalTest") + + // SignalEntity auto-creates the entity instance, then sends the event + be.EXPECT().CreateOrchestrationInstance( + ctx, + mock.AnythingOfType("*protos.HistoryEvent"), + mock.AnythingOfType("backend.OrchestrationIdReusePolicyOptions"), + ).Return(nil).Once() + + be.EXPECT().AddNewOrchestrationEvent( + ctx, + api.InstanceID("@counter@signalTest"), + mock.AnythingOfType("*protos.HistoryEvent"), + ).Return(nil).Once() + + err := client.SignalEntity(ctx, entityID, "increment", api.WithSignalInput(5)) + require.NoError(t, err) +} diff --git a/tests/entity_executor_test.go b/tests/entity_executor_test.go new file mode 100644 index 00000000..c77e9740 --- /dev/null +++ b/tests/entity_executor_test.go @@ -0,0 +1,618 @@ +package tests + +import ( + "context" + "fmt" + "testing" + + "github.com/microsoft/durabletask-go/api" + "github.com/microsoft/durabletask-go/backend" + "github.com/microsoft/durabletask-go/internal/protos" + "github.com/microsoft/durabletask-go/task" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +func newEntityExecutor(r *task.TaskRegistry) backend.EntityExecutor { + return task.NewTaskExecutor(r).(backend.EntityExecutor) +} + +func Test_Executor_EntityBasicOperation(t *testing.T) { + r := task.NewTaskRegistry() + require.NoError(t, r.AddEntityN("counter", func(ctx *task.EntityContext) (any, error) { + var count int + if ctx.HasState() { + if err := ctx.GetState(&count); err != nil { + return nil, err + } + } + + switch ctx.Operation { + case "add": + var amount int + if err := ctx.GetInput(&amount); err != nil { + return nil, err + } + count += amount + case "get": + return count, ctx.SetState(count) + case "reset": + count = 0 + } + + if err := ctx.SetState(count); err != nil { + return nil, err + } + return count, nil + })) + + executor := newEntityExecutor(r) + entityCtx := context.Background() + iid := api.InstanceID("@counter@myCounter") + + // Test "add" operation with no initial state + req := &protos.EntityBatchRequest{ + InstanceId: "@counter@myCounter", + Operations: []*protos.OperationRequest{ + { + Operation: "add", + RequestId: "req1", + Input: wrapperspb.String("5"), + }, + }, + } + + result, err := executor.ExecuteEntity(entityCtx, iid, req) + require.NoError(t, err) + require.Len(t, result.Results, 1) + require.NotNil(t, result.Results[0].GetSuccess()) + assert.Equal(t, "5", result.Results[0].GetSuccess().GetResult().GetValue()) + assert.Equal(t, "5", result.EntityState.GetValue()) + + // Test "add" again with existing state + req2 := &protos.EntityBatchRequest{ + InstanceId: "@counter@myCounter", + EntityState: result.EntityState, + Operations: []*protos.OperationRequest{ + { + Operation: "add", + RequestId: "req2", + Input: wrapperspb.String("3"), + }, + }, + } + + result2, err := executor.ExecuteEntity(entityCtx, iid, req2) + require.NoError(t, err) + require.Len(t, result2.Results, 1) + require.NotNil(t, result2.Results[0].GetSuccess()) + assert.Equal(t, "8", result2.Results[0].GetSuccess().GetResult().GetValue()) + assert.Equal(t, "8", result2.EntityState.GetValue()) + + // Test "get" operation + req3 := &protos.EntityBatchRequest{ + InstanceId: "@counter@myCounter", + EntityState: result2.EntityState, + Operations: []*protos.OperationRequest{ + { + Operation: "get", + RequestId: "req3", + }, + }, + } + + result3, err := executor.ExecuteEntity(entityCtx, iid, req3) + require.NoError(t, err) + require.Len(t, result3.Results, 1) + require.NotNil(t, result3.Results[0].GetSuccess()) + assert.Equal(t, "8", result3.Results[0].GetSuccess().GetResult().GetValue()) +} + +func Test_Executor_EntityBatchOperations(t *testing.T) { + r := task.NewTaskRegistry() + require.NoError(t, r.AddEntityN("counter", func(ctx *task.EntityContext) (any, error) { + var count int + if ctx.HasState() { + if err := ctx.GetState(&count); err != nil { + return nil, err + } + } + + switch ctx.Operation { + case "add": + var amount int + if err := ctx.GetInput(&amount); err != nil { + return nil, err + } + count += amount + case "get": + return count, ctx.SetState(count) + } + + if err := ctx.SetState(count); err != nil { + return nil, err + } + return count, nil + })) + + executor := newEntityExecutor(r) + entityCtx := context.Background() + iid := api.InstanceID("@counter@myCounter") + + // Batch multiple operations + req := &protos.EntityBatchRequest{ + InstanceId: "@counter@myCounter", + Operations: []*protos.OperationRequest{ + {Operation: "add", RequestId: "req1", Input: wrapperspb.String("10")}, + {Operation: "add", RequestId: "req2", Input: wrapperspb.String("20")}, + {Operation: "get", RequestId: "req3"}, + }, + } + + result, err := executor.ExecuteEntity(entityCtx, iid, req) + require.NoError(t, err) + require.Len(t, result.Results, 3) + + // First add: 0 + 10 = 10 + require.NotNil(t, result.Results[0].GetSuccess()) + assert.Equal(t, "10", result.Results[0].GetSuccess().GetResult().GetValue()) + + // Second add: 10 + 20 = 30 + require.NotNil(t, result.Results[1].GetSuccess()) + assert.Equal(t, "30", result.Results[1].GetSuccess().GetResult().GetValue()) + + // Get: 30 + require.NotNil(t, result.Results[2].GetSuccess()) + assert.Equal(t, "30", result.Results[2].GetSuccess().GetResult().GetValue()) + + // Final state should be 30 + assert.Equal(t, "30", result.EntityState.GetValue()) +} + +func Test_Executor_EntityOperationError(t *testing.T) { + r := task.NewTaskRegistry() + require.NoError(t, r.AddEntityN("faulty", func(ctx *task.EntityContext) (any, error) { + var count int + if ctx.HasState() { + _ = ctx.GetState(&count) + } + + switch ctx.Operation { + case "fail": + return nil, assert.AnError + case "add": + count++ + _ = ctx.SetState(count) + return count, nil + } + return nil, nil + })) + + executor := newEntityExecutor(r) + entityCtx := context.Background() + iid := api.InstanceID("@faulty@key1") + + // Batch: add, fail, add — the "fail" operation should not affect state + req := &protos.EntityBatchRequest{ + InstanceId: "@faulty@key1", + Operations: []*protos.OperationRequest{ + {Operation: "add", RequestId: "req1"}, + {Operation: "fail", RequestId: "req2"}, + {Operation: "add", RequestId: "req3"}, + }, + } + + result, err := executor.ExecuteEntity(entityCtx, iid, req) + require.NoError(t, err) + require.Len(t, result.Results, 3) + + // First add succeeds + require.NotNil(t, result.Results[0].GetSuccess()) + assert.Equal(t, "1", result.Results[0].GetSuccess().GetResult().GetValue()) + + // Second op fails + require.NotNil(t, result.Results[1].GetFailure()) + + // Third add succeeds (state from first op is preserved, failure is rolled back) + require.NotNil(t, result.Results[2].GetSuccess()) + assert.Equal(t, "2", result.Results[2].GetSuccess().GetResult().GetValue()) + + // Final state is 2 + assert.Equal(t, "2", result.EntityState.GetValue()) +} + +func Test_Executor_EntityPanic(t *testing.T) { + r := task.NewTaskRegistry() + require.NoError(t, r.AddEntityN("panicky", func(ctx *task.EntityContext) (any, error) { + panic("oh no!") + })) + + executor := newEntityExecutor(r) + entityCtx := context.Background() + iid := api.InstanceID("@panicky@key1") + + req := &protos.EntityBatchRequest{ + InstanceId: "@panicky@key1", + Operations: []*protos.OperationRequest{ + {Operation: "test", RequestId: "req1"}, + }, + } + + result, err := executor.ExecuteEntity(entityCtx, iid, req) + require.NoError(t, err) + require.Len(t, result.Results, 1) + require.NotNil(t, result.Results[0].GetFailure()) + assert.Contains(t, result.Results[0].GetFailure().GetFailureDetails().GetErrorMessage(), "oh no!") +} + +func Test_Executor_EntityNotRegistered(t *testing.T) { + r := task.NewTaskRegistry() + executor := newEntityExecutor(r) + entityCtx := context.Background() + iid := api.InstanceID("@unknown@key1") + + req := &protos.EntityBatchRequest{ + InstanceId: "@unknown@key1", + Operations: []*protos.OperationRequest{ + {Operation: "test", RequestId: "req1"}, + }, + } + + result, err := executor.ExecuteEntity(entityCtx, iid, req) + require.NoError(t, err) + require.NotNil(t, result.FailureDetails) + assert.Equal(t, "EntityNotRegistered", result.FailureDetails.ErrorType) +} + +func Test_Executor_EntitySignalAction(t *testing.T) { + r := task.NewTaskRegistry() + require.NoError(t, r.AddEntityN("sender", func(ctx *task.EntityContext) (any, error) { + return nil, ctx.SignalEntity(api.NewEntityID("receiver", "key2"), "notify", "hello") + })) + + executor := newEntityExecutor(r) + entityCtx := context.Background() + iid := api.InstanceID("@sender@key1") + + req := &protos.EntityBatchRequest{ + InstanceId: "@sender@key1", + Operations: []*protos.OperationRequest{ + {Operation: "send", RequestId: "req1"}, + }, + } + + result, err := executor.ExecuteEntity(entityCtx, iid, req) + require.NoError(t, err) + require.Len(t, result.Results, 1) + require.NotNil(t, result.Results[0].GetSuccess()) + + // Check that a signal action was emitted + require.Len(t, result.Actions, 1) + signal := result.Actions[0].GetSendSignal() + require.NotNil(t, signal) + assert.Equal(t, "@receiver@key2", signal.InstanceId) + assert.Equal(t, "notify", signal.Name) + assert.Equal(t, `"hello"`, signal.Input.GetValue()) +} + +func Test_Executor_EntityDeleteState(t *testing.T) { + r := task.NewTaskRegistry() + require.NoError(t, r.AddEntityN("deletable", func(ctx *task.EntityContext) (any, error) { + switch ctx.Operation { + case "set": + return nil, ctx.SetState(42) + case "delete": + return nil, ctx.SetState(nil) + } + return nil, nil + })) + + executor := newEntityExecutor(r) + entityCtx := context.Background() + iid := api.InstanceID("@deletable@key1") + + // First set state + req := &protos.EntityBatchRequest{ + InstanceId: "@deletable@key1", + Operations: []*protos.OperationRequest{ + {Operation: "set", RequestId: "req1"}, + }, + } + + result, err := executor.ExecuteEntity(entityCtx, iid, req) + require.NoError(t, err) + assert.Equal(t, "42", result.EntityState.GetValue()) + + // Then delete state + req2 := &protos.EntityBatchRequest{ + InstanceId: "@deletable@key1", + EntityState: result.EntityState, + Operations: []*protos.OperationRequest{ + {Operation: "delete", RequestId: "req2"}, + }, + } + + result2, err := executor.ExecuteEntity(entityCtx, iid, req2) + require.NoError(t, err) + assert.Nil(t, result2.EntityState) +} + +// Tests that state persists correctly across multiple batch requests +func Test_Executor_EntityStatePersistsAcrossBatches(t *testing.T) { + r := task.NewTaskRegistry() + require.NoError(t, r.AddEntityN("counter", func(ctx *task.EntityContext) (any, error) { + var count int + if ctx.HasState() { + _ = ctx.GetState(&count) + } + switch ctx.Operation { + case "increment": + count++ + case "get": + // no-op + } + _ = ctx.SetState(count) + return count, nil + })) + + executor := newEntityExecutor(r) + entityCtx := context.Background() + iid := api.InstanceID("@counter@persist") + + // Batch 1: increment 3 times + req := &protos.EntityBatchRequest{ + InstanceId: "@counter@persist", + Operations: []*protos.OperationRequest{ + {Operation: "increment", RequestId: "r1"}, + {Operation: "increment", RequestId: "r2"}, + {Operation: "increment", RequestId: "r3"}, + }, + } + result, err := executor.ExecuteEntity(entityCtx, iid, req) + require.NoError(t, err) + require.Len(t, result.Results, 3) + assert.Equal(t, "3", result.EntityState.GetValue()) + + // Batch 2: use state from batch 1, increment 2 more times + req2 := &protos.EntityBatchRequest{ + InstanceId: "@counter@persist", + EntityState: result.EntityState, + Operations: []*protos.OperationRequest{ + {Operation: "increment", RequestId: "r4"}, + {Operation: "get", RequestId: "r5"}, + }, + } + result2, err := executor.ExecuteEntity(entityCtx, iid, req2) + require.NoError(t, err) + require.Len(t, result2.Results, 2) + // After 4th increment: 4 + assert.Equal(t, "4", result2.Results[0].GetSuccess().GetResult().GetValue()) + // Get returns 4 + assert.Equal(t, "4", result2.Results[1].GetSuccess().GetResult().GetValue()) + assert.Equal(t, "4", result2.EntityState.GetValue()) +} + +// When an operation fails, state rolls back to the last successful commit +func Test_Executor_EntityErrorRollbackInBatch(t *testing.T) { + r := task.NewTaskRegistry() + require.NoError(t, r.AddEntityN("rollback", func(ctx *task.EntityContext) (any, error) { + var val int + if ctx.HasState() { + _ = ctx.GetState(&val) + } + switch ctx.Operation { + case "set": + var newVal int + if err := ctx.GetInput(&newVal); err != nil { + return nil, err + } + val = newVal + _ = ctx.SetState(val) + return val, nil + case "fail_after_set": + val = 999 // modify state... + _ = ctx.SetState(val) + return nil, fmt.Errorf("intentional failure") // ...then fail + case "get": + _ = ctx.SetState(val) + return val, nil + } + return nil, fmt.Errorf("unknown op") + })) + + executor := newEntityExecutor(r) + entityCtx := context.Background() + iid := api.InstanceID("@rollback@key1") + + req := &protos.EntityBatchRequest{ + InstanceId: "@rollback@key1", + Operations: []*protos.OperationRequest{ + {Operation: "set", RequestId: "r1", Input: wrapperspb.String("10")}, + {Operation: "fail_after_set", RequestId: "r2"}, // fails, state should rollback + {Operation: "get", RequestId: "r3"}, + }, + } + + result, err := executor.ExecuteEntity(entityCtx, iid, req) + require.NoError(t, err) + require.Len(t, result.Results, 3) + + // First op succeeds with value 10 + require.NotNil(t, result.Results[0].GetSuccess()) + assert.Equal(t, "10", result.Results[0].GetSuccess().GetResult().GetValue()) + + // Second op fails + require.NotNil(t, result.Results[1].GetFailure()) + assert.Contains(t, result.Results[1].GetFailure().GetFailureDetails().GetErrorMessage(), "intentional failure") + + // Third op sees state 10 (rolled back from 999) + require.NotNil(t, result.Results[2].GetSuccess()) + assert.Equal(t, "10", result.Results[2].GetSuccess().GetResult().GetValue()) + + // Final state is 10 (not 999) + assert.Equal(t, "10", result.EntityState.GetValue()) +} + +func Test_Executor_EntityWildcardRegistration(t *testing.T) { + r := task.NewTaskRegistry() + // Register a wildcard entity that handles any entity name + require.NoError(t, r.AddEntityN("*", func(ctx *task.EntityContext) (any, error) { + return fmt.Sprintf("handled %s on %s", ctx.Operation, ctx.ID.Name), nil + })) + + executor := newEntityExecutor(r) + entityCtx := context.Background() + iid := api.InstanceID("@anything@key1") + + req := &protos.EntityBatchRequest{ + InstanceId: "@anything@key1", + Operations: []*protos.OperationRequest{ + {Operation: "test", RequestId: "r1"}, + }, + } + + result, err := executor.ExecuteEntity(entityCtx, iid, req) + require.NoError(t, err) + require.Len(t, result.Results, 1) + require.NotNil(t, result.Results[0].GetSuccess()) + assert.Equal(t, `"handled test on anything"`, result.Results[0].GetSuccess().GetResult().GetValue()) +} + +func Test_Executor_EntitySignalAndStartOrchestrationActions(t *testing.T) { + r := task.NewTaskRegistry() + require.NoError(t, r.AddEntityN("coordinator", func(ctx *task.EntityContext) (any, error) { + switch ctx.Operation { + case "notify_all": + // Signal another entity + _ = ctx.SignalEntity(api.NewEntityID("worker", "w1"), "process", nil) + _ = ctx.SignalEntity(api.NewEntityID("worker", "w2"), "process", nil) + // Start an orchestration + _ = ctx.StartNewOrchestration("CleanupOrchestrator", + task.WithEntityStartOrchestrationInstanceID("cleanup-1"), + task.WithEntityStartOrchestrationInput("batch-42"), + ) + return "notified", nil + } + return nil, nil + })) + + executor := newEntityExecutor(r) + entityCtx := context.Background() + iid := api.InstanceID("@coordinator@main") + + req := &protos.EntityBatchRequest{ + InstanceId: "@coordinator@main", + Operations: []*protos.OperationRequest{ + {Operation: "notify_all", RequestId: "r1"}, + }, + } + + result, err := executor.ExecuteEntity(entityCtx, iid, req) + require.NoError(t, err) + require.Len(t, result.Results, 1) + require.NotNil(t, result.Results[0].GetSuccess()) + + // Should have 3 actions: 2 signals + 1 start orchestration + require.Len(t, result.Actions, 3) + + signal1 := result.Actions[0].GetSendSignal() + require.NotNil(t, signal1) + assert.Equal(t, "@worker@w1", signal1.InstanceId) + assert.Equal(t, "process", signal1.Name) + + signal2 := result.Actions[1].GetSendSignal() + require.NotNil(t, signal2) + assert.Equal(t, "@worker@w2", signal2.InstanceId) + + startOrch := result.Actions[2].GetStartNewOrchestration() + require.NotNil(t, startOrch) + assert.Equal(t, "CleanupOrchestrator", startOrch.Name) + assert.Equal(t, "cleanup-1", startOrch.InstanceId) + assert.Equal(t, `"batch-42"`, startOrch.Input.GetValue()) +} + +func Test_Executor_EntityComplexState(t *testing.T) { + type item struct { + Name string `json:"name"` + Price int `json:"price"` + } + type cart struct { + Items []item `json:"items"` + Total int `json:"total"` + } + + r := task.NewTaskRegistry() + require.NoError(t, r.AddEntityN("cart", func(ctx *task.EntityContext) (any, error) { + var state cart + if ctx.HasState() { + _ = ctx.GetState(&state) + } + switch ctx.Operation { + case "add_item": + var i item + if err := ctx.GetInput(&i); err != nil { + return nil, err + } + state.Items = append(state.Items, i) + state.Total += i.Price + _ = ctx.SetState(state) + return len(state.Items), nil + case "get": + _ = ctx.SetState(state) + return state, nil + case "clear": + _ = ctx.SetState(nil) + return nil, nil + } + return nil, nil + })) + + executor := newEntityExecutor(r) + entityCtx := context.Background() + iid := api.InstanceID("@cart@user1") + + req := &protos.EntityBatchRequest{ + InstanceId: "@cart@user1", + Operations: []*protos.OperationRequest{ + {Operation: "add_item", RequestId: "r1", Input: wrapperspb.String(`{"name":"apple","price":3}`)}, + {Operation: "add_item", RequestId: "r2", Input: wrapperspb.String(`{"name":"banana","price":2}`)}, + {Operation: "get", RequestId: "r3"}, + }, + } + + result, err := executor.ExecuteEntity(entityCtx, iid, req) + require.NoError(t, err) + require.Len(t, result.Results, 3) + + // 1 item after first add + assert.Equal(t, "1", result.Results[0].GetSuccess().GetResult().GetValue()) + // 2 items after second add + assert.Equal(t, "2", result.Results[1].GetSuccess().GetResult().GetValue()) + // Get returns full cart + getResult := result.Results[2].GetSuccess().GetResult().GetValue() + assert.Contains(t, getResult, `"apple"`) + assert.Contains(t, getResult, `"banana"`) + assert.Contains(t, getResult, `"total":5`) +} + +func Test_Executor_EntityEmptyBatch(t *testing.T) { + r := task.NewTaskRegistry() + require.NoError(t, r.AddEntityN("noop", func(ctx *task.EntityContext) (any, error) { + return nil, nil + })) + + executor := newEntityExecutor(r) + entityCtx := context.Background() + iid := api.InstanceID("@noop@key1") + + req := &protos.EntityBatchRequest{ + InstanceId: "@noop@key1", + Operations: []*protos.OperationRequest{}, + } + + result, err := executor.ExecuteEntity(entityCtx, iid, req) + require.NoError(t, err) + assert.Empty(t, result.Results) + assert.Empty(t, result.Actions) +} diff --git a/tests/entity_integration_test.go b/tests/entity_integration_test.go new file mode 100644 index 00000000..fa33b7cd --- /dev/null +++ b/tests/entity_integration_test.go @@ -0,0 +1,129 @@ +// Integration tests for in-process entity execution via the orchestration worker. +package tests + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/microsoft/durabletask-go/api" + "github.com/microsoft/durabletask-go/backend" + "github.com/microsoft/durabletask-go/task" +) + +// Test that an entity can be signaled from the client and processed in-process. +func Test_InProcess_Entity_SignalAndQuery(t *testing.T) { + r := task.NewTaskRegistry() + require.NoError(t, r.AddEntityN("counter", func(ctx *task.EntityContext) (any, error) { + var count int + if ctx.HasState() { + _ = ctx.GetState(&count) + } + switch ctx.Operation { + case "add": + var amount int + if err := ctx.GetInput(&amount); err != nil { + return nil, err + } + count += amount + case "get": + // no-op, just return + } + _ = ctx.SetState(count) + return count, nil + })) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + baseClient, worker := initTaskHubWorker(ctx, r) + client := baseClient.(backend.EntityTaskHubClient) + defer func() { + if err := worker.Shutdown(context.Background()); err != nil { + t.Logf("shutdown: %v", err) + } + }() + + entityID := api.NewEntityID("counter", "test1") + + // Signal the entity to add 5 + err := client.SignalEntity(ctx, entityID, "add", api.WithSignalInput(5)) + require.NoError(t, err) + + // Give the entity worker time to process + time.Sleep(2 * time.Second) + + // Signal again to add 3 + err = client.SignalEntity(ctx, entityID, "add", api.WithSignalInput(3)) + require.NoError(t, err) + + time.Sleep(2 * time.Second) + + // Verify state via FetchEntityMetadata + meta, err := client.FetchEntityMetadata(ctx, entityID, true) + require.NoError(t, err) + require.NotNil(t, meta) + assert.Equal(t, entityID, meta.InstanceID) + // State should be 8 (5+3), stored as custom status + assert.Contains(t, meta.SerializedState, "8") +} + +// Test that entities work with the auto-dispatch pattern. +func Test_InProcess_Entity_AutoDispatch(t *testing.T) { + type counter struct { + Value int `json:"value"` + } + + r := task.NewTaskRegistry() + require.NoError(t, r.AddEntityN("smartcounter", task.NewEntityFor[counter]())) + // Register a dummy Add method on counter for the dispatcher + // Actually, we need counter to have methods. Let's use the raw entity pattern instead. + // Re-register with raw function since we can't add methods to a local type. + + r2 := task.NewTaskRegistry() + require.NoError(t, r2.AddEntityN("counter", func(ctx *task.EntityContext) (any, error) { + var val int + if ctx.HasState() { + _ = ctx.GetState(&val) + } + switch ctx.Operation { + case "increment": + val++ + case "decrement": + val-- + case "get": + } + _ = ctx.SetState(val) + return val, nil + })) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + baseClient, worker := initTaskHubWorker(ctx, r2) + client := baseClient.(backend.EntityTaskHubClient) + defer func() { + if err := worker.Shutdown(context.Background()); err != nil { + t.Logf("shutdown: %v", err) + } + }() + + entityID := api.NewEntityID("counter", "auto") + + // Send multiple signals + require.NoError(t, client.SignalEntity(ctx, entityID, "increment")) + require.NoError(t, client.SignalEntity(ctx, entityID, "increment")) + require.NoError(t, client.SignalEntity(ctx, entityID, "increment")) + require.NoError(t, client.SignalEntity(ctx, entityID, "decrement")) + + time.Sleep(3 * time.Second) + + // Should be 2 (3 increments - 1 decrement) + meta, err := client.FetchEntityMetadata(ctx, entityID, true) + require.NoError(t, err) + require.NotNil(t, meta) + assert.Contains(t, meta.SerializedState, "2") +} diff --git a/tests/mocks/EntityBackend.go b/tests/mocks/EntityBackend.go new file mode 100644 index 00000000..3fafbd15 --- /dev/null +++ b/tests/mocks/EntityBackend.go @@ -0,0 +1,144 @@ +package mocks + +import ( + "context" + + "github.com/microsoft/durabletask-go/api" + "github.com/stretchr/testify/mock" +) + +// EntityBackend is a mock that implements both backend.Backend and backend.EntityBackend. +type EntityBackend struct { + Backend +} + +type EntityBackend_Expecter struct { + mock *mock.Mock +} + +func (_m *EntityBackend) EXPECT() *EntityBackend_Expecter { + return &EntityBackend_Expecter{mock: &_m.Mock} +} + +// GetEntityMetadata provides a mock function +func (_m *EntityBackend) GetEntityMetadata(_a0 context.Context, _a1 api.EntityID, _a2 bool) (*api.EntityMetadata, error) { + ret := _m.Called(_a0, _a1, _a2) + + if len(ret) == 0 { + panic("no return value specified for GetEntityMetadata") + } + + var r0 *api.EntityMetadata + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, api.EntityID, bool) (*api.EntityMetadata, error)); ok { + return rf(_a0, _a1, _a2) + } + if rf, ok := ret.Get(0).(func(context.Context, api.EntityID, bool) *api.EntityMetadata); ok { + r0 = rf(_a0, _a1, _a2) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*api.EntityMetadata) + } + } + if rf, ok := ret.Get(1).(func(context.Context, api.EntityID, bool) error); ok { + r1 = rf(_a0, _a1, _a2) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +func (_e *EntityBackend_Expecter) GetEntityMetadata(_a0 interface{}, _a1 interface{}, _a2 interface{}) *EntityBackend_GetEntityMetadata_Call { + return &EntityBackend_GetEntityMetadata_Call{Call: _e.mock.On("GetEntityMetadata", _a0, _a1, _a2)} +} + +type EntityBackend_GetEntityMetadata_Call struct { + *mock.Call +} + +func (_c *EntityBackend_GetEntityMetadata_Call) Return(_a0 *api.EntityMetadata, _a1 error) *EntityBackend_GetEntityMetadata_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +// QueryEntities provides a mock function +func (_m *EntityBackend) QueryEntities(_a0 context.Context, _a1 api.EntityQuery) (*api.EntityQueryResults, error) { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for QueryEntities") + } + + var r0 *api.EntityQueryResults + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, api.EntityQuery) (*api.EntityQueryResults, error)); ok { + return rf(_a0, _a1) + } + if rf, ok := ret.Get(0).(func(context.Context, api.EntityQuery) *api.EntityQueryResults); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*api.EntityQueryResults) + } + } + if rf, ok := ret.Get(1).(func(context.Context, api.EntityQuery) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +func (_e *EntityBackend_Expecter) QueryEntities(_a0 interface{}, _a1 interface{}) *EntityBackend_QueryEntities_Call { + return &EntityBackend_QueryEntities_Call{Call: _e.mock.On("QueryEntities", _a0, _a1)} +} + +type EntityBackend_QueryEntities_Call struct { + *mock.Call +} + +func (_c *EntityBackend_QueryEntities_Call) Return(_a0 *api.EntityQueryResults, _a1 error) *EntityBackend_QueryEntities_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +// CleanEntityStorage provides a mock function +func (_m *EntityBackend) CleanEntityStorage(_a0 context.Context, _a1 api.CleanEntityStorageRequest) (*api.CleanEntityStorageResult, error) { + ret := _m.Called(_a0, _a1) + + if len(ret) == 0 { + panic("no return value specified for CleanEntityStorage") + } + + var r0 *api.CleanEntityStorageResult + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, api.CleanEntityStorageRequest) (*api.CleanEntityStorageResult, error)); ok { + return rf(_a0, _a1) + } + if rf, ok := ret.Get(0).(func(context.Context, api.CleanEntityStorageRequest) *api.CleanEntityStorageResult); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*api.CleanEntityStorageResult) + } + } + if rf, ok := ret.Get(1).(func(context.Context, api.CleanEntityStorageRequest) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +func (_e *EntityBackend_Expecter) CleanEntityStorage(_a0 interface{}, _a1 interface{}) *EntityBackend_CleanEntityStorage_Call { + return &EntityBackend_CleanEntityStorage_Call{Call: _e.mock.On("CleanEntityStorage", _a0, _a1)} +} + +type EntityBackend_CleanEntityStorage_Call struct { + *mock.Call +} + +func (_c *EntityBackend_CleanEntityStorage_Call) Return(_a0 *api.CleanEntityStorageResult, _a1 error) *EntityBackend_CleanEntityStorage_Call { + _c.Call.Return(_a0, _a1) + return _c +} From 4eec0d6f6343daa66973308c2115907604894e54 Mon Sep 17 00:00:00 2001 From: qmuntal Date: Tue, 24 Mar 2026 11:59:48 +0100 Subject: [PATCH 02/13] fix: address golangci-lint issues - Replace log.Fatalf with log.Printf+return in entity sample to avoid exitAfterDefer (gocritic) - Replace single-case switch with if statement in entity executor test (gocritic) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/entity/entity.go | 24 ++++++++++++++++-------- tests/entity_executor_test.go | 3 +-- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/samples/entity/entity.go b/samples/entity/entity.go index 793dfd4f..a06d9f50 100644 --- a/samples/entity/entity.go +++ b/samples/entity/entity.go @@ -46,13 +46,16 @@ func main() { // Signal the entity to perform operations if err := client.SignalEntity(ctx, counterID, "add", api.WithSignalInput(10)); err != nil { - log.Fatalf("Failed to signal entity: %v", err) + log.Printf("Failed to signal entity: %v", err) //nolint:gocritic // sample code, keeping simple + return } if err := client.SignalEntity(ctx, counterID, "add", api.WithSignalInput(5)); err != nil { - log.Fatalf("Failed to signal entity: %v", err) + log.Printf("Failed to signal entity: %v", err) + return } if err := client.SignalEntity(ctx, counterID, "add", api.WithSignalInput(-3)); err != nil { - log.Fatalf("Failed to signal entity: %v", err) + log.Printf("Failed to signal entity: %v", err) + return } // Wait for processing @@ -61,7 +64,8 @@ func main() { // Query the entity state meta, err := client.FetchEntityMetadata(ctx, counterID, true) if err != nil { - log.Fatalf("Failed to fetch entity: %v", err) + log.Printf("Failed to fetch entity: %v", err) + return } fmt.Printf("Counter state: %s\n", meta.SerializedState) // Expected: 12 @@ -70,20 +74,24 @@ func main() { accountID := api.NewEntityID("bankaccount", "checking-001") if err := client.SignalEntity(ctx, accountID, "Deposit", api.WithSignalInput(1000)); err != nil { - log.Fatalf("Failed to signal entity: %v", err) + log.Printf("Failed to signal entity: %v", err) + return } if err := client.SignalEntity(ctx, accountID, "Deposit", api.WithSignalInput(500)); err != nil { - log.Fatalf("Failed to signal entity: %v", err) + log.Printf("Failed to signal entity: %v", err) + return } if err := client.SignalEntity(ctx, accountID, "Withdraw", api.WithSignalInput(200)); err != nil { - log.Fatalf("Failed to signal entity: %v", err) + log.Printf("Failed to signal entity: %v", err) + return } time.Sleep(3 * time.Second) meta, err = client.FetchEntityMetadata(ctx, accountID, true) if err != nil { - log.Fatalf("Failed to fetch entity: %v", err) + log.Printf("Failed to fetch entity: %v", err) + return } fmt.Printf("Bank account state: %s\n", meta.SerializedState) // Expected: {"balance":1300} diff --git a/tests/entity_executor_test.go b/tests/entity_executor_test.go index c77e9740..d3a615ce 100644 --- a/tests/entity_executor_test.go +++ b/tests/entity_executor_test.go @@ -482,8 +482,7 @@ func Test_Executor_EntityWildcardRegistration(t *testing.T) { func Test_Executor_EntitySignalAndStartOrchestrationActions(t *testing.T) { r := task.NewTaskRegistry() require.NoError(t, r.AddEntityN("coordinator", func(ctx *task.EntityContext) (any, error) { - switch ctx.Operation { - case "notify_all": + if ctx.Operation == "notify_all" { // Signal another entity _ = ctx.SignalEntity(api.NewEntityID("worker", "w1"), "process", nil) _ = ctx.SignalEntity(api.NewEntityID("worker", "w2"), "process", nil) From b57984630cd5ecdba4fb431e0860ee1dccd1c81d Mon Sep 17 00:00:00 2001 From: qmuntal Date: Tue, 24 Mar 2026 12:06:54 +0100 Subject: [PATCH 03/13] fix: address code review feedback - entity_dispatch.go: safe nil check for non-nilable return types (int, struct) to prevent reflect.Value.IsNil panic - api/entity.go: normalize entity name to lowercase in EntityIDFromString for consistent lookup - orchestration.go: use EntityIDFromString parsing instead of HasPrefix for robust entity detection; treat batch-level failures as non-retriable - orchestrator.go: encode requestID in CallEntity event name for response correlation; stub LockEntities until backend scheduler support exists - executor.go: store pending entity after dispatch to prevent leak on context cancellation; clean up on cancel - worker_grpc.go: process entity work items synchronously to preserve FIFO completion ordering - entity_integration_test.go: replace time.Sleep with require.Eventually polling for deterministic CI behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/entity.go | 2 +- backend/executor.go | 7 ++++-- backend/orchestration.go | 10 +++++---- client/worker_grpc.go | 2 +- task/entity_dispatch.go | 27 ++++++++++++++++++----- task/orchestrator.go | 38 ++------------------------------ tests/entity_integration_test.go | 37 +++++++++++++++---------------- 7 files changed, 54 insertions(+), 69 deletions(-) diff --git a/api/entity.go b/api/entity.go index ca71a837..33c56f08 100644 --- a/api/entity.go +++ b/api/entity.go @@ -37,7 +37,7 @@ func EntityIDFromString(s string) (EntityID, error) { if !ok { return EntityID{}, fmt.Errorf("invalid entity instance ID format: missing second '@'") } - return EntityID{Name: before, Key: after}, nil + return EntityID{Name: strings.ToLower(before), Key: after}, nil } // EntityMetadata contains metadata about an entity instance. diff --git a/backend/executor.go b/backend/executor.go index fc2affc7..7ed12705 100644 --- a/backend/executor.go +++ b/backend/executor.go @@ -233,8 +233,6 @@ func (g *grpcExecutor) Shutdown(ctx context.Context) error { func (executor *grpcExecutor) ExecuteEntity(ctx context.Context, iid api.InstanceID, req *protos.EntityBatchRequest) (*protos.EntityBatchResult, error) { key := req.InstanceId result := &entityExecutionResult{complete: make(chan struct{})} - executor.pendingEntities.Store(key, result) - executor.entityQueue <- key workItem := &protos.WorkItem{ Request: &protos.WorkItem_EntityRequest{ @@ -242,6 +240,7 @@ func (executor *grpcExecutor) ExecuteEntity(ctx context.Context, iid api.Instanc }, } + // Send the work item first, then register pending state select { case <-ctx.Done(): executor.logger.Warnf("%s: context canceled before dispatching entity work item", iid) @@ -249,8 +248,12 @@ func (executor *grpcExecutor) ExecuteEntity(ctx context.Context, iid api.Instanc case executor.workItemQueue <- workItem: } + executor.pendingEntities.Store(key, result) + executor.entityQueue <- key + select { case <-ctx.Done(): + executor.pendingEntities.Delete(key) executor.logger.Warnf("%s: context canceled before receiving entity result", iid) return nil, ctx.Err() case <-result.complete: diff --git a/backend/orchestration.go b/backend/orchestration.go index 3b61310f..6e62154e 100644 --- a/backend/orchestration.go +++ b/backend/orchestration.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "strings" "time" "go.opentelemetry.io/otel/attribute" @@ -69,8 +68,10 @@ func (w *orchestratorProcessor) ProcessWorkItem(ctx context.Context, cwi WorkIte w.logger.Debugf("%v: received work item with %d new event(s): %v", wi.InstanceID, len(wi.NewEvents), helpers.HistoryListSummary(wi.NewEvents)) // Detect entity instances by their "@name@key" prefix and route to entity executor - if w.entityExecutor != nil && strings.HasPrefix(string(wi.InstanceID), "@") { - return w.processEntityWorkItem(ctx, wi) + if w.entityExecutor != nil { + if _, err := api.EntityIDFromString(string(wi.InstanceID)); err == nil { + return w.processEntityWorkItem(ctx, wi) + } } // TODO: Caching @@ -214,7 +215,8 @@ func (w *orchestratorProcessor) processEntityWorkItem(ctx context.Context, wi *O return fmt.Errorf("failed to execute entity: %w", err) } if batchResult.FailureDetails != nil { - return fmt.Errorf("entity execution failed: %s", batchResult.FailureDetails.ErrorMessage) + w.logger.Errorf("%v: non-retriable entity execution failure: %s", wi.InstanceID, batchResult.FailureDetails.ErrorMessage) + return nil } // Ensure the entity orchestration instance exists in state diff --git a/client/worker_grpc.go b/client/worker_grpc.go index 8e4f732e..2efc1a86 100644 --- a/client/worker_grpc.go +++ b/client/worker_grpc.go @@ -122,7 +122,7 @@ func (c *TaskHubGrpcClient) StartWorkItemListener(ctx context.Context, r *task.T } else if actReq := workItem.GetActivityRequest(); actReq != nil { go c.processActivityWorkItem(ctx, executor, actReq) } else if entityReq := workItem.GetEntityRequest(); entityReq != nil { - go c.processEntityWorkItem(ctx, executor, entityReq) + c.processEntityWorkItem(ctx, executor, entityReq) } else { c.logger.Warnf("received unknown work item type: %v", workItem) } diff --git a/task/entity_dispatch.go b/task/entity_dispatch.go index 5fa28f83..66fd00c3 100644 --- a/task/entity_dispatch.go +++ b/task/entity_dispatch.go @@ -131,24 +131,39 @@ func dispatchToMethod[S any](ctx *EntityContext, state *S) (any, error) { results := method.Func.Call(args) // Parse return values: expect (any, error) or (error) or () + errorType := reflect.TypeOf((*error)(nil)).Elem() + isNilValue := func(v reflect.Value) bool { + switch v.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice: + return v.IsNil() + default: + return false + } + } + switch len(results) { case 0: return nil, nil case 1: - // Could be just error - if results[0].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) { - if results[0].IsNil() { + if results[0].Type().Implements(errorType) { + if isNilValue(results[0]) { return nil, nil } return nil, results[0].Interface().(error) } return results[0].Interface(), nil case 2: + errVal := results[1] + if !errVal.Type().Implements(errorType) { + return nil, fmt.Errorf("method '%s' has unsupported error return type: %s", ctx.Operation, errVal.Type()) + } + var retErr error - if !results[1].IsNil() { - retErr = results[1].Interface().(error) + if !isNilValue(errVal) { + retErr = errVal.Interface().(error) } - if results[0].IsNil() { + + if isNilValue(results[0]) { return nil, retErr } return results[0].Interface(), retErr diff --git a/task/orchestrator.go b/task/orchestrator.go index 4c680331..674e9fb7 100644 --- a/task/orchestrator.go +++ b/task/orchestrator.go @@ -496,7 +496,7 @@ func (ctx *OrchestrationContext) CallEntity(entityID api.EntityID, operationName // Send the operation request to the entity via a SendEvent action. sendEventAction := helpers.NewSendEventAction( entityID.String(), - operationName, + operationName+"|"+requestID, options.rawInput, ) sendEventAction.Id = ctx.getNextSequenceNumber() @@ -544,41 +544,7 @@ func (ctx *OrchestrationContext) SignalEntity(entityID api.EntityID, operationNa // defer unlock() // // ... perform entity operations safely ... func (ctx *OrchestrationContext) LockEntities(entityIDs ...api.EntityID) (unlock func(), err error) { - if len(entityIDs) == 0 { - return func() {}, nil - } - - // Generate a deterministic ID for this critical section - criticalSectionID := ctx.NewGuid() - - // Send lock acquisition events to each entity and wait for confirmation - for _, entityID := range entityIDs { - sendEventAction := helpers.NewSendEventAction( - entityID.String(), - "lock:"+criticalSectionID, - nil, - ) - sendEventAction.Id = ctx.getNextSequenceNumber() - ctx.pendingActions[sendEventAction.Id] = sendEventAction - - // Wait for the lock confirmation - if err := ctx.WaitForSingleEvent("lock:"+entityID.String(), -1).Await(nil); err != nil { - return nil, fmt.Errorf("failed to acquire lock on entity %s: %w", entityID, err) - } - } - - // Return an unlock function that releases all locks - return func() { - for _, entityID := range entityIDs { - releaseAction := helpers.NewSendEventAction( - entityID.String(), - "unlock:"+criticalSectionID, - nil, - ) - releaseAction.Id = ctx.getNextSequenceNumber() - ctx.pendingActions[releaseAction.Id] = releaseAction - } - }, nil + return nil, fmt.Errorf("entity locking is not yet fully implemented: requires backend-level entity scheduler support") } func (ctx *OrchestrationContext) ContinueAsNew(newInput any, options ...ContinueAsNewOption) { diff --git a/tests/entity_integration_test.go b/tests/entity_integration_test.go index fa33b7cd..aaf0236f 100644 --- a/tests/entity_integration_test.go +++ b/tests/entity_integration_test.go @@ -3,6 +3,7 @@ package tests import ( "context" + "strings" "testing" "time" @@ -53,22 +54,19 @@ func Test_InProcess_Entity_SignalAndQuery(t *testing.T) { err := client.SignalEntity(ctx, entityID, "add", api.WithSignalInput(5)) require.NoError(t, err) - // Give the entity worker time to process - time.Sleep(2 * time.Second) - // Signal again to add 3 err = client.SignalEntity(ctx, entityID, "add", api.WithSignalInput(3)) require.NoError(t, err) - time.Sleep(2 * time.Second) - - // Verify state via FetchEntityMetadata - meta, err := client.FetchEntityMetadata(ctx, entityID, true) - require.NoError(t, err) - require.NotNil(t, meta) - assert.Equal(t, entityID, meta.InstanceID) - // State should be 8 (5+3), stored as custom status - assert.Contains(t, meta.SerializedState, "8") + // Poll until state contains "8" + require.Eventually(t, func() bool { + meta, err := client.FetchEntityMetadata(ctx, entityID, true) + if err != nil || meta == nil { + return false + } + return assert.ObjectsAreEqual(entityID, meta.InstanceID) && + strings.Contains(meta.SerializedState, "8") + }, 10*time.Second, 200*time.Millisecond) } // Test that entities work with the auto-dispatch pattern. @@ -119,11 +117,12 @@ func Test_InProcess_Entity_AutoDispatch(t *testing.T) { require.NoError(t, client.SignalEntity(ctx, entityID, "increment")) require.NoError(t, client.SignalEntity(ctx, entityID, "decrement")) - time.Sleep(3 * time.Second) - - // Should be 2 (3 increments - 1 decrement) - meta, err := client.FetchEntityMetadata(ctx, entityID, true) - require.NoError(t, err) - require.NotNil(t, meta) - assert.Contains(t, meta.SerializedState, "2") + // Poll until state contains "2" + require.Eventually(t, func() bool { + meta, err := client.FetchEntityMetadata(ctx, entityID, true) + if err != nil || meta == nil { + return false + } + return strings.Contains(meta.SerializedState, "2") + }, 10*time.Second, 200*time.Millisecond) } From 170d24432df332d89becafbc1e73faa5b4ab01f0 Mon Sep 17 00:00:00 2001 From: qmuntal Date: Tue, 24 Mar 2026 13:14:26 +0100 Subject: [PATCH 04/13] refactor: wire-compatible entity message protocol Implement the .NET DTFx-compatible entity message protocol: - Request event name: 'op' (matching EntityMessageEventNames) - Request payload: JSON EntityRequestMessage with id, parentInstanceId, isSignal, operation, input fields - Response event name: requestID GUID - Response payload: JSON EntityResponseMessage with result, errorMessage Move wire protocol types to internal/helpers/entity.go so they are shared between task and backend packages but not exported to consumers. Also: - Simplify NewGuid to use uuid.NewSHA1 instead of manual SHA1 hashing - Auto-create entity instances in orchestration processor CompleteWorkItem for backend-agnostic CallEntity support (no sqlite-specific changes) - Add Test_InProcess_Entity_CallEntity regression test - Remove splitEntityEventName helper (no longer needed) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/client.go | 18 ++++- backend/orchestration.go | 129 +++++++++++++++++++++++++++++-- internal/helpers/entity.go | 22 ++++++ task/orchestrator.go | 107 ++++++++++++++++--------- tests/entity_integration_test.go | 61 +++++++++++++++ 5 files changed, 294 insertions(+), 43 deletions(-) create mode 100644 internal/helpers/entity.go diff --git a/backend/client.go b/backend/client.go index 4712683d..c014195d 100644 --- a/backend/client.go +++ b/backend/client.go @@ -2,6 +2,7 @@ package backend import ( "context" + "encoding/json" "errors" "fmt" "time" @@ -10,6 +11,7 @@ import ( "github.com/google/uuid" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" + "google.golang.org/protobuf/types/known/wrapperspb" "github.com/microsoft/durabletask-go/api" "github.com/microsoft/durabletask-go/internal/helpers" @@ -243,7 +245,21 @@ func (c *backendClient) SignalEntity(ctx context.Context, entityID api.EntityID, return fmt.Errorf("failed to create entity instance: %w", createErr) } - e := helpers.NewEventRaisedEvent(req.Name, req.Input) + // Build the .NET-compatible EntityRequestMessage payload with isSignal=true. + reqMsg := helpers.EntityRequestMessage{ + ID: uuid.New().String(), + IsSignal: true, + Operation: req.Name, + } + if req.Input != nil { + reqMsg.Input = req.Input.GetValue() + } + payload, err := json.Marshal(reqMsg) + if err != nil { + return fmt.Errorf("failed to marshal signal request: %w", err) + } + + e := helpers.NewEventRaisedEvent(helpers.EntityRequestEventName, wrapperspb.String(string(payload))) if err := c.be.AddNewOrchestrationEvent(ctx, api.InstanceID(req.InstanceId), e); err != nil { return fmt.Errorf("failed to signal entity: %w", err) } diff --git a/backend/orchestration.go b/backend/orchestration.go index 6e62154e..b04a7f96 100644 --- a/backend/orchestration.go +++ b/backend/orchestration.go @@ -2,10 +2,12 @@ package backend import ( "context" + "encoding/json" "errors" "fmt" "time" + "github.com/google/uuid" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" @@ -153,6 +155,27 @@ func (w *orchestratorProcessor) ProcessWorkItem(ctx context.Context, cwi WorkIte // CompleteWorkItem implements TaskProcessor func (p *orchestratorProcessor) CompleteWorkItem(ctx context.Context, wi WorkItem) error { owi := wi.(*OrchestrationWorkItem) + + // Auto-create entity instances for any pending messages targeting entity IDs. + // This ensures CallEntity from orchestrations works even when the target entity + // doesn't exist yet, without requiring backend-specific entity support. + for _, msg := range owi.State.PendingMessages() { + if msg.HistoryEvent.GetExecutionStarted() != nil { + continue // sub-orchestration creation, handled by the backend + } + entityID, err := api.EntityIDFromString(msg.TargetInstanceID) + if err != nil { + continue // not an entity ID + } + startEvent := helpers.NewExecutionStartedEvent(entityID.Name, msg.TargetInstanceID, nil, nil, nil, nil) + if createErr := p.be.CreateOrchestrationInstance(ctx, startEvent, WithOrchestrationIdReusePolicy(&protos.OrchestrationIdReusePolicy{ + Action: protos.CreateOrchestrationAction_IGNORE, + OperationStatus: []protos.OrchestrationStatus{protos.OrchestrationStatus_ORCHESTRATION_STATUS_RUNNING}, + })); createErr != nil && !errors.Is(createErr, api.ErrDuplicateInstance) && !errors.Is(createErr, api.ErrIgnoreInstance) { + p.logger.Warnf("%v: failed to auto-create entity instance %s: %v", owi.InstanceID, msg.TargetInstanceID, createErr) + } + } + return p.be.CompleteOrchestrationWorkItem(ctx, owi) } @@ -186,16 +209,61 @@ func (w *orchestratorProcessor) processEntityWorkItem(ctx context.Context, wi *O entityState = wrapperspb.String(meta.SerializedCustomStatus) } - // Convert new EventRaised events into entity OperationRequests + // entityCallInfo tracks response routing for CallEntity requests. + type entityCallInfo struct { + callerInstanceID string + requestID string + isSignal bool + } + + // Convert new EventRaised and EventSent events into entity OperationRequests. + // Events use the .NET-compatible protocol: event name is "op" and the payload + // is a JSON EntityRequestMessage containing routing and operation information. var operations []*protos.OperationRequest + var callInfos []entityCallInfo // parallel array for _, e := range wi.NewEvents { + var eventName string + var eventInput *wrapperspb.StringValue + if er := e.GetEventRaised(); er != nil { - operations = append(operations, &protos.OperationRequest{ - Operation: er.Name, - RequestId: fmt.Sprintf("%d", e.EventId), - Input: er.Input, - }) + eventName = er.Name + eventInput = er.Input + } else if es := e.GetEventSent(); es != nil { + eventName = es.Name + eventInput = es.Input + } else { + continue + } + + if eventName != helpers.EntityRequestEventName { + continue + } + + var reqMsg helpers.EntityRequestMessage + if eventInput == nil || eventInput.GetValue() == "" { + w.logger.Warnf("%v: received 'op' event with no payload, skipping", wi.InstanceID) + continue } + if err := json.Unmarshal([]byte(eventInput.GetValue()), &reqMsg); err != nil { + w.logger.Warnf("%v: failed to parse RequestMessage: %v", wi.InstanceID, err) + continue + } + + var inputVal *wrapperspb.StringValue + if reqMsg.Input != "" { + inputVal = wrapperspb.String(reqMsg.Input) + } + + operations = append(operations, &protos.OperationRequest{ + Operation: reqMsg.Operation, + RequestId: reqMsg.ID, + Input: inputVal, + }) + callInfos = append(callInfos, entityCallInfo{ + callerInstanceID: reqMsg.ParentInstanceID, + requestID: reqMsg.ID, + isSignal: reqMsg.IsSignal, + }) } if len(operations) == 0 { @@ -239,10 +307,57 @@ func (w *orchestratorProcessor) processEntityWorkItem(ctx context.Context, wi *O // Save entity state as the orchestration's custom status wi.State.CustomStatus = batchResult.EntityState + // Send results back to calling orchestrations (for CallEntity requests) + for i, info := range callInfos { + if info.isSignal || info.callerInstanceID == "" || info.requestID == "" { + continue // signal, no response needed + } + if i >= len(batchResult.Results) { + break + } + + // Build the .NET-compatible EntityResponseMessage payload. + var resp helpers.EntityResponseMessage + if success := batchResult.Results[i].GetSuccess(); success != nil { + if success.Result != nil { + resp.Result = success.Result.GetValue() + } + } else if failure := batchResult.Results[i].GetFailure(); failure != nil { + resp.ErrorMessage = failure.FailureDetails.GetErrorMessage() + } + + respJSON, err := json.Marshal(resp) + if err != nil { + w.logger.Warnf("%v: failed to marshal entity response: %v", wi.InstanceID, err) + continue + } + + // Send the result as an EventRaised to the caller orchestration, using the requestID + // as the event name so it matches the WaitForSingleEvent in CallEntity. + responseEvent := helpers.NewEventRaisedEvent(info.requestID, wrapperspb.String(string(respJSON))) + if err := w.be.AddNewOrchestrationEvent(ctx, api.InstanceID(info.callerInstanceID), responseEvent); err != nil { + w.logger.Warnf("%v: failed to send entity response to %s: %v", wi.InstanceID, info.callerInstanceID, err) + } + } + // Process actions from the entity batch result (signals to other entities, new orchestrations) for _, action := range batchResult.Actions { if signal := action.GetSendSignal(); signal != nil { - e := helpers.NewEventRaisedEvent(signal.Name, signal.Input) + // Wrap entity-to-entity signals in the .NET-compatible EntityRequestMessage format. + sigMsg := helpers.EntityRequestMessage{ + ID: uuid.New().String(), + IsSignal: true, + Operation: signal.Name, + } + if signal.Input != nil { + sigMsg.Input = signal.Input.GetValue() + } + sigJSON, err := json.Marshal(sigMsg) + if err != nil { + w.logger.Warnf("%v: failed to marshal signal request: %v", wi.InstanceID, err) + continue + } + e := helpers.NewEventRaisedEvent("op", wrapperspb.String(string(sigJSON))) if err := w.be.AddNewOrchestrationEvent(ctx, api.InstanceID(signal.InstanceId), e); err != nil { w.logger.Warnf("%v: failed to send entity signal to %s: %v", wi.InstanceID, signal.InstanceId, err) } diff --git a/internal/helpers/entity.go b/internal/helpers/entity.go new file mode 100644 index 00000000..f7e99e7b --- /dev/null +++ b/internal/helpers/entity.go @@ -0,0 +1,22 @@ +package helpers + +// EntityRequestEventName is the event name used for all entity operation requests. +// This matches the .NET DTFx EntityMessageEventNames.RequestMessageEventName constant. +const EntityRequestEventName = "op" + +// EntityRequestMessage is the payload sent to an entity for operation requests. +// This matches the .NET DTFx RequestMessage format for wire compatibility. +type EntityRequestMessage struct { + ID string `json:"id"` + ParentInstanceID string `json:"parentInstanceId,omitempty"` + IsSignal bool `json:"isSignal"` + Operation string `json:"operation"` + Input string `json:"input,omitempty"` +} + +// EntityResponseMessage is the payload sent back from an entity to a calling orchestration. +// This matches the .NET DTFx ResponseMessage format for wire compatibility. +type EntityResponseMessage struct { + Result string `json:"result,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` +} diff --git a/task/orchestrator.go b/task/orchestrator.go index 674e9fb7..bab89212 100644 --- a/task/orchestrator.go +++ b/task/orchestrator.go @@ -2,7 +2,6 @@ package task import ( "container/list" - "crypto/sha1" "encoding/json" "errors" "fmt" @@ -12,6 +11,7 @@ import ( "google.golang.org/protobuf/types/known/wrapperspb" + "github.com/google/uuid" "github.com/microsoft/durabletask-go/api" "github.com/microsoft/durabletask-go/backend" "github.com/microsoft/durabletask-go/internal/helpers" @@ -243,45 +243,21 @@ func (octx *OrchestrationContext) SetCustomStatus(cs string) { octx.customStatus = cs } +// guidNamespace UUID matching the .NET SDK's DnsNamespaceValue +var guidNamespace = uuid.MustParse("9e952958-5e33-4daf-827f-2fa12937b875") + // NewGuid generates a deterministic UUID v5 that is safe for use in orchestrator functions. // The generated UUID is based on the orchestration instance ID, the current orchestrator // timestamp, and an internal counter, making it deterministic across replays. // // This is compatible with the .NET Durable Task SDK's NewGuid implementation. func (ctx *OrchestrationContext) NewGuid() string { - // Namespace UUID: 9e952958-5e33-4daf-827f-2fa12937b875 - // This matches the .NET SDK's DnsNamespaceValue - namespaceBytes := [16]byte{ - 0x9e, 0x95, 0x29, 0x58, - 0x5e, 0x33, - 0x4d, 0xaf, - 0x82, 0x7f, - 0x2f, 0xa1, 0x29, 0x37, 0xb8, 0x75, - } - // Format: instanceID_timestamp_counter // The timestamp uses .NET's 'o' format (7 fractional digits) ts := ctx.CurrentTimeUtc.UTC().Format("2006-01-02T15:04:05.0000000Z") name := fmt.Sprintf("%s_%s_%d", ctx.ID, ts, ctx.newGuidCounter) ctx.newGuidCounter++ - - // SHA1(namespace + name) per UUID v5 spec - h := sha1.New() - h.Write(namespaceBytes[:]) - h.Write([]byte(name)) - hash := h.Sum(nil) - - // Take first 16 bytes - guid := hash[:16] - - // Set version to 5 - guid[6] = (guid[6] & 0x0F) | 0x50 - // Set variant to RFC 4122 - guid[8] = (guid[8] & 0x3F) | 0x80 - - // Format as UUID string - return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", - guid[0:4], guid[4:6], guid[6:8], guid[8:10], guid[10:16]) + return uuid.NewSHA1(guidNamespace, []byte(name)).String() } // GetInput unmarshals the serialized orchestration input and stores it in [v]. @@ -493,17 +469,34 @@ func (ctx *OrchestrationContext) CallEntity(entityID api.EntityID, operationName // Generate a deterministic request ID for response correlation. requestID := ctx.NewGuid() - // Send the operation request to the entity via a SendEvent action. + // Build the .NET-compatible RequestMessage payload. + reqMsg := helpers.EntityRequestMessage{ + ID: requestID, + ParentInstanceID: string(ctx.ID), + IsSignal: false, + Operation: operationName, + } + if options.rawInput != nil { + reqMsg.Input = options.rawInput.GetValue() + } + payload, err := json.Marshal(reqMsg) + if err != nil { + failedTask := newTask(ctx) + failedTask.fail(helpers.NewTaskFailureDetails(err)) + return failedTask + } + sendEventAction := helpers.NewSendEventAction( entityID.String(), - operationName+"|"+requestID, - options.rawInput, + helpers.EntityRequestEventName, + wrapperspb.String(string(payload)), ) sendEventAction.Id = ctx.getNextSequenceNumber() ctx.pendingActions[sendEventAction.Id] = sendEventAction // Wait for the entity's response via an external event keyed on the request ID. - return ctx.WaitForSingleEvent(requestID, -1) + // Wrap the task to unwrap the ResponseMessage payload. + return &entityResponseTask{delegate: ctx.WaitForSingleEvent(requestID, -1)} } // SignalEntity sends a fire-and-forget signal to an entity. @@ -520,10 +513,24 @@ func (ctx *OrchestrationContext) SignalEntity(entityID api.EntityID, operationNa } } + // Build the .NET-compatible RequestMessage payload with isSignal=true. + reqMsg := helpers.EntityRequestMessage{ + ID: ctx.NewGuid(), + IsSignal: true, + Operation: operationName, + } + if options.rawInput != nil { + reqMsg.Input = options.rawInput.GetValue() + } + payload, err := json.Marshal(reqMsg) + if err != nil { + return fmt.Errorf("failed to marshal signal request: %w", err) + } + sendEventAction := helpers.NewSendEventAction( entityID.String(), - operationName, - options.rawInput, + helpers.EntityRequestEventName, + wrapperspb.String(string(payload)), ) sendEventAction.Id = ctx.getNextSequenceNumber() ctx.pendingActions[sendEventAction.Id] = sendEventAction @@ -859,3 +866,33 @@ func (ctx *OrchestrationContext) actions() []*protos.OrchestratorAction { } return actions } + +// entityResponseTask wraps a task to unwrap entity response payloads. +type entityResponseTask struct { + delegate Task +} + +func (t *entityResponseTask) Await(v any) error { + var resp helpers.EntityResponseMessage + if err := t.delegate.Await(&resp); err != nil { + return err + } + if resp.ErrorMessage != "" { + return &taskFailedError{ErrorMessage: resp.ErrorMessage} + } + if v != nil && resp.Result != "" { + if err := unmarshalData([]byte(resp.Result), v); err != nil { + return err + } + } + return nil +} + +// taskFailedError represents a failure returned by an entity operation. +type taskFailedError struct { + ErrorMessage string +} + +func (e *taskFailedError) Error() string { + return e.ErrorMessage +} diff --git a/tests/entity_integration_test.go b/tests/entity_integration_test.go index aaf0236f..53d73c8d 100644 --- a/tests/entity_integration_test.go +++ b/tests/entity_integration_test.go @@ -126,3 +126,64 @@ func Test_InProcess_Entity_AutoDispatch(t *testing.T) { return strings.Contains(meta.SerializedState, "2") }, 10*time.Second, 200*time.Millisecond) } + +// Test that CallEntity works end-to-end: an orchestration calls an entity and gets a response. +func Test_InProcess_Entity_CallEntity(t *testing.T) { + r := task.NewTaskRegistry() + + // Register a counter entity + require.NoError(t, r.AddEntityN("counter", func(ctx *task.EntityContext) (any, error) { + var count int + if ctx.HasState() { + _ = ctx.GetState(&count) + } + switch ctx.Operation { + case "add": + var amount int + if err := ctx.GetInput(&amount); err != nil { + return nil, err + } + count += amount + case "get": + // just return + } + _ = ctx.SetState(count) + return count, nil + })) + + // Register an orchestration that calls the entity and returns the result + require.NoError(t, r.AddOrchestratorN("CallEntityOrchestrator", func(ctx *task.OrchestrationContext) (any, error) { + entityID := api.NewEntityID("counter", "fromOrch") + + // Call entity (request-response) to add and get result + var result int + if err := ctx.CallEntity(entityID, "add", task.WithEntityInput(15)).Await(&result); err != nil { + return nil, err + } + + return result, nil + })) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + baseClient, worker := initTaskHubWorker(ctx, r) + defer func() { + if err := worker.Shutdown(context.Background()); err != nil { + t.Logf("shutdown: %v", err) + } + }() + + // Note: we do NOT pre-create the entity — the orchestration processor + // auto-creates entity instances when pending messages target entity IDs. + + // Run the orchestration + id, err := baseClient.ScheduleNewOrchestration(ctx, "CallEntityOrchestrator") + require.NoError(t, err) + + // Wait for orchestration to complete + metadata, err := baseClient.WaitForOrchestrationCompletion(ctx, id) + require.NoError(t, err) + assert.Equal(t, "ORCHESTRATION_STATUS_COMPLETED", metadata.RuntimeStatus.String()) + assert.Contains(t, metadata.SerializedOutput, "15") +} From d66156e0c34dc7d97c0699190c4bb53e6e8d1185 Mon Sep 17 00:00:00 2001 From: qmuntal Date: Tue, 24 Mar 2026 13:18:06 +0100 Subject: [PATCH 05/13] fix: address remaining review feedback - Remove dead code in Test_InProcess_Entity_AutoDispatch (unused registry and smartcounter registration) - Fix race in grpcExecutor.ExecuteEntity: store pendingEntities before dispatching work item, clean up on cancellation - Add entity auto-creation in grpcExecutor.SignalEntity to mirror backendClient.SignalEntity behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/executor.go | 15 +++++++++++++-- tests/entity_integration_test.go | 14 ++------------ 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/backend/executor.go b/backend/executor.go index 7ed12705..e4af549e 100644 --- a/backend/executor.go +++ b/backend/executor.go @@ -233,6 +233,7 @@ func (g *grpcExecutor) Shutdown(ctx context.Context) error { func (executor *grpcExecutor) ExecuteEntity(ctx context.Context, iid api.InstanceID, req *protos.EntityBatchRequest) (*protos.EntityBatchResult, error) { key := req.InstanceId result := &entityExecutionResult{complete: make(chan struct{})} + executor.pendingEntities.Store(key, result) workItem := &protos.WorkItem{ Request: &protos.WorkItem_EntityRequest{ @@ -240,15 +241,14 @@ func (executor *grpcExecutor) ExecuteEntity(ctx context.Context, iid api.Instanc }, } - // Send the work item first, then register pending state select { case <-ctx.Done(): + executor.pendingEntities.Delete(key) executor.logger.Warnf("%s: context canceled before dispatching entity work item", iid) return nil, ctx.Err() case executor.workItemQueue <- workItem: } - executor.pendingEntities.Store(key, result) executor.entityQueue <- key select { @@ -513,6 +513,17 @@ func (g *grpcExecutor) RaiseEvent(ctx context.Context, req *protos.RaiseEventReq // SignalEntity implements protos.TaskHubSidecarServiceServer func (g *grpcExecutor) SignalEntity(ctx context.Context, req *protos.SignalEntityRequest) (*protos.SignalEntityResponse, error) { + // Ensure the entity orchestration instance exists. Create with IGNORE policy + // so it's a no-op if the instance already exists. + startEvent := helpers.NewExecutionStartedEvent(req.Name, req.InstanceId, nil, nil, nil, nil) + createErr := g.backend.CreateOrchestrationInstance(ctx, startEvent, WithOrchestrationIdReusePolicy(&protos.OrchestrationIdReusePolicy{ + Action: protos.CreateOrchestrationAction_IGNORE, + OperationStatus: []protos.OrchestrationStatus{protos.OrchestrationStatus_ORCHESTRATION_STATUS_RUNNING}, + })) + if createErr != nil && !errors.Is(createErr, api.ErrDuplicateInstance) && !errors.Is(createErr, api.ErrIgnoreInstance) { + return nil, fmt.Errorf("failed to create entity instance: %w", createErr) + } + e := helpers.NewEventRaisedEvent(req.Name, req.Input) if err := g.backend.AddNewOrchestrationEvent(ctx, api.InstanceID(req.InstanceId), e); err != nil { return nil, fmt.Errorf("failed to signal entity: %w", err) diff --git a/tests/entity_integration_test.go b/tests/entity_integration_test.go index 53d73c8d..81673071 100644 --- a/tests/entity_integration_test.go +++ b/tests/entity_integration_test.go @@ -71,18 +71,8 @@ func Test_InProcess_Entity_SignalAndQuery(t *testing.T) { // Test that entities work with the auto-dispatch pattern. func Test_InProcess_Entity_AutoDispatch(t *testing.T) { - type counter struct { - Value int `json:"value"` - } - r := task.NewTaskRegistry() - require.NoError(t, r.AddEntityN("smartcounter", task.NewEntityFor[counter]())) - // Register a dummy Add method on counter for the dispatcher - // Actually, we need counter to have methods. Let's use the raw entity pattern instead. - // Re-register with raw function since we can't add methods to a local type. - - r2 := task.NewTaskRegistry() - require.NoError(t, r2.AddEntityN("counter", func(ctx *task.EntityContext) (any, error) { + require.NoError(t, r.AddEntityN("counter", func(ctx *task.EntityContext) (any, error) { var val int if ctx.HasState() { _ = ctx.GetState(&val) @@ -101,7 +91,7 @@ func Test_InProcess_Entity_AutoDispatch(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - baseClient, worker := initTaskHubWorker(ctx, r2) + baseClient, worker := initTaskHubWorker(ctx, r) client := baseClient.(backend.EntityTaskHubClient) defer func() { if err := worker.Shutdown(context.Background()); err != nil { From e6f7212c6fb6b1758f385ccb8a74ec311c636b0f Mon Sep 17 00:00:00 2001 From: qmuntal Date: Tue, 24 Mar 2026 13:22:59 +0100 Subject: [PATCH 06/13] fix: concurrent entity execution with metadata-based correlation Replace FIFO queue-based entity completion correlation with gRPC metadata. The worker now passes the entity instance ID via the 'entity-instance-id' gRPC metadata header on CompleteEntityTask calls, enabling correct correlation regardless of completion order. This allows entity work items to be processed concurrently (goroutines) without blocking the GetWorkItems stream, matching the behavior of orchestration and activity work items. The FIFO queue is retained as a fallback for non-Go workers that don't send the metadata header. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/executor.go | 21 ++++++++++++++------- client/worker_grpc.go | 9 +++++++-- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/backend/executor.go b/backend/executor.go index e4af549e..05eb3165 100644 --- a/backend/executor.go +++ b/backend/executor.go @@ -432,14 +432,21 @@ func getActivityExecutionKey(iid string, taskID int32) string { // CompleteEntityTask implements protos.TaskHubSidecarServiceServer func (g *grpcExecutor) CompleteEntityTask(ctx context.Context, res *protos.EntityBatchResult) (*protos.CompleteTaskResponse, error) { - // EntityBatchResult doesn't include instance ID (unlike OrchestratorResponse/ActivityResponse). - // We use a FIFO queue to correlate completions with dispatched entity work items, since the - // worker processes them in order. + // EntityBatchResult doesn't include an instance ID field (unlike OrchestratorResponse/ActivityResponse). + // The worker passes the instance ID via gRPC metadata for correlation. var key string - select { - case key = <-g.entityQueue: - default: - return emptyCompleteTaskResponse, fmt.Errorf("no pending entity found for completion") + if md, ok := metadata.FromIncomingContext(ctx); ok { + if values := md.Get("entity-instance-id"); len(values) > 0 { + key = values[0] + } + } + if key == "" { + // Fallback to FIFO queue for non-Go workers that don't send metadata. + select { + case key = <-g.entityQueue: + default: + return emptyCompleteTaskResponse, fmt.Errorf("no pending entity found for completion: missing entity-instance-id metadata") + } } p, ok := g.pendingEntities.LoadAndDelete(key) diff --git a/client/worker_grpc.go b/client/worker_grpc.go index 2efc1a86..01fcd08c 100644 --- a/client/worker_grpc.go +++ b/client/worker_grpc.go @@ -14,6 +14,7 @@ import ( "github.com/microsoft/durabletask-go/internal/protos" "github.com/microsoft/durabletask-go/task" "google.golang.org/grpc/codes" + grpcmetadata "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/wrapperspb" @@ -122,7 +123,7 @@ func (c *TaskHubGrpcClient) StartWorkItemListener(ctx context.Context, r *task.T } else if actReq := workItem.GetActivityRequest(); actReq != nil { go c.processActivityWorkItem(ctx, executor, actReq) } else if entityReq := workItem.GetEntityRequest(); entityReq != nil { - c.processEntityWorkItem(ctx, executor, entityReq) + go c.processEntityWorkItem(ctx, executor, entityReq) } else { c.logger.Warnf("received unknown work item type: %v", workItem) } @@ -225,7 +226,11 @@ func (c *TaskHubGrpcClient) processEntityWorkItem( } } - if _, err = c.client.CompleteEntityTask(ctx, result); err != nil { + // Pass the entity instance ID via gRPC metadata so the server can correlate + // the completion with the correct pending entity (EntityBatchResult doesn't + // include an instance ID field in the proto). + completeCtx := grpcmetadata.AppendToOutgoingContext(ctx, "entity-instance-id", req.InstanceId) + if _, err = c.client.CompleteEntityTask(completeCtx, result); err != nil { if ctx.Err() != nil { c.logger.Warn("failed to complete entity task: context canceled") } else { From 15021ea7d9b582154b5ec88e7714c7d20d7dd474 Mon Sep 17 00:00:00 2001 From: qmuntal Date: Tue, 24 Mar 2026 13:31:55 +0100 Subject: [PATCH 07/13] fix: address second round of review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - executor.go: commit entity state/actions only after successful output marshal to prevent state corruption on serialization failure - registry.go: lowercase entity names at registration for case-insensitive lookup consistency with EntityID.String() - api/entity.go: lowercase name in NewEntityID so EntityID values are canonical (roundtrip-safe with EntityIDFromString) - orchestration.go: handle AddEvent errors in entity history — propagate real errors, log and skip duplicates - executor.go: fix gRPC SignalEntity to use wire-compatible protocol (event name 'op' + JSON EntityRequestMessage payload) instead of raw EventRaised with operation name Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/entity.go | 2 +- backend/executor.go | 26 ++++++++++++++++++++++++-- backend/orchestration.go | 7 ++++++- task/executor.go | 8 ++++---- task/registry.go | 2 ++ 5 files changed, 37 insertions(+), 8 deletions(-) diff --git a/api/entity.go b/api/entity.go index 33c56f08..348e4aa7 100644 --- a/api/entity.go +++ b/api/entity.go @@ -19,7 +19,7 @@ type EntityID struct { // NewEntityID creates a new EntityID with the specified name and key. func NewEntityID(name string, key string) EntityID { - return EntityID{Name: name, Key: key} + return EntityID{Name: strings.ToLower(name), Key: key} } // String returns the entity instance ID in the format "@@". diff --git a/backend/executor.go b/backend/executor.go index 05eb3165..058122fb 100644 --- a/backend/executor.go +++ b/backend/executor.go @@ -2,6 +2,7 @@ package backend import ( context "context" + "encoding/json" "errors" "fmt" "strconv" @@ -10,6 +11,7 @@ import ( "time" "github.com/cenkalti/backoff/v4" + "github.com/google/uuid" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" @@ -520,9 +522,29 @@ func (g *grpcExecutor) RaiseEvent(ctx context.Context, req *protos.RaiseEventReq // SignalEntity implements protos.TaskHubSidecarServiceServer func (g *grpcExecutor) SignalEntity(ctx context.Context, req *protos.SignalEntityRequest) (*protos.SignalEntityResponse, error) { + // Parse entity name from the instance ID + entityID, err := api.EntityIDFromString(req.InstanceId) + if err != nil { + return nil, fmt.Errorf("failed to parse entity instance ID: %w", err) + } + + // Build wire-compatible EntityRequestMessage + requestMsg := helpers.EntityRequestMessage{ + ID: uuid.New().String(), + IsSignal: true, + Operation: req.Name, + } + if req.Input != nil { + requestMsg.Input = req.Input.GetValue() + } + payload, err := json.Marshal(requestMsg) + if err != nil { + return nil, fmt.Errorf("failed to marshal entity request message: %w", err) + } + // Ensure the entity orchestration instance exists. Create with IGNORE policy // so it's a no-op if the instance already exists. - startEvent := helpers.NewExecutionStartedEvent(req.Name, req.InstanceId, nil, nil, nil, nil) + startEvent := helpers.NewExecutionStartedEvent(entityID.Name, req.InstanceId, nil, nil, nil, nil) createErr := g.backend.CreateOrchestrationInstance(ctx, startEvent, WithOrchestrationIdReusePolicy(&protos.OrchestrationIdReusePolicy{ Action: protos.CreateOrchestrationAction_IGNORE, OperationStatus: []protos.OrchestrationStatus{protos.OrchestrationStatus_ORCHESTRATION_STATUS_RUNNING}, @@ -531,7 +553,7 @@ func (g *grpcExecutor) SignalEntity(ctx context.Context, req *protos.SignalEntit return nil, fmt.Errorf("failed to create entity instance: %w", createErr) } - e := helpers.NewEventRaisedEvent(req.Name, req.Input) + e := helpers.NewEventRaisedEvent(helpers.EntityRequestEventName, wrapperspb.String(string(payload))) if err := g.backend.AddNewOrchestrationEvent(ctx, api.InstanceID(req.InstanceId), e); err != nil { return nil, fmt.Errorf("failed to signal entity: %w", err) } diff --git a/backend/orchestration.go b/backend/orchestration.go index b04a7f96..6f012ed9 100644 --- a/backend/orchestration.go +++ b/backend/orchestration.go @@ -301,7 +301,12 @@ func (w *orchestratorProcessor) processEntityWorkItem(ctx context.Context, wi *O // Add incoming events to state history for _, e := range wi.NewEvents { - _ = wi.State.AddEvent(e) + if err := wi.State.AddEvent(e); err != nil { + if !errors.Is(err, ErrDuplicateEvent) { + return fmt.Errorf("failed to add event to entity state: %w", err) + } + w.logger.Debugf("%v: skipping duplicate event in entity history", wi.InstanceID) + } } // Save entity state as the orchestration's custom status diff --git a/task/executor.go b/task/executor.go index 36b2a60e..4c4e3b34 100644 --- a/task/executor.go +++ b/task/executor.go @@ -170,10 +170,6 @@ func (te *taskExecutor) ExecuteEntity(ctx context.Context, id api.InstanceID, re } } - // Operation succeeded - commit state - state = entityCtx.state - allActions = append(allActions, entityCtx.actions...) - var rawResult *wrapperspb.StringValue if output != nil { bytes, marshalErr := marshalData(output) @@ -194,6 +190,10 @@ func (te *taskExecutor) ExecuteEntity(ctx context.Context, id api.InstanceID, re } } + // Only commit state after successful marshal + state = entityCtx.state + allActions = append(allActions, entityCtx.actions...) + return &protos.OperationResult{ ResultType: &protos.OperationResult_Success{ Success: &protos.OperationResultSuccess{ diff --git a/task/registry.go b/task/registry.go index 96461876..5e4b3e79 100644 --- a/task/registry.go +++ b/task/registry.go @@ -2,6 +2,7 @@ package task import ( "fmt" + "strings" "github.com/microsoft/durabletask-go/internal/helpers" ) @@ -64,6 +65,7 @@ func (r *TaskRegistry) AddEntity(e Entity) error { // AddEntityN adds an entity function to the registry with a specified name. func (r *TaskRegistry) AddEntityN(name string, e Entity) error { + name = strings.ToLower(name) if _, ok := r.entities[name]; ok { return fmt.Errorf("entity named '%s' is already registered", name) } From 9ed7e78e9528c91d1f8d4c19ac4be5ca5b397706 Mon Sep 17 00:00:00 2001 From: qmuntal Date: Tue, 24 Mar 2026 13:36:43 +0100 Subject: [PATCH 08/13] ci: retrigger CI --- backend/executor.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/executor.go b/backend/executor.go index 058122fb..ba4986cd 100644 --- a/backend/executor.go +++ b/backend/executor.go @@ -251,7 +251,12 @@ func (executor *grpcExecutor) ExecuteEntity(ctx context.Context, iid api.Instanc case executor.workItemQueue <- workItem: } - executor.entityQueue <- key + // Non-blocking send to FIFO queue (fallback for non-Go workers without metadata). + // Go workers use gRPC metadata for correlation and never drain this queue. + select { + case executor.entityQueue <- key: + default: + } select { case <-ctx.Done(): From b99091bc6e7216f95e065b1059e454418497a9f1 Mon Sep 17 00:00:00 2001 From: qmuntal Date: Tue, 24 Mar 2026 14:07:15 +0100 Subject: [PATCH 09/13] fix: normalize entity IDs, consume events on empty batches, case-insensitive op matching - executor.go: normalize instance ID in gRPC SignalEntity via EntityIDFromString + String() to ensure lowercase consistency - orchestration.go: move event consumption before the empty-operations early return so events are always added to state history - orchestration.go: use strings.EqualFold for 'op' event name matching, consistent with case-insensitive event handling elsewhere Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/executor.go | 7 +++++-- backend/orchestration.go | 45 ++++++++++++++++++++-------------------- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/backend/executor.go b/backend/executor.go index ba4986cd..edf738c1 100644 --- a/backend/executor.go +++ b/backend/executor.go @@ -547,9 +547,12 @@ func (g *grpcExecutor) SignalEntity(ctx context.Context, req *protos.SignalEntit return nil, fmt.Errorf("failed to marshal entity request message: %w", err) } + // Normalize the instance ID to lowercase for consistent routing + normalizedID := entityID.String() + // Ensure the entity orchestration instance exists. Create with IGNORE policy // so it's a no-op if the instance already exists. - startEvent := helpers.NewExecutionStartedEvent(entityID.Name, req.InstanceId, nil, nil, nil, nil) + startEvent := helpers.NewExecutionStartedEvent(entityID.Name, normalizedID, nil, nil, nil, nil) createErr := g.backend.CreateOrchestrationInstance(ctx, startEvent, WithOrchestrationIdReusePolicy(&protos.OrchestrationIdReusePolicy{ Action: protos.CreateOrchestrationAction_IGNORE, OperationStatus: []protos.OrchestrationStatus{protos.OrchestrationStatus_ORCHESTRATION_STATUS_RUNNING}, @@ -559,7 +562,7 @@ func (g *grpcExecutor) SignalEntity(ctx context.Context, req *protos.SignalEntit } e := helpers.NewEventRaisedEvent(helpers.EntityRequestEventName, wrapperspb.String(string(payload))) - if err := g.backend.AddNewOrchestrationEvent(ctx, api.InstanceID(req.InstanceId), e); err != nil { + if err := g.backend.AddNewOrchestrationEvent(ctx, api.InstanceID(normalizedID), e); err != nil { return nil, fmt.Errorf("failed to signal entity: %w", err) } return &protos.SignalEntityResponse{}, nil diff --git a/backend/orchestration.go b/backend/orchestration.go index 6f012ed9..2619e330 100644 --- a/backend/orchestration.go +++ b/backend/orchestration.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "github.com/google/uuid" @@ -235,7 +236,7 @@ func (w *orchestratorProcessor) processEntityWorkItem(ctx context.Context, wi *O continue } - if eventName != helpers.EntityRequestEventName { + if !strings.EqualFold(eventName, helpers.EntityRequestEventName) { continue } @@ -266,27 +267,6 @@ func (w *orchestratorProcessor) processEntityWorkItem(ctx context.Context, wi *O }) } - if len(operations) == 0 { - w.logger.Debugf("%v: no entity operations to process", wi.InstanceID) - return nil - } - - // Build and execute the entity batch - batchReq := &protos.EntityBatchRequest{ - InstanceId: iid, - EntityState: entityState, - Operations: operations, - } - - batchResult, err := w.entityExecutor.ExecuteEntity(ctx, wi.InstanceID, batchReq) - if err != nil { - return fmt.Errorf("failed to execute entity: %w", err) - } - if batchResult.FailureDetails != nil { - w.logger.Errorf("%v: non-retriable entity execution failure: %s", wi.InstanceID, batchResult.FailureDetails.ErrorMessage) - return nil - } - // Ensure the entity orchestration instance exists in state if wi.State.startEvent == nil { entityID, _ := api.EntityIDFromString(iid) @@ -309,6 +289,27 @@ func (w *orchestratorProcessor) processEntityWorkItem(ctx context.Context, wi *O } } + if len(operations) == 0 { + w.logger.Debugf("%v: no entity operations to process", wi.InstanceID) + return nil + } + + // Build and execute the entity batch + batchReq := &protos.EntityBatchRequest{ + InstanceId: iid, + EntityState: entityState, + Operations: operations, + } + + batchResult, err := w.entityExecutor.ExecuteEntity(ctx, wi.InstanceID, batchReq) + if err != nil { + return fmt.Errorf("failed to execute entity: %w", err) + } + if batchResult.FailureDetails != nil { + w.logger.Errorf("%v: non-retriable entity execution failure: %s", wi.InstanceID, batchResult.FailureDetails.ErrorMessage) + return nil + } + // Save entity state as the orchestration's custom status wi.State.CustomStatus = batchResult.EntityState From b8642410ca7fa75f788333806076f402ce03417d Mon Sep 17 00:00:00 2001 From: qmuntal Date: Tue, 24 Mar 2026 15:13:50 +0100 Subject: [PATCH 10/13] preserve visible time and ids --- api/entity_test.go | 1 + backend/client.go | 9 ++- backend/executor.go | 13 ++++- backend/executor_test.go | 99 ++++++++++++++++++++++++++++++++ backend/orchestration.go | 16 ++++-- client/client_grpc.go | 1 - task/entity.go | 6 ++ task/entity_test.go | 16 ++++++ tests/entity_client_test.go | 28 +++++++++ tests/entity_integration_test.go | 51 ++++++++++++++++ 10 files changed, 232 insertions(+), 8 deletions(-) create mode 100644 backend/executor_test.go diff --git a/api/entity_test.go b/api/entity_test.go index fffdb365..17106f27 100644 --- a/api/entity_test.go +++ b/api/entity_test.go @@ -21,6 +21,7 @@ func Test_API_EntityIDFromString(t *testing.T) { }{ {name: "valid", input: "@counter@key1", want: EntityID{Name: "counter", Key: "key1"}}, {name: "empty key", input: "@entity@", want: EntityID{Name: "entity", Key: ""}}, + {name: "empty name", input: "@@key1", want: EntityID{Name: "", Key: "key1"}}, {name: "invalid no prefix", input: "no-at-sign", wantErr: true}, {name: "invalid no second @", input: "@onlyone", wantErr: true}, } diff --git a/backend/client.go b/backend/client.go index c014195d..0c4dd6cf 100644 --- a/backend/client.go +++ b/backend/client.go @@ -246,8 +246,12 @@ func (c *backendClient) SignalEntity(ctx context.Context, entityID api.EntityID, } // Build the .NET-compatible EntityRequestMessage payload with isSignal=true. + requestID := req.RequestId + if requestID == "" { + requestID = uuid.New().String() + } reqMsg := helpers.EntityRequestMessage{ - ID: uuid.New().String(), + ID: requestID, IsSignal: true, Operation: req.Name, } @@ -260,6 +264,9 @@ func (c *backendClient) SignalEntity(ctx context.Context, entityID api.EntityID, } e := helpers.NewEventRaisedEvent(helpers.EntityRequestEventName, wrapperspb.String(string(payload))) + if req.ScheduledTime != nil { + e.Timestamp = req.ScheduledTime + } if err := c.be.AddNewOrchestrationEvent(ctx, api.InstanceID(req.InstanceId), e); err != nil { return fmt.Errorf("failed to signal entity: %w", err) } diff --git a/backend/executor.go b/backend/executor.go index edf738c1..b3e79414 100644 --- a/backend/executor.go +++ b/backend/executor.go @@ -235,7 +235,9 @@ func (g *grpcExecutor) Shutdown(ctx context.Context) error { func (executor *grpcExecutor) ExecuteEntity(ctx context.Context, iid api.InstanceID, req *protos.EntityBatchRequest) (*protos.EntityBatchResult, error) { key := req.InstanceId result := &entityExecutionResult{complete: make(chan struct{})} - executor.pendingEntities.Store(key, result) + if _, loaded := executor.pendingEntities.LoadOrStore(key, result); loaded { + return nil, fmt.Errorf("entity batch for instance '%s' is already pending", key) + } workItem := &protos.WorkItem{ Request: &protos.WorkItem_EntityRequest{ @@ -534,8 +536,12 @@ func (g *grpcExecutor) SignalEntity(ctx context.Context, req *protos.SignalEntit } // Build wire-compatible EntityRequestMessage + requestID := req.RequestId + if requestID == "" { + requestID = uuid.New().String() + } requestMsg := helpers.EntityRequestMessage{ - ID: uuid.New().String(), + ID: requestID, IsSignal: true, Operation: req.Name, } @@ -562,6 +568,9 @@ func (g *grpcExecutor) SignalEntity(ctx context.Context, req *protos.SignalEntit } e := helpers.NewEventRaisedEvent(helpers.EntityRequestEventName, wrapperspb.String(string(payload))) + if req.ScheduledTime != nil { + e.Timestamp = req.ScheduledTime + } if err := g.backend.AddNewOrchestrationEvent(ctx, api.InstanceID(normalizedID), e); err != nil { return nil, fmt.Errorf("failed to signal entity: %w", err) } diff --git a/backend/executor_test.go b/backend/executor_test.go new file mode 100644 index 00000000..b185dd52 --- /dev/null +++ b/backend/executor_test.go @@ -0,0 +1,99 @@ +package backend + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/microsoft/durabletask-go/api" + "github.com/microsoft/durabletask-go/internal/helpers" + "github.com/microsoft/durabletask-go/internal/protos" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type capturingBackend struct { + created []*HistoryEvent + added []*HistoryEvent + addedTo []api.InstanceID +} + +func (*capturingBackend) CreateTaskHub(context.Context) error { return nil } +func (*capturingBackend) DeleteTaskHub(context.Context) error { return nil } +func (*capturingBackend) Start(context.Context) error { return nil } +func (*capturingBackend) Stop(context.Context) error { return nil } +func (b *capturingBackend) CreateOrchestrationInstance(_ context.Context, e *HistoryEvent, _ ...OrchestrationIdReusePolicyOptions) error { + b.created = append(b.created, e) + return nil +} +func (b *capturingBackend) AddNewOrchestrationEvent(_ context.Context, iid api.InstanceID, e *HistoryEvent) error { + b.addedTo = append(b.addedTo, iid) + b.added = append(b.added, e) + return nil +} +func (*capturingBackend) GetOrchestrationWorkItem(context.Context) (*OrchestrationWorkItem, error) { + return nil, nil +} +func (*capturingBackend) GetOrchestrationRuntimeState(context.Context, *OrchestrationWorkItem) (*OrchestrationRuntimeState, error) { + return nil, nil +} +func (*capturingBackend) GetOrchestrationMetadata(context.Context, api.InstanceID) (*api.OrchestrationMetadata, error) { + return nil, nil +} +func (*capturingBackend) CompleteOrchestrationWorkItem(context.Context, *OrchestrationWorkItem) error { + return nil +} +func (*capturingBackend) AbandonOrchestrationWorkItem(context.Context, *OrchestrationWorkItem) error { + return nil +} +func (*capturingBackend) GetActivityWorkItem(context.Context) (*ActivityWorkItem, error) { + return nil, nil +} +func (*capturingBackend) CompleteActivityWorkItem(context.Context, *ActivityWorkItem) error { + return nil +} +func (*capturingBackend) AbandonActivityWorkItem(context.Context, *ActivityWorkItem) error { + return nil +} +func (*capturingBackend) PurgeOrchestrationState(context.Context, api.InstanceID) error { return nil } + +func Test_GrpcExecutor_ExecuteEntity_RejectsConcurrentInstance(t *testing.T) { + executor, _ := NewGrpcExecutor(nil, DefaultLogger()) + g := executor.(*grpcExecutor) + + req := &protos.EntityBatchRequest{InstanceId: "@counter@key"} + g.pendingEntities.Store(req.InstanceId, &entityExecutionResult{complete: make(chan struct{})}) + + _, err := g.ExecuteEntity(context.Background(), api.InstanceID(req.InstanceId), req) + require.Error(t, err) + assert.Contains(t, err.Error(), "already pending") +} + +func Test_GrpcExecutor_SignalEntity_PreservesScheduledTimeAndRequestID(t *testing.T) { + be := &capturingBackend{} + executor, _ := NewGrpcExecutor(be, DefaultLogger()) + g := executor.(*grpcExecutor) + + scheduledTime := time.Now().Add(2 * time.Hour).UTC().Truncate(time.Millisecond) + _, err := g.SignalEntity(context.Background(), &protos.SignalEntityRequest{ + InstanceId: "@counter@key", + Name: "increment", + RequestId: "request-123", + ScheduledTime: timestamppb.New(scheduledTime), + }) + require.NoError(t, err) + + require.Len(t, be.created, 1) + require.Len(t, be.added, 1) + require.Equal(t, api.InstanceID("@counter@key"), be.addedTo[0]) + require.WithinDuration(t, scheduledTime, be.added[0].Timestamp.AsTime(), time.Millisecond) + + payload := be.added[0].GetEventRaised().GetInput().GetValue() + var msg helpers.EntityRequestMessage + require.NoError(t, json.Unmarshal([]byte(payload), &msg)) + require.Equal(t, "request-123", msg.ID) + require.True(t, msg.IsSignal) + require.Equal(t, "increment", msg.Operation) +} diff --git a/backend/orchestration.go b/backend/orchestration.go index 2619e330..71ecd5e9 100644 --- a/backend/orchestration.go +++ b/backend/orchestration.go @@ -2,6 +2,7 @@ package backend import ( "context" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -269,7 +270,10 @@ func (w *orchestratorProcessor) processEntityWorkItem(ctx context.Context, wi *O // Ensure the entity orchestration instance exists in state if wi.State.startEvent == nil { - entityID, _ := api.EntityIDFromString(iid) + entityID, err := api.EntityIDFromString(iid) + if err != nil { + return fmt.Errorf("invalid entity instance ID format: %w", err) + } startEvent := helpers.NewExecutionStartedEvent(entityID.Name, iid, nil, nil, nil, nil) if err := wi.State.AddEvent(helpers.NewOrchestratorStartedEvent()); err != nil { return fmt.Errorf("failed to add orchestrator started event: %w", err) @@ -363,16 +367,20 @@ func (w *orchestratorProcessor) processEntityWorkItem(ctx context.Context, wi *O w.logger.Warnf("%v: failed to marshal signal request: %v", wi.InstanceID, err) continue } - e := helpers.NewEventRaisedEvent("op", wrapperspb.String(string(sigJSON))) + e := helpers.NewEventRaisedEvent(helpers.EntityRequestEventName, wrapperspb.String(string(sigJSON))) + if signal.ScheduledTime != nil { + e.Timestamp = signal.ScheduledTime + } if err := w.be.AddNewOrchestrationEvent(ctx, api.InstanceID(signal.InstanceId), e); err != nil { w.logger.Warnf("%v: failed to send entity signal to %s: %v", wi.InstanceID, signal.InstanceId, err) } } else if startOrch := action.GetStartNewOrchestration(); startOrch != nil { orchInstanceID := startOrch.InstanceId if orchInstanceID == "" { - orchInstanceID = fmt.Sprintf("%s:%04x", iid, action.Id) + id := uuid.New() + orchInstanceID = hex.EncodeToString(id[:]) } - e := helpers.NewExecutionStartedEvent(startOrch.Name, orchInstanceID, startOrch.Input, nil, nil, nil) + e := helpers.NewExecutionStartedEvent(startOrch.Name, orchInstanceID, startOrch.Input, nil, nil, startOrch.ScheduledTime) if err := w.be.CreateOrchestrationInstance(ctx, e); err != nil { w.logger.Warnf("%v: failed to start orchestration %s: %v", wi.InstanceID, orchInstanceID, err) } diff --git a/client/client_grpc.go b/client/client_grpc.go index 7cf40b05..b4c9813f 100644 --- a/client/client_grpc.go +++ b/client/client_grpc.go @@ -42,7 +42,6 @@ func (c *TaskHubGrpcClient) ScheduleNewOrchestration(ctx context.Context, orches if req.InstanceId == "" { req.InstanceId = uuid.NewString() } - resp, err := c.client.StartInstance(ctx, req) if err != nil { if ctx.Err() != nil { diff --git a/task/entity.go b/task/entity.go index 9f61b9ea..f6b63932 100644 --- a/task/entity.go +++ b/task/entity.go @@ -1,9 +1,11 @@ package task import ( + "encoding/hex" "encoding/json" "fmt" + "github.com/google/uuid" "github.com/microsoft/durabletask-go/api" "github.com/microsoft/durabletask-go/internal/protos" "google.golang.org/protobuf/types/known/wrapperspb" @@ -97,6 +99,10 @@ func (ctx *EntityContext) StartNewOrchestration(name string, opts ...entityStart return err } } + if options.instanceID == "" { + id := uuid.New() + options.instanceID = hex.EncodeToString(id[:]) + } action := &protos.OperationAction{ Id: ctx.nextActionID(), diff --git a/task/entity_test.go b/task/entity_test.go index bcddb82f..335dcc99 100644 --- a/task/entity_test.go +++ b/task/entity_test.go @@ -1,6 +1,7 @@ package task import ( + "regexp" "testing" "github.com/microsoft/durabletask-go/api" @@ -112,6 +113,21 @@ func Test_EntityContext_StartNewOrchestration(t *testing.T) { assert.Equal(t, `"hello"`, startOrch.Input.GetValue()) } +func Test_EntityContext_StartNewOrchestration_DefaultInstanceID(t *testing.T) { + ctx := &EntityContext{ + ID: api.NewEntityID("test", "key1"), + Operation: "op", + } + + err := ctx.StartNewOrchestration("MyOrchestrator") + require.NoError(t, err) + require.Len(t, ctx.actions, 1) + + startOrch := ctx.actions[0].GetStartNewOrchestration() + require.NotNil(t, startOrch) + assert.Regexp(t, regexp.MustCompile("^[a-f0-9]{32}$"), startOrch.InstanceId) +} + func Test_EntityRegistry(t *testing.T) { r := NewTaskRegistry() diff --git a/tests/entity_client_test.go b/tests/entity_client_test.go index aa9af1e4..9373e9cd 100644 --- a/tests/entity_client_test.go +++ b/tests/entity_client_test.go @@ -12,6 +12,7 @@ import ( "github.com/microsoft/durabletask-go/api" "github.com/microsoft/durabletask-go/backend" + "github.com/microsoft/durabletask-go/internal/protos" "github.com/microsoft/durabletask-go/tests/mocks" ) @@ -258,3 +259,30 @@ func Test_EntityClient_SignalEntity(t *testing.T) { err := client.SignalEntity(ctx, entityID, "increment", api.WithSignalInput(5)) require.NoError(t, err) } + +func Test_EntityClient_SignalEntity_PreservesScheduledTime(t *testing.T) { + be := &mocks.Backend{} + client := newEntityClient(be) + ctx := context.Background() + + entityID := api.NewEntityID("counter", "signalTest") + scheduledTime := time.Now().Add(2 * time.Hour).UTC().Truncate(time.Millisecond) + + be.EXPECT().CreateOrchestrationInstance( + ctx, + mock.AnythingOfType("*protos.HistoryEvent"), + mock.AnythingOfType("backend.OrchestrationIdReusePolicyOptions"), + ).Return(nil).Once() + + be.EXPECT().AddNewOrchestrationEvent( + ctx, + api.InstanceID("@counter@signalTest"), + mock.AnythingOfType("*protos.HistoryEvent"), + ).Run(func(_ context.Context, _ api.InstanceID, e *protos.HistoryEvent) { + require.NotNil(t, e.Timestamp) + require.WithinDuration(t, scheduledTime, e.Timestamp.AsTime(), time.Millisecond) + }).Return(nil).Once() + + err := client.SignalEntity(ctx, entityID, "increment", api.WithSignalScheduledTime(scheduledTime)) + require.NoError(t, err) +} diff --git a/tests/entity_integration_test.go b/tests/entity_integration_test.go index 81673071..58f1d5c5 100644 --- a/tests/entity_integration_test.go +++ b/tests/entity_integration_test.go @@ -69,6 +69,57 @@ func Test_InProcess_Entity_SignalAndQuery(t *testing.T) { }, 10*time.Second, 200*time.Millisecond) } +func Test_InProcess_Entity_SignalScheduledTime(t *testing.T) { + r := task.NewTaskRegistry() + require.NoError(t, r.AddEntityN("counter", func(ctx *task.EntityContext) (any, error) { + var count int + if ctx.HasState() { + _ = ctx.GetState(&count) + } + if ctx.Operation == "add" { + var amount int + if err := ctx.GetInput(&amount); err != nil { + return nil, err + } + count += amount + } + _ = ctx.SetState(count) + return count, nil + })) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + baseClient, worker := initTaskHubWorker(ctx, r) + client := baseClient.(backend.EntityTaskHubClient) + defer func() { + if err := worker.Shutdown(context.Background()); err != nil { + t.Logf("shutdown: %v", err) + } + }() + + entityID := api.NewEntityID("counter", "scheduled") + fireAt := time.Now().Add(750 * time.Millisecond) + + require.NoError(t, client.SignalEntity(ctx, entityID, "add", api.WithSignalInput(5), api.WithSignalScheduledTime(fireAt))) + + require.Never(t, func() bool { + meta, err := client.FetchEntityMetadata(ctx, entityID, true) + if err != nil || meta == nil { + return false + } + return strings.Contains(meta.SerializedState, "5") + }, 300*time.Millisecond, 100*time.Millisecond) + + require.Eventually(t, func() bool { + meta, err := client.FetchEntityMetadata(ctx, entityID, true) + if err != nil || meta == nil { + return false + } + return strings.Contains(meta.SerializedState, "5") + }, 10*time.Second, 100*time.Millisecond) +} + // Test that entities work with the auto-dispatch pattern. func Test_InProcess_Entity_AutoDispatch(t *testing.T) { r := task.NewTaskRegistry() From a1bb8a45f8d4f7e894c0ac605d62136bb5b82a33 Mon Sep 17 00:00:00 2001 From: qmuntal Date: Tue, 24 Mar 2026 15:48:04 +0100 Subject: [PATCH 11/13] fix: restore internal entity validation Keep validation behavior through existing entry points, move shared helpers under internal/helpers, and preserve scheduled entity signal visibility handling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/entity.go | 16 +++++------ api/entity_test.go | 7 ++++- api/orchestration.go | 3 ++ api/orchestration_test.go | 23 +++++++++++++++ backend/client.go | 10 +++++++ backend/executor.go | 3 ++ backend/executor_test.go | 12 ++++++++ backend/orchestration.go | 4 +++ backend/postgres/postgres.go | 8 ++++-- backend/sqlite/sqlite.go | 4 ++- client/client_grpc.go | 12 ++++++++ internal/helpers/entity_ids.go | 52 ++++++++++++++++++++++++++++++++++ internal/helpers/visibility.go | 21 ++++++++++++++ task/entity.go | 7 +++++ task/entity_test.go | 28 ++++++++++++++++++ task/orchestrator.go | 8 ++++++ task/registry.go | 3 ++ 17 files changed, 208 insertions(+), 13 deletions(-) create mode 100644 api/orchestration_test.go create mode 100644 internal/helpers/entity_ids.go create mode 100644 internal/helpers/visibility.go diff --git a/api/entity.go b/api/entity.go index 348e4aa7..f3aaece5 100644 --- a/api/entity.go +++ b/api/entity.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/microsoft/durabletask-go/internal/helpers" "github.com/microsoft/durabletask-go/internal/protos" "google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/wrapperspb" @@ -19,6 +20,9 @@ type EntityID struct { // NewEntityID creates a new EntityID with the specified name and key. func NewEntityID(name string, key string) EntityID { + if err := helpers.ValidateEntityName(name); err != nil { + panic(err) + } return EntityID{Name: strings.ToLower(name), Key: key} } @@ -29,15 +33,11 @@ func (e EntityID) String() string { // EntityIDFromString parses an entity instance ID string in the format "@@". func EntityIDFromString(s string) (EntityID, error) { - if !strings.HasPrefix(s, "@") { - return EntityID{}, fmt.Errorf("invalid entity instance ID format: %q", s) - } - s = s[1:] // trim leading '@' - before, after, ok := strings.Cut(s, "@") - if !ok { - return EntityID{}, fmt.Errorf("invalid entity instance ID format: missing second '@'") + name, key, err := helpers.ParseEntityInstanceID(s) + if err != nil { + return EntityID{}, err } - return EntityID{Name: strings.ToLower(before), Key: after}, nil + return EntityID{Name: name, Key: key}, nil } // EntityMetadata contains metadata about an entity instance. diff --git a/api/entity_test.go b/api/entity_test.go index 17106f27..d2b14b18 100644 --- a/api/entity_test.go +++ b/api/entity_test.go @@ -21,7 +21,7 @@ func Test_API_EntityIDFromString(t *testing.T) { }{ {name: "valid", input: "@counter@key1", want: EntityID{Name: "counter", Key: "key1"}}, {name: "empty key", input: "@entity@", want: EntityID{Name: "entity", Key: ""}}, - {name: "empty name", input: "@@key1", want: EntityID{Name: "", Key: "key1"}}, + {name: "invalid empty name", input: "@@key1", wantErr: true}, {name: "invalid no prefix", input: "no-at-sign", wantErr: true}, {name: "invalid no second @", input: "@onlyone", wantErr: true}, } @@ -38,3 +38,8 @@ func Test_API_EntityIDFromString(t *testing.T) { }) } } + +func Test_API_NewEntityID_InvalidNamePanics(t *testing.T) { + assert.Panics(t, func() { NewEntityID("", "key") }) + assert.Panics(t, func() { NewEntityID("bad@name", "key") }) +} diff --git a/api/orchestration.go b/api/orchestration.go index 12bc9ef3..dfe0ac44 100644 --- a/api/orchestration.go +++ b/api/orchestration.go @@ -80,6 +80,9 @@ type PurgeOptions func(*protos.PurgeInstancesRequest) error // a random UUID value will be used for the orchestration instance ID. func WithInstanceID(id InstanceID) NewOrchestrationOptions { return func(req *protos.CreateInstanceRequest) error { + if err := helpers.ValidateOrchestrationInstanceID(string(id)); err != nil { + return err + } req.InstanceId = string(id) return nil } diff --git a/api/orchestration_test.go b/api/orchestration_test.go new file mode 100644 index 00000000..ef1c738a --- /dev/null +++ b/api/orchestration_test.go @@ -0,0 +1,23 @@ +package api + +import ( + "testing" + + "github.com/microsoft/durabletask-go/internal/protos" + "github.com/stretchr/testify/require" +) + +func Test_API_WithInstanceID_RejectsEntityFormat(t *testing.T) { + req := &protos.CreateInstanceRequest{} + + err := WithInstanceID(InstanceID("@counter@key"))(req) + require.Error(t, err) +} + +func Test_API_WithInstanceID_AllowsNormalValue(t *testing.T) { + req := &protos.CreateInstanceRequest{} + + err := WithInstanceID(InstanceID("my-instance"))(req) + require.NoError(t, err) + require.Equal(t, "my-instance", req.InstanceId) +} diff --git a/backend/client.go b/backend/client.go index 0c4dd6cf..983093cb 100644 --- a/backend/client.go +++ b/backend/client.go @@ -66,6 +66,9 @@ func (c *backendClient) ScheduleNewOrchestration(ctx context.Context, orchestrat } req.InstanceId = u.String() } + if err := helpers.ValidateOrchestrationInstanceID(req.InstanceId); err != nil { + return api.EmptyInstanceID, err + } var span trace.Span ctx, span = helpers.StartNewCreateOrchestrationSpan(ctx, req.Name, req.Version.GetValue(), req.InstanceId) @@ -224,6 +227,10 @@ func (c *backendClient) PurgeOrchestrationState(ctx context.Context, id api.Inst // // If the entity doesn't exist, it will be created automatically when the signal is processed. func (c *backendClient) SignalEntity(ctx context.Context, entityID api.EntityID, operationName string, opts ...api.SignalEntityOptions) error { + if err := helpers.ValidateEntityName(entityID.Name); err != nil { + return err + } + req := &protos.SignalEntityRequest{ InstanceId: entityID.String(), Name: operationName, @@ -279,6 +286,9 @@ func (c *backendClient) SignalEntity(ctx context.Context, entityID api.EntityID, // If the backend implements [EntityBackend], its native entity storage is used. // Otherwise, falls back to orchestration metadata. func (c *backendClient) FetchEntityMetadata(ctx context.Context, entityID api.EntityID, includeState bool) (*api.EntityMetadata, error) { + if err := helpers.ValidateEntityName(entityID.Name); err != nil { + return nil, err + } if eb, ok := c.be.(EntityBackend); ok { return eb.GetEntityMetadata(ctx, entityID, includeState) } diff --git a/backend/executor.go b/backend/executor.go index b3e79414..f9aa4bea 100644 --- a/backend/executor.go +++ b/backend/executor.go @@ -580,6 +580,9 @@ func (g *grpcExecutor) SignalEntity(ctx context.Context, req *protos.SignalEntit // StartInstance implements protos.TaskHubSidecarServiceServer func (g *grpcExecutor) StartInstance(ctx context.Context, req *protos.CreateInstanceRequest) (*protos.CreateInstanceResponse, error) { instanceID := req.InstanceId + if err := helpers.ValidateOrchestrationInstanceID(instanceID); err != nil { + return nil, err + } ctx, span := helpers.StartNewCreateOrchestrationSpan(ctx, req.Name, req.Version.GetValue(), instanceID) defer span.End() diff --git a/backend/executor_test.go b/backend/executor_test.go index b185dd52..ba0973f2 100644 --- a/backend/executor_test.go +++ b/backend/executor_test.go @@ -71,6 +71,18 @@ func Test_GrpcExecutor_ExecuteEntity_RejectsConcurrentInstance(t *testing.T) { assert.Contains(t, err.Error(), "already pending") } +func Test_GrpcExecutor_StartInstance_RejectsEntityInstanceID(t *testing.T) { + executor, _ := NewGrpcExecutor(nil, DefaultLogger()) + g := executor.(*grpcExecutor) + + _, err := g.StartInstance(context.Background(), &protos.CreateInstanceRequest{ + Name: "orchestrator", + InstanceId: "@counter@key", + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "reserved entity format") +} + func Test_GrpcExecutor_SignalEntity_PreservesScheduledTimeAndRequestID(t *testing.T) { be := &capturingBackend{} executor, _ := NewGrpcExecutor(be, DefaultLogger()) diff --git a/backend/orchestration.go b/backend/orchestration.go index 71ecd5e9..f491efe2 100644 --- a/backend/orchestration.go +++ b/backend/orchestration.go @@ -380,6 +380,10 @@ func (w *orchestratorProcessor) processEntityWorkItem(ctx context.Context, wi *O id := uuid.New() orchInstanceID = hex.EncodeToString(id[:]) } + if err := helpers.ValidateOrchestrationInstanceID(orchInstanceID); err != nil { + w.logger.Warnf("%v: dropping entity start-orchestration action for reserved instance ID %q: %v", wi.InstanceID, orchInstanceID, err) + continue + } e := helpers.NewExecutionStartedEvent(startOrch.Name, orchInstanceID, startOrch.Input, nil, nil, startOrch.ScheduledTime) if err := w.be.CreateOrchestrationInstance(ctx, e); err != nil { w.logger.Warnf("%v: failed to start orchestration %s: %v", wi.InstanceID, orchInstanceID, err) diff --git a/backend/postgres/postgres.go b/backend/postgres/postgres.go index 10d83363..394f1efd 100644 --- a/backend/postgres/postgres.go +++ b/backend/postgres/postgres.go @@ -140,7 +140,7 @@ func (be *postgresBackend) AbandonOrchestrationWorkItem(ctx context.Context, wi } defer tx.Rollback(ctx) //nolint:errcheck // rollback after commit is a no-op - var visibleTime*time.Time = nil + var visibleTime *time.Time = nil if delay := wi.GetAbandonDelay(); delay > 0 { t := time.Now().UTC().Add(delay) visibleTime = &t @@ -643,11 +643,13 @@ func (be *postgresBackend) AddNewOrchestrationEvent(ctx context.Context, iid api return err } + visibleTime := helpers.GetVisibleTime(e) _, err = be.db.Exec( ctx, - `INSERT INTO NewEvents (InstanceID, EventPayload) VALUES ($1, $2)`, + `INSERT INTO NewEvents (InstanceID, EventPayload, VisibleTime) VALUES ($1, $2, $3)`, string(iid), eventPayload, + visibleTime, ) if err != nil { @@ -769,7 +771,7 @@ func (be *postgresBackend) GetOrchestrationWorkItem(ctx context.Context) (*backe defer tx.Rollback(ctx) //nolint:errcheck // rollback after commit is a no-op now := time.Now().UTC() - newLockExpiration:= now.Add(be.options.OrchestrationLockTimeout) + newLockExpiration := now.Add(be.options.OrchestrationLockTimeout) // Place a lock on an orchestration instance that has new events that are ready to be executed. row := tx.QueryRow( diff --git a/backend/sqlite/sqlite.go b/backend/sqlite/sqlite.go index 1e1d1eab..583081df 100644 --- a/backend/sqlite/sqlite.go +++ b/backend/sqlite/sqlite.go @@ -620,11 +620,13 @@ func (be *sqliteBackend) AddNewOrchestrationEvent(ctx context.Context, iid api.I return err } + visibleTime := helpers.GetVisibleTime(e) _, err = be.db.ExecContext( ctx, - `INSERT INTO NewEvents ([InstanceID], [EventPayload]) VALUES (?, ?)`, + `INSERT INTO NewEvents ([InstanceID], [EventPayload], [VisibleTime]) VALUES (?, ?, ?)`, string(iid), eventPayload, + visibleTime, ) if err != nil { diff --git a/client/client_grpc.go b/client/client_grpc.go index b4c9813f..d1516e55 100644 --- a/client/client_grpc.go +++ b/client/client_grpc.go @@ -12,6 +12,7 @@ import ( "github.com/microsoft/durabletask-go/api" "github.com/microsoft/durabletask-go/backend" + "github.com/microsoft/durabletask-go/internal/helpers" "github.com/microsoft/durabletask-go/internal/protos" ) @@ -42,6 +43,9 @@ func (c *TaskHubGrpcClient) ScheduleNewOrchestration(ctx context.Context, orches if req.InstanceId == "" { req.InstanceId = uuid.NewString() } + if err := helpers.ValidateOrchestrationInstanceID(req.InstanceId); err != nil { + return api.EmptyInstanceID, err + } resp, err := c.client.StartInstance(ctx, req) if err != nil { if ctx.Err() != nil { @@ -216,6 +220,10 @@ func (c *TaskHubGrpcClient) PurgeOrchestrationState(ctx context.Context, id api. // // If the entity doesn't exist, it will be created automatically when the signal is processed. func (c *TaskHubGrpcClient) SignalEntity(ctx context.Context, entityID api.EntityID, operationName string, opts ...api.SignalEntityOptions) error { + if err := helpers.ValidateEntityName(entityID.Name); err != nil { + return err + } + req := &protos.SignalEntityRequest{ InstanceId: entityID.String(), Name: operationName, @@ -239,6 +247,10 @@ func (c *TaskHubGrpcClient) SignalEntity(ctx context.Context, entityID api.Entit // // Returns nil if the entity doesn't exist. func (c *TaskHubGrpcClient) FetchEntityMetadata(ctx context.Context, entityID api.EntityID, includeState bool) (*api.EntityMetadata, error) { + if err := helpers.ValidateEntityName(entityID.Name); err != nil { + return nil, err + } + req := &protos.GetEntityRequest{ InstanceId: entityID.String(), IncludeState: includeState, diff --git a/internal/helpers/entity_ids.go b/internal/helpers/entity_ids.go new file mode 100644 index 00000000..f5feb47b --- /dev/null +++ b/internal/helpers/entity_ids.go @@ -0,0 +1,52 @@ +package helpers + +import ( + "fmt" + "strings" +) + +// ValidateEntityName applies the durable-entity naming constraints used by the wire format. +func ValidateEntityName(name string) error { + switch { + case name == "": + return fmt.Errorf("invalid entity id: entity name must not be empty") + case strings.Contains(name, "@"): + return fmt.Errorf("invalid entity id: entity name %q must not contain '@'", name) + default: + return nil + } +} + +// ParseEntityInstanceID parses an entity instance ID in the format "@@". +func ParseEntityInstanceID(instanceID string) (string, string, error) { + if !strings.HasPrefix(instanceID, "@") { + return "", "", fmt.Errorf("invalid entity instance ID format: %q", instanceID) + } + + trimmed := instanceID[1:] + name, key, ok := strings.Cut(trimmed, "@") + if !ok { + return "", "", fmt.Errorf("invalid entity instance ID format: missing second '@'") + } + if err := ValidateEntityName(name); err != nil { + return "", "", err + } + return strings.ToLower(name), key, nil +} + +// IsEntityInstanceID reports whether the instance ID uses the reserved entity format. +func IsEntityInstanceID(instanceID string) bool { + _, _, err := ParseEntityInstanceID(instanceID) + return err == nil +} + +// ValidateOrchestrationInstanceID rejects orchestration IDs that collide with entity instance IDs. +func ValidateOrchestrationInstanceID(instanceID string) error { + if instanceID == "" { + return nil + } + if IsEntityInstanceID(instanceID) { + return fmt.Errorf("orchestration instance ID %q uses the reserved entity format", instanceID) + } + return nil +} diff --git a/internal/helpers/visibility.go b/internal/helpers/visibility.go new file mode 100644 index 00000000..2ac20f70 --- /dev/null +++ b/internal/helpers/visibility.go @@ -0,0 +1,21 @@ +package helpers + +import ( + "time" + + "github.com/microsoft/durabletask-go/internal/protos" +) + +// GetVisibleTime returns the visibility time for a new event when it should be delayed. +func GetVisibleTime(e *protos.HistoryEvent) *time.Time { + if e == nil || e.Timestamp == nil { + return nil + } + + visibleTime := e.Timestamp.AsTime() + if !visibleTime.After(time.Now()) { + return nil + } + + return &visibleTime +} diff --git a/task/entity.go b/task/entity.go index f6b63932..8fb7ec1d 100644 --- a/task/entity.go +++ b/task/entity.go @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" "github.com/microsoft/durabletask-go/api" + "github.com/microsoft/durabletask-go/internal/helpers" "github.com/microsoft/durabletask-go/internal/protos" "google.golang.org/protobuf/types/known/wrapperspb" ) @@ -68,6 +69,10 @@ func (ctx *EntityContext) SetState(state any) error { // SignalEntity sends a fire-and-forget signal to another entity. func (ctx *EntityContext) SignalEntity(entityID api.EntityID, operationName string, input any) error { + if err := helpers.ValidateEntityName(entityID.Name); err != nil { + return err + } + var rawInput *wrapperspb.StringValue if input != nil { bytes, err := json.Marshal(input) @@ -102,6 +107,8 @@ func (ctx *EntityContext) StartNewOrchestration(name string, opts ...entityStart if options.instanceID == "" { id := uuid.New() options.instanceID = hex.EncodeToString(id[:]) + } else if err := helpers.ValidateOrchestrationInstanceID(options.instanceID); err != nil { + return err } action := &protos.OperationAction{ diff --git a/task/entity_test.go b/task/entity_test.go index 335dcc99..98db4045 100644 --- a/task/entity_test.go +++ b/task/entity_test.go @@ -92,6 +92,17 @@ func Test_EntityContext_SignalEntity(t *testing.T) { assert.Equal(t, "5", signal.Input.GetValue()) } +func Test_EntityContext_SignalEntity_RejectsInvalidEntityID(t *testing.T) { + ctx := &EntityContext{ + ID: api.NewEntityID("test", "key1"), + Operation: "op", + } + + err := ctx.SignalEntity(api.EntityID{Name: "bad@name", Key: "key2"}, "increment", 5) + require.Error(t, err) + require.Empty(t, ctx.actions) +} + func Test_EntityContext_StartNewOrchestration(t *testing.T) { ctx := &EntityContext{ ID: api.NewEntityID("test", "key1"), @@ -128,6 +139,17 @@ func Test_EntityContext_StartNewOrchestration_DefaultInstanceID(t *testing.T) { assert.Regexp(t, regexp.MustCompile("^[a-f0-9]{32}$"), startOrch.InstanceId) } +func Test_EntityContext_StartNewOrchestration_RejectsEntityInstanceID(t *testing.T) { + ctx := &EntityContext{ + ID: api.NewEntityID("test", "key1"), + Operation: "op", + } + + err := ctx.StartNewOrchestration("MyOrchestrator", WithEntityStartOrchestrationInstanceID("@counter@key1")) + require.Error(t, err) + require.Empty(t, ctx.actions) +} + func Test_EntityRegistry(t *testing.T) { r := NewTaskRegistry() @@ -138,4 +160,10 @@ func Test_EntityRegistry(t *testing.T) { err := r.AddEntityN("counter", myEntity) require.Error(t, err) assert.Contains(t, err.Error(), "already registered") + + err = r.AddEntityN("", myEntity) + require.Error(t, err) + + err = r.AddEntityN("bad@name", myEntity) + require.Error(t, err) } diff --git a/task/orchestrator.go b/task/orchestrator.go index bab89212..c6f275f6 100644 --- a/task/orchestrator.go +++ b/task/orchestrator.go @@ -465,6 +465,11 @@ func (ctx *OrchestrationContext) CallEntity(entityID api.EntityID, operationName return failedTask } } + if err := helpers.ValidateEntityName(entityID.Name); err != nil { + failedTask := newTask(ctx) + failedTask.fail(helpers.NewTaskFailureDetails(err)) + return failedTask + } // Generate a deterministic request ID for response correlation. requestID := ctx.NewGuid() @@ -512,6 +517,9 @@ func (ctx *OrchestrationContext) SignalEntity(entityID api.EntityID, operationNa return err } } + if err := helpers.ValidateEntityName(entityID.Name); err != nil { + return err + } // Build the .NET-compatible RequestMessage payload with isSignal=true. reqMsg := helpers.EntityRequestMessage{ diff --git a/task/registry.go b/task/registry.go index 5e4b3e79..d7830614 100644 --- a/task/registry.go +++ b/task/registry.go @@ -65,6 +65,9 @@ func (r *TaskRegistry) AddEntity(e Entity) error { // AddEntityN adds an entity function to the registry with a specified name. func (r *TaskRegistry) AddEntityN(name string, e Entity) error { + if err := helpers.ValidateEntityName(name); err != nil { + return err + } name = strings.ToLower(name) if _, ok := r.entities[name]; ok { return fmt.Errorf("entity named '%s' is already registered", name) From c7494804ecc9781049470108d79ac82cb8cd6d6d Mon Sep 17 00:00:00 2001 From: qmuntal Date: Tue, 24 Mar 2026 16:11:51 +0100 Subject: [PATCH 12/13] fix: respect explicit entity state updates Make NewEntityFor preserve explicit ctx.SetState mutations, including state deletion, instead of always re-saving the reflected receiver state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- task/entity.go | 2 ++ task/entity_dispatch.go | 6 +++-- task/entity_dispatch_test.go | 52 ++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/task/entity.go b/task/entity.go index 8fb7ec1d..93d4c1de 100644 --- a/task/entity.go +++ b/task/entity.go @@ -23,6 +23,7 @@ type EntityContext struct { rawInput []byte state entityState + stateDirty bool actions []*protos.OperationAction actionIDSeq int32 } @@ -53,6 +54,7 @@ func (ctx *EntityContext) GetState(v any) error { // SetState sets the entity state. The state must be JSON-serializable. // Passing nil deletes the entity state. func (ctx *EntityContext) SetState(state any) error { + ctx.stateDirty = true if state == nil { ctx.state.value = nil ctx.state.hasValue = false diff --git a/task/entity_dispatch.go b/task/entity_dispatch.go index 66fd00c3..14487d6d 100644 --- a/task/entity_dispatch.go +++ b/task/entity_dispatch.go @@ -74,8 +74,10 @@ func NewEntityFor[S any]() Entity { } // Save state back - if err := ctx.SetState(state); err != nil { - return nil, fmt.Errorf("failed to save entity state: %w", err) + if !ctx.stateDirty { + if err := ctx.SetState(state); err != nil { + return nil, fmt.Errorf("failed to save entity state: %w", err) + } } return result, nil } diff --git a/task/entity_dispatch_test.go b/task/entity_dispatch_test.go index 6119e39d..45e8c2a7 100644 --- a/task/entity_dispatch_test.go +++ b/task/entity_dispatch_test.go @@ -279,6 +279,30 @@ func Test_EntityDispatcher_ExplicitDeleteOverridesImplicit(t *testing.T) { assert.Equal(t, 42, state.Value) } +type entityWithContextDelete struct { + Value int `json:"value"` + Deleted bool `json:"deleted"` +} + +func (e *entityWithContextDelete) Delete(ctx *EntityContext) (any, error) { + e.Deleted = true + return "deleted via ctx", ctx.SetState(nil) +} + +func Test_EntityDispatcher_ExplicitContextDeleteCanClearState(t *testing.T) { + entity := NewEntityFor[entityWithContextDelete]() + + ctx := &EntityContext{ + ID: api.NewEntityID("e", "k"), + Operation: "delete", + state: entityState{value: []byte(`{"value":42,"deleted":false}`), hasValue: true}, + } + result, err := entity(ctx) + require.NoError(t, err) + assert.Equal(t, "deleted via ctx", result) + assert.False(t, ctx.HasState()) +} + // Throws_ExceptionPreserved: error propagation from entity methods type errorEntity struct{} @@ -498,6 +522,34 @@ func Test_EntityDispatcher_ContextAndInputBinding(t *testing.T) { assert.Equal(t, "@logger@main:Process:hello world", state.Log[0]) } +type explicitStateEntity struct { + Value int `json:"value"` + Mode string `json:"mode,omitempty"` +} + +func (e *explicitStateEntity) Replace(ctx *EntityContext, value int) (any, error) { + e.Value = value + e.Mode = "receiver" + return value, ctx.SetState(explicitStateEntity{Value: value * 2, Mode: "context"}) +} + +func Test_EntityDispatcher_ExplicitContextStateWins(t *testing.T) { + entity := NewEntityFor[explicitStateEntity]() + + ctx := &EntityContext{ + ID: api.NewEntityID("explicit", "k"), + Operation: "Replace", + rawInput: []byte("5"), + } + result, err := entity(ctx) + require.NoError(t, err) + assert.Equal(t, 5, result) + + var state explicitStateEntity + require.NoError(t, ctx.GetState(&state)) + assert.Equal(t, explicitStateEntity{Value: 10, Mode: "context"}, state) +} + func Test_EntityDispatcher_ZeroValueInitialization(t *testing.T) { entity := NewEntityFor[testCounter]() From 4edd2cc16aa62ba61733b174a60d556ceb708fa2 Mon Sep 17 00:00:00 2001 From: qmuntal Date: Tue, 24 Mar 2026 16:43:06 +0100 Subject: [PATCH 13/13] fix: address entity review findings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/executor.go | 66 +++++++++++++++++++++++++++-------- backend/executor_test.go | 26 +++++++++++++- backend/orchestration.go | 4 +-- client/client_grpc.go | 2 +- client/client_grpc_test.go | 48 +++++++++++++++++++++++++ client/worker_grpc.go | 2 +- task/entity_dispatch.go | 4 +++ task/entity_dispatch_test.go | 6 ++++ task/executor.go | 2 +- task/orchestrator.go | 7 ++-- task/orchestrator_test.go | 33 ++++++++++++++++++ tests/entity_executor_test.go | 47 +++++++++---------------- 12 files changed, 194 insertions(+), 53 deletions(-) create mode 100644 client/client_grpc_test.go diff --git a/backend/executor.go b/backend/executor.go index f9aa4bea..ea2a66ff 100644 --- a/backend/executor.go +++ b/backend/executor.go @@ -47,6 +47,44 @@ type entityExecutionResult struct { pending chan string } +type entityExecutionQueue struct { + mu sync.Mutex + keys []string +} + +func (q *entityExecutionQueue) Enqueue(key string) { + q.mu.Lock() + defer q.mu.Unlock() + q.keys = append(q.keys, key) +} + +func (q *entityExecutionQueue) Dequeue() (string, bool) { + q.mu.Lock() + defer q.mu.Unlock() + + if len(q.keys) == 0 { + return "", false + } + + key := q.keys[0] + q.keys = q.keys[1:] + return key, true +} + +func (q *entityExecutionQueue) Remove(key string) bool { + q.mu.Lock() + defer q.mu.Unlock() + + for i, candidate := range q.keys { + if candidate == key { + q.keys = append(q.keys[:i], q.keys[i+1:]...) + return true + } + } + + return false +} + type Executor interface { ExecuteOrchestrator(ctx context.Context, iid api.InstanceID, oldEvents []*protos.HistoryEvent, newEvents []*protos.HistoryEvent) (*ExecutionResults, error) ExecuteActivity(context.Context, api.InstanceID, *protos.HistoryEvent) (*protos.HistoryEvent, error) @@ -59,7 +97,7 @@ type grpcExecutor struct { pendingOrchestrators *sync.Map // map[api.InstanceID]*ExecutionResults pendingActivities *sync.Map // map[string]*activityExecutionResult pendingEntities *sync.Map // map[string]*entityExecutionResult - entityQueue chan string + entityQueue *entityExecutionQueue backend Backend logger Logger onWorkItemConnection func(context.Context) error @@ -98,7 +136,7 @@ func NewGrpcExecutor(be Backend, logger Logger, opts ...grpcExecutorOptions) (ex pendingOrchestrators: &sync.Map{}, pendingActivities: &sync.Map{}, pendingEntities: &sync.Map{}, - entityQueue: make(chan string, 100), + entityQueue: &entityExecutionQueue{}, } for _, opt := range opts { @@ -232,7 +270,7 @@ func (g *grpcExecutor) Shutdown(ctx context.Context) error { } // ExecuteEntity implements Executor -func (executor *grpcExecutor) ExecuteEntity(ctx context.Context, iid api.InstanceID, req *protos.EntityBatchRequest) (*protos.EntityBatchResult, error) { +func (executor *grpcExecutor) ExecuteEntity(ctx context.Context, req *protos.EntityBatchRequest) (*protos.EntityBatchResult, error) { key := req.InstanceId result := &entityExecutionResult{complete: make(chan struct{})} if _, loaded := executor.pendingEntities.LoadOrStore(key, result); loaded { @@ -248,26 +286,25 @@ func (executor *grpcExecutor) ExecuteEntity(ctx context.Context, iid api.Instanc select { case <-ctx.Done(): executor.pendingEntities.Delete(key) - executor.logger.Warnf("%s: context canceled before dispatching entity work item", iid) + executor.logger.Warnf("%s: context canceled before dispatching entity work item", key) return nil, ctx.Err() case executor.workItemQueue <- workItem: } - // Non-blocking send to FIFO queue (fallback for non-Go workers without metadata). - // Go workers use gRPC metadata for correlation and never drain this queue. - select { - case executor.entityQueue <- key: - default: - } + // Track FIFO completion order for workers that don't send metadata. + // Metadata-based completions remove their corresponding key from this queue. + executor.entityQueue.Enqueue(key) select { case <-ctx.Done(): executor.pendingEntities.Delete(key) - executor.logger.Warnf("%s: context canceled before receiving entity result", iid) + executor.entityQueue.Remove(key) + executor.logger.Warnf("%s: context canceled before receiving entity result", key) return nil, ctx.Err() case <-result.complete: executor.logger.Debugf("%s: entity got result", key) if result.response == nil { + executor.entityQueue.Remove(key) return nil, ErrOperationAborted } } @@ -451,11 +488,12 @@ func (g *grpcExecutor) CompleteEntityTask(ctx context.Context, res *protos.Entit } if key == "" { // Fallback to FIFO queue for non-Go workers that don't send metadata. - select { - case key = <-g.entityQueue: - default: + var ok bool + if key, ok = g.entityQueue.Dequeue(); !ok { return emptyCompleteTaskResponse, fmt.Errorf("no pending entity found for completion: missing entity-instance-id metadata") } + } else { + g.entityQueue.Remove(key) } p, ok := g.pendingEntities.LoadAndDelete(key) diff --git a/backend/executor_test.go b/backend/executor_test.go index ba0973f2..f33e600d 100644 --- a/backend/executor_test.go +++ b/backend/executor_test.go @@ -11,6 +11,7 @@ import ( "github.com/microsoft/durabletask-go/internal/protos" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc/metadata" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -66,7 +67,7 @@ func Test_GrpcExecutor_ExecuteEntity_RejectsConcurrentInstance(t *testing.T) { req := &protos.EntityBatchRequest{InstanceId: "@counter@key"} g.pendingEntities.Store(req.InstanceId, &entityExecutionResult{complete: make(chan struct{})}) - _, err := g.ExecuteEntity(context.Background(), api.InstanceID(req.InstanceId), req) + _, err := g.ExecuteEntity(context.Background(), req) require.Error(t, err) assert.Contains(t, err.Error(), "already pending") } @@ -109,3 +110,26 @@ func Test_GrpcExecutor_SignalEntity_PreservesScheduledTimeAndRequestID(t *testin require.True(t, msg.IsSignal) require.Equal(t, "increment", msg.Operation) } + +func Test_GrpcExecutor_CompleteEntityTask_RemovesMetadataCorrelatedQueueEntry(t *testing.T) { + executor, _ := NewGrpcExecutor(nil, DefaultLogger()) + g := executor.(*grpcExecutor) + + first := &entityExecutionResult{complete: make(chan struct{})} + second := &entityExecutionResult{complete: make(chan struct{})} + g.pendingEntities.Store("@counter@one", first) + g.pendingEntities.Store("@counter@two", second) + g.entityQueue.Enqueue("@counter@one") + g.entityQueue.Enqueue("@counter@two") + + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("entity-instance-id", "@counter@one")) + _, err := g.CompleteEntityTask(ctx, &protos.EntityBatchResult{}) + require.NoError(t, err) + + next, ok := g.entityQueue.Dequeue() + require.True(t, ok) + assert.Equal(t, "@counter@two", next) + + _, ok = g.entityQueue.Dequeue() + assert.False(t, ok) +} diff --git a/backend/orchestration.go b/backend/orchestration.go index f491efe2..eeee13ce 100644 --- a/backend/orchestration.go +++ b/backend/orchestration.go @@ -33,7 +33,7 @@ type OrchestratorExecutor interface { // entity work items will be automatically dispatched. type EntityExecutor interface { Executor - ExecuteEntity(context.Context, api.InstanceID, *protos.EntityBatchRequest) (*protos.EntityBatchResult, error) + ExecuteEntity(context.Context, *protos.EntityBatchRequest) (*protos.EntityBatchResult, error) } type orchestratorProcessor struct { @@ -305,7 +305,7 @@ func (w *orchestratorProcessor) processEntityWorkItem(ctx context.Context, wi *O Operations: operations, } - batchResult, err := w.entityExecutor.ExecuteEntity(ctx, wi.InstanceID, batchReq) + batchResult, err := w.entityExecutor.ExecuteEntity(ctx, batchReq) if err != nil { return fmt.Errorf("failed to execute entity: %w", err) } diff --git a/client/client_grpc.go b/client/client_grpc.go index d1516e55..cd8f7a8d 100644 --- a/client/client_grpc.go +++ b/client/client_grpc.go @@ -315,7 +315,7 @@ func (c *TaskHubGrpcClient) QueryEntities(ctx context.Context, query api.EntityQ for _, e := range resp.Entities { entityID, parseErr := api.EntityIDFromString(e.InstanceId) if parseErr != nil { - continue + return nil, fmt.Errorf("failed to parse entity ID %q: %w", e.InstanceId, parseErr) } meta := &api.EntityMetadata{ InstanceID: entityID, diff --git a/client/client_grpc_test.go b/client/client_grpc_test.go new file mode 100644 index 00000000..96137f71 --- /dev/null +++ b/client/client_grpc_test.go @@ -0,0 +1,48 @@ +package client + +import ( + "context" + "fmt" + "testing" + + "github.com/microsoft/durabletask-go/api" + "github.com/microsoft/durabletask-go/backend" + "github.com/microsoft/durabletask-go/internal/protos" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +type fakeClientConn struct { + invoke func(ctx context.Context, method string, args any, reply any, opts ...grpc.CallOption) error +} + +func (f fakeClientConn) Invoke(ctx context.Context, method string, args any, reply any, opts ...grpc.CallOption) error { + return f.invoke(ctx, method, args, reply, opts...) +} + +func (fakeClientConn) NewStream(context.Context, *grpc.StreamDesc, string, ...grpc.CallOption) (grpc.ClientStream, error) { + return nil, fmt.Errorf("unexpected stream call") +} + +func Test_TaskHubGrpcClient_QueryEntities_ErrorsOnInvalidEntityID(t *testing.T) { + conn := fakeClientConn{ + invoke: func(ctx context.Context, method string, args any, reply any, opts ...grpc.CallOption) error { + if method != protos.TaskHubSidecarService_QueryEntities_FullMethodName { + return fmt.Errorf("unexpected method: %s", method) + } + + resp := reply.(*protos.QueryEntitiesResponse) + resp.Entities = []*protos.EntityMetadata{ + {InstanceId: "not-an-entity-id"}, + } + return nil + }, + } + + client := NewTaskHubGrpcClient(conn, backend.DefaultLogger()) + + _, err := client.QueryEntities(context.Background(), api.EntityQuery{}) + require.Error(t, err) + assert.Contains(t, err.Error(), `failed to parse entity ID "not-an-entity-id"`) +} diff --git a/client/worker_grpc.go b/client/worker_grpc.go index 01fcd08c..24a6997b 100644 --- a/client/worker_grpc.go +++ b/client/worker_grpc.go @@ -215,7 +215,7 @@ func (c *TaskHubGrpcClient) processEntityWorkItem( return } - result, err := ee.ExecuteEntity(ctx, api.InstanceID(req.InstanceId), req) + result, err := ee.ExecuteEntity(ctx, req) if err != nil { result = &protos.EntityBatchResult{ diff --git a/task/entity_dispatch.go b/task/entity_dispatch.go index 14487d6d..1b45f807 100644 --- a/task/entity_dispatch.go +++ b/task/entity_dispatch.go @@ -50,6 +50,10 @@ import ( // // The special operation "delete" resets the entity state (unless a Delete method exists). func NewEntityFor[S any]() Entity { + if reflect.TypeFor[S]().Kind() == reflect.Ptr { + panic("NewEntityFor does not support pointer state types") + } + return func(ctx *EntityContext) (any, error) { // Load state var state S diff --git a/task/entity_dispatch_test.go b/task/entity_dispatch_test.go index 45e8c2a7..15174fec 100644 --- a/task/entity_dispatch_test.go +++ b/task/entity_dispatch_test.go @@ -550,6 +550,12 @@ func Test_EntityDispatcher_ExplicitContextStateWins(t *testing.T) { assert.Equal(t, explicitStateEntity{Value: 10, Mode: "context"}, state) } +func Test_EntityDispatcher_RejectsPointerStateType(t *testing.T) { + assert.PanicsWithValue(t, "NewEntityFor does not support pointer state types", func() { + _ = NewEntityFor[*testCounter]() + }) +} + func Test_EntityDispatcher_ZeroValueInitialization(t *testing.T) { entity := NewEntityFor[testCounter]() diff --git a/task/executor.go b/task/executor.go index 4c4e3b34..8eabc85c 100644 --- a/task/executor.go +++ b/task/executor.go @@ -97,7 +97,7 @@ func (te taskExecutor) Shutdown(ctx context.Context) error { } // ExecuteEntity implements backend.Executor and executes an entity batch in the current goroutine. -func (te *taskExecutor) ExecuteEntity(ctx context.Context, id api.InstanceID, req *protos.EntityBatchRequest) (result *protos.EntityBatchResult, err error) { +func (te *taskExecutor) ExecuteEntity(ctx context.Context, req *protos.EntityBatchRequest) (result *protos.EntityBatchResult, err error) { entityID, parseErr := api.EntityIDFromString(req.InstanceId) if parseErr != nil { return nil, fmt.Errorf("invalid entity instance ID: %w", parseErr) diff --git a/task/orchestrator.go b/task/orchestrator.go index c6f275f6..f3b167b8 100644 --- a/task/orchestrator.go +++ b/task/orchestrator.go @@ -523,9 +523,10 @@ func (ctx *OrchestrationContext) SignalEntity(entityID api.EntityID, operationNa // Build the .NET-compatible RequestMessage payload with isSignal=true. reqMsg := helpers.EntityRequestMessage{ - ID: ctx.NewGuid(), - IsSignal: true, - Operation: operationName, + ID: ctx.NewGuid(), + ParentInstanceID: string(ctx.ID), + IsSignal: true, + Operation: operationName, } if options.rawInput != nil { reqMsg.Input = options.rawInput.GetValue() diff --git a/task/orchestrator_test.go b/task/orchestrator_test.go index 72640b24..f7608532 100644 --- a/task/orchestrator_test.go +++ b/task/orchestrator_test.go @@ -1,8 +1,15 @@ package task import ( + "encoding/json" "testing" "time" + + "github.com/microsoft/durabletask-go/api" + "github.com/microsoft/durabletask-go/internal/helpers" + "github.com/microsoft/durabletask-go/internal/protos" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func Test_computeNextDelay(t *testing.T) { @@ -172,3 +179,29 @@ func Test_NewGuid_Format(t *testing.T) { t.Errorf("expected version 5 at position 14, got: %c in %s", guid[14], guid) } } + +func Test_OrchestrationContext_SignalEntity_SetsParentInstanceID(t *testing.T) { + ctx := &OrchestrationContext{ + ID: api.InstanceID("orchestrator-instance"), + pendingActions: make(map[int32]*protos.OrchestratorAction), + } + + err := ctx.SignalEntity(api.NewEntityID("counter", "key1"), "increment") + require.NoError(t, err) + require.Len(t, ctx.pendingActions, 1) + + var action *protos.OrchestratorAction + for _, candidate := range ctx.pendingActions { + action = candidate + } + require.NotNil(t, action) + + send := action.GetSendEvent() + require.NotNil(t, send) + + var msg helpers.EntityRequestMessage + require.NoError(t, json.Unmarshal([]byte(send.Data.GetValue()), &msg)) + assert.Equal(t, "orchestrator-instance", msg.ParentInstanceID) + assert.True(t, msg.IsSignal) + assert.Equal(t, "increment", msg.Operation) +} diff --git a/tests/entity_executor_test.go b/tests/entity_executor_test.go index d3a615ce..481841ee 100644 --- a/tests/entity_executor_test.go +++ b/tests/entity_executor_test.go @@ -49,7 +49,6 @@ func Test_Executor_EntityBasicOperation(t *testing.T) { executor := newEntityExecutor(r) entityCtx := context.Background() - iid := api.InstanceID("@counter@myCounter") // Test "add" operation with no initial state req := &protos.EntityBatchRequest{ @@ -63,7 +62,7 @@ func Test_Executor_EntityBasicOperation(t *testing.T) { }, } - result, err := executor.ExecuteEntity(entityCtx, iid, req) + result, err := executor.ExecuteEntity(entityCtx, req) require.NoError(t, err) require.Len(t, result.Results, 1) require.NotNil(t, result.Results[0].GetSuccess()) @@ -83,7 +82,7 @@ func Test_Executor_EntityBasicOperation(t *testing.T) { }, } - result2, err := executor.ExecuteEntity(entityCtx, iid, req2) + result2, err := executor.ExecuteEntity(entityCtx, req2) require.NoError(t, err) require.Len(t, result2.Results, 1) require.NotNil(t, result2.Results[0].GetSuccess()) @@ -102,7 +101,7 @@ func Test_Executor_EntityBasicOperation(t *testing.T) { }, } - result3, err := executor.ExecuteEntity(entityCtx, iid, req3) + result3, err := executor.ExecuteEntity(entityCtx, req3) require.NoError(t, err) require.Len(t, result3.Results, 1) require.NotNil(t, result3.Results[0].GetSuccess()) @@ -138,7 +137,6 @@ func Test_Executor_EntityBatchOperations(t *testing.T) { executor := newEntityExecutor(r) entityCtx := context.Background() - iid := api.InstanceID("@counter@myCounter") // Batch multiple operations req := &protos.EntityBatchRequest{ @@ -150,7 +148,7 @@ func Test_Executor_EntityBatchOperations(t *testing.T) { }, } - result, err := executor.ExecuteEntity(entityCtx, iid, req) + result, err := executor.ExecuteEntity(entityCtx, req) require.NoError(t, err) require.Len(t, result.Results, 3) @@ -191,7 +189,6 @@ func Test_Executor_EntityOperationError(t *testing.T) { executor := newEntityExecutor(r) entityCtx := context.Background() - iid := api.InstanceID("@faulty@key1") // Batch: add, fail, add — the "fail" operation should not affect state req := &protos.EntityBatchRequest{ @@ -203,7 +200,7 @@ func Test_Executor_EntityOperationError(t *testing.T) { }, } - result, err := executor.ExecuteEntity(entityCtx, iid, req) + result, err := executor.ExecuteEntity(entityCtx, req) require.NoError(t, err) require.Len(t, result.Results, 3) @@ -230,7 +227,6 @@ func Test_Executor_EntityPanic(t *testing.T) { executor := newEntityExecutor(r) entityCtx := context.Background() - iid := api.InstanceID("@panicky@key1") req := &protos.EntityBatchRequest{ InstanceId: "@panicky@key1", @@ -239,7 +235,7 @@ func Test_Executor_EntityPanic(t *testing.T) { }, } - result, err := executor.ExecuteEntity(entityCtx, iid, req) + result, err := executor.ExecuteEntity(entityCtx, req) require.NoError(t, err) require.Len(t, result.Results, 1) require.NotNil(t, result.Results[0].GetFailure()) @@ -250,7 +246,6 @@ func Test_Executor_EntityNotRegistered(t *testing.T) { r := task.NewTaskRegistry() executor := newEntityExecutor(r) entityCtx := context.Background() - iid := api.InstanceID("@unknown@key1") req := &protos.EntityBatchRequest{ InstanceId: "@unknown@key1", @@ -259,7 +254,7 @@ func Test_Executor_EntityNotRegistered(t *testing.T) { }, } - result, err := executor.ExecuteEntity(entityCtx, iid, req) + result, err := executor.ExecuteEntity(entityCtx, req) require.NoError(t, err) require.NotNil(t, result.FailureDetails) assert.Equal(t, "EntityNotRegistered", result.FailureDetails.ErrorType) @@ -273,7 +268,6 @@ func Test_Executor_EntitySignalAction(t *testing.T) { executor := newEntityExecutor(r) entityCtx := context.Background() - iid := api.InstanceID("@sender@key1") req := &protos.EntityBatchRequest{ InstanceId: "@sender@key1", @@ -282,7 +276,7 @@ func Test_Executor_EntitySignalAction(t *testing.T) { }, } - result, err := executor.ExecuteEntity(entityCtx, iid, req) + result, err := executor.ExecuteEntity(entityCtx, req) require.NoError(t, err) require.Len(t, result.Results, 1) require.NotNil(t, result.Results[0].GetSuccess()) @@ -310,7 +304,6 @@ func Test_Executor_EntityDeleteState(t *testing.T) { executor := newEntityExecutor(r) entityCtx := context.Background() - iid := api.InstanceID("@deletable@key1") // First set state req := &protos.EntityBatchRequest{ @@ -320,7 +313,7 @@ func Test_Executor_EntityDeleteState(t *testing.T) { }, } - result, err := executor.ExecuteEntity(entityCtx, iid, req) + result, err := executor.ExecuteEntity(entityCtx, req) require.NoError(t, err) assert.Equal(t, "42", result.EntityState.GetValue()) @@ -333,7 +326,7 @@ func Test_Executor_EntityDeleteState(t *testing.T) { }, } - result2, err := executor.ExecuteEntity(entityCtx, iid, req2) + result2, err := executor.ExecuteEntity(entityCtx, req2) require.NoError(t, err) assert.Nil(t, result2.EntityState) } @@ -358,7 +351,6 @@ func Test_Executor_EntityStatePersistsAcrossBatches(t *testing.T) { executor := newEntityExecutor(r) entityCtx := context.Background() - iid := api.InstanceID("@counter@persist") // Batch 1: increment 3 times req := &protos.EntityBatchRequest{ @@ -369,7 +361,7 @@ func Test_Executor_EntityStatePersistsAcrossBatches(t *testing.T) { {Operation: "increment", RequestId: "r3"}, }, } - result, err := executor.ExecuteEntity(entityCtx, iid, req) + result, err := executor.ExecuteEntity(entityCtx, req) require.NoError(t, err) require.Len(t, result.Results, 3) assert.Equal(t, "3", result.EntityState.GetValue()) @@ -383,7 +375,7 @@ func Test_Executor_EntityStatePersistsAcrossBatches(t *testing.T) { {Operation: "get", RequestId: "r5"}, }, } - result2, err := executor.ExecuteEntity(entityCtx, iid, req2) + result2, err := executor.ExecuteEntity(entityCtx, req2) require.NoError(t, err) require.Len(t, result2.Results, 2) // After 4th increment: 4 @@ -423,7 +415,6 @@ func Test_Executor_EntityErrorRollbackInBatch(t *testing.T) { executor := newEntityExecutor(r) entityCtx := context.Background() - iid := api.InstanceID("@rollback@key1") req := &protos.EntityBatchRequest{ InstanceId: "@rollback@key1", @@ -434,7 +425,7 @@ func Test_Executor_EntityErrorRollbackInBatch(t *testing.T) { }, } - result, err := executor.ExecuteEntity(entityCtx, iid, req) + result, err := executor.ExecuteEntity(entityCtx, req) require.NoError(t, err) require.Len(t, result.Results, 3) @@ -463,7 +454,6 @@ func Test_Executor_EntityWildcardRegistration(t *testing.T) { executor := newEntityExecutor(r) entityCtx := context.Background() - iid := api.InstanceID("@anything@key1") req := &protos.EntityBatchRequest{ InstanceId: "@anything@key1", @@ -472,7 +462,7 @@ func Test_Executor_EntityWildcardRegistration(t *testing.T) { }, } - result, err := executor.ExecuteEntity(entityCtx, iid, req) + result, err := executor.ExecuteEntity(entityCtx, req) require.NoError(t, err) require.Len(t, result.Results, 1) require.NotNil(t, result.Results[0].GetSuccess()) @@ -498,7 +488,6 @@ func Test_Executor_EntitySignalAndStartOrchestrationActions(t *testing.T) { executor := newEntityExecutor(r) entityCtx := context.Background() - iid := api.InstanceID("@coordinator@main") req := &protos.EntityBatchRequest{ InstanceId: "@coordinator@main", @@ -507,7 +496,7 @@ func Test_Executor_EntitySignalAndStartOrchestrationActions(t *testing.T) { }, } - result, err := executor.ExecuteEntity(entityCtx, iid, req) + result, err := executor.ExecuteEntity(entityCtx, req) require.NoError(t, err) require.Len(t, result.Results, 1) require.NotNil(t, result.Results[0].GetSuccess()) @@ -569,7 +558,6 @@ func Test_Executor_EntityComplexState(t *testing.T) { executor := newEntityExecutor(r) entityCtx := context.Background() - iid := api.InstanceID("@cart@user1") req := &protos.EntityBatchRequest{ InstanceId: "@cart@user1", @@ -580,7 +568,7 @@ func Test_Executor_EntityComplexState(t *testing.T) { }, } - result, err := executor.ExecuteEntity(entityCtx, iid, req) + result, err := executor.ExecuteEntity(entityCtx, req) require.NoError(t, err) require.Len(t, result.Results, 3) @@ -603,14 +591,13 @@ func Test_Executor_EntityEmptyBatch(t *testing.T) { executor := newEntityExecutor(r) entityCtx := context.Background() - iid := api.InstanceID("@noop@key1") req := &protos.EntityBatchRequest{ InstanceId: "@noop@key1", Operations: []*protos.OperationRequest{}, } - result, err := executor.ExecuteEntity(entityCtx, iid, req) + result, err := executor.ExecuteEntity(entityCtx, req) require.NoError(t, err) assert.Empty(t, result.Results) assert.Empty(t, result.Actions)