diff --git a/chasm/context.go b/chasm/context.go index 87e057f5415..8eb17021408 100644 --- a/chasm/context.go +++ b/chasm/context.go @@ -112,6 +112,8 @@ type immutableCtx struct { // But it will be when we support partial loading later, // and the framework potentially needs to go to persistence to load some fields. ctx context.Context + // now is constant for this context; child contexts inherit the same value. + now time.Time executionKey ExecutionKey @@ -140,10 +142,12 @@ func newContext( ctx context.Context, node *Node, ) *immutableCtx { + root := node.root() workflowKey := node.backend.GetWorkflowKey() return &immutableCtx{ ctx: ctx, - root: node.root(), + now: root.Now(nil), + root: root, executionKey: ExecutionKey{ NamespaceID: workflowKey.NamespaceID, BusinessID: workflowKey.WorkflowID, @@ -168,8 +172,8 @@ func (c *immutableCtx) UserMetadata(component Component) *sdkpb.UserMetadata { return c.root.componentUserMetadata(component) } -func (c *immutableCtx) Now(component Component) time.Time { - return c.root.Now(component) +func (c *immutableCtx) Now(_ Component) time.Time { + return c.now } func (c *immutableCtx) ExecutionKey() ExecutionKey { @@ -211,6 +215,7 @@ func (c *immutableCtx) Value(key any) any { func (c *immutableCtx) withValue(key any, value any) Context { return &immutableCtx{ ctx: context.WithValue(c.goContext(), key, value), + now: c.now, root: c.root, executionKey: c.executionKey, } diff --git a/chasm/tree_test.go b/chasm/tree_test.go index 72c9bd2e310..cf0bda6b035 100644 --- a/chasm/tree_test.go +++ b/chasm/tree_test.go @@ -3220,6 +3220,44 @@ func (s *nodeSuite) testComponentTree() *Node { return node // maybe tc too } +func (s *nodeSuite) TestContextNowStableWithinContext() { + root := s.testComponentTree() + + startTime := time.Date(2026, 1, 1, 1, 0, 0, 0, time.UTC) + updatedTime := startTime.Add(time.Minute) + laterTime := updatedTime.Add(time.Minute) + finalTime := laterTime.Add(time.Minute) + + s.timeSource.Update(startTime) + + mutableContext := NewMutableContext(context.Background(), root) + s.timeSource.Update(updatedTime) + + component, err := root.Component(mutableContext, ComponentRef{}) + s.NoError(err) + testComponent := component.(*TestComponent) + + s.Equal(startTime, mutableContext.Now(component)) + s.Equal(startTime, mutableContext.Now(component)) + + childComponent := testComponent.SubComponent1.Get(mutableContext) + s.Equal(startTime, mutableContext.Now(childComponent)) + + contextWithValue := ContextWithValue(mutableContext, "test-key", "test-value") + s.Equal("test-value", contextWithValue.Value("test-key")) + s.Equal(startTime, contextWithValue.Now(component)) + + s.timeSource.Update(laterTime) + s.Equal(startTime, contextWithValue.Now(component)) + s.Equal(laterTime, NewMutableContext(context.Background(), root).Now(component)) + + immutableContext := NewContext(context.Background(), root) + s.Equal(laterTime, immutableContext.Now(component)) + + s.timeSource.Update(finalTime) + s.Equal(laterTime, immutableContext.Now(component)) +} + func (s *nodeSuite) TestExecuteImmediatePureTask() { root := s.testComponentTree() @@ -3274,6 +3312,64 @@ func (s *nodeSuite) TestExecuteImmediatePureTask() { s.Equal(tasks.MaximumKey.FireTime, s.nodeBackend.LastDeletePureTaskCall()) } +func (s *nodeSuite) TestImmediatePureTaskNowStableWithinTaskOnly() { + root := s.testComponentTree() + + _, err := root.CloseTransaction() + s.NoError(err) + + taskStartTime := time.Date(2026, 1, 1, 2, 0, 0, 0, time.UTC) + nextTaskTime := taskStartTime.Add(time.Minute) + s.timeSource.Update(taskStartTime) + + mutableContext := NewMutableContext(context.Background(), root) + component, err := root.Component(mutableContext, ComponentRef{}) + s.NoError(err) + + taskAttributes := TaskAttributes{ScheduledTime: TaskScheduledTimeImmediate} + mutableContext.AddTask( + component, + taskAttributes, + &TestPureTask{}, + ) + mutableContext.AddTask( + component, + taskAttributes, + &TestPureTask{}, + ) + + s.testLibrary.mockPureTaskHandler.EXPECT(). + Validate(gomock.Any(), gomock.Any(), gomock.Eq(taskAttributes), gomock.Any()).Return(true, nil).Times(2) + + var observedTimes []time.Time + s.testLibrary.mockPureTaskHandler.EXPECT(). + Execute( + gomock.AssignableToTypeOf(&mutableCtx{}), + gomock.AssignableToTypeOf(&TestComponent{}), + gomock.Eq(taskAttributes), + gomock.Any(), + ). + DoAndReturn(func(ctx MutableContext, component any, _ TaskAttributes, _ *TestPureTask) error { + chasmComponent := component.(Component) + firstNow := ctx.Now(chasmComponent) + secondNow := ctx.Now(chasmComponent) + s.Equal(firstNow, secondNow) + + observedTimes = append(observedTimes, firstNow) + if len(observedTimes) == 1 { + s.timeSource.Update(nextTaskTime) + } + return nil + }). + Times(2) + + mutations, err := root.CloseTransaction() + s.NoError(err) + s.Len(mutations.UpdatedNodes, 1, "root should be updated") + s.Empty(mutations.DeletedNodes) + s.Equal([]time.Time{taskStartTime, nextTaskTime}, observedTimes) +} + func (s *nodeSuite) TestEachPureTask() { now := s.timeSource.Now()