CHASM: Best effort pure task deletion (#8531)

## What changed?
- CHASM: Best effort pure task deletion

## Why?
- Performance improvement, prevent invalid physical pure tasks from
firing.

## How did you test it?
- [x] built
- [ ] run locally and tested manually
- [ ] covered by existing tests
- [x] added new unit test(s)
- [ ] added new functional test(s)
This commit is contained in:
Yichao Yang
2025-11-06 14:21:10 -08:00
committed by GitHub
parent b58a196c81
commit 2075d1685a
9 changed files with 197 additions and 9 deletions

View File

@@ -3,6 +3,7 @@ package chasm
import (
"context"
"sync"
"time"
enumspb "go.temporal.io/api/enums/v1"
enumsspb "go.temporal.io/server/api/enums/v1"
@@ -28,9 +29,10 @@ type MockNodeBackend struct {
HandleGetNexusCompletion func(ctx context.Context, requestID string) (nexusrpc.OperationCompletion, error)
// Recorded calls (protected by mu).
mu sync.Mutex
TasksByCategory map[tasks.Category][]tasks.Task
UpdateCalls []struct {
mu sync.Mutex
TasksByCategory map[tasks.Category][]tasks.Task
DeletePureTaskCalls []time.Time
UpdateCalls []struct {
State enumsspb.WorkflowExecutionState
Status enumspb.WorkflowExecutionStatus
}
@@ -90,6 +92,23 @@ func (m *MockNodeBackend) AddTasks(ts ...tasks.Task) {
}
}
func (m *MockNodeBackend) DeleteCHASMPureTasks(maxScheduledTime time.Time) {
m.mu.Lock()
defer m.mu.Unlock()
m.DeletePureTaskCalls = append(m.DeletePureTaskCalls, maxScheduledTime)
}
func (m *MockNodeBackend) LastDeletePureTaskCall() time.Time {
m.mu.Lock()
defer m.mu.Unlock()
if len(m.DeletePureTaskCalls) == 0 {
return time.Time{}
}
return m.DeletePureTaskCalls[len(m.DeletePureTaskCalls)-1]
}
func (m *MockNodeBackend) UpdateWorkflowStateStatus(
state enumsspb.WorkflowExecutionState,
status enumspb.WorkflowExecutionStatus,

View File

@@ -184,6 +184,7 @@ type (
CurrentVersionedTransition() *persistencespb.VersionedTransition
GetWorkflowKey() definition.WorkflowKey
AddTasks(...tasks.Task)
DeleteCHASMPureTasks(maxScheduledTime time.Time)
UpdateWorkflowStateStatus(
state enumsspb.WorkflowExecutionState,
status enumspb.WorkflowExecutionStatus,
@@ -1842,13 +1843,21 @@ func (n *Node) closeTransactionGeneratePhysicalPureTask(
firstPureTask *persistencespb.ChasmComponentAttributes_Task,
firstTaskNode *Node,
) error {
if firstPureTask == nil || firstPureTask.PhysicalTaskStatus == physicalTaskStatusCreated {
if firstPureTask == nil {
n.backend.DeleteCHASMPureTasks(tasks.MaximumKey.FireTime)
return nil
}
firstPureTaskScheduledTime := firstPureTask.ScheduledTime.AsTime()
n.backend.DeleteCHASMPureTasks(firstPureTaskScheduledTime)
if firstPureTask.PhysicalTaskStatus == physicalTaskStatusCreated {
return nil
}
n.backend.AddTasks(&tasks.ChasmTaskPure{
WorkflowKey: n.backend.GetWorkflowKey(),
VisibilityTimestamp: firstPureTask.ScheduledTime.AsTime(),
VisibilityTimestamp: firstPureTaskScheduledTime,
Category: tasks.CategoryTimer,
})

View File

@@ -1101,6 +1101,7 @@ func (s *nodeSuite) TestApplyMutation_OutOfOrder() {
func (s *nodeSuite) TestRefreshTasks() {
now := s.timeSource.Now()
pureTaskScheduledTime := now.Add(time.Second).UTC()
persistenceNodes := map[string]*persistencespb.ChasmNode{
"": {
Metadata: &persistencespb.ChasmNodeMetadata{
@@ -1132,7 +1133,7 @@ func (s *nodeSuite) TestRefreshTasks() {
PureTasks: []*persistencespb.ChasmComponentAttributes_Task{
{
Type: "TestLibrary.test_pure_task",
ScheduledTime: timestamppb.New(now.Add(time.Second)),
ScheduledTime: timestamppb.New(pureTaskScheduledTime),
VersionedTransition: &persistencespb.VersionedTransition{TransitionCount: 1},
VersionedTransitionOffset: 2,
PhysicalTaskStatus: physicalTaskStatusCreated,
@@ -1176,6 +1177,7 @@ func (s *nodeSuite) TestRefreshTasks() {
s.NoError(err)
s.Len(mutation.UpdatedNodes, 2) // TaskStatus for the root node is not reset, so no need to persist it.
s.Equal(2, s.nodeBackend.NumTasksAdded())
s.Equal(pureTaskScheduledTime, s.nodeBackend.LastDeletePureTaskCall())
}
func (s *nodeSuite) TestCarryOverTaskStatus() {
@@ -2050,6 +2052,8 @@ func (s *nodeSuite) TestCloseTransaction_InvalidateComponentTasks() {
err = root.closeTransactionUpdateComponentTasks(&persistencespb.VersionedTransition{TransitionCount: 2})
s.NoError(err)
s.Equal(tasks.MaximumKey.FireTime, s.nodeBackend.LastDeletePureTaskCall())
componentAttr := root.serializedNode.Metadata.GetComponentAttributes()
s.Empty(componentAttr.PureTasks)
s.Len(componentAttr.SideEffectTasks, 1)
@@ -2163,6 +2167,8 @@ func (s *nodeSuite) TestCloseTransaction_NewComponentTasks() {
mutation, err := root.CloseTransaction()
s.NoError(err)
s.Equal(s.timeSource.Now().UTC(), s.nodeBackend.LastDeletePureTaskCall())
rootAttr := mutation.UpdatedNodes[""].GetMetadata().GetComponentAttributes()
s.Len(rootAttr.SideEffectTasks, 1) // Only one valid side effect task.
newSideEffectTask := rootAttr.SideEffectTasks[0]
@@ -2308,7 +2314,7 @@ func (s *nodeSuite) TestCloseTransaction_ApplyMutation_SideEffectTasks() {
}
func (s *nodeSuite) TestCloseTransaction_ApplyMutation_PureTasks() {
now := s.timeSource.Now()
now := s.timeSource.Now().UTC()
persistenceNodes := map[string]*persistencespb.ChasmNode{
"": {
Metadata: &persistencespb.ChasmNodeMetadata{
@@ -2386,6 +2392,8 @@ func (s *nodeSuite) TestCloseTransaction_ApplyMutation_PureTasks() {
mutation, err := root.CloseTransaction()
s.NoError(err)
s.Equal(now.Add(time.Minute), s.nodeBackend.LastDeletePureTaskCall())
// Although only root is mutated in ApplyMutation, we generated a pure task for the child node,
// and need to persist that as well.
s.Len(mutation.UpdatedNodes, 2)
@@ -2565,6 +2573,9 @@ func (s *nodeSuite) TestExecuteImmediatePureTask() {
s.NoError(err)
s.Len(mutations.UpdatedNodes, 2, "root and subcomponent1 should be updated")
s.Empty(mutations.DeletedNodes)
// immedidate pure tasks will be executed inline and no physical chasm pure task will be generated.
s.Equal(tasks.MaximumKey.FireTime, s.nodeBackend.LastDeletePureTaskCall())
}
func (s *nodeSuite) TestEachPureTask() {

View File

@@ -2602,6 +2602,12 @@ that task will be sent to DLQ.`,
"Use real chasm tree implementation instead of the noop one",
)
ChasmMaxInMemoryPureTasks = NewGlobalIntSetting(
"history.chasmMaxInMemoryPureTasks",
32,
`ChasmMaxInMemoryPureTasks is the maximum number of physical pure tasks that can be held in memory for best effort task deletion.`,
)
EnableCHASMSchedulerCreation = NewNamespaceBoolSetting(
"history.enableCHASMSchedulerCreation",
false,

View File

@@ -64,6 +64,7 @@ type Config struct {
MaxCallbacksPerWorkflow dynamicconfig.IntPropertyFnWithNamespaceFilter
EnableRequestIdRefLinks dynamicconfig.BoolPropertyFn
EnableChasm dynamicconfig.BoolPropertyFn
ChasmMaxInMemoryPureTasks dynamicconfig.IntPropertyFn
EnableCHASMSchedulerCreation dynamicconfig.BoolPropertyFnWithNamespaceFilter
EnableCHASMSchedulerMigration dynamicconfig.BoolPropertyFnWithNamespaceFilter
@@ -445,6 +446,7 @@ func NewConfig(
MaxCallbacksPerWorkflow: dynamicconfig.MaxCallbacksPerWorkflow.Get(dc),
EnableRequestIdRefLinks: dynamicconfig.EnableRequestIdRefLinks.Get(dc),
EnableChasm: dynamicconfig.EnableChasm.Get(dc),
ChasmMaxInMemoryPureTasks: dynamicconfig.ChasmMaxInMemoryPureTasks.Get(dc),
EnableCHASMSchedulerCreation: dynamicconfig.EnableCHASMSchedulerCreation.Get(dc),
EnableCHASMSchedulerMigration: dynamicconfig.EnableCHASMSchedulerMigration.Get(dc),

View File

@@ -286,6 +286,8 @@ type (
AddTasks(tasks ...tasks.Task)
PopTasks() map[tasks.Category][]tasks.Task
DeleteCHASMPureTasks(maxScheduledTime time.Time)
SetUpdateCondition(int64, int64)
GetUpdateCondition() (int64, int64)

View File

@@ -1736,6 +1736,18 @@ func (mr *MockMutableStateMockRecorder) CurrentVersionedTransition() *gomock.Cal
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CurrentVersionedTransition", reflect.TypeOf((*MockMutableState)(nil).CurrentVersionedTransition))
}
// DeleteCHASMPureTasks mocks base method.
func (m *MockMutableState) DeleteCHASMPureTasks(maxScheduledTime time.Time) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "DeleteCHASMPureTasks", maxScheduledTime)
}
// DeleteCHASMPureTasks indicates an expected call of DeleteCHASMPureTasks.
func (mr *MockMutableStateMockRecorder) DeleteCHASMPureTasks(maxScheduledTime any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCHASMPureTasks", reflect.TypeOf((*MockMutableState)(nil).DeleteCHASMPureTasks), maxScheduledTime)
}
// DeleteSignalRequested mocks base method.
func (m *MockMutableState) DeleteSignalRequested(requestID string) {
m.ctrl.T.Helper()

View File

@@ -227,6 +227,21 @@ type (
wftScheduleToStartTimeoutTask *tasks.WorkflowTaskTimeoutTask
wftStartToCloseTimeoutTask *tasks.WorkflowTaskTimeoutTask
// In-memory storage for CHASM pure tasks. These are set when CHASM pure tasks are generated and used to
// delete them when then are no longer needed. (i.e. when the task's scheduled time is after that of the
// earliest valid CHASM pure task's).
//
// Those pure tasks are mostly reverse ordered by their scheduled time (the VisibilityTimestamp field).
// Since a physical pure task is only generated when there's no other pure task with an earlier scheduled time,
// simply appending new pure tasks to the end of the slice maintains the order.
//
// NOTE: shard context may move those tasks' scheduled time to the future if they are earlier than the timer queue's
// max read level (otherwise those tasks won't be loaded), which may potentially break the reverse order.
// That is fine, however, as in the worst case we just delete fewer tasks than we could have, but we will never delete
// tasks that are still needed (all tasks deleted are those having an earlier scheduled time than what's needed).
// Task deletion is just a best-effort optimization after all, so not complicating the logic to account for that here.
chasmPureTasks []*tasks.ChasmTaskPure
// Do not rely on this, this is only updated on
// Load() and closeTransactionXXX methods. So when
// a transaction is in progress, this value will be
@@ -6138,14 +6153,47 @@ func (ms *MutableStateImpl) AddTasks(
ms.logger.Info("Dropped long duration scheduled task.", tasks.Tags(task)...)
continue
}
if chasmPureTask, ok := task.(*tasks.ChasmTaskPure); ok {
ms.chasmPureTasks = append(ms.chasmPureTasks, chasmPureTask)
maxPureTasks := ms.config.ChasmMaxInMemoryPureTasks()
if len(ms.chasmPureTasks) > maxPureTasks {
// Since tasks are reverse ordered by their scheduled time, tasks in the beginning are those
// - Generated a long time ago
// - Scheduled time is far in the future
// both types of tasks are likely to already be persisted in DB and best-effort deletion won't help,
// so drop them from the in-memory list first.
ms.chasmPureTasks = ms.chasmPureTasks[len(ms.chasmPureTasks)-maxPureTasks:]
}
}
ms.InsertTasks[category] = append(ms.InsertTasks[category], task)
}
}
func (ms *MutableStateImpl) PopTasks() map[tasks.Category][]tasks.Task {
insterTasks := ms.InsertTasks
insertTasks := ms.InsertTasks
ms.InsertTasks = make(map[tasks.Category][]tasks.Task)
return insterTasks
return insertTasks
}
func (ms *MutableStateImpl) DeleteCHASMPureTasks(maxScheduledTime time.Time) {
for lastTaskIdx := len(ms.chasmPureTasks) - 1; lastTaskIdx >= 0; lastTaskIdx-- {
task := ms.chasmPureTasks[lastTaskIdx]
if !task.GetVisibilityTime().Before(maxScheduledTime) {
ms.chasmPureTasks = ms.chasmPureTasks[:lastTaskIdx+1]
return
}
ms.BestEffortDeleteTasks[tasks.CategoryTimer] = append(
ms.BestEffortDeleteTasks[tasks.CategoryTimer],
task.GetKey(),
)
}
// If we reach here, all tasks have visibility time before maxScheduledTime
// and need to be deleted.
ms.chasmPureTasks = ms.chasmPureTasks[:0]
}
func (ms *MutableStateImpl) SetUpdateCondition(

View File

@@ -5358,3 +5358,82 @@ func (s *mutableStateSuite) TestHasRequestID_EmptyExecutionState() {
s.False(s.mutableState.HasRequestID(requestID), "Should return false for request ID: %s", requestID)
}
}
func (s *mutableStateSuite) TestAddTasks_CHASMPureTask() {
s.mockConfig.ChasmMaxInMemoryPureTasks = dynamicconfig.GetIntPropertyFn(5)
totalTasks := 2 * s.mockConfig.ChasmMaxInMemoryPureTasks()
visTimestamp := s.mockShard.GetTimeSource().Now()
for i := 0; i < totalTasks; i++ {
task := &tasks.ChasmTaskPure{
VisibilityTimestamp: visTimestamp,
Category: tasks.CategoryTimer,
}
s.mutableState.AddTasks(task)
s.LessOrEqual(len(s.mutableState.chasmPureTasks), s.mockConfig.ChasmMaxInMemoryPureTasks())
visTimestamp = visTimestamp.Add(-time.Minute)
}
s.mockConfig.ChasmMaxInMemoryPureTasks = dynamicconfig.GetIntPropertyFn(2)
s.mutableState.AddTasks(&tasks.ChasmTaskPure{
VisibilityTimestamp: visTimestamp,
Category: tasks.CategoryTimer,
})
s.Len(s.mutableState.chasmPureTasks, 2)
}
func (s *mutableStateSuite) TestDeleteCHASMPureTasks() {
now := s.mockShard.GetTimeSource().Now()
testCases := []struct {
name string
maxScheduledTime time.Time
expectedRemaining int
}{
{
name: "none",
maxScheduledTime: now,
expectedRemaining: 3,
},
{
name: "paritial",
maxScheduledTime: now.Add(2 * time.Minute),
expectedRemaining: 2,
},
{
name: "all",
maxScheduledTime: now.Add(5 * time.Minute),
expectedRemaining: 0,
},
}
for _, tc := range testCases {
s.Run(tc.name, func() {
s.mutableState.chasmPureTasks = []*tasks.ChasmTaskPure{
{
VisibilityTimestamp: now.Add(3 * time.Minute),
Category: tasks.CategoryTimer,
},
{
VisibilityTimestamp: now.Add(2 * time.Minute),
Category: tasks.CategoryTimer,
},
{
VisibilityTimestamp: now.Add(time.Minute),
Category: tasks.CategoryTimer,
},
}
s.mutableState.BestEffortDeleteTasks = make(map[tasks.Category][]tasks.Key)
s.mutableState.DeleteCHASMPureTasks(tc.maxScheduledTime)
s.Len(s.mutableState.chasmPureTasks, tc.expectedRemaining)
for _, task := range s.mutableState.chasmPureTasks {
s.False(task.VisibilityTimestamp.Before(tc.maxScheduledTime))
}
s.Len(s.mutableState.BestEffortDeleteTasks[tasks.CategoryTimer], 3-tc.expectedRemaining)
})
}
}