OXYGENHUB

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