Skip to content

Commit e00ce35

Browse files
ZhiXiao-LinRoy Lin
andauthored
feat(sdk): parallel pipeline fan-out + typed JSON report with metrics (#156)
* feat(sdk): parallel pipeline fan-out + typed JSON report with metrics Base::run_parallel runs steps as concurrent copy-on-write MicroVM forks (bounded, collect-all, input-ordered) and returns a dependency-free JSON Report. StepResult gains separated stdout/stderr, duration_ms, and metrics parsed from `::metric <key>=<value>` guest-stdout lines (a scoring channel for matrix/selection workloads). Steps now take &self (atomic fork counter), so fan-out no longer needs hand-rolled threads. RAII removes each fork on every path and the base snapshot on Drop (--force). Box/snapshot names carry per-process+instance entropy to prevent cross-pipeline name collisions; output is capped to bound report memory under large fan-out; infra failures use a distinct sentinel. 15 unit tests + doctest, clippy clean; validated end-to-end on a real /dev/kvm host (real boot, CoW fork-per-step, metrics, JSON report, 0 leaks). * test(sdk): real-KVM integration + soak suites; add crash-orphan sweep + infra retry Backs the parallel pipeline with real-microVM coverage and hardens it for sustained, highly-concurrent churn: - sweep_orphans(): reclaim ci-base-* boxes/snapshots left by a SIGKILL/OOM'd pipeline process (its RAII never ran), matched by the dead owner pid embedded in the resource name; never touches a live peer's resources. - WarmBase::infra_retries (default 2): retry a fork that hits a TRANSIENT infra failure (restore/start/boot); the step's command never ran, so re-forking is idempotent. Keeps sustained high-concurrency churn green. - tests/integration_kvm.rs (5 #[ignore] tests): warm+fork+exec, cache hit, parallel order/metrics, fork isolation, leak-freeness, sweep crash-recovery. - tests/soak_kvm.rs: sustained fork-eval churn stays leak-free and RSS-stable; leak gates are process-scoped (robust to a concurrent pipeline on the host). - ci.yml: run both under the integration-kvm (real /dev/kvm) gate. Validated on a real KVM host (a3s-box 2.5.1): integration 5/5; soak 1500 fork-evals across 75 generations leak-free, RSS +512 KiB; 0 orphans after. * feat(sdk): a3s-box-ci runner binary + warm_base infra-retry - a3s-box-ci: a dep-free bin (in a3s-box-sdk) bridging any agent/tool to the pipeline. `run [SPEC|-]` parses a line-based spec -> run_parallel -> JSON Report (exit 0 iff passed); `sweep` reclaims crashed-pipeline orphans. This is what lets a3s-code / Claude Code / Codex drive the pipeline from a script. - warm_base now retries a transient infrastructure failure too (DRY'd with the per-step fork via a shared retry_infra), so concurrent same-image warms stay robust under load. Validated end-to-end on real KVM (runner: real pipeline + sweep; a3s-code drives it via session.program through the QuickJS runtime). 19 unit/bin tests + clippy. * fix(runtime): rootfs-cache in-use guard prevents concurrent same-image corruption RootfsCache::prune (called after a cache-miss put) evicted LRU entries with no in-use guard, so it could remove_dir_all a cache entry that a CONCURRENT box was using as its overlayfs lowerdir — that box's mount(2) then failed with ENOENT ('No such file or directory (os error 2)'), persisting through retries since the backing was gone. Two pipelines from the same image collapse onto one cache key, so this hit any concurrent same-image workload. Fix: the same in-use guard SnapshotStore::prune already applies to live CoW lowers. Each overlay box records the cache key it holds in <box_dir>/.rootfs- cache-key (removed with the box dir); prune skips any still-referenced key (prune_protecting, with prune as the empty-set wrapper). Found via a concurrent-pipeline chaos test driven through a3s-code; verified on a real /dev/kvm host (concurrency scenario: ~50% failure -> reliably green). 41 rootfs-cache unit tests + clippy clean. * style(runtime): rustfmt the rootfs-cache in-use-guard test --------- Co-authored-by: Roy Lin <roylin@a3s.box>
1 parent 0249c6d commit e00ce35

11 files changed

Lines changed: 1526 additions & 86 deletions

File tree

.github/workflows/ci.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,3 +244,27 @@ jobs:
244244
unset A3S_DEPS_STUB
245245
chmod +x bench/bench.sh
246246
bench/bench.sh race
247+
# SDK programmable-CI pipeline against REAL microVMs: warm + CoW fork-per-step,
248+
# parallel collect-all, `::metric` extraction, fork isolation, leak-freeness,
249+
# and crash-orphan recovery (sweep_orphans). The unit suite only drives a fake
250+
# CLI, so this is the only gate that the real fork/snapshot path actually works.
251+
- name: SDK pipeline integration — real fork-per-step + sweep
252+
env:
253+
A3S_BOX: ${{ github.workspace }}/src/target/release/a3s-box
254+
A3S_SDK_TEST_IMAGE: ${{ vars.KVM_CI_AGENT_IMAGE || 'docker.m.daocloud.io/library/alpine:latest' }}
255+
run: |
256+
unset A3S_DEPS_STUB
257+
cd src
258+
cargo test --release -p a3s-box-sdk --test integration_kvm -- --ignored --nocapture --test-threads=1
259+
# Soak the fork-eval loop: sustained churn must stay leak-free (no orphan
260+
# boxes/snapshots, snapshot count flat) and memory-stable. Light here (120
261+
# fork-evals); crank A3S_SDK_SOAK_FORKS for a manual long soak.
262+
- name: SDK pipeline soak — leak-free under churn
263+
env:
264+
A3S_BOX: ${{ github.workspace }}/src/target/release/a3s-box
265+
A3S_SDK_TEST_IMAGE: ${{ vars.KVM_CI_AGENT_IMAGE || 'docker.m.daocloud.io/library/alpine:latest' }}
266+
A3S_SDK_SOAK_FORKS: "120"
267+
run: |
268+
unset A3S_DEPS_STUB
269+
cd src
270+
cargo test --release -p a3s-box-sdk --test soak_kvm -- --ignored --nocapture --test-threads=1

