Sch v1 wf - #11821
Conversation
…0817) ## What changed? Adds `worker.schedulerV1VersionCeiling`, a per-namespace dynamic config that clamps the V1 scheduler workflow's recorded `TweakablePolicies.Version` to `min(current, ceiling)`. This will artificially gate the execution's functionality, to enable backwards-compatibility with historical server versions (in a cross-version multi-cluster setup). ## Why? In a cross-version multi-cluster topology, a newer cluster can write scheduler history an older rollback peer cannot replay after a failover plus rollback. Clamping the recorded version to a configured ceiling lets the newer cluster emit history the older cluster can replay. ## How did you test it? - [x] built - [x] added new unit test(s)
…ID (#11462) ### Summary Combines #11134 and #11427 as two distinct fixes affecting V1 schedules and requiring a version bump. they're joined together. Shipping them separately would require two separate version-bump deploys for the "same" version number. This merges both behavioral changes under one shared v13: - **`RefreshBeforeMigrationCheck`** (from #11134): This fixes a problem that was preventing v1->v2 migration from ever succeeding under default configuration - **`PreserveMigratedStartIDs`** (from #11427): Try and keep the requestIDs from when workflows are started under when a rollback occurs. - Adds a third guard while in this space: Guards against late migrations that occur when a transient error bounces a v1>v2 migration and the schedule goes back to sleep and then attempts to migrate again. Following #11134's two-phase-rollout rationale, this PR only teaches the scheduler to *understand* v13 for safe replay/rollback: both fixes are gated behind `hasMinVersion(13)`, but `CurrentTweakablePolicies.Version` stays at `TriggerImmediatelyTimestamp` (12). A follow-up deploy bumps `Version` to 13 to activate both fixes at once — a single activation instead of two. #### Details - `service/worker/scheduler/workflow.go`: adds `RefreshBeforeMigrationCheck` and `PreserveMigratedStartIDs`, both `= 13`, with a shared doc comment; adds a `// TODO` on `CurrentTweakablePolicies.Version` pointing at the follow-up activation deploy; ports both fixes' logic unchanged (gated on the respective constant). - `service/worker/scheduler/workflow_test.go`: ports all three new tests from the two source PRs (`TestAutoMigrateReconcilesRunningWorkflowBeforeCheck`, `TestMigratedBufferedStartPreservesIdempotencyIDs`, `TestMigratedBufferedStartUsesLegacyIDsAtOldVersion`) plus the `TestStart` `RequestId` assertion. `TestMigratedBufferedStartPreservesIdempotencyIDs` now force-sets `CurrentTweakablePolicies.Version` (mirroring the other two version-forcing tests), since `Version` no longer defaults to 13 here. - `service/worker/scheduler/testdata/replay_migration_v1_to_v2.json.gz` and `tests/schedule_migration_v1_to_v2_callback_compat_test.go`: brought in verbatim from #11134. ## 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) `go test -tags test_dep ./service/worker/scheduler/...` passes, including `TestReplays` against the copied fixture and all three new/updated unit tests. `go build`/`go vet` pass for `./service/worker/scheduler/...` and `./tests/...`. ## Potential risks Moderately high risk as this is touching the Schedule V1 code. A problem with nondeterminism could affect schedules quite badly. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: liam-lowe <56076876+liam-lowe@users.noreply.github.com> Co-authored-by: alex.stanfield <13949480+chaptersix@users.noreply.github.com> Co-authored-by: Stephan Behnke <stephanos@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: michaely520 <michaely520@users.noreply.github.com> Co-authored-by: Feiyang Xie <feiyang3cat@outlook.com> Co-authored-by: Kannan <rkannan82@users.noreply.github.com> Co-authored-by: Fred Tzeng <41805201+fretz12@users.noreply.github.com> Co-authored-by: Lakshay <54310363+Lakshaymiddha@users.noreply.github.com> Co-authored-by: samm <sam.mathis@temporal.io> Co-authored-by: Quinn Klassen <klassenq@gmail.com> Co-authored-by: Will Duan <xinw.duan@gmail.com> Co-authored-by: Qian Chen <qyc5937@gmail.com> Co-authored-by: Prathyush PV <prathyush.pv@temporal.io> Co-authored-by: Sean Kane <sean.kane@temporal.io> Co-authored-by: mavemuri <74267563+mavemuri@users.noreply.github.com> Co-authored-by: Rodrigo Zhou <rodrigo.zhou@temporal.io> Co-authored-by: Brian VanLoo <brian.vanloo@gmail.com> Co-authored-by: akbala <akbala@gmail.com> Co-authored-by: Dan Davison <dan.davison@temporal.io> Co-authored-by: Chris Smith <chrsmith@users.noreply.github.com>
## What changed? - Record a buffered start's desired time when a refresh (non-long-poll watch) observes the prior action complete, not just when the long-poll watcher does. - Gate the new state mutation behind a new version, `RefreshCompletionDesiredTime` (14) -- `BufferedStarts[0].DesiredTime` flows into the continue-as-new `Input`, which is replay-checked history, so this can't be applied unconditionally to histories recorded below the gate. 13 is already claimed by the in-flight `MigrationHandoffFixes` work, so this is numbered 14; `CurrentTweakablePolicies.Version` is left at `TriggerImmediatelyTimestamp` (12), so this PR does not itself activate anything. `processWatcherResult` branches strictly on the version (old codepath unchanged, new codepath gated) rather than folding the version check into a boolean expression, so it's visually obvious the old path is untouched on replay. - Only backdate `DesiredTime` on the refresh path when the prior action's `CloseTime` is genuinely after the next start's own due time -- i.e. it was actually blocked waiting on the prior action -- **and** the start's own resolved overlap policy actually waits for a running workflow to finish at all. A start resolved to `ALLOW_ALL` is never blocked by a running workflow (`processBuffer` starts it regardless of `isRunning`), so backdating it to an unrelated close time would understate its real delay. This check is shared with `ProcessBuffer` via a new `IgnoresRunningWorkflow` helper in `buffer.go`, so the two places that need to agree on "does this policy wait for a running workflow" can't drift apart. - `refreshWorkflows` calls the backdate logic once per tracked execution in `RunningWorkflows`. If a run still has multiple tracked executions (e.g. `ALLOW_ALL` runs inherited from before a pre-`DontTrackOverlapping` version ceiling was lifted), only move the recorded close time forward -- a later-processed but earlier-closing execution must not overwrite a genuinely later close already recorded earlier in the same pass, or the reported delay understates how long the start was actually blocked. - The whole backdate decision is extracted into a pure, directly unit-testable function, `shouldBackdateDesiredTime`. ## Why? `processWatcherResult` only set `DesiredTime` when `long` was true (the long-poll path). When a refresh discovered the prior action had completed instead, `DesiredTime` stayed unset, so `ScheduleActionDelay` fell back to the scheduled time instead of the prior action's close time -- inflating the reported delay for back-to-back buffered actions. Review then surfaced two follow-on correctness gaps in the fix itself: it didn't account for `ALLOW_ALL` starts that were never actually blocked, and it could pick the wrong close time when refreshing multiple tracked executions in one pass.
## What changed? `CHASMToLegacyStartScheduleArgs` (the CHASM-to-V1 rollback conversion) appends trigger-derived `BufferedStarts` after the regular pending ones unconditionally, without sorting by due time. Sort the combined list by `ActualTime` after appending, mirroring the sort already applied to `RecentActions` a few lines above in the same function. ## Why? V1's `processWatcherResult` (and the buffer-processing code generally) assumes `BufferedStarts[0]` is always the earliest-due pending start -- it has no equivalent of CHASM's `Attempt` field to reorder around, so it never needs to search for the right entry, unlike CHASM's own `invoker.go`. That assumption isn't guaranteed across a CHASM-to-V1 rollback: - `convertBackfillersCHASMToLegacy` builds `triggerStarts` from manual-trigger backfillers by iterating a Go map, whose iteration order is randomized -- with more than one pending trigger, their relative order isn't stable across calls. - `triggerStarts` are appended after the regular buffered starts regardless of their own due time. A manual trigger queued (and not yet drained) before rollback could have a due time earlier than an already-pending regular buffered start, landing it in the wrong position in the resulting V1 list. Found while reviewing the "legacy path doesn't use deferred starts, so `BufferedStarts[0]` is always the next pending start" invariant this comment documents (`service/worker/scheduler/workflow.go`) -- true for V1 running natively, but not rigorously guaranteed for state arriving via rollback. ## How did you test it? - [x] Added `TestCHASMToLegacyStartScheduleArgs_BufferedStartsSortedByActualTime`, constructing a manual trigger whose due time predates an already-pending regular buffered start; verified it fails without the fix (`the earlier-due manual trigger must sort first`) and passes with it. - [x] `go test ./chasm/lib/scheduler/...` - [x] `make lint-code` (golangci-lint, 0 new issues) ## Potential risks Low. This only reorders an in-memory slice being constructed fresh for a rollback's `StartScheduleArgs` -- it doesn't change what's already been recorded to history, and the sort key (`ActualTime`) is the same field V1 already uses everywhere else to mean "due time."
> **Stack 1/2** (base `sch-v1-wf`): **#11831** -> #11856. ## What changes relative to `main` `main` records the static `CurrentTweakablePolicies.Version` in the scheduler workflow `tweakables` `MutableSideEffect`; it is currently v12. There is no namespace dynamic config for choosing a V1 scheduler workflow version. A running workflow can advance only when a later binary changes that static default. The base branch adds `worker.schedulerV1VersionCeiling`, but selects the capped version only on the first `tweakables` evaluation. Raising or removing a v11 ceiling therefore leaves an in-flight workflow at v11 until continue-as-new. This PR re-reads the ceiling on every `tweakables` evaluation. The pure transition is: ```text next version = max(recorded version, min(binary default, current ceiling)) ``` A negative ceiling is unset. The version remains monotonic, while the ceiling is captured for the current evaluation. ## Result With a binary default of v12: ```text first evaluation: ceiling=11 -> records version=11, ceiling=11 next wakeup: ceiling=-1 -> records version=12, ceiling=-1 ``` The scheduler advances at that next wakeup; it does not wait for continue-as-new. Conversely, lowering a ceiling after a run has already recorded a higher version does not downgrade that run. The new, lower ceiling is recorded and applies when a fresh run starts. ## Replay compatibility `MutableSideEffect` records each selected version/ceiling result, so replay consumes history rather than live dynamic config. Older markers that predate the ceiling field remain readable; a later live evaluation records the current ceiling. The recorded version remains the monotonic floor throughout. ## Scope A ceiling can restrict the binary default but cannot promote beyond it. The child PR #11856 adds the explicit override needed to select a supported version such as v14 before a later release changes the static default. ## Coverage The pure transition table covers unset and zero ceilings, capping, retaining an already-recorded version under a newly lower ceiling, and advancement when the ceiling is raised or removed. Workflow coverage verifies that lifting the ceiling advances and permits migration within the same run.
> **Stack 2/2** (base #11831): #11831 -> **#11856**. ## Purpose #11831 makes a ceiling dynamic, but a ceiling can only restrict a default. If the binary default is v12, `min(v12, v14)` is still v12. This PR adds the namespace-level promotion control required to activate a newer behavior already supported by the binary. ## What changed Adds `worker.schedulerV1VersionOverride`. Its default `-1` retains the binary default. A value from the current default through `LatestSchedulerWorkflowVersion` is valid; values below the default or above the versions supported by this binary are ignored. The override is re-read during every scheduler `tweakables` `MutableSideEffect` evaluation. It is selected before the ceiling from #11831, and the version already recorded by the current run remains a floor: ```text requested version = valid override, otherwise binary default next version = max(recorded version, min(requested version, current ceiling)) ``` With the static default at v12 and this binary supporting v14: ```text override=-1, ceiling=-1 -> v12 override=14, ceiling=-1 -> v14 override=14, ceiling=12 -> v12 ``` If a v14 override is already configured while a v12 ceiling is active, lifting the ceiling lets the workflow record v14 at its next wakeup. Lowering/removing the override, or lowering the ceiling, never downgrades a version already recorded in that run; the lower value applies to a fresh run after continue-as-new. The frontend uses the same override for initial schedule memo/list-info construction, so version-dependent metadata agrees with the first worker task. ## Determinism and coverage The selected result is recorded by the existing `tweakables` `MutableSideEffect`; replay uses that marker rather than live dynamic config. A single dynamic-config struct supplies the workflow dependencies, and named version-selection fields make the frontend handoff explicit. The pure transition table covers promotion to the latest supported version, ceiling precedence, and invalid overrides. Workflow coverage verifies that an override advances within the same run when a prior ceiling is lifted.
|
Claude finished @davidporter-id-au's task in 6m 11s —— View job Reviewed 23 files, 9 findings. |
| // if there's been a rollback, turn off the pending migration flag | ||
| if s.hasMinVersion(MigrationHandoffFixes) && s.State.PendingMigration { | ||
| if !s.tweakables.EnableCHASMMigration { | ||
| s.State.PendingMigration = false | ||
| } | ||
| } |
There was a problem hiding this comment.
high — The v13 rollback reset also discards operator-initiated migrations, making the admin MigrateSchedule RPC a no-op under default dynamic config.
PendingMigration has two producers, and this reset can't tell them apart:
- the auto-eligibility block immediately below (
s.tweakables.EnableCHASMMigration && ...) handleMigrateSignal(workflow.go:1172), which is whatadmin_handler.go:2283signals forAdminService.MigrateSchedule
s.tweakables.EnableCHASMMigration is EnableCHASMSchedulerMigration(ns) && RolloutAccepts(key, CHASMSchedulerMigrationRolloutPercent(ns)) (fx.go:117-120). CHASMSchedulerMigrationRolloutPercent defaults to 0, so it is false for essentially every schedule in a default-configured namespace.
Sequence once MigrationHandoffFixes becomes the recorded version:
- Operator calls
AdminService.MigrateSchedule→ signal →sleep()receives it →PendingMigration = true. updateTweakables()runs;EnableCHASMMigrationis false (rollout percent 0).- Next loop iteration reaches this block first and clears
PendingMigration. executeMigration()never runs. No error, no metric, no log — the RPC returns success and nothing happens.
The PR already knows about this: workflow_test.go:3584 TestOperatorMigrateSignalSurvivesRolloutPercentZero asserts the correct behavior and is t.Skip'd as a "known gap". A skipped test is not a mitigation for an RPC that silently stops working — and this is exactly the "activate v13" deploy this PR is staging for.
Note the activity-side guard added in activities.go:398 reads only EnableCHASMSchedulerMigration (not the rollout percent), so the two rollback guards disagree on what "rolled back" means.
Suggestion: Make the reset condition match the intent ("the operator turned migration off"), not the per-schedule rollout sampling. Thread a second reader that consults only dynamicconfig.EnableCHASMSchedulerMigration — the same predicate activities.migrationEnabled uses — and gate the reset on that:
// if there's been a rollback, turn off the pending migration flag
if s.hasMinVersion(MigrationHandoffFixes) && s.State.PendingMigration &&
!s.tweakables.MigrationFeatureEnabled {
s.State.PendingMigration = false
}Whichever shape you pick, the skipped test at workflow_test.go:3584 should be unskipped as the acceptance criterion.
There was a problem hiding this comment.
This comment misunderstands the problematic mechanism of late/retriggered migrations.
| nil, | ||
| ) | ||
| } | ||
| if a.migrationEnabled != nil && !a.migrationEnabled() { |
There was a problem hiding this comment.
med — The migrationEnabled != nil guard makes an unwired dependency fail open, which is the wrong polarity for a rollback guard.
activities is only constructed in two places: fx.go:156 (always sets migrationEnabled) and activities_test.go:33 (always sets it). So the nil branch is unreachable in production and exists only to let one test set the field to nil.
The PR's own TestMigrateScheduleToChasm_MigrationEnabledUnwired (activities_test.go) asserts that behavior and its comment says it is "the wrong polarity for a rollback guard". A test that pins a known-wrong default entrenches it: if a future refactor adds a third construction site and forgets the field, this activity silently resumes creating V2 schedules after a rollback, which is the exact failure the guard was added to prevent — and no test fails.
Suggestion: Drop the nil check so a missing dependency panics loudly instead of migrating, and delete TestMigrateScheduleToChasm_MigrationEnabledUnwired.
| if a.migrationEnabled != nil && !a.migrationEnabled() { | |
| if !a.migrationEnabled() { |
There was a problem hiding this comment.
seems like is should probably be if a.migrationEnabled == nil || !a.migrationEnabled()
not a big deal though. it should always not be nil
| Version SchedulerWorkflowVersion | ||
| // VersionCeiling captures the raw worker.schedulerV1VersionCeiling value, even when Version | ||
| // is retained. This makes a ceiling change part of the recorded tweakables value. | ||
| VersionCeiling int | ||
| // VersionCeilingSet distinguishes a captured zero ceiling from history written before this | ||
| // flag was introduced. | ||
| VersionCeilingSet bool |
There was a problem hiding this comment.
med — Two new fields are persisted into the replay-critical TweakablePolicies solely to deduplicate a log warning.
VersionCeiling and VersionCeilingSet have exactly one reader in the whole tree — shouldWarnForVersionCeiling (workflow.go:1923). Neither participates in determineVersionTransition, in hasMinVersion, or in any behavior. Their only effect is to suppress a repeat logger.Warn.
The cost is not free:
updateTweakablessetsVersionCeilingSet = trueunconditionally, so on the first wakeup after this deploy every running V1 scheduler workflow in the fleet fails theeqcomparison against its recorded tweakables and writes a freshMutableSideEffectmarker — a fleet-wide history write for no behavior change.- Thereafter, any ceiling edit writes another marker per schedule even when the effective version is unchanged (the common case: a ceiling above the binary default, which is precisely what this warning is about).
The asymmetry also undercuts the rationale: the sibling worker.schedulerV1VersionOverride warning at workflow.go:1916 has no dedup at all and fires on every iteration for as long as the value is misconfigured — TestDetermineVersionDiagnostics (version_ceiling_test.go:82) explicitly asserts it is logged twice for two evaluations. So a misconfigured override already produces the unbounded log stream this machinery exists to avoid for the ceiling.
Suggestion: Drop VersionCeiling and VersionCeilingSet, return only the version from determineVersionTransition, and handle both misconfiguration warnings the same way — either log unconditionally (matching the override) or dedupe both via a non-persisted scheduler field, which does not need to survive replay since MutableSideEffect callbacks don't run then.
There was a problem hiding this comment.
already discussed. I don't like having random bools around, but this was added due to the ambiguity between zero value for version and unset. It seemed the lesser of the two evils
| func TestScheduleMigrationV1ToV2_AdminMigratePreservesRunningWorkflowHistory(t *testing.T) { | ||
|
|
||
| // TODO: This is the admin-API directly poking a migration to v2: | ||
| t.Skip("admin MigrateSchedule path still injects EVENT_TYPE_WORKFLOW_EXECUTION_OPTIONS_UPDATED " + | ||
| "into a running workflow's history; remove this skip when the retroactive callback-attach is fixed") | ||
|
|
There was a problem hiding this comment.
med — 225 lines of integration test are added that never execute, including two helpers nothing references and comments citing a file that isn't in the repo.
This is the only test in the +225 hunk, and it is t.Skip'd unconditionally. The five helpers below it (requireNoChasmSentinel, createV1Schedule, awaitRunningAction, awaitV1SchedulerCompleted, requireNoOptionsUpdatedEvent) are reachable only from it, so none of them run either. On top of that:
awaitAnyAction(line 2465) andrequireV2ScheduleExists(line 2525) are not referenced from anywhere intests/— dead on arrival.- The comments at lines 2506 and 2516 point readers at
repros/scheduler-migration-bug-evidence.md; there is norepros/directory in the repo, so the pointer is a dead end for anyone triaging the skip later.
None of this exercises the V1 workflow changes this PR is about, and it will silently rot: the skip message describes a bug in the admin migration path, not something this PR fixes or regresses.
Suggestion: Drop the skipped test, its five exclusive helpers, and the two unreferenced ones from this PR, and track the admin-path EVENT_TYPE_WORKFLOW_EXECUTION_OPTIONS_UPDATED bug as an issue instead. If it must stay, at minimum delete awaitAnyAction and requireV2ScheduleExists and replace the repros/... references with the issue link.
There was a problem hiding this comment.
This was mostly documentation for a repro of an issue identified with the Coinbase SDK, it's here as a form of documentation
| workflowID := "" | ||
| if s.hasMinVersion(MigrationHandoffFixes) { | ||
| workflowID = start.WorkflowId | ||
| } | ||
| if workflowID == "" { | ||
| workflowID = newWorkflow.WorkflowId | ||
| } | ||
| if (!s.hasMinVersion(MigrationHandoffFixes) || start.WorkflowId == "") && | ||
| (start.OverlapPolicy == enumspb.SCHEDULE_OVERLAP_POLICY_ALLOW_ALL || s.tweakables.AlwaysAppendTimestamp) { | ||
| // must match AppendedTimestampForValidation | ||
| workflowID += "-" + nominalTimeSec.Format(time.RFC3339) | ||
| } |
There was a problem hiding this comment.
small — The workflow-ID derivation now needs three interacting conditionals (one of them a double negative) to express two cases.
The "" sentinel plus the (!s.hasMinVersion(MigrationHandoffFixes) || start.WorkflowId == "") re-test makes the reader reconstruct the invariant that the suffix must be suppressed exactly when the migrated ID was used — which is the one thing that must not be got wrong here, since a doubled suffix breaks dedup against V2's run (TestMigratedBufferedStartSkipsTimestampSuffixForNonAllowAll). Nesting the fallback makes it structural instead of inferred.
Suggestion:
| workflowID := "" | |
| if s.hasMinVersion(MigrationHandoffFixes) { | |
| workflowID = start.WorkflowId | |
| } | |
| if workflowID == "" { | |
| workflowID = newWorkflow.WorkflowId | |
| } | |
| if (!s.hasMinVersion(MigrationHandoffFixes) || start.WorkflowId == "") && | |
| (start.OverlapPolicy == enumspb.SCHEDULE_OVERLAP_POLICY_ALLOW_ALL || s.tweakables.AlwaysAppendTimestamp) { | |
| // must match AppendedTimestampForValidation | |
| workflowID += "-" + nominalTimeSec.Format(time.RFC3339) | |
| } | |
| workflowID := start.WorkflowId | |
| if !s.hasMinVersion(MigrationHandoffFixes) || workflowID == "" { | |
| workflowID = newWorkflow.WorkflowId | |
| if start.OverlapPolicy == enumspb.SCHEDULE_OVERLAP_POLICY_ALLOW_ALL || s.tweakables.AlwaysAppendTimestamp { | |
| // must match AppendedTimestampForValidation | |
| workflowID += "-" + nominalTimeSec.Format(time.RFC3339) | |
| } | |
| } | |
| if shouldWarnForVersionCeiling(s.tweakables, resolveVersionBeforeCeiling(defaultVersion, override), ceiling) { | ||
| s.logger.Warn("worker.schedulerV1VersionCeiling above the version this binary records; no effect", | ||
| "ceiling", ceiling, "recordedVersion", defaultVersion) |
There was a problem hiding this comment.
small — The recordedVersion log tag carries the binary default, not the recorded version, and it disagrees with the value the warning was decided on.
defaultVersion is the incoming CurrentTweakablePolicies.Version. The version actually recorded for this run is s.tweakables.Version. Worse, the predicate compares the ceiling against resolveVersionBeforeCeiling(defaultVersion, override) while the log reports the un-overridden defaultVersion — so with an active override the message claims "ceiling above the version this binary records" against a number that is not the version it decided on. Anyone reading this warning to work out whether their ceiling is doing anything gets a misleading number.
Suggestion: Log the value the decision used, under an accurate name.
| if shouldWarnForVersionCeiling(s.tweakables, resolveVersionBeforeCeiling(defaultVersion, override), ceiling) { | |
| s.logger.Warn("worker.schedulerV1VersionCeiling above the version this binary records; no effect", | |
| "ceiling", ceiling, "recordedVersion", defaultVersion) | |
| requestedVersion := resolveVersionBeforeCeiling(defaultVersion, override) | |
| if shouldWarnForVersionCeiling(s.tweakables, requestedVersion, ceiling) { | |
| s.logger.Warn("worker.schedulerV1VersionCeiling above the version this binary records; no effect", | |
| "ceiling", ceiling, "requestedVersion", requestedVersion, "recordedVersion", s.tweakables.Version) |
| func TestDetermineVersionTransition(t *testing.T) { | ||
| for defaultVersion := InitialVersion; defaultVersion <= LatestSchedulerWorkflowVersion; defaultVersion++ { | ||
| for recordedVersion := InitialVersion; recordedVersion <= LatestSchedulerWorkflowVersion; recordedVersion++ { | ||
| for ceiling := -1; ceiling <= int(LatestSchedulerWorkflowVersion)+1; ceiling++ { | ||
| for override := -1; override <= int(LatestSchedulerWorkflowVersion)+1; override++ { | ||
| wantVersion := defaultVersion | ||
| if override >= int(wantVersion) && override <= int(LatestSchedulerWorkflowVersion) { | ||
| wantVersion = SchedulerWorkflowVersion(override) | ||
| } | ||
| if ceiling >= 0 && ceiling < int(wantVersion) { | ||
| wantVersion = SchedulerWorkflowVersion(ceiling) | ||
| } | ||
| if recordedVersion > wantVersion { | ||
| wantVersion = recordedVersion | ||
| } | ||
|
|
||
| version, capturedCeiling := determineVersionTransition(defaultVersion, recordedVersion, ceiling, override) | ||
| require.Equalf(t, wantVersion, version, "default=%d recorded=%d ceiling=%d override=%d", defaultVersion, recordedVersion, ceiling, override) | ||
| require.Equalf(t, ceiling, capturedCeiling, "default=%d recorded=%d ceiling=%d override=%d", defaultVersion, recordedVersion, ceiling, override) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
small — TestDetermineVersionTransition recomputes the expected value with the same algorithm as the code under test, so it cannot detect a wrong specification.
The wantVersion block is a line-for-line restatement of resolveVersion + max(..., recordedVersion). It will pass for any implementation that agrees with itself, including one whose precedence is wrong (e.g. clamp-before-override instead of override-before-clamp, or a ceiling that can lower the recorded version). Those precedence choices are the entire point of the function and the whole reason this change carries nondeterminism risk.
The four-deep loop also runs ~1,400 iterations to assert capturedCeiling == ceiling, which is a pass-through.
Suggestion: Replace the mirrored oracle with a table of hand-written cases naming the property each pins — e.g. override promotes past the default; override below the default is ignored; ceiling caps an override; a lowered ceiling does not downgrade recordedVersion; ceiling above Latest is a no-op. A dozen explicit rows document the contract and would fail if the precedence were inverted.
There was a problem hiding this comment.
I think this is true and worth a followup, I wasn't going to block on it
| // load-bearing -- it's fed into the same ProcessBuffer V1 uses, where BUFFER_ONE and | ||
| // "nothing running" both take whichever entry comes first in iteration order, never | ||
| // comparing ActualTime. A pending trigger Backfiller hasn't been enqueued yet -- | ||
| // BackfillerTaskHandler.processTrigger builds its single BufferedStart and only appends it | ||
| // via Invoker.EnqueueBufferedStarts once its task actually executes, regardless of the | ||
| // trigger's own ActualTime -- so simulating "if CHASM kept running" means every still-pending | ||
| // trigger belongs after whatever's already buffered, not repositioned into it by time. | ||
| // triggerStarts are sorted only among themselves (built from a randomized map iteration, so | ||
| // they have no defined relative order of their own) purely for a deterministic tie-break, | ||
| // then appended after bufferedStarts unchanged. | ||
| bufferedStarts = appendSortedTriggerStarts(bufferedStarts, triggerStarts) |
There was a problem hiding this comment.
small — The same 11-line rationale is repeated verbatim on appendSortedTriggerStarts's doc comment, and both copies are written as an argument against an alternative rather than a description of the code.
Lines 193-202 and 239-245 say the same thing twice, ~20 comment lines for an 8-line function, and both are framed as "why not the other design" ("so simulating if CHASM kept running means...", "not repositioned into it by time"). Per the repo's comment guidance, a comment should describe the code, not relitigate the decision, and should not stack subordinate clauses.
Two load-bearing facts are in there and are worth keeping once: ProcessBuffer takes the first matching entry in list order without comparing ActualTime, and a pending trigger Backfiller is only ever appended by Invoker.EnqueueBufferedStarts when its task runs. Everything else is commentary. The three tests added in migration_test.go already carry the scenario-level explanation.
(Separately: the PR description says this fix "sort[s] the combined list by ActualTime after appending" — the opposite of what the code and tests do. Worth correcting before merge so the commit message doesn't contradict the change.)
Suggestion: Keep one short call-site comment and reduce the function doc to a single line.
| // load-bearing -- it's fed into the same ProcessBuffer V1 uses, where BUFFER_ONE and | |
| // "nothing running" both take whichever entry comes first in iteration order, never | |
| // comparing ActualTime. A pending trigger Backfiller hasn't been enqueued yet -- | |
| // BackfillerTaskHandler.processTrigger builds its single BufferedStart and only appends it | |
| // via Invoker.EnqueueBufferedStarts once its task actually executes, regardless of the | |
| // trigger's own ActualTime -- so simulating "if CHASM kept running" means every still-pending | |
| // trigger belongs after whatever's already buffered, not repositioned into it by time. | |
| // triggerStarts are sorted only among themselves (built from a randomized map iteration, so | |
| // they have no defined relative order of their own) purely for a deterministic tie-break, | |
| // then appended after bufferedStarts unchanged. | |
| bufferedStarts = appendSortedTriggerStarts(bufferedStarts, triggerStarts) | |
| // V1's ProcessBuffer takes the first entry in list order without comparing ActualTime, so | |
| // bufferedStarts' enqueue order must be preserved. A pending trigger Backfiller has not been | |
| // enqueued yet -- Invoker.EnqueueBufferedStarts appends it only when its task runs -- so it | |
| // belongs after everything already buffered. | |
| bufferedStarts = appendSortedTriggerStarts(bufferedStarts, triggerStarts) |
| recentFromInfo := len(info.GetRecentActions()) > 0 | ||
| if recentFromInfo { | ||
| storedRecent := make([]*schedulepb.ScheduleActionResult, 0, len(info.GetRecentActions())) | ||
| for _, action := range info.GetRecentActions() { | ||
| storedRecent = append(storedRecent, common.CloneProto(action)) | ||
| } | ||
| recent = append(storedRecent, recent...) | ||
| } | ||
| ongoingBackfills, triggerStarts := convertBackfillersCHASMToLegacy(backfillers, migrationTime) | ||
|
|
||
| // recent is a concatenation of independently-ordered sources (stored info + invoker-derived), | ||
| // and RecentActions has no order-sensitive consumer -- it's just a display/history list -- so | ||
| // a plain re-sort by ActualTime is correct. | ||
| if recentFromInfo { | ||
| slices.SortFunc(recent, func(a, b *schedulepb.ScheduleActionResult) int { | ||
| return a.GetActualTime().AsTime().Compare(b.GetActualTime().AsTime()) | ||
| }) | ||
| recent = util.SliceTail(recent, legacyRecentActionCount) | ||
| } | ||
| ongoingBackfills, triggerStarts := convertBackfillersCHASMToLegacy(backfillers, migrationTime) | ||
| bufferedStarts = append(bufferedStarts, triggerStarts...) | ||
|
|
There was a problem hiding this comment.
nit — The recentFromInfo variable and the second if add a branch without changing behavior; the block was split only to make room for a comment.
The original single if len(info.GetRecentActions()) > 0 block did exactly this. Now the same predicate is tested twice with convertBackfillersCHASMToLegacy moved in between, purely so the sort/truncate can be introduced by a comment. That leaves a reader checking whether the two halves can ever disagree.
Suggestion: Restore the single block and keep ongoingBackfills, triggerStarts := ... after it; the sort comment reads the same inside the block.
| for _, vr := range versionReleases { | ||
| mapped[vr.version] = true | ||
| _, err := os.Stat(filepath.Join("testdata", "replay_"+vr.server+".json.gz")) | ||
| require.NoErrorf(t, err, "missing snapshot replay_%s.json.gz", vr.server) | ||
| } | ||
| for version, fixture := range versionFixtures { | ||
| mapped[version] = true |
There was a problem hiding this comment.
small — versionReleases entries are only checked for file existence, so the version→snapshot mapping this test exists to guarantee is unverified for v0–v12.
versionFixtures gets the real check (fixtureTweakablesVersions must contain the version), but versionReleases gets only os.Stat. So mapped[v] = true claims coverage for v0 through v12 on the strength of a filename.
That matters here specifically: this PR renames replay_v1.23-pre.json.gz → replay_v1.23.0.json.gz and maps it to DontTrackOverlapping, and adds four new snapshots each mapped to two or three versions. Nothing checks that any of those files actually record the version claimed — a snapshot captured from the wrong build, or a mislabelled rename, passes silently while TestEveryVersionIsMapped reports full coverage.
Suggestion: Apply fixtureTweakablesVersions to the release snapshots too, for the versions that can record one. Version only exists from BatchAndCacheTimeQueries on, and v4–v6 / v7–v8 / v9–v10 / v11–v12 share a file, so the check is "the snapshot records some version in this release's set":
for _, vr := range versionReleases {
mapped[vr.version] = true
path := filepath.Join("testdata", "replay_"+vr.server+".json.gz")
_, err := os.Stat(path)
require.NoErrorf(t, err, "missing snapshot replay_%s.json.gz", vr.server)
if vr.version < scheduler.BatchAndCacheTimeQueries {
continue // predates the Version field
}
require.Containsf(t, releaseVersions(vr.server), maxVersionForRelease(vr.server),
"snapshot replay_%s.json.gz does not record the highest version of its release", vr.server)
}Even just asserting the highest version per release would have to be right for the rename and the four new files.
There was a problem hiding this comment.
not a big deal / not worth blocking on
|
A lot of nits that I don't think are all that important. |
|
Manual testing done, looks good |
Summary
We held back several fixes in the V1 schedule workflow and bundled them into a single atomic commit with the intention of avoiding having to perform multiple increments of the workflow versioning. The changes are broadly: Migration v1->v2 fixes + CGS version changes.
Risk
This touches V1 schedules workflow, and a mistake risks nondeterminitism. This is relatively high impact and has quite a bit of subtlety. This is therefore a commit we need to merge with care
Testing and validation
Our intention is to a) make the 163 release cut and b) continue to manually testing a few operational scenarios manually while this is being merged in. If we see problems we can hotfix this. Manually doing some scenario testing will take time so we intend to do this in parallel.
Some of the scenarios we will test manually are:
Some of the tests we've already run and manually validated(cc @liam-lowe)
LLM Summary
This PR stacks a series of changes to the V1 (legacy) scheduler workflow: two migration-correctness fixes, and dynamic-config levers to safely roll the V1 workflow version forward/backward across a multi-cluster deployment without requiring continue-as-new. Summary of each PR in the stack, in order:
#11462 — V1→V2 migration-eligibility fix and migrated-start ID (David Porter)
Combines two previously separate fixes under one shared version bump (
v13), avoiding two separate version-bump deploys for the same version number:RefreshBeforeMigrationCheck: fixes a bug that was preventing V1→V2 migration from ever succeeding under default configuration.PreserveMigratedStartIDs: preserves the request IDs workflows were originally started with, in case of a rollback.hasMinVersion(13));CurrentTweakablePolicies.Versionitself stays at v12 pending a follow-up activation deploy.#11588 — Fix schedule action delay after refresh (Alex Stanfield)
processWatcherResultonly recordedDesiredTimeon the long-poll path; when a refresh instead discovered the prior action had completed,DesiredTimestayed unset, inflating the reportedScheduleActionDelayfor back-to-back buffered actions.DesiredTimeon the refresh path too, gated behind a new version,RefreshCompletionDesiredTime(v14).ALLOW_ALLstarts (never blocked by a running workflow, so shouldn't be backdated to an unrelated close time) and multiple tracked executions in one refresh pass (must only move the recorded close time forward, never backward).shouldBackdateDesiredTimefunction, and a sharedIgnoresRunningWorkflowhelper soProcessBufferand the new backdate logic can't drift apart on what "waits for a running workflow" means.#11827 — Sort BufferedStarts by due time on CHASM-to-V1 rollback (Alex Stanfield)
CHASMToLegacyStartScheduleArgs(the CHASM→V1 rollback conversion) appended trigger-derivedBufferedStartsafter the regular pending ones unconditionally, without sorting by due time.BufferedStarts[0]is always the earliest-due pending start — an invariant not guaranteed across a rollback, since manual triggers are built by iterating a Go map (randomized order) and appended regardless of their own due time.ActualTimeafter appending, mirroring the sort already applied toRecentActionsa few lines above.#11831 — Re-evaluate V1 version ceiling per iteration (Alex Stanfield)
worker.schedulerV1VersionCeilingbut only applied it on the firsttweakablesevaluation, so raising/removing a ceiling left an in-flight workflow stuck at the capped version until continue-as-new.next version = max(recorded version, min(binary default, current ceiling)). The recorded version stays monotonic — a newly-lowered ceiling never downgrades a version already recorded in the current run, but a raised/removed ceiling lets the workflow advance at its next wakeup instead of waiting for continue-as-new.MutableSideEffect, so replay consumes history rather than re-evaluating live dynamic config.#11856 — Add worker.schedulerV1VersionOverride (Alex Stanfield)
requested version = valid override, otherwise binary default;next version = max(recorded version, min(requested version, current ceiling)). Default-1retains the binary default; values below the binary default or aboveLatestSchedulerWorkflowVersionare ignored.Base of the stack: #10817 (liam-lowe) introduced the original (static-per-run)
worker.schedulerV1VersionCeilingdynamic config that #11831/#11856 build on.Why?
To support safe, gradual rollout/rollback of V1 scheduler workflow version bumps (migration fixes, action-delay-after-refresh fix) in a cross-version multi-cluster topology, without requiring continue-as-new to pick up config changes.
How did you test it?