Skip to content

Handle task queue dispatch rate limiting in WCI autoscaling - #64

Draft
smuneebahmad wants to merge 9 commits into
mainfrom
muneeb/tq-dispatch-rl
Draft

Handle task queue dispatch rate limiting in WCI autoscaling#64
smuneebahmad wants to merge 9 commits into
mainfrom
muneeb/tq-dispatch-rl

Conversation

@smuneebahmad

@smuneebahmad smuneebahmad commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

What was changed

Extend WCI to correctly handle task queue dispatch rate limiting for Lambda compute providers. Rate-limited events based on task queue dispatch rate, were previously incorrectly treated as "no worker available" and triggered worker invocations.
This change classifies rate-limited events separately using the new SyncMatchOutcome signal from PR #10045, preventing spurious Lambda invocations when rate limiting is the bottleneck.

The rate_based algorithm (GCP Cloud Run) will be tracked as a follow-up change.

Why?

Adding more workers when the rate limiting is the bottleneck does nothing. Before this change, WCI couldn't distinguish "no worker available" from "worker available but rate-limited", so it scaled up in both cases unnecessarily.

Checklist

  1. Closes: https://temporalio.atlassian.net/browse/COM-125

  2. How was this tested:

  • Unit tests cover signal classification, and scale-up suppression
    go test ./wci/client/... ./wci/workflow/scaling_algorithm/... -v -count=1 -timeout=60s
    
  • E2E validated on a test cell with a 1 RPS dispatch cap

@smuneebahmad
smuneebahmad marked this pull request as ready for review June 25, 2026 21:46
@smuneebahmad
smuneebahmad requested a review from a team as a code owner June 25, 2026 21:46
@smuneebahmad
smuneebahmad requested a review from 02strich June 25, 2026 22:07
Comment thread wci/client/hook.go Outdated
TaskQueueName: th.taskQueueName,
TaskQueueType: th.taskQueueType,
IsSyncMatch: event.IsSyncMatch,
IsSyncMatch: noSyncMatchBatchCount == 0, // backward compatibility: true when no genuine not-matched events so old WCI workers don't scale up

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this really the same? I am a bit confused why the change is needed as part of the PR

Comment thread wci/client/hook.go Outdated
Comment thread wci/workflow/iface/workflow.go Outdated
Comment on lines +48 to +54
// configNoSyncRateLimitedSuppressQuietMsKey: in ProcessMetricsPoll, suppress scale-up for this many ms
// after the last observed rate-limited signal. 0 = disabled.
// Default is 2× the poll interval.
configNoSyncRateLimitedSuppressQuietMsKey = "rate_limited_suppress_quiet_ms"
configNoSyncRateLimitedSuppressQuietMsDefault = 2 * configNoSyncMetricsPollIntervalMsDefault

stateLastRateLimitedTimestampKey = "last_rate_limited_time_ms"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is this needed for?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added more explanation around how these values are used to adjust ProcessMetricsPoll to handle TQ dispatch rate limiting.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The explanation helps, but now I am unclear whether you are leveraging an implementation detail here. Did you confirm that these rate limit events will be created reliably?

Comment thread wci/workflow/scaling_algorithm/no_sync_match.go Outdated
Comment thread wci/workflow/scaling_algorithm/no_sync_match.go Outdated
@smuneebahmad
smuneebahmad force-pushed the muneeb/tq-dispatch-rl branch from 67a83bb to 231c21d Compare June 26, 2026 00:08
@smuneebahmad
smuneebahmad force-pushed the muneeb/tq-dispatch-rl branch from 231c21d to 4859762 Compare June 26, 2026 00:11
@smuneebahmad
smuneebahmad force-pushed the muneeb/tq-dispatch-rl branch from 58f5b1e to 27e03e5 Compare June 26, 2026 00:50
Comment thread wci/workflow/iface/workflow.go Outdated
Comment on lines +157 to +158
// Rate-limited events (worker is available but there's no task handoff due to rate-limiting) are a
// distinct outcome tracked separately in RateLimitedSignalsSinceLast.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

invert the sentence structure and move it down to be the comment above RateLimitedSignalsSinceLast?

updatedState[stateRateLimitedCountSinceLastPollKey] = prevCount + int64(event.RateLimitedSignalsSinceLast)
}

return &TaskAddResponse{Actions: actions, Status: updatedState, ThrottledCount: throttledCount, RateLimitedCount: rateLimitedCount}, nil

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we return the input data RateLimitedCount here? do you expect the algo to change the number?

Comment on lines +48 to +54
// configNoSyncRateLimitedSuppressQuietMsKey: in ProcessMetricsPoll, suppress scale-up for this many ms
// after the last observed rate-limited signal. 0 = disabled.
// Default is 2× the poll interval.
configNoSyncRateLimitedSuppressQuietMsKey = "rate_limited_suppress_quiet_ms"
configNoSyncRateLimitedSuppressQuietMsDefault = 2 * configNoSyncMetricsPollIntervalMsDefault

