Files
tasks/service/matching/matcher_data.go
David ReissandCarly de Frondeville 0078fa0f51 Consolidate default priority handling (#8483)
## What changed?
- Set default priority on all internalTasks after creation.
- Combine effective priority and poll forwarder flag into one value.
- For simplicity, default priority and level count are set at partition
load time.

## Why?
Tasks with unset priority key were not actually getting the default
value when read from backlog. This sets the default priority on all
sync, backlog, query, and nexus tasks. (Priority is not meaningful for
sync match tasks, and nexus has no priority yet, but they're set anyway
for consistency.) This will also make it easier to adjust the priority
for other purposes, e.g. prioritizing the old backlog for migrating
persistence.

## How did you test it?
- [x] built
- [x] run locally and tested manually
- [x] covered by existing tests
- [ ] added new unit test(s)
- [x] modified functional test
2025-10-17 14:22:19 -07:00

640 lines
20 KiB
Go

package matching
import (
"container/heap"
"context"
"slices"
"sync"
"time"
enumsspb "go.temporal.io/server/api/enums/v1"
"go.temporal.io/server/common/clock"
"go.temporal.io/server/common/log"
"go.temporal.io/server/common/softassert"
"go.temporal.io/server/common/util"
)
const (
invalidHeapIndex = -13 // use unusual value to stand out in panics
pollForwarderPriority = 1000000 // lower than any other priority. must be > maxPriorityLevels.
)
// maxTokens is the maximum number of tokens we might consume at a time for simpleLimiter. This
// is used to update ready times after a rate is changed from very low (or zero) to higher: we
// may have set a ready time far in the future and need to clip it to something reasonable so
// we can dispatch again.
//
// Currently we only use 1 token at a time.
const maxTokens = 1
type pollerPQ struct {
heap []*waitingPoller
}
// implements heap.Interface
func (p *pollerPQ) Len() int {
return len(p.heap)
}
// implements heap.Interface, do not call directly
func (p *pollerPQ) Less(i int, j int) bool {
a, b := p.heap[i], p.heap[j]
if !(a.isTaskForwarder || a.isTaskValidator) && (b.isTaskForwarder || b.isTaskValidator) {
return true
} else if (a.isTaskForwarder || a.isTaskValidator) && !(b.isTaskForwarder || b.isTaskValidator) {
return false
}
return a.startTime.Before(b.startTime)
}
func (p *pollerPQ) Add(poller *waitingPoller) {
heap.Push(p, poller)
}
func (p *pollerPQ) Remove(poller *waitingPoller) {
heap.Remove(p, poller.matchHeapIndex)
}
// implements heap.Interface, do not call directly
func (p *pollerPQ) Swap(i int, j int) {
p.heap[i], p.heap[j] = p.heap[j], p.heap[i]
p.heap[i].matchHeapIndex = i
p.heap[j].matchHeapIndex = j
}
// implements heap.Interface, do not call directly
func (p *pollerPQ) Push(x any) {
poller := x.(*waitingPoller) // nolint:revive
poller.matchHeapIndex = len(p.heap)
p.heap = append(p.heap, poller)
}
// implements heap.Interface, do not call directly
func (p *pollerPQ) Pop() any {
last := len(p.heap) - 1
poller := p.heap[last]
p.heap = p.heap[:last]
poller.matchHeapIndex = invalidHeapIndex
return poller
}
type taskPQ struct {
heap []*internalTask
// ages holds task create time for tasks from merged local backlogs (not forwarded).
// note that matcherData may get tasks from multiple versioned backlogs due to
// versioning redirection.
ages backlogAgeTracker
}
func (t *taskPQ) Add(task *internalTask) {
heap.Push(t, task)
}
func (t *taskPQ) Remove(task *internalTask) {
heap.Remove(t, task.matchHeapIndex)
}
// implements heap.Interface
func (t *taskPQ) Len() int {
return len(t.heap)
}
// implements heap.Interface, do not call directly
func (t *taskPQ) Less(i int, j int) bool {
// Overall priority key will eventually look something like:
// - ready time: to sort all ready tasks ahead of others, or else find the earliest ready task
// - effective priority key: to sort tasks by priority (including poll forwarder flag)
// - fairness key pass: to arrange tasks fairly by key
// - ordering key: to sort tasks by ordering key
// - task id: last resort comparison
a, b := t.heap[i], t.heap[j]
// TODO(pri): ready time is not task-specific yet, we only have whole-queue, so we don't
// need to consider this here yet.
// // ready time
// aready, bready := max(t.now, t.readyTimeForTask(a)), max(t.now, t.readyTimeForTask(b))
// if aready < bready {
// return true
// } else if aready > bready {
// return false
// }
// use effective priority
if a.effectivePriority < b.effectivePriority {
return true
} else if a.effectivePriority > b.effectivePriority {
return false
}
// Note: sync match tasks have a fixed negative id.
// Query tasks will get 0 here.
var alevel, blevel fairLevel
if a.event != nil && a.event.AllocatedTaskInfo != nil {
alevel = fairLevelFromAllocatedTask(a.event.AllocatedTaskInfo)
}
if b.event != nil && b.event.AllocatedTaskInfo != nil {
blevel = fairLevelFromAllocatedTask(b.event.AllocatedTaskInfo)
}
return alevel.less(blevel)
}
// implements heap.Interface, do not call directly
func (t *taskPQ) Swap(i int, j int) {
t.heap[i], t.heap[j] = t.heap[j], t.heap[i]
t.heap[i].matchHeapIndex = i
t.heap[j].matchHeapIndex = j
}
// implements heap.Interface, do not call directly
func (t *taskPQ) Push(x any) {
task := x.(*internalTask) // nolint:revive
task.matchHeapIndex = len(t.heap)
t.heap = append(t.heap, task)
if task.source == enumsspb.TASK_SOURCE_DB_BACKLOG && task.forwardInfo == nil {
t.ages.record(task.event.Data.CreateTime, 1)
}
}
// implements heap.Interface, do not call directly
func (t *taskPQ) Pop() any {
last := len(t.heap) - 1
task := t.heap[last]
t.heap = t.heap[:last]
task.matchHeapIndex = invalidHeapIndex
if task.source == enumsspb.TASK_SOURCE_DB_BACKLOG && task.forwardInfo == nil {
t.ages.record(task.event.Data.CreateTime, -1)
}
return task
}
// Calls pred on each task. If it returns true, call post on the task and remove it
// from the queue, otherwise keep it.
// pred and post must not make any other calls on taskPQ until ForEachTask returns!
func (t *taskPQ) ForEachTask(pred func(*internalTask) bool, post func(*internalTask)) {
t.heap = slices.DeleteFunc(t.heap, func(task *internalTask) bool {
if task.isPollForwarder() || !pred(task) {
return false
}
task.matchHeapIndex = invalidHeapIndex - 1 // maintain heap/index invariant
if task.source == enumsspb.TASK_SOURCE_DB_BACKLOG && task.forwardInfo == nil {
t.ages.record(task.event.Data.CreateTime, -1)
}
post(task)
return true
})
// re-establish heap
for i, task := range t.heap {
task.matchHeapIndex = i
}
heap.Init(t)
}
type matcherData struct {
config *taskQueueConfig
logger log.Logger
timeSource clock.TimeSource
canForward bool
rateLimitManager *rateLimitManager
lock sync.Mutex // covers everything below, and all fields in any waitableMatchResult
rateLimitTimer resettableTimer
reconsiderForwardTimer resettableTimer
// waiting pollers and tasks
// invariant: all pollers and tasks in these data structures have matchResult == nil
pollers pollerPQ
tasks taskPQ
lastPoller time.Time // most recent poll start time
}
func newMatcherData(config *taskQueueConfig, logger log.Logger, timeSource clock.TimeSource, canForward bool, rateLimitManager *rateLimitManager) matcherData {
return matcherData{
config: config,
logger: logger,
timeSource: timeSource,
canForward: canForward,
rateLimitManager: rateLimitManager,
tasks: taskPQ{
ages: newBacklogAgeTracker(),
},
}
}
func (d *matcherData) EnqueueTaskNoWait(task *internalTask) {
d.lock.Lock()
defer d.lock.Unlock()
task.initMatch(d)
d.tasks.Add(task)
d.findAndWakeMatches()
}
func (d *matcherData) RemoveTask(task *internalTask) {
d.lock.Lock()
defer d.lock.Unlock()
if task.matchHeapIndex >= 0 {
d.tasks.Remove(task)
}
}
func (d *matcherData) EnqueueTaskAndWait(ctxs []context.Context, task *internalTask) *matchResult {
d.lock.Lock()
defer d.lock.Unlock()
// add and look for match
task.initMatch(d)
d.tasks.Add(task)
d.findAndWakeMatches()
// if already matched, return
if task.matchResult != nil {
return task.matchResult
}
// arrange to wake up on context close
for i, ctx := range ctxs {
stop := context.AfterFunc(ctx, func() {
d.lock.Lock()
defer d.lock.Unlock()
if task.matchResult == nil {
d.tasks.Remove(task)
task.wake(d.logger, &matchResult{ctxErr: ctx.Err(), ctxErrIdx: i})
}
})
defer stop() // nolint:revive // there's only ever a small number of contexts
}
return task.waitForMatch()
}
func (d *matcherData) ReenqueuePollerIfNotMatched(poller *waitingPoller) {
d.lock.Lock()
defer d.lock.Unlock()
if poller.matchResult == nil {
d.pollers.Add(poller)
d.findAndWakeMatches()
}
}
func (d *matcherData) EnqueuePollerAndWait(ctxs []context.Context, poller *waitingPoller) *matchResult {
d.lock.Lock()
defer d.lock.Unlock()
// update this for timeSinceLastPoll
d.lastPoller = util.MaxTime(d.lastPoller, poller.startTime)
// add and look for match
poller.initMatch(d)
d.pollers.Add(poller)
d.findAndWakeMatches()
// if already matched, return
if poller.matchResult != nil {
return poller.matchResult
}
// arrange to wake up on context close
for i, ctx := range ctxs {
stop := context.AfterFunc(ctx, func() {
d.lock.Lock()
defer d.lock.Unlock()
if poller.matchResult == nil {
// if poll was being forwarded, it would be absent from heap even though
// matchResult == nil
if poller.matchHeapIndex >= 0 {
d.pollers.Remove(poller)
}
poller.wake(d.logger, &matchResult{ctxErr: ctx.Err(), ctxErrIdx: i})
}
})
defer stop() // nolint:revive // there's only ever a small number of contexts
}
return poller.waitForMatch()
}
func (d *matcherData) MatchTaskImmediately(task *internalTask) (canSyncMatch, gotSyncMatch bool) {
d.lock.Lock()
defer d.lock.Unlock()
if !d.isBacklogNegligible() {
// To ensure better dispatch ordering, we block sync match when a significant backlog is present.
// Note that this check does not make a noticeable difference for history tasks, as they do not wait for a
// poller to become available. In presence of a backlog the chance of a poller being available when sync match
// request comes is almost zero.
// This check is mostly effective for the sync match requests that come from child partitions for spooled tasks.
return false, false
}
task.initMatch(d)
d.tasks.Add(task)
d.findAndWakeMatches()
// don't wait, check if match() picked this one already
if task.matchResult != nil {
return true, true
}
d.tasks.Remove(task)
return true, false
}
func (d *matcherData) ReprocessTasks(pred func(*internalTask) bool) []*internalTask {
d.lock.Lock()
defer d.lock.Unlock()
reprocess := make([]*internalTask, 0, len(d.tasks.heap))
d.tasks.ForEachTask(
pred,
func(task *internalTask) {
// for sync tasks: wake up waiters with a fake context error
// for backlog tasks: the caller should call finish()
task.wake(d.logger, &matchResult{ctxErr: errReprocessTask, ctxErrIdx: -1})
reprocess = append(reprocess, task)
},
)
return reprocess
}
// findMatch should return the highest priority task+poller match even if the per-task rate
// limit doesn't allow the task to be matched yet.
// call with lock held
// nolint:revive // will improve later
func (d *matcherData) findMatch(allowForwarding bool) (*internalTask, *waitingPoller) {
// TODO(pri): optimize so it's not O(d*n) worst case
// TODO(pri): this iterates over heap as slice, which isn't quite correct, but okay for now
for _, task := range d.tasks.heap {
if !allowForwarding && task.isPollForwarder() {
continue
}
for _, poller := range d.pollers.heap {
// can't match cases:
if poller.queryOnly && !task.isQuery() && !task.isPollForwarder() {
continue
} else if task.isPollForwarder() && poller.forwardCtx == nil {
continue
} else if poller.isTaskForwarder && !allowForwarding {
continue
} else if poller.isTaskValidator && task.forwardCtx != nil {
continue
}
return task, poller
}
}
return nil, nil
}
// call with lock held
func (d *matcherData) allowForwarding() (allowForwarding bool) {
// If there is a non-negligible backlog, we pause forwarding to make sure
// root and leaf partitions are treated equally and can process their
// backlog at the same rate. Stopping task forwarding, prevent poll
// forwarding as well (in presence of a backlog). This ensures all partitions
// receive polls and tasks at the same rate.
//
// Exception: we allow forward if this partition has not got any polls
// recently. This is helpful when there are very few pollers and they
// and they are all stuck in the wrong (root) partition. (Note that since
// frontend balanced the number of pending pollers per partition this only
// becomes an issue when the pollers are fewer than the partitions)
//
// If allowForwarding was false and changes to true due solely to the passage
// of time, then we should ensure that match() is called again so that
// pending tasks/polls can now be forwarded. When does that happen? if
// isBacklogNegligible changes from false to true, or if we no longer have
// recent polls.
//
// With time, backlog age gets larger, so isBacklogNegligible can go from
// true to false and not the other way, so that's safe. But it is possible
// that we no longer have recent polls. So we need to ensure that match() is
// called again in that case, using reconsiderForwardTimer.
if d.isBacklogNegligible() {
d.reconsiderForwardTimer.unset()
return true
}
delayToForwardingAllowed := d.config.MaxWaitForPollerBeforeFwd() - time.Since(d.lastPoller)
d.reconsiderForwardTimer.set(d.timeSource, d.rematchAfterTimer, delayToForwardingAllowed)
return delayToForwardingAllowed <= 0
}
// call with lock held
func (d *matcherData) findAndWakeMatches() {
allowForwarding := d.canForward && d.allowForwarding()
now := d.timeSource.Now().UnixNano()
// TODO(pri): for task-specific ready time, we need to do a full/partial re-heapify here
for {
// search for highest priority match
task, poller := d.findMatch(allowForwarding)
if task == nil || poller == nil {
// no more current matches, stop rate limit timer if was running
d.rateLimitTimer.unset()
return
}
// check ready time
delay := d.rateLimitManager.readyTimeForTask(task).delay(now)
d.rateLimitTimer.set(d.timeSource, d.rematchAfterTimer, delay)
if delay > 0 {
return // not ready yet, timer will call match later
}
// ready to signal match
d.tasks.Remove(task)
d.pollers.Remove(poller)
// TODO(pri): maybe we can allow tasks to have costs other than 1
d.rateLimitManager.consumeTokens(now, task, 1)
task.recycleToken = d.recycleToken
res := &matchResult{task: task, poller: poller}
task.wake(d.logger, res)
// for poll forwarder: skip waking poller, forwarder will call finishMatchAfterPollForward
if !task.isPollForwarder() {
poller.wake(d.logger, res)
}
// TODO(pri): consider having task forwarding work the same way, with a half-match,
// instead of full match and then pass forward result on response channel?
// TODO(pri): maybe consider leaving tasks and polls in the heap while forwarding and
// allow them to be matched locally while forwarded (and then cancel the forward)?
}
}
func (d *matcherData) recycleToken(task *internalTask) {
d.lock.Lock()
defer d.lock.Unlock()
now := d.timeSource.Now().UnixNano()
d.rateLimitManager.consumeTokens(now, task, -1)
d.findAndWakeMatches() // another task may be ready to match now
}
// called from timer
func (d *matcherData) rematchAfterTimer() {
d.lock.Lock()
defer d.lock.Unlock()
d.findAndWakeMatches()
}
func (d *matcherData) FinishMatchAfterPollForward(poller *waitingPoller, task *internalTask) {
d.lock.Lock()
defer d.lock.Unlock()
if poller.matchResult == nil {
poller.wake(d.logger, &matchResult{task: task, poller: poller})
}
}
// isBacklogNegligible returns true if the age of the task backlog is less than the threshold.
// call with lock held.
func (d *matcherData) isBacklogNegligible() bool {
t := d.tasks.ages.oldestTime()
return t.IsZero() || time.Since(t) < d.config.BacklogNegligibleAge()
}
func (d *matcherData) TimeSinceLastPoll() time.Duration {
d.lock.Lock()
defer d.lock.Unlock()
return time.Since(d.lastPoller)
}
// waitable match result:
type waitableMatchResult struct {
// these fields are under matcherData.lock even though they're embedded in other structs
matchCond sync.Cond
matchResult *matchResult
matchHeapIndex int // current heap index for easy removal
}
func (w *waitableMatchResult) initMatch(d *matcherData) {
w.matchCond.L = &d.lock
w.matchResult = nil
}
// call with matcherData.lock held.
// w.matchResult must be nil (can't call wake twice).
// w must not be in queues anymore.
func (w *waitableMatchResult) wake(logger log.Logger, res *matchResult) {
softassert.That(logger, w.matchResult == nil, "wake called twice")
softassert.That(logger, w.matchHeapIndex < 0, "wake called but still in heap")
w.matchResult = res
w.matchCond.Signal()
}
// call with matcherData.lock held
func (w *waitableMatchResult) waitForMatch() *matchResult {
for w.matchResult == nil {
w.matchCond.Wait()
}
return w.matchResult
}
// resettable timer:
type resettableTimer struct {
timer clock.Timer // AfterFunc timer
}
// set sets rt to call f after delay. set to <= 0 stops the timer.
func (rt *resettableTimer) set(ts clock.TimeSource, f func(), delay time.Duration) {
if delay <= 0 {
rt.unset()
} else if rt.timer == nil {
rt.timer = ts.AfterFunc(delay, f)
} else {
rt.timer.Reset(delay)
}
}
// unset stops the timer.
func (rt *resettableTimer) unset() {
if rt.timer != nil {
rt.timer.Stop()
rt.timer = nil
}
}
// simple limiter
// simpleLimiter and simpleLimiterParams implement a "GCRA" limiter.
// A simpleLimiter is "ready" if its value is <= now (as unix nanos).
type simpleLimiter int64 // ready time as unix nanos
type simpleLimiterParams struct {
interval time.Duration // ideal task spacing interval, or 0 for no limit (infinite), or -1 for zero limit
burst time.Duration // burst duration
}
const maxBurst = time.Minute
const simpleLimiterNever = simpleLimiter(7 << 60) // this is in the year 2225
func makeSimpleLimiterParams(rate float64, burstDuration time.Duration) simpleLimiterParams {
// 1e-9 would make interval overflow int64
if rate <= 1e-9 {
return simpleLimiterParams{
interval: time.Duration(-1),
}
}
return simpleLimiterParams{
interval: time.Duration(float64(time.Second) / rate),
burst: min(burstDuration, maxBurst),
}
}
func (p simpleLimiterParams) never() bool { return p.interval < 0 }
func (p simpleLimiterParams) limited() bool { return p.interval > 0 }
// delay returns the time until the limiter is ready.
// If the return value is <= 0 then the limiter can go now.
func (ready simpleLimiter) delay(now int64) time.Duration {
return time.Duration(int64(ready) - now)
}
// consume updates ready based on the current time and number of new tokens consumed.
func (ready simpleLimiter) consume(p simpleLimiterParams, now int64, tokens int64) simpleLimiter {
// This is a slight variation of the normal GCRA: instead of tracking the end of the
// allowed interval (the theoretical arrival time), ready tracks the beginning of it, and
// the end is ready + burst. To find the next ready time:
// - Add ready+burst to find the next theoretical arrival time.
// - If that's in the past, clip it at the current time.
// - Subtract burst to turn it back into a ready time.
// - Finally add the tokens we used.
//
// For intuition, consider that if if now is > ready by only a tiny amount, i.e. we're
// bursting, then the max takes ready+burst and we push up the ready time by the full
// interval. We can do this burst/interval times before it catches up and we're no longer
// ready.
//
// Alternatively, if now is > ready by more than burst, then we end up subtracting the full
// burst from now and adding one interval.
if p.never() {
return simpleLimiterNever
}
clippedReady := max(now, int64(ready)+p.burst.Nanoseconds()) - p.burst.Nanoseconds()
return simpleLimiter(clippedReady + tokens*p.interval.Nanoseconds())
}
// clip updates ready to an allowable range based on the given parameters.
func (ready simpleLimiter) clip(p simpleLimiterParams, now int64, maxTokens int64) simpleLimiter {
if p.never() {
return simpleLimiterNever
}
// If ready was set very far in the future (e.g. because the rate was zero), then we can
// clip it back to now + maxTokens*interval + burst.
maxDelay := maxTokens*p.interval.Nanoseconds() + p.burst.Nanoseconds()
return min(ready, simpleLimiter(now+maxDelay))
}