Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion docs/adr/ADR-0010-beta-upgrade-channel-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,31 @@ the public `UpgradeMonitor::version_matches_channel`.
Option 3 is deliberately deferred rather than rejected: nothing here prevents adding an `rc` channel
later, and the exact-identifier rule means doing so is an additive change.

### Amendment: a beta node skips its own promotion

Channel eligibility alone produced an unwanted hop. Semver ranks a final above its own
pre-release, so a node running `0.18.0-beta.1` treated the promoted `0.18.0` as an upgrade — a
binary swap and a network restart for what is the same code re-tagged. Because the train promotes
on every cycle, this would have happened on every train, to every beta node.

Eligibility is therefore refined: **on the beta channel, a final release is not a candidate when the
running version is a `beta.*` pre-release of the same `major.minor.patch`.** The skip is narrow by
design:

- A genuinely newer final is still taken (`0.19.0` while running `0.18.0-beta.1`), so a beta node
does not stagnate if the beta line stalls.
- A later beta of the same version is still taken (`0.18.0-beta.2`).
- It applies to the beta channel only. A node running a beta build while configured for `stable`
still takes the final, since that is its route back onto the stable line.

This is a rule about the *pair* (candidate, running version) rather than about the candidate alone,
so it lives beside the channel predicate rather than inside it.

Note the accepted limitation: if the train can change code between the last `beta.N` and the final
without cutting `beta.N+1`, a beta node skipping the promotion misses that change until the next
version. Guarding against that is a process obligation on the train — anything that changes after a
beta must get a new beta tag — not something the node can detect.

## Consequences

### Positive
Expand All @@ -79,6 +104,8 @@ later, and the exact-identifier rule means doing so is an additive change.
- A beta node holds its soak build until a higher `-beta.N` or a final release appears.
- The rule exists in one place, so the selection path and the predicate cannot diverge.
- The `stable` channel is unchanged, so the production fleet is unaffected.
- Beta nodes restart once per train rather than twice, and keep their identity as beta builds
instead of being silently converted to stable ones on every promotion.

### Negative / Trade-offs

Expand All @@ -87,6 +114,8 @@ later, and the exact-identifier rule means doing so is an additive change.
capability reduction and is the main thing a reviewer should weigh.
- Any future pre-release suffix is rejected by default. That is the safe direction, but it means a
new suffix requires a deliberate code change rather than working implicitly.
- A beta node skipping its own promotion depends on the train tagging a new beta whenever code
changes after `beta.N`. If that discipline slips, the node holds an older build than it should.

### Neutral / Operational

Expand All @@ -102,7 +131,9 @@ later, and the exact-identifier rule means doing so is an additive change.
accepting `-beta.N` and finals while rejecting `-rc.N`, `-alpha.N` and `-betax.N`; selection over a
mixed list (`0.16.0`, `0.17.0-beta.1`, `0.17.0-rc.1`) resolving to `0.16.0` on stable and
`0.17.0-beta.1` on beta; and the ship-and-promote-same-day case hopping from `0.16.0-beta.1`
straight to `0.17.0-beta.1`.
straight to `0.17.0-beta.1`. For the amendment: a beta node ignoring its own promotion, still
taking a newer final, still taking a later beta of the same version, preferring the next beta over
its own promotion, and a stable-channel node still taking the promotion of a beta it is running.
- End-to-end validation is tracked separately as V2-1012: a dev testnet where the cohort pulls a fake
`-beta.N` release and demonstrably ignores a real rc published in the same window.
- Review trigger: revisit this ADR if a new pre-release suffix is introduced, if internal rc soaking
Expand Down
115 changes: 113 additions & 2 deletions src/upgrade/monitor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,11 @@ impl UpgradeMonitor {
return None;
}

if is_redundant_beta_promotion(&latest_version, &self.current_version, self.channel) {
debug!("Skipping {latest_version}: promotion of the beta already running");
return None;
}

// Find platform assets
let binary_asset = find_platform_asset(&release.assets)?;

Expand Down Expand Up @@ -458,13 +463,48 @@ fn version_matches_channel(version: &Version, channel: UpgradeChannel) -> bool {

match channel {
UpgradeChannel::Stable => false,
UpgradeChannel::Beta => version.pre.as_str().split('.').next() == Some("beta"),
UpgradeChannel::Beta => is_beta_prerelease(version),
}
}

/// Whether a version's pre-release component marks it as a beta build.
///
/// Matches on the exact first identifier, so `0.17.0-beta.1` and `0.17.0-beta` qualify while
/// `0.17.0-betax.1` does not.
#[must_use]
fn is_beta_prerelease(version: &Version) -> bool {
version.pre.as_str().split('.').next() == Some("beta")
}

