Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ criterion = { version = "0.8", default-features = false, features = ["cargo_benc
name = "commands"
harness = false

# ⚠️ Its own target so `cargo bench` stays usable: it seeds 26 000 notes.
[[bench]]
name = "corpus_size"
harness = false

[profile.release]
lto = true
codegen-units = 1
Expand Down
18 changes: 2 additions & 16 deletions src-tauri/benches/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,12 @@ 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::notes::view::{NoteFilter, NotesQuery};
use devbox_lib::transfer::{bundle, file};

use corpus::{Corpus, NOTES, build, now};
use corpus::{NOTES, build, now, run_query};

fn query(search: &str) -> NotesQuery {
NotesQuery {
Expand All @@ -37,19 +36,6 @@ fn query(search: &str) -> NotesQuery {
}
}

/// 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 that matters most: it runs on every keystroke, behind the 150 ms debounce.
fn whole_corpus_read(c: &mut Criterion) {
let mut corpus = build();
Expand Down
28 changes: 27 additions & 1 deletion src-tauri/benches/corpus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,22 @@
//! ⚠️ **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.

// ⚠️ Two bench targets compile their own copy of this module and each uses a part of it:
// `commands` never calls `build_of`, `corpus_size` never reads `note_ids`.
#![allow(dead_code)]

use std::path::PathBuf;

use chrono::{DateTime, TimeDelta, Utc};
use devbox_lib::db::Library;

use devbox_lib::attachments::store as attachments;
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::notes::view::{self, NotesQuery};
use devbox_lib::spaces::store as spaces;

pub(crate) const NOTES: usize = 8000;
Expand Down Expand Up @@ -104,8 +110,28 @@ fn draft(seed: usize, space_id: &str) -> NoteDraft {
}
}

/// Everything `query_notes` does, in its order, serialisation included — the string it
/// returns is what would cross the bridge.
pub(crate) 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")
}

/// Seeded once per benchmark group, never inside a `b.iter`.
pub(crate) fn build() -> Corpus {
build_of(NOTES)
}

/// ⚠️ The size is a parameter only because #21 asks what a query costs as a corpus grows.
/// Every other group takes [`NOTES`], so its numbers stay comparable across runs.
pub(crate) fn build_of(notes: usize) -> 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");

Expand All @@ -126,7 +152,7 @@ pub(crate) fn build() -> Corpus {
.collect();

let at = now();
let note_ids: Vec<String> = (0..NOTES)
let note_ids: Vec<String> = (0..notes)
.map(|seed| {
let space_id = &space_ids[seed % SPACES];
store::create(&mut connection, draft(seed, space_id), at)
Expand Down
84 changes: 84 additions & 0 deletions src-tauri/benches/corpus_size.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
//! What a query costs as the corpus grows, and how much of it crosses the bridge.
//!
//! The measurement #21 asks for, kept out of `commands` so that bench's numbers stay
//! comparable across runs and its corpus stays one size.
//!
//! ⚠️ Slow by construction: it seeds three corpora of ~13 kB notes, the largest of them
//! 20 000. Run it on its own.
//!
//! ```
//! cargo bench --bench corpus_size
//! ```

mod corpus;

use std::hint::black_box;

use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};

use devbox_lib::notes::store;
use devbox_lib::notes::view::{NoteFilter, NotesQuery};

use corpus::{Corpus, build_of, now, run_query};

/// The sizes the ticket names. 20 000 notes of ~13 kB is ~260 MB of body — well past
/// what a person writes, which is the point: the shape of the curve is what decides.
const SIZES: [usize; 3] = [1_000, 5_000, 20_000];

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 the query does before the view: the SQL, the rows, and opening every
/// sealed value. No `view::build`, no serde.
fn read_only(corpus: &mut Corpus, request: &NotesQuery) -> usize {
let (notes, _) = store::fetch(&mut corpus.connection, request).expect("a view");

notes.len()
}

fn query_by_corpus_size(c: &mut Criterion) {
let mut group = c.benchmark_group("query by corpus size");
// A pass over 20 000 notes is long enough that a hundred samples would take an hour.
group.sample_size(10);

for size in SIZES {
let mut corpus = build_of(size);

for (name, request) in [("open", query("")), ("search", query("étape"))] {
// Printed rather than measured: the payload is a size, not a duration, and it
// is half of what the ticket is about.
let payload = run_query(&mut corpus, &request).len();
println!("{size} notes, {name}: {payload} bytes on the wire");

group.bench_with_input(BenchmarkId::new(name, size), &size, |b, _| {
b.iter(|| black_box(run_query(&mut corpus, &request)));
});

// ⚠️ The same query stopped before the view is built and serialised: the gap
// between the two is what a lighter wire shape could take away, and what a
// LIMIT could not.
group.bench_with_input(
BenchmarkId::new(format!("{name}, read only"), size),
&size,
|b, _| {
b.iter(|| black_box(read_only(&mut corpus, &request)));
},
);
}
}

group.finish();
}

criterion_group!(benches, query_by_corpus_size);
criterion_main!(benches);