max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
4 files changed,
+112 insertions,
-36 deletions
| @@ -1651,7 +1651,7 @@ | |||
| 1651 | 1651 | ||
| 1652 | 1652 | [[package]] | |
| 1653 | 1653 | name = "pom" | |
| 1654 | - | version = "0.4.3" | |
| 1654 | + | version = "0.4.4" | |
| 1655 | 1655 | dependencies = [ | |
| 1656 | 1656 | "axum", | |
| 1657 | 1657 | "chrono", | |
| @@ -3601,6 +3601,46 @@ | |||
| 3601 | 3601 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3602 | 3602 | checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" | |
| 3603 | 3603 | ||
| 3604 | + | [[patch.unused]] | |
| 3605 | + | name = "quasi-axum" | |
| 3606 | + | version = "0.56.0" | |
| 3607 | + | ||
| 3608 | + | [[patch.unused]] | |
| 3609 | + | name = "quasi-basics" | |
| 3610 | + | version = "0.56.0" | |
| 3611 | + | ||
| 3612 | + | [[patch.unused]] | |
| 3613 | + | name = "quasi-http" | |
| 3614 | + | version = "0.56.0" | |
| 3615 | + | ||
| 3616 | + | [[patch.unused]] | |
| 3617 | + | name = "quasi-immediate" | |
| 3618 | + | version = "0.56.0" | |
| 3619 | + | ||
| 3620 | + | [[patch.unused]] | |
| 3621 | + | name = "quasi-notifs" | |
| 3622 | + | version = "0.56.0" | |
| 3623 | + | ||
| 3624 | + | [[patch.unused]] | |
| 3625 | + | name = "quasi-router" | |
| 3626 | + | version = "0.56.0" | |
| 3627 | + | ||
| 3628 | + | [[patch.unused]] | |
| 3629 | + | name = "quasi-store" | |
| 3630 | + | version = "0.1.0" | |
| 3631 | + | ||
| 3632 | + | [[patch.unused]] | |
| 3633 | + | name = "quasi-tauri" | |
| 3634 | + | version = "0.56.0" | |
| 3635 | + | ||
| 3636 | + | [[patch.unused]] | |
| 3637 | + | name = "quasi-webview" | |
| 3638 | + | version = "0.56.0" | |
| 3639 | + | ||
| 3640 | + | [[patch.unused]] | |
| 3641 | + | name = "quasi-type" | |
| 3642 | + | version = "0.1.0" | |
| 3643 | + | ||
| 3604 | 3644 | [[patch.unused]] | |
| 3605 | 3645 | name = "docengine" | |
| 3606 | 3646 | version = "0.7.0" | |
| @@ -3615,44 +3655,12 @@ | |||
| 3615 | 3655 | ||
| 3616 | 3656 | [[patch.unused]] | |
| 3617 | 3657 | name = "tagtree" | |
| 3618 | - | version = "0.4.0" | |
| 3658 | + | version = "0.4.1" | |
| 3619 | 3659 | ||
| 3620 | 3660 | [[patch.unused]] | |
| 3621 | 3661 | name = "synckit-client" | |
| 3622 | - | version = "0.8.0" | |
| 3662 | + | version = "0.8.1" | |
| 3623 | 3663 | ||
| 3624 | 3664 | [[patch.unused]] | |
| 3625 | 3665 | name = "synckit-config" | |
| 3626 | 3666 | version = "0.2.0" | |
| 3627 | - | ||
| 3628 | - | [[patch.unused]] | |
| 3629 | - | name = "quasi-axum" | |
| 3630 | - | version = "0.11.0" | |
| 3631 | - | ||
| 3632 | - | [[patch.unused]] | |
| 3633 | - | name = "quasi-basics" | |
| 3634 | - | version = "0.11.0" | |
| 3635 | - | ||
| 3636 | - | [[patch.unused]] | |
| 3637 | - | name = "quasi-http" | |
| 3638 | - | version = "0.11.0" | |
| 3639 | - | ||
| 3640 | - | [[patch.unused]] | |
| 3641 | - | name = "quasi-immediate" | |
| 3642 | - | version = "0.11.0" | |
| 3643 | - | ||
| 3644 | - | [[patch.unused]] | |
| 3645 | - | name = "quasi-router" | |
| 3646 | - | version = "0.11.0" | |
| 3647 | - | ||
| 3648 | - | [[patch.unused]] | |
| 3649 | - | name = "quasi-store" | |
| 3650 | - | version = "0.1.0" | |
| 3651 | - | ||
| 3652 | - | [[patch.unused]] | |
| 3653 | - | name = "quasi-tauri" | |
| 3654 | - | version = "0.11.0" | |
| 3655 | - | ||
| 3656 | - | [[patch.unused]] | |
| 3657 | - | name = "quasi-webview" | |
| 3658 | - | version = "0.11.0" |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "pom" | |
| 3 | - | version = "0.4.3" | |
| 3 | + | version = "0.4.4" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license = "LicenseRef-PolyForm-Noncommercial-1.0.0" | |
| 6 | 6 |
| @@ -1230,6 +1230,40 @@ | |||
| 1230 | 1230 | assert!(recent[0].duration_secs.is_some()); | |
| 1231 | 1231 | } | |
| 1232 | 1232 | ||
| 1233 | + | /// The restart hole, in miniature. | |
| 1234 | + | /// | |
| 1235 | + | /// An incident closes on a *confirmed* transition back to Operational. If the | |
| 1236 | + | /// process restarts across that recovery, the transition gate seeds itself from | |
| 1237 | + | /// the last stored health, observes the same status, reports no transition, and | |
| 1238 | + | /// nothing ever closes the record: the instance reads `failed` forever while the | |
| 1239 | + | /// target is up. | |
| 1240 | + | /// | |
| 1241 | + | /// Measured on astra 2026-08-23. Incident 307 (`mt`) opened 2026-08-16 16:57 and | |
| 1242 | + | /// still had a NULL `ended_at` a week later, while the two incidents opened by | |
| 1243 | + | /// the same blip in the same minute (`mnw`, `htpy`) both closed an hour on and | |
| 1244 | + | /// mt's health sat at 100% uptime throughout. | |
| 1245 | + | /// | |
| 1246 | + | /// The reconciliation is `close_open_incidents` at seed time when the seeded | |
| 1247 | + | /// status is Operational. This pins the property it relies on: closing is safe | |
| 1248 | + | /// to run when there is nothing to close, so the boot path can be unconditional | |
| 1249 | + | /// rather than having to ask first. | |
| 1250 | + | #[tokio::test] | |
| 1251 | + | async fn closing_incidents_is_idempotent_so_a_boot_time_reconcile_is_safe() { | |
| 1252 | + | let pool = db::connect_in_memory().await.unwrap(); | |
| 1253 | + | ||
| 1254 | + | // Nothing open: the reconcile a healthy target performs on every start. | |
| 1255 | + | assert_eq!(db::close_open_incidents(&pool, "mt").await.unwrap(), 0); | |
| 1256 | + | ||
| 1257 | + | db::insert_incident(&pool, "mt", "operational", "unreachable") | |
| 1258 | + | .await | |
| 1259 | + | .unwrap(); | |
| 1260 | + | // The recovery the restart swallowed, applied late. | |
| 1261 | + | assert_eq!(db::close_open_incidents(&pool, "mt").await.unwrap(), 1); | |
| 1262 | + | assert!(db::get_open_incident(&pool, "mt").await.unwrap().is_none()); | |
| 1263 | + | // And again on the next start, with nothing left to do. | |
| 1264 | + | assert_eq!(db::close_open_incidents(&pool, "mt").await.unwrap(), 0); | |
| 1265 | + | } | |
| 1266 | + | ||
| 1233 | 1267 | #[tokio::test] | |
| 1234 | 1268 | async fn incident_close_only_affects_target() { | |
| 1235 | 1269 | let pool = db::connect_in_memory().await.unwrap(); |
| @@ -49,6 +49,40 @@ | |||
| 49 | 49 | .flatten() | |
| 50 | 50 | .map(|s| s.status); | |
| 51 | 51 | let mut gate = TransitionGate::seeded(seed, confirmations); | |
| 52 | + | ||
| 53 | + | // RECONCILE THE INCIDENT LEDGER AGAINST THE SEED, before the | |
| 54 | + | // first tick. | |
| 55 | + | // | |
| 56 | + | // Seeding is what stops a restart re-firing a transition, and it | |
| 57 | + | // is also how a restart can LOSE one. An incident closes on a | |
| 58 | + | // confirmed transition back to Operational; if the process | |
| 59 | + | // restarts across that recovery, the gate seeds Operational, | |
| 60 | + | // observes Operational, reports no transition, and the incident | |
| 61 | + | // stays open forever with nothing to reopen the question. | |
| 62 | + | // | |
| 63 | + | // Measured on astra 2026-08-23: incident 307, `mt`, opened | |
| 64 | + | // 2026-08-16 16:57 with `ended_at` still NULL a week later. Two | |
| 65 | + | // sibling incidents opened in the same minute by the same blip | |
| 66 | + | // (`mnw` 308, `htpy` 309) both closed an hour later, and mt's | |
| 67 | + | // health had been Operational at 100% uptime throughout. The | |
| 68 | + | // instance had read `failed` ever since, which is a monitor | |
| 69 | + | // reporting its own bookkeeping as an outage. | |
| 70 | + | // | |
| 71 | + | // Idempotent and once per target per start, so it costs one | |
| 72 | + | // query at boot and nothing afterwards. Deliberately does NOT | |
| 73 | + | // alert: this closes a stale record, and a recovery notice for | |
| 74 | + | // an outage that ended a week ago is noise. | |
| 75 | + | if seed == Some(HealthStatus::Operational) { | |
| 76 | + | match db::close_open_incidents(&pool, &name).await { | |
| 77 | + | Ok(0) => {} | |
| 78 | + | Ok(n) => info!( | |
| 79 | + | "{name}: closed {n} incident(s) left open across a restart; health reads operational" | |
| 80 | + | ), | |
| 81 | + | Err(e) => { | |
| 82 | + | tracing::error!("{name}: failed to reconcile open incidents: {e}"); | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | } | |
| 52 | 86 | while ticks.next().await { | |
| 53 | 87 | let snapshot = http::check_health(&name, &health_config, expect).await; | |
| 54 | 88 | info!( |