/// Whether upgrading to `candidate` would only trade a beta build for its own promotion.
///
/// Promoting `X.Y.Z-beta.N` to the final `X.Y.Z` re-tags the same code, so a node already running
/// that beta would swap its binary and restart for no behavioural change. Semver ranks the final
/// above the pre-release, so without this the hop would happen on every release train, to every
/// beta node.
///
/// Only the matching final is skipped. A genuinely newer final — `0.19.0` while running
/// `0.18.0-beta.1` — is still taken, so a node does not stagnate if the beta line stalls.
///
/// Beta channel only: a node running a beta build while configured for `stable` should land on the
/// final, since that is its route back to the stable line.
#[must_use]
fn is_redundant_beta_promotion(
candidate: &Version,
current: &Version,
channel: UpgradeChannel,
) -> bool {
channel == UpgradeChannel::Beta
&& candidate.pre.is_empty()
&& is_beta_prerelease(current)
&& (candidate.major, candidate.minor, candidate.patch)
== (current.major, current.minor, current.patch)
}

/// Select the most appropriate upgrade from a list of releases.
///
/// Only versions eligible for the channel are considered; see [`version_matches_channel`].
/// Only versions eligible for the channel are considered, and a beta node skips the promotion of
/// the very build it is running; see `version_matches_channel` and `is_redundant_beta_promotion`.
///
/// Returns the newest version that matches the channel and has platform assets.
fn select_upgrade_from_releases(
Expand All @@ -487,6 +527,11 @@ fn select_upgrade_from_releases(
continue;
}

if is_redundant_beta_promotion(&version, current_version, channel) {
debug!("Skipping {version}: promotion of the beta already running");
continue;
}

let Some(binary_asset) = find_platform_asset(&release.assets) else {
continue;
};
Expand Down Expand Up @@ -1169,6 +1214,72 @@ mod tests {
assert_eq!(beta.version, Version::parse("0.17.0-beta.1").unwrap());
}

/// A beta node does not restart onto the promotion of the build it is already running:
/// `0.18.0-beta.1` -> `0.18.0` re-tags the same code.
#[test]
fn test_select_upgrade_beta_skips_own_promotion() {
let current = Version::parse("0.18.0-beta.1").unwrap();
let releases = vec![release_with_assets("v0.18.0")];

assert!(
select_upgrade_from_releases(&releases, &current, UpgradeChannel::Beta).is_none(),
"beta node should stay on 0.18.0-beta.1 when only its own promotion is published"
);
}

/// The skip is narrow: a genuinely newer final is still taken, so a beta node does not
/// stagnate if the beta line stalls.
#[test]
fn test_select_upgrade_beta_takes_newer_final() {
let current = Version::parse("0.18.0-beta.1").unwrap();
let releases = vec![
release_with_assets("v0.18.0"),
release_with_assets("v0.19.0"),
];

let upgrade =
select_upgrade_from_releases(&releases, &current, UpgradeChannel::Beta).unwrap();
assert_eq!(upgrade.version, Version::parse("0.19.0").unwrap());
}

/// With its own promotion and a newer beta both published, the beta wins and the promotion is
/// skipped rather than taken first.
#[test]
fn test_select_upgrade_beta_prefers_next_beta_over_own_promotion() {
let current = Version::parse("0.18.0-beta.1").unwrap();
let releases = vec![
release_with_assets("v0.18.0"),
release_with_assets("v0.19.0-beta.1"),
];

let upgrade =
select_upgrade_from_releases(&releases, &current, UpgradeChannel::Beta).unwrap();
assert_eq!(upgrade.version, Version::parse("0.19.0-beta.1").unwrap());
}

/// The skip is beta-channel only. A node running a beta build but configured for stable takes
/// the final, since that is its route back onto the stable line.
#[test]
fn test_select_upgrade_stable_takes_promotion_of_running_beta() {
let current = Version::parse("0.18.0-beta.1").unwrap();
let releases = vec![release_with_assets("v0.18.0")];

let upgrade =
select_upgrade_from_releases(&releases, &current, UpgradeChannel::Stable).unwrap();
assert_eq!(upgrade.version, Version::parse("0.18.0").unwrap());
}

/// A later beta of the same version is still an upgrade — the skip only covers finals.
#[test]
fn test_select_upgrade_beta_takes_later_beta_of_same_version() {
let current = Version::parse("0.18.0-beta.1").unwrap();
let releases = vec![release_with_assets("v0.18.0-beta.2")];

let upgrade =
select_upgrade_from_releases(&releases, &current, UpgradeChannel::Beta).unwrap();
assert_eq!(upgrade.version, Version::parse("0.18.0-beta.2").unwrap());
}

/// Ship-and-promote on the same day: a node soaking `0.16.0-beta.1` sees both the promoted
/// `0.16.0` and the next cut `0.17.0-beta.1`, and hops straight to the new beta.
#[test]
Expand Down
Loading