chadtmiller.com/knotless
Git hosted in your atproto account, on any stock PDS, fast enough for agents.
git clone knotless://chadtmiller.com/knotlessneeds the knotless git helper. set it up
Push: blob parts no larger than the PDS says it takes
upload reads blobUploadLimit from describeServer, cached as identities are, and starts at the smaller of it and KNOTLESS_CHUNK_SIZE. A PDS under 4 MB no longer costs a refused round per push, each re-sending the whole pack. Halving on a refusal stays, for a limit that is not what is in front of the PDS. examples/chunks measures what the part size costs over a shaped link; harness/pds.mjs takes --blob-limit for it. 4 MB stays the default: larger parts were no faster, and one 64 MiB part was slower at 20 Mbit/s. package-lock.json matches package.json's pinned playwright again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 8 changed files with 300 additions and 13 deletions
README.md+2−1
| @@ -328,7 +328,8 @@ its page with), `KNOTLESS_SERVICE` | ||
(skip DID resolution), `KNOTLESS_PLC_URL`, `KNOTLESS_HANDLE_RESOLVER`, | ||
`KNOTLESS_APPVIEW`, `KNOTLESS_MODERATOR` (the appview's), | ||
`KNOTLESS_ADMIN_PASSWORD`, `KNOTLESS_DID_WEB_SCHEME` (`https`), | ||
`KNOTLESS_INLINE_MAX` (192 KiB), `KNOTLESS_CHUNK_SIZE` (4 MB), | ||
`KNOTLESS_INLINE_MAX` (192 KiB), `KNOTLESS_CHUNK_SIZE` (4 MB, or the PDS's | ||
blob limit if smaller), | ||
`KNOTLESS_COMPACT` (`background`, `inline`, `off`), | ||
`KNOTLESS_COMPACT_THRESHOLD` (32), `KNOTLESS_COMPACT_GRACE_SECS` (600), | ||
`KNOTLESS_CLAIM_GRACE_SECS` (86400), | ||
crates/knotless/examples/chunks.rsnew+206−0
| @@ -0,0 +1,206 @@ | ||
//! What the part size costs a large push and its clone, over a link with a | ||
//! chosen round-trip time and bandwidth. | ||
//! | ||
//! node harness/pds.mjs --accounts alice --blob-limit 100000000 > target/chunks-net.json | ||
//! cargo build --release | ||
//! KNOTLESS_TEST_NET=target/chunks-net.json cargo run --release --example chunks -- \ | ||
//! --rtt 50 --mbit 0,100,20 --parts 4,16,64 --pack 64 -n 3 | ||
//! | ||
//! The PDS needs a blob limit above the largest part: the stock 5 MB | ||
//! refuses anything larger, and push would halve its way down. | ||
| ||
#[path = "../tests/common/mod.rs"] | ||
mod common; | ||
| ||
use common::*; | ||
use std::path::Path; | ||
use std::sync::atomic::{AtomicU64, Ordering}; | ||
use std::sync::{Arc, Mutex}; | ||
use std::time::{Duration, Instant}; | ||
use tokio::io::{AsyncReadExt, AsyncWriteExt}; | ||
use tokio::net::{TcpListener, TcpStream}; | ||
| ||
/// One direction of the link, shared by every connection, as an uplink is: | ||
/// bytes leave one after another at `bits` per second. | ||
struct Link { | ||
free_at: Mutex<Instant>, | ||
bits: AtomicU64, | ||
} | ||
| ||
impl Link { | ||
fn new() -> Arc<Link> { | ||
Arc::new(Link { | ||
free_at: Mutex::new(Instant::now()), | ||
bits: AtomicU64::new(u64::MAX), | ||
}) | ||
} | ||
| ||
/// When `n` bytes read now have finished crossing. | ||
fn send(&self, n: usize) -> Instant { | ||
let bits = self.bits.load(Ordering::Relaxed); | ||
let mut free_at = self.free_at.lock().unwrap(); | ||
let start = (*free_at).max(Instant::now()); | ||
*free_at = start + Duration::from_secs_f64(n as f64 * 8.0 / bits as f64); | ||
*free_at | ||
} | ||
} | ||
| ||
/// Forwards to `upstream` over a link `rtt` long each way round, at the | ||
/// bandwidth `up` and `down` are set to. | ||
async fn shaped_proxy(upstream: String, rtt: Duration, up: Arc<Link>, down: Arc<Link>) -> String { | ||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); | ||
let addr = listener.local_addr().unwrap(); | ||
tokio::spawn(async move { | ||
loop { | ||
let (client, _) = listener.accept().await.unwrap(); | ||
let server = TcpStream::connect(&upstream).await.unwrap(); | ||
client.set_nodelay(true).unwrap(); | ||
server.set_nodelay(true).unwrap(); | ||
let (cr, cw) = client.into_split(); | ||
let (sr, sw) = server.into_split(); | ||
tokio::spawn(pipe(cr, sw, rtt / 2, up.clone())); | ||
tokio::spawn(pipe(sr, cw, rtt / 2, down.clone())); | ||
} | ||
}); | ||
format!("http://{addr}") | ||
} | ||
| ||
async fn pipe( | ||
mut from: tokio::net::tcp::OwnedReadHalf, | ||
mut to: tokio::net::tcp::OwnedWriteHalf, | ||
delay: Duration, | ||
link: Arc<Link>, | ||
) { | ||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(Instant, Vec<u8>)>(); | ||
tokio::spawn(async move { | ||
while let Some((due, bytes)) = rx.recv().await { | ||
tokio::time::sleep_until(due.into()).await; | ||
if to.write_all(&bytes).await.is_err() { | ||
return; | ||
} | ||
} | ||
let _ = to.shutdown().await; | ||
}); | ||
let mut buf = vec![0u8; 64 * 1024]; | ||
loop { | ||
match from.read(&mut buf).await { | ||
Ok(0) | Err(_) => return, | ||
Ok(n) => { | ||
let _ = tx.send((link.send(n) + delay, buf[..n].to_vec())); | ||
} | ||
} | ||
} | ||
} | ||
| ||
fn arg<'a>(args: &'a [String], name: &str, default: &'a str) -> &'a str { | ||
args.iter() | ||
.position(|a| a == name) | ||
.and_then(|i| args.get(i + 1)) | ||
.map_or(default, String::as_str) | ||
} | ||
| ||
fn list(s: &str) -> Vec<u64> { | ||
s.split(',').map(|v| v.parse().unwrap()).collect() | ||
} | ||
| ||
fn secs(f: impl FnOnce()) -> f64 { | ||
let start = Instant::now(); | ||
f(); | ||
start.elapsed().as_secs_f64() | ||
} | ||
| ||
fn median(mut v: Vec<f64>) -> f64 { | ||
v.sort_by(f64::total_cmp); | ||
v[v.len() / 2] | ||
} | ||
| ||
/// Bytes nothing compresses, from a xorshift generator. | ||
fn noise(len: usize, mut x: u64) -> Vec<u8> { | ||
let mut out = Vec::with_capacity(len + 8); | ||
while out.len() < len { | ||
x ^= x << 13; | ||
x ^= x >> 7; | ||
x ^= x << 17; | ||
out.extend_from_slice(&x.to_le_bytes()); | ||
} | ||
out.truncate(len); | ||
out | ||
} | ||
| ||
fn main() { | ||
let args: Vec<String> = std::env::args().collect(); | ||
let rtt: u64 = arg(&args, "--rtt", "50").parse().unwrap(); | ||
let mbits = list(arg(&args, "--mbit", "100,20")); | ||
let parts = list(arg(&args, "--parts", "4,16,64")); | ||
let pack_mib: usize = arg(&args, "--pack", "64").parse().unwrap(); | ||
let n: usize = arg(&args, "-n", "3").parse().unwrap(); | ||
let release = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/release"); | ||
assert!( | ||
release.join("git-remote-knotless").exists(), | ||
"run `cargo build --release` first" | ||
); | ||
// SAFETY: set before any thread that reads the environment starts. | ||
unsafe { std::env::set_var("KNOTLESS_BIN_DIR", &release) }; | ||
| ||
let rt = tokio::runtime::Runtime::new().unwrap(); | ||
let (up, down) = (Link::new(), Link::new()); | ||
let upstream = net().pds.trim_start_matches("http://").to_string(); | ||
let proxy = rt.block_on(shaped_proxy( | ||
upstream, | ||
Duration::from_millis(rtt), | ||
up.clone(), | ||
down.clone(), | ||
)); | ||
| ||
let mut s = Sandbox::new(); | ||
s.set("KNOTLESS_SERVICE", &proxy); | ||
s.set("KNOTLESS_COMPACT", "off"); | ||
s.set("KNOTLESS_TIMEOUT_SECS", "600"); | ||
let work = s.work("work", 1); | ||
std::fs::write( | ||
work.join("big.bin"), | ||
noise(pack_mib << 20, 0x9e3779b97f4a7c15), | ||
) | ||
.unwrap(); | ||
s.git(&work, &["add", "big.bin"]); | ||
s.git(&work, &["commit", "-q", "-m", "big"]); | ||
// A login and the identity, cached before anything is timed. | ||
s.git(&work, &["push", "-q", &s.url("warm"), "main"]); | ||
| ||
println!("{pack_mib} MiB of noise, {rtt} ms RTT, median of {n}\n"); | ||
println!(" link part parts push s clone s"); | ||
let mut run = 0; | ||
for &mbit in &mbits { | ||
// 0 is no cap: the cost of the work itself, on this machine. | ||
let bits = if mbit == 0 { | ||
u64::MAX | ||
} else { | ||
mbit * 1_000_000 | ||
}; | ||
for link in [&up, &down] { | ||
link.bits.store(bits, Ordering::Relaxed); | ||
} | ||
for &part in &parts { | ||
s.set("KNOTLESS_CHUNK_SIZE", &(part << 20).to_string()); | ||
let (mut push, mut clone) = (Vec::new(), Vec::new()); | ||
for _ in 0..n { | ||
run += 1; | ||
let url = s.url(&format!("chunks{run}")); | ||
push.push(secs(|| { | ||
s.git(&work, &["push", "-q", &url, "main"]); | ||
})); | ||
let dir = s.path(&format!("clone{run}")); | ||
clone.push(secs(|| { | ||
s.git(s.dir.path(), &["clone", "-q", &url, dir.to_str().unwrap()]); | ||
})); | ||
std::fs::remove_dir_all(&dir).unwrap(); | ||
} | ||
println!( | ||
"{mbit:4} Mbit/s {part:4} MiB {:6} {:6.1} {:6.1}", | ||
(pack_mib as u64).div_ceil(part), | ||
median(push), | ||
median(clone) | ||
); | ||
} | ||
} | ||
} | ||
crates/knotless/src/env.rs+2−1
| @@ -39,7 +39,8 @@ pub struct Env { | ||
pub browser: Option<String>, | ||
/// Packs up to this many bytes ride inside the pack record itself. | ||
pub inline_max: usize, | ||
/// Blob part size for larger packs; halved when a PDS refuses it. | ||
/// Blob part size for larger packs, at most the PDS's advertised limit; | ||
/// halved when a PDS refuses it. | ||
pub chunk_size: usize, | ||
pub compact_threshold: usize, | ||
pub compact_mode: CompactMode, | ||
crates/knotless/src/identity.rs+1−1
| @@ -23,7 +23,7 @@ struct Cached { | ||
at: u64, | ||
} | ||
| ||
fn now_secs() -> u64 { | ||
pub(crate) fn now_secs() -> u64 { | ||
crate::keys::now_micros() / 1_000_000 | ||
} | ||
| ||
crates/knotless/src/push.rs+48−3
| @@ -8,11 +8,13 @@ use crate::records::{ | ||
self, BlobRef, Bytes, ClaimRecord, PACK_MIME, PackRecord, RefRecord, RepoRecord, nsid, | ||
}; | ||
use crate::remote::{self, Remote, Snapshot, Stored}; | ||
use crate::xrpc::{Auth, XrpcError}; | ||
use crate::xrpc::{Auth, Xrpc, XrpcError}; | ||
use crate::{identity, store}; | ||
use anyhow::{Result, bail}; | ||
use futures::{StreamExt, TryStreamExt}; | ||
use serde::{Deserialize, Serialize}; | ||
use serde_json::Value; | ||
use std::collections::BTreeSet; | ||
use std::collections::{BTreeMap, BTreeSet}; | ||
| ||
/// One refspec of a push. `src: None` deletes `dst`. | ||
#[derive(Clone, Debug, PartialEq, Eq)] | ||
| @@ -323,7 +325,14 @@ pub async fn place( | ||
/// Uploads `pack` as blobs of at most the chunk size, four at a time, | ||
/// halving the size while the PDS calls a part too large. | ||
pub async fn upload(ctx: &Ctx, remote: &Remote, auth: &Auth, pack: &[u8]) -> Result<Vec<BlobRef>> { | ||
let mut chunk = ctx.env.chunk_size.max(1); | ||
// Never above what the PDS says it takes; still halved below that when | ||
// it refuses, since what it says is not always what is in front of it. | ||
let limit = blob_limit(ctx, &remote.xrpc).await; | ||
let mut chunk = ctx | ||
.env | ||
.chunk_size | ||
.min(limit.map_or(usize::MAX, |l| l as usize)) | ||
.max(1); | ||
loop { | ||
// Owned parts, so the stream's future is Send for any caller. | ||
let parts: Vec<Vec<u8>> = pack.chunks(chunk).map(<[u8]>::to_vec).collect(); | ||
| @@ -343,6 +352,42 @@ pub async fn upload(ctx: &Ctx, remote: &Remote, auth: &Auth, pack: &[u8]) -> Res | ||
} | ||
} | ||
| ||
#[derive(Serialize, Deserialize)] | ||
struct Server { | ||
blob_limit: Option<u64>, | ||
at: u64, | ||
} | ||
| ||
/// The largest blob `xrpc`'s PDS says it takes, from `describeServer`: | ||
/// `None` if it does not say, or cannot be asked. Cached on disk, as | ||
/// identities are. | ||
async fn blob_limit(ctx: &Ctx, xrpc: &Xrpc) -> Option<u64> { | ||
let path = ctx.env.cache_dir.join("servers.json"); | ||
let mut cache: BTreeMap<String, Server> = store::read(&path).unwrap_or_default(); | ||
if let Some(hit) = cache.get(&xrpc.base) | ||
&& identity::now_secs().saturating_sub(hit.at) < ctx.env.identity_ttl.as_secs() | ||
{ | ||
return hit.blob_limit; | ||
} | ||
#[derive(Deserialize)] | ||
#[serde(rename_all = "camelCase")] | ||
struct Out { | ||
blob_upload_limit: Option<u64>, | ||
} | ||
let described = xrpc.query::<Out>("com.atproto.server.describeServer", &[], None); | ||
let out = described.await.ok()?; | ||
cache.insert( | ||
xrpc.base.clone(), | ||
Server { | ||
blob_limit: out.blob_upload_limit, | ||
at: identity::now_secs(), | ||
}, | ||
); | ||
// A cache that cannot be written only costs the next push. | ||
let _ = store::write(&path, &cache, false); | ||
out.blob_upload_limit | ||
} | ||
| ||
fn too_large(err: &anyhow::Error) -> bool { | ||
XrpcError::of(err).is_some_and(|e| { | ||
e.status == 413 || e.error == "BlobTooLarge" || e.error == "PayloadTooLargeError" | ||
crates/knotless/tests/git_flows.rs+38−5
| @@ -102,14 +102,13 @@ fn tags_and_empty_repositories() { | ||
assert_eq!(s.git(&clone, &["describe"]), "v1"); | ||
} | ||
| ||
#[test] | ||
fn large_packs_go_over_blobs() { | ||
let mut s = Sandbox::new(); | ||
/// Pushes 7 MB nothing compresses and clones it back, through blobs of at | ||
/// most `KNOTLESS_CHUNK_SIZE` (6 MB, above the reference PDS's 5 MiB | ||
/// limit): the number of parts the pack went up in. | ||
fn push_large(s: &mut Sandbox) -> usize { | ||
s.set("KNOTLESS_INLINE_MAX", "1024"); | ||
// Larger than the reference PDS's 5 MB blob limit: the push halves it. | ||
s.set("KNOTLESS_CHUNK_SIZE", "6000000"); | ||
let work = s.work("work", 1); | ||
// Incompressible, so the pack stays larger than one blob. | ||
let mut x: u64 = 0x9e3779b97f4a7c15; | ||
let noise: Vec<u8> = (0..7_000_000) | ||
.map(|_| { | ||
| @@ -130,6 +129,40 @@ fn large_packs_go_over_blobs() { | ||
&["clone", "-q", &url, clone.to_str().unwrap()], | ||
); | ||
assert_eq!(std::fs::read(clone.join("blob.bin")).unwrap(), noise); | ||
let packs: serde_json::Value = reqwest::blocking::Client::new() | ||
.get(format!("{}/xrpc/com.atproto.repo.listRecords", net().pds)) | ||
.query(&[ | ||
("repo", s.account.did.as_str()), | ||
("collection", "dev.knotless.pack"), | ||
]) | ||
.send() | ||
.and_then(|r| r.json()) | ||
.unwrap(); | ||
let [pack] = packs["records"].as_array().unwrap().as_slice() else { | ||
panic!("{packs}") | ||
}; | ||
pack["value"]["parts"].as_array().unwrap().len() | ||
} | ||
| ||
#[test] | ||
fn large_packs_go_over_blobs_the_pds_takes() { | ||
// Its describeServer says 5 MiB, so two parts, with nothing refused: | ||
// halving 6 MB on a refusal would have made three. | ||
assert_eq!(push_large(&mut Sandbox::new()), 2); | ||
} | ||
| ||
#[test] | ||
fn a_refused_part_is_halved() { | ||
// A limit the PDS was taken to say, above what it takes. | ||
let mut s = Sandbox::new(); | ||
let cache = s.path("cache"); | ||
std::fs::create_dir_all(&cache).unwrap(); | ||
let now = std::time::UNIX_EPOCH.elapsed().unwrap().as_secs(); | ||
let servers = serde_json::json!({ | ||
net().pds.trim_end_matches('/'): {"blob_limit": 100_000_000, "at": now}, | ||
}); | ||
std::fs::write(cache.join("servers.json"), servers.to_string()).unwrap(); | ||
assert_eq!(push_large(&mut s), 3); | ||
} | ||
| ||
#[test] | ||
harness/package-lock.json+1−1
| @@ -9,7 +9,7 @@ | ||
"@atproto/dev-env": "0.6.26" | ||
}, | ||
"devDependencies": { | ||
"@playwright/test": "^1.58.2" | ||
"@playwright/test": "1.58.2" | ||
} | ||
}, | ||
"node_modules/@atproto-labs/did-resolver": { | ||
harness/pds.mjs+2−1
| @@ -2,7 +2,7 @@ | ||
// directory on free ports, creates test accounts, prints one JSON line | ||
// describing them, and shuts down when stdin closes. | ||
// | ||
// node harness/pds.mjs [--accounts alice,bob,carol] [--rate-limits] | ||
// node harness/pds.mjs [--accounts alice,bob,carol] [--rate-limits] [--blob-limit <bytes>] | ||
// | ||
// KNOTLESS_DEV_ENV names a built dev-env package to use instead of the npm | ||
// one, such as an atproto checkout's packages/dev-env on the spaces alpha. | ||
| @@ -58,6 +58,7 @@ const net = await TestNetworkNoAppView.create({ | ||
pds: { | ||
hostname: 'localhost', | ||
rateLimitsEnabled: process.argv.includes('--rate-limits'), | ||
...(arg('--blob-limit') && { blobUploadLimit: Number(arg('--blob-limit')) }), | ||
}, | ||
}) | ||