stateLastRateLimitedTimestampKey = "last_rate_limited_time_ms"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The explanation helps, but now I am unclear whether you are leveraging an implementation detail here. Did you confirm that these rate limit events will be created reliably?

@smuneebahmad
smuneebahmad marked this pull request as draft July 13, 2026 06:09
Comment thread wci/client/hook.go Outdated
Comment on lines +143 to +144
// In older or existing workflows, handle scenario when SyncMatchOutcome
// is not available in the hooks API

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that "In older or existing workflows" immediately is out-of-date because there is no indication of what "currently" is. Better to be specific here. Something like "SyncMatchOutcome was added in server release X. Here, we handle situations for workflows that were started before that server release."

Comment thread wci/client/hook.go Outdated
}

func (th *taskHookImpl) batchMatchSignals(_ context.Context, workflowID string, isSyncMatch bool) (int, int, bool) {
func (th *taskHookImpl) batchMatchSignals(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function could really use a docstring, especially with the many unrelated input and output parameters.

Especially curious is the "skip" parameter. I honestly have no idea what that is doing, even after reading through the Claude-generated unit tests.

And, come to think of it maybe rename the function to storeTaskAddSignalResults or something like that? "batch" is an implementation detail and "match" doesn't have anything to do with the the signal (no sync match is just one of multiple signals, no?)

configNoSyncMetricsPollIntervalMsKey = "metrics_poll_interval_ms"
configNoSyncMetricsPollIntervalMsDefault = int64(60_000) // 60s

// configNoSyncRateLimitedSuppressQuietMsKey is the duration to block backlog-driven scale-up

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"suppress quiet"? I don't know what Claude/Cursor is attempting to convey with that. Sounds like gibberish to me.

Questions:

  1. Is this something that really needs to be configurable?
  2. "backlog-driven scale-up after rate limiting is no longer actively signalled" sounds similarly obtuse to me. Would it be clearer to say "This is the duration that WCI will wait after encountering rate-limiting attempting to invoke a serverless worker"?

@smuneebahmad
smuneebahmad force-pushed the muneeb/tq-dispatch-rl branch 3 times, most recently from 15652d6 to fbd5f93 Compare August 6, 2026 02:42
@smuneebahmad
smuneebahmad force-pushed the muneeb/tq-dispatch-rl branch from fbd5f93 to 6659cf2 Compare August 6, 2026 15:32
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Merging this branch will not change overall coverage

Impacted Packages Coverage Δ 🤖
github.com/temporalio/temporal-auto-scaled-workers/cmd/worker 0.00% (ø)
github.com/temporalio/temporal-auto-scaled-workers/tests/integration 0.00% (ø)
github.com/temporalio/temporal-auto-scaled-workers/wci 0.00% (ø)
github.com/temporalio/temporal-auto-scaled-workers/wci/client 0.00% (ø)
github.com/temporalio/temporal-auto-scaled-workers/wci/metrics 0.00% (ø)
github.com/temporalio/temporal-auto-scaled-workers/wci/workercomponent 0.00% (ø)
github.com/temporalio/temporal-auto-scaled-workers/wci/workflow 0.00% (ø)
github.com/temporalio/temporal-auto-scaled-workers/wci/workflow/compute_provider 0.00% (ø)
github.com/temporalio/temporal-auto-scaled-workers/wci/workflow/iface 0.00% (ø)
github.com/temporalio/temporal-auto-scaled-workers/wci/workflow/scaling_algorithm 0.00% (ø)

Coverage by file

Changed files (no unit tests)

Changed File Coverage Δ Total Covered Missed 🤖
github.com/temporalio/temporal-auto-scaled-workers/cmd/worker/main.go 0.00% (ø) 0 0 0
github.com/temporalio/temporal-auto-scaled-workers/wci/client/fx.go 0.00% (ø) 0 0 0
github.com/temporalio/temporal-auto-scaled-workers/wci/client/hook.go 0.00% (ø) 0 0 0
github.com/temporalio/temporal-auto-scaled-workers/wci/client/workflow_interaction.go 0.00% (ø) 0 0 0
github.com/temporalio/temporal-auto-scaled-workers/wci/fx.go 0.00% (ø) 0 0 0
github.com/temporalio/temporal-auto-scaled-workers/wci/metrics/metric_defs.go 0.00% (ø) 0 0 0
github.com/temporalio/temporal-auto-scaled-workers/wci/workercomponent/fx.go 0.00% (ø) 0 0 0
github.com/temporalio/temporal-auto-scaled-workers/wci/workflow/activities.go 0.00% (ø) 0 0 0
github.com/temporalio/temporal-auto-scaled-workers/wci/workflow/compute_provider/aws.go 0.00% (ø) 0 0 0
github.com/temporalio/temporal-auto-scaled-workers/wci/workflow/compute_provider/aws_ecs.go 0.00% (ø) 0 0 0
github.com/temporalio/temporal-auto-scaled-workers/wci/workflow/compute_provider/k8s.go 0.00% (ø) 0 0 0
github.com/temporalio/temporal-auto-scaled-workers/wci/workflow/iface/spec.go 0.00% (ø) 0 0 0
github.com/temporalio/temporal-auto-scaled-workers/wci/workflow/iface/spec_update.go 0.00% (ø) 0 0 0
github.com/temporalio/temporal-auto-scaled-workers/wci/workflow/iface/workflow.go 0.00% (ø) 0 0 0
github.com/temporalio/temporal-auto-scaled-workers/wci/workflow/scaling_algorithm/no_sync_match.go 0.00% (ø) 0 0 0
github.com/temporalio/temporal-auto-scaled-workers/wci/workflow/scaling_metrics_snapshot.go 0.00% (ø) 0 0 0

Please note that the "Total", "Covered", and "Missed" counts above refer to code statements instead of lines of code. The value in brackets refers to the test coverage of that file in the old version of the code.

Changed unit test files

  • github.com/temporalio/temporal-auto-scaled-workers/tests/integration/wci_test.go
  • github.com/temporalio/temporal-auto-scaled-workers/tests/integration/worker_deployment_test.go
  • github.com/temporalio/temporal-auto-scaled-workers/wci/client/hook_test.go
  • github.com/temporalio/temporal-auto-scaled-workers/wci/workflow/activities_test.go
  • github.com/temporalio/temporal-auto-scaled-workers/wci/workflow/scaling_algorithm/no_sync_match_test.go
  • github.com/temporalio/temporal-auto-scaled-workers/wci/workflow/scaling_algorithm/rate_based_test.go
  • github.com/temporalio/temporal-auto-scaled-workers/wci/workflow/scaling_algorithm/safe_activity_logger_test.go

Comment thread wci/workflow/scaling_metrics_snapshot.go
Comment thread wci/workflow/scaling_algorithm/no_sync_match.go
@mani-j9

mani-j9 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

I ran a continuous load test of 5 fast activities every 10 seconds, one wci instance here for reference.

mani@manis-MacBook-Pro hello-temporal-go % RL_DURATION_SEC=600 RL_INTERVAL_SEC=10 RL_ACTIVITIES=5 go run ./starter/ratelimit_continuous_main.go
2026/08/07 12:42:12 continuous load: every 10s, 5 fast activities, until 2026-08-07T12:52:12-07:00 (queue mani-serverless-longrun-timeout-test)
2026/08/07 12:43:03 injected 6 workflows so far (~30 activity tasks)
2026/08/07 12:44:03 injected 12 workflows so far (~60 activity tasks)
2026/08/07 12:45:03 injected 18 workflows so far (~90 activity tasks)
2026/08/07 12:46:04 injected 24 workflows so far (~120 activity tasks)
2026/08/07 12:47:04 injected 30 workflows so far (~150 activity tasks)
2026/08/07 12:48:04 injected 36 workflows so far (~180 activity tasks)
2026/08/07 12:49:04 injected 42 workflows so far (~210 activity tasks)
2026/08/07 12:50:05 injected 48 workflows so far (~240 activity tasks)
2026/08/07 12:51:05 injected 54 workflows so far (~270 activity tasks)
2026/08/07 12:52:05 injected 60 workflows so far (~300 activity tasks)
2026/08/07 12:52:15 DONE: injected 60 workflows x 5 fast activities over 600s

I configured the task queue rps to be very low at 0.02 rps

--profile ns-compute-mani-aws task-queue config get --task-queue $TQ --task-queue-type activity  
Note: Long content may be truncated. Use --output json for full details.
      Setting        Value    Reason                  UpdatedBy                      UpdatedTime     
  Queue Rate Limit  0.02 rps          temporal-cli:mani@manis-MacBook-Pro.local  2026-08-07T19:05:29Z
mani@manis-MacBook-Pro hello-temporal-go % 

and fired many more InvokeWorker than 0.02 rps, I'll check more on why matching didn't rate limit here because it should as per 0.02 rps.
image

one other observation is that a task signal sometimes comes with both the conditions met which in our code transforms to scale up and log that it was rate limited which is confusing and i'm also wondering if we should even scale up in that case?

    {
      "eventId": "180",
      "eventTime": "2026-08-07T17:47:32.681038788Z",
      "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED",
      "version": "8196",
      "taskId": "8390849",
      "workflowExecutionSignaledEventAttributes": {
        "signalName": "task-add-signal",
        "input": {
          "payloads": [
            {
              "metadata": {
                "encoding": "json/plain"
              },
              "data": {
                "task_queue_name": "mani-serverless-longrun-timeout-test",
                "task_queue_type": 2,
                "is_sync_match": false,
                "no_sync_match_signals_batched": 8,
                "rate_limited_signals_batched": 3
              }
            }
          ]
        }
      }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants