Peer-held images: the hub keeps runners' manifests of images it lacks and places their jobs where a peer in the site holds them (closes #342; pins OxygenFlow #94) 951aed50c2f5
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 32754bcc2e32
- When
- · 2026-10-27 02:34 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
951aed50c2f5854e52d81f28953e61194b4f20492918fe9bbb1ed6c8fd1e64cc- Depends on
- 380996db84e5 3b1bc27fe805 42fa95540468 44a3d2ebaddd 4d2e44d79b03 4fa0da126b70 501d8b268dd4 56b8dbe66974 58cbf42447bc 5caa65fd51e7 5d90e0e045e2 668b2ad7495c 6ca0b971ff07 750579ecc721 92a797b20912 93501da2a162 a46d15e72101 a7e6e8e6094c b0612e7dce30 b5668108487f c95446a17fdd d23464c0ac60 efd29a0033e8
- Depended on by
- 0468394734e9 c4bffee5b0d4
- In bookmarks
- main
- In tags
- v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.8 v0.9.9
- Edits
- 33 atoms, 2 tree operations, 10 files
Browse the tree at this change
added crates/hub/migrations-pg/0098_peer_images.sql +7 −0
| 1 | + | -- Images only runners hold (oxygenflow#94, oxygenhub#342): a runner sends the |
|
| 2 | + | -- manifest of an image it holds that the hub keeps none of, and the swarm |
|
| 3 | + | -- moves the chunks between runners. `source` is 'hub' for an image pushed |
|
| 4 | + | -- here and verified whole, 'peer' for one only runners hold; `holder` names |
|
| 5 | + | -- the runner that sent a peer manifest. |
|
| 6 | + | ALTER TABLE jet_images ADD COLUMN source TEXT NOT NULL DEFAULT 'hub'; |
|
| 7 | + | ALTER TABLE jet_images ADD COLUMN holder TEXT NOT NULL DEFAULT ''; |
added crates/hub/migrations/0098_peer_images.sql +7 −0
| 1 | + | -- Images only runners hold (oxygenflow#94, oxygenhub#342): a runner sends the |
|
| 2 | + | -- manifest of an image it holds that the hub keeps none of, and the swarm |
|
| 3 | + | -- moves the chunks between runners. `source` is 'hub' for an image pushed |
|
| 4 | + | -- here and verified whole, 'peer' for one only runners hold; `holder` names |
|
| 5 | + | -- the runner that sent a peer manifest. |
|
| 6 | + | ALTER TABLE jet_images ADD COLUMN source TEXT NOT NULL DEFAULT 'hub'; |
|
| 7 | + | ALTER TABLE jet_images ADD COLUMN holder TEXT NOT NULL DEFAULT ''; |
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Images only runners hold (#342, oxygenflow#94).** A runner that serves its peers sends the manifest of each image it holds that the hub lacks (`POST /flow/images/held`, `PUT /flow/images/{hash}/held`, runner tokens only); the hub keeps it as held by runners and offers the image's jobs to runners of the same swarm site, which fetch it from their peers (the announce says the hub is no seed for it). A pushed image replaces such a record. On 2026-10-25 a re-pinned image that was never pushed kept every Rust job on one runner for a day. Migration 0098 (`jet_images.source`, `holder`). |
|
| 8 | + | ||
| 7 | 9 | ## 0.9.7 (2026-10-27) |
|
| 8 | 10 | ||
| 9 | 11 | - **A push to a landed proposal's bookmark says so (#329).** The changes reach no target until a new proposal takes them, and the push was silent: now it is answered `its proposal #N has landed in TARGET, and these changes are not on TARGET: open a new proposal with oh proposal create --from BOOKMARK` (oxygen's `Backend::push_notes`). The landed proposal's page lists them as changes since it landed, with a link to a new proposal; it no longer shows them as its own, and no longer offers to delete a bookmark that holds them. |
|
| … | 356 unchanged lines |
modified crates/hub/src/api/images.rs +47 −0
| … | 116 unchanged lines |
||
| 117 | 117 | Ok((StatusCode::CREATED, Json(serde_json::json!({ "hash": m.hash, "size": m.size, "chunks": m.chunks.len(), "arch": m.arch })))) |
|
| 118 | 118 | } |
|
| 119 | 119 | ||
| 120 | + | /// Most images one `held` question may name. |
|
| 121 | + | const MAX_HELD: usize = 1000; |
|
| 122 | + | ||
| 123 | + | /// `POST /flow/images/held` (a runner's token): the images it holds; the |
|
| 124 | + | /// answer names those the hub keeps no manifest of, for the runner to send |
|
| 125 | + | /// with `PUT /flow/images/{hash}/held` (oxygenflow#94). |
|
| 126 | + | pub async fn held( |
|
| 127 | + | State(app): State<Shared>, |
|
| 128 | + | headers: HeaderMap, |
|
| 129 | + | Json(ask): Json<flow_core::api::HeldImages>, |
|
| 130 | + | ) -> Result<Json<flow_core::api::WantedManifests>, ApiError> { |
|
| 131 | + | crate::flow::runner_auth(&app, &headers).await?; |
|
| 132 | + | let kept = crate::images::held(&app.db).await?; |
|
| 133 | + | let mut wanted: Vec<String> = |
|
| 134 | + | ask.images.into_iter().take(MAX_HELD).map(|h| h.to_ascii_lowercase()).filter(|h| is_hash(h) && !kept.contains(h)).collect(); |
|
| 135 | + | wanted.sort(); |
|
| 136 | + | wanted.dedup(); |
|
| 137 | + | Ok(Json(flow_core::api::WantedManifests { wanted })) |
|
| 138 | + | } |
|
| 139 | + | ||
| 140 | + | /// `PUT /flow/images/{hash}/held` (a runner's token): the manifest of an |
|
| 141 | + | /// image the runner holds and the hub does not. Kept as held by runners |
|
| 142 | + | /// only: the hub has none of its chunks to check it against, and a fetcher |
|
| 143 | + | /// checks what it assembles against the pinned hash (jetstream.md §8.1.1). |
|
| 144 | + | pub async fn put_held( |
|
| 145 | + | State(app): State<Shared>, |
|
| 146 | + | headers: HeaderMap, |
|
| 147 | + | Path(hash): Path<String>, |
|
| 148 | + | Json(m): Json<Manifest>, |
|
| 149 | + | ) -> Result<StatusCode, ApiError> { |
|
| 150 | + | let runner = crate::flow::runner_auth(&app, &headers).await?; |
|
| 151 | + | if !is_hash(&hash) || m.hash != hash { |
|
| 152 | + | return Err(ApiError(StatusCode::BAD_REQUEST, format!("the manifest is for {}, not {}", m.hash, hash))); |
|
| 153 | + | } |
|
| 154 | + | if crate::images::held(&app.db).await?.contains(&hash) { |
|
| 155 | + | return Ok(StatusCode::NO_CONTENT); |
|
| 156 | + | } |
|
| 157 | + | let store = app.images(); |
|
| 158 | + | let kept = m.clone(); |
|
| 159 | + | tokio::task::spawn_blocking(move || store.keep_peer_manifest(&kept)) |
|
| 160 | + | .await |
|
| 161 | + | .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? |
|
| 162 | + | .map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?; |
|
| 163 | + | crate::images::record_peer(&app.db, &m, &runner.name).await?; |
|
| 164 | + | Ok(StatusCode::CREATED) |
|
| 165 | + | } |
|
| 166 | + | ||
| 120 | 167 | /// `GET /flow/images/{hash}`: an image's manifest. |
|
| 121 | 168 | pub async fn get_manifest(State(app): State<Shared>, headers: HeaderMap, Path(hash): Path<String>) -> Result<Json<Manifest>, ApiError> { |
|
| 122 | 169 | fetcher(&app, &headers).await?; |
|
| … | 14 unchanged lines |
modified crates/hub/src/api/mod.rs +4 −0
| … | 62 unchanged lines |
||
| 63 | 63 | .route("/flow/runners/heartbeat", post(crate::flow::heartbeat)) |
|
| 64 | 64 | .route("/flow/runner/update", get(crate::flow::runner_update).post(crate::flow::report_update)) |
|
| 65 | 65 | .route("/flow/images/missing", post(images::missing).layer(axum::extract::DefaultBodyLimit::max(images::MAX_ASK_BYTES))) |
|
| 66 | + | .route("/flow/images/held", post(images::held)) |
|
| 66 | 67 | .route("/flow/swarm/announce", post(crate::swarm::announce)) |
|
| 67 | 68 | .route("/flow/images/chunks/{hash}", axum::routing::put(images::put_chunk).get(images::get_chunk)) |
|
| 68 | 69 | .route( |
|
| 69 | 70 | "/flow/images/{hash}", |
|
| 70 | 71 | axum::routing::put(images::put_manifest).get(images::get_manifest).layer(axum::extract::DefaultBodyLimit::max(64 << 20)), |
|
| 71 | 72 | ) |
|
| 73 | + | // A runner's manifest of an image only runners hold (oxygenflow#94); |
|
| 74 | + | // a 14 GB image's is about 11 MB. |
|
| 75 | + | .route("/flow/images/{hash}/held", axum::routing::put(images::put_held).layer(axum::extract::DefaultBodyLimit::max(64 << 20))) |
|
| 72 | 76 | .route("/flow/jobs/{id}/log", post(crate::flow::log)) |
|
| 73 | 77 | .route("/flow/jobs/{id}/finish", post(crate::flow::finish)) |
|
| 74 | 78 | .route("/flow/jobs/{id}/decline", post(crate::flow::decline)) |
|
| … | 3125 unchanged lines |
modified crates/hub/src/flow.rs +19 −11
| … | 1048 unchanged lines |
||
| 1049 | 1049 | images: req.images.as_ref().map(|i| i.iter().map(|h| h.to_ascii_lowercase()).collect()), |
|
| 1050 | 1050 | arch, |
|
| 1051 | 1051 | image_archs: fdb::image_archs(&app.db).await?, |
|
| 1052 | - | fetchable: if req.fetches_images { crate::images::held(&app.db).await? } else { Default::default() }, |
|
| 1052 | + | // The hub's own images, and those a runner of its site holds (oxygenflow#94). |
|
| 1053 | + | fetchable: if req.fetches_images { crate::images::reach(&app).await?.for_runner(me.id) } else { Default::default() }, |
|
| 1053 | 1054 | free_disk_mib: req.facts.as_ref().and_then(|f| f.free_disk_mib), |
|
| 1054 | 1055 | }; |
|
| 1055 | 1056 | // The labels it reports now, or those it registered with, and those |
|
| … | 17 unchanged lines |
||
| 1073 | 1074 | // Each job's build cache slot (oxygenflow#51), as runners report theirs. |
|
| 1074 | 1075 | let slots: std::collections::HashMap<i64, String> = |
|
| 1075 | 1076 | queue.iter().map(|q| (q.job_id, flow_core::api::cache_slot(&format!("{}/{}", q.owner, q.name), &q.job))).collect(); |
|
| 1076 | - | let (archs, held) = (fdb::image_archs(&app.db).await?, crate::images::held(&app.db).await?); |
|
| 1077 | + | let (archs, reach) = (fdb::image_archs(&app.db).await?, crate::images::reach(&app).await?); |
|
| 1077 | 1078 | let now = db::now_ms(); |
|
| 1078 | - | let fleet = Fleet { image_archs: &archs, hub_images: &held, now }; |
|
| 1079 | + | let fleet = Fleet { image_archs: &archs, reach: &reach, now }; |
|
| 1079 | 1080 | let hub_key = app.repos.root().display().to_string(); |
|
| 1080 | 1081 | let better = |j: &fdb::Job| -> bool { |
|
| 1081 | 1082 | let Some((owner, repo_id)) = owners.get(&j.id) else { return false }; |
|
| … | 495 unchanged lines |
||
| 1577 | 1578 | /// the hub holds, and the time. |
|
| 1578 | 1579 | pub struct Fleet<'a> { |
|
| 1579 | 1580 | pub image_archs: &'a std::collections::HashMap<String, String>, |
|
| 1580 | - | pub hub_images: &'a std::collections::HashSet<String>, |
|
| 1581 | + | /// Where images can come from: the hub, or a peer in a runner's site. |
|
| 1582 | + | pub reach: &'a crate::images::Reach, |
|
| 1581 | 1583 | pub now: i64, |
|
| 1582 | 1584 | } |
|
| 1583 | 1585 | ||
| … | 27 unchanged lines |
||
| 1611 | 1613 | return Some(format!("is {}; the job needs {w}", r.arch)); |
|
| 1612 | 1614 | } |
|
| 1613 | 1615 | // A job with no image (the WASM tier) needs none on the runner, as its claim says. |
|
| 1614 | - | let fetches = r.fetches_images && fleet.hub_images.contains(rootfs); |
|
| 1616 | + | let fetches = r.fetches_images && fleet.reach.fetchable(r.id, rootfs); |
|
| 1615 | 1617 | if !rootfs.is_empty() && !fetches && r.image_list().is_some_and(|have| !have.iter().any(|h| h == rootfs)) { |
|
| 1616 | - | let why = if r.fetches_images { " (nor does the hub: send it with flow rootfs push)" } else { "" }; |
|
| 1618 | + | let why = if !r.fetches_images { |
|
| 1619 | + | "" |
|
| 1620 | + | } else if fleet.reach.peer_only(rootfs) { |
|
| 1621 | + | " (only runners hold it, none in this runner's swarm site: send it to the hub with flow rootfs push)" |
|
| 1622 | + | } else { |
|
| 1623 | + | " (nor does the hub: send it with flow rootfs push)" |
|
| 1624 | + | }; |
|
| 1617 | 1625 | return Some(format!("does not hold image {}{why}", &rootfs[..12.min(rootfs.len())])); |
|
| 1618 | 1626 | } |
|
| 1619 | 1627 | let labels = r.label_list(); |
|
| … | 36 unchanged lines |
||
| 1656 | 1664 | image_archs: &std::collections::HashMap<String, String>, |
|
| 1657 | 1665 | declines: &[fdb::DeclineRow], |
|
| 1658 | 1666 | now: i64, |
|
| 1659 | - | hub_images: &std::collections::HashSet<String>, |
|
| 1667 | + | reach: &crate::images::Reach, |
|
| 1660 | 1668 | ) -> String { |
|
| 1661 | - | let fleet = Fleet { image_archs, hub_images, now }; |
|
| 1669 | + | let fleet = Fleet { image_archs, reach, now }; |
|
| 1662 | 1670 | let online: Vec<&db::runners::Runner> = runners.iter().filter(|r| r.state != "disabled" && r.online()).collect(); |
|
| 1663 | 1671 | if online.is_empty() { |
|
| 1664 | 1672 | return "no runner is online".into(); |
|
| … | 24 unchanged lines |
||
| 1689 | 1697 | owner: &str, |
|
| 1690 | 1698 | repo_id: i64, |
|
| 1691 | 1699 | runners: &[db::runners::Runner], |
|
| 1692 | - | fleet: (&std::collections::HashMap<String, String>, &std::collections::HashSet<String>, i64), |
|
| 1700 | + | fleet: (&std::collections::HashMap<String, String>, &crate::images::Reach, i64), |
|
| 1693 | 1701 | declines: &[fdb::DeclineRow], |
|
| 1694 | 1702 | running: &std::collections::HashMap<i64, i64>, |
|
| 1695 | 1703 | ahead: usize, |
|
| 1696 | 1704 | behind_urgent: &[String], |
|
| 1697 | 1705 | ) -> String { |
|
| 1698 | - | let fleet = Fleet { image_archs: fleet.0, hub_images: fleet.1, now: fleet.2 }; |
|
| 1706 | + | let fleet = Fleet { image_archs: fleet.0, reach: fleet.1, now: fleet.2 }; |
|
| 1699 | 1707 | let mut busy = Vec::new(); |
|
| 1700 | 1708 | for r in runners.iter().filter(|r| r.state != "disabled" && r.online()) { |
|
| 1701 | 1709 | if runner_lacks(job, owner, repo_id, r, &fleet, declines).is_some() { |
|
| … | 119 unchanged lines |
||
| 1821 | 1829 | } |
|
| 1822 | 1830 | let runners = db::runners::list(&app.db).await?; |
|
| 1823 | 1831 | let archs = fdb::image_archs(&app.db).await?; |
|
| 1824 | - | let held = crate::images::held(&app.db).await?; |
|
| 1832 | + | let held = crate::images::reach(app).await?; |
|
| 1825 | 1833 | let now = db::now_ms(); |
|
| 1826 | 1834 | let ranks = fdb::queued_ranks(&app.db).await?; |
|
| 1827 | 1835 | let running = fdb::running_by_runner(&app.db).await?; |
|
| … | 1222 unchanged lines |
modified crates/hub/src/images.rs +124 −4
| … | 84 unchanged lines |
||
| 85 | 85 | self.keep_manifest(m) |
|
| 86 | 86 | } |
|
| 87 | 87 | ||
| 88 | + | /// Keep manifest `m` of an image only runners hold (oxygenflow#94): well |
|
| 89 | + | /// formed, its chunks not here, so not checked against its hash; a |
|
| 90 | + | /// fetcher checks the image it assembles against the pinned hash. |
|
| 91 | + | /// Never over a manifest already kept. |
|
| 92 | + | pub fn keep_peer_manifest(&self, m: &Manifest) -> Result<(), String> { |
|
| 93 | + | m.check().map_err(|e| e.to_string())?; |
|
| 94 | + | if self.manifest_path(&m.hash).exists() { |
|
| 95 | + | return Ok(()); |
|
| 96 | + | } |
|
| 97 | + | self.keep_manifest(m) |
|
| 98 | + | } |
|
| 99 | + | ||
| 88 | 100 | /// Write manifest `m`, whose image is here. |
|
| 89 | 101 | fn keep_manifest(&self, m: &Manifest) -> Result<(), String> { |
|
| 90 | 102 | let path = self.manifest_path(&m.hash); |
|
| … | 72 unchanged lines |
||
| 163 | 175 | } |
|
| 164 | 176 | } |
|
| 165 | 177 | ||
| 166 | - | /// Record an image this hub now keeps. |
|
| 178 | + | /// Record an image this hub now keeps, its chunks and all: one only |
|
| 179 | + | /// runners held before becomes the hub's. |
|
| 167 | 180 | pub async fn record(pool: &Pool, m: &Manifest, uploader: &str) -> sqlx::Result<()> { |
|
| 168 | 181 | sqlx::query( |
|
| 169 | 182 | "INSERT INTO jet_images (hash, size, arch, chunks, uploader, created_ms) VALUES ($1, $2, $3, $4, $5, $6) \ |
|
| 170 | - | ON CONFLICT (hash) DO UPDATE SET arch = excluded.arch WHERE excluded.arch != ''", |
|
| 183 | + | ON CONFLICT (hash) DO UPDATE SET source = 'hub', holder = '', \ |
|
| 184 | + | arch = CASE WHEN excluded.arch != '' THEN excluded.arch ELSE jet_images.arch END", |
|
| 171 | 185 | ) |
|
| 172 | 186 | .bind(&m.hash) |
|
| 173 | 187 | .bind(m.size as i64) |
|
| … | 35 unchanged lines |
||
| 209 | 223 | } |
|
| 210 | 224 | crate::db::flow::learn_archs_from_runs(&app.db).await?; |
|
| 211 | 225 | Ok(read) |
|
| 226 | + | } |
|
| 227 | + | ||
| 228 | + | /// Record an image only runners hold, whose manifest `holder` sent |
|
| 229 | + | /// (oxygenflow#94). Never over one the hub keeps. |
|
| 230 | + | pub async fn record_peer(pool: &Pool, m: &Manifest, holder: &str) -> sqlx::Result<()> { |
|
| 231 | + | sqlx::query( |
|
| 232 | + | "INSERT INTO jet_images (hash, size, arch, chunks, uploader, created_ms, source, holder) VALUES ($1, $2, $3, $4, $5, $6, 'peer', $7) \ |
|
| 233 | + | ON CONFLICT (hash) DO NOTHING", |
|
| 234 | + | ) |
|
| 235 | + | .bind(&m.hash) |
|
| 236 | + | .bind(m.size as i64) |
|
| 237 | + | .bind(m.arch.clone().unwrap_or_default()) |
|
| 238 | + | .bind(m.chunks.len() as i64) |
|
| 239 | + | .bind(holder) |
|
| 240 | + | .bind(now_ms()) |
|
| 241 | + | .bind(holder) |
|
| 242 | + | .execute(pool) |
|
| 243 | + | .await?; |
|
| 244 | + | if let Some(a) = m.arch.as_ref().filter(|a| !a.is_empty()) { |
|
| 245 | + | crate::db::flow::record_image_archs(pool, &[(m.hash.clone(), a.clone())].into_iter().collect()).await?; |
|
| 246 | + | } |
|
| 247 | + | Ok(()) |
|
| 212 | 248 | } |
|
| 213 | 249 | ||
| 214 | - | /// Every image this hub keeps, by hash. |
|
| 250 | + | /// Every image this hub keeps a manifest of, by hash: its own and those |
|
| 251 | + | /// only runners hold. |
|
| 215 | 252 | pub async fn held(pool: &Pool) -> sqlx::Result<std::collections::HashSet<String>> { |
|
| 216 | 253 | Ok(sqlx::query_scalar::<_, String>("SELECT hash FROM jet_images").fetch_all(pool).await?.into_iter().collect()) |
|
| 217 | 254 | } |
|
| 218 | 255 | ||
| 256 | + | /// Where the images jobs need can come from (oxygenflow#94): the hub's |
|
| 257 | + | /// own, pushed and verified, and those only runners hold, each with the |
|
| 258 | + | /// swarm sites of the online runners holding it now. |
|
| 259 | + | #[derive(Debug, Default)] |
|
| 260 | + | pub struct Reach { |
|
| 261 | + | pub hub: std::collections::HashSet<String>, |
|
| 262 | + | pub peers: std::collections::HashMap<String, Vec<String>>, |
|
| 263 | + | /// Each runner in a swarm now, and its site. |
|
| 264 | + | pub sites: std::collections::HashMap<i64, String>, |
|
| 265 | + | } |
|
| 266 | + | ||
| 267 | + | impl Reach { |
|
| 268 | + | /// Whether a runner that fetches images can get `image`: from the hub, |
|
| 269 | + | /// or from a runner of its own site. |
|
| 270 | + | pub fn fetchable(&self, runner: i64, image: &str) -> bool { |
|
| 271 | + | self.hub.contains(image) || self.sites.get(&runner).is_some_and(|s| self.peers.get(image).is_some_and(|v| v.contains(s))) |
|
| 272 | + | } |
|
| 273 | + | ||
| 274 | + | /// Every image `runner` can fetch. |
|
| 275 | + | pub fn for_runner(&self, runner: i64) -> std::collections::HashSet<String> { |
|
| 276 | + | let mut out = self.hub.clone(); |
|
| 277 | + | out.extend(self.peers.keys().filter(|i| self.fetchable(runner, i)).cloned()); |
|
| 278 | + | out |
|
| 279 | + | } |
|
| 280 | + | ||
| 281 | + | /// Whether only runners hold `image`. |
|
| 282 | + | pub fn peer_only(&self, image: &str) -> bool { |
|
| 283 | + | !self.hub.contains(image) && self.peers.contains_key(image) |
|
| 284 | + | } |
|
| 285 | + | } |
|
| 286 | + | ||
| 287 | + | /// [`Reach`] now. |
|
| 288 | + | pub async fn reach(app: &crate::Shared) -> sqlx::Result<Reach> { |
|
| 289 | + | let rows: Vec<(String, String)> = sqlx::query_as("SELECT hash, source FROM jet_images").fetch_all(&app.db).await?; |
|
| 290 | + | let mut reach = Reach::default(); |
|
| 291 | + | for (hash, source) in rows { |
|
| 292 | + | if source == "peer" { |
|
| 293 | + | reach.peers.insert(hash, Vec::new()); |
|
| 294 | + | } else { |
|
| 295 | + | reach.hub.insert(hash); |
|
| 296 | + | } |
|
| 297 | + | } |
|
| 298 | + | for r in crate::db::runners::list(&app.db).await? { |
|
| 299 | + | if r.state == "disabled" || !r.online() { |
|
| 300 | + | continue; |
|
| 301 | + | } |
|
| 302 | + | let Some(addr) = app.swarm.addr(r.id) else { continue }; |
|
| 303 | + | for image in r.image_list().unwrap_or_default() { |
|
| 304 | + | if let Some(sites) = reach.peers.get_mut(&image.to_ascii_lowercase()) { |
|
| 305 | + | if !sites.contains(&addr.site) { |
|
| 306 | + | sites.push(addr.site.clone()); |
|
| 307 | + | } |
|
| 308 | + | } |
|
| 309 | + | } |
|
| 310 | + | reach.sites.insert(r.id, addr.site); |
|
| 311 | + | } |
|
| 312 | + | Ok(reach) |
|
| 313 | + | } |
|
| 314 | + | ||
| 219 | 315 | /// An image this hub keeps (`jet_images`). |
|
| 220 | 316 | #[derive(sqlx::FromRow)] |
|
| 221 | 317 | pub struct Kept { |
|
| … | 3 unchanged lines |
||
| 225 | 321 | pub chunks: i64, |
|
| 226 | 322 | pub uploader: String, |
|
| 227 | 323 | pub created_ms: i64, |
|
| 324 | + | /// 'hub', or 'peer' for one only runners hold (oxygenflow#94). |
|
| 325 | + | pub source: String, |
|
| 326 | + | pub holder: String, |
|
| 228 | 327 | } |
|
| 229 | 328 | ||
| 230 | 329 | pub async fn kept(pool: &Pool) -> sqlx::Result<Vec<Kept>> { |
|
| 231 | - | sqlx::query_as("SELECT hash, size, arch, chunks, uploader, created_ms FROM jet_images").fetch_all(pool).await |
|
| 330 | + | sqlx::query_as("SELECT hash, size, arch, chunks, uploader, created_ms, source, holder FROM jet_images").fetch_all(pool).await |
|
| 232 | 331 | } |
|
| 233 | 332 | ||
| 234 | 333 | /// One repository's use of one image: its jobs since `since_ms`, and when |
|
| … | 42 unchanged lines |
||
| 277 | 376 | /// The architecture runners reported for each image (`flow_images`). |
|
| 278 | 377 | pub async fn reported_archs(pool: &Pool) -> sqlx::Result<std::collections::HashMap<String, String>> { |
|
| 279 | 378 | Ok(sqlx::query_as::<_, (String, String)>("SELECT hash, arch FROM flow_images").fetch_all(pool).await?.into_iter().collect()) |
|
| 379 | + | } |
|
| 380 | + | ||
| 381 | + | #[cfg(test)] |
|
| 382 | + | mod reach_tests { |
|
| 383 | + | use super::Reach; |
|
| 384 | + | ||
| 385 | + | #[test] |
|
| 386 | + | fn an_image_only_runners_hold_is_fetchable_in_their_site_alone() { |
|
| 387 | + | let mut r = Reach::default(); |
|
| 388 | + | r.hub.insert("hub-image".into()); |
|
| 389 | + | r.peers.insert("peer-image".into(), vec!["home".into()]); |
|
| 390 | + | r.sites.insert(1, "home".into()); |
|
| 391 | + | r.sites.insert(2, "cloud".into()); |
|
| 392 | + | assert!(r.fetchable(1, "hub-image") && r.fetchable(2, "hub-image") && r.fetchable(3, "hub-image")); |
|
| 393 | + | assert!(r.fetchable(1, "peer-image"), "a runner of the holder's site"); |
|
| 394 | + | assert!(!r.fetchable(2, "peer-image"), "another site"); |
|
| 395 | + | assert!(!r.fetchable(3, "peer-image"), "a runner in no swarm"); |
|
| 396 | + | assert!(r.peer_only("peer-image") && !r.peer_only("hub-image")); |
|
| 397 | + | assert_eq!(r.for_runner(1).len(), 2); |
|
| 398 | + | assert_eq!(r.for_runner(2).len(), 1); |
|
| 399 | + | } |
|
| 280 | 400 | } |
modified crates/hub/src/swarm.rs +10 −1
| … | 129 unchanged lines |
||
| 130 | 130 | } |
|
| 131 | 131 | } |
|
| 132 | 132 | let ticket = ticket(&app, &me, &mine.site)?; |
|
| 133 | - | Ok(Json(Announced { ticket, peers, interval_s: INTERVAL_S })) |
|
| 133 | + | // An image only runners hold (oxygenflow#94): the hub has its manifest |
|
| 134 | + | // and none of its chunks, so the fetcher asks peers alone. |
|
| 135 | + | let seed = Some( |
|
| 136 | + | sqlx::query_scalar::<_, String>("SELECT source FROM jet_images WHERE hash = $1") |
|
| 137 | + | .bind(&image) |
|
| 138 | + | .fetch_optional(&app.db) |
|
| 139 | + | .await? |
|
| 140 | + | .is_none_or(|s| s != "peer"), |
|
| 141 | + | ); |
|
| 142 | + | Ok(Json(Announced { ticket, peers, interval_s: INTERVAL_S, seed })) |
|
| 134 | 143 | } |
|
| 135 | 144 | ||
| 136 | 145 | /// The runner's ticket, made again when it nears its end or its site |
|
| … | 33 unchanged lines |
modified crates/hub/tests/flow.rs +52 −0
| … | 3129 unchanged lines |
||
| 3130 | 3130 | /// manifest, checked against its hash; runners fetch it; and a job whose |
|
| 3131 | 3131 | /// image only the hub holds is offered to a runner that fetches, not to one |
|
| 3132 | 3132 | /// that does not. |
|
| 3133 | + | /// oxygenflow#94, #342: a runner sends the manifest of an image it holds |
|
| 3134 | + | /// that the hub lacks; the hub keeps it as held by runners only (no chunks, |
|
| 3135 | + | /// announce says it is no seed) until the image is pushed whole. |
|
| 3133 | 3136 | #[tokio::test(flavor = "multi_thread")] |
|
| 3137 | + | async fn a_runner_offers_the_manifest_of_an_image_the_hub_lacks() { |
|
| 3138 | + | let h = hub().await; |
|
| 3139 | + | db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap(); |
|
| 3140 | + | let image: Vec<u8> = (0..300_000u32).map(|i| (i % 241) as u8).collect(); |
|
| 3141 | + | let mut chunks = std::collections::HashMap::new(); |
|
| 3142 | + | let m = jet::images::scan(&image[..], Some("x86_64".into()), |c, b| { |
|
| 3143 | + | chunks.insert(c.hash().to_string(), b.to_vec()); |
|
| 3144 | + | Ok(()) |
|
| 3145 | + | }) |
|
| 3146 | + | .unwrap(); |
|
| 3147 | + | let hash = m.hash.clone(); |
|
| 3148 | + | // Asked which of its images the hub lacks: this one. |
|
| 3149 | + | let held = serde_json::json!({ "images": [hash.clone(), "not-a-hash"] }).to_string(); |
|
| 3150 | + | let (st, body) = call(&h.app, "POST", "/api/v1/flow/images/held", Some("runner-secret"), Some(held.clone())).await; |
|
| 3151 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 3152 | + | assert_eq!(serde_json::from_str::<serde_json::Value>(&body).unwrap()["wanted"], serde_json::json!([hash.clone()])); |
|
| 3153 | + | // Only runners may say so. |
|
| 3154 | + | assert_ne!(call(&h.app, "POST", "/api/v1/flow/images/held", Some("site"), Some(held.clone())).await.0, StatusCode::OK); |
|
| 3155 | + | // A manifest filed under another hash is refused; its own is kept as peer-held. |
|
| 3156 | + | let manifest = serde_json::to_string(&m).unwrap(); |
|
| 3157 | + | let wrong = format!("/api/v1/flow/images/{}/held", "ab".repeat(32)); |
|
| 3158 | + | assert_eq!(call(&h.app, "PUT", &wrong, Some("runner-secret"), Some(manifest.clone())).await.0, StatusCode::BAD_REQUEST); |
|
| 3159 | + | let (st, body) = call(&h.app, "PUT", &format!("/api/v1/flow/images/{hash}/held"), Some("runner-secret"), Some(manifest.clone())).await; |
|
| 3160 | + | assert_eq!(st, StatusCode::CREATED, "{body}"); |
|
| 3161 | + | let kept = oxygenhub::images::kept(&h.app.db).await.unwrap(); |
|
| 3162 | + | let row = kept.iter().find(|k| k.hash == hash).expect("recorded"); |
|
| 3163 | + | assert_eq!(row.source, "peer"); |
|
| 3164 | + | let (_, body) = call(&h.app, "POST", "/api/v1/flow/images/held", Some("runner-secret"), Some(held)).await; |
|
| 3165 | + | assert!(body.contains("\"wanted\":[]"), "{body}"); |
|
| 3166 | + | // Runners fetch its manifest; the hub has none of its chunks. |
|
| 3167 | + | let (st, _) = call(&h.app, "GET", &format!("/api/v1/flow/images/{hash}"), Some("runner-secret"), None).await; |
|
| 3168 | + | assert_eq!(st, StatusCode::OK); |
|
| 3169 | + | let first = m.needed()[0].to_string(); |
|
| 3170 | + | assert_ne!(call(&h.app, "GET", &format!("/api/v1/flow/images/chunks/{first}"), Some("runner-secret"), None).await.0, StatusCode::OK); |
|
| 3171 | + | let reach = oxygenhub::images::reach(&h.app).await.unwrap(); |
|
| 3172 | + | assert!(reach.peer_only(&hash)); |
|
| 3173 | + | // Pushed whole, it becomes the hub's. |
|
| 3174 | + | for c in m.needed() { |
|
| 3175 | + | let (st, body) = put_bytes(&h.app, &format!("/api/v1/flow/images/chunks/{c}"), "site", chunks[c].clone()).await; |
|
| 3176 | + | assert_eq!(st, StatusCode::NO_CONTENT, "{body}"); |
|
| 3177 | + | } |
|
| 3178 | + | let (st, body) = call(&h.app, "PUT", &format!("/api/v1/flow/images/{hash}"), Some("site"), Some(manifest)).await; |
|
| 3179 | + | assert_eq!(st, StatusCode::CREATED, "{body}"); |
|
| 3180 | + | let kept = oxygenhub::images::kept(&h.app.db).await.unwrap(); |
|
| 3181 | + | assert_eq!(kept.iter().find(|k| k.hash == hash).unwrap().source, "hub"); |
|
| 3182 | + | assert!(!oxygenhub::images::reach(&h.app).await.unwrap().peer_only(&hash)); |
|
| 3183 | + | } |
|
| 3184 | + | ||
| 3185 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 3134 | 3186 | async fn images_are_sent_by_chunk_and_offered_to_runners_that_fetch() { |
|
| 3135 | 3187 | let h = hub().await; |
|
| 3136 | 3188 | // The site token acts as the first site administrator. |
|
| … | 3786 unchanged lines |
replaced deps/oxygenflow +0 −0
No line changes.