chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
A check's log is read ahead as it is filed, and many more are kept
crates/knotless-serve/src/card.rs+45−7
| @@ -807,27 +807,40 @@ pub fn render(svg: &str, picture: Option<Arc<Vec<u8>>>) -> Vec<u8> { | ||
encode(&raster(svg, picture)) | ||
} | ||
| ||
/// Values by key, the oldest dropped past a number of them. | ||
/// Values by key, the oldest dropped past a number of them, or past a | ||
/// number of bytes when each is weighed. | ||
pub struct Newest<V> { | ||
kept: Mutex<Entries<V>>, | ||
cap: usize, | ||
weigh: fn(&V) -> usize, | ||
} | ||
| ||
struct Entries<V> { | ||
by_key: HashMap<String, V>, | ||
/// Oldest first. | ||
order: VecDeque<String>, | ||
/// What all of them weigh together. | ||
weight: usize, | ||
} | ||
| ||
impl<V: Clone> Newest<V> { | ||
/// Keeping `cap` of them. | ||
pub fn new(cap: usize) -> Newest<V> { | ||
Newest::weighed(cap, |_| 1) | ||
} | ||
| ||
/// Keeping as many as `weigh` says weigh `cap` together, and always the | ||
/// newest. | ||
pub fn weighed(cap: usize, weigh: fn(&V) -> usize) -> Newest<V> { | ||
let keys = Entries { | ||
by_key: HashMap::new(), | ||
order: VecDeque::new(), | ||
weight: 0, | ||
}; | ||
Newest { | ||
kept: Mutex::new(keys), | ||
cap, | ||
weigh, | ||
} | ||
} | ||
| ||
| @@ -837,13 +850,20 @@ impl<V: Clone> Newest<V> { | ||
| ||
pub fn put(&self, key: &str, value: V) { | ||
let mut kept = self.kept.lock().unwrap(); | ||
let Entries { by_key, order } = &mut *kept; | ||
if by_key.insert(key.to_string(), value).is_none() { | ||
order.push_back(key.to_string()); | ||
let Entries { | ||
by_key, | ||
order, | ||
weight, | ||
} = &mut *kept; | ||
*weight += (self.weigh)(&value); | ||
match by_key.insert(key.to_string(), value) { | ||
Some(was) => *weight -= (self.weigh)(&was), | ||
None => order.push_back(key.to_string()), | ||
} | ||
while order.len() > self.cap { | ||
let oldest = order.pop_front().expect("longer than the cap"); | ||
by_key.remove(&oldest); | ||
while *weight > self.cap && order.len() > 1 { | ||
let oldest = order.pop_front().expect("more than one"); | ||
let was = by_key.remove(&oldest).expect("each in order is kept"); | ||
*weight -= (self.weigh)(&was); | ||
} | ||
} | ||
| ||
| @@ -898,6 +918,24 @@ impl Cache { | ||
mod tests { | ||
use super::*; | ||
| ||
#[test] | ||
fn newest_by_weight() { | ||
let kept: Newest<Vec<u8>> = Newest::weighed(10, Vec::len); | ||
kept.put("a", vec![0; 4]); | ||
kept.put("b", vec![0; 4]); | ||
assert_eq!(kept.len(), 2); | ||
// Put again, it weighs what it weighs now, not both. | ||
kept.put("b", vec![0; 6]); | ||
assert_eq!((kept.len(), kept.get("b").map(|b| b.len())), (2, Some(6))); | ||
// Past the cap, the oldest go. | ||
kept.put("c", vec![0; 3]); | ||
assert!(kept.get("a").is_none()); | ||
assert_eq!(kept.len(), 2); | ||
// One heavier than the cap is still kept, alone. | ||
kept.put("d", vec![0; 20]); | ||
assert_eq!((kept.len(), kept.get("d").map(|d| d.len())), (1, Some(20))); | ||
} | ||
| ||
#[test] | ||
fn wrapping() { | ||
let w = |t: &str, width: f32, max: usize| wrap(t, 40.0, Weight::Regular, width, max); | ||
crates/knotless-serve/src/lib.rs+13−0
| @@ -439,6 +439,7 @@ impl App { | ||
telemetry::indexed(&did, index_inline(&index, &did, op, rev)); | ||
} | ||
accounts::repos_deleted(self, &did, &deleted); | ||
self.warm_checks(&did, &event.ops); | ||
return; | ||
} | ||
let (app, ctx) = (self.clone(), self.ctx.clone()); | ||
| @@ -448,10 +449,22 @@ impl App { | ||
telemetry::indexed(&did, index_op(&ctx, &index, &did, op, rev).await); | ||
} | ||
accounts::repos_deleted(&app, &did, &deleted); | ||
app.warm_checks(&did, &event.ops); | ||
}); | ||
} | ||
} | ||
| ||
/// Reads ahead the logs of the checks `ops` filed for `did`, once they | ||
/// are indexed ([`web::warm_check_log`]). | ||
fn warm_checks(self: &Arc<App>, did: &str, ops: &[firehose::Op]) { | ||
let check = knotless::records::nsid::CHECK; | ||
let filed = ops.iter().filter(|op| op.collection == check); | ||
for op in filed.filter(|op| op.action != "delete") { | ||
let (app, did, rkey) = (self.clone(), did.to_string(), op.rkey.clone()); | ||
tokio::spawn(web::warm_check_log(app, did, rkey)); | ||
} | ||
} | ||
| ||
/// Whether the account `did` is taken down here, or by a labeler this | ||
/// server follows: never without an index, and not when the index | ||
/// cannot answer. | ||
crates/knotless-serve/src/web.rs+93−7
| @@ -2037,11 +2037,20 @@ async fn in_space(reader: &knotless::env::Ctx, uri: &str, did: &str) -> anyhow:: | ||
crate::spaces::remote(reader, &joined, did).await | ||
} | ||
| ||
/// What check runs made, as shown, by kind, runner and CID: a log's tail, | ||
/// or a picture. A blob is the same for good, so it is read once; one in a | ||
/// space is read each time, as whoever asks may read it. | ||
/// Pictures check runs made and blobs attached, by kind, author and CID. | ||
/// A blob is the same for good, so it is read once; one in a space is | ||
/// read each time, as whoever asks may read it. | ||
static RUN_BLOBS: LazyLock<card::Newest<Arc<Vec<u8>>>> = LazyLock::new(|| card::Newest::new(64)); | ||
| ||
/// The tails of check logs, kept as [`RUN_BLOBS`] are: a few kilobytes | ||
/// most, and at most [`LOG_SHOWN`], so many more of them, up to | ||
/// [`LOG_TAILS_KEPT`] bytes in all. | ||
static LOG_TAILS: LazyLock<card::Newest<Arc<Vec<u8>>>> = | ||
LazyLock::new(|| card::Newest::weighed(LOG_TAILS_KEPT, |tail| tail.len())); | ||
| ||
/// What the kept tails of check logs weigh at most. | ||
const LOG_TAILS_KEPT: usize = 64 * 1024 * 1024; | ||
| ||
/// The blob `cid` of the runner `remote`, as `shown` makes it of `kind`. | ||
async fn run_blob( | ||
remote: &Remote, | ||
| @@ -2051,25 +2060,71 @@ async fn run_blob( | ||
) -> anyhow::Result<Arc<Vec<u8>>> { | ||
let public = remote.space.is_none(); | ||
let key = format!("{kind}/{}/{cid}", remote.did()); | ||
let kept = public.then(|| RUN_BLOBS.get(&key)).flatten(); | ||
let cache = [&RUN_BLOBS, &LOG_TAILS][usize::from(kind == "log")]; | ||
let kept = public.then(|| cache.get(&key)).flatten(); | ||
if let Some(kept) = looked("run blobs", kept) { | ||
return Ok(kept); | ||
} | ||
let blob = Arc::new(shown(remote.get_blob(cid).await?)); | ||
if public { | ||
RUN_BLOBS.put(&key, blob.clone()); | ||
cache.put(&key, blob.clone()); | ||
} | ||
Ok(blob) | ||
} | ||
| ||
/// A log as a check's page shows it: its tail. | ||
fn shown_tail(log: Vec<u8>) -> Vec<u8> { | ||
log_tail(&log).into_bytes() | ||
} | ||
| ||
/// How many check logs are read ahead at once, as their checks come. | ||
static WARMING: LazyLock<tokio::sync::Semaphore> = LazyLock::new(|| tokio::sync::Semaphore::new(4)); | ||
| ||
/// The CID of the log of `did`'s check `rkey` as the index has it, when | ||
/// its repository names `did` among its runners, so that its page shows it. | ||
fn log_to_warm(index: &Index, did: &str, rkey: &str) -> Option<String> { | ||
let uri = format!("at://{did}/{}/{rkey}", nsid::CHECK); | ||
let value = index.check_record(&uri).ok().flatten()?; | ||
let check: CheckRecord = serde_json::from_value(value).ok()?; | ||
let cid = check.log?.link.cid; | ||
let repo = index.repo(&check.repo.uri).ok().flatten()?; | ||
let repo: RepoRecord = serde_json::from_value(repo).ok()?; | ||
repo.is_runner(did).then_some(cid) | ||
} | ||
| ||
/// Reads ahead the tail of the log of `did`'s check `rkey`, as the check | ||
/// comes from the network, so its page's first look need not wait on the | ||
/// runner's PDS. A few at once, and none past them: a burst of checks | ||
/// leaves the rest to be read as they are looked at. | ||
pub(crate) async fn warm_check_log(app: Arc<App>, did: String, rkey: String) { | ||
let index = app.index.as_ref(); | ||
let cid = index.and_then(|index| log_to_warm(index, &did, &rkey)); | ||
let permit = cid.as_ref().and_then(|_| WARMING.try_acquire().ok()); | ||
let (Some(cid), Some(_permit)) = (cid, permit) else { | ||
return; | ||
}; | ||
let at = RepoUrl { | ||
authority: did, | ||
name: "_".into(), | ||
}; | ||
let read = async { | ||
let remote = Remote::connect(&app.ctx, &at).await?; | ||
run_blob(&remote, "log", &cid, shown_tail).await | ||
}; | ||
fill( | ||
"check logs", | ||
&format!("{}/{cid}", at.authority), | ||
&read.await, | ||
); | ||
} | ||
| ||
/// `dev.knotless.getCheck`. | ||
async fn get_check(State(app): State<Arc<App>>, Query(p): Query<CheckParams>) -> Result<Response> { | ||
let (repo, remote, mut c) = trusted_check(&app, &p).await?; | ||
let tail = |b: Vec<u8>| log_tail(&b).into_bytes(); | ||
let read = c | ||
.log | ||
.as_ref() | ||
.map(|b| run_blob(&remote, "log", &b.link.cid, tail)); | ||
.map(|b| run_blob(&remote, "log", &b.link.cid, shown_tail)); | ||
let blob = futures::future::OptionFuture::from(read).await; | ||
let log = blob.and_then(|b| b.ok()); | ||
let log = log.map(|b| String::from_utf8_lossy(&b).into_owned()); | ||
| @@ -3216,6 +3271,37 @@ async fn item_card_png( | ||
mod tests { | ||
use super::*; | ||
| ||
#[test] | ||
fn only_a_named_runners_logs_are_read_ahead() { | ||
let dir = tempfile::tempdir().unwrap(); | ||
let index = Index::open(&dir.path().join("i.db")).unwrap(); | ||
let ran = serde_json::json!({"name": "r", "key": "k", "runners": [{"did": "did:ci"}], | ||
"createdAt": "2026-01-01T00:00:00Z"}); | ||
index.put("did:a", nsid::REPO, "r", &ran).unwrap(); | ||
let repo = serde_json::json!({"uri": "at://did:a/dev.knotless.repo/r", "key": "k"}); | ||
let check = |by: &str, rkey: &str, log: bool| { | ||
let mut run = serde_json::json!({"repo": repo, "ref": "refs/heads/main", | ||
"commit": "c", "workflow": "test", "status": "success", | ||
"startedAt": "2026-01-02T00:00:00Z"}); | ||
if log { | ||
run["log"] = serde_json::json!({"$type": "blob", "ref": {"$link": "bafylog"}, | ||
"mimeType": "text/plain", "size": 3}); | ||
} | ||
index.put(by, nsid::CHECK, rkey, &run).unwrap(); | ||
}; | ||
check("did:ci", "1", true); | ||
check("did:ci", "2", false); | ||
check("did:other", "3", true); | ||
assert_eq!( | ||
log_to_warm(&index, "did:ci", "1").as_deref(), | ||
Some("bafylog") | ||
); | ||
// No log yet, a runner the repository does not name, or no check. | ||
assert_eq!(log_to_warm(&index, "did:ci", "2"), None); | ||
assert_eq!(log_to_warm(&index, "did:other", "3"), None); | ||
assert_eq!(log_to_warm(&index, "did:ci", "none"), None); | ||
} | ||
| ||
#[test] | ||
fn first_files_drawn_stop_at_ten_or_the_lines() { | ||
let file = |changed: usize, binary: bool| diff::File { | ||
docs/design/serve.md+6−1
| @@ -653,7 +653,12 @@ again, so a member removed meanwhile stops hearing. | ||
log stays scrolled to its end as output comes, unless the reader has | ||
scrolled up from it. Once the run ends the | ||
record is filed, the repository's stream says so, and the page draws the | ||
record's log. | ||
record's log. Its tail, the last 256 KiB the page shows, is read from | ||
the runner's PDS as the record comes from the network, a few at once, | ||
and kept in memory, 64 MiB of tails in all, so the page's first look | ||
does not wait on that PDS; one the server had no time for is read as | ||
it is looked at, and kept the same way. A private repository's is read | ||
each time, as whoever asks may read it. | ||
| ||
Any page left out of sight a minute or more asks its answers again as it | ||
is looked at, and any page asks again as the browser is back online, so a | ||