Skip to content

Snapshot task queue families at the worker-deployment level - #11952

Open
Shivs11 wants to merge 1 commit into
mainfrom
shivam/worker-deployment-tq-family-bloom
Open

Snapshot task queue families at the worker-deployment level#11952
Shivs11 wants to merge 1 commit into
mainfrom
shivam/worker-deployment-tq-family-bloom

Conversation

@Shivs11

@Shivs11 Shivs11 commented Sep 7, 2026

Copy link
Copy Markdown
Member

What changed?

  • Add an exact task queue family count and a serialized Bloom filter to each v3 Worker Deployment Version summary.
  • Have the Version workflow publish a fresh snapshot when a new task queue family is registered.
  • Add a Deployment workflow update validator that rejects a definitely new task queue at the configured limit before the registration update is accepted. Bloom hits still reach the Version workflow, whose exact state remains authoritative.
  • Add focused unit and integration coverage and a v3 replay-history corpus containing the new summary signals and persisted Continue-As-New marker.
  • Keep the production workflow-version dynamic-config default at v2 for a separate rollout.

Why?

#11347 allows a new task queue type to register under an existing family name at the family limit. This follow-up lets the Deployment workflow distinguish that allowed case from a definitely new family, avoiding update acceptance and child workflow traffic when the limit is already known to be exceeded.

How did you test it?

  • unit tests, integration tests, replay tests.

Potential risks and rollout

  • Bloom false positives only preserve the legacy child-update path; they cannot bypass the Version workflow's exact limit check.
  • Roll out workflow version 3 cell by cell through dynamic config, validating each cell before proceeding to the next one.

Note

Medium Risk
Changes worker deployment registration validation and workflow state/proto fields; incorrect Bloom handling or mixed-version rollout could affect limit enforcement until v3 is enabled cell-by-cell via dynamic config.

Overview
Introduces workflow version 3 (TaskQueueFamilySummary) so the Deployment workflow can tell a definitely new task queue family from an existing one at the family limit, and reject registration updates before they reach the Version workflow.

Version summaries now carry a TaskQueueFamilySummary (exact family count plus a serialized Bloom filter of family names). The Version workflow publishes that snapshot when a new family is registered and performs a one-time bootstrap after an active v2 run continues-as-new into v3 (tracked with task_queue_family_summary_signal_sent). RegisterWorkerInWorkerDeployment gains an update validator that fails fast when count is at the limit and the Bloom filter says the name is absent; Bloom false positives and missing summaries still defer to the Version workflow’s exact check.

Adds the bits-and-blooms/bloom dependency, unit/replay/integration tests, and a v3 replay history corpus. Production workflow version default remains v2 for separate rollout.

Reviewed by Cursor Bugbot for commit 9a608da. Bugbot is set up for automated code reviews on this repo. Configure here.

@Shivs11
Shivs11 requested review from a team as code owners September 7, 2026 23:00
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Shivs11's task in 8m 45s —— View job


Reviewed 55 files, 7 findings.
· branch shivam/worker-deployment-tq-family-bloom

@Shivs11 Shivs11 changed the title Avoid accepting task queue family registrations over the version limit Snapshot task queue families at the worker-deployment level Sep 7, 2026
Comment on lines +112 to +123
if tc.wantSignalCnt > 0 {
env.OnSignalExternalWorkflow(
mock.Anything,
GenerateDeploymentWorkflowID(tv.DeploymentSeries()),
"",
SyncVersionSummarySignal,
mock.Anything,
).Return(func(namespace string, workflowID string, runID string, signalName string, arg any) error {
capturedSummary = arg.(*deploymentspb.WorkerDeploymentVersionSummary)
return nil
}).Once()
}

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.

med — The "later v3 run" case is vacuous: no signal mock is registered, so capturedSummary is nil regardless of what the workflow does.

capturedSummary is only ever assigned from inside the OnSignalExternalWorkflow return func, and that mock is registered only when tc.wantSignalCnt > 0. In the summarySent: true case the mock is skipped entirely, so require.Nil(t, capturedSummary) at line 160 holds even if run() did send a bootstrap summary — the test env just handles the unmocked SignalExternalWorkflow itself. The guard against re-bootstrapping is the whole point of that case, and nothing currently exercises it.

