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 |