OXYGENHUB

A job's result is kept until the hub has it (refs andymccall/oxygenhub#71) 3007d8f011fa

The runner tried to report a job's end three times over seven seconds and
dropped it, so a hub that was briefly down left the job running there for
good (Earth's reset stranded five). report_finish keeps the result in
pending/ in the Flow home and offers it until the hub takes it, after 5 s
doubling to a minute, for up to two hours, through server errors and lost
connections; a restarted runner sends what it kept. A refusal is final.
A test runs it against a hub that fails four times.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-02 00:36 UTC
Signature
✔ signed by Andy McCall
Hash
3007d8f011fa204cca15dfdeaba4cea01ff8581dd267ae2b814f912a35edf6a3
Depends on
1b2229dab1a4 2d025bbf9842 47c1d64dec8a a67b97e9a717 bb643c252515 c3db750ed96e e0918b887850 f98a8f7881e0
Depended on by
1215f05ff8d0 77c8c24066ef 4dd392d16454 494a1b9a8ac2 cedd12342afa 85275e1ef3b7 4be5e4cd0af6 a4b3f8cb9aae ea81796bb757 1e6b9fb1e73f 82cf6e91d398 89171fca079c b14aeaae683a 8f211e20bea5 62829d15ef09 4cf0c99a1486 6d53fb7d00ec b11e30f5031d c5b702e59c1f 280ef951d193 1daca0b6bb8c 8c0a4e3a8a30
In bookmarks
main newer-pipeline push-uses-login
In tags
v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
Edits
7 atoms, 0 tree operations, 2 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **A job's result is kept until the hub has it (oxygenhub#71).** The runner offered a finished job's result three times over seven seconds and then dropped it, so a hub that was briefly down (Earth's reset) left the job running forever. The result is now kept on disk (`pending/` in the Flow home) and offered again, every 5 s doubling to a minute, for up to two hours, through any server error or lost connection; a runner that restarts sends what it kept. A refusal (the hub ended the job already) is final.
8 +
7 9
- **The Rust build image vendors mimalloc (#20).** Oxygen's new allocator was missing from the image, so oxygen's runs #150 to #153 failed. The image was rebuilt with `images/rust/build.sh`, imported on Earth and pinned in the oxygen, oxygenhub and oxygenflow pipelines. Rebuild it whenever a `Cargo.lock` of those projects changes.
8 10
- **A hint for shell escapes in a pipeline.** KDL 2.0 strings have no `\033`, `\e` or `\x` escapes; a pipeline that uses one (for ANSI colours, say) is still invalid, but `flow check` and the hub now name the line and say to write ESC as `\u{1b}`.
modified crates/flow/src/runner.rs +145 −58
…
92 unchanged lines
93 93
        }
94 94
    }
95 95
96 -
    pub fn finish(&self, job: i64, f: &Finish) -> Result<()> {
97 -
        self.post(&format!("/flow/jobs/{job}/finish"), f)?;
98 -
        Ok(())
96 +
    /// Report a job's end until the hub has it (oxygenhub#71): while the hub
97 +
    /// cannot be reached or fails (5xx), try again after `first_wait`,
98 +
    /// doubling to a minute, for up to `patience`. A refusal (4xx: the job was ended
99 +
    /// already, say) is final. A result lost here leaves the job running
100 +
    /// on the hub until its time limit.
101 +
    pub fn finish_until_accepted(&self, job: i64, f: &Finish, patience: Duration, first_wait: Duration) -> Result<()> {
102 +
        let url = format!("{}/api/v1/flow/jobs/{job}/finish", self.base);
103 +
        let deadline = std::time::Instant::now() + patience;
104 +
        let mut wait = first_wait;
105 +
        loop {
106 +
            let why = match self.agent.post(&url).set("Authorization", &format!("Bearer {}", self.token)).send_json(f) {
107 +
                Ok(_) => return Ok(()),
108 +
                Err(ureq::Error::Status(code, r)) if code < 500 => {
109 +
                    bail!("{url}: HTTP {code}: {}", r.into_string().unwrap_or_default())
110 +
                }
111 +
                Err(ureq::Error::Status(code, _)) => format!("HTTP {code}"),
112 +
                Err(e) => e.to_string(),
113 +
            };
114 +
            if std::time::Instant::now() + wait > deadline {
115 +
                bail!("{url}: gave up after {} minutes: {why}", patience.as_secs() / 60);
116 +
            }
117 +
            eprintln!("job {job}: the hub did not take the result ({why}); trying again in {} s", wait.as_secs());
118 +
            std::thread::sleep(wait);
119 +
            wait = (wait * 2).min(Duration::from_secs(60));
120 +
        }
121 +
    }
122 +
}
123 +
124 +
/// How long a runner keeps trying to report a job's end.
125 +
const FINISH_PATIENCE: Duration = Duration::from_secs(2 * 3600);
126 +
127 +
/// Where a job's result waits until the hub has it, so a runner that
128 +
/// restarts still reports it.
129 +
fn pending_path(home: &Home, job: i64) -> PathBuf {
130 +
    home.root.join("pending").join(format!("{job}.json"))
131 +
}
132 +
133 +
/// Report `f`, keeping it on disk until the hub has it.
134 +
fn report_finish(home: &Home, hub: &Hub, job: i64, f: &Finish) {
135 +
    let path = pending_path(home, job);
136 +
    let kept = std::fs::create_dir_all(path.parent().unwrap())
137 +
        .and_then(|_| std::fs::write(&path, serde_json::to_vec(f).unwrap_or_default()))
138 +
        .is_ok();
139 +
    match hub.finish_until_accepted(job, f, FINISH_PATIENCE, Duration::from_secs(5)) {
140 +
        Ok(()) => {}
141 +
        Err(e) => eprintln!("job {job}: reporting failed: {e:#}"),
142 +
    }
143 +
    if kept {
144 +
        let _ = std::fs::remove_file(&path);
99 145
    }
146 +
}
147 +
148 +
/// Results a previous run of the runner could not report, to send again.
149 +
fn report_pending(home: &Home) -> Vec<(i64, Finish)> {
150 +
    let Ok(dir) = std::fs::read_dir(home.root.join("pending")) else {
151 +
        return Vec::new();
152 +
    };
153 +
    dir.filter_map(|e| {
154 +
        let p = e.ok()?.path();
155 +
        let job: i64 = p.file_stem()?.to_str()?.parse().ok()?;
156 +
        let f: Finish = serde_json::from_slice(&std::fs::read(&p).ok()?).ok()?;
157 +
        Some((job, f))
158 +
    })
159 +
    .collect()
100 160
}
101 161
102 162
/// `flow runner register`: exchange a one-time registration token from
…
100 unchanged lines
203 263
    );
