Skip to content

Commit 86bb2d9

Browse files
zackeesclaude
andauthored
feat(library-selection): #214 wire resolve_cached into teensy + stm32 orchestrators (#217)
Phase 4 of #205 (PR #212) shipped `resolve_cached(...)` with a `zccache_artifact::KvStore` backend, but no production code called it — every build re-ran the LDF walk from scratch. This wires the cache into the two orchestrators that already use `framework_libs`. framework_libs.rs gains: - `resolve_framework_library_sources_cached(...)` — the cached counterpart to the existing uncached entry point. Falls back to the uncached path on KvStore backend errors (open/read/write) so a degraded cache can never poison a build, mirroring the corrupt-entry recovery already in `cache.rs`. - `library_select_kv_store()` — process-shared `KvStore` opened lazily via `OnceLock`, rooted under `fbuild_paths::get_cache_root()` so it honors `FBUILD_DEV_MODE` and `FBUILD_CACHE_DIR`. Returns `None` on open failure and callers degrade gracefully. teensy + stm32 orchestrators construct `CacheKeyInputs` from the framework's `PackageInfo` (install path, version) plus a stable toolchain triple (`teensy-arm-none-eabi`, `stm32-arm-none-eabi`) and route through the cached helper when the KvStore is available. Tests: - New `cached_resolution_round_trips_through_kvstore` exercises miss → hit on the public cached entry through a tempdir-backed KvStore. - All 4 existing framework_libs tests still pass (uncached path). - All 16 fbuild-library-select tests still pass. Docs: - `docs/architecture/library-selection.md` status, cache-key, and future-work sections refreshed; Phase 4 is no longer "not yet shipped" and Phase 8.b dead-helper cleanup is essentially done. Closes #214 Refs #205 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 43c2d65 commit 86bb2d9

6 files changed

Lines changed: 256 additions & 23 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/fbuild-build/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ fbuild-config = { path = "../fbuild-config" }
1212
fbuild-paths = { path = "../fbuild-paths" }
1313
fbuild-packages = { path = "../fbuild-packages" }
1414
fbuild-library-select = { path = "../fbuild-library-select" }
15+
zccache-artifact = { workspace = true }
1516
tokio = { workspace = true }
1617
serde = { workspace = true }
1718
serde_json = { workspace = true }

crates/fbuild-build/src/framework_libs.rs

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,13 @@
1313
//! example) stay out of the compile set. See FastLED/fbuild#205.
1414
1515
use std::path::{Path, PathBuf};
16+
use std::sync::OnceLock;
1617

18+
use fbuild_library_select::cache::{resolve_cached, CacheKeyInputs};
1719
use fbuild_library_select::resolve as resolve_library_selection;
1820
use fbuild_packages::library::FrameworkLibrary;
1921
use walkdir::{DirEntry, WalkDir};
22+
use zccache_artifact::KvStore;
2023

