Diffusion index writes log every failure and try once more 5d54cf746e00
record() now says which step failed (reading the pins, begin, delete, insert, commit), with the repository and bookmark, waits 250 ms and tries again; a second failure is logged as an error and counted. A state whose pins cannot be read no longer empties the index (pins_in returned an empty list on any error). The landing test forces the first write to fail and checks the index still follows; a timing-sensitive wait in the bump test gets 15 s under a full parallel run. Refs #210
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-16 01:23 UTC
- Signature
- ✔ signed by Claude Code
- Hash
5d54cf746e00d45c6b6aaf56a134676c71e01469210c0d73fef3f1cc1f9bd32e- Depends on
- 25091e5c3bba 3776b70d9b17 42fa95540468 757195ad7a5d f677d6270298 fd3db644df22
- Depended on by
- 5d90e0e045e2 0e04dfc20211
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 16 atoms, 0 tree operations, 3 files
Browse the tree at this change
modified crates/hub/src/diffusion.rs +79 −13
| … | 47 unchanged lines |
||
| 48 | 48 | ||
| 49 | 49 | /// Record `row`'s pins on `bookmark` at `heads`, replacing what was there. |
|
| 50 | 50 | pub async fn record(app: &Shared, row: &RepoRow, bookmark: &str, heads: &[ChangeHash]) { |
|
| 51 | - | let Some(name) = RepoName::new(&row.owner, &row.name) else { return }; |
|
| 52 | - | let pins = if heads.is_empty() { Vec::new() } else { crate::flow::pins_in(app, name, heads.to_vec()).await }; |
|
| 51 | + | // A failure is logged and tried once more after a moment (oxygenhub#210): |
|
| 52 | + | // an index left behind shows a consumer behind with no bump. |
|
| 53 | + | let repo = format!("{}/{}", row.owner, row.name); |
|
| 54 | + | for attempt in 1..=2 { |
|
| 55 | + | match try_record(app, row, bookmark, heads).await { |
|
| 56 | + | Ok(()) => { |
|
| 57 | + | if attempt > 1 { |
|
| 58 | + | tracing::info!("diffusion: indexed {repo} {bookmark} on the second try"); |
|
| 59 | + | } |
|
| 60 | + | return; |
|
| 61 | + | } |
|
| 62 | + | Err(why) if attempt == 1 => { |
|
| 63 | + | tracing::warn!("diffusion: indexing {repo} {bookmark} failed ({why}); trying again"); |
|
| 64 | + | tokio::time::sleep(RETRY_AFTER).await; |
|
| 65 | + | } |
|
| 66 | + | Err(why) => { |
|
| 67 | + | FAILURES.fetch_add(1, std::sync::atomic::Ordering::Relaxed); |
|
| 68 | + | tracing::error!("diffusion: indexing {repo} {bookmark} failed again ({why}); its pins stay as they were"); |
|
| 69 | + | } |
|
| 70 | + | } |
|
| 71 | + | } |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | /// How long [`record`] waits before its second try. |
|
| 75 | + | const RETRY_AFTER: std::time::Duration = std::time::Duration::from_millis(250); |
|
| 76 | + | ||
| 77 | + | /// Indexing failures that the retry did not mend, since the hub started. |
|
| 78 | + | pub static FAILURES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); |
|
| 79 | + | ||
| 80 | + | /// Failures to force, per repository (tests only: [`fail_next_records`]). |
|
| 81 | + | static FORCED: std::sync::Mutex<Option<std::collections::HashMap<String, u32>>> = std::sync::Mutex::new(None); |
|
| 82 | + | ||
| 83 | + | /// Make the next `n` index writes for a repository fail, as a busy |
|
| 84 | + | /// database would (for tests of the retry). |
|
| 85 | + | #[doc(hidden)] |
|
| 86 | + | pub fn fail_next_records(app: &Shared, repo_id: i64, n: u32) { |
|
| 87 | + | FORCED.lock().unwrap_or_else(|p| p.into_inner()).get_or_insert_with(Default::default).insert(key(app, repo_id), n); |
|
| 88 | + | } |
|
| 89 | + | ||
| 90 | + | /// Forced failures not yet used (tests only). |
|
| 91 | + | #[doc(hidden)] |
|
| 92 | + | pub fn forced_left(app: &Shared, repo_id: i64) -> u32 { |
|
| 93 | + | FORCED.lock().unwrap_or_else(|p| p.into_inner()).as_ref().and_then(|m| m.get(&key(app, repo_id)).copied()).unwrap_or(0) |
|
| 94 | + | } |
|
| 95 | + | ||
| 96 | + | fn forced(app: &Shared, repo_id: i64) -> bool { |
|
| 97 | + | let mut forced = FORCED.lock().unwrap_or_else(|p| p.into_inner()); |
|
| 98 | + | match forced.as_mut().and_then(|m| m.get_mut(&key(app, repo_id))) { |
|
| 99 | + | Some(n) if *n > 0 => { |
|
| 100 | + | *n -= 1; |
|
| 101 | + | true |
|
| 102 | + | } |
|
| 103 | + | _ => false, |
|
| 104 | + | } |
|
| 105 | + | } |
|
| 106 | + | ||
| 107 | + | /// One try at replacing a bookmark's indexed pins; which step failed and |
|
| 108 | + | /// why. A state that cannot be read leaves the index as it was rather than |
|
| 109 | + | /// emptying it. |
|
| 110 | + | async fn try_record(app: &Shared, row: &RepoRow, bookmark: &str, heads: &[ChangeHash]) -> Result<(), String> { |
|
| 111 | + | let Some(name) = RepoName::new(&row.owner, &row.name) else { return Ok(()) }; |
|
| 112 | + | let pins = if heads.is_empty() { |
|
| 113 | + | Vec::new() |
|
| 114 | + | } else { |
|
| 115 | + | crate::flow::pins_in(app, name, heads.to_vec()).await.map_err(|e| format!("reading the pins: {e}"))? |
|
| 116 | + | }; |
|
| 53 | 117 | let now = db::now_ms(); |
|
| 54 | 118 | // The upstream's repository on this hub, when it is one. |
|
| 55 | 119 | let mut upstreams = Vec::new(); |
|
| 56 | 120 | for (_, id, _) in &pins { |
|
| 57 | 121 | upstreams.push(repo_by_oxy_id(app, &hex::encode(id)).await.map(|r| r.id)); |
|
| 58 | 122 | } |
|
| 59 | - | let Ok(mut tx) = app.db.begin().await else { return }; |
|
| 60 | - | let mut ok = sqlx::query("DELETE FROM diffusion_pins WHERE consumer_id = ? AND bookmark = ?") |
|
| 123 | + | if forced(app, row.id) { |
|
| 124 | + | return Err("begin: forced for a test".into()); |
|
| 125 | + | } |
|
| 126 | + | let mut tx = app.db.begin().await.map_err(|e| format!("begin: {e}"))?; |
|
| 127 | + | sqlx::query("DELETE FROM diffusion_pins WHERE consumer_id = ? AND bookmark = ?") |
|
| 61 | 128 | .bind(row.id) |
|
| 62 | 129 | .bind(bookmark) |
|
| 63 | 130 | .execute(&mut *tx) |
|
| 64 | 131 | .await |
|
| 65 | - | .is_ok(); |
|
| 132 | + | .map_err(|e| format!("delete: {e}"))?; |
|
| 66 | 133 | for ((path, id, pinned), upstream_row) in pins.iter().zip(&upstreams) { |
|
| 67 | 134 | let heads: Vec<String> = pinned.iter().map(|h| h.to_hex()).collect(); |
|
| 68 | - | ok &= sqlx::query( |
|
| 135 | + | sqlx::query( |
|
| 69 | 136 | "INSERT INTO diffusion_pins (consumer_id, bookmark, path, upstream, heads, updated_ms, upstream_repo_id) VALUES (?, ?, ?, ?, ?, ?, ?)", |
|
| 70 | 137 | ) |
|
| 71 | 138 | .bind(row.id) |
|
| … | 5 unchanged lines |
||
| 77 | 144 | .bind(upstream_row) |
|
| 78 | 145 | .execute(&mut *tx) |
|
| 79 | 146 | .await |
|
| 80 | - | .is_ok(); |
|
| 81 | - | if let Some(u) = upstream_row { |
|
| 82 | - | mark(app, row.id); |
|
| 83 | - | mark(app, *u); |
|
| 84 | - | } |
|
| 147 | + | .map_err(|e| format!("insert {path}: {e}"))?; |
|
| 85 | 148 | } |
|
| 86 | - | if ok { |
|
| 87 | - | tx.commit().await.ok(); |
|
| 149 | + | tx.commit().await.map_err(|e| format!("commit: {e}"))?; |
|
| 150 | + | for u in upstreams.iter().flatten() { |
|
| 151 | + | mark(app, row.id); |
|
| 152 | + | mark(app, *u); |
|
| 88 | 153 | } |
|
| 154 | + | Ok(()) |
|
| 89 | 155 | } |
|
| 90 | 156 | ||
| 91 | 157 | /// After a push: index the pins of each moved bookmark that is followed. |
|
| … | 948 unchanged lines |
modified crates/hub/src/flow.rs +3 −3
| … | 976 unchanged lines |
||
| 977 | 977 | app: &Shared, |
|
| 978 | 978 | name: crate::repos::RepoName, |
|
| 979 | 979 | heads: Vec<oxy_core::hash::ChangeHash>, |
|
| 980 | - | ) -> Vec<(String, [u8; 16], Vec<oxy_core::hash::ChangeHash>)> { |
|
| 980 | + | ) -> Result<Vec<(String, [u8; 16], Vec<oxy_core::hash::ChangeHash>)>, String> { |
|
| 981 | 981 | app.repos |
|
| 982 | 982 | .read(&name, move |repo, cache| { |
|
| 983 | 983 | let loaded = cache.load(repo, &heads)?; |
|
| … | 17 unchanged lines |
||
| 1001 | 1001 | Ok(out) |
|
| 1002 | 1002 | }) |
|
| 1003 | 1003 | .await |
|
| 1004 | - | .unwrap_or_default() |
|
| 1004 | + | .map_err(|e| e.to_string()) |
|
| 1005 | 1005 | } |
|
| 1006 | 1006 | ||
| 1007 | 1007 | /// How deep subrepos of subrepos are followed. |
|
| … | 23 unchanged lines |
||
| 1031 | 1031 | if depth == MAX_SUBREPO_DEPTH { |
|
| 1032 | 1032 | continue; |
|
| 1033 | 1033 | } |
|
| 1034 | - | for (path, id, pinned) in pins_in(app, name, heads).await { |
|
| 1034 | + | for (path, id, pinned) in pins_in(app, name, heads).await.unwrap_or_default() { |
|
| 1035 | 1035 | let path = if prefix.is_empty() { path } else { format!("{prefix}/{path}") }; |
|
| 1036 | 1036 | let mut hosted = false; |
|
| 1037 | 1037 | for other in &rows { |
|
| … | 889 unchanged lines |
modified crates/hub/tests/flow.rs +7 −2
| … | 3389 unchanged lines |
||
| 3390 | 3390 | assert_eq!(st, StatusCode::NOT_FOUND, "administrators only"); |
|
| 3391 | 3391 | ||
| 3392 | 3392 | // Landing the bump on the hub moves the game's pin as a push would: |
|
| 3393 | - | // the index follows, so it is no longer behind. |
|
| 3393 | + | // the index follows, so it is no longer behind; even when the first |
|
| 3394 | + | // write fails, as a busy database's would (#210: it is tried again). |
|
| 3395 | + | let game = oxygenhub::db::repos::get(&h.app.db, "andy", "game").await.unwrap().unwrap(); |
|
| 3396 | + | oxygenhub::diffusion::fail_next_records(&h.app, game.id, 1); |
|
| 3394 | 3397 | let (st, body) = |
|
| 3395 | 3398 | call(&h.app, "POST", &format!("/api/v1/repos/andy/game/proposals/{}/land", p2.number), Some("site"), Some("{}".into())).await; |
|
| 3396 | 3399 | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| … | 8 unchanged lines |
||
| 3405 | 3408 | tokio::time::sleep(std::time::Duration::from_millis(50)).await; |
|
| 3406 | 3409 | } |
|
| 3407 | 3410 | assert_eq!(behind, 0, "the index followed the landing"); |
|
| 3411 | + | assert_eq!(oxygenhub::diffusion::forced_left(&h.app, game.id), 0, "the first write did fail"); |
|
| 3408 | 3412 | ||
| 3409 | 3413 | // A consumer turning Diffusion on is bumped at once to the upstream's |
|
| 3410 | 3414 | // newest passing state, without waiting for it to move again. |
|
| … | 27 unchanged lines |
||
| 3438 | 3442 | let (st, _) = call(&h.app, "PUT", "/api/v1/repos/andy/game2/diffusion", Some("site"), Some(r#"{"follow":"green"}"#.into())).await; |
|
| 3439 | 3443 | assert_eq!(st, StatusCode::OK); |
|
| 3440 | 3444 | let mut caught = None; |
|
| 3441 | - | for _ in 0..100 { |
|
| 3445 | + | // Up to 15 s: under a full parallel test run the bump can take a while. |
|
| 3446 | + | for _ in 0..300 { |
|
| 3442 | 3447 | if let Some(p) = db::proposals::list(&h.app.db, g2_row.id, &["open"]).await.unwrap().into_iter().next() { |
|
| 3443 | 3448 | caught = Some(p); |
|
| 3444 | 3449 | break; |
|
| … | 79 unchanged lines |