OXYGENHUB

Diffusion reconciler: every 10 minutes the pin index is checked and mended 0e04dfc20211

Each index write records the state it indexed (diffusion_indexed, new). A
reconciler compares each followed bookmark with that state and re-indexes
where they differ; then each consumer whose pin is not its upstream's newest
passing state, with no open or draft bump (a closed one is respected), is
caught up. Repairs are logged and counted. Tests: a stale index is mended
from the bookmark, a second pass mends nothing, a closed bump is not opened
again. docs/diffusion.md 4.1.

Closes #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:44 UTC
Signature
✔ signed by Claude Code
Hash
0e04dfc20211a964fc6d4a9f87831c208d704ebfbf2cc1b7097f2c87651ebcd9
Depends on
3776b70d9b17 42fa95540468 5d54cf746e00 757195ad7a5d 8620925e5c53 93501da2a162 a7e6e8e6094c d23464c0ac60 f677d6270298
Depended on by
e57765dee61a 3b1bc27fe805 5d90e0e045e2 62cdf34daec1
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
9 atoms, 1 tree operation, 5 files

Browse the tree at this change

added crates/hub/migrations/0069_diffusion_indexed.sql +11 −0
1 +
-- The state each followed bookmark's pins were indexed at (oxygenhub#210):
2 +
-- the reconciler compares it with the bookmark as it is and re-indexes
3 +
-- where they differ, so an index write that failed is mended within
4 +
-- minutes rather than at the next restart.
5 +
CREATE TABLE diffusion_indexed (
6 +
    consumer_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
7 +
    bookmark TEXT NOT NULL,
8 +
    heads TEXT NOT NULL,
9 +
    updated_ms INTEGER NOT NULL,
10 +
    PRIMARY KEY (consumer_id, bookmark)
11 +
);
modified crates/hub/src/diffusion.rs +121 −0
…
145 unchanged lines
146 146
        .await
147 147
        .map_err(|e| format!("insert {path}: {e}"))?;
148 148
    }
149 +
    sqlx::query("INSERT OR REPLACE INTO diffusion_indexed (consumer_id, bookmark, heads, updated_ms) VALUES (?, ?, ?, ?)")
150 +
        .bind(row.id)
151 +
        .bind(bookmark)
152 +
        .bind(heads_key(heads))
153 +
        .bind(now)
154 +
        .execute(&mut *tx)
155 +
        .await
156 +
        .map_err(|e| format!("noting the state: {e}"))?;
149 157
    tx.commit().await.map_err(|e| format!("commit: {e}"))?;
150 158
    for u in upstreams.iter().flatten() {
151 159
        mark(app, row.id);
…
39 unchanged lines
191 199
            let heads = app.repos.read(&name, move |r, _| heads_of(r, &b)).await;
192 200
            if let Ok(heads) = heads {
193 201
                record(app, &row, &bookmark, &heads).await;
202 +
            }
203 +
        }
204 +
    }
205 +
}
206 +
207 +
/// Heads as one comparable string: sorted, joined with `+`.
208 +
fn heads_key(heads: &[ChangeHash]) -> String {
209 +
    let mut h: Vec<String> = heads.iter().map(|h| h.to_hex()).collect();
210 +
    h.sort();
211 +
    h.join("+")
212 +
}
213 +
214 +
/// How often [`reconciler`] looks.
215 +
pub const RECONCILE_EVERY: std::time::Duration = std::time::Duration::from_secs(10 * 60);
216 +
217 +
/// What one reconciling pass mended.
218 +
#[derive(Debug, Default, PartialEq, Eq)]
219 +
pub struct Repairs {
220 +
    /// Followed bookmarks whose index was behind the bookmark.
221 +
    pub reindexed: usize,
222 +
    /// Consumers behind an upstream's newest passing state with no open
223 +
    /// bump, bumped now.
224 +
    pub caught_up: usize,
225 +
}
226 +
227 +
/// Every [`RECONCILE_EVERY`], mend the pin index and the bumps it drives
228 +
/// (oxygenhub#210).
229 +
pub async fn reconciler(app: Shared) {
230 +
    loop {
231 +
        tokio::time::sleep(RECONCILE_EVERY).await;
232 +
        let r = reconcile(&app).await;
233 +
        if r != Repairs::default() {
234 +
            tracing::warn!("diffusion: reconciled: {} bookmarks re-indexed, {} consumers caught up", r.reindexed, r.caught_up);
235 +
        }
236 +
    }
237 +
}
238 +
239 +
/// One pass: re-index each followed bookmark whose indexed state is not
240 +
/// the state it is at now; then bump each consumer whose pin is not its
241 +
/// upstream's newest passing state and that has no open bump for it.
242 +
pub async fn reconcile(app: &Shared) -> Repairs {
243 +
    let mut repairs = Repairs::default();
244 +
    let Ok(rows) = db::repos::list(&app.db).await else { return repairs };
245 +
    for row in &rows {
246 +
        let Some(name) = RepoName::new(&row.owner, &row.name) else { continue };
247 +
        for bookmark in followed_bookmarks(&app.db, row).await {
248 +
            let b = bookmark.clone();
249 +
            let Ok(heads) = app.repos.read(&name, move |r, _| heads_of(r, &b)).await else { continue };
250 +
            let indexed: Option<String> = sqlx::query_scalar("SELECT heads FROM diffusion_indexed WHERE consumer_id = ? AND bookmark = ?")
251 +
                .bind(row.id)
252 +
                .bind(&bookmark)
253 +
                .fetch_optional(&app.db)
254 +
                .await
255 +
                .ok()
256 +
                .flatten();
257 +
            if indexed.as_deref() != Some(heads_key(&heads).as_str()) {
258 +
                tracing::info!("diffusion: {}/{} {bookmark}: the index was not at the bookmark; re-indexing", row.owner, row.name);
259 +
                record(app, row, &bookmark, &heads).await;
260 +
                repairs.reindexed += 1;
194 261
            }
262 +
        }
263 +
    }
264 +
    // Consumers behind with no open bump.
265 +
    let pins: Vec<(i64, String, String, String)> =
266 +
        sqlx::query_as("SELECT consumer_id, path, upstream, heads FROM diffusion_pins WHERE upstream_repo_id IS NOT NULL")
267 +
            .fetch_all(&app.db)
268 +
            .await
269 +
            .unwrap_or_default();
270 +
    let mut caught = std::collections::HashSet::new();
271 +
    for (consumer_id, path, upstream, pinned) in pins {
272 +
        if caught.contains(&consumer_id) {
273 +
            continue;
274 +
        }
275 +
        let Some(up) = repo_by_oxy_id(app, &upstream).await else { continue };
276 +
        let Some(green) = last_green(app, &up).await else { continue };
277 +
        if heads_key(&green) == heads_key(&pinned.split('+').filter_map(ChangeHash::from_hex).collect::<Vec<_>>()) {
278 +
            continue;
279 +
        }
280 +
        let Ok(Some(consumer)) = db::repos::get_by_id(&app.db, consumer_id).await else { continue };
281 +
        let Some(cname) = RepoName::new(&consumer.owner, &consumer.name) else { continue };
282 +
        let learned = target(&app.db, &consumer).await;
283 +
        let modules = oxymodules(app, &cname, &learned).await;
284 +
        if rule(&app.config.diffusion.follow, &consumer, modules.as_deref(), &path).follow == Follow::Off {
285 +
            continue;
286 +
        }
287 +
        // The newest bump at this path: open or a draft is a bump already,
288 +
        // and one a person closed is a "no" the reconciler respects (the
289 +
        // upstream's next move proposes again as usual).
290 +
        let bump_bm = bump_bookmark(&path);
291 +
        let newest = || async {
292 +
            db::proposals::list(&app.db, consumer_id, &[])
293 +
                .await
294 +
                .unwrap_or_default()
295 +
                .into_iter()
296 +
                .filter(|p| p.source_bookmark == bump_bm)
297 +
                .max_by_key(|p| p.number)
298 +
                .map(|p| p.state)
299 +
        };
300 +
        let has_bump = || async { matches!(newest().await.as_deref(), Some("open" | "draft")) };
301 +
        if matches!(newest().await.as_deref(), Some("open" | "draft" | "closed")) {
302 +
            continue;
303 +
        }
304 +
        caught.insert(consumer_id);
305 +
        catch_up(app, &consumer).await;
306 +
        if has_bump().await {
307 +
            tracing::info!(
308 +
                "diffusion: {}/{} {path} was behind {}/{} with no bump; bumped",
309 +
                consumer.owner,
310 +
                consumer.name,
311 +
                up.owner,
312 +
                up.name
313 +
            );
314 +
            repairs.caught_up += 1;
195 315
        }
196 316
    }
317 +
    repairs
197 318
}
198 319
199 320
/// A bookmark's heads as the repository has them now (not a cached
…
993 unchanged lines
modified crates/hub/src/lib.rs +2 −0
…
139 unchanged lines
140 140
        let app = app.clone();
