Answer a thumbnail miss with the other stored class before the network

Offline, a grid zoomed past 256px was blank wherever it had not been
zoomed over before. The store was asked only for the exact class the cell
wanted, and the sweep stores only the grid class, so every zoomed cell
missed and went to a server that was not there — with its 256px thumbnail
sitting in the store the whole time. Online it cost the same round trip,
just without the blank cell at the end of it.

The split now tries the other class on a miss. A smaller one stands in
and the fetch for the real class still goes out; a larger one answers
the request outright, since there is nothing a fetch would improve on.
`ThumbnailReady` carries the class its pixels are, and the drain records
that rather than the batch's class, so a stand-in is replaced on the next
reload instead of being counted as served. `already_served` counts a
held large thumbnail as serving the grid class too, so zooming back out
does not re-read the store for pixels already on screen.

The split moves into `split_by_store` so it can be tested without a
worker thread or a network.
This commit is contained in:
2026-10-03 16:44:58 -04:00
parent 185e134ead
commit 33779a70bd
4 changed files with 244 additions and 57 deletions
+39 -8
View File
@@ -96,8 +96,20 @@ fn already_served(
held: &std::collections::HashMap<i64, Held>,
ids: impl Iterator<Item = i64>,
) -> std::collections::HashSet<(i64, dr_thumbs::ThumbSize)> {
ids.filter_map(|id| Some((id, held.get(&id)?.class?)))
.collect()
use dr_thumbs::ThumbSize;
let mut served = std::collections::HashSet::new();
for id in ids {
let Some(class) = held.get(&id).and_then(|h| h.class) else {
continue;
};
served.insert((id, class));
// The large class is everything the grid class would be, and more: a
// cell the store answered with it has nothing left to ask for.
if class == ThumbSize::Large {
served.insert((id, ThumbSize::Grid));
}
}
served
}
/// Where the loaded window starts for a view whose first visible cell is
@@ -1014,11 +1026,13 @@ fn drain_thumbnails(
cached,
fetching,
dating,
standing_in,
} => {
// Date reads produce no cell, so they are counted into
// the bar's denominator or it finishes while work is
// still running.
job.add_total(dating);
// still running. A stand-in is delivered once from the
// store and again from the fetch, so it counts twice.
job.add_total(dating + standing_in);
let mut parts = Vec::new();
if cached > 0 {
@@ -1084,10 +1098,11 @@ fn drain_thumbnails(
row.has_thumb = true;
model.set_row_data(t.row, row);
// What this cell is now showing, so the next reload
// can carry it over and know not to ask again.
if let Some(class) = classes.get(t.row) {
record_class(&ctl_cb, t.row, *class);
}
// can carry it over and know not to ask again. The
// pixels' own class, not the one the row asked for:
// a stand-in recorded as the class it stands in for
// would never be replaced.
record_class(&ctl_cb, t.row, t.class);
}
}
ThumbnailMessage::Unavailable { row, reason } => {
@@ -1542,6 +1557,22 @@ mod tests {
);
}
#[test]
fn a_large_thumbnail_satisfies_the_grid_class() {
// Zooming out past 256px reloads the window at the grid class. A cell
// already showing the large class has every pixel the small one would
// give, so asking the store for it again is a read for nothing.
let held = hold_thumbnails(
&model_of(&[5], &[5]),
&[5],
&[Some(dr_thumbs::ThumbSize::Large)],
);
let served = already_served(&held, [5i64].into_iter());
assert!(served.contains(&(5, dr_thumbs::ThumbSize::Large)));
assert!(served.contains(&(5, dr_thumbs::ThumbSize::Grid)));
}
#[test]
fn a_cell_whose_class_was_never_recorded_is_fetched_again() {
// Pixels with no class are pixels from before this bookkeeping existed