diff --git a/src/Temporalio/Client/Schedules/Schedule.cs b/src/Temporalio/Client/Schedules/Schedule.cs index 2d145ad1..47da933d 100644 --- a/src/Temporalio/Client/Schedules/Schedule.cs +++ b/src/Temporalio/Client/Schedules/Schedule.cs @@ -30,10 +30,10 @@ public record Schedule( /// Proto. /// Data converter. /// Converted value. - internal static Schedule FromProto( + internal static async Task FromProtoAsync( Api.Schedule.V1.Schedule proto, DataConverter dataConverter) => new( - Action: ScheduleAction.FromProto(proto.Action, dataConverter), + Action: await ScheduleAction.FromProtoAsync(proto.Action, dataConverter).ConfigureAwait(false), Spec: ScheduleSpec.FromProto(proto.Spec)) { Policy = SchedulePolicy.FromProto(proto.Policies), diff --git a/src/Temporalio/Client/Schedules/ScheduleAction.cs b/src/Temporalio/Client/Schedules/ScheduleAction.cs index afd10966..e20eb374 100644 --- a/src/Temporalio/Client/Schedules/ScheduleAction.cs +++ b/src/Temporalio/Client/Schedules/ScheduleAction.cs @@ -16,12 +16,13 @@ public abstract record ScheduleAction /// Proto. /// Data converter. /// Converted value. - internal static ScheduleAction FromProto( + internal static async Task FromProtoAsync( Api.Schedule.V1.ScheduleAction proto, DataConverter dataConverter) { if (proto.StartWorkflow != null) { - return ScheduleActionStartWorkflow.FromProto(proto.StartWorkflow, dataConverter); + return await ScheduleActionStartWorkflow.FromProtoAsync( + proto.StartWorkflow, dataConverter).ConfigureAwait(false); } else { diff --git a/src/Temporalio/Client/Schedules/ScheduleActionStartWorkflow.cs b/src/Temporalio/Client/Schedules/ScheduleActionStartWorkflow.cs index 4882a3af..51cd6fe5 100644 --- a/src/Temporalio/Client/Schedules/ScheduleActionStartWorkflow.cs +++ b/src/Temporalio/Client/Schedules/ScheduleActionStartWorkflow.cs @@ -84,7 +84,7 @@ public static ScheduleActionStartWorkflow Create( /// Proto. /// Data converter. /// Converted value. - internal static ScheduleActionStartWorkflow FromProto( + internal static async Task FromProtoAsync( Api.Workflow.V1.NewWorkflowExecutionInfo proto, DataConverter dataConverter) { IReadOnlyCollection args = proto.Input == null ? @@ -93,6 +93,8 @@ internal static ScheduleActionStartWorkflow FromProto( var headers = proto.Header?.Fields?.ToDictionary( kvp => kvp.Key, kvp => (IEncodedRawValue)new EncodedRawValue(dataConverter, kvp.Value)); + var (staticSummary, staticDetails) = + await dataConverter.FromUserMetadataAsync(proto.UserMetadata).ConfigureAwait(false); return new( Workflow: proto.WorkflowType.Name, Args: args, @@ -109,6 +111,8 @@ internal static ScheduleActionStartWorkflow FromProto( TypedSearchAttributes = proto.SearchAttributes == null ? SearchAttributeCollection.Empty : SearchAttributeCollection.FromProto(proto.SearchAttributes), + StaticSummary = staticSummary, + StaticDetails = staticDetails, }, Headers: headers); } @@ -168,6 +172,8 @@ internal static ScheduleActionStartWorkflow FromProto( WorkflowTaskTimeout = Options.TaskTimeout is TimeSpan taskTimeout ? Duration.FromTimeSpan(taskTimeout) : null, RetryPolicy = Options.RetryPolicy?.ToProto(), + UserMetadata = await dataConverter.ToUserMetadataAsync( + Options.StaticSummary, Options.StaticDetails).ConfigureAwait(false), }; if (Options.Memo != null && Options.Memo.Count > 0) { diff --git a/src/Temporalio/Client/Schedules/ScheduleDescription.cs b/src/Temporalio/Client/Schedules/ScheduleDescription.cs index afb47ea9..9f687923 100644 --- a/src/Temporalio/Client/Schedules/ScheduleDescription.cs +++ b/src/Temporalio/Client/Schedules/ScheduleDescription.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading; +using System.Threading.Tasks; using Temporalio.Api.WorkflowService.V1; using Temporalio.Common; using Temporalio.Converters; @@ -16,16 +17,11 @@ public class ScheduleDescription private readonly Lazy> memo; private readonly Lazy searchAttributes; - /// - /// Initializes a new instance of the class. - /// - /// Workflow ID. - /// Raw proto description. - /// Data converter. - internal ScheduleDescription( - string id, DescribeScheduleResponse rawDescription, DataConverter dataConverter) + private ScheduleDescription( + string id, Schedule schedule, DescribeScheduleResponse rawDescription, DataConverter dataConverter) { Id = id; + Schedule = schedule; RawDescription = rawDescription; // Search attribute conversion is cheap so it doesn't need to lock on publication. But // memo conversion may use remote codec so it should only ever be created once lazily. @@ -39,7 +35,6 @@ internal ScheduleDescription( SearchAttributeCollection.Empty : SearchAttributeCollection.FromProto(rawDescription.SearchAttributes), LazyThreadSafetyMode.PublicationOnly); - Schedule = Schedule.FromProto(rawDescription.Schedule, dataConverter); Info = ScheduleInfo.FromProto(rawDescription.Info); } @@ -77,5 +72,20 @@ internal ScheduleDescription( /// Gets the raw proto description. /// internal DescribeScheduleResponse RawDescription { get; private init; } + + /// + /// Convert from proto. + /// + /// ID. + /// Proto. + /// Converter. + /// Converted value. + internal static async Task FromProtoAsync( + string id, DescribeScheduleResponse rawDescription, DataConverter dataConverter) => + new( + id, + await Schedule.FromProtoAsync(rawDescription.Schedule, dataConverter).ConfigureAwait(false), + rawDescription, + dataConverter); } } \ No newline at end of file diff --git a/src/Temporalio/Client/TemporalClient.Schedules.cs b/src/Temporalio/Client/TemporalClient.Schedules.cs index 6b0499ef..5a97b994 100644 --- a/src/Temporalio/Client/TemporalClient.Schedules.cs +++ b/src/Temporalio/Client/TemporalClient.Schedules.cs @@ -136,7 +136,8 @@ public override async Task DescribeScheduleAsync( ScheduleId = input.Id, }, DefaultRetryOptions(input.RpcOptions)).ConfigureAwait(false); - return new(input.Id, desc, Client.Options.DataConverter); + return await ScheduleDescription.FromProtoAsync( + input.Id, desc, Client.Options.DataConverter).ConfigureAwait(false); } /// diff --git a/src/Temporalio/Client/TemporalClient.Workflow.cs b/src/Temporalio/Client/TemporalClient.Workflow.cs index 507503e4..8fca7506 100644 --- a/src/Temporalio/Client/TemporalClient.Workflow.cs +++ b/src/Temporalio/Client/TemporalClient.Workflow.cs @@ -332,7 +332,8 @@ public override async Task DescribeWorkflowAsync( }, }, DefaultRetryOptions(input.Options?.Rpc)).ConfigureAwait(false); - return new(resp, Client.Options.DataConverter); + return await WorkflowExecutionDescription.FromProtoAsync( + resp, Client.Options.DataConverter).ConfigureAwait(false); } /// @@ -513,6 +514,9 @@ private async Task> StartWorkflowInternalAsyn WorkflowIdConflictPolicy = input.Options.IdConflictPolicy, RetryPolicy = input.Options.RetryPolicy?.ToProto(), RequestEagerExecution = input.Options.RequestEagerStart, + UserMetadata = await Client.Options.DataConverter.ToUserMetadataAsync( + input.Options.StaticSummary, input.Options.StaticDetails). + ConfigureAwait(false), }; if (input.Args.Count > 0) { @@ -614,6 +618,7 @@ private async Task> StartWorkflowInternalAsyn WorkflowStartDelay = req.WorkflowStartDelay, SignalName = input.Options.StartSignal, WorkflowIdConflictPolicy = input.Options.IdConflictPolicy, + UserMetadata = req.UserMetadata, }; if (input.Options.StartSignalArgs != null && input.Options.StartSignalArgs.Count > 0) { diff --git a/src/Temporalio/Client/WorkflowExecutionDescription.cs b/src/Temporalio/Client/WorkflowExecutionDescription.cs index 601c9e93..3ec20615 100644 --- a/src/Temporalio/Client/WorkflowExecutionDescription.cs +++ b/src/Temporalio/Client/WorkflowExecutionDescription.cs @@ -1,3 +1,4 @@ +using System.Threading.Tasks; using Temporalio.Api.WorkflowService.V1; using Temporalio.Converters; @@ -8,18 +9,49 @@ namespace Temporalio.Client /// public class WorkflowExecutionDescription : WorkflowExecution { + private WorkflowExecutionDescription( + DescribeWorkflowExecutionResponse rawDescription, + string? staticSummary, + string? staticDetails, + DataConverter dataConverter) + : base(rawDescription.WorkflowExecutionInfo, dataConverter) + { + RawDescription = rawDescription; + StaticSummary = staticSummary; + StaticDetails = staticDetails; + } + /// - /// Initializes a new instance of the class. + /// Gets the single-line fixed summary for this workflow execution that may appear in + /// UI/CLI. This can be in single-line Temporal markdown format. /// - /// Raw description response. - /// Data converter for memos. - internal WorkflowExecutionDescription( - DescribeWorkflowExecutionResponse rawDescription, DataConverter dataConverter) - : base(rawDescription.WorkflowExecutionInfo, dataConverter) => RawDescription = rawDescription; + /// WARNING: This setting is experimental. + public string? StaticSummary { get; private init; } + + /// + /// Gets the general fixed details for this workflow execution that may appear in UI/CLI. + /// This can be in Temporal markdown format and can span multiple lines. + /// + /// WARNING: This setting is experimental. + public string? StaticDetails { get; private init; } /// /// Gets the raw proto info. /// internal DescribeWorkflowExecutionResponse RawDescription { get; private init; } + + /// + /// Convert from proto. + /// + /// Raw description. + /// Data converter. + /// Converted value. + internal static async Task FromProtoAsync( + DescribeWorkflowExecutionResponse rawDescription, DataConverter dataConverter) + { + var (staticSummary, staticDetails) = await dataConverter.FromUserMetadataAsync( + rawDescription.ExecutionConfig?.UserMetadata).ConfigureAwait(false); + return new(rawDescription, staticSummary, staticDetails, dataConverter); + } } } \ No newline at end of file diff --git a/src/Temporalio/Client/WorkflowOptions.cs b/src/Temporalio/Client/WorkflowOptions.cs index c585df75..826e9c43 100644 --- a/src/Temporalio/Client/WorkflowOptions.cs +++ b/src/Temporalio/Client/WorkflowOptions.cs @@ -41,6 +41,22 @@ public WorkflowOptions(string id, string taskQueue) /// public string? TaskQueue { get; set; } + /// + /// Gets or sets a single-line fixed summary for this workflow execution that may appear in + /// UI/CLI. This can be in single-line Temporal markdown format. + /// + /// WARNING: This setting is experimental. + public string? StaticSummary { get; set; } + + /// + /// Gets or sets general fixed details for this workflow execution that may appear in + /// UI/CLI. This can be in Temporal markdown format and can span multiple lines. This is a + /// fixed value on the workflow that cannot be updated. For details that can be updated, use + /// within the workflow. + /// + /// WARNING: This setting is experimental. + public string? StaticDetails { get; set; } + /// /// Gets or sets the total workflow execution timeout including retries and continue as new. /// diff --git a/src/Temporalio/Converters/ConverterExtensions.cs b/src/Temporalio/Converters/ConverterExtensions.cs index 34e38544..16dec6aa 100644 --- a/src/Temporalio/Converters/ConverterExtensions.cs +++ b/src/Temporalio/Converters/ConverterExtensions.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using Temporalio.Api.Common.V1; using Temporalio.Api.Failure.V1; +using Temporalio.Api.Sdk.V1; namespace Temporalio.Converters { @@ -195,5 +196,44 @@ public static T ToValue(this IPayloadConverter converter, IRawValue rawValue) /// The converted value. public static RawValue ToRawValue(this IPayloadConverter converter, object? value) => new(converter.ToPayload(value)); + + /// + /// Create user metadata using this converter. + /// + /// Converter. + /// Summary. + /// Details. + /// Created metadata if any. + internal static async Task ToUserMetadataAsync( + this DataConverter converter, string? summary, string? details) + { + if (summary == null && details == null) + { + return null; + } + var metadata = new UserMetadata(); + if (summary != null) + { + metadata.Summary = await converter.ToPayloadAsync(summary).ConfigureAwait(false); + } + if (details != null) + { + metadata.Details = await converter.ToPayloadAsync(details).ConfigureAwait(false); + } + return metadata; + } + + /// + /// Extract summary and details from the given user metadata. + /// + /// Converter. + /// Metadata. + /// Extracted summary and details if any. + internal static async Task<(string? Summary, string? Details)> FromUserMetadataAsync( + this DataConverter converter, UserMetadata? metadata) => ( + Summary: metadata?.Summary is { } s ? + await converter.ToValueAsync(s).ConfigureAwait(false) : null, + Details: metadata?.Details is { } d ? + await converter.ToValueAsync(d).ConfigureAwait(false) : null); } } diff --git a/src/Temporalio/Worker/Interceptors/DelayAsyncInput.cs b/src/Temporalio/Worker/Interceptors/DelayAsyncInput.cs index 6238be3b..06a47294 100644 --- a/src/Temporalio/Worker/Interceptors/DelayAsyncInput.cs +++ b/src/Temporalio/Worker/Interceptors/DelayAsyncInput.cs @@ -1,5 +1,6 @@ using System; using System.Threading; +using Temporalio.Workflows; namespace Temporalio.Worker.Interceptors { @@ -8,11 +9,23 @@ namespace Temporalio.Worker.Interceptors /// /// Delay duration. /// Optional cancellation token. + /// Summary for the delay. /// /// WARNING: This constructor may have required properties added. Do not rely on the exact /// constructor, only use "with" clauses. /// public record DelayAsyncInput( TimeSpan Delay, - CancellationToken? CancellationToken); + CancellationToken? CancellationToken, + string? Summary) + { + /// + /// Initializes a new instance of the class. + /// + /// Options. + internal DelayAsyncInput(DelayOptions options) + : this(options.Delay, options.CancellationToken, options.Summary) + { + } + } } \ No newline at end of file diff --git a/src/Temporalio/Worker/WorkflowInstance.cs b/src/Temporalio/Worker/WorkflowInstance.cs index 162441dc..98888673 100644 --- a/src/Temporalio/Worker/WorkflowInstance.cs +++ b/src/Temporalio/Worker/WorkflowInstance.cs @@ -219,6 +219,9 @@ public WorkflowInstance(WorkflowInstanceDetails details) /// public string CurrentBuildId { get; private set; } + /// + public string CurrentDetails { get; set; } = string.Empty; + /// public int CurrentHistoryLength { get; private set; } @@ -343,8 +346,8 @@ public ContinueAsNewException CreateContinueAsNewException( Headers: null)); /// - public Task DelayAsync(TimeSpan delay, CancellationToken? cancellationToken) => - outbound.Value.DelayAsync(new(Delay: delay, CancellationToken: cancellationToken)); + public Task DelayWithOptionsAsync(DelayOptions options) => + outbound.Value.DelayAsync(new(options)); /// public Task ExecuteActivityAsync( @@ -461,14 +464,11 @@ public void UpsertTypedSearchAttributes(IReadOnlyCollection - public Task WaitConditionAsync( - Func conditionCheck, - TimeSpan? timeout, - CancellationToken? cancellationToken) + public Task WaitConditionWithOptionsAsync(WaitConditionOptions options) { var source = new TaskCompletionSource(); - var node = conditions.AddLast(Tuple.Create(conditionCheck, source)); - var token = cancellationToken ?? CancellationToken; + var node = conditions.AddLast(Tuple.Create(options.ConditionCheck, source)); + var token = options.CancellationToken ?? CancellationToken; return QueueNewTaskAsync(async () => { try @@ -476,7 +476,7 @@ public Task WaitConditionAsync( using (token.Register(() => source.TrySetCanceled(token))) { // If there's no timeout, it'll never return false, so just wait - if (timeout == null) + if (options.Timeout == null) { await source.Task.ConfigureAwait(true); return true; @@ -484,8 +484,11 @@ public Task WaitConditionAsync( // Try a timeout that we cancel if never hit using (var delayCancelSource = new CancellationTokenSource()) { - var completedTask = await Task.WhenAny(source.Task, DelayAsync( - timeout.GetValueOrDefault(), delayCancelSource.Token)).ConfigureAwait(true); + var completedTask = await Task.WhenAny(source.Task, DelayWithOptionsAsync( + new( + delay: options.Timeout.GetValueOrDefault(), + summary: options.TimeoutSummary, + cancellationToken: delayCancelSource.Token))).ConfigureAwait(true); // Do not timeout if (completedTask == source.Task) { @@ -1141,6 +1144,12 @@ private void ApplyQueryWorkflow(QueryWorkflow query) queryDefn = WorkflowQueryDefinition.CreateWithoutAttribute( "__stack_trace", getter); } + else if (query.QueryType == "__temporal_workflow_metadata") + { + Func getter = GetWorkflowMetadata; + queryDefn = WorkflowQueryDefinition.CreateWithoutAttribute( + "__temporal_workflow_metadata", getter); + } else { // Find definition or fail @@ -1453,6 +1462,45 @@ private string GetStackTrace() }).Where(s => !string.IsNullOrEmpty(s)).Select(s => $"Task waiting at:\n{s}")); } + private Api.Sdk.V1.WorkflowMetadata GetWorkflowMetadata() + { + var defn = new Api.Sdk.V1.WorkflowDefinition() { Type = Info.WorkflowType }; + if (DynamicQuery is { } dynQuery) + { + defn.QueryDefinitions.Add( + new Api.Sdk.V1.WorkflowInteractionDefinition() { Description = dynQuery.Description }); + } + defn.QueryDefinitions.AddRange(Queries.Values.Select(query => + new Api.Sdk.V1.WorkflowInteractionDefinition() + { + Name = query.Name ?? string.Empty, + Description = query.Description ?? string.Empty, + }).OrderBy(q => q.Name)); + if (DynamicSignal is { } dynSignal) + { + defn.SignalDefinitions.Add( + new Api.Sdk.V1.WorkflowInteractionDefinition() { Description = dynSignal.Description }); + } + defn.SignalDefinitions.AddRange(Signals.Values.Select(query => + new Api.Sdk.V1.WorkflowInteractionDefinition() + { + Name = query.Name ?? string.Empty, + Description = query.Description ?? string.Empty, + }).OrderBy(q => q.Name)); + if (DynamicUpdate is { } dynUpdate) + { + defn.UpdateDefinitions.Add( + new Api.Sdk.V1.WorkflowInteractionDefinition() { Description = dynUpdate.Description }); + } + defn.UpdateDefinitions.AddRange(Updates.Values.Select(query => + new Api.Sdk.V1.WorkflowInteractionDefinition() + { + Name = query.Name ?? string.Empty, + Description = query.Description ?? string.Empty, + }).OrderBy(q => q.Name)); + return new() { Definition = defn, CurrentDetails = CurrentDetails }; + } + private void ApplyLegacyCompletionCommandReordering( WorkflowActivation act, WorkflowActivationCompletion completion, @@ -1732,6 +1780,8 @@ public override Task DelayAsync(DelayAsyncInput input) { Seq = seq, StartToFireTimeout = Google.Protobuf.WellKnownTypes.Duration.FromTimeSpan(delay), + Summary = input.Summary == null ? + null : instance.PayloadConverter.ToPayload(input.Summary), }, }); } @@ -1801,6 +1851,10 @@ public override Task ScheduleActivityAsync( { cmd.VersioningIntent = (Bridge.Api.Common.VersioningIntent)(int)vi; } + if (input.Options.Summary is { } summary) + { + cmd.Summary = instance.PayloadConverter.ToPayload(summary); + } instance.AddCommand(new() { ScheduleActivity = cmd }); return seq; }, @@ -1932,6 +1986,10 @@ public override Task> StartChildWorkflow RetryPolicy = input.Options.RetryPolicy?.ToProto(), CronSchedule = input.Options.CronSchedule ?? string.Empty, CancellationType = (Bridge.Api.ChildWorkflow.ChildWorkflowCancellationType)input.Options.CancellationType, + StaticSummary = input.Options.StaticSummary is { } summ ? + instance.PayloadConverter.ToPayload(summ) : null, + StaticDetails = input.Options.StaticDetails is { } det ? + instance.PayloadConverter.ToPayload(det) : null, }; if (input.Options.ExecutionTimeout is TimeSpan execTimeout) { @@ -2171,9 +2229,10 @@ private Task ExecuteActivityInternalAsync( case ActivityResolution.StatusOneofCase.Backoff: // We have to sleep the backoff amount. Note, this can be cancelled // like any other timer. - await instance.DelayAsync( - res.Backoff.BackoffDuration.ToTimeSpan(), - cancellationToken).ConfigureAwait(true); + await instance.DelayWithOptionsAsync(new( + delay: res.Backoff.BackoffDuration.ToTimeSpan(), + summary: "LocalActivityBackoff", + cancellationToken: cancellationToken)).ConfigureAwait(true); // Re-schedule with backoff info seq = applyScheduleCommand(res.Backoff); source = new TaskCompletionSource(); diff --git a/src/Temporalio/Workflows/ActivityOptions.cs b/src/Temporalio/Workflows/ActivityOptions.cs index 11c440cb..3000e22b 100644 --- a/src/Temporalio/Workflows/ActivityOptions.cs +++ b/src/Temporalio/Workflows/ActivityOptions.cs @@ -72,6 +72,13 @@ public class ActivityOptions : ICloneable /// public string? TaskQueue { get; set; } + /// + /// Gets or sets a single-line fixed summary for this activity that may appear in UI/CLI. + /// This can be in single-line Temporal markdown format. + /// + /// WARNING: This setting is experimental. + public string? Summary { get; set; } + /// /// Gets or sets the unique identifier for the activity. This should never be set unless /// users have a strong understanding of the system. Contact Temporal support to discuss the diff --git a/src/Temporalio/Workflows/ChildWorkflowOptions.cs b/src/Temporalio/Workflows/ChildWorkflowOptions.cs index e3fe5fd3..b4fd6387 100644 --- a/src/Temporalio/Workflows/ChildWorkflowOptions.cs +++ b/src/Temporalio/Workflows/ChildWorkflowOptions.cs @@ -22,6 +22,22 @@ public class ChildWorkflowOptions : ICloneable /// public string? TaskQueue { get; set; } + /// + /// Gets or sets a single-line fixed summary for this workflow execution that may appear in + /// UI/CLI. This can be in single-line Temporal markdown format. + /// + /// WARNING: This setting is experimental. + public string? StaticSummary { get; set; } + + /// + /// Gets or sets general fixed details for this workflow execution that may appear in + /// UI/CLI. This can be in Temporal markdown format and can span multiple lines. This is a + /// fixed value on the workflow that cannot be updated. For details that can be updated, use + /// within the workflow. + /// + /// WARNING: This setting is experimental. + public string? StaticDetails { get; set; } + /// /// Gets or sets the retry policy. Default is no retries. /// diff --git a/src/Temporalio/Workflows/DelayOptions.cs b/src/Temporalio/Workflows/DelayOptions.cs new file mode 100644 index 00000000..673d23d0 --- /dev/null +++ b/src/Temporalio/Workflows/DelayOptions.cs @@ -0,0 +1,80 @@ +using System; +using System.Threading; + +namespace Temporalio.Workflows +{ + /// + /// Options for timers (i.e. DelayAsync). + /// + public class DelayOptions : ICloneable + { + /// + /// Initializes a new instance of the class. + /// + public DelayOptions() + : this(TimeSpan.Zero) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// See . + /// See . + /// See . + public DelayOptions(int millisecondsDelay, string? summary = null, CancellationToken? cancellationToken = null) + : this(TimeSpan.FromMilliseconds(millisecondsDelay), summary, cancellationToken) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// See . + /// See . + /// See . + public DelayOptions(TimeSpan delay, string? summary = null, CancellationToken? cancellationToken = null) + { + Delay = delay; + Summary = summary; + CancellationToken = cancellationToken; + } + + /// + /// Gets or sets the amount of time to sleep. + /// + /// + /// + /// The delay value can be or + /// but otherwise cannot be + /// negative. A server-side timer is not created for infinite delays, so it is + /// non-deterministic to change a timer to/from infinite from/to an actual value. + /// + /// + /// If the delay is 0, it is assumed to be 1 millisecond and still results in a + /// server-side timer. Since Temporal timers are server-side, timer resolution may not end + /// up as precise as system timers. + /// + /// + public TimeSpan Delay { get; set; } + + /// + /// Gets or sets a simple string identifying this timer that may be visible in UI/CLI. While + /// it can be normal text, it is best to treat as a timer ID. + /// + /// WARNING: This setting is experimental. + public string? Summary { get; set; } + + /// + /// Gets or sets the cancellation token for the timer. If unset, this defaults to + /// . + /// + public CancellationToken? CancellationToken { get; set; } + + /// + /// Create a shallow copy of these options. + /// + /// A shallow copy of these options. + public virtual object Clone() => MemberwiseClone(); + } +} diff --git a/src/Temporalio/Workflows/IWorkflowContext.cs b/src/Temporalio/Workflows/IWorkflowContext.cs index 9e5d65c0..e29920d0 100644 --- a/src/Temporalio/Workflows/IWorkflowContext.cs +++ b/src/Temporalio/Workflows/IWorkflowContext.cs @@ -33,6 +33,11 @@ internal interface IWorkflowContext /// string CurrentBuildId { get; } + /// + /// Gets or sets value for . + /// + string CurrentDetails { get; set; } + /// /// Gets value for . /// @@ -135,13 +140,12 @@ ContinueAsNewException CreateContinueAsNewException( string workflow, IReadOnlyCollection args, ContinueAsNewOptions? options); /// - /// Backing call for and + /// Backing call for and /// overloads. /// - /// Delay duration. - /// Optional cancellation token. + /// Options. /// Task for completion. - Task DelayAsync(TimeSpan delay, CancellationToken? cancellationToken); + Task DelayWithOptionsAsync(DelayOptions options); /// /// Backing call for @@ -213,16 +217,11 @@ Task> StartChildWorkflowAsync updates); /// - /// Backing call for + /// Backing call for /// and overloads. /// - /// Function to call. - /// Optional timeout. - /// Optional cancellation token. + /// Options. /// Task for completion. - Task WaitConditionAsync( - Func conditionCheck, - TimeSpan? timeout, - CancellationToken? cancellationToken); + Task WaitConditionWithOptionsAsync(WaitConditionOptions options); } } \ No newline at end of file diff --git a/src/Temporalio/Workflows/WaitConditionOptions.cs b/src/Temporalio/Workflows/WaitConditionOptions.cs new file mode 100644 index 00000000..a431522f --- /dev/null +++ b/src/Temporalio/Workflows/WaitConditionOptions.cs @@ -0,0 +1,72 @@ +using System; +using System.Threading; + +namespace Temporalio.Workflows +{ + /// + /// Options for wait conditions. + /// + public class WaitConditionOptions : ICloneable + { + /// + /// Initializes a new instance of the class. + /// + public WaitConditionOptions() + : this(() => false) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// See . + /// See . + /// See . + /// See . + public WaitConditionOptions( + Func conditionCheck, + TimeSpan? timeout = null, + string? timeoutSummary = null, + CancellationToken? cancellationToken = null) + { + ConditionCheck = conditionCheck; + Timeout = timeout; + TimeoutSummary = timeoutSummary; + CancellationToken = cancellationToken; + } + + /// + /// Gets or sets the condition function. + /// + /// + /// This function is invoked on each iteration of the event loop. Therefore, it should be + /// fast and side-effect free. + /// + public Func ConditionCheck { get; set; } + + /// + /// Gets or sets an optional timeout. + /// + public TimeSpan? Timeout { get; set; } + + /// + /// Gets or sets a simple string identifying the timer (created if is + /// present) that may be visible in UI/CLI. While it can be normal text, it is best to treat + /// as a timer ID. + /// + /// WARNING: This setting is experimental. + public string? TimeoutSummary { get; set; } + + /// + /// Gets or sets the cancellation token for the timer. If unset, this defaults to + /// . + /// + public CancellationToken? CancellationToken { get; set; } + + /// + /// Create a shallow copy of these options. + /// + /// A shallow copy of these options. + public virtual object Clone() => MemberwiseClone(); + } +} diff --git a/src/Temporalio/Workflows/Workflow.cs b/src/Temporalio/Workflows/Workflow.cs index d21db644..0aa62536 100644 --- a/src/Temporalio/Workflows/Workflow.cs +++ b/src/Temporalio/Workflows/Workflow.cs @@ -59,6 +59,18 @@ public static class Workflow /// public static string CurrentBuildId => Context.CurrentBuildId; + /// + /// Gets or sets the current details for this workflow that may appear in UI/CLI. Unlike + /// static details set at start, this value can be updated throughout the life of the + /// workflow. This can be in Temporal markdown format and can span multiple lines. + /// + /// WARNING: This setting is experimental. + public static string CurrentDetails + { + get => Context.CurrentDetails; + set => Context.CurrentDetails = value; + } + /// /// Gets the current number of events in history. /// @@ -295,7 +307,7 @@ public static ContinueAsNewException CreateContinueAsNewException( /// for details. /// public static Task DelayAsync(int millisecondsDelay, CancellationToken? cancellationToken = null) => - DelayAsync(TimeSpan.FromMilliseconds(millisecondsDelay), cancellationToken); + Context.DelayWithOptionsAsync(new(millisecondsDelay, cancellationToken: cancellationToken)); /// /// Sleep in a workflow for the given time. @@ -318,7 +330,17 @@ public static Task DelayAsync(int millisecondsDelay, CancellationToken? cancella /// /// public static Task DelayAsync(TimeSpan delay, CancellationToken? cancellationToken = null) => - Context.DelayAsync(delay, cancellationToken); + Context.DelayWithOptionsAsync(new(delay, cancellationToken: cancellationToken)); + + /// + /// Sleep in a workflow for the given options. + /// + /// Options. + /// Task for completion. See documentation of + /// for details. + /// + public static Task DelayWithOptionsAsync(DelayOptions options) => + Context.DelayWithOptionsAsync(options); /// /// Mark a patch as deprecated. @@ -1154,7 +1176,7 @@ public static void UpsertTypedSearchAttributes(params SearchAttributeUpdate[] up /// . public static Task WaitConditionAsync( Func conditionCheck, CancellationToken? cancellationToken = null) => - Context.WaitConditionAsync(conditionCheck, null, cancellationToken); + Context.WaitConditionWithOptionsAsync(new(conditionCheck, cancellationToken: cancellationToken)); /// /// Wait for the given function to return true or a timeout. See documentation of @@ -1178,10 +1200,11 @@ public static Task WaitConditionAsync( Func conditionCheck, int timeoutMilliseconds, CancellationToken? cancellationToken = null) => - Context.WaitConditionAsync( + Context.WaitConditionWithOptionsAsync(new( conditionCheck, - TimeSpan.FromMilliseconds(timeoutMilliseconds), - cancellationToken); + timeout: TimeSpan.FromMilliseconds(timeoutMilliseconds), + timeoutSummary: "WaitConditionAsync", + cancellationToken: cancellationToken)); /// /// Wait for the given function to return true or a timeout. @@ -1198,7 +1221,20 @@ public static Task WaitConditionAsync( /// public static Task WaitConditionAsync( Func conditionCheck, TimeSpan timeout, CancellationToken? cancellationToken = null) => - Context.WaitConditionAsync(conditionCheck, timeout, cancellationToken); + Context.WaitConditionWithOptionsAsync(new( + conditionCheck, + timeout: timeout, + timeoutSummary: "WaitConditionAsync", + cancellationToken: cancellationToken)); + + /// + /// Wait for the given function to return true or a timeout. + /// + /// Options for the wait condition. + /// Task with true when condition becomes true or false if a timeout + /// occurs. + public static Task WaitConditionWithOptionsAsync(WaitConditionOptions options) => + Context.WaitConditionWithOptionsAsync(options); /// /// Workflow-safe form of . diff --git a/src/Temporalio/Workflows/WorkflowQueryAttribute.cs b/src/Temporalio/Workflows/WorkflowQueryAttribute.cs index 85c93795..bf7e4d66 100644 --- a/src/Temporalio/Workflows/WorkflowQueryAttribute.cs +++ b/src/Temporalio/Workflows/WorkflowQueryAttribute.cs @@ -37,6 +37,13 @@ public WorkflowQueryAttribute(string name) /// public string? Name { get; } + /// + /// Gets or sets a short description for this query that may appear in UI/CLI when workflow + /// is asked for which queries it supports. + /// + /// WARNING: This setting is experimental. + public string? Description { get; set; } + /// /// Gets or sets a value indicating whether the query is dynamic. If a query is dynamic, it /// cannot be given a name in this attribute and the method must accept a string name and diff --git a/src/Temporalio/Workflows/WorkflowQueryDefinition.cs b/src/Temporalio/Workflows/WorkflowQueryDefinition.cs index 3190f41e..6ac6ca5a 100644 --- a/src/Temporalio/Workflows/WorkflowQueryDefinition.cs +++ b/src/Temporalio/Workflows/WorkflowQueryDefinition.cs @@ -13,9 +13,10 @@ public class WorkflowQueryDefinition private static readonly ConcurrentDictionary MethodDefinitions = new(); private static readonly ConcurrentDictionary PropertyDefinitions = new(); - private WorkflowQueryDefinition(string? name, MethodInfo? method, Delegate? del) + private WorkflowQueryDefinition(string? name, string? description, MethodInfo? method, Delegate? del) { Name = name; + Description = description; Method = method; Delegate = del; } @@ -25,6 +26,11 @@ private WorkflowQueryDefinition(string? name, MethodInfo? method, Delegate? del) /// public string? Name { get; private init; } + /// + /// Gets the optional query description. + /// + public string? Description { get; private init; } + /// /// Gets a value indicating whether the query is dynamic. /// @@ -81,7 +87,7 @@ public static WorkflowQueryDefinition FromProperty(PropertyInfo property) => { throw new ArgumentException($"WorkflowQuery property {property} cannot be dynamic"); } - return new(attr.Name ?? property.Name, method, null); + return new(attr.Name ?? property.Name, attr.Description, method, null); }); /// @@ -90,11 +96,14 @@ public static WorkflowQueryDefinition FromProperty(PropertyInfo property) => /// /// Query name. Null for dynamic query. /// Query delegate. + /// Optional description. WARNING: This setting is experimental. + /// /// Query definition. - public static WorkflowQueryDefinition CreateWithoutAttribute(string? name, Delegate del) + public static WorkflowQueryDefinition CreateWithoutAttribute( + string? name, Delegate del, string? description = null) { AssertValid(del.Method, dynamic: name == null); - return new(name, null, del); + return new(name, description, null, del); } /// @@ -137,7 +146,7 @@ private static WorkflowQueryDefinition CreateFromMethod(MethodInfo method) { name = method.Name; } - return new(name, method, null); + return new(name, attr.Description, method, null); } private static void AssertValid(MethodInfo method, bool dynamic) diff --git a/src/Temporalio/Workflows/WorkflowSignalAttribute.cs b/src/Temporalio/Workflows/WorkflowSignalAttribute.cs index 0125997f..f9494266 100644 --- a/src/Temporalio/Workflows/WorkflowSignalAttribute.cs +++ b/src/Temporalio/Workflows/WorkflowSignalAttribute.cs @@ -37,6 +37,13 @@ public WorkflowSignalAttribute(string name) /// public string? Name { get; } + /// + /// Gets or sets a short description for this signal that may appear in UI/CLI when workflow + /// is asked for which signals it supports. + /// + /// WARNING: This setting is experimental. + public string? Description { get; set; } + /// /// Gets or sets a value indicating whether the signal is dynamic. If a signal is dynamic, /// it cannot be given a name in this attribute and the method must accept a string name and diff --git a/src/Temporalio/Workflows/WorkflowSignalDefinition.cs b/src/Temporalio/Workflows/WorkflowSignalDefinition.cs index f77af280..dde3c1cb 100644 --- a/src/Temporalio/Workflows/WorkflowSignalDefinition.cs +++ b/src/Temporalio/Workflows/WorkflowSignalDefinition.cs @@ -14,11 +14,13 @@ public class WorkflowSignalDefinition private WorkflowSignalDefinition( string? name, + string? description, MethodInfo? method, Delegate? del, HandlerUnfinishedPolicy unfinishedPolicy) { Name = name; + Description = description; Method = method; Delegate = del; UnfinishedPolicy = unfinishedPolicy; @@ -29,6 +31,12 @@ private WorkflowSignalDefinition( /// public string? Name { get; private init; } + /// + /// Gets the optional signal description. + /// + /// WARNING: This setting is experimental. + public string? Description { get; private init; } + /// /// Gets a value indicating whether the signal is dynamic. /// @@ -75,14 +83,17 @@ public static WorkflowSignalDefinition FromMethod(MethodInfo method) /// Signal delegate. /// Actions taken if a workflow exits with a running instance /// of this handler. + /// Optional description. WARNING: This setting is experimental. + /// /// Signal definition. public static WorkflowSignalDefinition CreateWithoutAttribute( string? name, Delegate del, - HandlerUnfinishedPolicy unfinishedPolicy = HandlerUnfinishedPolicy.WarnAndAbandon) + HandlerUnfinishedPolicy unfinishedPolicy = HandlerUnfinishedPolicy.WarnAndAbandon, + string? description = null) { AssertValid(del.Method, dynamic: name == null); - return new(name, null, del, unfinishedPolicy); + return new(name, description, null, del, unfinishedPolicy); } /// @@ -117,7 +128,7 @@ private static WorkflowSignalDefinition CreateFromMethod(MethodInfo method) name = name.Substring(0, name.Length - 5); } } - return new(name, method, null, attr.UnfinishedPolicy); + return new(name, attr.Description, method, null, attr.UnfinishedPolicy); } private static void AssertValid(MethodInfo method, bool dynamic) diff --git a/src/Temporalio/Workflows/WorkflowUpdateAttribute.cs b/src/Temporalio/Workflows/WorkflowUpdateAttribute.cs index b24ff648..f0a8b5af 100644 --- a/src/Temporalio/Workflows/WorkflowUpdateAttribute.cs +++ b/src/Temporalio/Workflows/WorkflowUpdateAttribute.cs @@ -41,6 +41,13 @@ public WorkflowUpdateAttribute() /// public string? Name { get; } + /// + /// Gets or sets a short description for this update that may appear in UI/CLI when workflow + /// is asked for which updates it supports. + /// + /// WARNING: This setting is experimental. + public string? Description { get; set; } + /// /// Gets or sets a value indicating whether the update is dynamic. If a update is dynamic, /// it cannot be given a name in this attribute and the method must accept a string name and diff --git a/src/Temporalio/Workflows/WorkflowUpdateDefinition.cs b/src/Temporalio/Workflows/WorkflowUpdateDefinition.cs index d18e8663..3c26e5b1 100644 --- a/src/Temporalio/Workflows/WorkflowUpdateDefinition.cs +++ b/src/Temporalio/Workflows/WorkflowUpdateDefinition.cs @@ -15,6 +15,7 @@ public class WorkflowUpdateDefinition private WorkflowUpdateDefinition( string? name, + string? description, MethodInfo? method, MethodInfo? validatorMethod, Delegate? del, @@ -22,6 +23,7 @@ private WorkflowUpdateDefinition( HandlerUnfinishedPolicy unfinishedPolicy) { Name = name; + Description = description; Method = method; ValidatorMethod = validatorMethod; Delegate = del; @@ -34,6 +36,11 @@ private WorkflowUpdateDefinition( /// public string? Name { get; private init; } + /// + /// Gets the optional update description. + /// + public string? Description { get; private init; } + /// /// Gets a value indicating whether the update is dynamic. /// @@ -101,15 +108,18 @@ public static WorkflowUpdateDefinition FromMethod( /// Optional validator delegate. /// Actions taken if a workflow exits with a running instance /// of this handler. + /// Optional description. WARNING: This setting is experimental. + /// /// Update definition. public static WorkflowUpdateDefinition CreateWithoutAttribute( string? name, Delegate del, Delegate? validatorDel = null, - HandlerUnfinishedPolicy unfinishedPolicy = HandlerUnfinishedPolicy.WarnAndAbandon) + HandlerUnfinishedPolicy unfinishedPolicy = HandlerUnfinishedPolicy.WarnAndAbandon, + string? description = null) { AssertValid(del.Method, dynamic: name == null, validatorDel?.Method); - return new(name, null, null, del, validatorDel, unfinishedPolicy); + return new(name, description, null, null, del, validatorDel, unfinishedPolicy); } /// @@ -145,7 +155,7 @@ private static WorkflowUpdateDefinition CreateFromMethod( name = name.Substring(0, name.Length - 5); } } - return new(name, method, validatorMethod, null, null, attr.UnfinishedPolicy); + return new(name, attr.Description, method, validatorMethod, null, null, attr.UnfinishedPolicy); } private static void AssertValid( diff --git a/tests/Temporalio.Tests/Worker/WorkflowWorkerTests.cs b/tests/Temporalio.Tests/Worker/WorkflowWorkerTests.cs index bec04e4a..3c4a1ed1 100644 --- a/tests/Temporalio.Tests/Worker/WorkflowWorkerTests.cs +++ b/tests/Temporalio.Tests/Worker/WorkflowWorkerTests.cs @@ -6011,6 +6011,161 @@ await ExecuteWorkerAsync( new TemporalWorkerOptions().AddAllActivities(activities)); } + [Workflow] + public class UserMetadataWorkflow + { + [Activity] + public static string DoNothing() => "done"; + + [WorkflowRun] + public async Task RunAsync(bool returnImmediately) + { + if (returnImmediately) + { + return; + } + + // Timer, wait condition, activity, and child with metadata + + // Timer + await Workflow.DelayWithOptionsAsync(new(1) { Summary = "my-timer" }); + + // Wait condition + await Workflow.WaitConditionWithOptionsAsync(new( + () => false, TimeSpan.FromMilliseconds(2), "my-wait-condition-timer")); + + // Activity + await Workflow.ExecuteActivityAsync(() => DoNothing(), new() + { + StartToCloseTimeout = TimeSpan.FromSeconds(30), + Summary = "my-activity", + }); + + // Child + await Workflow.ExecuteChildWorkflowAsync( + (UserMetadataWorkflow wf) => wf.RunAsync(true), + new() { StaticSummary = "my-child", StaticDetails = "my-child-details" }); + } + } + + [Fact] + public async Task ExecuteWorkflowAsync_UserMetadata_PropagatedProperly() + { + await ExecuteWorkerAsync( + async worker => + { + var handle = await Client.StartWorkflowAsync( + (UserMetadataWorkflow wf) => wf.RunAsync(false), + new(id: $"workflow-{Guid.NewGuid()}", taskQueue: worker.Options.TaskQueue!) + { + StaticSummary = "my-workflow", + StaticDetails = "my-workflow-details", + }); + await handle.GetResultAsync(); + + // Check description has summary/details + var desc = await handle.DescribeAsync(); + Assert.Equal("my-workflow", desc.StaticSummary); + Assert.Equal("my-workflow-details", desc.StaticDetails); + + // Check history for timer (x2), activity, and child metadata + var history = await handle.FetchHistoryAsync(); + Assert.Contains(history.Events, evt => evt.TimerStartedEventAttributes != null && + evt.UserMetadata?.Summary?.Data?.ToStringUtf8() == "\"my-timer\""); + Assert.Contains(history.Events, evt => evt.TimerStartedEventAttributes != null && + evt.UserMetadata?.Summary?.Data?.ToStringUtf8() == "\"my-wait-condition-timer\""); + Assert.Contains(history.Events, evt => evt.ActivityTaskScheduledEventAttributes != null && + evt.UserMetadata?.Summary?.Data?.ToStringUtf8() == "\"my-activity\""); + Assert.Contains(history.Events, evt => evt.StartChildWorkflowExecutionInitiatedEventAttributes != null && + evt.UserMetadata?.Summary?.Data?.ToStringUtf8() == "\"my-child\"" && + evt.UserMetadata?.Details?.Data?.ToStringUtf8() == "\"my-child-details\""); + + // Go ahead and describe the child and confirm its metadata + var child = history.Events.Single(evt => evt.StartChildWorkflowExecutionInitiatedEventAttributes != null); + desc = await Client.GetWorkflowHandle( + child.StartChildWorkflowExecutionInitiatedEventAttributes.WorkflowId).DescribeAsync(); + Assert.Equal("my-child", desc.StaticSummary); + Assert.Equal("my-child-details", desc.StaticDetails); + }, + new TemporalWorkerOptions().AddAllActivities(null)); + } + + [Workflow] + public class WorkflowMetadataWorkflow + { + [WorkflowRun] + public async Task RunAsync() + { + Workflow.CurrentDetails = "initial current details"; + Workflow.Signals["some manual signal"] = WorkflowSignalDefinition.CreateWithoutAttribute( + "some manual signal", () => Task.CompletedTask, description: "some manual signal description"); + await Workflow.WaitConditionAsync(() => Continue); + Workflow.CurrentDetails = "final current details"; + } + + [WorkflowSignal] + public Task SomeSignalAsync() => Task.CompletedTask; + + [WorkflowSignal("some signal", Description = "some signal description")] + public Task SomeOtherSignalAsync() => Task.CompletedTask; + + [WorkflowQuery(Description = "continue description")] + public bool Continue { get; set; } + + [WorkflowQuery(Description = "some query description", Dynamic = true)] + public string SomeDynamicQueryAsync(string name, IRawValue[] args) => "some value"; + + [WorkflowUpdate(Description = "some update description")] + public async Task SomeUpdateAsync() => Continue = true; + + [WorkflowUpdate("some update")] + public Task SomeOtherUpdateAsync() => Task.CompletedTask; + } + + [Fact] + public async Task ExecuteWorkflowAsync_WorkflowMetadata_HasProperValues() + { + await ExecuteWorkerAsync(async worker => + { + var handle = await Client.StartWorkflowAsync( + (WorkflowMetadataWorkflow wf) => wf.RunAsync(), + new(id: $"workflow-{Guid.NewGuid()}", taskQueue: worker.Options.TaskQueue!)); + + // Wait for start + await AssertMore.EqualEventuallyAsync(false, () => handle.QueryAsync(wf => wf.Continue)); + + // Check workflow metadata + var meta = await handle.QueryAsync( + "__temporal_workflow_metadata", Array.Empty()); + Assert.Equal("initial current details", meta.CurrentDetails); + Assert.Equal("WorkflowMetadataWorkflow", meta.Definition.Type); + Assert.Equal(3, meta.Definition.SignalDefinitions.Count); + Assert.Equal( + string.Empty, + Assert.Single(meta.Definition.SignalDefinitions, s => s.Name == "SomeSignal").Description); + Assert.Equal( + "some signal description", + Assert.Single(meta.Definition.SignalDefinitions, s => s.Name == "some signal").Description); + Assert.Equal( + "some manual signal description", + Assert.Single(meta.Definition.SignalDefinitions, s => s.Name == "some manual signal").Description); + Assert.Equal(2, meta.Definition.QueryDefinitions.Count); + Assert.Equal( + "continue description", + Assert.Single(meta.Definition.QueryDefinitions, s => s.Name == "Continue").Description); + Assert.Equal( + "some query description", + Assert.Single(meta.Definition.QueryDefinitions, s => s.Name.Length == 0).Description); + Assert.Equal(2, meta.Definition.UpdateDefinitions.Count); + Assert.Equal( + "some update description", + Assert.Single(meta.Definition.UpdateDefinitions, s => s.Name == "SomeUpdate").Description); + Assert.Equal( + string.Empty, + Assert.Single(meta.Definition.UpdateDefinitions, s => s.Name == "some update").Description); + }); + } + internal static Task AssertTaskFailureContainsEventuallyAsync( WorkflowHandle handle, string messageContains) {