204 264
    let used = std::sync::Mutex::new(Used::default());
205 265
    let mut idle = Duration::from_secs(2);
206 -
    std::thread::scope(|scope| loop {
207 -
        let room = budget.room(*used.lock().unwrap());
208 -
        let Some((free_memory_mib, free_cpus)) = room else {
209 -
            std::thread::sleep(Duration::from_millis(500));
210 -
            continue;
211 -
        };
212 -
        let req = ClaimRequest {
213 -
            runner: name.clone(),
214 -
            runtimes: vec!["microvm".into()],
215 -
            free_memory_mib,
216 -
            free_cpus,
217 -
            info: Some(RunnerInfo {
218 -
                version: env!("OXY_VERSION_LONG").into(),
219 -
                ..RunnerInfo::here(cfg.labels.clone(), budget.max_jobs as u32, cfg.memory_mib, cfg.cpus)
220 -
            }),
221 -
        };
222 -
        match hub.claim(&req) {
223 -
            Ok(Some(claim)) => {
224 -
                idle = Duration::from_secs(2);
225 -
                let (mem, cpus) = (claim.sandbox.memory_mib, claim.sandbox.cpus);
226 -
                {
227 -
                    let mut u = used.lock().unwrap();
228 -
                    u.jobs += 1;
229 -
                    u.memory_mib += mem;
230 -
                    u.cpus += cpus;
231 -
                }
232 -
                let (home, hub, cfg, used, key) = (home, &hub, &cfg, &used, &key);
233 -
                scope.spawn(move || {
234 -
                    let id = claim.job_id;
235 -
                    eprintln!("job {id}: {} {} on {}", claim.repo, claim.job.name, claim.heads.first().map(|h| &h[..12]).unwrap_or("-"));
236 -
                    let finish = run_claim(home, hub, cfg, key, &claim).unwrap_or_else(|e| Finish {
237 -
                        exit_code: None,
238 -
                        duration_ms: 0,
239 -
                        receipt: None,
240 -
                        signed_receipt: None,
241 -
                        error: Some(format!("{e:#}")),
242 -
                    });
243 -
                    eprintln!(
244 -
                        "job {id}: exit {:?}{}",
245 -
                        finish.exit_code,
246 -
                        finish.error.as_deref().map(|e| format!(" ({e})")).unwrap_or_default()
247 -
                    );
248 -
                    if let Err(e) = hub.finish(id, &finish) {
249 -
                        eprintln!("job {id}: reporting failed: {e:#}");
266 +
    let pending = report_pending(home);
267 +
    std::thread::scope(|scope| {
268 +
        for (job, f) in pending {
269 +
            let hub = &hub;
270 +
            scope.spawn(move || {
271 +
                eprintln!("job {job}: reporting the result kept from before the restart");
272 +
                report_finish(home, hub, job, &f);
273 +
            });
274 +
        }
275 +
        loop {
276 +
            let room = budget.room(*used.lock().unwrap());
277 +
            let Some((free_memory_mib, free_cpus)) = room else {
278 +
                std::thread::sleep(Duration::from_millis(500));
279 +
                continue;
280 +
            };
281 +
            let req = ClaimRequest {
282 +
                runner: name.clone(),
283 +
                runtimes: vec!["microvm".into()],
284 +
                free_memory_mib,
285 +
                free_cpus,
286 +
                info: Some(RunnerInfo {
287 +
                    version: env!("OXY_VERSION_LONG").into(),
288 +
                    ..RunnerInfo::here(cfg.labels.clone(), budget.max_jobs as u32, cfg.memory_mib, cfg.cpus)
289 +
                }),
290 +
            };
291 +
            match hub.claim(&req) {
292 +
                Ok(Some(claim)) => {
293 +
                    idle = Duration::from_secs(2);
294 +
                    let (mem, cpus) = (claim.sandbox.memory_mib, claim.sandbox.cpus);
295 +
                    {
296 +
                        let mut u = used.lock().unwrap();
297 +
                        u.jobs += 1;
298 +
                        u.memory_mib += mem;
299 +
                        u.cpus += cpus;
250 300
                    }
251 -
                    let mut u = used.lock().unwrap();
252 -
                    u.jobs -= 1;
253 -
                    u.memory_mib -= mem;
254 -
                    u.cpus -= cpus;
255 -
                });
256 -
            }
257 -
            Ok(None) => std::thread::sleep(idle),
258 -
            Err(e) => {
259 -
                eprintln!("claim: {e:#}");
260 -
                std::thread::sleep(idle);
261 -
                idle = (idle * 2).min(Duration::from_secs(60));
301 +
                    let (home, hub, cfg, used, key) = (home, &hub, &cfg, &used, &key);
302 +
                    scope.spawn(move || {
303 +
                        let id = claim.job_id;
304 +
                        eprintln!(
305 +
                            "job {id}: {} {} on {}",
306 +
                            claim.repo,
307 +
                            claim.job.name,
308 +
                            claim.heads.first().map(|h| &h[..12]).unwrap_or("-")
309 +
                        );
310 +
                        let finish = run_claim(home, hub, cfg, key, &claim).unwrap_or_else(|e| Finish {
311 +
                            exit_code: None,
312 +
                            duration_ms: 0,
313 +
                            receipt: None,
314 +
                            signed_receipt: None,
315 +
                            error: Some(format!("{e:#}")),
316 +
                        });
317 +
                        eprintln!(
318 +
                            "job {id}: exit {:?}{}",
319 +
                            finish.exit_code,
320 +
                            finish.error.as_deref().map(|e| format!(" ({e})")).unwrap_or_default()
321 +
                        );
322 +
                        report_finish(home, hub, id, &finish);
323 +
                        let mut u = used.lock().unwrap();
324 +
                        u.jobs -= 1;
325 +
                        u.memory_mib -= mem;
326 +
                        u.cpus -= cpus;
327 +
                    });
328 +
                }
329 +
                Ok(None) => std::thread::sleep(idle),
330 +
                Err(e) => {
331 +
                    eprintln!("claim: {e:#}");
332 +
                    std::thread::sleep(idle);
333 +
                    idle = (idle * 2).min(Duration::from_secs(60));
334 +
                }
262 335
            }
263 336
        }
264 337
    })
…
313 unchanged lines
578 651
579 652
    fn hub_at(base: String) -> Hub {
580 653
        Hub { base, token: "t".into(), agent: ureq::AgentBuilder::new().timeout(Duration::from_secs(5)).build() }
654 +
    }
655 +
656 +
    /// oxygenhub#71: a job's result is offered until the hub takes it,
657 +
    /// through server errors, and a refusal ends the trying at once.
658 +
    #[test]
659 +
    fn a_result_is_reported_until_the_hub_takes_it() {
660 +
        let f = Finish { exit_code: Some(0), duration_ms: 1, receipt: None, signed_receipt: None, error: None };
661 +
        let quick = Duration::from_millis(10);
662 +
        let (base, seen) = fake_hub(vec![502, 503, 500, 500, 204]);
663 +
        hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).unwrap();
664 +
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 5, "kept trying through four server errors");
665 +
        let (base, seen) = fake_hub(vec![409]);
666 +
        assert!(hub_at(base).finish_until_accepted(7, &f, Duration::from_secs(30), quick).is_err());
667 +
        assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1, "a refusal is final");
581 668
    }
582 669
583 670
    #[test]
…
32 unchanged lines