app: an update published over an hour before the engine started skips the hourly rollout slot

PC 1, 6 October 2026 06:58:47Z: the app came up after the 0.3.11 publish, had the installer verified 44 s
later and sat on "installs at the next safe moment" with its slot at minute 35; the project lead pressed Install now at
07:00:48Z. The slot staggers a fleet through a NEW publish; a machine that was off through the publish has
nothing to stagger. Now: published_at + 3,600 s <= engine start => slot_ok, logged once. The other safe-moment
guards (node synced, miner idle, boundary, network drop) are unchanged. manifest::unix_from_rfc3339 with tests.
For 0.3.12.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-06 08:13:51 +00:00
parent f8088c49c3
commit 50a08e8d50
2 changed files with 54 additions and 1 deletions

View file

@ -546,3 +546,42 @@ mod tests {
assert_eq!(fingerprint("zz"), "");
}
}
/// Unix seconds of a manifest `published_at` ("2026-10-04T13:00:00Z", whole seconds, UTC); `None` for any other shape.
pub fn unix_from_rfc3339(t: &str) -> Option<u64> {
let t = t.trim();
let b = t.as_bytes();
if b.len() < 20 || b[4] != b'-' || b[7] != b'-' || b[10] != b'T' || b[13] != b':' || b[16] != b':' || !t.ends_with('Z') {
return None;
}
let n = |a: usize, z: usize| t[a..z].parse::<i64>().ok();
let (y, m, d, hh, mm, ss) = (n(0, 4)?, n(5, 7)?, n(8, 10)?, n(11, 13)?, n(14, 16)?, n(17, 19)?);
if !(1..=12).contains(&m) || !(1..=31).contains(&d) || hh > 23 || mm > 59 || ss > 60 {
return None;
}
// days from civil (Howard Hinnant), valid for every date after 1970
let (y2, m2) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
let era = y2.div_euclid(400);
let yoe = y2 - era * 400;
let doy = (153 * m2 + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146097 + doe - 719468;
if days < 0 {
return None;
}
Some((days as u64) * 86400 + (hh as u64) * 3600 + (mm as u64) * 60 + ss as u64)
}
#[cfg(test)]
mod rfc3339_tests {
use super::unix_from_rfc3339;
#[test]
fn a_manifest_publish_time_parses_to_unix_seconds() {
assert_eq!(unix_from_rfc3339("1970-01-01T00:00:00Z"), Some(0));
assert_eq!(unix_from_rfc3339("2026-10-05T23:57:49Z"), Some(1791244669));
assert_eq!(unix_from_rfc3339("2026-10-04T13:00:00Z"), Some(1791118800));
assert_eq!(unix_from_rfc3339(""), None);
assert_eq!(unix_from_rfc3339("2026-10-05 23:57:49"), None);
assert_eq!(unix_from_rfc3339("2026-13-05T23:57:49Z"), None);
}
}

View file

@ -34,6 +34,8 @@ use std::process::Command;
use std::sync::Arc;
use std::time::{Duration, Instant};
/// A manifest published this long before the engine started is a catch-up: the hourly rollout slot does not apply.
const CATCH_UP_AFTER_S: u64 = 3600;
const HEALTHY_AFTER_S: u64 = 90;
const CHECK_EVERY_S: u64 = 3600;
const RETRY_AFTER_ERROR_S: u64 = 600;
@ -117,6 +119,11 @@ pub struct Updater {
deferred_until: Option<Instant>,
/// this machine's minute of the hour for applying (manifest::slot_minute of the machine id)
slot: u64,
/// When this engine started (unix seconds): an update published more than an hour before it is a catch-up, not a
/// rollout, and skips the hourly slot (the project lead's morning of 6 October 2026: PC 1 came up after the 0.3.11 publish and
/// sat on "installs at the next safe moment" until he pressed Install now).
started_unix: u64,
catch_up_logged: bool,
/// identity counts from /api/live over the last 10 minutes, sampled while an update is ready
live_samples: Vec<(Instant, u64)>,
live_next: Instant,
@ -163,6 +170,8 @@ impl Updater {
apply_launched: None,
deferred_until: None,
slot: manifest::slot_minute(&shared.runtime.id8()),
started_unix: crate::platform::unix_now(),
catch_up_logged: false,
live_samples: Vec::new(),
live_next: now,
live_busy: false,
@ -519,7 +528,12 @@ impl Updater {
}
};
let minute = (crate::platform::unix_now() / 60) % 60;
let slot_ok = minute == self.slot || std::env::var("IGNEUM_APP_UPDATE_NO_SLOT").map(|v| v == "1").unwrap_or(false);
let catch_up = self.manifest.as_ref().and_then(|m| manifest::unix_from_rfc3339(&m.published_at)).map(|p| p + CATCH_UP_AFTER_S <= self.started_unix).unwrap_or(false);
if catch_up && !self.catch_up_logged {
self.catch_up_logged = true;
shared.log(&format!("update: {} was published over an hour before this start, so it installs at the first safe moment (no hourly slot)", self.version()));
}
let slot_ok = minute == self.slot || catch_up || std::env::var("IGNEUM_APP_UPDATE_NO_SLOT").map(|v| v == "1").unwrap_or(false);
let ready_for = self.ready_since.map(|t| now.duration_since(t).as_secs()).unwrap_or(0);
let moment = Moment { node_synced: ctx.node_synced, boundary_eta_s: ctx.boundary_eta_s, miner_busy: ctx.miner_busy, ready_for_s: ready_for, urgent: urgent || self.install_asked, slot_ok, network_drop_pct };
if !self.auto && !urgent && !self.install_asked {