Skip to content

Sch v1 wf - #11821

Merged
davidporter-id-au merged 8 commits into
mainfrom
sch-v1-wf
Sep 3, 2026
Merged

Sch v1 wf#11821
davidporter-id-au merged 8 commits into
mainfrom
sch-v1-wf

Conversation

@davidporter-id-au

@davidporter-id-au davidporter-id-au commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

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

  1. Changes have been manually tested on individual PRs already
  2. Changes have been manually run through CGS's validation suite (cc @liam-lowe)

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:

  1. Deploy and rollback to an earlier version (probably with LLM assist, but running locally)
  2. Manually lifting the the version of a schedule via dynamic config (manually) ensuring there's no nondeterminitism risk
  3. V2->v1 Rollback works as expected

Some of the tests we've already run and manually validated(cc @liam-lowe)

  1. Version floor works as expected
  2. That this change fixes v1->v2 migration
  3. That this change avoids the problematic history entries which broke a customer using the coinbase Ruby SDK

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.
  • Adds a guard against late migrations caused by a transient error bouncing a migration attempt and the schedule re-attempting it after waking again.
  • Follows the existing two-phase-rollout pattern: this PR only teaches the scheduler to understand v13 (gated behind hasMinVersion(13)); CurrentTweakablePolicies.Version itself stays at v12 pending a follow-up activation deploy.

#11588 — Fix schedule action delay after refresh (Alex Stanfield)

  • processWatcherResult only recorded DesiredTime on the long-poll path; when a refresh instead discovered the prior action had completed, DesiredTime stayed unset, inflating the reported ScheduleActionDelay for back-to-back buffered actions.
  • Backdates DesiredTime on the refresh path too, gated behind a new version, RefreshCompletionDesiredTime (v14).
  • Handles two follow-on correctness gaps surfaced in review: ALLOW_ALL starts (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).
  • Extracts the decision into a pure, unit-testable shouldBackdateDesiredTime function, and a shared IgnoresRunningWorkflow helper so ProcessBuffer and 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-derived BufferedStarts after the regular pending ones unconditionally, without sorting by due time.
  • V1's buffer-processing code assumes 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.
  • Fix: sort the combined list by ActualTime after appending, mirroring the sort already applied to RecentActions a few lines above.

#11831 — Re-evaluate V1 version ceiling per iteration (Alex Stanfield)

  • The base PR (add workflow version clamp to the V1 scheduler's recorded version #10817, by liam-lowe) added worker.schedulerV1VersionCeiling but only applied it on the first tweakables evaluation, so raising/removing a ceiling left an in-flight workflow stuck at the capped version until continue-as-new.
  • Re-reads the ceiling on every evaluation: 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.
  • Recorded via the existing MutableSideEffect, so replay consumes history rather than re-evaluating live dynamic config.

#11856 — Add worker.schedulerV1VersionOverride (Alex Stanfield)

  • feat: [Scheduler] re-evaluate V1 version ceiling per iteration #11831 makes the ceiling dynamic, but a ceiling can only restrict a binary default, never promote past it. Adds the namespace-level override needed to activate a newer version already supported by the binary.
  • requested version = valid override, otherwise binary default; next version = max(recorded version, min(requested version, current ceiling)). Default -1 retains the binary default; values below the binary default or above LatestSchedulerWorkflowVersion are ignored.
  • The frontend uses the same override for initial schedule memo/list-info construction, so version-dependent metadata agrees with the first worker task.

Base of the stack: #10817 (liam-lowe) introduced the original (static-per-run) worker.schedulerV1VersionCeiling dynamic 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?

  • built
  • covered by existing tests
  • added new unit test(s)

liam-lowe and others added 7 commits August 25, 2026 20:51
…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.
@davidporter-id-au
davidporter-id-au marked this pull request as ready for review September 2, 2026 19:49
@davidporter-id-au
davidporter-id-au requested a review from a team September 2, 2026 19:49
@davidporter-id-au
davidporter-id-au requested review from a team as code owners September 2, 2026 19:49
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @davidporter-id-au's task in 6m 11s —— View job


Reviewed 23 files, 9 findings.
• branch sch-v1-wf

@davidporter-id-au davidporter-id-au added the reliability-2026 Reliability related changes label Sep 2, 2026
Comment on lines +404 to +409
// 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
}
}

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.

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 what admin_handler.go:2283 signals for AdminService.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:

  1. Operator calls AdminService.MigrateSchedule → signal → sleep() receives it → PendingMigration = true.
  2. updateTweakables() runs; EnableCHASMMigration is false (rollout percent 0).
  3. Next loop iteration reaches this block first and clears PendingMigration.
  4. 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.

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.

This comment misunderstands the problematic mechanism of late/retriggered migrations.

nil,
)
}
if a.migrationEnabled != nil && !a.migrationEnabled() {

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 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.

Suggested change
if a.migrationEnabled != nil && !a.migrationEnabled() {
if !a.migrationEnabled() {

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.

seems like is should probably be if a.migrationEnabled == nil || !a.migrationEnabled()

not a big deal though. it should always not be nil

Comment on lines +196 to +202
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

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 — 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:

  • updateTweakables sets VersionCeilingSet = true unconditionally, so on the first wakeup after this deploy every running V1 scheduler workflow in the fleet fails the eq comparison against its recorded tweakables and writes a fresh MutableSideEffect marker — 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.

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.

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

Comment on lines +2313 to +2318
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")

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 — 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) and requireV2ScheduleExists (line 2525) are not referenced from anywhere in tests/ — dead on arrival.
  • The comments at lines 2506 and 2516 point readers at repros/scheduler-migration-bug-evidence.md; there is no repros/ 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.

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.

This was mostly documentation for a repro of an issue identified with the Coinbase SDK, it's here as a form of documentation

Comment on lines +1640 to 1651
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)
}

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 — 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:

Suggested change
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)
}
}

Comment on lines +1912 to +1914
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)

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 — 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.

Suggested change
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)

Comment on lines +78 to +99
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)
}
}
}

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.

smallTestDetermineVersionTransition 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.

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.

I think this is true and worth a followup, I wasn't going to block on it

Comment on lines +193 to +203
// 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)

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 — 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.

Suggested change
// 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)

Comment on lines +172 to +191
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...)

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 — 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.

Comment on lines +143 to +149
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

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.

smallversionReleases 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.gzreplay_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.

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.

not a big deal / not worth blocking on

@chaptersix

Copy link
Copy Markdown
Contributor

A lot of nits that I don't think are all that important.

@davidporter-id-au

Copy link
Copy Markdown
Contributor Author

Manual testing done, looks good

@davidporter-id-au
davidporter-id-au merged commit cd667da into main Sep 3, 2026
84 of 85 checks passed
@davidporter-id-au
davidporter-id-au deleted the sch-v1-wf branch September 3, 2026 01:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

reliability-2026 Reliability related changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants