OXYGENHUB

Diffusion: the bump 1d634f698ecd

A bump moves a consumer's pin at a path to a newer upstream state: one
change on the target bookmark's heads replacing only the pin, signed with
the hub's key, on the hub's own bookmark diffusion/<path>, rebuilt on the
target each time. Skipped when the pin already holds the state, when the
bump already moves it there, or when the state lacks changes the pin has.
The OxygenFlow pin moves for oxy-core's build_pin_change.

refs #148
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-13 00:53 UTC
Signature
✔ signed by Andy McCall
Hash
1d634f698ecd59f20847877c4e119ed717c47c6c0e4bde499c9e1fb7fbbd3c61
Depends on
380996db84e5 42fa95540468 6adcd68b90b1 93be9ce4653e cc19697f8871 e71583501b1d f677d6270298
Depended on by
3e868a1f94e1 62cdf34daec1 e5acd2a2ceb5 9aaa88b2d9c8 f01cb865fc60 7a14bdaeb857 a7e6e8e6094c
In bookmarks
api-unknown-fields cancel-landed-runs close-proposal-140 diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
v0.5.0 v0.5.1 v0.5.2 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
5 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified crates/hub/src/diffusion.rs +133 −0
…
166 unchanged lines
167 167
    Ok(out)
168 168
}
169 169
170 +
/// The bookmark a consumer's bump for the subrepo at `path` lives on.
171 +
pub fn bump_bookmark(path: &str) -> String {
172 +
    format!("diffusion/{}", path.trim_matches('/'))
173 +
}
174 +
175 +
/// What a bump did.
176 +
#[derive(Clone, Debug, PartialEq, Eq)]
177 +
pub enum Bumped {
178 +
    /// The bump bookmark now holds the target plus one change moving the pin.
179 +
    Moved { bookmark: String, change: ChangeHash, heads: Vec<ChangeHash> },
180 +
    /// The pin already holds the upstream state.
181 +
    UpToDate,
182 +
    /// The bump bookmark already moves the pin to that state.
183 +
    Already,
184 +
    /// The upstream state does not hold everything the pin does (upstream
185 +
    /// moved backwards or was rewritten): consumers only move forward.
186 +
    Backwards,
187 +
    /// The consumer has no Oxygen pin of that upstream at `path` on its
188 +
    /// target bookmark.
189 +
    NoPin,
190 +
}
191 +
192 +
/// How the upstream's state `new` relates to a consumer's pin `old`, read
193 +
/// in the upstream: `Some(n)` new changes when `new` holds all of `old`
194 +
/// and more, `Some(0)` when it adds nothing, `None` when it lacks some.
195 +
pub async fn ahead_of(
196 +
    app: &Shared,
197 +
    upstream: &RepoName,
198 +
    old: Vec<ChangeHash>,
199 +
    new: Vec<ChangeHash>,
200 +
) -> Result<Option<usize>, crate::repos::RepoError> {
201 +
    app.repos
202 +
        .read(upstream, move |r, cache| {
203 +
            let (o, n) = (cache.summary(r, &old)?, cache.summary(r, &new)?);
204 +
            if !o.changes.iter().all(|c| n.changes.contains(c)) {
205 +
                return Ok(None);
206 +
            }
207 +
            Ok(Some(n.changes.len() - o.changes.len()))
208 +
        })
209 +
        .await
210 +
}
211 +
212 +
/// Move `consumer`'s pin of `upstream` at `path` to `new_heads`, on a bump
213 +
/// bookmark built from `target` (§4.3): one change, signed with the hub's
214 +
/// key, on the target's heads. The upstream decides forwardness.
215 +
pub async fn bump(
216 +
    app: &Shared,
217 +
    consumer: &RepoRow,
218 +
    target: &str,
219 +
    path: &str,
220 +
    upstream: &RepoRow,
221 +
    new_heads: Vec<ChangeHash>,
222 +
) -> Result<Bumped, crate::repos::RepoError> {
223 +
    use oxy_core::identity::Domain;
224 +
    use oxy_core::subrepo::Pin;
225 +
    let (Some(cname), Some(uname)) = (RepoName::new(&consumer.owner, &consumer.name), RepoName::new(&upstream.owner, &upstream.name))
226 +
    else {
227 +
        return Ok(Bumped::NoPin);
228 +
    };
229 +
    let upstream_id = app.repos.read(&uname, |r, _| Ok(r.id()?)).await?;
230 +
    // The consumer's current pin at `path` on its target, and on its bump
231 +
    // bookmark if it has one.
232 +
    let (t, p) = (target.to_string(), path.trim_matches('/').to_string());
233 +
    let (current, bumped) = app
234 +
        .repos
235 +
        .read(&cname, move |r, cache| {
236 +
            let pin_on = |bookmark: &str| -> Result<Option<Pin>, crate::repos::RepoError> {
237 +
                let heads = heads_of(r, bookmark)?;
238 +
                if heads.is_empty() {
239 +
                    return Ok(None);
240 +
                }
241 +
                let loaded = cache.load(r, &heads)?;
242 +
                let Some(f) = loaded.snapshot.files.get(&p) else { return Ok(None) };
243 +
                let oxy_core::state::FileContent::Opaque { blobs } = &f.content else { return Ok(None) };
244 +
                let Some(b) = blobs.first() else { return Ok(None) };
245 +
                Ok(Pin::decode(&r.read_blob(b)?).ok())
246 +
            };
247 +
            Ok((pin_on(&t)?, pin_on(&bump_bookmark(&p))?))
248 +
        })
249 +
        .await?;
250 +
    let Some(Pin::Oxy { repo, heads: old, bookmark }) = current else { return Ok(Bumped::NoPin) };
251 +
    if let Some(Pin::Oxy { heads: b, .. }) = &bumped {
252 +
        let (mut b, mut n) = (b.clone(), new_heads.clone());
253 +
        b.sort();
254 +
        n.sort();
255 +
        if b == n {
256 +
            return Ok(Bumped::Already);
257 +
        }
258 +
    }
259 +
    if repo != upstream_id {
260 +
        return Ok(Bumped::NoPin);
261 +
    }
262 +
    match ahead_of(app, &uname, old.clone(), new_heads.clone()).await? {
263 +
        None => return Ok(Bumped::Backwards),
264 +
        Some(0) => return Ok(Bumped::UpToDate),
265 +
        Some(_) => {}
266 +
    }
267 +
    let (key, secret) =
268 +
        crate::keys::load_secret(&app.config.keys_dir()).map_err(|e| crate::repos::RepoError::Io(std::io::Error::other(e.to_string())))?;
269 +
    let identity = app.hub_key.identity.clone();
270 +
    let upstream_label = format!("{}/{}", upstream.owner, upstream.name);
271 +
    let (t, p) = (target.to_string(), path.trim_matches('/').to_string());
272 +
    let pin = Pin::Oxy { repo, heads: new_heads.clone(), bookmark };
273 +
    let short: Vec<String> = new_heads.iter().map(|h| h.to_hex()[..12].to_string()).collect();
274 +
    let message = format!("Diffusion: move {p} to {upstream_label} {}", short.join("+"));
275 +
    app.repos
276 +
        .write(&cname, move |r, _| {
277 +
            let base = heads_of(r, &t)?;
278 +
            let change = r.build_pin_change(&base, &p, &pin, &message, key)?;
279 +
            let hash = change.hash();
280 +
            let sig = secret.sign(Domain::Change, &key, &hash);
281 +
            let mut state = r.load_state(&base)?;
282 +
            state.add(change.clone()).map_err(|e| crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(e.to_string())))?;
283 +
            let mut candidates = base.clone();
284 +
            candidates.push(hash);
285 +
            let heads = state.maximal(&candidates);
286 +
            let bookmark = bump_bookmark(&p);
287 +
            // The bump bookmark is the hub's own and is rebuilt on the
288 +
            // target each time, so the bump it replaces may go.
289 +
            let mut update = r.host_ref_update(&bookmark, &heads, Some((&key, &secret)))?;
290 +
            update.force = true;
291 +
            let report = r.apply_hosted(Some((change, vec![sig])), &[identity], update)?;
292 +
            if let Some(refused) = report.results.iter().find(|x| !x.ok) {
293 +
                return Err(crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(format!(
294 +
                    "the bump was refused: {}",
295 +
                    refused.reason
296 +
                ))));
297 +
            }
298 +
            Ok(Bumped::Moved { bookmark, change: hash, heads })
299 +
        })
300 +
        .await
301 +
}
302 +
170 303
/// When a consumer takes an upstream's moves (§4.2).
171 304
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
172 305
#[serde(rename_all = "lowercase")]
…
142 unchanged lines
modified crates/hub/tests/flow.rs +38 −0
…
3177 unchanged lines
3178 3178
    assert!(!page.contains("andy/game"));
3179 3179
    sqlx::query("UPDATE repositories SET visibility = 'public' WHERE name = 'game'").execute(&h.app.db).await.unwrap();
3180 3180
3181 +
    // A bump (DF3): one signed change on the target moving only the pin, on
3182 +
    // `diffusion/deps/api`; again with the same state, nothing new.
3183 +
    let game_row = db::repos::get(&h.app.db, "andy", "game").await.unwrap().unwrap();
3184 +
    let api_row = db::repos::get(&h.app.db, "andy", "api").await.unwrap().unwrap();
3185 +
    let api_heads = api.bookmark_heads().unwrap();
3186 +
    let bumped = oxygenhub::diffusion::bump(&h.app, &game_row, "main", "deps/api", &api_row, api_heads.clone()).await.unwrap();
3187 +
    let oxygenhub::diffusion::Bumped::Moved { bookmark, heads, .. } = bumped else { panic!("{bumped:?}") };
