docs(sync): merge the commit handoff into the staging tradeoff curve
The bench phase the handoff was written for is closed (splice benched, fast-seek landed, cache removed, decision made), so the note's live half — the firmware plumbing plan — moves into sync-commit-staging.md as "The fix — wiring the O(depth) splice into the firmware", reconciled to the run-5 state. The duplicated TL;DR/measurement summaries are dropped in favor of the curve's own trail; inbound references (notes index, git_bench comments) now point at the curve.
This commit is contained in:
@@ -11,4 +11,4 @@
|
||||
| --- | --- |
|
||||
| [`wifi-auto-sync.md`](wifi-auto-sync.md) | `auto_sync` interval vs Wi-Fi energy (a `1/T` hyperbola) — why the default is 10 min and opportunistic, not a wall-clock timer. |
|
||||
| [`epd-refresh-latency.md`](epd-refresh-latency.md) | E-ink refresh latency vs rows driven — the full / full-area-partial / windowed-Y cost model behind typing responsiveness and the boot splash→editor swap. |
|
||||
| [`sync-commit-staging.md`](sync-commit-staging.md) | Commit-staging strategy vs working-tree size — `add_all(["*"])` (O(tree) FAT walk) vs explicit-path (O(churn)); the walk-vs-writes split that decides whether explicit staging is worth it. |
|
||||
| [`sync-commit-staging.md`](sync-commit-staging.md) | Commit-staging strategy vs working-tree size — RESOLVED: every index-based path is O(N_tree) and fails on the real repo (611 s / OOM); the O(depth) TreeBuilder splice is benched at ~2–2.8 s and ships. Holds the full measurement trail plus the firmware plumbing plan (merged from the retired handoff note). |
|
||||
|
||||
@@ -9,14 +9,17 @@
|
||||
> patch only the edited file's ancestor subtree chain onto HEAD's tree, never
|
||||
> materialise all 1179 entries.
|
||||
>
|
||||
> **Splice-bench update (2026-07-12, later the same day):** the walk was built
|
||||
> into `git_bench` and measured on the real repo — **6.5 s, failing the
|
||||
> sub-second bar.** The O(depth) *shape* holds (flat pack reads, healthy heap),
|
||||
> but each of its 4 loose-object writes costs **~1.5 s** (isolated `odb.write`
|
||||
> regressed from the 142 ms above; the mmap cache scored **0 hits** this run).
|
||||
> Localizing the loose-write cost is now the gating work — see
|
||||
> [Splice bench](#splice-bench-2026-07-12-second-real-repo-run--the-walk-is-right-the-loose-object-write-is-the-new-wall).
|
||||
> Shrinking the repo is **not** an option
|
||||
> **Final state (2026-07-13, after four localization rounds):** the splice walk
|
||||
> is benched and the block is lifted. It first measured 6.5 s (each loose-object
|
||||
> write cost ~1.5 s); the root cause was FatFS's lseek cluster-chain walk, and
|
||||
> the fast-seek fix cut it to **2.8 s cold / ~2 s steady-state**. The `esp_map.c`
|
||||
> window cache was **removed entirely** (0 hits across four instrumented runs —
|
||||
> `mwindow` absorbs any true repetition above `p_mmap`). The sub-second bar
|
||||
> failed, but ~2–2.8 s against 611 s/OOM for every alternative ships: a full
|
||||
> cold real-repo `:sync` lands at ~9–10 s. **Decision: wire the splice in** —
|
||||
> the [implementation plan](#the-fix--wiring-the-odepth-splice-into-the-firmware)
|
||||
> below (merged from the retired `notes/sync-commit-handoff.md`) is the working
|
||||
> checklist. Shrinking the repo is **not** an option
|
||||
> ([`../notes/git-sync-images-and-repo-size.md`](../notes/git-sync-images-and-repo-size.md):
|
||||
> the images are load-bearing for another app), which is exactly why the O(depth)
|
||||
> mechanism is the only lever left. This note records the cost model and the full
|
||||
@@ -479,10 +482,150 @@ work, ranked:
|
||||
window / 4 MB mapped limit). It fixed `odb.write` and the push read path; #2 just
|
||||
makes it well-behaved under memory pressure.
|
||||
|
||||
**Recommendation:** build #1 (O(depth) TreeBuilder walk) with #2 (cache
|
||||
evict-on-munmap) in the same pass. See
|
||||
[`../notes/sync-commit-handoff.md`](../notes/sync-commit-handoff.md) for the
|
||||
concrete next-session plan (bench design, firmware plumbing, exact call sites).
|
||||
**Recommendation:** build #1 (the O(depth) TreeBuilder walk) — #2 resolved
|
||||
itself by removal. The concrete plumbing plan (exact call sites, dirty-set
|
||||
threading, FD budget) is the
|
||||
[next section](#the-fix--wiring-the-odepth-splice-into-the-firmware).
|
||||
|
||||
## The fix — wiring the O(depth) splice into the firmware
|
||||
|
||||
> Merged from `docs/notes/sync-commit-handoff.md` (written 2026-07-12, retired
|
||||
> 2026-07-13 once the bench phase closed). The handoff's bench half is done —
|
||||
> the splice op lives in `git_bench` and the numbers above are its output. What
|
||||
> follows is the still-open firmware plumbing, updated to the run-5 final state.
|
||||
|
||||
### The splice walk
|
||||
|
||||
Rebuild only the edited file's ancestor subtree chain onto HEAD's tree. Never
|
||||
materialise the 1179-entry index; never `index.write()`; never `read_tree` the
|
||||
whole tree. Cost is O(depth × dirty_files), flat in repo size, tiny heap, and it
|
||||
carries every unchanged entry (all 260 images, the other 1176 files) forward
|
||||
untouched — which means **the device doesn't even need the images in its
|
||||
working tree.**
|
||||
|
||||
Sketch (git2 0.20 API — all present: `Repository::treebuilder(Option<&Tree>)`,
|
||||
`TreeBuilder::{insert,remove,write}`, `Tree::{get_name,get_path}`,
|
||||
`TreeEntry::{to_object,filemode}`):
|
||||
|
||||
```rust
|
||||
/// Return a new tree OID = `base` with `path` set to `new` (Some(blob_oid) to
|
||||
/// add/replace, None to delete). Reads ~depth subtree objects, writes ~depth trees.
|
||||
fn splice(repo: &Repository, base: &Tree, path: &[&str], new: Option<Oid>) -> Result<Oid> {
|
||||
let (head, rest) = path.split_first().unwrap();
|
||||
if rest.is_empty() {
|
||||
// leaf level: patch this tree directly
|
||||
let mut tb = repo.treebuilder(Some(base))?;
|
||||
match new {
|
||||
Some(oid) => tb.insert(head, oid, 0o100644)?, // FileMode::Blob
|
||||
None => { tb.remove(head).ok(); } // delete
|
||||
};
|
||||
return Ok(tb.write()?);
|
||||
}
|
||||
// descend: find (or synthesize) the subtree, recurse, re-insert its new OID
|
||||
let sub = base.get_name(head)
|
||||
.and_then(|e| e.to_object(repo).ok())
|
||||
.and_then(|o| o.peel_to_tree().ok());
|
||||
let empty; let sub_ref = match &sub { Some(t) => t, None => { empty = repo.treebuilder(None)?; /* ... */ unreachable!() } };
|
||||
let new_sub = splice(repo, sub_ref, rest, new)?;
|
||||
let mut tb = repo.treebuilder(Some(base))?;
|
||||
// if new_sub is the empty tree and this was a delete, remove the dir entry instead
|
||||
tb.insert(head, new_sub, 0o040000)?; // FileMode::Tree
|
||||
Ok(tb.write()?)
|
||||
}
|
||||
```
|
||||
|
||||
Then, for the dirty set, fold each change through `splice` (thread the running
|
||||
root tree), and `commit(Some("HEAD"), …, &final_tree, &[&parent])`. Edge cases
|
||||
to handle: new files where an intermediate dir doesn't exist yet (build subtree
|
||||
from `None`), deletes that empty a directory (remove the dir entry rather than
|
||||
insert an empty tree), and path splitting on `/` (paths are repo-relative
|
||||
POSIX). The benched reference is `git_bench`'s `splice stage→tree` op.
|
||||
|
||||
### `firmware/src/git_sync.rs`
|
||||
|
||||
- **Set the mwindow options at service start** (before the first
|
||||
`Repository::open`): `git2::opts::set_mwindow_size(256 * 1024)` +
|
||||
`set_mwindow_mapped_limit(4 * 1024 * 1024)`. Today only `git_bench.rs` sets
|
||||
them — the shipping service runs libgit2's 32-bit defaults (**32 MB window /
|
||||
256 MB mapped limit**, mwindow.c:16), so the first pack access on the 570 MB
|
||||
clone would try to `git__malloc` a 32 MB window and die on the 8 MB PSRAM
|
||||
heap before the walk even runs. Run 5 sharpened the stakes: the ~1.85 MB
|
||||
"resident" in the final bench **is** mwindow's live open-window set, so these
|
||||
opts are the only thing bounding it.
|
||||
- Rewrite `stage_and_commit` (currently ~L271–332) to the `splice`-walk above.
|
||||
Drop `add_all`/`update_all`/`index.write`/`index.write_tree`. Keep the
|
||||
`commit split —` timing log, the `tree unchanged → nothing to publish` check,
|
||||
and the signature/message code.
|
||||
- `PublishRequest` (~L79) is currently an **empty struct** — it must carry the
|
||||
dirty set: `{ changed: Vec<(String /*repo-rel path*/, ...)>, deleted: Vec<String> }`.
|
||||
The commit needs the blob content or a way to read it; simplest is to pass the
|
||||
paths and let the git thread `repo.blob_path(abs)` / read `/sd/repo/<path>`.
|
||||
- `reconcile_onto_origin` (~L377/L394) uses `repo.reset(Mixed)` — with an
|
||||
index-free commit there's no index to reset; switch to `ResetType::Soft` (move
|
||||
HEAD only) or drop the reset and just re-`splice` onto the new origin tip.
|
||||
- The macOS-cruft filter (`skip_macos_cruft`) is no longer needed — the walk only
|
||||
ever touches paths the editor explicitly hands it, so `._*`/`.DS_Store` can't
|
||||
sneak in. (Keep a note; don't silently lose the Spike-14 lesson.)
|
||||
- **Deliberate behavior change to record:** the walk commits *only* the
|
||||
editor's dirty set. Files changed on the card outside the editor (e.g. the
|
||||
card mounted on a Mac) were swept in by `add_all` before; they will now never
|
||||
be committed, and the working tree will show a permanent diff against HEAD if
|
||||
inspected on a desktop. Correct for the appliance (it's also what makes the
|
||||
cruft filter unnecessary), but it must be intentional, not accidental.
|
||||
|
||||
### Dirty-set source — `firmware/src/persistence.rs` + `main.rs`
|
||||
|
||||
- Writes funnel through `Storage::save_path` (~L316) and deletes through
|
||||
`Storage::delete_path` (~L352), both `&self`. Accumulate a dirty/deleted set
|
||||
(needs `RefCell` interior mutability, or move the set up to `main.rs`).
|
||||
- `Effect::Publish` handler in `main.rs` (~L222) builds the `PublishRequest` from
|
||||
that set and clears it on a successful `Pushed`/`UpToDate` outcome.
|
||||
- **FD budget: `main.rs` (~L413) mounts with `Storage::mount()` = 4 open
|
||||
files, and the git thread shares that mount.** libgit2 keeps the pack +
|
||||
`.idx` (+ commit-graph) descriptors open and opens loose objects on top —
|
||||
that's why `git_bench` needed `mount_for_git` (16). On the real repo the
|
||||
shipping `:sync` will fail with "no free file descriptors" long before any
|
||||
latency question. Either mount with the 16-file budget in `main.rs` (the
|
||||
editor's 2-FD peak coexists fine) or split the budgets some other way.
|
||||
|
||||
### `esp_map.c` — nothing left to do
|
||||
|
||||
The handoff's third work item (an evict-on-`p_munmap` cache fix) is superseded:
|
||||
run 5 removed the cache outright and `esp_map.c` is already the plain
|
||||
malloc-read/free-at-munmap emulation (see verdict item 2). One surviving
|
||||
operational note: editing `components/libgit2/*.c` won't rebuild via plain
|
||||
`cargo build` — first
|
||||
`rm -rf firmware/target/xtensa-esp32s3-espidf/release/.fingerprint/esp-idf-sys-*`
|
||||
(the `esp-idf-component-rebuild` lesson).
|
||||
|
||||
### How to bench / flash
|
||||
|
||||
`git_bench.rs` runs git ops on the 96 KB `GIT_STACK` thread (the main task stack
|
||||
overflows on these ops — that's why the real service has a dedicated thread). It's
|
||||
Rust-only, so a plain rebuild picks it up (no fingerprint dance unless you also
|
||||
touched `esp_map.c`).
|
||||
|
||||
```
|
||||
just flash-gitbench
|
||||
# = . ~/export-esp.sh && LIBGIT2_SRC=<repo>/firmware/components/libgit2/vendor \
|
||||
# LIBGIT2_NO_VENDOR=1 PKG_CONFIG_ALLOW_CROSS=1 \
|
||||
# PKG_CONFIG_LIBDIR=<repo>/firmware/pkgconfig \
|
||||
# cargo run --release --bin git_bench --features git
|
||||
```
|
||||
|
||||
Bench on the **real repo** clone (`/sd/repo` = full `jcalixte/notes`), not the
|
||||
toy — the toy pack understates everything by ~2 orders of magnitude.
|
||||
|
||||
### Still open (none block the plumbing)
|
||||
|
||||
- The residual ~360 ms/loose-write ≈ 8 unique small SD round-trips; next
|
||||
suspect is FAT **directory-op** cost in the freshen/refresh path. One
|
||||
instrumentation pass for later (log each `p_mmap` miss + bench dir ops in
|
||||
`sd_bench`).
|
||||
- Ref/reflog-update cost on the real repo — the bench's `commit(None)` writes
|
||||
no ref, so the shipping commit's last leg is unmeasured.
|
||||
- The push's ~6.5 s network floor
|
||||
([`../notes/sync-latency.md`](../notes/sync-latency.md)) — a separate curve.
|
||||
|
||||
## Adjacent lever: should the images be on the card at all?
|
||||
|
||||
|
||||
Reference in New Issue
Block a user