-
Notifications
You must be signed in to change notification settings - Fork 1.8k
dynamic partitioning: use probability of AddTask to root partition to estimate overall add task rate #11699
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
dynamic partitioning: use probability of AddTask to root partition to estimate overall add task rate #11699
Changes from all commits
71c2dbf
6c395d5
095eb9d
4014f36
9a4ee05
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| package matching | ||
|
|
||
| import ( | ||
| "math" | ||
| "math/rand" | ||
| "sync" | ||
|
|
||
|
|
@@ -18,21 +19,26 @@ import ( | |
| // when another partition has encoded-greater-than-zero backlog. 2x feels like a good ratio. | ||
| var readPartitionWeightFloor = number.DecodeCompact8(1) | ||
|
|
||
| // Keep a small root sample for estimating total write rate when backlog-aware routing would | ||
| // otherwise make the root probability too small. | ||
| const writePartitionRootProbabilityFloor = 0.01 | ||
|
|
||
| type ( | ||
| // LoadBalancer is the interface for implementers of | ||
| // component that distributes add/poll api calls across | ||
| // available task queue partitions when possible | ||
| LoadBalancer interface { | ||
| // PickWritePartition returns the task queue partition for adding | ||
| // an activity or workflow task. The input is the name of the | ||
| // PickWritePartition returns the task queue partition for adding an | ||
| // activity or workflow task and the estimated number of tasks added | ||
| // across all partitions per root task. The input is the name of the | ||
| // original task queue (with no partition info). When forwardedFrom | ||
| // is non-empty, this call is forwardedFrom from a child partition | ||
| // to a parent partition in which case, no load balancing should be | ||
| // performed | ||
| PickWritePartition( | ||
| taskQueue *tqid.TaskQueue, | ||
| pc PartitionCounts, | ||
| ) *tqid.NormalPartition | ||
| ) (*tqid.NormalPartition, int) | ||
|
|
||
| // PickReadPartition returns the task queue partition to send a poller to. | ||
| // Input is name of the original task queue as specified by caller. When | ||
|
|
@@ -86,14 +92,19 @@ func NewLoadBalancer( | |
| func (lb *defaultLoadBalancer) PickWritePartition( | ||
| taskQueue *tqid.TaskQueue, | ||
| pc PartitionCounts, | ||
| ) *tqid.NormalPartition { | ||
| ) (*tqid.NormalPartition, int) { | ||
| if n, ok := testhooks.Get(lb.testHooks, testhooks.MatchingLBForceWritePartition, namespace.ID(taskQueue.NamespaceId())); ok { | ||
| return taskQueue.NormalPartition(n) | ||
| partition := taskQueue.NormalPartition(n) | ||
| if partition.IsRoot() { | ||
| return partition, 1 | ||
| } | ||
| // probability of reaching root is 0, so root can't know how many tasks were added overall | ||
| return partition, 0 | ||
| } | ||
|
|
||
| nsName, err := lb.namespaceIDToName(namespace.ID(taskQueue.NamespaceId())) | ||
| if err != nil { | ||
| return taskQueue.RootPartition() | ||
| return taskQueue.RootPartition(), 1 | ||
| } | ||
|
|
||
| var partitionCount int | ||
|
|
@@ -103,22 +114,29 @@ func (lb *defaultLoadBalancer) PickWritePartition( | |
| partitionCount = max(1, lb.nWritePartitions(nsName.String(), taskQueue.Name(), taskQueue.TaskType())) | ||
| } | ||
|
|
||
| return taskQueue.NormalPartition(pickWritePartitionByGap( | ||
| partitionID, estimatedTasksAllPartitions := pickWritePartitionByGap( | ||
| pc.BacklogCount, | ||
| partitionCount, | ||
| number.DecodeCompact8(pc.BacklogCap), | ||
| )) | ||
| ) | ||
| partition := taskQueue.NormalPartition(partitionID) | ||
|
|
||
| return partition, estimatedTasksAllPartitions | ||
| } | ||
|
|
||
| // pickWritePartitionByGap picks a partition with probability proportional to how far its backlog | ||
| // is below backlogCap. Falls back to uniform random if any of these are true: | ||
| // - every partition is at or above the backlogCap | ||
| // - backlogCap is 0 | ||
| // - when backlog data is not available for all write partitions | ||
| func pickWritePartitionByGap(counts []number.Compact8, partitionCount int, backlogCap int64) int { | ||
| func pickWritePartitionByGap( | ||
| counts []number.Compact8, | ||
| partitionCount int, | ||
| backlogCap int64, | ||
| ) (partitionID int, estimatedTasksAllPartitions int) { | ||
| if backlogCap == 0 || | ||
| len(counts) < partitionCount { | ||
| return rand.Intn(partitionCount) | ||
| return rand.Intn(partitionCount), partitionCount | ||
| } | ||
|
|
||
| var total int64 | ||
|
|
@@ -128,20 +146,43 @@ func pickWritePartitionByGap(counts []number.Compact8, partitionCount int, backl | |
| } | ||
| } | ||
| if total <= 0 { // all partitions are at or above cap | ||
| return rand.Intn(partitionCount) | ||
| return rand.Intn(partitionCount), partitionCount | ||
| } | ||
| r := rand.Int63n(total) | ||
| for i := range partitionCount { | ||
| gap := backlogCap - number.DecodeCompact8(counts[i]) | ||
| if gap <= 0 { // this partition is at or above cap | ||
| continue | ||
|
|
||
| // if rootProbability < 0.01, choose the root with p=0.01 | ||
| rootGap := max(int64(0), backlogCap-number.DecodeCompact8(counts[0])) | ||
| rootProbability := float64(rootGap) / float64(total) | ||
| if rootProbability < writePartitionRootProbabilityFloor { | ||
| if rand.Float64() < writePartitionRootProbabilityFloor { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No metric when the root-probability floor activates If root backlog starts growing unexpectedly in production, an operator today would have to read the source code and manually calculate |
||
| return 0, int(math.Round(1 / writePartitionRootProbabilityFloor)) | ||
| } | ||
| // choose between non-root partitions according to gap from backlog cap | ||
| partitionID := pickPartitionByGap(counts[1:partitionCount], backlogCap, total-rootGap) + 1 | ||
| return partitionID, int(math.Round(1 / writePartitionRootProbabilityFloor)) | ||
| } | ||
|
|
||
| return pickPartitionByGap(counts[:partitionCount], backlogCap, total), randomRound(1 / rootProbability) | ||
| } | ||
|
|
||
| // randomRound rounds without biasing the expected value. | ||
| func randomRound(x float64) int { | ||
| n := math.Floor(x) | ||
| if rand.Float64() < x-n { | ||
| n++ | ||
| } | ||
| return int(n) | ||
| } | ||
|
|
||
| func pickPartitionByGap(counts []number.Compact8, backlogCap int64, total int64) int { | ||
| r := rand.Int63n(total) | ||
| for i, count := range counts { | ||
| gap := max(int64(0), backlogCap-number.DecodeCompact8(count)) | ||
| if r < gap { // more likely to be true the bigger this partition's gap is | ||
| return i | ||
| } | ||
| r -= gap | ||
| } | ||
| return partitionCount - 1 // unreachable in practice; guard against compact8 rounding | ||
| return len(counts) - 1 | ||
| } | ||
|
|
||
| // PickReadPartition picks a partition for poller to poll task from, and keeps load balanced between partitions. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,6 +15,24 @@ import ( | |
| "google.golang.org/grpc/metadata" | ||
| ) | ||
|
|
||
| func TestEstimatedTasksAllPartitionsMetadata(t *testing.T) { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. could delete this |
||
| for _, estimatedTasksAllPartitions := range []int{1, 2, 100, 102} { | ||
| outgoingCtx := appendEstimatedTasksAllPartitions(context.Background(), estimatedTasksAllPartitions, true) | ||
| md, ok := metadata.FromOutgoingContext(outgoingCtx) | ||
| require.True(t, ok) | ||
| incomingCtx := metadata.NewIncomingContext(context.Background(), md) | ||
| require.Equal(t, estimatedTasksAllPartitions, ParseEstimatedTasksAllPartitions(incomingCtx)) | ||
| } | ||
|
|
||
| for _, estimatedTasksAllPartitions := range []int{0, -1} { | ||
| outgoingCtx := appendEstimatedTasksAllPartitions(context.Background(), estimatedTasksAllPartitions, true) | ||
| md, ok := metadata.FromOutgoingContext(outgoingCtx) | ||
| require.False(t, ok) | ||
| incomingCtx := metadata.NewIncomingContext(context.Background(), md) | ||
| require.Zero(t, ParseEstimatedTasksAllPartitions(incomingCtx)) | ||
| } | ||
| } | ||
|
|
||
| // setTrailerInOpts finds the grpc.TrailerCallOption in opts and populates it. | ||
| func setTrailerInOpts(opts []grpc.CallOption, pc PartitionCounts) { | ||
| v, _ := pc.encode(true) // trailer path (server -> client) includes backlog info | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Root check uses wrong partition
Low Severity
appendEstimatedTasksAllPartitionsis gated onp.IsRoot(), butpis still the pre-balance root partition.pickClientForWriteonly reassigns its localpwhen choosing a write partition, so the caller always sees the original root. The estimate header is therefore attached on every load-balanced write, including requests sent to non-root partitions, even though the value is only meaningful for root samples.Additional Locations (2)
client/matching/client.go#L214-L215client/matching/client.go#L325-L326Reviewed by Cursor Bugbot for commit 9a4ee05. Configure here.