3188 +
    assert_eq!(bookmark, "diffusion/deps/api");
3189 +
    game.fetch("origin").unwrap();
3190 +
    let bump_state = game.load_state(&heads).unwrap().materialize();
3191 +
    let pin = match &bump_state.files["deps/api"].content {
3192 +
        oxy_core::state::FileContent::Opaque { blobs } => oxy_core::subrepo::Pin::decode(&game.read_blob(&blobs[0]).unwrap()).unwrap(),
3193 +
        other => panic!("{other:?}"),
3194 +
    };
3195 +
    assert!(
3196 +
        matches!(&pin, oxy_core::subrepo::Pin::Oxy { heads, .. } if { let mut a = heads.clone(); a.sort(); let mut b = api_heads.clone(); b.sort(); a == b }),
3197 +
        "{pin:?}"
3198 +
    );
3199 +
    assert!(bump_state.files.contains_key("game.c"), "the rest of the game is as it was");
3200 +
    assert_eq!(
3201 +
        oxygenhub::diffusion::bump(&h.app, &game_row, "main", "deps/api", &api_row, api_heads.clone()).await.unwrap(),
3202 +
        oxygenhub::diffusion::Bumped::Already
3203 +
    );
3204 +
    // A state lacking what the pin has is not followed backwards.
3205 +
    write(api_work.path(), "other.c", "v3\n");
3206 +
    api.snapshot("t").unwrap();
3207 +
    let v3 = api.commit("api v3, on its own", &[]).unwrap();
3208 +
    assert!(api.push("origin", false).unwrap().iter().all(|r| r.ok));
3209 +
    let only_v3 = api.load_state(&[v3]).unwrap().heads().to_vec();
3210 +
    assert_eq!(
3211 +
        oxygenhub::diffusion::bump(&h.app, &game_row, "main", "deps/api", &api_row, only_v3).await.unwrap(),
3212 +
        oxygenhub::diffusion::Bumped::Backwards
3213 +
    );
3214 +
    // The upstream moves again: the same bookmark moves.
3215 +
    let newer = api.bookmark_heads().unwrap();
3216 +
    let again = oxygenhub::diffusion::bump(&h.app, &game_row, "main", "deps/api", &api_row, newer).await.unwrap();
3217 +
    assert!(matches!(again, oxygenhub::diffusion::Bumped::Moved { ref bookmark, .. } if bookmark == "diffusion/deps/api"), "{again:?}");
3218 +
3181 3219
    // Following is off by default; owners turn it on (DF2).
3182 3220
    let put =
3183 3221
        |body: &'static str, who: Option<&'static str>| call(&h.app, "PUT", "/api/v1/repos/andy/game/diffusion", who, Some(body.into()));
…
17 unchanged lines
replaced deps/oxygenflow +0 −0

No line changes.

modified docs/spec.md +1 −1
…
514 unchanged lines
515 515
  - its key is the one the runner's token first signed with. The first signed receipt under a token binds that token to its key in `flow_runner_keys`.
516 516
517 517
  Otherwise the job's result still stands, and the run page says why the receipt was not kept. The run page links each kept receipt, with its signer. The change page lists the receipts for the state whose only head is that change. `/{owner}/{name}/receipts/{attest}` downloads a signed receipt for `oxy receipt verify` or `import`.
518 -
- **Architecture** (OxygenFlow spec §11.2–§11.3, oxygenflow#36): a runner reports each image's architecture on its claims (`image_archs`), kept in `flow_images`; a job is offered only to a runner whose architecture matches its sandbox's `arch`, else its image's as known; unknown matches, as before. The queue says which architecture a waiting job asks for. **Images by hash** (oxygenflow#39, Jetstream): `flow rootfs push HASH` sends a rootfs image as the FastCDC chunks the hub lacks (`POST /flow/images/missing`, `PUT /flow/images/chunks/{hash}`, by a token that may write repositories) and then its manifest (`PUT /flow/images/{hash}`), which the hub accepts only when every chunk is held and they make an image with that hash; chunks are kept zstd-compressed under `<data>/images`, shared by every image, and `jet_images` lists what is held. Runners and accounts fetch with `GET /flow/images/{hash}` and `GET /flow/images/chunks/{hash}`. A runner whose claims say `fetches_images` is offered jobs whose image only the hub holds, and fetches it before its preflight. Each claim also carries the runner's `facts` (OxygenFlow spec §11.1, oxygenflow#40): free disk, load, speed score and class, kernel; the hub keeps the latest on the runner, shows them on Admin › Runners and in `GET /runners`. A job's finish carries the most disk and memory it took (`peak_disk_mib`, `peak_memory_mib`, oxygenflow#41); a new job's `need_disk_mib` is the most its last five runs of the same repository and name took, plus a quarter and 64 MiB, and it is offered only to a runner whose claim reports at least that much free disk; the waiting reason says so. **Ranking** (oxygenflow#42): a job that another free runner which polled in the last 10 s would do better (it holds the job's image while the claimant would fetch it, or has a higher speed class) is held from the claimant for up to 15 s, then any runner that fits takes it; each job records why it ran where it did (`placed`: "holds the image, idle, speed 100 (class 3)", with "after N s held for a better runner" when it was), shown on the run page, in the API and by `oh run view`. A run whose jobs pin an image held by neither the hub nor a runner sets a failing `pipeline/images` check on its state (#137), naming the images and how to send them (`flow rootfs push`), so a proposal pinning a new image shows it before it lands; a later run of the state turns it to passed once every image is held. A placement pass every 15 seconds works out why each ready queued job no runner can take waits, from what each online runner offered at its last claim (scope, tier, architecture, image held, labels, memory and CPUs, a recent decline), and shows it on the job (API `waiting`, `oh run view` and `oh run watch`, its panel open on the run page) and in its check's summary, so a proposal says why its check is stuck (#28). **Diffusion** (#148, `docs/diffusion.md`): a push that moves a repository's default bookmark, or the bookmark its work lands on (the most common target of its proposals landed in the last 90 days), records each Oxygen subrepo pin in the new state (`diffusion_pins`: consumer, bookmark, path, the upstream's Oxygen repository id, the pinned heads); a hub that had no index fills it at start. `GET /repos/{owner}/{name}/consumers` lists the consumers the caller may read, each with its pin and how many changes of this repository's default bookmark it lacks (`behind`, null when the pin names changes this repository does not hold), and the overview shows them as Used by. A consumer follows a subrepo's upstream as its `.oxymodules` entry says (`follow = off | green | any`, `land = propose | auto`, `target` naming the bookmark to bump, `watch` listing paths whose upstream changes are flagged), else as its repository setting says (Settings › Diffusion, or `PUT /repos/{owner}/{name}/diffusion {"follow": ...}` by its owners and admins; migration 0058), else as `[diffusion] follow` in hub.toml says (default `off`). **Images page** (#146): Admin › Runners › Images lists every rootfs image a runner holds, the hub keeps, or a job ran on in the last 90 days (from each job's sandbox), newest use first: its size and architecture, where it is (the hub, each runner holding it, each runner fetching it from the swarm now), the repositories whose jobs used it, and who sent it; an image held nowhere is marked. **The swarm** (#144, oxygenflow docs/jetstream.md §8.1): a runner in an image swarm reports its peer address, its site and the BLAKE3 of its TLS certificate with each claim (`swarm`; without a certificate it is not in the swarm), kept in memory and shown on Admin › Runners. `POST /flow/swarm/announce {image, state}` (by a runner; `fetching`, `done` or `stopped`) returns the online runners in its site that hold the image or announced fetching it in the last 45 seconds, each with its certificate's fingerprint, which the fetcher pins its TLS connection to, and a ticket signed with the hub key (JSON naming the runner, its site and an expiry 15 minutes on, hex, then the signature) that peers check against `/.well-known/oxyhub-key` before serving chunks; a runner that has not reported an address is told to add `[runner.swarm]` (409).
518 +
- **Architecture** (OxygenFlow spec §11.2–§11.3, oxygenflow#36): a runner reports each image's architecture on its claims (`image_archs`), kept in `flow_images`; a job is offered only to a runner whose architecture matches its sandbox's `arch`, else its image's as known; unknown matches, as before. The queue says which architecture a waiting job asks for. **Images by hash** (oxygenflow#39, Jetstream): `flow rootfs push HASH` sends a rootfs image as the FastCDC chunks the hub lacks (`POST /flow/images/missing`, `PUT /flow/images/chunks/{hash}`, by a token that may write repositories) and then its manifest (`PUT /flow/images/{hash}`), which the hub accepts only when every chunk is held and they make an image with that hash; chunks are kept zstd-compressed under `<data>/images`, shared by every image, and `jet_images` lists what is held. Runners and accounts fetch with `GET /flow/images/{hash}` and `GET /flow/images/chunks/{hash}`. A runner whose claims say `fetches_images` is offered jobs whose image only the hub holds, and fetches it before its preflight. Each claim also carries the runner's `facts` (OxygenFlow spec §11.1, oxygenflow#40): free disk, load, speed score and class, kernel; the hub keeps the latest on the runner, shows them on Admin › Runners and in `GET /runners`. A job's finish carries the most disk and memory it took (`peak_disk_mib`, `peak_memory_mib`, oxygenflow#41); a new job's `need_disk_mib` is the most its last five runs of the same repository and name took, plus a quarter and 64 MiB, and it is offered only to a runner whose claim reports at least that much free disk; the waiting reason says so. **Ranking** (oxygenflow#42): a job that another free runner which polled in the last 10 s would do better (it holds the job's image while the claimant would fetch it, or has a higher speed class) is held from the claimant for up to 15 s, then any runner that fits takes it; each job records why it ran where it did (`placed`: "holds the image, idle, speed 100 (class 3)", with "after N s held for a better runner" when it was), shown on the run page, in the API and by `oh run view`. A run whose jobs pin an image held by neither the hub nor a runner sets a failing `pipeline/images` check on its state (#137), naming the images and how to send them (`flow rootfs push`), so a proposal pinning a new image shows it before it lands; a later run of the state turns it to passed once every image is held. A placement pass every 15 seconds works out why each ready queued job no runner can take waits, from what each online runner offered at its last claim (scope, tier, architecture, image held, labels, memory and CPUs, a recent decline), and shows it on the job (API `waiting`, `oh run view` and `oh run watch`, its panel open on the run page) and in its check's summary, so a proposal says why its check is stuck (#28). **Diffusion** (#148, `docs/diffusion.md`): a push that moves a repository's default bookmark, or the bookmark its work lands on (the most common target of its proposals landed in the last 90 days), records each Oxygen subrepo pin in the new state (`diffusion_pins`: consumer, bookmark, path, the upstream's Oxygen repository id, the pinned heads); a hub that had no index fills it at start. `GET /repos/{owner}/{name}/consumers` lists the consumers the caller may read, each with its pin and how many changes of this repository's default bookmark it lacks (`behind`, null when the pin names changes this repository does not hold), and the overview shows them as Used by. A consumer follows a subrepo's upstream as its `.oxymodules` entry says (`follow = off | green | any`, `land = propose | auto`, `target` naming the bookmark to bump, `watch` listing paths whose upstream changes are flagged), else as its repository setting says (Settings › Diffusion, or `PUT /repos/{owner}/{name}/diffusion {"follow": ...}` by its owners and admins; migration 0058), else as `[diffusion] follow` in hub.toml says (default `off`). A **bump** moves a consumer's pin at a path to a newer upstream state: one change on the target bookmark's heads that replaces only the pin (oxy-core `build_pin_change`), signed with the hub's key, on the bookmark `diffusion/<path>`, which the hub owns and rebuilds on the target each time (a forced update). It is skipped when the pin already holds the state, when the bump bookmark already moves it there, or when the state lacks changes the pin has (consumers only move forward). **Images page** (#146): Admin › Runners › Images lists every rootfs image a runner holds, the hub keeps, or a job ran on in the last 90 days (from each job's sandbox), newest use first: its size and architecture, where it is (the hub, each runner holding it, each runner fetching it from the swarm now), the repositories whose jobs used it, and who sent it; an image held nowhere is marked. **The swarm** (#144, oxygenflow docs/jetstream.md §8.1): a runner in an image swarm reports its peer address, its site and the BLAKE3 of its TLS certificate with each claim (`swarm`; without a certificate it is not in the swarm), kept in memory and shown on Admin › Runners. `POST /flow/swarm/announce {image, state}` (by a runner; `fetching`, `done` or `stopped`) returns the online runners in its site that hold the image or announced fetching it in the last 45 seconds, each with its certificate's fingerprint, which the fetcher pins its TLS connection to, and a ticket signed with the hub key (JSON naming the runner, its site and an expiry 15 minutes on, hex, then the signature) that peers check against `/.well-known/oxyhub-key` before serving chunks; a runner that has not reported an address is told to add `[runner.swarm]` (409).
519 519
- **Variables** (oxygenhub#102): Administration, an organisation's settings and a repository's settings each hold `NAME=value` pairs (a name is a letter or `_` then letters, digits and `_`, up to 64, never `FLOW_*`; a value up to 4096 characters; up to 100 per level) under **Variables**, each page showing what it inherits and which inherited names it overrides. A claim carries the repository's effective set as `env`, inner levels overriding outer; Flow puts them in the job's environment under `FLOW_*` and the pipeline's own `env`, so they are part of the receipt. They are not secrets and are shown in full to whoever sees the page; secrets are mounted by Flow (OxygenFlow spec §9), never put in the environment. Changes are audited (`flow.variable.set`, `flow.variable.deleted`); `flow run --var NAME=VALUE` gives a local run the same.
520 520
- **Secrets** (oxygenhub#107; OxygenFlow spec §9): the same three levels hold **Secrets**, named as a pipeline's `secret "name" { mount-path "/run/secrets/…" }` names them (1 to 64 of `A-Z a-z 0-9 . _ -`, a value up to 64 KiB, up to 100 per level), typed once and sealed under `keys/secrets.key`; pages show names, when each was set and where it comes from, never a value. A claim carries only the secrets its job declares, each with its path and value, from the innermost level that sets the name; one no level sets is noted at the top of the job's log and left out, so the job fails on the missing file rather than running with a stand-in. Flow writes each as a file (0400, the job's user's, on an in-memory mount) before the job starts and never puts it in the environment, the receipt or an artifact. Log intake replaces each value, and its base64, with `***` before a record is stored. Changes are audited (`flow.secret.set`, `flow.secret.deleted`), never with the value.
521 521
- **Artifacts** (OxygenFlow spec §5.2): a job may declare artifacts with `output`.
…
231 unchanged lines