chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
A backfill never puts back what the feed has since changed or deleted
crates/knotless/src/jetstream.rs+4−0
| @@ -24,6 +24,9 @@ pub struct Event { | ||
pub did: Option<String>, | ||
/// Writes to the knotless collections. | ||
pub ops: Vec<Op>, | ||
/// The revision of the commit that made them, when the frame says: | ||
/// what the index weighs them against a backfill by. | ||
pub rev: Option<String>, | ||
/// Whether it says the account's identity changed: its handle, say. | ||
pub identity: bool, | ||
/// What an `#account` event says of the account at its host: `active`, | ||
| @@ -248,6 +251,7 @@ pub fn parse(frame: &str) -> Option<Event> { | ||
record, | ||
}); | ||
event.did = Some(text(&payload, "did")?); | ||
event.rev = payload["rev"].as_str().map(str::to_string); | ||
} | ||
} | ||
"identity" | "account" | "sync" => { | ||
crates/knotless-serve/src/web/repos.rs+1−1
| @@ -30,7 +30,7 @@ pub(super) async fn wrote(app: &Arc<App>, did: &str, ops: &[(&str, &str, &str)]) | ||
rkey: rkey.to_string(), | ||
record: None, | ||
}; | ||
let indexed = crate::index_op(&app.ctx, index, did, &op).await; | ||
let indexed = crate::index_op(&app.ctx, index, did, &op, None).await; | ||
indexed.map_err(AppError::internal)?; | ||
} | ||
} | ||
crates/knotless-serve/src/firehose.rs+1−0
| @@ -59,6 +59,7 @@ pub fn parse(frame: &[u8]) -> Option<Event> { | ||
} | ||
if !event.ops.is_empty() { | ||
event.did = Some(text(&body, "repo")?); | ||
event.rev = text(&body, "rev"); | ||
} | ||
} | ||
"#sync" | "#account" | "#identity" => { | ||
crates/knotless-serve/src/index.rs+135−1
| @@ -68,6 +68,10 @@ pub struct Index { | ||
stamps: Stamps, | ||
/// Repositories' pulls and issues, as [`Index::assembled`] made them. | ||
kept: Mutex<Kept>, | ||
/// Held across a write weighed by its revision, from the weighing to | ||
/// the stamp, so a backfill and the feed cannot both pass it at once | ||
/// ([`Index::put_as_of`]). | ||
revs: Mutex<()>, | ||
} | ||
| ||
/// A repository's pulls and issues: the forge records about it that stand, | ||
| @@ -520,6 +524,9 @@ pub(crate) fn connect(path: &Path) -> rusqlite::Result<(Connection, bool)> { | ||
sealed BLOB NOT NULL, at TEXT NOT NULL, | ||
PRIMARY KEY (owner, repo, runner, name)); | ||
CREATE TABLE IF NOT EXISTS inactive (did TEXT PRIMARY KEY, status TEXT NOT NULL); | ||
CREATE TABLE IF NOT EXISTS record_rev ( | ||
uri TEXT PRIMARY KEY, did TEXT NOT NULL, rev TEXT NOT NULL, gone INTEGER NOT NULL); | ||
CREATE INDEX IF NOT EXISTS record_rev_gone ON record_rev (did, rev) WHERE gone; | ||
CREATE INDEX IF NOT EXISTS label_uri ON label (uri); | ||
DROP VIEW IF EXISTS temp.down; | ||
CREATE TABLE IF NOT EXISTS down (subject TEXT PRIMARY KEY); | ||
| @@ -621,7 +628,8 @@ const CACHE: &str = "DROP TABLE IF EXISTS repo; DROP TABLE IF EXISTS forge; | ||
DROP TABLE IF EXISTS reaction; DROP TABLE IF EXISTS release; DROP TABLE IF EXISTS mention; | ||
DROP TABLE IF EXISTS reviewer; | ||
DROP TABLE IF EXISTS org; DROP TABLE IF EXISTS org_member; DROP TABLE IF EXISTS membership; | ||
DROP TABLE IF EXISTS collaborator; DROP TABLE IF EXISTS shared;"; | ||
DROP TABLE IF EXISTS collaborator; DROP TABLE IF EXISTS shared; | ||
DROP TABLE IF EXISTS record_rev;"; | ||
| ||
/// A label that has not lapsed. | ||
const LIVE: &str = "(exp IS NULL OR exp > strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))"; | ||
| @@ -774,6 +782,7 @@ impl Index { | ||
reading: Mutex::new(Some(reading)), | ||
stamps: Stamps::new(STAMPS_MAX), | ||
kept: Mutex::new(Kept::new(KEPT_ENTRIES)), | ||
revs: Mutex::default(), | ||
}; | ||
Ok(index) | ||
} | ||
| @@ -789,6 +798,83 @@ impl Index { | ||
self.put_at("", did, collection, rkey, value) | ||
} | ||
| ||
/// [`Index::put`], of the record as the commit at `rev` left it, | ||
/// unless a later commit's write to it is indexed already: a backfill | ||
/// reads a repository while the feed goes on, and must not put back | ||
/// what the feed has since changed or deleted. With no revision it is | ||
/// [`Index::put`]. | ||
pub fn put_as_of( | ||
&self, | ||
did: &str, | ||
collection: &str, | ||
rkey: &str, | ||
value: &Value, | ||
rev: Option<&str>, | ||
) -> rusqlite::Result<()> { | ||
self.as_of(did, collection, rkey, rev, false, &|| { | ||
self.put(did, collection, rkey, value) | ||
}) | ||
} | ||
| ||
/// [`Index::delete`], as of `rev`, as [`Index::put_as_of`] puts. The | ||
/// deletion is remembered until a backfill reads the account past it | ||
/// ([`Index::read_through`]), so the record is not put back meanwhile. | ||
pub fn delete_as_of( | ||
&self, | ||
did: &str, | ||
collection: &str, | ||
rkey: &str, | ||
rev: Option<&str>, | ||
) -> rusqlite::Result<()> { | ||
self.as_of(did, collection, rkey, rev, true, &|| { | ||
self.delete(did, collection, rkey) | ||
}) | ||
} | ||
| ||
/// Runs `write` unless the record's last indexed write came at a | ||
/// revision after `rev`, then stamps `rev` on it, `gone` for a deletion. | ||
fn as_of( | ||
&self, | ||
did: &str, | ||
collection: &str, | ||
rkey: &str, | ||
rev: Option<&str>, | ||
gone: bool, | ||
write: &dyn Fn() -> rusqlite::Result<()>, | ||
) -> rusqlite::Result<()> { | ||
let Some(rev) = rev else { | ||
return write(); | ||
}; | ||
let _held = self.revs.lock().unwrap(); | ||
let uri = record_uri("", did, collection, rkey); | ||
let later = self.db.lock().unwrap().query_row( | ||
"SELECT 1 FROM record_rev WHERE uri = ?1 AND rev > ?2", | ||
params![uri, rev], | ||
|_| Ok(()), | ||
); | ||
if later.optional()?.is_some() { | ||
return Ok(()); | ||
} | ||
write()?; | ||
self.db.lock().unwrap().execute( | ||
"INSERT INTO record_rev VALUES (?1, ?2, ?3, ?4) | ||
ON CONFLICT (uri) DO UPDATE SET rev = excluded.rev, gone = excluded.gone", | ||
params![uri, did, rev, gone], | ||
)?; | ||
Ok(()) | ||
} | ||
| ||
/// Forgets the deletions of `did`'s records made at or before `rev`, | ||
/// once a backfill has read the account as of then: what it read no | ||
/// longer holds them. | ||
pub fn read_through(&self, did: &str, rev: &str) -> rusqlite::Result<()> { | ||
self.db.lock().unwrap().execute( | ||
"DELETE FROM record_rev WHERE did = ?1 AND rev <= ?2 AND gone", | ||
params![did, rev], | ||
)?; | ||
Ok(()) | ||
} | ||
| ||
/// [`Index::put`], of a record in `space`, or in public records when | ||
/// it is empty: where an organization's record is kept tells what it | ||
/// says apart, as one keeps a record of the same name in each. | ||
| @@ -3845,6 +3931,54 @@ mod tests { | ||
json!({"name": "r", "key": "k", "createdAt": "2026-01-01T00:00:00Z"}) | ||
} | ||
| ||
#[test] | ||
fn a_backfill_never_undoes_a_later_write() { | ||
let dir = tempfile::tempdir().unwrap(); | ||
let index = Index::open(&dir.path().join("i.db")).unwrap(); | ||
let uri = "at://did:a/dev.knotless.repo/r"; | ||
let named = |about: &str| json!({"name": "r", "key": "k", "description": about}); | ||
let about = || index.repo(uri).unwrap().map(|r| r["description"].clone()); | ||
// The feed's write at rev 3 stands over a backfill's read as of 2. | ||
let (fed, read) = (named("fed"), named("read")); | ||
index | ||
.put_as_of("did:a", nsid::REPO, "r", &fed, Some("3")) | ||
.unwrap(); | ||
index | ||
.put_as_of("did:a", nsid::REPO, "r", &read, Some("2")) | ||
.unwrap(); | ||
assert_eq!(about(), Some(json!("fed"))); | ||
// One as of a later commit replaces it. | ||
index | ||
.put_as_of("did:a", nsid::REPO, "r", &read, Some("4")) | ||
.unwrap(); | ||
assert_eq!(about(), Some(json!("read"))); | ||
// A deletion at 5 keeps a read as of 4 from putting it back, and | ||
// one from before it changes nothing. | ||
index | ||
.delete_as_of("did:a", nsid::REPO, "r", Some("5")) | ||
.unwrap(); | ||
index | ||
.delete_as_of("did:a", nsid::REPO, "r", Some("1")) | ||
.unwrap(); | ||
index | ||
.put_as_of("did:a", nsid::REPO, "r", &read, Some("4")) | ||
.unwrap(); | ||
assert_eq!(about(), None); | ||
// A read past it forgets it, and what was read before it then lands. | ||
index.read_through("did:a", "4").unwrap(); | ||
index | ||
.put_as_of("did:a", nsid::REPO, "r", &read, Some("4")) | ||
.unwrap(); | ||
assert_eq!(about(), None); | ||
index.read_through("did:a", "5").unwrap(); | ||
index | ||
.put_as_of("did:a", nsid::REPO, "r", &read, Some("4")) | ||
.unwrap(); | ||
assert_eq!(about(), Some(json!("read"))); | ||
index.delete_as_of("did:a", nsid::REPO, "r", None).unwrap(); | ||
assert_eq!(about(), None); | ||
} | ||
| ||
#[test] | ||
fn pulls_and_issues_are_kept_until_written_about() { | ||
let dir = tempfile::tempdir().unwrap(); | ||
crates/knotless-serve/src/lib.rs+45−15
| @@ -315,7 +315,7 @@ impl App { | ||
}; | ||
let (ctx, index, did) = (self.ctx.clone(), index.clone(), did.to_string()); | ||
tokio::spawn(async move { | ||
let _ = index_op(&ctx, &index, &did, &op).await; | ||
let _ = index_op(&ctx, &index, &did, &op, None).await; | ||
}); | ||
} | ||
} | ||
| @@ -389,15 +389,17 @@ impl App { | ||
.all(|op| op.action == "delete" || op.record.is_some()) | ||
{ | ||
for op in &event.ops { | ||
telemetry::indexed(&did, index_inline(&index, &did, op)); | ||
let rev = event.rev.as_deref(); | ||
telemetry::indexed(&did, index_inline(&index, &did, op, rev)); | ||
} | ||
accounts::repos_deleted(self, &did, &deleted); | ||
return; | ||
} | ||
let (app, ctx) = (self.clone(), self.ctx.clone()); | ||
tokio::spawn(async move { | ||
for op in event.ops { | ||
telemetry::indexed(&did, index_op(&ctx, &index, &did, &op).await); | ||
let rev = event.rev.as_deref(); | ||
for op in &event.ops { | ||
telemetry::indexed(&did, index_op(&ctx, &index, &did, op, rev).await); | ||
} | ||
accounts::repos_deleted(&app, &did, &deleted); | ||
}); | ||
| @@ -612,21 +614,35 @@ impl App { | ||
} | ||
} | ||
| ||
/// Indexes a write the event carried in full: a deletion, or a record | ||
/// delivered inline. `false` when the record has to be fetched. | ||
fn index_inline(index: &index::Index, did: &str, op: &firehose::Op) -> rusqlite::Result<bool> { | ||
/// Indexes a write the event carried in full, made in the commit at `rev` | ||
/// when it says: a deletion, or a record delivered inline. `false` when | ||
/// the record has to be fetched. | ||
fn index_inline( | ||
index: &index::Index, | ||
did: &str, | ||
op: &firehose::Op, | ||
rev: Option<&str>, | ||
) -> rusqlite::Result<bool> { | ||
let (collection, rkey) = (op.collection.as_str(), op.rkey.as_str()); | ||
match (&op.record, op.action.as_str()) { | ||
(_, "delete") => index.delete(did, &op.collection, &op.rkey)?, | ||
(Some(record), _) => index.put(did, &op.collection, &op.rkey, record)?, | ||
(_, "delete") => index.delete_as_of(did, collection, rkey, rev)?, | ||
(Some(record), _) => index.put_as_of(did, collection, rkey, record, rev)?, | ||
(None, _) => return Ok(false), | ||
} | ||
Ok(true) | ||
} | ||
| ||
/// Indexes one write: the record the event carried, else the record as its | ||
/// PDS now has it, or its absence. | ||
pub async fn index_op(ctx: &Ctx, index: &index::Index, did: &str, op: &firehose::Op) -> Result<()> { | ||
if index_inline(index, did, op)? { | ||
/// Indexes one write, made in the commit at `rev` when it is known: the | ||
/// record the event carried, else the record as its PDS now has it, which | ||
/// is as of that commit or later. | ||
pub async fn index_op( | ||
ctx: &Ctx, | ||
index: &index::Index, | ||
did: &str, | ||
op: &firehose::Op, | ||
rev: Option<&str>, | ||
) -> Result<()> { | ||
if index_inline(index, did, op, rev)? { | ||
return Ok(()); | ||
} | ||
let id = knotless::identity::resolve(ctx, did).await?; | ||
| @@ -641,7 +657,7 @@ pub async fn index_op(ctx: &Ctx, index: &index::Index, did: &str, op: &firehose: | ||
None, | ||
) | ||
.await?; | ||
index.put(did, &op.collection, &op.rkey, &out["value"])?; | ||
index.put_as_of(did, &op.collection, &op.rkey, &out["value"], rev)?; | ||
Ok(()) | ||
} | ||
| ||
| @@ -725,18 +741,32 @@ async fn pages( | ||
| ||
/// Indexes every account on `service`, a PDS or relay, that holds knotless | ||
/// records: each account's repositories, checks and forge records. | ||
/// | ||
/// Each account is read as of its latest commit, asked before its records | ||
/// are listed: what the listing finds is that commit's or later, and a | ||
/// write the feed indexed from a later commit stands over it. | ||
pub async fn backfill(ctx: &Ctx, index: &index::Index, service: &str) -> Result<usize> { | ||
#[derive(serde::Deserialize)] | ||
struct Latest { | ||
rev: String, | ||
} | ||
let xrpc = knotless::xrpc::Xrpc::new(ctx.http.clone(), service); | ||
let mut indexed = 0; | ||
for did in accounts(&xrpc).await? { | ||
let url = RepoUrl::parse(&format!("knotless://{did}/_"))?; | ||
let remote = Remote::connect(ctx, &url).await?; | ||
let asked = [("did", did.as_str())]; | ||
let latest = remote | ||
.xrpc | ||
.query::<Latest>("com.atproto.sync.getLatestCommit", &asked, None); | ||
let rev = latest.await?.rev; | ||
for collection in indexed_collections() { | ||
for r in remote.list_all::<serde_json::Value>(collection).await? { | ||
index.put(&did, collection, &r.rkey, &r.value)?; | ||
index.put_as_of(&did, collection, &r.rkey, &r.value, Some(&rev))?; | ||
indexed += 1; | ||
} | ||
} | ||
index.read_through(&did, &rev)?; | ||
} | ||
Ok(indexed) | ||
} | ||
crates/knotless-serve/src/plans.rs+2−1
| @@ -32,7 +32,7 @@ pub const ENV: &str = "KNOTLESS_PLANS"; | ||
/// The tables that grow with what people do, as many rows as prod might | ||
/// hold: a scan of one is a scan of the network. The rest are said to be | ||
/// small, as a scan of them is. | ||
pub const GROWING: [&str; 16] = [ | ||
pub const GROWING: [&str; 17] = [ | ||
"forge", | ||
"check_run", | ||
"comment", | ||
| @@ -49,6 +49,7 @@ pub const GROWING: [&str; 16] = [ | ||
"theme", | ||
"shared", | ||
"shared_use", | ||
"record_rev", | ||
]; | ||
| ||
/// The rows a growing table is said to hold, and a small one. | ||
crates/knotless-serve/tests/forge.rs+3−1
| @@ -343,7 +343,8 @@ fn forks_pulls_reviews_and_merges() { | ||
&av.app.ctx, | ||
index, | ||
&owner.account.did, | ||
&gone | ||
&gone, | ||
None | ||
)) | ||
.is_err() | ||
); | ||
| @@ -555,6 +556,7 @@ fn forks_pulls_reviews_and_merges() { | ||
&index, | ||
&contrib.account.did, | ||
&deleted, | ||
None, | ||
)) | ||
.unwrap(); | ||
assert_eq!(index.forge(did, &key).unwrap().len(), backfilled - 1); | ||
docs/design/serve.md+9−0
| @@ -453,6 +453,15 @@ runs one at startup. `--relay` runs one when the index was rebuilt, then sweeps | ||
every 10 minutes, since a record whose event a feed never delivered would | ||
otherwise be missing for good. | ||
| ||
A backfill reads while the feed goes on, so it must not put back what the feed | ||
has since changed. Each write the index takes from a feed keeps the revision of | ||
the commit that made it, and a deletion is remembered with its revision too. A | ||
backfill asks each account for its latest commit before listing its records, | ||
and what it lists is as of that revision or later. A record whose indexed write | ||
came at a later revision is left as the feed has it. Once the account is read, | ||
the deletions at or before that revision are forgotten, as what was read no | ||
longer holds them. | ||
| ||
A rollout runs a new build beside the old one, both holding the index open. | ||
That is safe only if the new build would not rebuild it, so the deploy script | ||
asks first with `--check-index`. | ||