diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index a0694ee..f584f08 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -28,7 +28,7 @@ permissions: jobs: cargo-deny: - name: Cargo advisories and licenses + name: Cargo advisories runs-on: ubuntu-22.04 timeout-minutes: 10 steps: @@ -36,12 +36,15 @@ jobs: # Reads `Cargo.lock` and the advisory database; it compiles nothing, which is # why this job needs no Rust toolchain and no cargo cache. + # + # ⚠️ `advisories` alone, and it is the repository's only advisory net — Dependabot + # alerts are off. `licenses`, `bans` and `sources` stood here and never made a + # decision in 25 runs; the GPL-3.0 compatibility of the tree is a release-time + # review, not a per-pull-request gate. - uses: EmbarkStudios/cargo-deny-action@v2 with: manifest-path: src-tauri/Cargo.toml - # `licenses` is not decoration: DevBox ships under GPL-3.0-only, and a - # dependency that is not compatible with it cannot be distributed. - command: check advisories bans licenses sources + command: check advisories npm-audit: name: npm advisories diff --git a/CLAUDE.md b/CLAUDE.md index b446c51..9945ad7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,6 +57,8 @@ Run all commands from the repo root (`package.json` there wraps both Angular and - `npm run e2e:build` then `npm run test:e2e` — the end-to-end suite (WebdriverIO + `tauri-driver`), twelve scenarios under `e2e/specs/` against the **assembled** application. The build step is not optional after a change to `src/` or `src-tauri/`: the suite drives a binary with the front end compiled into it. +- `cargo bench` from `src-tauri/` — criterion, against a **file-backed** database of 8000 notes of ~13 kB (~104 MB, seeded once per group), below the command boundary. Deliberately **not in CI**, and not in `cargo test` either — cargo gives a `[[bench]]` target `test = false`, so the suite never builds it. `--save-baseline main` then `--baseline main` is the comparison the harness exists for, but that baseline lands in the gitignored `target/`: it is local to one machine and dies with `cargo clean`, so the durable record is the table in `docs/architecture.md`. ⚠️ `autobenches = false` and `bench = false` on the lib and both bins are load-bearing, and `Corpus` must not implement `Drop` itself — all three are explained in `docs/architecture.md` rather than in `Cargo.toml`. The baseline: `query_notes` costs **403 ms on 8000 notes**, which is past its own 150 ms debounce; the search still is not what costs; and `list_tags` is the only command that degrades faster than the corpus (×64 for ×10 the notes). + - `cargo clippy --all-targets -- -D warnings` and `cargo fmt --check` from `src-tauri/` — `Cargo.toml` forbids `unsafe_code`, denies `clippy::all` and warns on `clippy::pedantic`, `rust_2018_idioms` and `unreachable_pub`. The toolchain is pinned in `rust-toolchain.toml`, so a new stable release can't turn CI red on an untouched commit. ## Things that will bite you diff --git a/docs/architecture.md b/docs/architecture.md index 7582b51..096eb69 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2010,6 +2010,83 @@ release that replayed it would pay for it twice. ## Testing +### Benchmarks + +`cargo bench` from `src-tauri/`. They are **not in CI**: a timing assertion on a shared +runner flaps, and a suite that flaps is a suite everyone learns to ignore. They run +locally, on demand. + +```bash +cargo bench -- --save-baseline main # before +cargo bench -- --baseline main # after +``` + +That comparison is the whole reason the harness is **criterion** and not divan, which is +lighter and pleasanter but does not offer it out of the box. ⚠️ The baseline it writes lives +in `src-tauri/target/criterion/`, which is gitignored: it is local to one machine and dies +with `cargo clean`. The table below is the durable record. + +⚠️ **They run below the command boundary, not through Tauri.** A command is four lines — +validate, lock, delegate, translate the error — so `store::*` plus `view::*` plus the serde +round-trip captures nearly all of the cost. `tauri::test::mock_app` would drag the whole app +lifecycle in and buy only the IPC transport, which this codebase does not control. So these +numbers are **not** "the IPC is fast": they are what the work behind a command costs. +Serialisation is included on purpose — a `NotesView` over 8000 notes is a real `serde_json` +cost paid on every keystroke. + +⚠️ **The corpus is file-backed, never `open_in_memory`.** An in-memory database has no pager +behind a file, no page cache doing real work and no I/O at all — it measures something the +application never does. `benches/corpus.rs` writes 8000 notes of ~13 kB into a temporary file +database — about 104 MB, seeded once per group — and its bodies are accented on purpose: a +pure-ASCII corpus would exercise only `fold`'s fast path. + +⚠️ **`Corpus` does not implement `Drop`; its `TempDir` field does, and is declared last.** +Fields drop in declaration order but a `Drop` on the struct runs before all of them, so the +erasure fired while SQLite still held the file open — which Windows refuses to delete over, +and `let _ =` swallowed the error. Six groups leaked a corpus each, 600 MB a run. + +Two Cargo details exist solely to make this work, and they are documented here rather than in +`Cargo.toml`: `autobenches = false` (or the shared corpus module is discovered as a benchmark +of its own and reported as entirely unused) and `bench = false` on the lib and both bins (or +cargo runs their built-in harness first, which rejects criterion's own flags). + +#### The baseline + +8000 notes, Windows, release profile with `lto = true`. The last column is the same benchmark +against the 800-note corpus this suite started on, which is what says whether a cost is linear +in the corpus or worse. + +| Command | Cost | 800 → 8000 | +| ---------------------------------------------- | --------------- | ------------- | +| `query_notes`, search matching nothing | 406 ms | ×15.1 | +| `query_notes`, unfiltered | **403 ms** | ×14.7 | +| `query_notes`, search folding accents | 392 ms | ×15.2 | +| `export_notes` / `import_notes` | 377 ms / 98 ms | ×14.1 / ×11.8 | +| `list_tags` | 37.9 ms | **×64** | +| `delete_notes` then `restore_notes`, 100 notes | 10.4 ms | ×1.2 | +| `rename_tag` across the corpus | 8.2 ms | ×2.2 | +| `move_notes` / `tag_notes`, 100 notes | 5.1 ms / 4.2 ms | ×1.9 / ×1.8 | +| `list_trash` | 4.5 ms | ×10.5 | +| `update_note` | 1.5 ms | ×1.0 | +| `list_global_placeholders` | 1.5 µs | ×1.0 | + +Three things worth reading off that table. + +**`query_notes` has gone past the debounce.** It runs on every keystroke behind a 150 ms +debounce, and at 800 notes its 27 ms sat comfortably inside it. At 8000 it costs 403 ms: the +query fired for one keystroke is still running when the third one after it arrives. That is +what #21 is about, and this is the number that says the problem has stopped being theoretical. + +**The search is still not what costs.** Folding accents is the _cheapest_ of the three +variants (392 ms against 403 ms unfiltered) — fewer notes survive to be serialised. Fetching +8000 × 13 kB out of SQLite and turning the view into JSON is the whole cost, and work aimed at +making the match faster would be aimed at the wrong half. + +**`list_tags` is the one that degrades faster than the corpus.** ×64 for ×10 the data, the only +entry on the table that is markedly super-linear, and invisible at 800 notes where it cost +591 µs. It joins `note_tags` to `notes` to read one nullable column, so each of the 16 000 tag +rows dereferences a ~13 kB note row; the notes table was 10 MB at 800 notes and is ~104 MB at 8000. ⚠️ That is the likely cause and it is **not confirmed** — no query plan was taken. + Unit tests run with Vitest through the `@angular/build:unit-test` builder in a jsdom environment (configured in `angular.json`'s `test` target and `vitest-base.config.ts`), so no browser is needed. Specs sit next to the file they cover. Coverage thresholds are set at diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cb868d7..7871ba1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -38,6 +38,15 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + [[package]] name = "android_log-sys" version = "0.3.2" @@ -64,6 +73,18 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "anyhow" version = "1.0.104" @@ -538,6 +559,12 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.3.0" @@ -605,6 +632,58 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + [[package]] name = "clipboard-win" version = "5.4.1" @@ -711,6 +790,39 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools", + "num-traits", + "oorandom", + "page_size", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +dependencies = [ + "cast", + "itertools", +] + [[package]] name = "crossbeam-channel" version = "0.5.16" @@ -908,6 +1020,7 @@ version = "0.1.4" dependencies = [ "base64 0.23.1", "chrono", + "criterion", "diesel", "diesel_migrations", "libsqlite3-sys", @@ -2282,6 +2395,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2986,6 +3108,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "open" version = "5.4.0" @@ -3043,6 +3171,16 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "pango" version = "0.18.3" @@ -3577,9 +3715,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "once_cell", "ring", @@ -3639,9 +3777,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -5031,6 +5169,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.12.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 02bcb6b..baf07f0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,7 +1,6 @@ [package] name = "devbox" version = "0.1.4" -# ⚠️ Shipped: the installer and the Linux `.desktop` entry show this string. description = "A developer's notes and snippets manager for the desktop" authors = ["Valentin MILLET"] edition = "2024" @@ -10,6 +9,7 @@ repository = "https://github.com/vmillet-dev/devbox-rs" license = "GPL-3.0-only" readme = "../README.md" default-run = "devbox" +autobenches = false [package.metadata.devbox] display-name = "DevBox" @@ -18,10 +18,19 @@ author-handle = "@vmillet-dev" [lib] name = "devbox_lib" crate-type = ["staticlib", "cdylib", "rlib"] +bench = false + +[[bin]] +name = "devbox" +path = "src/main.rs" +bench = false + +[[bin]] +name = "export-bindings" +path = "src/bin/export-bindings.rs" +bench = false [features] -# The end-to-end harness, and nothing else. Off by default, so a release build -# never links the test plugin into the shipped binary. e2e = ["dep:tauri-plugin-wdio", "dep:tauri-plugin-wdio-webdriver"] [build-dependencies] @@ -49,29 +58,22 @@ tauri-plugin-store = "2" tauri-plugin-clipboard-manager = "2" tauri-plugin-window-state = "2.4.1" tauri-plugin-log = "2" -log = "0.4" -thiserror = "2.0.20" -unicode-normalization = "0.1.25" -# Pulled in only by the `e2e` feature, and not optional in practice: the runner -# waits on `window.wdioTauri` before it will open a session, so a binary without -# the plugin hangs the suite rather than failing it. Its Rust half and the npm half -# (`polyfills` in the `e2e` Angular configuration) go together — with one but not -# the other, the front end invokes `plugin:wdio|…` commands nothing answers and the -# error banner comes up on launch. -# -# ⚠️ Both crates, not one: the documentation presents `-webdriver` as needed by the -# embedded driver provider alone, but it also serves the HTTP endpoint the runner's -# `DirectEvalClient` posts to. -tauri-plugin-wdio = { version = "1", optional = true } -tauri-plugin-wdio-webdriver = { version = "1", optional = true } - -# Desktop-only plugins. They are plain dependencies rather than a -# `cfg(not(android/ios))` table: DevBox targets the desktop, and a conditional table -# only pretended the crate could build anywhere else. tauri-plugin-updater = "2" tauri-plugin-global-shortcut = "2" tauri-plugin-autostart = "2" tauri-plugin-single-instance = "2" +tauri-plugin-wdio = { version = "1", optional = true } +tauri-plugin-wdio-webdriver = { version = "1", optional = true } +log = "0.4" +thiserror = "2.0.20" +unicode-normalization = "0.1.25" + +[dev-dependencies] +criterion = { version = "0.8", default-features = false, features = ["cargo_bench_support"] } + +[[bench]] +name = "commands" +harness = false [profile.release] lto = true @@ -90,4 +92,3 @@ missing_errors_doc = "allow" missing_panics_doc = "allow" must_use_candidate = "allow" module_name_repetitions = "allow" - diff --git a/src-tauri/benches/commands.rs b/src-tauri/benches/commands.rs new file mode 100644 index 0000000..4996d9a --- /dev/null +++ b/src-tauri/benches/commands.rs @@ -0,0 +1,229 @@ +//! What the IPC surface costs, by class of command. +//! +//! ⚠️ **Below the command boundary, not through Tauri.** A command is four lines — validate, +//! lock, delegate, translate the error — so `store::*` plus `view::*` plus the serde round +//! trip captures nearly all of the cost. `tauri::test::mock_app` would drag the whole app +//! lifecycle in and buy only the IPC transport, which this codebase does not control. So +//! these numbers are not "the IPC is fast": they are what the work behind a command costs. +//! +//! The three commands in `desktop.rs` are out of scope: they touch no database. +//! +//! ``` +//! cargo bench -- --save-baseline main +//! cargo bench -- --baseline main +//! ``` + +mod corpus; + +use std::hint::black_box; + +use criterion::{Criterion, criterion_group, criterion_main}; + +use devbox_lib::attachments::store as attachments; +use devbox_lib::notes::model::NotePatch; +use devbox_lib::notes::store; +use devbox_lib::notes::view::{self, NoteFilter, NotesQuery}; +use devbox_lib::transfer::{bundle, file}; + +use corpus::{Corpus, NOTES, build, now}; + +fn query(search: &str) -> NotesQuery { + NotesQuery { + space_id: None, + search: search.to_string(), + filter: NoteFilter::All, + tags: Vec::new(), + languages: Vec::new(), + now: now(), + tz_offset_minutes: -120, + pinned_first: true, + } +} + +/// Everything `query_notes` does, in its order, serialisation included. +fn run_query(corpus: &mut Corpus, request: &NotesQuery) -> String { + let (notes, facets) = store::fetch(&mut corpus.connection, request).expect("a view"); + let counts = attachments::counts(&mut corpus.connection).expect("the counters"); + let globals = store::global_placeholder_values(&mut corpus.connection).expect("the globals"); + + let mut built = view::build(notes, facets, request); + view::apply_attachment_counts(&mut built, &counts); + view::apply_global_defaults(&mut built, &globals); + + serde_json::to_string(&built).expect("a serialisable view") +} + +/// The one number that matters most: it runs on every keystroke, behind the 150 ms +/// debounce, and #21 says it reads the whole corpus to do it. +fn whole_corpus_read(c: &mut Criterion) { + let mut corpus = build(); + let mut group = c.benchmark_group("whole-corpus read"); + // A pass over 8000 notes is long enough that criterion's hundred samples would make + // this group most of the run. + group.sample_size(20); + + group.bench_function("query_notes, unfiltered", |b| { + b.iter(|| black_box(run_query(&mut corpus, &query("")))); + }); + + // Matching runs on the fetched rows, not in SQL — the needle decides the cost. + group.bench_function("query_notes, search matching nothing", |b| { + b.iter(|| black_box(run_query(&mut corpus, &query("zzz-no-such-needle")))); + }); + + // ⚠️ Unaccented on purpose: the fold has to strip the accents off every byte of the + // corpus before it can answer, which is the branch the 6.3 ms figure was taken on. + group.bench_function("query_notes, search folding accents", |b| { + b.iter(|| black_box(run_query(&mut corpus, &query("deploiement")))); + }); + + group.finish(); +} + +/// The editor's round trip, once per field committed. +fn single_write(c: &mut Criterion) { + let mut corpus = build(); + let mut group = c.benchmark_group("single write"); + + group.bench_function("update_note", |b| { + let id = corpus.note_ids[NOTES / 2].clone(); + let mut at = 0u32; + b.iter(|| { + at += 1; + let patch = NotePatch { + title: Some(format!("Retitled {at}")), + ..NotePatch::default() + }; + black_box(store::update(&mut corpus.connection, &id, &patch, now()).expect("a write")); + }); + }); + + group.finish(); +} + +/// One lock, N rows. The selection bar's actions. +fn bulk(c: &mut Criterion) { + let mut corpus = build(); + let mut group = c.benchmark_group("bulk over 100 notes"); + let ids: Vec = corpus.note_ids.iter().take(100).cloned().collect(); + + group.bench_function("tag_notes", |b| { + b.iter(|| { + black_box( + store::tag_many(&mut corpus.connection, &ids, &["bulk".to_string()], now()) + .expect("a batch"), + ); + }); + }); + + group.bench_function("move_notes", |b| { + let mut at = 0usize; + b.iter(|| { + at += 1; + let target = &corpus.space_ids[at % corpus.space_ids.len()]; + black_box( + store::move_many(&mut corpus.connection, &ids, target, now()).expect("a batch"), + ); + }); + }); + + group.bench_function("delete_notes then restore_notes", |b| { + b.iter(|| { + store::trash::trash_many(&mut corpus.connection, &ids, now()).expect("a trashing"); + black_box( + store::trash::restore_many(&mut corpus.connection, &ids).expect("a restoration"), + ); + }); + }); + + group.finish(); +} + +/// The facet and panel queries, each one a pass over a side table. +fn aggregation(c: &mut Criterion) { + let mut corpus = build(); + let mut group = c.benchmark_group("corpus-wide aggregation"); + + group.bench_function("list_tags", |b| { + b.iter(|| black_box(store::tag_usage(&mut corpus.connection).expect("the tags"))); + }); + + group.bench_function("list_trash, nothing trashed", |b| { + b.iter(|| { + black_box(store::trash::list_trashed(&mut corpus.connection).expect("the trash")); + }); + }); + + group.bench_function("list_global_placeholders", |b| { + b.iter(|| { + black_box( + store::global_placeholder_values(&mut corpus.connection).expect("the globals"), + ); + }); + }); + + group.finish(); +} + +/// `retag` touches every matching row, in one transaction. +fn corpus_rewrite(c: &mut Criterion) { + let mut corpus = build(); + let mut group = c.benchmark_group("corpus-wide rewrite"); + + group.bench_function("rename_tag", |b| { + let mut at = 0u32; + b.iter(|| { + at += 1; + let (from, into) = if at.is_multiple_of(2) { + ("ops", "operations") + } else { + ("operations", "ops") + }; + black_box( + store::retag(&mut corpus.connection, &[from.to_string()], into).expect("a retag"), + ); + }); + }); + + group.finish(); +} + +/// The bundle both ways, against a real file. +fn disk(c: &mut Criterion) { + let mut corpus = build(); + let mut group = c.benchmark_group("disk"); + // The bundle is ~100 MB, so these two want their own sample size. + group.sample_size(10); + + let path = std::env::temp_dir().join("devbox-bench-export.json"); + let target = path.to_string_lossy().to_string(); + + group.bench_function("export_notes", |b| { + b.iter(|| { + let notes = store::all(&mut corpus.connection, None).expect("the corpus"); + let packed = bundle::collect(&mut corpus.connection, notes).expect("a bundle"); + black_box(file::write(&target, &packed).expect("a written file")); + }); + }); + + group.bench_function("import_notes, every id already there", |b| { + b.iter(|| { + let incoming = file::read(&target).expect("a readable file"); + black_box(bundle::merge(&mut corpus.connection, incoming).expect("a merge")); + }); + }); + + group.finish(); + let _ = std::fs::remove_file(&path); +} + +criterion_group!( + benches, + whole_corpus_read, + single_write, + bulk, + aggregation, + corpus_rewrite, + disk +); +criterion_main!(benches); diff --git a/src-tauri/benches/corpus.rs b/src-tauri/benches/corpus.rs new file mode 100644 index 0000000..89da3f1 --- /dev/null +++ b/src-tauri/benches/corpus.rs @@ -0,0 +1,138 @@ +//! The corpus the benchmarks run against. +//! +//! **8000 notes of ~13 kB**, the shape quoted in `notes/view.rs` taken to the size #21 is +//! about — a corpus big enough that a linear cost shows up as one. +//! +//! ⚠️ **File-backed, never `open_in_memory`.** An in-memory database has no pager behind a +//! file, no page cache and no I/O, so it measures something the application never does. + +use std::path::PathBuf; + +use chrono::{DateTime, TimeDelta, Utc}; +use diesel::SqliteConnection; + +use devbox_lib::db; +use devbox_lib::notes::checklist::{ChecklistItem, NoteKind}; +use devbox_lib::notes::language::Language; +use devbox_lib::notes::model::{NoteDraft, NoteLifecycle}; +use devbox_lib::notes::store; +use devbox_lib::spaces::store as spaces; + +pub(crate) const NOTES: usize = 8000; +/// Roughly 13 kB of body. +const LINES_PER_NOTE: usize = 200; +const SPACES: usize = 4; +const TAGS: &[&str] = &[ + "angular", "api", "ci", "db", "docker", "git", "ops", "rust", "sql", "ssh", +]; + +/// A database file of its own per group, erased with the guard. +pub(crate) struct Corpus { + pub(crate) connection: SqliteConnection, + pub(crate) space_ids: Vec, + pub(crate) note_ids: Vec, + /// ⚠️ Last, and the erasure lives on this field rather than on `Corpus`: fields drop + /// in declaration order, but a `Drop` on the struct runs *before* all of them — with + /// the connection still open, which Windows refuses to delete over. + _directory: TempDir, +} + +struct TempDir(PathBuf); + +impl Drop for TempDir { + fn drop(&mut self) { + // The WAL and the shm sit beside it; the whole directory goes. + let _ = std::fs::remove_dir_all(&self.0); + } +} + +pub(crate) fn now() -> DateTime { + db::iso8601::parse("2026-07-25T09:00:00.000Z").expect("a valid instant") +} + +/// ⚠️ Accented on purpose: `fold` has a fast path for pure ASCII, and a corpus that only +/// took it would measure the one branch that was never in question. +fn body(seed: usize) -> String { + let mut text = String::with_capacity(LINES_PER_NOTE * 70); + for line in 0..LINES_PER_NOTE { + text.push_str(match (seed + line) % 4 { + 0 => " let étape = migration::run(&mut connection)?; // déploiement\n", + 1 => " kubectl rollout restart deployment/api --namespace prod\n", + 2 => " SELECT calls, mean_exec_time FROM pg_stat_statements LIMIT 20;\n", + _ => " const keysOf = (value: T) => Object.keys(value);\n", + }); + } + text +} + +fn draft(seed: usize, space_id: &str) -> NoteDraft { + let checklist = seed.is_multiple_of(10); + + NoteDraft { + space_id: space_id.to_string(), + title: format!("Note {seed} — étape de déploiement"), + language: Language::Rs, + content: if checklist { String::new() } else { body(seed) }, + source: "Runbook".to_string(), + tags: vec![ + TAGS[seed % TAGS.len()].to_string(), + TAGS[(seed + 3) % TAGS.len()].to_string(), + ], + pinned: seed.is_multiple_of(50), + lifecycle: if seed.is_multiple_of(25) { + NoteLifecycle::Expires { + at: now() + TimeDelta::days(3), + } + } else { + NoteLifecycle::Permanent + }, + kind: if checklist { + NoteKind::Checklist + } else { + NoteKind::Snippet + }, + items: if checklist { + (0..5u32) + .map(|at| ChecklistItem { + text: format!("Étape {at} du déploiement"), + done: at.is_multiple_of(2), + }) + .collect() + } else { + Vec::new() + }, + } +} + +/// Seeded once per benchmark group, never inside a `b.iter`. +pub(crate) fn build() -> Corpus { + let directory = std::env::temp_dir().join(format!("devbox-bench-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&directory).expect("a writable temporary directory"); + + let mut connection = db::open(&directory.join("bench.sqlite3")).expect("a fresh database"); + + let space_ids: Vec = (0..SPACES) + .map(|at| { + spaces::create(&mut connection, &format!("Space {at}")) + .expect("a space") + .id + }) + .collect(); + + let at = now(); + let note_ids: Vec = (0..NOTES) + .map(|seed| { + let space_id = &space_ids[seed % SPACES]; + store::create(&mut connection, draft(seed, space_id), at) + .expect("a note") + .id + }) + .collect(); + + Corpus { + connection, + space_ids, + note_ids, + _directory: TempDir(directory), + } +} diff --git a/src-tauri/deny.toml b/src-tauri/deny.toml index 4517bbc..1bcc924 100644 --- a/src-tauri/deny.toml +++ b/src-tauri/deny.toml @@ -1,65 +1,14 @@ [graph] -# The release matrix, plus macOS so a contributor working there is covered too. targets = [ "x86_64-pc-windows-msvc", "x86_64-unknown-linux-gnu", "aarch64-apple-darwin", ] -# Reaches the `e2e` feature's dependencies, which nothing else inspects. all-features = true [advisories] -# Only the crates this project actually chose. An unmaintained crate five levels -# down is not something a pull request here can act on. unmaintained = "workspace" -# "all", not "workspace": unsoundness in a transitive crate still runs in our -# process. The one exception below is named rather than the policy weakened, so a -# second one fails this job. unsound = "all" ignore = [ - # `glib::VariantStrIter` — Linux only, and immovable from here: 0.18.5 is the - # last 0.18.x, pinned by `gtk 0.18.2`, which Tauri v2.11 pins in turn, and the - # fix landed in glib 0.20. DevBox never calls glib, let alone the five affected - # `VariantStrIter` methods; the crate arrives under the GTK/WebKitGTK stack and - # is absent from the Windows tree entirely. - # - # ⚠️ Drop this line when Tauri moves to gtk-rs 0.20+ — keeping it afterwards - # would hide a real finding. - { id = "RUSTSEC-2024-0429", reason = "glib 0.18 is pinned by Tauri's gtk-rs 0.18; unreachable from our code" }, + { id = "RUSTSEC-2024-0429", reason = "glib 0.18 is pinned by Tauri's gtk-rs 0.18 and unreachable from our code; drop this when Tauri moves to gtk-rs 0.20+" }, ] - -[licenses] -confidence-threshold = 0.93 -# ⚠️ DevBox ships under GPL-3.0-only, so every dependency has to be -# GPL-3.0-compatible. This list is the inventory of what the tree actually -# carries — all permissive, MPL-2.0 included (compatible under its section 3.3). -# A new entry means a real decision, not a formality: check the compatibility -# before adding one. -allow = [ - "0BSD", - "Apache-2.0", - "Apache-2.0 WITH LLVM-exception", - "BSD-2-Clause", - "BSD-3-Clause", - "BSL-1.0", - "CC0-1.0", - "GPL-3.0-only", - "ISC", - "MIT", - "MIT-0", - "MPL-2.0", - "Unicode-3.0", - "Unlicense", - "Zlib", -] -exceptions = [] - -[bans] -# A Tauri tree always carries a few duplicate versions it does not control; -# denying them would make this job red for something no contributor can fix. -multiple-versions = "allow" -wildcards = "deny" - -[sources] -unknown-registry = "deny" -unknown-git = "deny"