141 141
        async move { diffusion::backfill(&app).await }
142 142
    });
143 +
    // Mend the pin index and the bumps it drives (oxygenhub#210).
144 +
    tokio::spawn(diffusion::reconciler(app.clone()));
143 145
    // What each image is for, read or learned once (oxygenhub#173).
144 146
    tokio::spawn({
145 147
        let app = app.clone();
…
68 unchanged lines
modified crates/hub/tests/flow.rs +22 −0
…
3464 unchanged lines
3465 3465
    }
3466 3466
    assert_eq!(behind, 0, "the index followed the landing");
3467 3467
    assert_eq!(oxygenhub::diffusion::forced_left(&h.app, game.id), 0, "the first write did fail");
3468 +
    // The reconciler (#210): an index left behind its bookmark (as when
3469 +
    // both writes failed) is mended from the bookmark itself, and a pass
3470 +
    // with nothing to mend changes nothing.
3471 +
    let pins_of = || async {
3472 +
        sqlx::query_scalar::<_, String>("SELECT heads FROM diffusion_pins WHERE consumer_id = ?")
3473 +
            .bind(game.id)
3474 +
            .fetch_one(&h.app.db)
3475 +
            .await
3476 +
            .unwrap()
3477 +
    };
3478 +
    let right = pins_of().await;
3479 +
    sqlx::query("UPDATE diffusion_pins SET heads = 'stale' WHERE consumer_id = ?").bind(game.id).execute(&h.app.db).await.unwrap();
3480 +
    sqlx::query("DELETE FROM diffusion_indexed WHERE consumer_id = ?").bind(game.id).execute(&h.app.db).await.unwrap();
3481 +
    let r = oxygenhub::diffusion::reconcile(&h.app).await;
3482 +
    assert!(r.reindexed >= 1, "{r:?}");
3483 +
    assert_eq!(pins_of().await, right, "the pin read again from the game's bookmark");
3484 +
    assert_eq!(oxygenhub::diffusion::reconcile(&h.app).await.reindexed, 0, "nothing left to mend");
3468 3485
3469 3486
    // A private upstream: the bump's run (started by the diffusion account,
3470 3487
    // which cannot read it) still gets the subrepo it bumps.
…
60 unchanged lines
3531 3548
    }
3532 3549
    let caught = caught.expect("bumped as soon as it was turned on");
3533 3550
    assert!(caught.body.contains("api v5") && caught.title.contains("1 change"), "{:?} {:?}", caught.title, caught.body);
3551 +
    // A bump someone closed is a "no": the reconciler does not open it again.
3552 +
    sqlx::query("UPDATE proposals SET state = 'closed' WHERE id = ?").bind(caught.id).execute(&h.app.db).await.unwrap();
3553 +
    let r = oxygenhub::diffusion::reconcile(&h.app).await;
3554 +
    assert_eq!(r.caught_up, 0, "{r:?}");
3555 +
    assert!(db::proposals::list(&h.app.db, g2_row.id, &["open", "draft"]).await.unwrap().is_empty(), "not reopened");
3534 3556
}
3535 3557
3536 3558
/// Whether any job is queued or running.
…
229 unchanged lines
modified docs/diffusion.md +11 −0
…
51 unchanged lines
52 52
`GET /repos/{owner}/{name}/consumers`, and `GET /repos/{owner}/{name}/diffusion`
53 53
gives both directions with their pills (#153).
54 54
55 +
Keeping it right (oxygenhub#210). Each write records the state it indexed
56 +
(`diffusion_indexed`); a failed write is logged with the step that failed
57 +
and tried once more, and a state whose pins cannot be read leaves the
58 +
index as it was. Every ten minutes a reconciler compares each followed
59 +
bookmark with the state it was indexed at and re-indexes where they
60 +
differ; in the same pass, a consumer whose pin is not its upstream's
61 +
newest passing state, with no bump open or in draft, is caught up as if it
62 +
had just turned Diffusion on. A bump a person closed counts as a "no": it
63 +
is not opened again until the upstream moves. Repairs are logged
64 +
(`diffusion: reconciled: …`).
65 +
55 66
### 4.2 Turning it on
56 67
57 68
A consumer follows a subrepo when, in order of precedence:
…
173 unchanged lines