CHANGELOG.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,58 @@ All notable changes to A3S Box will be documented in this file.
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- **Programmable-CI pipeline: parallel fan-out + typed JSON report (`a3s-box-sdk`).**
10+
`Base::run_parallel(steps, max_concurrency)` runs steps concurrently as isolated
11+
copy-on-write MicroVM forks (bounded, collect-all, results in input order) and returns a
12+
`Report` with a dependency-free `to_json()`. `StepResult` now carries separated
13+
`stdout`/`stderr`, `duration_ms`, and `metrics` parsed from `::metric <key>=<value>`
14+
guest-stdout lines (a machine-readable scoring channel for matrix/selection workloads).
15+
Steps run via `&self` (atomic fork counter), so fan-out no longer needs hand-rolled
16+
threads. The base auto-removes its snapshot on `Drop` (`--force`), and each fork is
17+
removed on every path (including panic). Box/snapshot names now carry per-process +
18+
per-instance entropy, so concurrent pipelines from the same image+setup can no longer
19+
collide and tear down each other's boxes. A fork that hits a *transient*
20+
infrastructure failure (restore/start/boot) is retried — `WarmBase::infra_retries`,
21+
default 2 — since its command never ran, which keeps sustained high-concurrency
22+
churn green. Validated end-to-end on a real `/dev/kvm` host.
23+
- **Crash-orphan recovery + real-VM integration & soak tests.** `sweep_orphans()`
24+
reclaims `ci-base-*` boxes/snapshots left behind when a pipeline process is
25+
`SIGKILL`ed / OOM-killed (its RAII cleanup never runs), by matching the dead owner
26+
pid embedded in the resource name — and it never touches a live peer's resources.
27+
Added `#[ignore]`'d real-microVM integration tests (`tests/integration_kvm.rs`:
28+
warm + fork-per-step, cache, parallel order/metrics, fork isolation, leak-freeness,
29+
sweep) and a soak test (`tests/soak_kvm.rs`: sustained fork-eval churn stays
30+
leak-free and RSS-stable), both wired into the KVM CI gate.
31+
- **`a3s-box-ci` runner + `warm_base` retry.** A dependency-free `a3s-box-ci` binary
32+
(shipped by the `a3s-box-sdk` crate) bridges any agent/tool to the pipeline: a
33+
line-based spec on stdin → a JSON `Report` on stdout (`a3s-box-ci run -`), plus
34+
`a3s-box-ci sweep` for crash-orphan recovery. `warm_base` now also retries on a
35+
transient infrastructure failure (sharing the step-fork's `retry_infra` budget),
36+
so concurrent same-image warms are more robust under load.
37+
38+
### Changed
39+
40+
- **`StepResult.logs` is replaced by separated `stdout` / `stderr` fields**
41+
(use `StepResult::combined()` for the old concatenated view). Breaking for
42+
direct `.logs` field access on the `a3s-box-sdk` pipeline API.
43+
44+
### Fixed
45+
46+
- **Concurrent same-image pipelines could corrupt each other's rootfs cache.**
47+
`RootfsCache::prune` (run after a cache-miss `put`) evicted least-recently-used
48+
entries with no in-use guard, so it could `remove_dir_all` a cache entry that
49+
another box was simultaneously using as its overlayfs **lowerdir** — the peer's
50+
`mount(2)` then failed with `No such file or directory (os error 2)`, and the
51+
failure persisted through retries (the backing was gone). Added the same in-use
52+
guard `SnapshotStore::prune` already applies to live copy-on-write lowers: each
53+
overlay box records the cache key it holds in a `<box_dir>/.rootfs-cache-key`
54+
marker (removed with the box dir), and `prune` skips any still-referenced key.
55+
Found via a concurrent-pipeline chaos test driven through a3s-code; root-caused
56+
and verified on a real `/dev/kvm` host (the concurrency scenario went from ~50%
57+
failure to reliably green).
58+
759
## [2.6.0] — 2026-06-26
860

961
### Added

src/runtime/src/cache/rootfs_cache.rs

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -190,11 +190,27 @@ impl RootfsCache {
190190
Ok(())
191191
}
192192

193-
/// Prune the cache to stay within the given entry count limit.
193+
/// Prune the cache to stay within the given entry count / byte limit.
194194
///
195-
/// Evicts least-recently-accessed entries first.
196-
/// Returns the number of entries evicted.
195+
/// Evicts least-recently-accessed entries first. Returns the number evicted.
197196
pub fn prune(&self, max_entries: usize, max_bytes: u64) -> Result<usize> {
197+
self.prune_protecting(max_entries, max_bytes, &std::collections::HashSet::new())
198+
}
199+
200+
/// Like [`RootfsCache::prune`], but never evicts an entry whose key is in
201+
/// `protected`. Such an entry is currently serving as a box's overlayfs
202+
/// **lowerdir**, and `remove_dir_all`-ing it out from under a concurrent box's
203+
/// `mount(2)` makes the mount fail with ENOENT ("No such file or directory").
204+
/// This is the same in-use guard [`crate::SnapshotStore::prune`] applies to
205+
/// live copy-on-write lowers — without it, two pipelines built from the same
206+
/// image (one cache-hit overlay box, one cache-miss box that prunes after its
207+
/// put) can race and corrupt each other.
208+
pub fn prune_protecting(
209+
&self,
210+
max_entries: usize,
211+
max_bytes: u64,
212+
protected: &std::collections::HashSet<String>,
213+
) -> Result<usize> {
198214
let mut entries = self.list_entries()?;
199215

200216
if entries.len() <= max_entries {
@@ -215,6 +231,11 @@ impl RootfsCache {
215231
if current_count <= max_entries && current_size <= max_bytes {
216232
break;
217233
}
234+
// Never evict an entry in use as a live overlay lower — deleting the
235+
// lowerdir under a concurrent box's mount(2) is the bug this guards.
236+
if protected.contains(&entry.key) {
237+
continue;
238+
}
218239
self.invalidate(&entry.key)?;
219240
current_count -= 1;
220241
current_size = current_size.saturating_sub(entry.size_bytes);
@@ -495,6 +516,52 @@ mod tests {
495516
assert_eq!(cache.entry_count().unwrap(), 1);
496517
}
497518

519+
#[test]
520+
fn prune_protecting_never_evicts_in_use_key() {
521+
let tmp = TempDir::new().unwrap();
522+
let cache = RootfsCache::new(tmp.path()).unwrap();
523+
for i in 0..4 {
524+
let src = tmp.path().join(format!("s{i}"));
525+
create_test_rootfs(&src, &[("f", "x")]);
526+
cache.put(&format!("k{i}"), &src, &format!("e{i}")).unwrap();
527+
std::thread::sleep(std::time::Duration::from_millis(10));
528+
}
529+
// k0 is the OLDEST (normally evicted first) but is in use as an overlay lower.
530+
let mut protected = std::collections::HashSet::new();
531+
protected.insert("k0".to_string());
532+
// keep=2 over 4 entries evicts two; the protected k0 is never one of them.
533+
// (last_accessed is second-resolution, so WHICH two unprotected entries go
534+
// is not asserted — only that the in-use lower survives.)
535+
let evicted = cache.prune_protecting(2, u64::MAX, &protected).unwrap();
536+
assert_eq!(evicted, 2, "two unprotected entries evicted to meet keep=2");
537+
assert!(
538+
cache.get("k0").unwrap().is_some(),
539+
"the in-use (protected) lower must survive prune"
540+
);
541+
assert_eq!(
542+
cache.entry_count().unwrap(),
543+
2,
544+
"k0 + one unprotected remain"
545+
);
546+
}
547+
548+
#[test]
549+
fn prune_protecting_keeps_all_when_all_in_use() {
550+
let tmp = TempDir::new().unwrap();
551+
let cache = RootfsCache::new(tmp.path()).unwrap();
552+
for i in 0..2 {
553+
let src = tmp.path().join(format!("s{i}"));
554+
create_test_rootfs(&src, &[("f", "x")]);
555+
cache.put(&format!("k{i}"), &src, "e").unwrap();
556+
}
557+
let protected: std::collections::HashSet<String> =
558+
["k0", "k1"].iter().map(|s| s.to_string()).collect();
559+
// Even asked to keep 0, nothing is evicted — every entry is a live lower.
560+
let evicted = cache.prune_protecting(0, 0, &protected).unwrap();
561+
assert_eq!(evicted, 0, "all in-use -> nothing evicted");
562+
assert_eq!(cache.entry_count().unwrap(), 2);
563+
}
564+
498565
#[test]
499566
fn test_rootfs_cache_metadata_persists() {
500567
let tmp = TempDir::new().unwrap();

src/runtime/src/vm/layout.rs

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,9 @@ impl VmManager {
156156
let cache_key = RootfsCache::compute_key(reference, &[], &[], &[]);
157157
if let Some(cached_path) = self.try_rootfs_cache_path(&cache_key)? {
158158
let rootfs_path = self.rootfs_provider.prepare(&box_dir, &cached_path)?;
159+
// Record that this box holds `cache_key` as its overlay lower, so a
160+
// concurrent box's cache prune won't evict it mid-mount (ENOENT).
161+
self.mark_rootfs_cache_key(&box_dir, &cache_key);
159162
let tee_instance_config = self.generate_tee_config(&box_dir)?;
160163
return Ok(BoxLayout {
161164
rootfs_path,
@@ -204,6 +207,9 @@ impl VmManager {
204207
prom.rootfs_cache_hits.inc();
205208
}
206209
let rootfs_path = self.rootfs_provider.prepare(&box_dir, &cached_path)?;
210+
// Record that this box holds `cache_key` as its overlay lower, so a
211+
// concurrent box's cache prune won't evict it mid-mount (ENOENT).
212+
self.mark_rootfs_cache_key(&box_dir, &cache_key);
207213

208214
if let Ok(guest_init_path) = Self::find_guest_init() {
209215
tracing::info!(
@@ -377,10 +383,14 @@ impl VmManager {
377383
description = %description,
378384
"Stored rootfs in cache"
379385
);
380-
// Prune if needed
381-
if let Err(e) = cache.prune(
386+
// Prune if needed — but never evict a cache entry that is in use as
387+
// a live overlay lower for a concurrent box (deleting the lowerdir
388+
// under its mount(2) is the same-image concurrency bug this guards).
389+
let protected = self.referenced_rootfs_cache_keys();
390+
if let Err(e) = cache.prune_protecting(
382391
self.config.cache.max_rootfs_entries,
383392
self.config.cache.max_cache_bytes,
393+
&protected,
384394
) {
385395
tracing::warn!(error = %e, "Failed to prune rootfs cache");
386396
}
@@ -391,6 +401,29 @@ impl VmManager {
391401
}
392402
}
393403

404+
/// Record which rootfs-cache key this box holds as its overlay lower, in a
405+
/// `<box_dir>/.rootfs-cache-key` marker (mirror of the snapshot store's
406+
/// `.snapshot-lower`). Read back by [`Self::referenced_rootfs_cache_keys`] so
407+
/// the cache prune never evicts a live lower. Best-effort; removed with box_dir.
408+
fn mark_rootfs_cache_key(&self, box_dir: &Path, cache_key: &str) {
409+
let _ = std::fs::write(box_dir.join(".rootfs-cache-key"), cache_key);
410+
}
411+
412+
/// Rootfs-cache keys currently in use as an overlay lower by some live box.
413+
/// Boxes live under `<home>/boxes/<id>/`; a removed box's marker is gone with
414+
/// its dir, so an evictable key is simply one no live box references.
415+
fn referenced_rootfs_cache_keys(&self) -> std::collections::HashSet<String> {
416+
let mut set = std::collections::HashSet::new();
417+
if let Ok(entries) = std::fs::read_dir(self.home_dir.join("boxes")) {
418+
for entry in entries.flatten() {
419+
if let Ok(k) = std::fs::read_to_string(entry.path().join(".rootfs-cache-key")) {
420+
set.insert(k.trim().to_string());
421+
}
422+
}
423+
}
424+
set
425+
}
426+
394427
/// Resolve the cache directory from config or default.
395428
pub(crate) fn resolve_cache_dir(&self) -> PathBuf {
396429
self.config

src/sdk/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,9 @@ description = "Rust SDK for a3s-box. Includes a programmable CI/CD pipeline API
99

1010
[dependencies]
1111
# none — a thin, dependency-free wrapper over the `a3s-box` CLI.
12+
13+
# Thin runner that bridges any agent (a3s-code / Claude Code / Codex) to the
14+
# pipeline API: a line-based spec on stdin -> JSON Report on stdout.
15+
[[bin]]
16+
name = "a3s-box-ci"
17+
path = "src/bin/a3s-box-ci.rs"

src/sdk/README.md

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,28 +11,48 @@ state). Set `A3S_BOX` if `a3s-box` is not on `PATH`.
1111

1212
## Pipelines
1313

14-
Warm a base box **once** (clone + install deps), snapshot it, fork per step:
14+
Warm a base box **once** (clone + install deps), snapshot it, fork per step.
15+
Run steps **sequentially** (fail-fast) or **in parallel** (collect-all → a typed
16+
report):
1517

1618
```rust
1719
use a3s_box_sdk::pipeline::{warm_base, WarmBase, FileCache, Step};
1820

1921
fn main() -> Result<(), a3s_box_sdk::pipeline::PipelineError> {
2022
let cache = FileCache::new(".ci-cache")?; // skip a step when its inputs are unchanged
21-
let mut base = warm_base(
23+
let base = warm_base(
2224
WarmBase::new("node:20", "git clone $REPO /w && cd /w && npm ci") // runs ONCE
2325
.env("REPO", "https://github.com/me/app")
2426
.cache(&cache),
2527
)?;
28+
29+
// Sequential, fail-fast: a non-zero exit returns Err.
2630
base.step(Step::new("lint", "cd /w && npm run lint"))?;
27-
base.step(Step::new("test", "cd /w && npm test"))?; // nonzero exit -> Err (fail-fast)
28-
base.step(Step::new("build", "cd /w && npm run build"))?;
29-
base.dispose(); // drops the snapshot
30-
Ok(())
31+
32+
// Parallel, collect-all: each step is an isolated CoW fork; <=4 at a time.
33+
let report = base.run_parallel(vec![
34+
Step::new("test", "cd /w && npm test"),
35+
Step::new("build", "cd /w && npm run build"),
36+
], 4);
37+
38+
println!("{}", report.to_json()); // {"passed":..,"total_ms":..,"steps":[..]}
39+
if !report.passed { /* inspect report.failures() */ }
40+
Ok(()) // `base` drops here -> snapshot auto-removed (or call base.dispose())
3141
}
3242
```
3343

34-
`Step::allow_failure()` keeps the pipeline going on a non-zero exit; `Step::input(..)`
35-
adds extra cache-key parts. Parallel steps = spawn threads (each `step` is blocking).
44+
- `run_parallel` is the way to use a3s-box's cheap (~ms) CoW fork at scale — a
45+
matrix / evolution-style batch — without hand-rolling threads (every method
46+
takes `&self`).
47+
- A step reports a metric by printing `::metric <key>=<number>` to stdout; it
48+
surfaces as `StepResult::metrics` (the scoring channel for a selection loop).
49+
- `StepResult` carries separated `stdout`/`stderr`, `duration_ms`, and `cached`;
50+
`Report::to_json()` is the machine-readable handoff to an agent/scorer.
51+
- `Step::allow_failure()` keeps a non-zero step from failing the run; `Step::input(..)`
52+
adds extra cache-key parts.
53+
54+
The base **auto-disposes** its snapshot on drop, and each per-step box is removed
55+
on every path (including a panic), so a long-running batch doesn't leak.
3656

3757
## Why forking is cheap
3858

src/sdk/examples/pipeline.rs

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,25 +12,36 @@ fn main() -> Result<(), a3s_box_sdk::pipeline::PipelineError> {
1212
let cache = FileCache::new("/tmp/.a3s-ci-demo")?;
1313

1414
// Warm the base once (here: write a marker = "deps installed"), snapshot it.
15-
let mut base = warm_base(WarmBase::new(image, "echo DEPS-INSTALLED > /warmed").cache(&cache))?;
15+
let base = warm_base(WarmBase::new(image, "echo DEPS-INSTALLED > /warmed").cache(&cache))?;
1616

17+
// Sequential, fail-fast: a non-zero exit returns Err.
1718
let r = base.step(Step::new("read", "cat /warmed"))?;
18-
println!(
19-
"read -> code={} out={:?} cached={}",
20-
r.exit_code,
21-
r.logs.trim(),
22-
r.cached
23-
);
24-
19+
println!("read -> code={} out={:?}", r.exit_code, r.stdout.trim());
2520
let r2 = base.step(Step::new("read", "cat /warmed"))?; // identical -> cache hit
2621
println!("read#2 -> cached={}", r2.cached);
2722

28-
match base.step(Step::new("fail", "exit 7")) {
29-
Err(e) => println!("fail-fast ok: {e}"),
30-
Ok(_) => println!("ERROR: fail step did not error"),
31-
}
23+
// Parallel matrix, collect-all: each step is an isolated CoW fork of the base.
24+
// A step reports a metric by printing `::metric key=value`.
25+
let report = base.run_parallel(
26+
vec![
27+
Step::new("ok", "echo fine"),
28+
Step::new("perf", "echo '::metric duration_ms=12.5'"),
29+
Step::new("fail", "exit 7"), // collected, not fatal
30+
],
31+
4,
32+
);
33+
println!("report -> {}", report.to_json());
34+
println!(
35+
"passed={} failures={:?}",
36+
report.passed,
37+
report
38+
.failures()
39+
.iter()
40+
.map(|s| &s.name)
41+
.collect::<Vec<_>>()
42+
);
3243

33-
base.dispose();
44+
// base.dispose() is optional — the snapshot is removed when `base` drops.
3445
println!("demo ok");
3546
Ok(())
3647
}

0 commit comments

Comments
 (0)