2124
/// Resolve framework library source files needed by a project.
2225
pub fn resolve_framework_library_sources(
@@ -56,6 +59,123 @@ pub fn resolve_framework_library_sources_from_libraries(
5659
selection.source_files
5760
}
5861

62+
/// Cached counterpart to [`resolve_framework_library_sources`].
63+
///
64+
/// Routes the same `(libraries, project_dir, src_dir)` resolution through
65+
/// `fbuild_library_select::cache::resolve_cached` using the supplied
66+
/// `KvStore`. On a backend failure (open, read, write) we log a warning and
67+
/// fall back to the uncached `resolve(...)` so a degraded cache can never
68+
/// poison a build — same philosophy as the corrupt-entry handling already
69+
/// inside `cache.rs`.
70+
pub fn resolve_framework_library_sources_cached(
71+
libraries: &[FrameworkLibrary],
72+
project_dir: &Path,
73+
src_dir: &Path,
74+
key_inputs: &CacheKeyInputs<'_>,
75+
store: &KvStore,
76+
) -> Vec<PathBuf> {
77+
let (sources, _hit) = resolve_framework_library_sources_cached_with_hit(
78+
libraries,
79+
project_dir,
80+
src_dir,
81+
key_inputs,
82+
store,
83+
);
84+
sources
85+
}
86+
87+
/// Internal helper that returns `(sources, from_cache)` so tests can assert
88+
/// hit/miss without the public API surfacing that bit. The hit flag is
89+
/// `false` whenever the cache backend errored and we fell back to the
90+
/// uncached resolver.
91+
pub(crate) fn resolve_framework_library_sources_cached_with_hit(
92+
libraries: &[FrameworkLibrary],
93+
project_dir: &Path,
94+
src_dir: &Path,
95+
key_inputs: &CacheKeyInputs<'_>,
96+
store: &KvStore,
97+
) -> (Vec<PathBuf>, bool) {
98+
let roots = framework_include_scan_roots(project_dir, src_dir);
99+
if libraries.is_empty() {
100+
return (Vec::new(), false);
101+
}
102+
103+
let seeds = collect_project_seeds(&roots);
104+
let search_paths: Vec<PathBuf> = roots.clone();
105+
106+
match resolve_cached(&seeds, &search_paths, libraries, key_inputs, store) {
107+
Ok(cached) => {
108+
for name in &cached.selection.required_libraries {
109+
if let Some(lib) = libraries.iter().find(|l| &l.name == name) {
110+
tracing::info!(
111+
"selected framework library '{}': {} source files",
112+
lib.name,
113+
lib.source_files.len()
114+
);
115+
}
116+
}
117+
tracing::info!(
118+
cache = if cached.from_cache { "hit" } else { "miss" },
119+
key = %cached.key.to_hex(),
120+
"library-select cache: {}",
121+
if cached.from_cache { "hit" } else { "miss" }
122+
);
123+
(cached.selection.source_files, cached.from_cache)
124+
}
125+
Err(err) => {
126+
tracing::warn!(
127+
error = %err,
128+
"library-select cache backend error; falling back to uncached resolve"
129+
);
130+
(
131+
resolve_framework_library_sources_from_libraries(libraries, &roots),
132+
false,
133+
)
134+
}
135+
}
136+
}
137+
138+
/// Process-shared `KvStore` for the library-selection cache.
139+
///
140+
/// Opens lazily on first call and caches the handle for the rest of the
141+
/// process. Returns `None` on open failure — callers must skip caching
142+
/// (and route through the uncached resolver) rather than crash.
143+
pub fn library_select_kv_store() -> Option<&'static KvStore> {
144+
static STORE: OnceLock<Option<KvStore>> = OnceLock::new();
145+
STORE
146+
.get_or_init(|| {
147+
let dir = library_select_cache_dir();
148+
match KvStore::open(&dir) {
149+
Ok(store) => {
150+
tracing::info!(
151+
path = %dir.display(),
152+
"library-select cache: opened KvStore"
153+
);
154+
Some(store)
155+
}
156+
Err(err) => {
157+
tracing::warn!(
158+
path = %dir.display(),
159+
error = %err,
160+
"library-select cache: failed to open KvStore; \
161+
resolution will run uncached"
162+
);
163+
None
164+
}
165+
}
166+
})
167+
.as_ref()
168+
}
169+
170+
/// Filesystem location of the library-selection KvStore.
171+
///
172+
/// Routes through `fbuild_paths::get_cache_root()` so the cache obeys the
173+
/// dev/prod isolation contract (`FBUILD_DEV_MODE=1` → `~/.fbuild/dev/cache`)
174+
/// and any `FBUILD_CACHE_DIR` override.
175+
fn library_select_cache_dir() -> PathBuf {
176+
fbuild_paths::get_cache_root().join("library-selection")
177+
}
178+
59179
/// Project directories to scan for `#include` directives and local headers.
60180
pub fn framework_include_scan_roots(project_dir: &Path, src_dir: &Path) -> Vec<PathBuf> {
61181
let mut roots = Vec::new();
@@ -331,4 +451,54 @@ mod tests {
331451

332452
assert_eq!(sources, vec![spi_dir.join("SPI.cpp")]);
333453
}
454+
455+
#[test]
456+
fn cached_resolution_round_trips_through_kvstore() {
457+
let tmp = tempfile::TempDir::new().unwrap();
458+
let project_dir = tmp.path().join("project");
459+
let src_dir = project_dir.join("src");
460+
std::fs::create_dir_all(&src_dir).unwrap();
461+
std::fs::write(src_dir.join("main.cpp"), "#include <SPI.h>\n").unwrap();
462+
463+
let spi_dir = tmp.path().join("framework").join("libraries").join("SPI");
464+
std::fs::create_dir_all(&spi_dir).unwrap();
465+
std::fs::write(spi_dir.join("SPI.h"), "").unwrap();
466+
std::fs::write(spi_dir.join("SPI.cpp"), "").unwrap();
467+
468+
let libraries = vec![FrameworkLibrary {
469+
name: "SPI".to_string(),
470+
dir: spi_dir.clone(),
471+
include_dirs: vec![spi_dir.clone()],
472+
source_files: vec![spi_dir.join("SPI.cpp")],
473+
}];
474+
475+
let framework_root = tmp.path().join("framework");
476+
let key_inputs = CacheKeyInputs {
477+
toolchain_triple: "test-arm-none-eabi",
478+
framework_install_path: &framework_root,
479+
framework_version: "0.0.0-test",
480+
};
481+
482+
let kv = KvStore::open(tmp.path().join("kv")).unwrap();
483+
484+
let (first, hit_first) = resolve_framework_library_sources_cached_with_hit(
485+
&libraries,
486+
&project_dir,
487+
&src_dir,
488+
&key_inputs,
489+
&kv,
490+
);
491+
assert!(!hit_first, "first call must miss the cache");
492+
assert_eq!(first, vec![spi_dir.join("SPI.cpp")]);
493+
494+
let (second, hit_second) = resolve_framework_library_sources_cached_with_hit(
495+
&libraries,
496+
&project_dir,
497+
&src_dir,
498+
&key_inputs,
499+
&kv,
500+
);
501+
assert!(hit_second, "second call must hit the cache");
502+
assert_eq!(first, second, "cache hit must yield identical sources");
503+
}
334504
}

crates/fbuild-build/src/stm32/orchestrator.rs

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ use fbuild_core::{Platform, Result};
2020
use fbuild_packages::{Framework, Toolchain};
2121

2222
use crate::compile_database::TargetArchitecture;
23-
use crate::framework_libs::resolve_framework_library_sources;
23+
use crate::framework_libs::{
24+
library_select_kv_store, resolve_framework_library_sources,
25+
resolve_framework_library_sources_cached,
26+
};
2427
use crate::generic_arm::{ArmCompiler, ArmLinker};
2528
use crate::pipeline;
2629
use crate::source_scanner::SourceCollection;
@@ -110,8 +113,34 @@ impl BuildOrchestrator for Stm32Orchestrator {
110113
// STM32duino only exposes bundled libraries via this framework-level
111114
// discovery (PlatformIO's LDF does the same for `framework = arduino`).
112115
let framework_libs = framework.get_framework_libraries();
113-
let framework_library_sources =
114-
resolve_framework_library_sources(&framework_libs, &params.project_dir, &ctx.src_dir);
116+
// WHY: STM32duino targets every Cortex-M family from M0 (F0xx) up
117+
// through M7 (H7xx) but the toolchain triple is constant
118+
// (`arm-none-eabi`). The cache key already includes
119+
// `framework_install_path` + `framework_version`, so per-MCU drift
120+
// is handled there — this string only needs to disambiguate stm32
121+
// from teensy etc. so cross-platform key collisions are impossible.
122+
let framework_info = fbuild_packages::Package::get_info(&framework);
123+
let framework_library_sources = match library_select_kv_store() {
124+
Some(store) => {
125+
let key_inputs = fbuild_library_select::cache::CacheKeyInputs {
126+
toolchain_triple: "stm32-arm-none-eabi",
127+
framework_install_path: &framework_info.install_path,
128+
framework_version: &framework_info.version,
129+
};
130+
resolve_framework_library_sources_cached(
131+
&framework_libs,
132+
&params.project_dir,
133+
&ctx.src_dir,
134+
&key_inputs,
135+
store,
136+
)
137+
}
138+
None => resolve_framework_library_sources(
139+
&framework_libs,
140+
&params.project_dir,
141+
&ctx.src_dir,
142+
),
143+
};
115144
if !framework_library_sources.is_empty() {
116145
tracing::info!(
117146
"STM32 framework library sources added: {}",

crates/fbuild-build/src/teensy/orchestrator.rs

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ use crate::build_fingerprint::{
2424
};
2525
use crate::compile_database::TargetArchitecture;
2626
use crate::compiler::Compiler as _;
27-
use crate::framework_libs::resolve_framework_library_sources;
27+
use crate::framework_libs::{
28+
library_select_kv_store, resolve_framework_library_sources,
29+
resolve_framework_library_sources_cached,
30+
};
2831
use crate::pipeline;
2932
use crate::{BuildOrchestrator, BuildParams, BuildResult, SourceScanner};
3033

@@ -159,8 +162,33 @@ impl BuildOrchestrator for TeensyOrchestrator {
159162
.retain(|p| p.file_name().map(|f| f != "Blink.cc").unwrap_or(true));
160163

161164
let framework_libs = framework.get_framework_libraries();
162-
let framework_library_sources =
163-
resolve_framework_library_sources(&framework_libs, &params.project_dir, &ctx.src_dir);
165+
// WHY: Teensy 3.x/4.x and TeensyLC all share teensyduino's
166+
// arm-none-eabi toolchain — a single stable triple covers every
167+
// board this orchestrator handles. The triple feeds the cache key
168+
// so bumping it invalidates the entire teensy slice without
169+
// touching SCANNER_VERSION / LDF_MODE_VERSION.
170+
let framework_info = fbuild_packages::Package::get_info(&framework);
171+
let framework_library_sources = match library_select_kv_store() {
172+
Some(store) => {
173+
let key_inputs = fbuild_library_select::cache::CacheKeyInputs {
174+
toolchain_triple: "teensy-arm-none-eabi",
175+
framework_install_path: &framework_info.install_path,
176+
framework_version: &framework_info.version,
177+
};
178+
resolve_framework_library_sources_cached(
179+
&framework_libs,
180+
&params.project_dir,
181+
&ctx.src_dir,
182+
&key_inputs,
183+
store,
184+
)
185+
}
186+
None => resolve_framework_library_sources(
187+
&framework_libs,
188+
&params.project_dir,
189+
&ctx.src_dir,
190+
),
191+
};
164192
if !framework_library_sources.is_empty() {
165193
tracing::info!(
166194
"Teensy framework library sources added: {}",

docs/architecture/library-selection.md

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
> Status: foundation phases (0–3 + Phase 5 framework_libs delegation) landed
44
> in PR #207. Phase 6 acceptance gates and Phase 8.a `lib-select` CLI landed
5-
> in PR #208. Phase 4 (zccache memoization) tracked at zackees/zccache#130.
6-
> Phase 7 perf gates and Phase 8.b cleanup remain follow-ups in `#205`.
5+
> in PR #208. Phase 4 (zccache memoization, `resolve_cached`) shipped in
6+
> PR #212 and is wired into the teensy + stm32 orchestrators by #214.
7+
> Phase 7 perf gates remain a follow-up in `#205`.
78
89
## Why
910

@@ -93,22 +94,25 @@ original issue framing ("fixed-point over include closure — typically 2–3
9394
iterations") was wrong; we match PIO's 2-pass semantics exactly so users
9495
who flip between PlatformIO and fbuild see the same library set.
9596

96-
## Cache key (Phase 4, not yet shipped)
97+
## Cache key
9798

98-
The resolver output is a pure function of:
99+
`resolve_cached` (see `crates/fbuild-library-select/src/cache.rs`) hashes:
99100

100101
- sorted blake3s of project source content,
101-
- sorted blake3s of each lib's canonical headers + `library.json` /
102-
`library.properties`,
103-
- ordered search-path list,
102+
- sorted blake3s of each lib's canonical header
103+
(`<include_dir>/<lib_name>.h`),
104+
- ordered search-path list (order matters — PIO's resolution is
105+
order-sensitive),
104106
- toolchain triple,
105107
- framework install path + version identifier,
106108
- `SCANNER_VERSION` (bumped on tokenizer changes),
107109
- `LDF_MODE_VERSION` (bumped on resolver semantic changes).
108110

109-
Memoization is gated on the K/V proposal at zackees/zccache#130
110-
(`tasks/zccache-kv-design.md`). The resolver is already deterministic and
111-
sort-stable, so cache wiring is a pure addition with no behavior change.
111+
The KvStore is opened lazily by `framework_libs::library_select_kv_store()`
112+
under `fbuild_paths::get_cache_root().join("library-selection")`, which
113+
respects `FBUILD_DEV_MODE` and `FBUILD_CACHE_DIR`. Wiring the cache was a
114+
pure addition: orchestrators that hit a backend error fall through to the
115+
uncached `resolve(...)` rather than fail.
112116

113117
## Determinism
114118

@@ -137,16 +141,16 @@ keys safe.
137141

138142
## Future work
139143

140-
- **Phase 4** — zccache K/V memoization. Gated on zackees/zccache#130
141-
shipping a versioned `KvStore` API and a 1.4.0 release; see
142-
`tasks/zccache-kv-design.md`.
143-
- **Phase 7** — perf gates wired into `bench/fastled-examples`.
144-
- **Phase 8.b** — final deletion of any dead helpers in `framework_libs.rs`
145-
once Phase 4 cache lands.
144+
- **Phase 7** — perf gates wired into `bench/fastled-examples` to catch
145+
regressions in both cold-resolve and warm cache-hit paths (#215).
146+
- **Phase 8.b**`framework_libs.rs` is now a thin delegator: scan-root
147+
collection, the uncached and cached entry points, the `KvStore` opener,
148+
and tests. No remaining dead helpers to retire.
146149

147150
## References
148151

149152
- PlatformIO LDF source: `platformio/builder/tools/piolib.py`.
150153
- Issue: FastLED/fbuild#205.
151154
- Closes: FastLED/fbuild#202, FastLED/fbuild#204.
152-
- Cache prerequisite: zackees/zccache#130.
155+
- Cache wiring: FastLED/fbuild#212 (cache helper), FastLED/fbuild#214
156+
(orchestrator wiring).

0 commit comments

Comments
 (0)