Bobbin
Bobbin is an API appview for Tangled records. It serves
XRPC endpoints for sh.tangled.*, with it you can
get repos, issues, pulls, comments, follows, stars, labels,
pipelines, and profiles. It is read-only, there is no auth,
since that should all be handled direct-to-PDS and knot
respectively.
Bobbin has no permanent storage.
It is only a glorified edge index, in the graph theory sense. Additionally it has a record cache, re-filled on demand. All other data that Bobbin serves comes live from PDSes & knots.
What Bobbin needs
The way that Bobbin is able to pull off being so stateless
is by moving state upstream. Primarily it depends on an
instance of Hydrant
, which is the service that gives an event stream for Bobbin
to quickly backfill from on every restart. Backfilling ought
to take less than a couple of minutes maximum. If the upstream
instance of Hydrant fails while Bobbin is live, its list/count
endpoints stop advancing and report a stale cursor.
Single-lookups will continue working, due to the second
dependency: Slingshot.
Slingshot fetches individual records & resolves
identities. If the upstream instance of Slingshot fails,
single-lookups will fail with a 502 error. There
are some aggregation endpoints that use Slingshot for
hydrating, which will also fail.
A soft dependency that ought to exist for Bobbin to operate correctly is simply the plethora of knots that are out there, that Bobbin talks to directly for git data and, for knots at v1.15+, members & collaborators.
Building Bobbin
Bobbin is under Tangled’s core monorepo, under bobbin/. Here’s an easy local debug-build:
cargo build -p bobbinBobbin loves being in a container. When using
bobbin/containerfiles/bobbin.Containerfile, it
runs
cargo build --release --bin bobbin --package bobbin
within a little Debian runtime, exposing port 8090.
Configuration
The best way to configure Bobbin is via a toml config file.
There’s an example.toml in Bobbin’s
subdir. Every value is overridable by a
BOBBIN_* env var. The load order is env, then
--config <path>, then
/etc/bobbin/config.toml, then built-in
defaults.
Load and check a config without starting the server:
bobbin --config config.toml validateMinimal config is the two upstream URLs. The hydrant URL
takes ws:// or wss://. An
http:// or https:// URL is rewritten
to the matching websocket scheme at connection-time.
[server]
binds = ["127.0.0.1:8090"]
# Loopback-only & can leave empty to disable debug introspection.
debug_bind = "127.0.0.1:8091"
[hydrant]
url = "https://hydrant.example.com"
[slingshot]
url = "https://slingshot.example.com"🦪 Lewis
At time of writing, we (Tangled) don’t host public instances of Hydrant or Slingshot. You will have to find public instances or spin these up yourself! :P
Take a gander in the project’s example.toml for an exhaustive list of things to configure.
You will discover fun things such as a configurable
adaptive loop that watches the cgroup memory limit &
throttles heavy requests under pressure. It only works if it
detects a cgroup limit is present. The config for that is in
the [backpressure] block of the config
template.
Running Bobbin
Start the server using a config toml:
bobbin --config config.tomlBobbin wakes up in a cold sweat and immediately gets to work: 1. It binds its listeners, connects to the Hydrant stream in the background. 2. It serves requests from the first moment it’s alive, even before the Hydrant stream connects or finishes catching up. Having a cold Hydrant itself costs only latency and approximate counts.
The API
Single lookups take a record’s AT-URI.
getRepotakes the repo URI:
curl "$BOBBIN/xrpc/sh.tangled.repo.getRepo?repo=at://did:plc:boltless/sh.tangled.repo/squid"{
"uri": "at://did:plc:boltless/sh.tangled.repo/squid",
"cid": "bafyrei...",
"value": { "$type": "sh.tangled.repo", "knot": "knot1.tangled.sh", "description": "...", "createdAt": "..." }
}getProfiletakes the full profile record URI, so a bare handle or DID will not resolve:
curl "$BOBBIN/xrpc/sh.tangled.actor.getProfile?actor=at://did:plc:boltless/sh.tangled.actor.profile/self"- If Slingshot cannot serve the record, the response is
502:
{ "error": "UpstreamFailed", "message": "upstream unavailable: ..." }Aggregation endpoints come in
list* and count* pairs, each with a
*By sibling, and require a subject
query param.
listReposandcountReposkey on the owner DID:
curl "$BOBBIN/xrpc/sh.tangled.repo.countRepos?subject=did:plc:boltless"{ "count": 7, "distinctAuthors": 1 }curl "$BOBBIN/xrpc/sh.tangled.repo.listRepos?subject=did:plc:boltless&limit=3"{ "items": [ { "uri": "at://did:plc:boltless/sh.tangled.repo/squid", "cid": "bafyrei...", "value": { } } ], "cursor": null }- Bobbin validates the subject per collection. Here a repo
URI is passed where a bare DID is required, so the call
returns a
400:
curl "$BOBBIN/xrpc/sh.tangled.graph.listFollows?subject=at://did:plc:boltless/sh.tangled.repo/squid"{ "error": "InvalidRequest", "message": "invalid request: subject must be a bare did, got at-uri with collection sh.tangled.repo" }Search is a single endpoint over an in-mem full-text index:
curl "$BOBBIN/xrpc/sh.tangled.search.query?q=tangled&limit=2"{ "hits": [ { "uri": "at://...", "cid": "...", "nsid": "sh.tangled.repo", "score": 27.1, "value": { } } ], "cursor": null }Git data such as blob, tree, diff, log, and archive proxies straight to the repo’s knot, streamed back without caching.
Coverage and warm-up
- While the edge index is catching up from Hydrant, the aggregation count is a lower bound & may still climb.
- One endpoint reports how far along the backfill it is:
curl "$BOBBIN/xrpc/sh.tangled.bobbin.getCoverage"While warming up:
{ "ready": false, "eventsProcessed": 45588, "lastCursor": 51658 }Once caught up, Bobbin flips to ready:
{ "ready": true, "eventsProcessed": 106085, "lastCursor": 116527 }If starting up Hydrant for the first time, Hydrant itself
will take a decent while (a couple of hours) to backfill from
PDSes. Hydrant stores its backfill on disk. Bobbin restart
reaches ready in minutes by replaying event from
an already-populated Hydrant. If your Hydrant is new, expect
Bobbin to backfill in that same couple of hours that Hydrant
takes.
Loose ends and not-gonna-impl
- No coverage signal for per-knot rosters yet. Coverage tracks the hydrant stream only. A v1.15 knot that is unreachable serves a stale or empty member set with nothing to flag it.
- Knot eventstream fan-out isn’t pooled. Bobbin opens one websocket per v1.15 knot on top of the hydrant subscription. A network with thousands of knots wants pooling or a shared subscription.
- No sequential issue or PR numbers. bobbin
returns rkeys, not
#42style ids like the web appview. A client deriving a display number does it from creation order. But why bother? rkeys are the IDs.