Register the mock unconditionally with .Maybe() so the capture is live in both cases (env.AssertExpectations then still covers the positive case via the assertion on the captured summary).

Suggestion:

Suggested change
if tc.wantSignalCnt > 0 {
env.OnSignalExternalWorkflow(
mock.Anything,
GenerateDeploymentWorkflowID(tv.DeploymentSeries()),
"",
SyncVersionSummarySignal,
mock.Anything,
).Return(func(namespace string, workflowID string, runID string, signalName string, arg any) error {
capturedSummary = arg.(*deploymentspb.WorkerDeploymentVersionSummary)
return nil
}).Once()
}
env.OnSignalExternalWorkflow(
mock.Anything,
GenerateDeploymentWorkflowID(tv.DeploymentSeries()),
"",
SyncVersionSummarySignal,
mock.Anything,
).Return(func(namespace string, workflowID string, runID string, signalName string, arg any) error {
capturedSummary = arg.(*deploymentspb.WorkerDeploymentVersionSummary)
return nil
}).Maybe()

Comment on lines +394 to +403
describeResponse, err := env.FrontendClient().DescribeWorkflowExecution(
s.Context(),
&workflowservice.DescribeWorkflowExecutionRequest{
Namespace: env.Namespace().String(),
Execution: &commonpb.WorkflowExecution{WorkflowId: deploymentWorkflowID},
},
)
s.Require().NoError(err)
deploymentWorkflowRunID := describeResponse.GetWorkflowExecutionInfo().GetExecution().GetRunId()
s.Require().NotEmpty(deploymentWorkflowRunID)

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.

med — The "update was never accepted" assertion can pass vacuously: the run ID is captured before the poll, and the update ID is re-derived from a duplicated production format.

This history scan is the only assertion covering the PR's headline behaviour (registration rejected at the validator, no child-update traffic), and two things let it pass without checking anything:

  1. deploymentWorkflowRunID is resolved before pollFromDeploymentExpectFail. The Deployment workflow continues-as-new whenever its state changes, so if a CAN happens between here and the poll, the scan runs over a run that could never have contained the event.
  2. updateID (line 411) reproduces the format from client.go:355 by hand. If that format ever changes, accepted.GetProtocolInstanceId() == updateID stops matching and the test goes green while the behaviour regresses.

For (1), move the DescribeWorkflowExecution call to after pollFromDeploymentExpectFail. For (2), consider adding a positive control — assert the same derivation does find the accepted event for the first, successful registration — so a format drift fails the test instead of silencing it.

Suggestion: Resolve deploymentWorkflowRunID after the failed poll rather than before it.

Comment on lines +720 to +722
if err := d.validateRegisterWorker(args); err != nil {
return err
}

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.

small — Re-running the validator inside the handler is unexplained, and when it fires it produces exactly the accepted-then-failed update this PR removes.

The same function is already wired as the update validator at line 371, so on the normal path this call is dead. It is only reachable when a SyncVersionSummarySignal lands between validation and the handler acquiring d.lock — and in that window it converts a would-be rejection into an accepted update that fails, writing the update-accepted/completed events the PR is trying to avoid. It also runs after the defer d.setStateChanged(), so a rejection here still dirties the workflow and forces a CaN.

Either drop it and let the child update be the backstop, or keep it with a comment naming the window it covers.

Suggestion:

Suggested change
if err := d.validateRegisterWorker(args); err != nil {
return err
}
// The validator ran before the update was accepted; re-check here because a
// SyncVersionSummarySignal may have advanced the version summary while this
// handler waited for the lock.
if err := d.validateRegisterWorker(args); err != nil {
return err
}

Comment on lines +19 to +29
filter := bloom.NewWithEstimates(uint(len(taskQueueFamilies)), taskQueueFamilyBloomFalsePositiveRate)
for _, taskQueueName := range workflow.DeterministicKeys(taskQueueFamilies) {
filter.AddString(taskQueueName)
}

