grain-serve backfills the index from hubble, or each account's PDS
MergedThis pull adds grain_serve::backfill. It discovers accounts from the collection directories, reads each repo from hubble as STAR-lite or from the account's PDS as a CAR, and checks it. It adds record_rev, so the newer revision wins. I read backfill.rs, index/backfill.rs and the revision logic in index/mod.rs. I did not read the full 168 KB diff (it includes the stacked backfill/star commits), tests/backfill.rs, the schema migration or lib.rs. All four checks were still queued, so none has a result.
The revision design holds together. I checked newer/stamp, the stale-write handling, backfilled's deletions and the tombstone cleanup, and found no fault.
Must change: the PDS URL comes from the DID document and is fetched unchecked. read_and_apply takes the endpoint from identity::service and fetch GETs {pds}/xrpc/com.atproto.sync.getRepo with no checks. Anyone can publish a did:web document, or a PLC one, and write a signal-collection record. They can then point the endpoint at loopback, a cloud metadata address or another internal host, and grain-serve will request it.
- The client follows redirects by default, so a public-looking PDS can redirect inward.
- The failure text, including the status, is stored in
last_error.
Before this runs with GRAIN_BACKFILL=1:
- Accept only
httpsendpoints, apart from an explicit test or dev override. - Refuse hosts that are loopback, private, link-local or otherwise non-public. Check the resolved address, for example with a custom resolver, so a hostname that resolves to a private address is refused too.
- Disable redirects, or re-validate each hop.
The tests use a local stand-in server, so they will need that override.
The shortcomings you list (a panicked task's DID staying busy, and the Jetstream rev follow-up) are acceptable for now.
The one blocker from round 1 is still open. The PDS URL comes from the account's DID document, and getRepo is still fetched with no checks. I found none in this head (a31b6fa).
Backfill::newincrates/grain-serve/src/backfill.rsbuilds a plainreqwest::Clientwith only a user agent and a connect timeout. Redirects are on by default, and no scheme or host check exists. A search for "redirect" in grain-serve finds nothing.- Anyone can publish a
did:webor PLC document that points at loopback, a cloud metadata address or another internal host. They then write a signal-collection record, and grain-serve will request that address onceGRAIN_BACKFILL=1is set. A public PDS can also redirect inward. - The failure text, including the status, is stored in
last_error.
What to change:
- Accept only
httpsPDS endpoints, apart from an explicit test or dev override. The tests' local stand-in will need that override. - Refuse hosts that are loopback, private, link-local or otherwise non-public. Check the resolved address, for example with a custom DNS resolver, so a hostname that resolves to a private address is refused too.
- Set
redirect(Policy::none())on the client, or re-validate each hop.
The same applies to the hubble mirror URL if it can come from anywhere other than config.
All four checks are still queued, so none has a result.
crates/grain-serve/src/backfill.rsline 226Outdated
| |
impl Backfill { | |
pub fn new(index: Arc<Index>, ctx: Ctx, config: Config) -> Arc<Backfill> { | |
let http = reqwest::Client::builder() |
This client follows redirects and has no scheme or address policy. Fetches of DID-document-supplied PDS URLs go through it. Add the https-only check, refuse non-public resolved addresses, and disable redirects.
The blocker from rounds 1 and 2 is fixed, so I approve.
- https only:
may_askinbackfill.rsrefuses a PDS that is not on https.read_and_applycalls it before anything is fetched, and a refusal gives up at once. - IP literals: a PDS given as an IP address must be public.
Urlparsing normalises odd forms such as decimal IPs, so they reach the same check. - Hostnames:
Backfill::newinstalls thePublicOnlyresolver unlessprivateis set, so a name that resolves inward is refused. - Redirects: the client sets
redirect(Policy::none()). - Test override:
GRAIN_BACKFILL_PRIVATE/Config::privatelifts all of this, and the tests use it.
Unit tests cover the URL check and the resolver.
The four checks on this head are still queued, so none has a result. Make sure they pass before you merge. The shortcomings you list (the configured mirror and directories are trusted, a panicked task's DID stays busy, and Jetstream passing rev later) are acceptable for now.
- grain-serve backfills the index from hubble, or each account's PDS
- main

What changes
Stacked on
backfill/star(Core reads whole repos as STAR-lite or CAR, checked as they stream). Its commits show here until it lands. What this pull adds iscrates/grain-serve.feature/rust(indexRecords) is merged in.grain_serve::backfillreads every tracked account's repo whole and makes it the index's.social.grain.actor.profile,fyi.opensocial.declaration). The directories are lightrail, then Bluesky's relay, usinglistReposByCollectionand paging by cursor; it moves to the next directory when one does not answer. It also reads whatever the index queues itself: a newly tracked account (Index::track, as Jetstream calls it), an account whose host says it is back (set_host_status), and whatever admin entry points ask for. For those it exposesBackfill::add_reposandBackfill::resync, ready forsocial.grain.admin.addReposandresyncRepos.accept: application/x.microcosm.star-lite,accept-encoding: zstd). The body streams through a bounded channel intograin::repoon a blocking thread, so a repo is never held whole and the checks never run on the async runtime. If hubble has no copy, answers an error, goes quiet, cuts off, asks to be left alone, or serves a copy that fails its checks, it readsgetRepofrom the account's PDS as a CAR, spilled to a temp file, and checks that the same way. Only records of collections the index keeps are kept, and nothing is written until the whole archive has checked out.GRAIN_BACKFILL_PRIVATE=1(Config::private) lifts all of this for dev and tests.record_rev: the revision each record was last written or deleted at (migration V2). Backfill writes withIndex::put_revat the revision it read the repo at, and the index answersApplied::Stalewhere it already has the record, or its deletion, from that revision or later. Writes that name no revision (putanddelete, as Jetstream andindexRecordscall them today) are stamped with the time they were applied, as a TID, so a repo exported before them never overwrites them. After applying,Index::backfilleddeletes whatever the index holds for that account that is older than the repo and absent from it. It then drops the deletions the repo already covers, and records the account'srev(which never goes backwards),backfilled_atand stateactive.repotable, with a newlast_errorcolumn.Retry-After(at most an hour).atproto.brid.gyare markedskipped, as hatk'sskipPdsdid.record_revdoes not grow without bound.GRAIN_BACKFILL=1turns the worker on. It is off by default, as in tests and dev, and needsGRAIN_INDEX.GRAIN_BACKFILL_DIRECTORIES,_MIRROR,_SKIP,_CONCURRENCYand_PRIVATEoverride the defaults. They are read intograin_serve::Settingsas itsbackfillfield.Settings::backfill(&app)gives theBackfillhandle (none when unset, an error when there is no index), andmainspawns itsrun(). The admin endpoints will take the same handle.Why
This is the backfill step of the move off hatk, on the design the owner approved, which is opensocial's (
packages/group-sites/src/networkIndex.ts): directories for discovery, hubble's STAR-lite first, the PDS's CAR as fallback, a SQLite queue, newer revision wins.Opensocial keeps a revision on every record in its own table. grain's record tables are shared with every other writer, so the revisions sit beside them in
record_rev, which covers tombstones too, rather than as a column on each table. The stamp for writes with no revision makes the Jetstream pull safe as it stands: it does not carry a commit'srevyet.How it was checked
CI only. Nothing was built or run here.
tests/backfill.rs, against the reference PDS, with an in-process stand-in serving both lightrail'slistReposByCollection(one DID a page, behind a dead first directory) and hubble'sgetRepo:resyncqueues it afresh. A deactivated account is given up on at once. A mirror that answers 500, is too slow to answer, stalls mid-body or cuts the connection falls back to the PDS. A 429 pauses the mirror before it is asked again. A skipped host is never read. Withoutprivate, the reference PDS (http, loopback) is given up on before it is asked.add_reposadds and whatresyncqueues, and still reads queued accounts when no directory answers.Settings::backfill.backfill.rs: public and non-public addresses of each kind, the PDS URL check, and the resolver refusinglocalhostand127.0.0.1and keeping a public address.put_revanddelete_revagainst older and newer revisions, stream stamps, a revision ahead of the clock, and purge. Also the queue (pending,queued,resync,backfill_failed,backfill_skipped),backfilled's deletions, andforget_deletions. The queue and revision queries are added to the plan check, so they are shown to read by index.Shortcomings
{repos: [{did}], cursor}, and STAR-lite served raw-zstd. Real paging limits, rate limits, and whether hubble sends zstd ascontent-encodingor as the body are untested. Either kind of zstd is read, since zstd is recognised by its magic.getRepoto a CDN, that read fails and falls back to the PDS; following a redirect would need each hop checked, which this does not do.rev(put_revanddelete_rev) once itsOpcarries it. Until then, a PDS whose clock runs ahead of grain's could see an older export overwrite a newer stream write. That is a follow-up forindex/jetstream.SIGNALSis defined here as well as in the Jetstream pull. Whichever merges second should take the other's.Review
The design is worth a look, especially
record_revand the stamp for writes that carry no revision, inindex/mod.rsandindex/backfill.rs. Thenbackfill.rs, fromread_and_apply, andpublic,may_askandPublicOnlyfor the address policy.🤖 Generated with Claude Code