Skip to content

Commit b2fab54

Browse files
authored
Add intra-namespace percentage rollout for CHASM Nexus operation creation (#10950)
## What changed? Adds a per-namespace **percentage** control for routing new workflow-triggered Nexus operations to the CHASM implementation, on top of the existing boolean `nexusoperation.enableChasmWorkflowOperations` flag. - New dynamic config `nexusoperation.chasmWorkflowOperationsRolloutPercent` (int, 0–100), **default 0**. - The HSM→CHASM creation decision is centralized in one predicate, `nexusoperation.UseChasmForWorkflow(enabled, rolloutPercent, namespaceName, workflowID)`: an operation is created on the CHASM tree only when the boolean flag is on **AND** the workflow falls within the rollout percentage. Membership is decided by the shared `RolloutAccepts` helper hashing `namespace + workflowID` (same key shape as the Scheduler CHASM rollout), so a given workflow deterministically lands on the same implementation across all of its operations and dialing the percentage up is monotonic. - **Both** framework-decision sites use the same predicate: - **Live creation** — the CHASM `ScheduleNexusOperation` command handler (`handleScheduleCommand`). Out-of-rollout → `ErrCommandNotSupported`, so the operation is created on the HSM tree as before. - **Reset / replication rebuild** — `MutableStateRebuilder.applyChasmEvent`, on the `NexusOperationScheduled` create event. Sharing the exact predicate is required: otherwise a reset could flip an out-of-rollout workflow's operations onto CHASM. - **Cancelation is intentionally not gated** by this predicate. It is routed by the tree that already owns the operation, so an operation created while the flag/percentage was higher can still be canceled after a downgrade. ## Why? The per-namespace boolean is too coarse for a safe migration: turning it on sends *all* of a namespace's new Nexus operations to CHASM at once. A percentage control lets us dial CHASM adoption up gradually *within* a namespace and roll back by dialing down — the de-risking control our rollout plan requires. The default of 0 makes the boolean alone a no-op until the percentage is explicitly raised, so enabling the flag can't cause an all-at-once cutover. ## How did you test it? - [x] built - [ ] run locally and tested manually - [x] covered by existing tests - [x] added new unit test(s) - [ ] added new functional test(s) ## Potential risks Low, and gated behind the default. Default 0 means behavior is unchanged for any namespace that hasn't set the percentage — enabling the boolean alone now routes nothing to CHASM until the percentage is raised. The one behavioral consequence: any test/config that enables the boolean expecting CHASM must also set the percentage (done for the tests in the repo); no production config force-enables the boolean today.
1 parent dc5b517 commit b2fab54

8 files changed

Lines changed: 202 additions & 88 deletions

File tree

chasm/lib/nexusoperation/config.go

Lines changed: 52 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,29 @@ var EnableChasmWorkflowOperations = dynamicconfig.NewNamespaceBoolSetting(
4242
CHASM-based implementation of Nexus will be used when scheduling new Nexus Operations.`,
4343
)
4444

45+
var ChasmWorkflowOperationsRolloutPercent = dynamicconfig.NewNamespaceIntSetting(
46+
"nexusoperation.chasmWorkflowOperationsRolloutPercent",
47+
0,
48+
`Per-namespace percentage [0,100] of workflows whose new Nexus Operations are created on the CHASM-based
49+
implementation instead of the legacy HSM-based implementation. This setting is only consulted when
50+
enableChasmWorkflowOperations is true and is re-evaluated on every ScheduleNexusOperation command. Membership is
51+
decided by a stable hash of the namespace and workflow ID, so a given workflow consistently lands on the same
52+
implementation across all of its operations and dialing the percentage up is monotonic. Defaults to 0 (conservative
53+
dial-up model): even when enableChasmWorkflowOperations is on, no operations are routed to CHASM until the percentage
54+
is explicitly dialed up.`,
55+
)
56+
57+
// UseChasmForWorkflow reports whether new Nexus operations for a workflow
58+
// should be created on CHASM. Live creation and rebuild must share this
59+
// predicate so reset keeps workflows in the same rollout bucket.
60+
func UseChasmForWorkflow(enabled bool, rolloutPercent int, namespaceName, workflowID string) bool {
61+
if !enabled {
62+
return false
63+
}
64+
key := fmt.Appendf(nil, "%s\x00%s", namespaceName, workflowID)
65+
return dynamicconfig.RolloutAccepts(key, rolloutPercent)
66+
}
67+
4568
var RequestTimeout = dynamicconfig.NewDestinationDurationSetting(
4669
"nexusoperation.request.timeout",
4770
time.Second*10,
@@ -218,40 +241,42 @@ Added for safety. Defaults to true. Likely to be removed in future server versio
218241
)
219242

220243
type Config struct {
221-
Enabled dynamicconfig.BoolPropertyFnWithNamespaceFilter
222-
EnableChasm dynamicconfig.BoolPropertyFnWithNamespaceFilter
223-
EnableChasmNexusWorkflowOperations dynamicconfig.BoolPropertyFnWithNamespaceFilter
224-
NumHistoryShards int32
225-
LongPollBuffer dynamicconfig.DurationPropertyFnWithNamespaceFilter
226-
LongPollTimeout dynamicconfig.DurationPropertyFnWithNamespaceFilter
227-
RequestTimeout dynamicconfig.DurationPropertyFnWithDestinationFilter
228-
MinRequestTimeout dynamicconfig.DurationPropertyFnWithNamespaceFilter
229-
MaxConcurrentOperationsPerWorkflow dynamicconfig.IntPropertyFnWithNamespaceFilter
230-
MaxServiceNameLength dynamicconfig.IntPropertyFnWithNamespaceFilter
231-
MaxOperationNameLength dynamicconfig.IntPropertyFnWithNamespaceFilter
232-
MaxOperationTokenLength dynamicconfig.IntPropertyFnWithNamespaceFilter
233-
MaxOperationHeaderSize dynamicconfig.IntPropertyFnWithNamespaceFilter
234-
DisallowedOperationHeaders dynamicconfig.TypedPropertyFn[[]string]
235-
MaxOperationScheduleToCloseTimeout dynamicconfig.DurationPropertyFnWithNamespaceFilter
236-
PayloadSizeLimit dynamicconfig.IntPropertyFnWithNamespaceFilter
237-
CallbackURLTemplate dynamicconfig.TypedPropertyFn[*template.Template]
238-
UseSystemCallbackURL dynamicconfig.BoolPropertyFn
239-
PayloadSizeLimitWarn dynamicconfig.IntPropertyFnWithNamespaceFilter
240-
MaxUserMetadataSummarySize dynamicconfig.IntPropertyFnWithNamespaceFilter
241-
MaxUserMetadataDetailsSize dynamicconfig.IntPropertyFnWithNamespaceFilter
242-
UseNewFailureWireFormat dynamicconfig.BoolPropertyFnWithNamespaceFilter
243-
RecordCancelRequestCompletionEvents dynamicconfig.BoolPropertyFn
244-
VisibilityMaxPageSize dynamicconfig.IntPropertyFnWithNamespaceFilter
245-
MaxIDLengthLimit dynamicconfig.IntPropertyFn
246-
MaxReasonLength dynamicconfig.IntPropertyFnWithNamespaceFilter
247-
RetryPolicy func() backoff.RetryPolicy
244+
Enabled dynamicconfig.BoolPropertyFnWithNamespaceFilter
245+
EnableChasm dynamicconfig.BoolPropertyFnWithNamespaceFilter
246+
EnableChasmNexusWorkflowOperations dynamicconfig.BoolPropertyFnWithNamespaceFilter
247+
ChasmNexusWorkflowOperationsRolloutPercent dynamicconfig.IntPropertyFnWithNamespaceFilter
248+
NumHistoryShards int32
249+
LongPollBuffer dynamicconfig.DurationPropertyFnWithNamespaceFilter
250+
LongPollTimeout dynamicconfig.DurationPropertyFnWithNamespaceFilter
251+
RequestTimeout dynamicconfig.DurationPropertyFnWithDestinationFilter
252+
MinRequestTimeout dynamicconfig.DurationPropertyFnWithNamespaceFilter
253+
MaxConcurrentOperationsPerWorkflow dynamicconfig.IntPropertyFnWithNamespaceFilter
254+
MaxServiceNameLength dynamicconfig.IntPropertyFnWithNamespaceFilter
255+
MaxOperationNameLength dynamicconfig.IntPropertyFnWithNamespaceFilter
256+
MaxOperationTokenLength dynamicconfig.IntPropertyFnWithNamespaceFilter
257+
MaxOperationHeaderSize dynamicconfig.IntPropertyFnWithNamespaceFilter
258+
DisallowedOperationHeaders dynamicconfig.TypedPropertyFn[[]string]
259+
MaxOperationScheduleToCloseTimeout dynamicconfig.DurationPropertyFnWithNamespaceFilter
260+
PayloadSizeLimit dynamicconfig.IntPropertyFnWithNamespaceFilter
261+
CallbackURLTemplate dynamicconfig.TypedPropertyFn[*template.Template]
262+
UseSystemCallbackURL dynamicconfig.BoolPropertyFn
263+
PayloadSizeLimitWarn dynamicconfig.IntPropertyFnWithNamespaceFilter
264+
MaxUserMetadataSummarySize dynamicconfig.IntPropertyFnWithNamespaceFilter
265+
MaxUserMetadataDetailsSize dynamicconfig.IntPropertyFnWithNamespaceFilter
266+
UseNewFailureWireFormat dynamicconfig.BoolPropertyFnWithNamespaceFilter
267+
RecordCancelRequestCompletionEvents dynamicconfig.BoolPropertyFn
268+
VisibilityMaxPageSize dynamicconfig.IntPropertyFnWithNamespaceFilter
269+
MaxIDLengthLimit dynamicconfig.IntPropertyFn
270+
MaxReasonLength dynamicconfig.IntPropertyFnWithNamespaceFilter
271+
RetryPolicy func() backoff.RetryPolicy
248272
}
249273

250274
func configProvider(dc *dynamicconfig.Collection, cfg *config.Persistence) *Config {
251275
return &Config{
252276
Enabled: Enabled.Get(dc),
253277
EnableChasm: dynamicconfig.EnableChasm.Get(dc),
254278
EnableChasmNexusWorkflowOperations: EnableChasmWorkflowOperations.Get(dc),
279+
ChasmNexusWorkflowOperationsRolloutPercent: ChasmWorkflowOperationsRolloutPercent.Get(dc),
255280
NumHistoryShards: cfg.NumHistoryShards,
256281
LongPollBuffer: LongPollBuffer.Get(dc),
257282
LongPollTimeout: LongPollTimeout.Get(dc),

chasm/lib/workflow/nexus_commands.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,12 @@ func (ch *nexusCommandHandler) handleScheduleCommand(
3535
ns := ctx.NamespaceEntry()
3636
nsName := ns.Name().String()
3737

38-
if !ch.config.EnableChasmNexusWorkflowOperations(nsName) {
38+
// Use the shared rollout predicate. If it rejects, let HSM create the operation.
39+
if !nexusoperation.UseChasmForWorkflow(
40+
ch.config.EnableChasmNexusWorkflowOperations(nsName),
41+
ch.config.ChasmNexusWorkflowOperationsRolloutPercent(nsName),
42+
nsName, ctx.ExecutionKey().BusinessID,
43+
) {
3944
return ErrCommandNotSupported
4045
}
4146

chasm/lib/workflow/nexus_commands_test.go

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,16 +54,21 @@ func (tcx *testContext) setHasAnyBufferedEvent(value bool) {
5454
}
5555

5656
var defaultConfig = &nexusoperation.Config{
57-
EnableChasmNexusWorkflowOperations: dynamicconfig.GetBoolPropertyFnFilteredByNamespace(true),
58-
MaxServiceNameLength: dynamicconfig.GetIntPropertyFnFilteredByNamespace(len("service")),
59-
MaxOperationNameLength: dynamicconfig.GetIntPropertyFnFilteredByNamespace(len("op")),
60-
MaxConcurrentOperationsPerWorkflow: dynamicconfig.GetIntPropertyFnFilteredByNamespace(2),
61-
MaxOperationHeaderSize: dynamicconfig.GetIntPropertyFnFilteredByNamespace(20),
62-
DisallowedOperationHeaders: dynamicconfig.GetTypedPropertyFn([]string{"request-timeout"}),
63-
MaxOperationScheduleToCloseTimeout: dynamicconfig.GetDurationPropertyFnFilteredByNamespace(time.Hour * 24),
57+
EnableChasmNexusWorkflowOperations: dynamicconfig.GetBoolPropertyFnFilteredByNamespace(true),
58+
ChasmNexusWorkflowOperationsRolloutPercent: dynamicconfig.GetIntPropertyFnFilteredByNamespace(100),
59+
MaxServiceNameLength: dynamicconfig.GetIntPropertyFnFilteredByNamespace(len("service")),
60+
MaxOperationNameLength: dynamicconfig.GetIntPropertyFnFilteredByNamespace(len("op")),
61+
MaxConcurrentOperationsPerWorkflow: dynamicconfig.GetIntPropertyFnFilteredByNamespace(2),
62+
MaxOperationHeaderSize: dynamicconfig.GetIntPropertyFnFilteredByNamespace(20),
63+
DisallowedOperationHeaders: dynamicconfig.GetTypedPropertyFn([]string{"request-timeout"}),
64+
MaxOperationScheduleToCloseTimeout: dynamicconfig.GetDurationPropertyFnFilteredByNamespace(time.Hour * 24),
6465
}
6566

66-
func newTestContext(t *testing.T, cfg *nexusoperation.Config) testContext {
67+
func newTestContext(t *testing.T, cfg *nexusoperation.Config, workflowID ...string) testContext {
68+
wfID := ""
69+
if len(workflowID) > 0 {
70+
wfID = workflowID[0]
71+
}
6772
endpointReg := nexustest.FakeEndpointRegistry{
6873
OnGetByName: func(ctx context.Context, namespaceID namespace.ID, endpointName string) (*persistencespb.NexusEndpointEntry, error) {
6974
if endpointName == "endpoint caller namespace unauthorized" {
@@ -113,6 +118,7 @@ func newTestContext(t *testing.T, cfg *nexusoperation.Config) testContext {
113118
HandleExecutionKey: func() chasm.ExecutionKey {
114119
return chasm.ExecutionKey{
115120
NamespaceID: tests.GlobalNamespaceEntry.ID().String(),
121+
BusinessID: wfID,
116122
}
117123
},
118124
GoCtx: context.WithValue(context.Background(), nexusoperation.OperationContextKey, &nexusoperation.OperationContext{MetricTagConfig: dynamicconfig.GetTypedPropertyFn(nexusoperation.NexusMetricTagConfig{})}),
@@ -143,13 +149,63 @@ func newTestContext(t *testing.T, cfg *nexusoperation.Config) testContext {
143149
func TestHandleScheduleCommand(t *testing.T) {
144150
t.Run("chasm nexus not enabled", func(t *testing.T) {
145151
tcx := newTestContext(t, &nexusoperation.Config{
146-
EnableChasmNexusWorkflowOperations: dynamicconfig.GetBoolPropertyFnFilteredByNamespace(false),
152+
EnableChasmNexusWorkflowOperations: dynamicconfig.GetBoolPropertyFnFilteredByNamespace(false),
153+
ChasmNexusWorkflowOperationsRolloutPercent: dynamicconfig.GetIntPropertyFnFilteredByNamespace(100),
147154
})
148155
err := tcx.scheduleHandler(tcx.chasmCtx, tcx.wf, commandValidator{maxPayloadSize: 1}, &commandpb.Command{}, CommandHandlerOptions{WorkflowTaskCompletedEventID: 1})
149156
require.ErrorIs(t, err, ErrCommandNotSupported)
150157
require.Empty(t, tcx.history.Events)
151158
})
152159

160+
validScheduleCmd := &commandpb.Command{
161+
Attributes: &commandpb.Command_ScheduleNexusOperationCommandAttributes{
162+
ScheduleNexusOperationCommandAttributes: &commandpb.ScheduleNexusOperationCommandAttributes{
163+
Endpoint: "endpoint",
164+
Service: "service",
165+
Operation: "op",
166+
},
167+
},
168+
}
169+
170+
t.Run("rollout percent 0 routes all operations to HSM", func(t *testing.T) {
171+
cfg := *defaultConfig
172+
cfg.ChasmNexusWorkflowOperationsRolloutPercent = dynamicconfig.GetIntPropertyFnFilteredByNamespace(0)
173+
tcx := newTestContext(t, &cfg, "any-workflow")
174+
err := tcx.scheduleHandler(tcx.chasmCtx, tcx.wf, commandValidator{maxPayloadSize: 1}, validScheduleCmd, CommandHandlerOptions{WorkflowTaskCompletedEventID: 1})
175+
require.ErrorIs(t, err, ErrCommandNotSupported)
176+
require.Empty(t, tcx.history.Events)
177+
})
178+
179+
t.Run("rollout percent 100 routes all operations to CHASM", func(t *testing.T) {
180+
cfg := *defaultConfig
181+
cfg.ChasmNexusWorkflowOperationsRolloutPercent = dynamicconfig.GetIntPropertyFnFilteredByNamespace(100)
182+
tcx := newTestContext(t, &cfg, "any-workflow")
183+
err := tcx.scheduleHandler(tcx.chasmCtx, tcx.wf, commandValidator{maxPayloadSize: 1}, validScheduleCmd, CommandHandlerOptions{WorkflowTaskCompletedEventID: 1})
184+
require.NoError(t, err)
185+
require.Len(t, tcx.history.Events, 1)
186+
})
187+
188+
t.Run("rollout percent routes by workflow id", func(t *testing.T) {
189+
nsName := tests.GlobalNamespaceEntry.Name().String()
190+
const percent = 50
191+
// These IDs are pinned to opposite sides of the 50% rollout for this namespace.
192+
const insideID, outsideID = "wf-4", "wf-0"
193+
require.True(t, dynamicconfig.RolloutAccepts(fmt.Appendf(nil, "%s\x00%s", nsName, insideID), percent))
194+
require.False(t, dynamicconfig.RolloutAccepts(fmt.Appendf(nil, "%s\x00%s", nsName, outsideID), percent))
195+
196+
cfg := *defaultConfig
197+
cfg.ChasmNexusWorkflowOperationsRolloutPercent = dynamicconfig.GetIntPropertyFnFilteredByNamespace(percent)
198+
199+
scheduleFor := func(workflowID string) error {
200+
tcx := newTestContext(t, &cfg, workflowID)
201+
return tcx.scheduleHandler(tcx.chasmCtx, tcx.wf, commandValidator{maxPayloadSize: 1}, validScheduleCmd, CommandHandlerOptions{WorkflowTaskCompletedEventID: 1})
202+
}
203+
204+
// A workflow inside the rollout routes to CHASM; one outside falls back to HSM.
205+
require.NoError(t, scheduleFor(insideID))
206+
require.ErrorIs(t, scheduleFor(outsideID), ErrCommandNotSupported)
207+
})
208+
153209
t.Run("empty attributes", func(t *testing.T) {
154210
tcx := newTestContext(t, defaultConfig)
155211
err := tcx.scheduleHandler(tcx.chasmCtx, tcx.wf, commandValidator{maxPayloadSize: 1}, &commandpb.Command{}, CommandHandlerOptions{WorkflowTaskCompletedEventID: 1})

0 commit comments

Comments
 (0)