serializedFilter, err := filter.MarshalBinary()
if err != nil {
return nil
}
summary.BloomFilter = serializedFilter
return summary

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.

small — Dead error branch that silently discards the exact count, plus an unnecessary DeterministicKeys sort.

bloom.BloomFilter.MarshalBinary serializes into an in-memory buffer and has no failure mode, so return nil is unreachable. If it somehow were reached, dropping the whole summary also drops Count, which is independently useful, and nothing is logged — the Deployment workflow just silently fails open forever.

Separately, Bloom insertion is order-independent: the resulting bit set is identical for any iteration order, so workflow.DeterministicKeys buys no determinism here and just adds an allocation and a sort.

Suggestion:

Suggested change
filter := bloom.NewWithEstimates(uint(len(taskQueueFamilies)), taskQueueFamilyBloomFalsePositiveRate)
for _, taskQueueName := range workflow.DeterministicKeys(taskQueueFamilies) {
filter.AddString(taskQueueName)
}
serializedFilter, err := filter.MarshalBinary()
if err != nil {
return nil
}
summary.BloomFilter = serializedFilter
return summary
filter := bloom.NewWithEstimates(uint(len(taskQueueFamilies)), taskQueueFamilyBloomFalsePositiveRate)
for taskQueueName := range taskQueueFamilies {
filter.AddString(taskQueueName)
}
serializedFilter, err := filter.MarshalBinary()
if err != nil {
return summary
}
summary.BloomFilter = serializedFilter
return summary

Comment on lines +1094 to +1096
func (d *VersionWorkflowRunner) versionStateToSummary(
s *deploymentspb.VersionLocalState,
) *deploymentspb.WorkerDeploymentVersionSummary {

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.

small — Method versionStateToSummary has the same name as the package-level function it wraps.

d.versionStateToSummary(s) and versionStateToSummary(s) now sit a few lines apart in the same file and differ only by the receiver. At every call site the reader has to check for a leading d. to know whether the task queue family summary is included.

Suggestion:

Suggested change
func (d *VersionWorkflowRunner) versionStateToSummary(
s *deploymentspb.VersionLocalState,
) *deploymentspb.WorkerDeploymentVersionSummary {
func (d *VersionWorkflowRunner) summaryForCurrentWorkflowVersion(
s *deploymentspb.VersionLocalState,
) *deploymentspb.WorkerDeploymentVersionSummary {

// Compute status for this version. Synced from the version workflow when WCI signals a status change.
temporal.api.deployment.v1.ComputeStatus compute_status = 14;

TaskQueueFamilySummary task_queue_family_summary = 15;

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.

nit — Undocumented proto field; every sibling in this message has a comment.

Worth noting here that the summary is a snapshot published by the version workflow and may lag its exact state, since that is what makes the Deployment-side check safe to fail open.

Suggestion:

Suggested change
TaskQueueFamilySummary task_queue_family_summary = 15;
// Snapshot of the version's registered task queue families, published by the version
// workflow. May lag the version workflow's exact state.
TaskQueueFamilySummary task_queue_family_summary = 15;

Comment on lines +42 to +48
func TestBuildTaskQueueFamilySummary_Empty(t *testing.T) {
t.Parallel()

summary := buildTaskQueueFamilySummary(nil)
require.Equal(t, int32(0), summary.GetCount())
require.Empty(t, summary.GetBloomFilter())
}

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.

nit — Underscore in the test name, and the empty case is the same behaviour as the case above it.

The two other tests in this file are already table-driven; folding the empty map in as a {name: "no families"} row keeps the style consistent and drops the Test_-style suffix, which the repo guidelines avoid.

Suggestion: Fold this into TestBuildTaskQueueFamilySummary as a table case rather than a separate _Empty function.

testCases := []struct {
name string
summarySent bool
wantSignalCnt int

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.

nitwantSignalCnt int only ever holds 0 or 1.

Every use is a > 0 / == 0 test, so the count adds nothing over a bool.

Suggestion:

Suggested change
wantSignalCnt int
wantSignal bool

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.

1 participant