| title | Event Groups Design |
|---|---|
| date | 2026-09-14 |
Event Groups introduces a way to group logically related Workflow Events, providing customers with improved visibility, analysis, and debugging capabilities.
By allowing Workflow Events to be grouped according to user-defined or system-inferred criteria, users can navigate and visualize timelines with greater clarity.
- Introduce a Workflow-side API to explicitly create Event Group Markers.
- Allow explicitly attaching Event Group Markers to commands produced by a workflow, similar to how one can already attach “summary” and “details” metadata to commands.
- Allow attaching Event Group Markers to a workflow code’s execution scope, automatically propagating through coroutines to all commands created from within that scope.
- Automatic creation of an implicit Event Group wrapping each inbound signal and update. Combined with execution scope based propagation of Event Group Markers, this allows correlation of an external trigger with its consequent workflow commands.
-
Provide enriched workflow history visualization that leverages Event Group Markers.
Initial visual enrichments of the workflow history are left to be decided; they could notably include:
- Colored labels next to events corresponding to each Event Group;
- Reordering of events display so that events that belong to a same Event Group are visually displayed together, rather than interleaved with events from other Event Groups;
- Filtering the workflow history events view based on Event Group (e.g. clicking on the colored label next to the event could gray out events that don’t belong to that group).
- Allow cancellation of commands based on Event Groups; i.e. in UI, a user clicks on an Event Group and selects “Cancel all commands in this group”.
- Automatic tracking of runtime relationship between events of a same group (i.e. in group X, completion of event Y resulted in the following event Z).
- Automatic creation of Event Group Markers on Temporal failures.
- Risk: Feasibility and runtime cost of this feature may vary depending on the language and SDK.
The Event Groups primitive is meant to provide detailed tracking of the intra-workflow execution graph. It would however be inefficient as a visibility mechanism for cross-workflow interactions, and would be redundant with other visibility primitives, notably the recently introduced Link mechanism on history events which already addresses a fundamentally equivalent use case. See Why Event Groups are scoped to a single workflow execution for the complete rationale.
Consequently, the following are considered non-goals for this proposal:
- Propagate Event Groups across workflow boundaries — i.e. Markers attached to a
signalExternalWorkflow,startChildWorkflow,continueAsNew, etc. command are recorded on the source workflow's history, but do not flow to the target/successor workflow. - Provide Event Group Markers when invoking workflows through the Workflow Service Client API.
- Extending Event Groups to non-Workflow execution primitives (Nexus Operations, Standalone Activities, Schedules, Batches, etc).
Future work could explore the potential of a streamlined API that combines the various visibility primitives offered by the Temporal platform.
- Event Groups must introduce no new Workflow Command or History Event types; they have to be carried as new fields on existing Commands and History Events.
- There might be multiple Event Groups arbitrarily attached to a single command/history event.
- A user may create multiple, distinct Event Groups with a same label in a single workflow execution.
- Event Groups may optionally carry user-provided labels and/or other forms of metadata; such metadata must be treated as sensitive content and must therefore be processed through the user provided payload codecs (similar to the existing
summaryanddetailsmetadata fields). - It must be possible for the UI to eagerly determine Event Group attributions while fetching a workflow's history, without requiring or waiting for the decoding of Marker payloads. For example, the UI could initially present Event Groups as simple colored balls (without text label), and then do payload decoding lazily.
- Modification of existing Workflow code that results in producing different Event Group Markers on replay (i.e. addition/change/removal of Event Group Markers):
- Must not cause a Non-Determinism Error;
- Must not result in incorrect attributions (i.e. an event being attributed to a group to which it does not belong);
- Must not otherwise break other unrelated Event Group attributions.
- Occasional missing attributions and split brains are undesirable but tolerable.
- Event Groups representation in history must be compact and efficient; more specifically, attaching an Event Group with non-trivial metadata payloads to a large number of events should not unnecessarily duplicate the Event Group's metadata payloads for each event, nor significantly increase the size of the history event.
The design rests on two concepts:
- A primitive — the Event Group Marker — that the SDK and protocol manipulate;
- A derived construct — the Event Group — that users and the UI reason about.
An Event Group Marker (or simply "Marker") is a discrete, opaque token that the SDK attaches to commands and history events. Internally, a Marker is identified by an opaque id, and may optionally carry a user-visible label payload.
Markers are not commands of their own: they are piggy-backed as a new field on existing commands and history events, so introducing them does not require any new Workflow Command or History Event type. A single Marker may be attached to multiple commands/events, and a single command/event may carry multiple Markers (M-to-N).
Markers can be created explicitly by the user through an SDK API, or implicitly by the SDK — for example, on inbound signal and update reception. In both cases, the SDK simply adds the Marker to the commands it produces, and the server transcribes it onto the corresponding history events.
An Event Group is the user-facing, conceptual construct built on top of Markers: it is the set of all events that carry the same Marker ID. Groups are not mutually exclusive: a single event may belong to several Event Groups at once. From the user's perspective, a group is typically perceived as an execution scope with a "start" and an "end". From the system's perspective, however, there is no dedicated start or end event — a group exists only implicitly, as the collection of events sharing a common Marker.
In practice, "Event Group" is primarily a UI and mental-model concept; the runtime and the protocol operate exclusively on Markers.
export async function myWorkflow() {
await sleep('5m'); // Sees no Event Group
// Implicit Event Group wrapping inbound signal handlers
setHandler(mySignal, async () => {
await sleep('5m'); // Sees "mySignal@event-id=123"
})
}export async function myWorkflow() {
// User explicitly creates an Event Group...
const myGroup = createEventGroup('my-group');
await myActivity.executeWithOptions( // Sees "my-group"
{
// ...
eventGroups: [myGroup] // ... then attaches it to specific commands
},
[...] // Activity args
)
}export async function myWorkflow() {
// User explicitly creates an Event Group...
const myGroup = createEventGroup('my-group');
// ... then execute code within that Event Group's scope.
await myGroup.withScope(async () => {
// All commands executed within this scope are
// automatically associated with the Event Group.
await myActivity1(...); // Sees "my-group"
await myActivity2(...); // Same
});
}message EventGroupMarker {
// What this Marker represents. The variant determines whether the Marker was
// created explicitly by user code (label) or implicitly by the SDK on inbound
// signals/events (inbound_event) or update handlers (inbound_update).
oneof variant {
Label label = 1;
InboundEvent inbound_event = 2;
InboundUpdate inbound_update = 3;
}
// A user-defined grouping key with optional metadata.
message Label {
// A stable, plaintext grouping key for this Label.
//
// This ID will be attached to every history event attributed to this
// Event Group. Use of short IDs is highly recommended (~40 chars at most).
// Must be non-empty.
string id = 1;
// Optional payload-encoded text to be displayed by the UI.
//
// When set, this should be a "json/plain"-serialized, codec-encoded payload
// that is a single JSON string. User interface formatting may apply.
//
// The payload data section is limited to 400 bytes by default.
//
// Payload only needs to be set on the first use of a given Label ID;
// further references to an existing Label ID reuse existing attributes of
// the referenced Label – i.e. further label payloads are ignored.
//
// If no label payload is provided, or if payload can't be decoded, the UI
// should display the Label ID instead.
//
// Note that it is valid to have distinct Markers (i.e. distinct Marker IDs)
// in a given workflow execution that carry the same label, provided that
// they have distinct IDs.
temporal.api.common.v1.Payload label = 2;
}
// The event ID of an event in the present workflow that triggered implicit
// creation of this group Marker.
//
// The target event's type must be `WORKFLOW_EXECUTION_SIGNALED`.
message InboundEvent {
int64 inbound_event_id = 1;
}
// The identifier of an inbound Update (request.meta.update_id)
// whose handler triggered implicit creation of this group Marker.
//
// Used in place of `inbound_event_id` for Updates because the event ID of the
// UpdateAccepted history event is not known until the Workflow Task is
// completed and recorded by the server, which may be too late.
message InboundUpdate {
string inbound_update_id = 1;
}
} message Command {
// ... (existing fields) ...
temporal.api.sdk.v1.UserMetadata user_metadata = 301;
+ repeated temporal.api.sdk.v1.EventGroupMarker event_group_markers = 302;
// ...
} message HistoryEvent {
// ... (existing fields) ...
temporal.api.sdk.v1.UserMetadata user_metadata = 301;
repeated temporal.api.common.v1.Link links = 302;
temporal.api.common.v1.Principal principal = 303;
+ repeated temporal.api.sdk.v1.EventGroupMarker event_group_markers = 304;
// ...
}Here is a high-level overview of the user-facing APIs we'll add or modify in each SDK. See the Per-Language SDK Details section for exact proposition for each language.
- Add a new
EventGroupinterface.- Exported from the "workflow" package.
- Add a
createEventGroup(id: string, options?: { label?: string }): EventGroupfactory function that creates a new Event Group with a given ID.- Exported from the "workflow" package.
idmust be a non-empty string.labelis optional. If omitted, the Marker carries no label payload, and the UI should display the ID instead. If provided, it must be a non-empty string.
- Add an
eventGroups?: EventGroup[]option on the following existing APIs:newTimer,sleep;condition(fn, duration);scheduleActivity,executeActivity;scheduleLocalActivity,executeLocalActivity;startChildWorkflow,executeChildWorkflow;startNexusOperation,scheduleNexusOperation;signalExternalWorkflow;patches(Core-based SDKs only);sideEffect,mutableSideEffect(Go and Java only);continueAsNew;- other APIs to be confirmed.
- Add an SDK-provided scope helper that runs a function within an Event Group's scope.
- All commands produced from within the scope automatically carry the group's Marker.
- Each SDK exposes the shape and vocabulary that is most idiomatic for its language. Refer to the Per-Language SDK Details section.
- Nested scopes are supported and compose: a command produced inside an inner scope carries the union of all enclosing scopes' Markers. The order of Markers is unimportant.
- There is currently no intent to provide a user-facing API surfacing "active" Event Groups.
- If a label is provided, it must be converted to a Payload as a JSON string using the SDK’s default Payload Converters, rather than user-provided Converters, and then processed through the user-provided Payload Codec, if any.
-
Add creation of an implicit Event Group wrapping calls to Signal handlers.
- This implicit Event Group Marker will be tied to the event id of the
WorkflowExecutionSignaledevent. - Divergence: The existing Go SDK's signal handling API does not expose a per-signal context. Signal handlers simply reuse the workflow's context. It will consequently not be possible to create implicit Event Groups on signals in that SDK unless we introduce a new signal handling API (e.g. callback-based, similar to the Update Handler API).
- This implicit Event Group Marker will be tied to the event id of the
-
Add creation of an implicit Event Group wrapping calls to Update handlers.
- This implicit Event Group Marker will be tied to the Update ID (i.e.
Request.meta.update_id). - This Event Group should only wrap the call to the Update's handler itself, not the call to the Update's validator function.
- This implicit Event Group Marker will be tied to the Update ID (i.e.
-
The implicit Event Groups described above do not propagate any outer Event Groups to their inner execution scope – this is particularly significant in the case of dynamically registered Signal and Update handlers.
Save for that exception, explicit Event Groups created in user code compose with these implicit scopes per the nesting rule described previously (i.e. a command produced inside an explicit scope nested in a handler carries both Markers).
For example:
async function myWorkflow(): Promise<void> { const groupA = createEventGroup('GroupA'); await groupA.withScope(async () => { await sleep(); // <-- "GroupA" setHandler(mySignal, async ()=>{ const groupB = createEventGroup('GroupB'); await groupB.withScope(async () => { await sleep(); // <-- "signal@eid=123" and "GroupB" // but not "GroupA" }); }); }); }
Per design requirements, adding, removing or changing Event Group Markers attached to a command:
- must never raise a Non-Determinism Error;
- must never cause an event to be attributed to a group it does not belong to;
- may result in missing attributions or split groups (undesirable but tolerable).
In other words, Event Group Markers must be identified in a way that remains as stable as possible across workflow replay and minor code edits, with the guarantee that distinct groups are never incorrectly considered equal.
That notably rules out the use of sequential internal IDs (like the ones used to identify timers, activities and other commands), as those would be subject to shifting and divergence on replay.
Implicit Event Groups — those tied to signal or update handlers — use the corresponding event ID or update ID as their identifier.
Explicit Event Groups created via createEventGroup(id, { label? }) take a user-provided identifier (the id argument). Users are responsible for ensuring that this identifier is stable across workflow replays and minor code edits.
IDs are stored in plaintext in the Workflow's history, i.e. they are not codec-encoded. Users are responsible for ensuring that these IDs do not contain any sensitive content.
The SDK does not enforce any uniqueness constraint on IDs or Labels. That notably means that:
- Users may create multiple distinct groups that share display text by passing different IDs and the same
label. - Users may create multiple
EventGroupobjects that will regroup to a single conceptual Event Group in the UI by passing the same ID. This may notably be used in cases where a user wants multiple commands to be associated with a same Event Group, but find it inconvenient to reuse theEventGroupobject itself across their codebase.
The sections below describe each SDK’s proposed public API shape and language-specific considerations. Common behavior is defined in SDK Changes Overview.
- Event Group Markers can be explicitly created with
createEventGroup(id)orcreateEventGroup(id, { label: string }) - Scope-based propagation through
eventGroup.withScope(fn)
import * as wf from '@temporalio/workflow';
export async function myWorkflow() {
// Creation of explicit Event Groups
const paymentGroup = **wf.createEventGroup('payment-processing')**;
const customerGroup = **wf.createEventGroup('customer-123456', { label: 'Customer: John Doe' })**;
// Explicit attachment of event groups to workflow commands
await myActivity.executeWithOptions(
{
startToCloseTimeout: '10s',
**eventGroups: [paymentGroup, customerGroup]**,
},
args,
);
// Scope-based propagation. Only singular form.
**await paymentGroup.withScope(**async () => {
await authorizePayment(...);
await capturePayment(...);
}**)**;
}- Event Group Markers can be explicitly created with
workflow.create_event_group(id)orworkflow.create_event_group(id, label="..."). - Scope-based propagation through
with event_group.scope().
@workflow.defn
class MyWorkflow:
@workflow.run
async def run(self) -> None:
# Creation of explicit Event Groups
payment_group = **workflow.create_event_group("payment-processing")**
customer_group = **workflow.create_event_group("customer-123456", label="Customer: John Doe")**
# Explicit attachment of event groups to workflow commands
await workflow.execute_activity(
my_activity,
arg,
**event_groups=[payment_group, customer_group]**,
)
# Scope-based propagation. Allows plural form.
**with payment_group.scope(), customer_group.scope():**
await authorize_payment(...)
await capture_payment(...)- Explicit Event Groups are created with
EventGroup::new(id). An optional display label is added with.with_label(label). - Various Command Options structs get an extra
event_groups: Vec<EventGroup>property, which is exposed in Bon-derived builders asevent_groups(groups: impl IntoIterator<Item = EventGroup>). - Scope-based propagation through context derivation:
ctx.with_event_group(group: impl Borrow<EventGroup>)orctx.with_event_groups(groups: impl IntoIterator<Item = EventGroup>)returns a derived context, and every command issued through that context carries the group's Marker.
#[workflow_methods]
impl MyWorkflow {
#[run]
pub async fn run(ctx: &mut WorkflowContext<Self>) -> WorkflowResult<()> {
// Creation of an explicit Event Group
let payment_group**: EventGroup = EventGroup::new("payment-processing");**
// Creation of an explicit Event Group with a display label
let customer_group**: EventGroup = EventGroup::new("customer-123456")
.with_label("Customer: John Doe");**
// Explicit attachment of Event Groups to workflow commands
ctx.execute_activity(
MyActivities::charge_card,
args,
ActivityOptions::with_start_to_close_timeout(Duration::from_secs(10))
**.event_groups([payment_group.clone(), customer_group.clone()])**
.build(),
)
.await?;
// Scope-based propagation. Also plural: **with_event_groups([...])**
let mut scoped = **ctx.with_event_group(payment_group)**;
authorize_payment(&mut scoped).await?;
capture_payment(&mut scoped).await?;
Ok(())
}
}- Explicit Event Groups are created through the workflow context:
Workflow.CreateEventGroup(id), orWorkflow.CreateEventGroup(id, new() { Label = "..." }). - Various command Options classes get an extra
EventGroupsproperty, of typeIReadOnlyCollection<EventGroup>?. - Scope-based propagation through
using (Workflow.WithEventGroups(group, …)), which returns a disposableEventGroupScope: IDisposable. Scope propagation is backed by anAsyncLocal. EventGroupandEventGroupScopearepublic sealed classwithinternalconstructors to prevent misconstructions.
[Workflow]
public class PaymentWorkflow
{
[WorkflowRun]
public async Task RunAsync()
{
// Creation of an explicit Event Group
EventGroup paymentGroup = **Workflow.CreateEventGroup("payment-processing")**;
// Creation of an explicit Event Group with a display label
EventGroup customerGroup = **Workflow.CreateEventGroup(
"customer-123456", new() { Label = "Customer: John Doe" })**;
// Explicit attachment of Event Groups to workflow commands
await Workflow.ExecuteActivityAsync(
(PaymentActivities act) => act.ChargeCard(chargeRequest),
new()
{
StartToCloseTimeout = TimeSpan.FromSeconds(10),
**EventGroups = new[] { paymentGroup, customerGroup }**,
});
// Scope-based propagation. Allows plural form.
**using (Workflow.WithEventGroups(paymentGroup, customerGroup))**
{
await AuthorizePaymentAsync();
await CapturePaymentAsync();
}
}
}- Explicit Event Groups are created through
Temporalio::Workflow.create_event_group(id), orTemporalio::Workflow.create_event_group(id, label: '…'). - Every command-producing workflow API gains an
event_groups:keyword argument, accepting anArrayof Event Groups.- For the
wait-condition-with-timeoutuse case, theevent_groups:keyword argument is passed to thetimeoutmethod, rather than thewait_conditionmethod, similar to howcancellation:andsummary:are already passed.
- For the
- Scope-based propagation through
Temporalio::Workflow.with_event_groups(group) { … }, which accepts one or more groups. This is backed by Ruby'sFiberstorage.
class PaymentWorkflow < Temporalio::Workflow::Definition
def execute
# Creation of an explicit Event Group
payment_group = **Temporalio::Workflow.create_event_group('payment-processing')**
# Creation of an explicit Event Group with a display label
customer_group = **Temporalio::Workflow.create_event_group(
'customer-123456',
label: 'Customer: John Doe'
)**
# Explicit attachment of Event Groups to workflow commands
Temporalio::Workflow.execute_activity(
ChargeCardActivity,
charge_request,
start_to_close_timeout: 10,
**event_groups: [payment_group, customer_group]**
)
# Scope-based propagation
**Temporalio::Workflow.with_event_groups(payment_group, customer_group) do**
authorize_payment
capture_payment
end
end
end- Explicit Event Groups are created through
workflow.NewEventGroup(id), orworkflow.NewEventGroupWithOptions(id, workflow.EventGroupOptions{Label: "…"}). - Scope-based propagation through context derivation:
workflow.WithEventGroups(ctx, groups...)returns a derived context, and every command issued through that context carries the groups markers. - Divergence: Go's channel-based signal delivery has no per-signal execution scope, so implicit Event Groups on inbound signals cannot be expressed against today's public API. Implicit groups therefore cover Update handlers only; see "Language-specific considerations" below.
func PaymentWorkflow(ctx workflow.Context, req ChargeRequest) error {
// Creation of an explicit Event Group
paymentGroup := **workflow.NewEventGroup("payment-processing")**
// Creation of an explicit Event Group with a display label
customerGroup := **workflow.NewEventGroupWithOptions("customer-123456",
workflow.EventGroupOptions{Label: "Customer: John Doe"})**
// Explicit attachment of Event Groups to workflow commands
actx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
StartToCloseTimeout: 10 * time.Second,
Summary: "charge the customer's card",
**EventGroups: []workflow.EventGroup{paymentGroup, customerGroup},**
})
if err := workflow.ExecuteActivity(actx, ChargeCard, req).Get(actx, nil); err != nil {
return err
}
// Scope-based propagation through context derivation
**gctx := workflow.WithEventGroups(ctx, paymentGroup, customerGroup)**
if err := authorizePayment(gctx); err != nil {
return err
}
return capturePayment(gctx)
}The Go SDK delivers inbound signals by pushing them into a buffered workflow.Channel; user code receives them later, from whichever coroutine happens to hold whichever Context it holds. There is no per-signal execution scope for the SDK to wrap, and no context that a signal's implicit Marker could be installed on.
Three ways to close that gap:
- Leave it open. Ship implicit Event Groups for the Update handlers only, and document that signals carry none.
- Derive a Context at the point of receipt. Carry the signal's event ID alongside its payload and return a derived
Contextfrom the receive operation, so that commands issued with that Context carry the signal's implicit Marker. This is idiomatic Go and opt-in, but it requires a new spelling for signal receipt, since adding a method to the existing publicReceiveChannelinterface would be a breaking change. - Add a callback-based signal handler API, mirroring the existing Update handler API. Implicit Event Groups on signals then follow from the same mechanism that serves Update handlers. This is a substantially larger change than Event Groups itself, and worth pursuing on its own merits rather than as a consequence of this proposal.
- Explicit Event Groups are created through
Workflow.createEventGroup(id), orWorkflow.createEventGroup(id, EventGroupOptions).EventGroupOptionscarries the optional display label viasetLabel. - Scope-based propagation through
eventGroup.run(…), which takes the block to run as either aFunctions.Procor aFunctions.Func<R>. Nested scopes compose. - The Options classes that already carry
summarymetadata gain anaddEventGroups(EventGroup…)builder method, which accumulates rather than replacing, so a builder derived from existing options extends that base's groups. - APIs that take no options object at all —
Workflow.sleep,Workflow.await,getVersion, theupsert…calls, and signaling an external or child workflow — carry Event Groups through scope propagation only. EventGroupispublic final classwith aprotectedconstructor to prevent misconstructions.
public class PaymentWorkflowImpl implements PaymentWorkflow {
@Override
public void processPayment(ChargeRequest request) {
// Creation of an explicit Event Group
EventGroup paymentGroup = **Workflow.createEventGroup("payment-processing");**
// Creation of an explicit Event Group with a display label
EventGroup customerGroup =
**Workflow.createEventGroup(
"customer-123456",
EventGroupOptions.newBuilder()
.setLabel("Customer: John Doe")
.build()
);**
// Explicit attachment of Event Groups to workflow commands, beside the summary
PaymentActivities activities =
Workflow.newActivityStub(
PaymentActivities.class,
ActivityOptions.newBuilder()
.setStartToCloseTimeout(Duration.ofSeconds(10))
.setSummary("charge the customer's card")
**.addEventGroups(paymentGroup, customerGroup)**
.build());
activities.chargeCard(request);
// Scope-based propagation. Only accepts singular form.
**paymentGroup.run(**
() -> {
authorizePayment(request);
capturePayment(request);
});
}
}- Workflow Activation messages for
SignalWorkflowjobs are modified to carry the Event ID of the corresponding history event. - Workflow Command messages are modified to accept an array of Event Group Markers attached to the command.
- Core propagates the Event Group Markers from the Language-side Commands to the server-side Commands.
- Dedup the
labelfield when emitting protobuftemporal.api.sdk.v1.EventGroupMarker.Labelmessages for IDs that have already been declared in that workflow.
- Creation of implicit Event Group Markers for inbound signals and inbound updates, and execution of handlers within the scope of those implicit Event Groups.
- Tracking of Event Groups execution scopes (i.e. using
AsyncLocalStorageor other applicable context propagation primitives for the given language).
message SignalWorkflow {
// ... (existing fields) ...
map<string, temporal.api.common.v1.Payload> headers = 5;
// Event ID of the `WORKFLOW_EXECUTION_SIGNALED` history event
// that produced this job.
**int64 originating_event_id = 6;**
}message WorkflowCommand {
// ... (existing fields) ...
temporal.api.sdk.v1.UserMetadata user_metadata = 100;
// Event group markers attached to the command. These are forwarded
// onto the corresponding server-side `Command` message, and then
// surfaced on the resulting `HistoryEvent`.
**repeated temporal.api.sdk.v1.EventGroupMarker event_group_markers = 101;**
// ... (existing fields) ...
}TBD. Cancellation commands are often generated by the SDK rather than written by user code, so it is not yet specified whether they should carry the markers of the command being cancelled, markers from the cancel call site, both, or neither.
There is a real use case for cross-workflow visibility — for example, identifying that a chain of related actions across workflows A, B and C all originated from a single user-initiated operation.
However, on closer examination, the granularity provided by Event Groups is better suited to grouping events within a single workflow execution than to expressing relationships across executions.
Trying to propagate Event Group Markers across workflow boundaries would bring several challenges:
- The per-event footprint would grow disproportionately. A cross-workflow Marker would need to carry enough information to identify its workflow of origin (namespace, workflow ID, run ID), which is significantly more expensive than an intra-workflow opaque ID. With multiple Markers active at the point of a cross-workflow command, and with Marker sets compounding across hops, the cost per history event would become effectively unbounded.
- Marker granularity collapses at the boundary. When workflow A sends a signal to workflow B, every Event Groups active on A's outbound event is equally responsible for the signal being sent — there is no semantically meaningful way to propagate a subset. From workflow B's perspective, a single backlink from B's inbound event to A's outbound event is therefore as informative, for navigation purposes, as carrying the full Marker set.
- Cross-workflow correlation without indexation has limited value. Reconstructing a cross-workflow group view requires walking the relationship graph across multiple histories. Indexing Markers in the visibility store would impose a constant write-time cost that the vast majority of workflows would never benefit from; performing the same traversal lazily in the UI — only when a user actually navigates between workflows — is a more appropriate tradeoff.
The same considerations apply to allowing clients to attach Event Group Markers when invoking workflows (start, signal, update) through the Workflow Service Client API. Without indexation, Markers supplied to multiple otherwise-unrelated workflows could not be correlated, which would leave the feature largely unusable for its intended purpose.
We believe that these use cases may be better addressed using other visibility primitives that the Temporal platform already provides, notably Link, SearchAttributes, UserMetadata, Headers, etc. Rather than attempting to extend Event Groups into a position where they would compete with other primitives, we propose to focus on the core use case of grouping events within a single workflow execution, for which there is no immediate solution.
Future work could explore the potential of a streamlined API that combines the various visibility primitives offered by the Temporal platform.
Explicit Event Groups created via createEventGroup(...) pose particular challenges from an history consolidation and the UI's regrouping logic, due to the conjunction of the following constraints:
- The label may contain sensitive data and must therefore be payload-encrypted.
- It must be possible for the UI to eagerly regroup events per Event Group, without having to decode label payloads.
- We can't assume that payload encryption is a deterministic transformation (i.e. that encrypting the same label multiple times would result in the same sequence of bytes).
- Minor code edits to the workflow code that add, remove, reorder or modify usage of Event Groups must not require the use of workflow code versioning features, and must not result in misattribution of Event Groups.
For those reasons, labels themselves can't be used as the grouping key, hence the need for an ID. It also means that the IDs themselves can't be assigned from a sequential counter, as we generally do for commands identifiers, as these would be subject to shifting and divergence on replay when the workflow code is later modified, which could result in misattribution of Event Groups.
A former version of this design proposal made "label" be the primary, mandatory argument of createEventGroup(...), deriving an Event Group's default ID value from a hash of its label (i.e. HEX(SHA1(runId + label)), where runId is the value of the WorkflowExecutionStarted.original_execution_run_id field, which remains stable on reset).
That initial direction was motivated by the belief that exposing only one string would make this API simpler for the user, and that this single string may contain sensitive data, so that string should be the Label rather than the ID (i.e. that would apparently make it "secure by default").
It was however later recognized that this approach presents some serious issues:
- The proposed derivation formula, and most other similar formulas, would produce a public fingerprint of the label that was cheap to brute-force for short and/or predictable label strings, which severely reduces the viability of using sensitive data as a label, or otherwise required that users systematically provide a non-sensitive ID for every Event Group they create.
- An Event Group's label's primary purpose is to be displayed in the UI, and may therefore evolve over time as the user's visibility needs change. IDs derived from such a label may therefore be unstable over time. By allowing users to only specify the label's text, rather than providing an ID, we're not encouraging them to be intentional about the long term stability requirements of the Event Group's grouping key, but instead let that be an afterthought issue to be addressed when they later need to modify their workflow code.
Concretely, that means that most users of the Event Groups API would effectively need to properly understand those intrinsic subtleties from day one—rather than make this a concern only for more advanced users—and that most users should really have both an ID and a label. That effectively negates the presumed simplicity of the API.
It is also questionable whether the assumption that most Event Groups will carry some sensitive data is valid; presumably, many developers may want their Event Groups to represent code structures (i.e. similar to function names), or to be based on non-sensitive data (i.e. an index number or non-sensitive business identifiers).
For those reasons, it was later decided to make the ID the primary, mandatory argument of createEventGroup(...), and the label the optional second argument. Labels are still serialized as json/plain payloads then codec-encoded, but they are no longer used as the grouping key, neither directly nor indirectly through the ID derivation formula.
When a label is omitted, the Marker carries only the ID, which the UI should display in replacement of the label payload.
- Reversed
createEventGroup(...)arguments so the ID is now required, and the label is optional. The reference factory is now defined ascreateEventGroup(id, options?: { label?: string }). The SDK uses the ID verbatim; it no longer derives IDs asSHA1(runId + label). An omitted label emits no payload. A provided label is stilljson/plainvia the default converter, then the worker's payload codec if any. User-provided IDs are not hashed. See Why Event Group IDs are caller-provided and plaintext. - Removed the
InitializeWorkflow.original_execution_run_idfield from this proposal. This field was previously used as a salt prefix as part of the Label ID derivation formula. Since that formula is now removed, this field is no longer needed. Note that even though this proposal no longer requires the field, that field has already been added to the Core SDK'sInitializeWorkflowmessage, and there is no intent to actually remove it at this point.
- Removed the implicit Event Group around the Workflow’s main function (i.e. associated with the Workflow Execution Started event). That implicit group adds a few bytes to most events in history with no real value — that association can easily be inferred from the fact that an event does not carry an implicit signal handler or update handler Event Group Marker. Also removed the associated Protobuf field
InitializeWorkflow.originating_event_id, which was required only to allow lang side to safely produce an implicit Event Group Marker linking back to theWORKFLOW_EXECUTION_STARTEDevent. - Specify that Event Group’s Label must be converted to Payload using the SDK’s Default Payload Converters, rather than user-provided Converters. The initial proposal was to use the user-provided Payload Converters, for alignment with the User Metadata feature, which mandates the use of user-provided Payload Converters. There’s however no real justification for either Event Groups nor User Metadata to rely on user-provided converters, yet this presents risks that the user-provided converters might encode strings into something that is not proper
json/plain, thus breaking the feature on the UI side.