Indexing pipeline
An indexing run turns a repository into three things: file text in SQLite, parsed entities in SQLite, and one searchable record per entity in Algolia. A run is a background job inside the backend process. It always ends in completed or failed, and it always records a reason when something was left out or went wrong.
This page covers the full path: how a repository gets added, what the run does in order, the limits it enforces, how it reads files from GitHub and from other hosts, and what happens when any step fails.
Adding a repository
Section titled “Adding a repository”Nothing is created until the repository has passed a pre-flight check. The check runs before the database row exists, so a typo in a URL never leaves a half-made repository behind.
flowchart TD
add["POST /api/v1/repositories/"] --> valid{"Valid GitHub URL<br/>and branch?"}
valid -- "no" --> bad["422"]
valid -- "yes" --> known{"Already added?"}
known -- "yes" --> dup["409"]
known -- "no" --> ready{"Algolia set up and<br/>repo and branch on GitHub?"}
ready -- "no" --> refused["422, 502, 503 or 504<br/>nothing created"]
ready -- "yes" --> create["Create the row<br/>status pending"]
create --> created["201 with the repository"]
create --> schedule["Start a background job"]
again["POST /api/v1/repositories/{id}/process"] --> busy{"Job running, or completed<br/>without force_reprocess?"}
busy -- "yes" --> conflict["409"]
busy -- "no" --> schedule
schedule --> processing["Mark it processing<br/>progress back to 0"]
processing --> slot["Wait for a run slot"]
slot --> run["Indexing run"]The dashboard shows every refusal under the repository field and stays on the Home page. The REST endpoint and the dashboard take github.com only. The add_repository tool runs the same checks for other hosts.
| You see | Status | Cause |
|---|---|---|
| A message about the URL or branch | 422 | Not a GitHub repository URL, or an invalid branch name |
| “GitHub repository owner/repo was not found” | 422 | The repository does not exist, or it is private and the server’s GITHUB_TOKEN cannot read it |
| “Branch ‘x’ does not exist”, “owner/repo is empty” or “GitHub denied access” | 422 | The branch is missing, the repository has no content, or GitHub refused access |
| “owner/repo has already been added”, with an “Open it” link | 409 | Same URL as an existing repository. Case does not matter. The body also has repository_id |
| “GitHub’s API rate limit was reached; try again in about N minutes” | 503 | GitHub is rate-limiting the server. Retry-After says how long |
| “GitHub rejected the server’s GITHUB_TOKEN” or “Could not reach GitHub; try again” | 502 | The token is invalid, or GitHub is down or unreachable |
| “GitHub did not respond in time; try again” | 504 | A check request took longer than 10 s |
| “Search indexing is not configured on the server” | 503 | ALGOLIA_APP_ID or ALGOLIA_ADMIN_API_KEY is unset |
The check makes one attempt per call. It has no retries and no rate-limit sleeps, because the caller is an HTTP request that is waiting. A repository that passes can still fail later, during the run. The dashboard then shows the reason on its card.
The indexing run
Section titled “The indexing run”flowchart TD
start(["Run starts"]) --> configured{"Algolia<br/>configured?"}
configured -- "yes" --> fetch["List the repository<br/>GitHub API, or a shallow clone<br/>skip vendored and minified files, lockfiles"]
fetch --> select["Keep files under 1 MB<br/>at most 1000 files and 100 MB"]
select --> download["Download the files"]
download --> clear["Clear the previous index<br/>Algolia records and rows"]
clear --> batch["Next batch of 200 files"]
batch --> parse["Parse each file<br/>a parse failure is counted"]
parse --> store["Store files and entities<br/>a failed batch is rolled back"]
store --> more{"More files?"}
more -- "yes" --> batch
more -- "no" --> index["Index the entities in Algolia"]
index --> skipped{"Anything left out?"}
skipped -- "no" --> completed["completed"]
skipped -- "yes" --> partial["completed<br/>error_message: Partially indexed"]
configured -- "no" --> failed["failed<br/>error_message says why"]
fetch -. "GitHub or clone error" .-> failed
index -. "nothing indexed,<br/>or Algolia rejects it" .-> failedRepository status
Section titled “Repository status”stateDiagram-v2 [*] --> pending: repository added pending --> processing: job starts processing --> completed: indexed processing --> failed: error or crash completed --> processing: reprocess with force_reprocess failed --> processing: reprocess pending --> failed: server restart processing --> failed: server restart completed --> [*]: deleted failed --> [*]: deleted note right of completed: a partial index is noted in error_message
A repository cannot be deleted while its job is running. Delete returns as soon as Algolia accepts the removal. It does not wait for Algolia to finish.
Limits
Section titled “Limits”Every limit below was checked against the code. File selection limits live in FileSelector and are shared by the GitHub source and the git-clone source, so they are identical for every host.
| Limit | Value | Where |
|---|---|---|
| Largest file | Under 1 MB (1,048,576 bytes). A file of exactly 1 MB or more is skipped. The size used is the one the listing reports | FileSelector.max_file_size in github_service.py |
| Files per run | 1000 | FileSelector.max_files |
| Bytes per run | 100 MB (104,857,600), summed from listed sizes | FileSelector.max_total_bytes |
| Contents-API walk | 300 directory requests, breadth-first | MAX_WALK_REQUESTS |
| Concurrent GitHub downloads | 5 | MAX_CONCURRENT_REQUESTS |
| GitHub request timeout during a run | 30 s per request, 10 s to connect | _new_session |
| GitHub request timeout during the add check | 10 s per request | check_repository |
| Retry attempts | 3 attempts per request, waiting 1 s and then 2 s between them | MAX_RETRIES, RETRY_BACKOFF_BASE |
| Rate-limit wait | Once per request, until the reset time plus 1 s, capped at 60 s | MAX_RATE_LIMIT_WAIT |
Remote check on other hosts (git ls-remote) |
10 s | CHECK_TIMEOUT in git_source.py |
Clone on other hosts (git clone --depth 1) |
120 s | CLONE_TIMEOUT |
| Files per database commit | 200 | DB_BATCH_SIZE in repository_service.py |
| Concurrent runs | 2 | MAX_CONCURRENT_RUNS |
| Parsed body per entity | 60 lines and 2000 characters | BODY_MAX_LINES, BODY_MAX_CHARS in code_parser.py |
| Records per Algolia request | 1000 | INDEX_BATCH_SIZE in algolia_service.py |
| Wait for Algolia to publish an index task | 300 s | INDEX_WAIT_SECONDS |
| Wait for Algolia to publish a delete | 120 s | DELETE_WAIT_SECONDS |
| Recorded error message | 500 characters | failure_message in security.py |
Why these numbers:
- 1000 files, 100 MB, 1 MB per file. A run holds every downloaded file in memory at once, so the cap protects the process. Two runs at a time means the worst case is about 200 MB of file text, plus parsed entities.
- Two concurrent runs. The limit bounds memory and keeps GitHub and Algolia calls from piling up. A third run still shows
processingwhile it waits for a slot. - 300 walk requests. The contents walk only runs for repositories too big for one tree call. A token has 5000 API requests an hour, so an unbounded walk could use the whole quota on one repository.
- 200 files per commit. One commit per batch bounds how much work a failure can lose and gives the dashboard a progress update at a steady rhythm.
- 1000 records per Algolia request. This is Algolia’s recommended batch size.
The 11 steps
Section titled “The 11 steps”-
Check and schedule.
POST /repositories/rejects a duplicate URL first (409, matched regardless of case). Then it requires Algolia to be configured (503). Then it checks the remote. For github.com that isGitHubService.check_repository: one GET for the repository and one for its branch. For any other host it isgit ls-remote. A problem maps to a status without creating a row. Then the create endpoint (201) orPOST /repositories/{id}/process(202) starts an asyncio task. The task is refused with409if one already runs for that repository./processsets the repository toprocessingbefore it returns, so a client polling/statusnever reads the oldfailed. -
Mark and wait. The run sets the status to
processing, setsprocessed_filesback to 0 (the previous run’s count would look like progress), clearserror_messageand broadcasts the status.total_filesis not reset, so the first frames can still show the previous run’s total. The run then waits for one of two slots while already showingprocessing. -
Check Algolia. The endpoints already refuse a missing configuration, so this only fails a run when the credentials went away after it was scheduled. The run fails with “Algolia credentials not configured. Set ALGOLIA_APP_ID and ALGOLIA_ADMIN_API_KEY.” A repository without a URL fails with “Repository URL is required for processing”.
-
List. The branch is the repository’s own, or the one in the URL, or the default branch the GitHub API reports. For github.com, the branch is first resolved to a commit (
GET /repos/<owner>/<repo>/branches/<branch>), then one recursive git trees call lists the files at that commit. For other hosts a shallow clone is walked instead, its commit is read withgit rev-parse HEAD, and step 6 does not apply. The commit is stored in the repository’scommit_sha, and source links point at it. -
Select. The deny list, the extension filter and the size filter run first. The survivors are ordered source files first, then test files, then Markdown, then files that yield no entities, each group by path, and kept in that order until the file count or byte cap is reached. The rest count as skipped. See File selection rules.
-
Truncated trees. If GitHub marks the tree as truncated, the contents API is walked instead. See The truncated-tree walk.
-
Download. Files download five at a time from
raw.githubusercontent.com, at the commit from step 4, so a push during the run cannot mix two versions. Other hosts read them from the clone. Empty files and binary files (any file containing a NUL byte) are skipped. One failed download fails the whole run, because skipping it would let a reprocess replace a complete index with a partial one. A run with no files at all fails with “No supported code files found in repository”. -
Clear. Only after a successful fetch does the run apply the index settings and delete the repository’s old Algolia records, then its old rows. This order means a transient GitHub error never wipes a working index. The Algolia delete is awaited for up to 120 s, and if it fails the run fails with “Could not remove previously indexed records from Algolia”. A plain repository delete does not wait for this step.
-
Parse and store. Files are handled in batches of 200. For each file the run parses entities in a worker thread, builds a
CodeFilerow with itsCodeEntityrows, and builds the Algolia records. The batch is committed in a worker thread, thenprocessed_filesis updated and progress is broadcast. -
Index. All records go to Algolia in requests of 1000. The run fails if there are no records (“No code entities were indexed from N files”) or if Algolia rejects a request (“Algolia rejected the indexing request”).
-
Finish. The run sets the primary language and the framework, adds a
Partially indexed:note if something was left out, setscompletedandprocessed_at, and broadcasts the final frame. See How language and framework are detected.
Fetching by host
Section titled “Fetching by host”The service picks the source from the URL’s host. GitHubService and GitSource both inherit from one FileSelector, so they share the deny list, the extension map and the size and count limits.
| Host | Check before the row exists | Fetch during the run |
|---|---|---|
| github.com | REST API (check_repository). Private repositories are readable when GITHUB_TOKEN has access |
Branches API to resolve the commit, trees API to list, then raw.githubusercontent.com to download, all at that commit |
| Any other host | The host name must resolve to public addresses, then git ls-remote --heads --symref, 10 s |
git clone --depth 1 --single-branch --no-tags, 120 s, into a temporary directory that is always removed |
GitHub
Section titled “GitHub”- The token, when set, is sent as
Authorization: token .... Without a token, GitHub’s low unauthenticated rate limit applies and private repositories cannot be read. - The listing keeps only entries of type
blobwhose mode is not120000. Mode120000is a symlink, so symlinks are never listed. - The run starts with
GET /repos/<owner>/<repo>/branches/<branch>, which gives the commit SHA. The listing and every download use that SHA, not the branch name. - A 404 on the branch lookup triggers one more request for the repository itself, to tell a missing repository from a missing branch. The error says which: “Branch ‘x’ not found in owner/repo”.
- Download retries cover timeouts, connection errors,
429and5xx. A404or403on a file fails immediately. - A rate-limited
403or429(withX-RateLimit-Remaining: 0) makes the API call wait for the reset time, capped at 60 s, and retry once.
Other hosts
Section titled “Other hosts”- Public only. Git cannot ask for credentials, so a repository that answers
401,403or404is reported as “not found or is private; only public repositories are supported on the host”. That is422at the check and404during a run. - Public addresses only. Before git runs, the host name is resolved. If it doesn’t resolve, or any address is not public (private, loopback, link-local and so on), the repository is refused with
422: “Can’t index host/path: host resolves to a private or loopback address.” Git is then pinned to the checked address with-c http.curloptResolve=<host>:443:<ip>(IPv4 when there is one), so it never resolves the name again. - No shell, no config. Git runs through
create_subprocess_execwith an emptyHOME, no user or system config, no credential helper,GIT_TERMINAL_PROMPT=0, and only https allowed. It is killed on timeout. - Symlinks are skipped. A clone keeps symlinks as real links, so the tree walk checks every entry. It never follows a link and never lists one, and it does not descend into denied directories or
.git. - What is passed through. Only
PATH,SSL_CERT_FILE,SSL_CERT_DIR,GIT_SSL_CAINFO,HTTPS_PROXY,https_proxyandNO_PROXYreach git from your environment. - Commit. After the clone,
git rev-parse HEADrecords the commit that was read. - Errors.
504for a timeout,503if git cannot start,404for a missing branch or repository,422for a host that resolves to a non-public address,502for any other clone failure. - Names. Other hosts are named
<host>/<path>. The REST endpoint takes github.com only. Theadd_repositorytool takes any host through the same service function. - Image. Both backend images install
git.
The security side of this (URL validation, sandboxing) is in the security model. The user-facing guide is Other git hosts.
File selection rules
Section titled “File selection rules”FileSelector._select_candidates applies these filters in order to every listed (path, size) pair:
- Denied directories. A file is dropped if any parent folder in its path is named
node_modules,dist,build,vendor,third_party,.git,__pycache__,.nextorcoverage. Matching is by whole path segment, so a folder calleddistributionis kept. - Denied file names, matched case-insensitively:
*.min.js,*.min.css,*.bundle.js,*.map,package-lock.json,yarn.lock,pnpm-lock.yaml,poetry.lockandcargo.lock. - Unknown extensions. A file is indexed only if its extension is in the map below. Anything else maps to
textand is dropped. - Size. A file whose listed size is 1 MB or more is dropped.
- Sort and cap. The rest are sorted into four groups: source files (languages the parser extracts entities from), then source files on a test path, then Markdown, then files that yield no entities (JSON, YAML, CSS, HTML, XML, SQL and so on). Each group is sorted by path. Files are kept in that order until 1000 files or 100 MB. The loop stops at the first file that would not fit. Everything after it counts as skipped.
| Extension | Language |
|---|---|
py |
python |
js, jsx |
javascript |
ts, tsx |
typescript |
java |
java |
go |
go |
rs |
rust |
cpp |
cpp |
c |
c |
cs |
csharp |
php |
php |
rb |
ruby |
html |
html |
css |
css |
scss |
scss |
sql |
sql |
json |
json |
yaml, yml |
yaml |
xml |
xml |
md |
markdown |
tf, tfvars |
terraform |
hcl |
hcl |
Three consequences are worth knowing:
- C and C++ headers are not indexed. The map has
candcpponly. Files ending in.h,.hppor.ccmap totextand are dropped. - The order decides who is dropped. When a repository is over the cap, files that yield no entities go first, then Markdown, then tests, and source files last. Within the group being cut, the files that sort last by path are left out. A repository with a large
docs/orexamples/folder keeps itssrc/. A test file is one under atest,tests,__tests__,spec,specs,__mocks__,e2eortestingfolder, or one named like a test (see Search). - Only cap overflow is reported. Files removed by the deny list, the extension filter or the size filter are dropped silently. Only files dropped because of the 1000-file or 100 MB cap are counted in the
Partially indexednote.
The truncated-tree walk
Section titled “The truncated-tree walk”GitHub’s trees API returns at most a fixed number of entries per call and sets truncated: true when a repository is bigger. When that happens:
- The run logs a warning and starts a breadth-first walk of the contents API from the repository root, one request per directory.
- Directories whose path contains a denied folder are pruned and never requested.
- After 300 requests the walk stops and flags the listing as truncated.
- The result is merged with the entries the truncated tree did return. If both list the same path, the walk’s size wins.
Breadth-first order is deliberate. When the 300 requests run out, it is the deepest directories that go unlisted, not whole top-level folders that happen to come late in the order. Merging with the truncated tree means a walk cut short never indexes fewer files than the tree alone would have.
When the cap is hit, the run still completes. The final message says “the repository is too large to list in full, so some directories were not indexed”.
Parsing
Section titled “Parsing”Each file is parsed in a worker thread so the event loop stays free. The parser is chosen by language:
| Language | Parser | Entities |
|---|---|---|
| python | Python ast. If ast cannot parse the file (Python 2 code, templates, very deep nesting), a line scanner takes over |
function (methods count as functions), class, import |
| javascript, typescript, java, go, rust, cpp, c, csharp, php, ruby | tree-sitter, driven by a LanguageSpec per language |
function, class, import |
| markdown | ATX heading scanner that ignores fenced code | doc_section, one per heading with content |
| terraform, hcl | tree-sitter HCL grammar | terraform_block, one per top-level block |
| css, html, scss, sql, json, yaml, xml | none | none |
A file that parses to nothing is still stored. See Supported languages for details per language, and Development to add one.
How language and framework are detected
Section titled “How language and framework are detected”Per file. The language comes from the file extension (the map above).
Primary language of the repository. The run counts files per language, ignoring text, markdown, json, yaml, xml, css, scss and html. The most common language wins. A tie goes to the language that appears first in selection order. Terraform, HCL and SQL do count as programming languages. If no programming file exists, the field keeps its earlier value.
Framework. Detection is best effort and uses two sources:
package.jsonfiles. Every file namedpackage.json, at any depth, is parsed.dependenciesanddevDependenciesare merged. The first match in this order wins:nextgivesnextjs,@angular/coregivesangular,vuegivesvue,sveltegivessvelte,reactgivesreact,expressgivesexpress. A file that is not a JSON object is skipped.- Python imports. If no
package.jsonmatched, the text of all Python files is searched forimport djangoorfrom django, thenfastapi, thenflask. This is a substring search, so a comment containing the text also matches.
If neither finds anything, the framework stays empty. Because the search order follows path order and node_modules is excluded, a vendored package.json never decides the result.
Failure handling
Section titled “Failure handling”| Event | What the code does | What you see |
|---|---|---|
| Parse failure | The file is still stored, without entities, and counted as unparsed | Partially indexed: N files could not be parsed |
| Store failure | A failed batch commit is rolled back, its files are counted as failed, its records are not indexed, and the run continues with the next batch | Partially indexed: N of M files could not be stored |
| Run failure | Any other exception rolls back and sets failed with a message of at most 500 characters, then broadcasts it |
Red card with the reason |
| Partial index | The run still ends completed and error_message starts with Partially indexed: |
Amber card |
| Job crash | If the task itself dies, the row is set to failed so it does not stay processing |
“Processing stopped unexpectedly; reprocess the repository” |
| Server restart | At start-up, every repository left pending or processing is set to failed |
“Processing was interrupted by a server restart; reprocess the repository” |
| Delete during a run | Refused | 409 |
How a failure message is written (failure_message in security.py):
- A SQLAlchemy error becomes the fixed text “Database error while storing the repository”. The real text carries SQL and file contents, so it goes to the log only.
- An error marked
user_facing(GitHub errors and ingestion errors) is recorded as its own sentence, such as the rate-limit message with the wait. - Anything else is recorded as
ClassName: message. - All messages are cut to 500 characters.
If a reprocess had already cleared the old index before it failed, the message ends with “ The previous index was removed; reprocess to rebuild it.” That tells you the repository has nothing searchable until it is reprocessed.
A Partially indexed note joins one or more of these causes with semicolons:
N of M files could not be storedN files could not be parsedN files over the 1000-file or 100 MB limit were skippedthe repository is too large to list in full, so some directories were not indexed
The Algolia wait is forgiving in one place. If Algolia accepted the records but a task is not published after 300 s, the run logs a warning and carries on, because the records will become searchable shortly. A timeout on the delete before a reprocess is not forgiven: it fails the run, to avoid leaving stale records.
Nothing retries a failed run by itself. Fix the cause (set GITHUB_TOKEN, repair the Algolia credentials, wait out a rate limit), then reprocess.
Delete and reprocess
Section titled “Delete and reprocess”- Delete. A delete holds the repository’s job slot while it works, so no run can start in the middle. It sends
delete_byto Algolia and does not poll. That is safe because Algolia applies an index’s tasks in order, so records indexed later under a reused id still land after the delete. If Algolia refuses the request, nothing is deleted and the API returns502. - Delete order. The service deletes entities, then files, with bulk queries, then the repository row. See the data model.
- Reprocess. A reprocess waits for the Algolia delete to finish before it indexes, so the delete cannot land after the new records.
- Editing.
PUT /repositories/{id}returns409if it would changebranch,languageorframeworkwhile a job or a delete holds the repository.
Progress broadcasting
Section titled “Progress broadcasting”The run pushes a repository_status frame to every WebSocket client subscribed to that repository:
| When | Status | processed_files and total_files |
|---|---|---|
| Run starts | processing |
processed_files is 0. total_files is the previous value until the first batch |
| After each batch of 200 files that stores successfully | processing |
Files stored so far, and the count of downloaded files |
| Run ends well | completed |
Final counts. message is the partial-index note, or “Processing completed successfully” |
| Run fails | failed |
error has the reason, message is “Processing failed” |
A batch that fails to store sends no frame of its own. Each send has a 5-second timeout, and a client that stalls is dropped, so a stuck browser tab cannot slow a run down. The dashboard also polls the repository while it is in progress, every 3 seconds, or every 15 seconds while the WebSocket is connected, so a missed frame never leaves the page stale. The frame format is in the WebSocket reference.
Next: how the records are searched, or the tables the run writes.