Architecture
One binary, several roles
Everything server-side is one executable, sourcegull. A process runs one or more roles:
sourcegull run --roles=web,worker # or SOURCEGULL_ROLES=web,worker; `all` runs every role
| Role | Does | State |
|---|---|---|
web |
The UI, GraphQL (/.api/graphql), streaming search, MCP and other HTTP APIs, sign-in, the executor queue API; fans searches out to the repo nodes. |
stateless; scale horizontally |
repo |
A repo node: bare git repositories, index shards, node-side search, symbols, diffs, blame, archives, commits for bulk changes. Repositories are partitioned across nodes. | data volume per node |
worker |
Background jobs (pgqueue workers) and leader-elected schedulers: discovery, fetch scheduling, permission sync, SCIP processing, auto-indexing, bulk change reconciliation, trends, watches, search jobs, analytics rollups, out-of-band migrations. | stateless; scale horizontally |
blob |
Blob storage over HTTP for SCIP uploads, code graph files and search job results. Not needed with an S3 bucket. | data volume |
Other subcommands of the same binary:
sourcegull executor: takes jobs from the web role and runs them in containers (Docker) or as Kubernetes Jobs. See Executors.sourcegull migrate: applies, reverts, checks and upgrades the database schema (configuration).sourcegull supervise: creates a key file if missing, applies migrations and runs every role in one process against the database atSOURCEGULL_DATABASE_URL.sourcegull keys status|rotate: data encryption keys.sourcegull users reset-password <username>: prints a password reset link (permissions).sourcegull monitoring generate: writes the Grafana dashboards, Prometheus rules and alert docs from their definitions incrates/monitoring.
The gull binary is the command-line client (SCIP uploads, bulk changes); it ships in the
same image.
Every process listens on :6060 (internal: /metrics, /healthz, /readyz, and the blob API
in a process with the blob role). The web role also listens on :7080 (public: UI and
APIs), and repo nodes listen for gRPC on :3178.
PostgreSQL is the only dependency
PostgreSQL (16 or newer) holds all durable state and also does the jobs often given to other services:
- Job queues (
pgqueue): one table per queue, dequeued withFOR UPDATE SKIP LOCKED, with heartbeats, retries with backoff and a resetter for jobs whose worker died. - Notifications (
LISTEN/NOTIFY) wake up workers and propagate site configuration, permission changes and the repo routing table. Every subscriber polls as a fallback, since notifications are not durable. - Leader election: advisory locks, or Kubernetes
Leaseobjects (SOURCEGULL_LEADER_BACKEND=kubernetes). - Sessions, caches and rate limits: code host request budgets are shared by every process through Postgres, and a code host's 429 pauses all of them.
Caches, sessions, rate limits and pub/sub are behind traits with in-process and Postgres implementations, so another backend can be added without touching their callers.
Secrets are encrypted at rest with envelope encryption: data keys per record class, wrapped by key-encryption keys from a file (encryption keys).
gRPC between roles
Roles talk to repo nodes over gRPC, defined in proto/repo.proto:
RepoService: clone, fetch, status, refs, files and trees, commits, logs, diffs, blame, archives, merge bases,CopyRepo(for rebalancing), and the write operations bulk changes need (CreateCommitFromPatch,ApplyPatch,Push).SearchService: unindexed search of a commit, commit and diff search, indexed search, index status and reindexing, trend series counts and language statistics.
The protos are compiled at build time (no protoc needed). Executors talk to the web role over
HTTP (/.executors/*), authenticated with the shared executor token.
Repo nodes and rebalancing
Each repository belongs to one repo node, chosen by rendezvous hashing of its name over the
node list in SOURCEGULL_REPO_NODES. Every process computes the same assignment, so web and
worker route requests without a lookup service. The assignment is stored per repository and
published with NOTIFY when it changes.
When nodes are added or removed, the repositories whose owner changes are moved without downtime:
- Every node sends heartbeats with its node list. Once every configured node is alive with the same list, the first live node plans a move for each misplaced repository.
- The new owner copies the repository from the old one with
CopyRepoin rounds (a ref snapshot plus a pack of what it lacks, checksummed, with connectivity and refs verified) until a round brings nothing new, then builds its index. - The flip: the repository's node changes in one transaction, routing tables reload that repository (the notification names it), and a catch-up fetch is queued.
- The old owner keeps serving during a grace period, then deletes its copy.
Writes check the assignment: a node clones, fetches, commits or pushes (gRPC) only repositories
assigned to it (or to none yet), and refuses the others with FAILED_PRECONDITION
(not_assigned). A fetch or index build that ends after the flip records nothing, so the new
owner's state is never overwritten; a first clone that ends after it is removed.
Repositories of a node that is gone are cloned again from the code host. Failed moves are retried with
backoff; Site admin → Repo nodes shows disk usage per node and the moves, and can retry
failed ones. repoNodes.pinnedRepos in the site configuration pins a
repository (for example a very large monorepo) to a node.
Repo nodes protect their disk: clones pause and caches of inactive repositories are evicted below configurable free-space thresholds (configuration).
The search index
Sourcegull has its own index format (crates/index-format), built by crates/indexer on the repo
nodes and queried by crates/search-exec. The rest of the system reaches it only through the
SearchIndex trait (crates/search/src/index.rs).
Shards. A shard file holds one repository's indexed branches: by default the default branch,
plus those listed in search.index.branches and those selected by
search.index.recentBranches, up to 64 in all. Recent branches are stored sorted by name, because
a delta build needs the same branches in the same order; a branch added or dropped means a full
build. A file version shared by several branches is stored once, with a bitmask of the branches that contain it. A shard has these sections,
each with its own CRC-32:
- the repository and its branches with their commits,
- a doc table (path, language, size, branch mask, flags) sorted by path, and front-coded paths,
- the content (LZ4-compressed) and newline offsets,
- a trigram index: an FST mapping each case-folded trigram to a posting list of doc ids (delta varints, or a Roaring bitmap when smaller),
- the symbols defined in each file (extracted with tree-sitter while indexing),
- optional static file ranks from the code graph.
Shards are read through mmap and every checksum and reference is checked when a shard opens, so
a corrupt file is an error, never a crash. Binary files and files over 1 MiB are kept as docs
(path search finds them) without content; files matched by search.index.alwaysIndex are read
up to 16 MiB. A change to that rule rebuilds the affected shards at the next reconcile pass.
Delta shards. After a push, a node does not rebuild the whole repository. A delta build
compares the branch trees with the base shard path by path: each changed path is written to a
delta shard with all its current versions, and its docs in the base are tombstoned. A
repository has a base shard and at most one delta; the next full build folds them together.
Pushes that arrive within the debounce window (SOURCEGULL_REPO_INDEX_DEBOUNCE_MS) are coalesced
into one build, and the last push is always indexed.
Compound shards. Small repositories are packed together into compound shards, merged from their existing shards without git access. Compound shards that become small or mostly obsolete are repacked. New files are swapped in before old ones are deleted, so a crash at any step leaves a consistent index.
Query evaluation. For each shard, the pattern's trigram query selects candidate docs (rarest
posting lists first); branch, file: and lang: filters run on the doc table; then candidates
are verified with the same matcher that unindexed search uses, in parallel batches. Symbol
search uses the same candidates and the symbols section, without reading content. Results are
scored (ranking) with the static file rank as a tie-breaker.
Unindexed revisions. A revision the index does not hold (another branch, a tag, a commit,
rev:at.time()) is searched directly from git: the repo node walks the commit's tree, applies
path filters before reading blobs, skips binary and oversized files, and searches blobs in
parallel. Both paths return the same matches. index:no forces it, index:only excludes it,
and SOURCEGULL_REPO_DISABLE_INDEX turns the index off on a node.
Commit and diff search (type:commit, type:diff) read a per-repository commit log maintained
on the repo node.
Executors
Executors run untrusted work (indexers, bulk change steps) outside the server processes. They
poll the web role's queues (codeintel, batches), send heartbeats, and report each job as
completed, failed or errored (errored jobs are retried). The Docker runtime runs each step as a
container with optional memory, CPU and network limits; the Kubernetes runtime runs each job as
a Kubernetes Job with configurable namespace, runtime class, node selector, tolerations and
resources. Executor secrets (site- or namespace-level) are injected as environment variables.
Crate map
| Area | Crates |
|---|---|
| Binary and client | sourcegull (server: roles, run, migrate, supervise, executor, keys; the feature list in src/features.rs), repo-node (the repo role: cloning, indexing, janitor, rebalancing), cli (gull) |
| Foundations | types, config (site configuration, settings, the resources file format), declarative (applies the configuration-as-code files), db (schema, migrations, core stores), feature (the Feature trait: a feature's routes, workers and singletons), pgqueue, pubsub, leader, ratelimit, crypto, oobmigration, observability, monitoring (dashboards and alerts over the metric names in types::metric_names), testutil |
| Identity and APIs | authn (accounts, sessions, tokens, RBAC), authz (repository permissions), services (shared state without HTTP: the git reader, email, relay IDs, search backends, compute, server-side bulk change execution), graphql (schema assembly and the core types: users, organizations, repositories, git, search, site, settings, permissions), graphql-core (context, pagination, node IDs, the Node interface and node loader registry, references to shared types, the GraphqlFeature trait), one <feature>-graphql crate per feature (insights, monitors, own, codeintel, batches, notebooks, searchjobs, saved-searches, outbound-webhooks, analytics, executors), http-api (HTTP server: routing, middleware, streaming endpoints, SCIM, webhooks), http-extract (caller extractors and error responses for crates that add routes), mcp, oauth-server (OAuth sign-in of MCP clients), web-assets (embedded UI) |
| Code hosts and git | codehost (traits and the shared REST client: retries, rate limits, pagination, the same-origin credential guard), codehost-gitlab, codehost-github, codehost-gitea, repos (discovery, webhooks), gitstore (bare repositories on repo nodes), rpc (gRPC and routing), languages, highlight |
| Search | query (parser), query-wasm (parser for the UI), search (planning, the pattern matcher, ranking, streaming types), search-web (web-side search execution, with code ownership passed in), search-client (fan-out to the repo nodes, repository predicates, wire forms), search-exec (node-side engines), index-format, indexer, structural, symbols, searchjobs |
| Code navigation | codenav, codeintel-scip, codeintel-syntactic, codeintel-policies, autoindex, ranking |
| Executors and blobs | executor, executor-api, blobstore |
| Bulk changes | batches (specs), batches-template, batches-resolver, batches-steps, batches-reconciler |
| Other features | insights, monitors, notebooks, own, compute, analytics, outbound-webhooks |
Permission rules live in the feature crates, in an access module that takes the actor and
returns authn::AccessError (insights, monitors, notebooks, own for teams, searchjobs;
bulk changes, executor secrets, settings, saved searches and search contexts in services).
GraphQL, MCP and the HTTP routes call the same functions and only map the error. The namespace
rule (who may act for a user or an organization) and role permissions are in authn::access.
Repository rows are read through the db stores with the viewer's RepoAccess;
scripts/check-repo-sql.py rejects raw repo reads elsewhere.
db holds the pool, the migrations (one directory) and the core stores: repositories, users,
organizations, code host connections, settings, sessions and tokens, the OAuth server's tokens
(checked by authn) and SCIM provisioning (it writes the user tables). Feature stores live in
their crates (batches behind its store feature, outbound-webhooks, codeintel-scip,
codeintel-policies, autoindex, ranking, insights, own, monitors, searchjobs,
notebooks, analytics).
A feature implements feature::Feature in its crate (routes, workers, singletons) and
graphql_core::GraphqlFeature in its <feature>-graphql crate (query and mutation objects, node
types and loaders), and is added to crates/sourcegull/src/features.rs (all() and the Graphql
tuple); see "GraphQL by domain" in development.md. A singleton runs under a lease of its name
(leader); each time a process takes the lease it advances the lease's epoch in
leader_epochs and hands the task a Fence, and the writes a leader that lost its lease must not
make (out-of-band migration batches and progress, the ranker's ranks, analytics rollups, the
watch evaluator's trigger jobs and cursor, insights backfill state, the changeset syncer and
rollout scheduler, team sync) commit only while that epoch is current. Watch actions other than email are queued on
the outbound webhook queue and delivered by its sender (request guard, retries, delivery log).
Outside crates/: proto/ (gRPC), ui/ (SvelteKit web app, browser checks in ui/e2e/),
deploy/ (images, Compose, Helm chart, GitLab CI template) and scripts/ (development,
tests, benchmarks).