Skip to content

Commit c658860

Browse files
dnrcarlydf
authored andcommitted
Make fair task gc conditional on rangeid (#12283)
Add ConditionRangeID to CompleteTasksLessThanRequest and make CompleteTasksLessThan conditional on unchanged rangeid, just like CreateTasks. When fair task reader gc's tasks, it's not protected by write fencing like adding tasks. So there's a small race window: a node advances its ack level to C, but it's not persisted yet. Another node takes over and loads the previous ack level, A. It writes a task above A but below C, call it B. The first node now deletes below C, deleting B. Note that this only happens with fair queues because fifo queues always write above the rangeid. - [ ] built - [ ] run locally and tested manually - [x] covered by existing tests - [ ] added new unit test(s) - [ ] added new functional test(s) - [x] validated in tla+ model (not in repo yet)
1 parent 3835a89 commit c658860

10 files changed

Lines changed: 196 additions & 26 deletions

File tree

‎common/persistence/cassandra/matching_task_store_queue.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,16 @@ const (
7676
`AND task_id = ? ` +
7777
`IF range_id = ?`
7878

79+
templateCheckRangeIDQuery = `UPDATE tasks_v2 SET ` +
80+
`range_id = ? ` +
81+
`WHERE namespace_id = ? ` +
82+
`AND task_queue_name = ? ` +
83+
`AND task_queue_type = ? ` +
84+
`AND type = ? ` +
85+
`AND pass = 0 ` +
86+
`AND task_id = ? ` +
87+
`IF range_id = ?`
88+
7989
templateUpdateTaskQueueQueryWithTTLPart1 = `INSERT INTO tasks_v2 ` +
8090
`(namespace_id, task_queue_name, task_queue_type, type, pass, task_id) ` +
8191
`VALUES (?, ?, ?, ?, 0, ?) USING TTL ?`

‎common/persistence/cassandra/matching_task_store_v1.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ func (d *matchingTaskStoreV1) CompleteTasksLessThan(
187187
ctx context.Context,
188188
request *p.CompleteTasksLessThanRequest,
189189
) (int, error) {
190-
if request.ExclusiveMaxPass != 0 {
190+
if request.ExclusiveMaxPass != 0 || request.ConditionRangeID != 0 {
191191
return 0, serviceerror.NewInternal("invalid CompleteTasksLessThan request on queue")
192192
}
193193

‎common/persistence/cassandra/matching_task_store_v2.go‎

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -203,19 +203,48 @@ func (d *matchingTaskStoreV2) CompleteTasksLessThan(
203203
return 0, serviceerror.NewInternal("invalid CompleteTasksLessThan request on fair queue")
204204
}
205205

206-
query := d.Session.Query(
207-
templateCompleteTasksLessThanQuery_v2,
206+
rowType := rowTypeTaskInSubqueue(request.Subqueue)
207+
args := []any{
208208
request.NamespaceID,
209209
request.TaskQueueName,
210210
request.TaskType,
211-
rowTypeTaskInSubqueue(request.Subqueue),
211+
rowType,
212212
request.ExclusiveMaxPass,
213213
request.ExclusiveMaxTaskID,
214-
).WithContext(ctx)
215-
err := query.Exec()
214+
}
215+
216+
if request.ConditionRangeID == 0 {
217+
err := d.Session.Query(templateCompleteTasksLessThanQuery_v2, args...).WithContext(ctx).Exec()
218+
if err != nil {
219+
return 0, gocql.ConvertError("CompleteTasksLessThan", err)
220+
}
221+
return p.UnknownNumRowsAffected, nil
222+
}
223+
224+
// Tasks and task queue metadata are in the same partition, so we can make the delete
225+
// conditional on the range id with a batch.
226+
batch := d.Session.NewBatch(gocql.LoggedBatch).WithContext(ctx)
227+
batch.Query(templateCompleteTasksLessThanQuery_v2, args...)
228+
batch.Query(switchTasksTable(templateCheckRangeIDQuery, matchingTaskVersion2),
229+
request.ConditionRangeID,
230+
request.NamespaceID,
231+
request.TaskQueueName,
232+
request.TaskType,
233+
rowTypeTaskQueue,
234+
taskQueueTaskID,
235+
request.ConditionRangeID,
236+
)
237+
previous := make(map[string]any)
238+
applied, _, err := d.Session.MapExecuteBatchCAS(batch, previous)
216239
if err != nil {
217240
return 0, gocql.ConvertError("CompleteTasksLessThan", err)
218241
}
242+
if !applied {
243+
return 0, &p.ConditionFailedError{
244+
Msg: fmt.Sprintf("Failed to complete tasks. TaskQueue: %v, taskQueueType: %v, rangeID: %v, db rangeID: %v",
245+
request.TaskQueueName, request.TaskType, request.ConditionRangeID, previous["range_id"]),
246+
}
247+
}
219248
return p.UnknownNumRowsAffected, nil
220249
}
221250

‎common/persistence/data_interfaces.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,9 @@ type (
646646
ExclusiveMaxTaskID int64 // Tasks less than this ID will be completed
647647
Subqueue int
648648
Limit int // Limit on the max number of tasks that can be completed. Required param
649+
// If set, only delete if the task queue's range id matches this value, otherwise return
650+
// ConditionFailedError. Only supported for fair (v2) task queues.
651+
ConditionRangeID int64
649652
}
650653

651654
// CreateNamespaceRequest is used to create the namespace

‎common/persistence/sql/task_v1.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ func (m *sqlTaskManagerV1) CompleteTasksLessThan(
157157
ctx context.Context,
158158
request *persistence.CompleteTasksLessThanRequest,
159159
) (int, error) {
160-
if request.ExclusiveMaxPass != 0 {
160+
if request.ExclusiveMaxPass != 0 || request.ConditionRangeID != 0 {
161161
return 0, serviceerror.NewInternal("invalid CompleteTasksLessThan request on queue")
162162
}
163163

‎common/persistence/sql/task_v2.go‎

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -175,18 +175,47 @@ func (m *sqlTaskManagerV2) CompleteTasksLessThan(
175175
TaskPass: request.ExclusiveMaxPass,
176176
TaskID: request.ExclusiveMaxTaskID,
177177
}
178-
result, err := m.DB.DeleteFromTasksV2(ctx, sqlplugin.TasksFilterV2{
178+
filter := sqlplugin.TasksFilterV2{
179179
RangeHash: tqHash,
180180
TaskQueueID: tqId,
181181
ExclusiveMaxLevel: &exclusiveMaxLevel,
182182
Limit: &request.Limit,
183-
})
184-
if err != nil {
185-
return 0, serviceerror.NewUnavailable(err.Error())
186183
}
187-
nRows, err := result.RowsAffected()
184+
185+
if request.ConditionRangeID == 0 {
186+
result, err := m.DB.DeleteFromTasksV2(ctx, filter)
187+
if err != nil {
188+
return 0, serviceerror.NewUnavailable(err.Error())
189+
}
190+
nRows, err := result.RowsAffected()
191+
if err != nil {
192+
return 0, serviceerror.NewUnavailablef("rowsAffected returned error: %v", err)
193+
}
194+
return int(nRows), nil
195+
}
196+
197+
var nRows int64
198+
err = m.SqlStore.txExecute(ctx, "CompleteTasksLessThan", func(tx sqlplugin.Tx) error {
199+
// Lock task queue (subqueue zero holds the range id) and check range id first.
200+
queueID, queueHash := taskQueueIdAndHash(nidBytes, request.TaskQueueName, request.TaskType, persistence.SubqueueZero)
201+
if err := lockTaskQueue(ctx,
202+
tx,
203+
queueHash,
204+
queueID,
205+
request.ConditionRangeID,
206+
sqlplugin.MatchingTaskVersion2,
207+
); err != nil {
208+
return err
209+
}
210+
result, err := tx.DeleteFromTasksV2(ctx, filter)
211+
if err != nil {
212+
return err
213+
}
214+
nRows, err = result.RowsAffected()
215+
return err
216+
})
188217
if err != nil {
189-
return 0, serviceerror.NewUnavailablef("rowsAffected returned error: %v", err)
218+
return 0, err
190219
}
191220
return int(nRows), nil
192221
}

‎common/persistence/tests/task_queue_fair_task.go‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,56 @@ func (s *TaskQueueFairTaskSuite) TestCreateDelete_Range() {
152152
s.Nil(resp.NextPageToken)
153153
}
154154

155+
func (s *TaskQueueFairTaskSuite) TestCreateDeleteWithConditionRangeID() {
156+
rangeID := rand.Int63()
157+
taskQueue := s.createTaskQueue(rangeID)
158+
159+
var tasks []*persistencespb.AllocatedTaskInfo
160+
for pass := int64(1); pass <= 5; pass++ {
161+
tasks = append(tasks, s.randomTask(pass, pass))
162+
}
163+
_, err := s.taskManager.CreateTasks(s.ctx, &p.CreateTasksRequest{
164+
TaskQueueInfo: &p.PersistedTaskQueueInfo{RangeID: rangeID, Data: taskQueue},
165+
Tasks: tasks,
166+
})
167+
s.NoError(err)
168+
169+
completeLessThan := func(pass, conditionRangeID int64) error {
170+
_, err := s.taskManager.CompleteTasksLessThan(s.ctx, &p.CompleteTasksLessThanRequest{
171+
NamespaceID: s.namespaceID,
172+
TaskQueueName: s.taskQueueName,
173+
TaskType: s.taskQueueType,
174+
ExclusiveMaxPass: pass,
175+
ExclusiveMaxTaskID: 0,
176+
Limit: 10,
177+
ConditionRangeID: conditionRangeID,
178+
})
179+
return err
180+
}
181+
getTasks := func() []*persistencespb.AllocatedTaskInfo {
182+
resp, err := s.taskManager.GetTasks(s.ctx, &p.GetTasksRequest{
183+
NamespaceID: s.namespaceID,
184+
TaskQueue: s.taskQueueName,
185+
TaskType: s.taskQueueType,
186+
InclusiveMinPass: 1,
187+
InclusiveMinTaskID: 0,
188+
ExclusiveMaxTaskID: math.MaxInt64,
189+
PageSize: 10,
190+
})
191+
s.NoError(err)
192+
return resp.Tasks
193+
}
194+
195+
// matching range id: tasks are deleted
196+
s.NoError(completeLessThan(3, rangeID))
197+
protorequire.ProtoSliceEqual(s.T(), tasks[2:], getTasks())
198+
199+
// wrong range id: condition fails and nothing is deleted
200+
err = completeLessThan(5, rangeID-1)
201+
s.ErrorAs(err, new(*p.ConditionFailedError))
202+
protorequire.ProtoSliceEqual(s.T(), tasks[2:], getTasks())
203+
}
204+
155205
func (s *TaskQueueFairTaskSuite) createTaskQueue(rangeID int64) *persistencespb.TaskQueueInfo {
156206
taskQueueKind := enumspb.TaskQueueKind(rand.Int31n(int32(len(enumspb.TaskQueueKind_name)) + 1))
157207
taskQueue := s.randomTaskQueueInfo(taskQueueKind)

‎service/matching/db.go‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package matching
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"math"
78
"slices"
@@ -658,6 +659,11 @@ func (db *taskQueueDB) CompleteFairTasksLessThan(
658659
limit int,
659660
subqueue subqueueIndex,
660661
) (int, error) {
662+
// For fair tasks, we do an LWT to avoid issues with lost ownership, therefore we should
663+
// serialize this with other LWTs by holding db.lock to avoid timeouts or conflicts.
664+
db.Lock()
665+
defer db.Unlock()
666+
661667
n, err := db.store.CompleteTasksLessThan(ctx, &persistence.CompleteTasksLessThanRequest{
662668
NamespaceID: db.queue.NamespaceId(),
663669
TaskQueueName: db.queue.PersistenceName(),
@@ -666,8 +672,14 @@ func (db *taskQueueDB) CompleteFairTasksLessThan(
666672
ExclusiveMaxTaskID: exclusiveMaxLevel.id,
667673
Subqueue: int(subqueue),
668674
Limit: limit,
675+
// We might have lost ownership without knowing it. A new owner assigns passes
676+
// starting from the ack level it loaded, which may be below our in-memory ack level,
677+
// so an unconditional delete could delete the new owner's tasks.
678+
ConditionRangeID: db.rangeID,
669679
})
670-
if err != nil {
680+
// ConditionFailedError means we lost ownership: not a store failure, caller will unload.
681+
var conditionFailed *persistence.ConditionFailedError
682+
if err != nil && !errors.As(err, &conditionFailed) {
671683
db.logger.Error("Persistent store operation failure",
672684
tag.StoreOperationCompleteTasksLessThan,
673685
tag.Error(err),

‎service/matching/fair_task_reader.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,7 @@ func (tr *fairTaskReader) doGCAt(ackLevel fairLevel) (int, error) {
653653
n, err := tr.backlogMgr.db.CompleteFairTasksLessThan(ctx, ackLevel.inc(), batchSize, tr.subqueue)
654654
if err != nil {
655655
tr.logger.Warn("failed to gc tasks", tag.Error(err))
656+
tr.backlogMgr.signalIfFatal(err)
656657
}
657658
return n, err
658659
}

‎service/matching/matching_engine_test.go‎

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1826,9 +1826,6 @@ func (s *matchingEngineSuite) TestForceUnloadTaskQueue() {
18261826
}
18271827

18281828
func (s *matchingEngineSuite) TestMultipleEnginesActivitiesRangeStealing() {
1829-
if s.newMatcher {
1830-
s.T().Skip("test is flaky with new matcher")
1831-
}
18321829
runID := uuid.NewString()
18331830
workflowID := "workflow1"
18341831
workflowExecution := &commonpb.WorkflowExecution{RunId: runID, WorkflowId: workflowID}
@@ -1923,10 +1920,12 @@ func (s *matchingEngineSuite) TestMultipleEnginesActivitiesRangeStealing() {
19231920
}),
19241921
}, nil
19251922
}).AnyTimes()
1923+
// If tasks are lost, we'd poll forever. Give up after a deadline and check below.
1924+
pollDeadline := time.Now().Add(30 * time.Second)
19261925
for j := 0; j < iterations; j++ {
19271926
for p := 0; p < engineCount; p++ {
19281927
engine := engines[p]
1929-
for i := int64(0); i < taskCount; /* incremented explicitly to skip empty polls */ {
1928+
for i := int64(0); i < taskCount && time.Now().Before(pollDeadline); /* incremented explicitly to skip empty polls */ {
19301929
result, err := engine.PollActivityTaskQueue(context.Background(), &matchingservice.PollActivityTaskQueueRequest{
19311930
NamespaceId: namespaceID,
19321931
PollRequest: &workflowservice.PollActivityTaskQueueRequest{
@@ -1971,8 +1970,12 @@ func (s *matchingEngineSuite) TestMultipleEnginesActivitiesRangeStealing() {
19711970
e.Stop()
19721971
}
19731972

1974-
s.EqualValues(0, s.taskManager.getTaskCount(tlID))
19751973
totalTasks := taskCount * engineCount * iterations
1974+
s.Len(startedTasks, totalTasks, "some tasks were never dispatched")
1975+
if !s.newMatcher {
1976+
// new matcher does gc lazily so some acked tasks may remain
1977+
s.EqualValues(0, s.taskManager.getTaskCount(tlID))
1978+
}
19761979
persisted := s.taskManager.getCreateTaskCount(tlID)
19771980
// No sync matching as all messages are published first
19781981
s.EqualValues(totalTasks, persisted)
@@ -1982,9 +1985,6 @@ func (s *matchingEngineSuite) TestMultipleEnginesActivitiesRangeStealing() {
19821985
}
19831986

19841987
func (s *matchingEngineSuite) TestMultipleEnginesWorkflowTasksRangeStealing() {
1985-
if s.newMatcher {
1986-
s.T().Skip("test is flaky with new matcher")
1987-
}
19881988
runID := uuid.NewString()
19891989
workflowID := "workflow1"
19901990
workflowExecution := &commonpb.WorkflowExecution{RunId: runID, WorkflowId: workflowID}
@@ -2068,10 +2068,12 @@ func (s *matchingEngineSuite) TestMultipleEnginesWorkflowTasksRangeStealing() {
20682068
}, nil
20692069
}).AnyTimes()
20702070

2071+
// If tasks are lost, we'd poll forever. Give up after a deadline and check below.
2072+
pollDeadline := time.Now().Add(30 * time.Second)
20712073
for j := 0; j < iterations; j++ {
20722074
for p := 0; p < engineCount; p++ {
20732075
engine := engines[p]
2074-
for i := int64(0); i < taskCount; /* incremented explicitly to skip empty polls */ {
2076+
for i := int64(0); i < taskCount && time.Now().Before(pollDeadline); /* incremented explicitly to skip empty polls */ {
20752077
result, err := engine.PollWorkflowTaskQueue(context.Background(), &matchingservice.PollWorkflowTaskQueueRequest{
20762078
NamespaceId: namespaceID,
20772079
PollRequest: &workflowservice.PollWorkflowTaskQueueRequest{
@@ -2115,8 +2117,12 @@ func (s *matchingEngineSuite) TestMultipleEnginesWorkflowTasksRangeStealing() {
21152117
e.Stop()
21162118
}
21172119

2118-
s.EqualValues(0, s.taskManager.getTaskCount(tlID))
21192120
totalTasks := taskCount * engineCount * iterations
2121+
s.Len(startedTasks, totalTasks, "some tasks were never dispatched")
2122+
if !s.newMatcher {
2123+
// new matcher does gc lazily so some acked tasks may remain
2124+
s.EqualValues(0, s.taskManager.getTaskCount(tlID))
2125+
}
21202126
persisted := s.taskManager.getCreateTaskCount(tlID)
21212127
// No sync matching as all messages are published first
21222128
s.EqualValues(totalTasks, persisted)
@@ -3476,8 +3482,18 @@ func (s *matchingEngineSuite) concurrentPublishAndConsumeValidateBacklogCounter(
34763482
wg.Wait()
34773483

34783484
ptqMgr := s.getPhysicalTaskQueueManagerImplFromKey(ptq)
3479-
dbTasks := int64(s.taskManager.getTaskCount(ptq))
34803485
backlogCount := totalApproximateBacklogCount(ptqMgr.backlogMgr)
3486+
3487+
// Only count tasks above the ack level: acked tasks may not have been gc'ed yet.
3488+
db := ptqMgr.backlogMgr.getDB()
3489+
db.Lock()
3490+
ackLevel := fairLevel{id: db.subqueues[subqueueZero].AckLevel}
3491+
if s.fairness {
3492+
ackLevel = fairLevelFromProto(db.subqueues[subqueueZero].FairAckLevel)
3493+
}
3494+
db.Unlock()
3495+
dbTasks := int64(s.taskManager.getTaskCountAbove(ptq, ackLevel))
3496+
34813497
if s.fairness {
34823498
// Relax this condition for fairBacklogManager: it can sometimes reset backlog count on
34833499
// read, making it more accurate in theory, but breaking this test's assumptions.
@@ -4920,6 +4936,20 @@ func (m *testTaskManager) minTaskID(dbq *PhysicalTaskQueueKey) (int64, bool) {
49204936
return key.id, ok
49214937
}
49224938

4939+
// getTaskCountAbove returns the number of tasks in a task queue above the given level.
4940+
func (m *testTaskManager) getTaskCountAbove(q *PhysicalTaskQueueKey, level fairLevel) int {
4941+
tlm := m.getQueueDataByKey(q)
4942+
tlm.Lock()
4943+
defer tlm.Unlock()
4944+
count := 0
4945+
for _, k := range tlm.tasks.Keys() {
4946+
if level.less(k.(fairLevel)) {
4947+
count++
4948+
}
4949+
}
4950+
return count
4951+
}
4952+
49234953
// maxTaskID returns the maximum value of the TaskID present in testTaskManager
49244954
func (m *testTaskManager) maxTaskID(dbq *PhysicalTaskQueueKey) (int64, bool) {
49254955
tlm := m.getQueueDataByKey(dbq)
@@ -4936,7 +4966,7 @@ func (m *testTaskManager) CompleteTasksLessThan(
49364966
) (int, error) {
49374967
if m.fairness && request.ExclusiveMaxPass < 1 {
49384968
return 0, serviceerror.NewInternal("invalid CompleteTasksLessThan request on fair queue")
4939-
} else if !m.fairness && request.ExclusiveMaxPass != 0 {
4969+
} else if !m.fairness && (request.ExclusiveMaxPass != 0 || request.ConditionRangeID != 0) {
49404970
return 0, serviceerror.NewInternal("invalid CompleteTasksLessThan request on queue")
49414971
}
49424972

@@ -4946,6 +4976,12 @@ func (m *testTaskManager) CompleteTasksLessThan(
49464976
tlm := m.getQueueData(request.TaskQueueName, request.NamespaceID, request.TaskType)
49474977
tlm.Lock()
49484978
defer tlm.Unlock()
4979+
if request.ConditionRangeID != 0 && tlm.rangeID != request.ConditionRangeID {
4980+
return 0, &persistence.ConditionFailedError{
4981+
Msg: fmt.Sprintf("CompleteTasksLessThan failed, range id mismatch. rangeID: %v, db rangeID: %v",
4982+
request.ConditionRangeID, tlm.rangeID),
4983+
}
4984+
}
49494985
keys := tlm.tasks.Keys()
49504986
for _, key := range keys {
49514987
level := key.(fairLevel)

0 commit comments

Comments
 (0)