diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index a51a66b4b..20ba888f4 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -102,7 +102,19 @@ jobs: os: windows-latest name_suffix: " (windows)" cache_suffix: "-windows" + # Restoring a 3.9 GB `target/` cache costs Windows more than + # compiling from a warm sccache does, so this job caches only + # `~/.cargo` and lets sccache carry compilation. + cache_targets: "false" + # Windows pays a steep per-file cost to unpack, compile and link, so + # PR runs cover only the crates that make up `jp` itself. The Linux + # `test` job still builds and tests everything on every PR, and + # `main` covers the full workspace on Windows too. + scope: ${{ github.ref == 'refs/heads/main' && 'workspace' || 'jp-only' }} name: ${{ matrix.task }}${{ matrix.name_suffix }} + # A run without a target cache falls back to a full build. Cap it so a + # pathological run doesn't burn an hour of Windows minutes unnoticed. + timeout-minutes: 60 steps: - name: Decide whether to run task id: task @@ -150,7 +162,11 @@ jobs: with: path: | ~/.rustup - key: toolchain-${{ matrix.task }}${{ matrix.cache_suffix }} + key: toolchain-${{ matrix.task }}${{ matrix.cache_suffix }}-${{ hashFiles('rust-toolchain.toml') }} + # Prefix match, so a toolchain bump falls back to the previous entry + # instead of paying for a full rustup download. + restore-keys: | + toolchain-${{ matrix.task }}${{ matrix.cache_suffix }} - if: ${{ steps.task.outputs.run == 'true' }} uses: extractions/setup-just@f8a3cce218d9f83db3a2ecd90e41ac3de6cdfd9b # v3 - if: ${{ steps.task.outputs.run == 'true' }} @@ -159,6 +175,7 @@ jobs: key: ${{ matrix.task }} cache-bin: false cache-workspace-crates: true + cache-targets: ${{ matrix.cache_targets || 'true' }} save-if: ${{ github.ref == 'refs/heads/main' }} - if: ${{ steps.task.outputs.run == 'true' && matrix.task != 'coverage' && matrix.task != 'shear' }} uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # V0.10 @@ -171,8 +188,17 @@ jobs: run: | echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" + # `rust-cache` strips `~/.cargo/registry/src` before saving, so the first + # cargo invocation of the job re-extracts every `.crate` archive. That is + # the single most expensive non-build phase on Windows. Doing it in a + # named step keeps the cost visible instead of burying it in the silent + # `cargo metadata` that `cargo nextest run` shells out to on startup. + - name: Warm the registry source tree + if: ${{ steps.task.outputs.run == 'true' && matrix.task == 'test' }} + shell: bash + run: cargo metadata --locked --format-version 1 > /dev/null - if: ${{ steps.task.outputs.run == 'true' }} - run: just ${{ matrix.task }}-ci + run: just ${{ matrix.task }}-ci ${{ matrix.scope }} - if: ${{ matrix.task == 'coverage' && steps.task.outputs.run == 'true' }} uses: coverallsapp/github-action@648a8eb78e6d50909eff900e4ec85cab4524a45b # v2 with: diff --git a/crates/jp_tombmap/src/lib.rs b/crates/jp_tombmap/src/lib.rs index 436fe97c9..3311307ea 100644 --- a/crates/jp_tombmap/src/lib.rs +++ b/crates/jp_tombmap/src/lib.rs @@ -1,3 +1,11 @@ +//! A hash map that remembers what changed. +//! +//! [`TombMap`] wraps a [`HashMap`] and records which keys were removed (leaving +//! a tombstone behind) and which were inserted or mutated. +//! A persistence layer can use those two sets to write only the entries that +//! changed and delete the ones that went away, instead of rewriting the whole +//! map. + use std::{ borrow::Borrow, collections::{HashMap, HashSet, TryReserveError, hash_map}, @@ -12,6 +20,23 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use self::Entry::*; +/// A [`HashMap`] that tracks removals and modifications. +/// +/// Removing a key records a tombstone, so [`removed_keys`] still reports it +/// afterwards. +/// Inserting a key or handing out a mutable reference to its value marks the +/// key as modified. +/// The `_untracked` methods (such as [`remove_untracked`] and +/// [`iter_mut_untracked`]) skip that bookkeeping. +/// +/// Only the live entries are serialized. +/// Deserializing therefore yields a map with no tombstones and nothing marked +/// modified, so both sets describe the changes made since the map was last +/// read. +/// +/// [`iter_mut_untracked`]: TombMap::iter_mut_untracked +/// [`remove_untracked`]: TombMap::remove_untracked +/// [`removed_keys`]: TombMap::removed_keys pub struct TombMap { live: HashMap, dead: HashSet, diff --git a/justfile b/justfile index 09f96122e..cabf3da82 100644 --- a/justfile +++ b/justfile @@ -12,6 +12,14 @@ vet_version := "0.10.2" quiet_flag := if env_var_or_default("CI", "") == "true" { "" } else { "--quiet" } +# Workspace crates that no `jp_*` crate depends on: standalone tooling binaries, +# docs infrastructure, and command plugins. Skipping them drops their +# dependencies from the build entirely, rather than merely skipping their tests. +# +# `grizzly` is deliberately absent: `jp_attachment_bear_note` depends on it, so +# it is compiled either way. +non_jp_excludes := "--exclude bookworm --exclude build-registry --exclude comfort --exclude jp-path --exclude jp-serve-web --exclude json_edit --exclude tools" + alias r := run alias i := install alias c := check @@ -2082,9 +2090,13 @@ fmt-markdown-ci: _install-comfort _install_ci_matchers comfort --check --workspace --language markdown --format-markdown --reference-links --prune-reference-links # Test the code on CI. +# +# `SCOPE` selects the crates to test: `workspace` covers every member, `jp-only` +# skips the crates listed in `non_jp_excludes`. An unrecognised scope tests the +# full workspace. [group('ci')] -test-ci: (_install "cargo-nextest@" + nextest_version) _install_ci_matchers - cargo nextest run --locked --lib --tests --cargo-profile=nextest --status-level=slow --failure-output=immediate-final --workspace --no-fail-fast +test-ci SCOPE="workspace": (_install "cargo-nextest@" + nextest_version) _install_ci_matchers + cargo nextest run --locked --lib --tests --cargo-profile=nextest --status-level=slow --failure-output=immediate-final --workspace --no-fail-fast {{ if SCOPE == "jp-only" { non_jp_excludes } else { "" } }} # Generate documentation on CI. [group('ci')]