Skip to content

Commit feec4da

Browse files
committed
timeskipping poc with saa
1 parent 9eb7e6b commit feec4da

19 files changed

Lines changed: 1037 additions & 104 deletions

chasm/context.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@ type MutableContext interface {
9797
// SetUserMetadata replaces the user metadata attached to the given component.
9898
SetUserMetadata(Component, *sdkpb.UserMetadata) error
9999

100+
// TimeSkippingController is the framework-provided surface for managing this execution's
101+
// time-skipping configuration (Init/Update/Get). A root component opts in by calling
102+
// InitTimeSkippingConfig when the execution is created. These take no Component argument because
103+
// the configuration is execution-scoped, like the ExecutionKey()/ExecutionInfo() accessors.
104+
TimeSkippingController
105+
100106
// Get a Ref for the component
101107
// This ref to the component state at the end of the transition
102108
// Same as Ref(Component) method in Context,
@@ -272,6 +278,18 @@ func (c *mutableCtx) SetUserMetadata(component Component, md *sdkpb.UserMetadata
272278
return c.root.setComponentUserMetadata(component, md)
273279
}
274280

281+
func (c *mutableCtx) InitTimeSkippingConfig(config *commonpb.TimeSkippingConfig) {
282+
c.root.backend.InitTimeSkippingConfig(config)
283+
}
284+
285+
func (c *mutableCtx) UpdateTimeSkippingConfig(config *commonpb.TimeSkippingConfig) {
286+
c.root.backend.UpdateTimeSkippingConfig(config)
287+
}
288+
289+
func (c *mutableCtx) GetTimeSkippingConfig() *commonpb.TimeSkippingConfig {
290+
return c.root.backend.GetTimeSkippingInfo().GetConfig()
291+
}
292+
275293
func (c *mutableCtx) withValue(key any, value any) Context {
276294
return &mutableCtx{
277295
immutableCtx: ContextWithValue(c.immutableCtx, key, value),

chasm/context_mock.go

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,10 +183,31 @@ func (c *MockContext) withValue(key any, value any) Context {
183183
type MockMutableContext struct {
184184
MockContext
185185

186-
mu sync.Mutex
187-
Tasks []MockTask
188-
LinksByRequest map[Component]map[string][]*commonpb.Link
189-
UserMetadataByComponent map[Component]*sdkpb.UserMetadata
186+
mu sync.Mutex
187+
Tasks []MockTask
188+
LinksByRequest map[Component]map[string][]*commonpb.Link
189+
UserMetadataByComponent map[Component]*sdkpb.UserMetadata
190+
TimeSkippingConfig *commonpb.TimeSkippingConfig
191+
TimeSkippingConfigInited bool
192+
}
193+
194+
func (c *MockMutableContext) InitTimeSkippingConfig(config *commonpb.TimeSkippingConfig) {
195+
c.mu.Lock()
196+
defer c.mu.Unlock()
197+
c.TimeSkippingConfig = config
198+
c.TimeSkippingConfigInited = true
199+
}
200+
201+
func (c *MockMutableContext) UpdateTimeSkippingConfig(config *commonpb.TimeSkippingConfig) {
202+
c.mu.Lock()
203+
defer c.mu.Unlock()
204+
c.TimeSkippingConfig = config
205+
}
206+
207+
func (c *MockMutableContext) GetTimeSkippingConfig() *commonpb.TimeSkippingConfig {
208+
c.mu.Lock()
209+
defer c.mu.Unlock()
210+
return c.TimeSkippingConfig
190211
}
191212

192213
func (c *MockMutableContext) AddTask(component Component, attributes TaskAttributes, payload any) {

chasm/lib/activity/activity.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,53 @@ func (a *Activity) LifecycleState(_ chasm.Context) chasm.LifecycleState {
124124
}
125125
}
126126

127+
// HasInflightWork implements chasm.TimeSkippable. It reports whether advancing virtual time would
128+
// skip past real work, gating the framework's per-transaction time-skipping decision.
129+
//
130+
// Only the activity attempt is considered: it is in flight unless the activity is SCHEDULED and
131+
// still waiting for its next dispatch — out of a start delay or retry backoff with the next-attempt
132+
// time strictly in the (virtual) future. Once that time arrives (dispatch pushed to matching,
133+
// waiting for a poller), the attempt is running (STARTED) or being cancelled (CANCEL_REQUESTED), or
134+
// the activity has closed, this returns true. This mirrors the workflow time-skipping rule for
135+
// activities, and means a closed activity is never time-skipped.
136+
func (a *Activity) HasInflightWork(ctx chasm.Context) bool {
137+
if a.GetStatus() != activitypb.ACTIVITY_EXECUTION_STATUS_SCHEDULED {
138+
return true
139+
}
140+
nextDispatch := a.attemptScheduleTime(a.LastAttempt.Get(ctx))
141+
if nextDispatch == nil || !ctx.Now(a).Before(nextDispatch.AsTime()) {
142+
return true
143+
}
144+
return false
145+
}
146+
147+
// FindNextTargetTime implements chasm.TimeSkippable. The framework calls it after HasInflightWork has
148+
// reported the activity is idle; the activity contributes its candidate virtual time by calling
149+
// targetTime, leaving it untouched if it has nothing of its own to skip to. Today that is only the next
150+
// attempt dispatch (start-delay end on the first attempt, retry-backoff end on a retry) while the
151+
// activity is SCHEDULED-and-waiting, gated by the schedule-to-close deadline (the execution timeout) so
152+
// we never skip past it.
153+
//
154+
// The framework pre-seeds targetTime with the configured fast-forward target, so the activity only
155+
// contributes its own next-wake time; CompareAndSet keeps the minimum and the framework decides whether
156+
// the fast-forward budget was reached.
157+
//
158+
// TODO(time-skipping): completion callback retry backoff
159+
func (a *Activity) FindNextTargetTime(
160+
ctx chasm.Context,
161+
targetTime *chasm.TimeSkippingTargetTime,
162+
) {
163+
nextDispatch := a.attemptScheduleTime(a.LastAttempt.Get(ctx))
164+
if nextDispatch == nil || !ctx.Now(a).Before(nextDispatch.AsTime()) {
165+
return
166+
}
167+
targetTime.CompareAndSet(nextDispatch.AsTime())
168+
// Gate by the schedule-to-close deadline (execution timeout) so we never skip past it.
169+
if deadline := a.scheduleToCloseDeadline(); !deadline.IsZero() {
170+
targetTime.GateByExecutionTimeout(deadline)
171+
}
172+
}
173+
127174
func (a *Activity) ContextMetadata(_ chasm.Context) map[string]string {
128175
md := make(map[string]string, 2)
129176
if actType := a.GetActivityType().GetName(); actType != "" {

chasm/lib/activity/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ type Config struct {
5353
MaxCallbacksPerExecution dynamicconfig.IntPropertyFnWithNamespaceFilter
5454
DefaultActivityRetryPolicy dynamicconfig.TypedPropertyFnWithNamespaceFilter[retrypolicy.DefaultRetrySettings]
5555
StartDelayEnabled dynamicconfig.BoolPropertyFnWithNamespaceFilter
56+
TimeSkippingEnabled dynamicconfig.BoolPropertyFnWithNamespaceFilter
5657
VisibilityMaxPageSize dynamicconfig.IntPropertyFnWithNamespaceFilter
5758
}
5859

@@ -68,6 +69,7 @@ func ConfigProvider(dc *dynamicconfig.Collection) *Config {
6869
LongPollTimeout: LongPollTimeout.Get(dc),
6970
MaxIDLengthLimit: dynamicconfig.MaxIDLengthLimit.Get(dc),
7071
StartDelayEnabled: StartDelayEnabled.Get(dc),
72+
TimeSkippingEnabled: dynamicconfig.TimeSkippingEnabled.Get(dc),
7173
MaxCallbacksPerExecution: callback.MaxPerExecution.Get(dc),
7274
VisibilityMaxPageSize: dynamicconfig.FrontendVisibilityMaxPageSize.Get(dc),
7375
}

chasm/lib/activity/handler.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ func (h *handler) StartActivityExecution(ctx context.Context, req *activitypb.St
9595
}
9696
}
9797

98+
if request.GetTimeSkippingConfig() != nil {
99+
mutableContext.InitTimeSkippingConfig(request.GetTimeSkippingConfig())
100+
}
101+
98102
err = TransitionScheduled.Apply(newActivity, mutableContext, nil)
99103
if err != nil {
100104
return nil, err

chasm/node_backend_mock.go

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"sync"
66
"time"
77

8+
commonpb "go.temporal.io/api/common/v1"
89
enumspb "go.temporal.io/api/enums/v1"
910
historypb "go.temporal.io/api/history/v1"
1011
enumsspb "go.temporal.io/server/api/enums/v1"
@@ -22,23 +23,27 @@ var _ NodeBackend = (*MockNodeBackend)(nil)
2223
// fields (thread-safe).
2324
type MockNodeBackend struct {
2425
// Optional function overrides. If nil, methods return zero-values.
25-
HandleGetExecutionState func() *persistencespb.WorkflowExecutionState
26-
HandleGetExecutionInfo func() *persistencespb.WorkflowExecutionInfo
27-
HandleGetCurrentVersion func() int64
28-
HandleNextTransitionCount func() int64
29-
HandleGetApproximatePersistedSize func() int
30-
HandleCurrentVersionedTransition func() *persistencespb.VersionedTransition
31-
HandleGetWorkflowKey func() definition.WorkflowKey
32-
HandleUpdateWorkflowStateStatus func(state enumsspb.WorkflowExecutionState, status enumspb.WorkflowExecutionStatus) (bool, error)
33-
HandleIsWorkflow func() bool
34-
HandleGetNexusCompletion func(ctx context.Context, requestID string) (nexusrpc.CompleteOperationOptions, error)
35-
HandleGetNexusUpdateCompletion func(ctx context.Context, updateID string, requestID string) (nexusrpc.CompleteOperationOptions, error)
36-
HandleAddHistoryEvent func(t enumspb.EventType, setAttributes func(*historypb.HistoryEvent)) *historypb.HistoryEvent
37-
HandleLoadHistoryEvent func(ctx context.Context, token []byte) (*historypb.HistoryEvent, error)
38-
HandleGenerateEventLoadToken func(event *historypb.HistoryEvent) ([]byte, error)
39-
HandleHasAnyBufferedEvent func(filter func(*historypb.HistoryEvent) bool) bool
40-
HandleGetNamespaceEntry func() *namespace.Namespace
41-
HandleEndpointRegistry func() EndpointRegistry
26+
HandleGetExecutionState func() *persistencespb.WorkflowExecutionState
27+
HandleGetExecutionInfo func() *persistencespb.WorkflowExecutionInfo
28+
HandleGetCurrentVersion func() int64
29+
HandleNextTransitionCount func() int64
30+
HandleGetApproximatePersistedSize func() int
31+
HandleCurrentVersionedTransition func() *persistencespb.VersionedTransition
32+
HandleGetWorkflowKey func() definition.WorkflowKey
33+
HandleUpdateWorkflowStateStatus func(state enumsspb.WorkflowExecutionState, status enumspb.WorkflowExecutionStatus) (bool, error)
34+
HandleIsWorkflow func() bool
35+
HandleGetNexusCompletion func(ctx context.Context, requestID string) (nexusrpc.CompleteOperationOptions, error)
36+
HandleGetNexusUpdateCompletion func(ctx context.Context, updateID string, requestID string) (nexusrpc.CompleteOperationOptions, error)
37+
HandleAddHistoryEvent func(t enumspb.EventType, setAttributes func(*historypb.HistoryEvent)) *historypb.HistoryEvent
38+
HandleLoadHistoryEvent func(ctx context.Context, token []byte) (*historypb.HistoryEvent, error)
39+
HandleGenerateEventLoadToken func(event *historypb.HistoryEvent) ([]byte, error)
40+
HandleHasAnyBufferedEvent func(filter func(*historypb.HistoryEvent) bool) bool
41+
HandleGetNamespaceEntry func() *namespace.Namespace
42+
HandleEndpointRegistry func() EndpointRegistry
43+
HandleInitTimeSkippingConfig func(config *commonpb.TimeSkippingConfig)
44+
HandleUpdateTimeSkippingConfig func(config *commonpb.TimeSkippingConfig)
45+
HandleGetTimeSkippingInfo func() *persistencespb.TimeSkippingInfo
46+
HandleRecordTimeSkippingTransition func(ctx context.Context, transition TimeSkippingTransition, archetype ArchetypeID) error
4247

4348
// Recorded calls (protected by mu).
4449
mu sync.Mutex
@@ -111,6 +116,32 @@ func (m *MockNodeBackend) AddTasks(ts ...tasks.Task) {
111116
}
112117
}
113118

119+
func (m *MockNodeBackend) InitTimeSkippingConfig(config *commonpb.TimeSkippingConfig) {
120+
if m.HandleInitTimeSkippingConfig != nil {
121+
m.HandleInitTimeSkippingConfig(config)
122+
}
123+
}
124+
125+
func (m *MockNodeBackend) UpdateTimeSkippingConfig(config *commonpb.TimeSkippingConfig) {
126+
if m.HandleUpdateTimeSkippingConfig != nil {
127+
m.HandleUpdateTimeSkippingConfig(config)
128+
}
129+
}
130+
131+
func (m *MockNodeBackend) GetTimeSkippingInfo() *persistencespb.TimeSkippingInfo {
132+
if m.HandleGetTimeSkippingInfo != nil {
133+
return m.HandleGetTimeSkippingInfo()
134+
}
135+
return nil
136+
}
137+
138+
func (m *MockNodeBackend) RecordTimeSkippingTransition(ctx context.Context, transition TimeSkippingTransition, archetype ArchetypeID) error {
139+
if m.HandleRecordTimeSkippingTransition != nil {
140+
return m.HandleRecordTimeSkippingTransition(ctx, transition, archetype)
141+
}
142+
return nil
143+
}
144+
114145
func (m *MockNodeBackend) DeleteCHASMPureTasks(maxScheduledTime time.Time) {
115146
m.mu.Lock()
116147
defer m.mu.Unlock()

chasm/timeskipping_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package chasm
2+
3+
import (
4+
"time"
5+
6+
persistencespb "go.temporal.io/server/api/persistence/v1"
7+
"go.temporal.io/server/service/history/tasks"
8+
"google.golang.org/protobuf/types/known/timestamppb"
9+
)
10+
11+
// timeSkippingTaskNodes builds a tree with three CategoryTimer tasks already materialized
12+
// (PhysicalTaskStatus == Created): a side-effect timer on the root, a side-effect timer on a child,
13+
// and a pure timer on the root. The scheduled times are in the (virtual) future and carry no
14+
// destination, so taskCategory classifies the side-effect tasks as CategoryTimer.
15+
func (s *nodeSuite) timeSkippingTaskNodes() map[string]*persistencespb.ChasmNode {
16+
now := s.timeSource.Now()
17+
return map[string]*persistencespb.ChasmNode{
18+
"": {
19+
Metadata: &persistencespb.ChasmNodeMetadata{
20+
InitialVersionedTransition: &persistencespb.VersionedTransition{TransitionCount: 1},
21+
LastUpdateVersionedTransition: &persistencespb.VersionedTransition{TransitionCount: 1},
22+
Attributes: &persistencespb.ChasmNodeMetadata_ComponentAttributes{
23+
ComponentAttributes: &persistencespb.ChasmComponentAttributes{
24+
TypeId: testComponentTypeID,
25+
PureTasks: []*persistencespb.ChasmComponentAttributes_Task{
26+
{
27+
TypeId: testPureTaskTypeID,
28+
ScheduledTime: timestamppb.New(now.Add(time.Hour)),
29+
VersionedTransition: &persistencespb.VersionedTransition{TransitionCount: 1},
30+
VersionedTransitionOffset: 1,
31+
PhysicalTaskStatus: physicalTaskStatusCreated,
32+
},
33+
},
34+
SideEffectTasks: []*persistencespb.ChasmComponentAttributes_Task{
35+
{
36+
TypeId: testSideEffectTaskTypeID,
37+
ScheduledTime: timestamppb.New(now.Add(time.Hour)),
38+
VersionedTransition: &persistencespb.VersionedTransition{TransitionCount: 1},
39+
VersionedTransitionOffset: 2,
40+
PhysicalTaskStatus: physicalTaskStatusCreated,
41+
},
42+
},
43+
},
44+
},
45+
},
46+
},
47+
"SubComponent1": {
48+
Metadata: &persistencespb.ChasmNodeMetadata{
49+
InitialVersionedTransition: &persistencespb.VersionedTransition{TransitionCount: 1},
50+
LastUpdateVersionedTransition: &persistencespb.VersionedTransition{TransitionCount: 1},
51+
Attributes: &persistencespb.ChasmNodeMetadata_ComponentAttributes{
52+
ComponentAttributes: &persistencespb.ChasmComponentAttributes{
53+
TypeId: testSubComponent1TypeID,
54+
SideEffectTasks: []*persistencespb.ChasmComponentAttributes_Task{
55+
{
56+
TypeId: testSideEffectTaskTypeID,
57+
ScheduledTime: timestamppb.New(now.Add(2 * time.Hour)),
58+
VersionedTransition: &persistencespb.VersionedTransition{TransitionCount: 1},
59+
VersionedTransitionOffset: 3,
60+
PhysicalTaskStatus: physicalTaskStatusCreated,
61+
},
62+
},
63+
},
64+
},
65+
},
66+
},
67+
}
68+
}
69+
70+
// TestRegenerateTasksForTimeSkipping_RestampsEachTimerExactlyOnce verifies a time-skipping
71+
// regeneration re-emits exactly one physical task per logical CategoryTimer task — two side-effect
72+
// timers plus a single pure timer (the earliest across the tree) — and not duplicates. This is the
73+
// "generated only once" guarantee: even though every task starts already Created, the re-stamp emits
74+
// each one exactly once.
75+
func (s *nodeSuite) TestRegenerateTasksForTimeSkipping_RestampsEachTimerExactlyOnce() {
76+
root, err := s.newTestTree(s.timeSkippingTaskNodes())
77+
s.NoError(err)
78+
79+
err = root.regenerateTasksForTimeSkipping()
80+
s.NoError(err)
81+
82+
// 2 side-effect CategoryTimer tasks (root + SubComponent1) + 1 pure timer (earliest) = 3, all
83+
// CategoryTimer. Not 6 (would indicate double-emission) and not 2 (would indicate the no-break
84+
// re-stamp skipped already-Created tasks).
85+
s.Equal(3, s.nodeBackend.NumTasksAdded())
86+
s.Len(s.nodeBackend.TasksByCategory[tasks.CategoryTimer], 3)
87+
}
88+
89+
// TestCloseTransaction_DoesNotRegenerateAlreadyMaterializedTimers verifies the complementary
90+
// property: a plain CloseTransaction (no time-skipping transition, no user-state change) does NOT
91+
// re-emit timer tasks that are already materialized. This is what keeps the active cluster from
92+
// generating new timer tasks on every transaction — only a skip (via regenerateTasksForTimeSkipping)
93+
// re-stamps them.
94+
func (s *nodeSuite) TestCloseTransaction_DoesNotRegenerateAlreadyMaterializedTimers() {
95+
root, err := s.newTestTree(s.timeSkippingTaskNodes())
96+
s.NoError(err)
97+
98+
// Time skipping is not enabled (the mock backend's GetTimeSkippingInfo returns nil) and nothing was
99+
// mutated, so the normal generation loop must skip the already-Created timer tasks.
100+
_, err = root.CloseTransaction()
101+
s.NoError(err)
102+
s.Equal(0, s.nodeBackend.NumTasksAdded())
103+
}

0 commit comments

Comments
 (0)