Skip to content

Add incremental (ref-addressed) branch indexing (v3.0.0) - #2

Merged
davemoore- merged 30 commits into
elastic:mainfrom
simianhacker:incremental-indexing
Aug 25, 2026
Merged

Add incremental (ref-addressed) branch indexing (v3.0.0)#2
davemoore- merged 30 commits into
elastic:mainfrom
simianhacker:incremental-indexing

Conversation

@simianhacker

@simianhacker simianhacker commented Aug 15, 2026

Copy link
Copy Markdown
Member

What

Adds delta (ref-addressed) branch indexing as an alternative to Sourcerer's existing
commit-addressed snapshot indexing, so a fast-moving branch (e.g. a GitOps/IaC repo that deploys
off main) can stay current with a cheap delta update instead of a full re-index on every HEAD
advance.

Set mode: delta on a branch source in sourcerer.yml to opt in; mode: snapshot (the
default) is unchanged. Delta content is stored per-ref (carries git.ref, no git.commit); a
HEAD advance diffs old tip vs. new tip and only deletes/reindexes the changed paths. Snapshot
content is stored per-commit as before (carries git.commit, no git.ref).

Every Agent Builder content tool gains two new optional filters — git_ref and git_ref_type
alongside the existing git_commit. All three are wildcard filters resolved against the small
sourcerer-refs index first, so one query shape serves both content shapes without the caller
knowing which mode a source uses.

This is a big enough change to the index schema to ship as v3.0.0: backing indices move from
sourcerer-v2-* to sourcerer-v3-*. See Breaking Changes below.

How to Verify

  1. Add a branch source with mode: delta to a sourcerer.yml and run
    sourcerer index --config sourcerer.yml. Confirm the indexed content carries git.ref and
    no git.commit, and that a single refs join doc (keyed by host~org~repo~ref) holds the
    citable git.commit for that ref.
  2. Re-run the same command — it should report the ref as already up to date and make no content
    changes (sourcerer-files/sourcerer-lines doc counts unchanged).
  3. Push a commit to the tracked branch and re-run — only the changed files should be
    deleted/reindexed (visible via the reported files/lines counts).
  4. Run sourcerer setup against a fresh cluster (or one only holding sourcerer-v2-* data), then
    sourcerer index github/<org>/<repo> -t <tag> — confirm it creates and populates the new
    sourcerer-v3-* indices from scratch (a v2->v3 index rename has no automatic migration).
  5. Run sourcerer setup to push the updated tool definitions, then call
    sourcerer.code.search/sourcerer.files.cat/etc. filtering by git_commit against a
    snapshot source and by git_ref (e.g. main) against a delta source — both return rows with
    a citable git.commit.

Query Change Example

Content docs come in two disjoint shapes: snapshot rows carry git.commit (no git.ref); delta
rows carry git.ref (no git.commit). Each tool takes wildcard filters git_commit, git_ref,
and git_ref_type, resolves them against the small sourcerer-refs index, then selects matching
rows in the large content index:

FROM sourcerer-lines
| WHERE <scope>
    AND (
      -- snapshot rows: keep those whose commit the refs index matched
      (git.commit IS NOT NULL AND git.commit IN (<matching commits from sourcerer-refs>))
      OR
      -- delta rows: keep those whose ref the refs index matched
      (git.ref IS NOT NULL AND git.commit IS NULL AND git.ref IN (<matching refs from sourcerer-refs>))
    )
| FORK
    ( WHERE git.commit IS NOT NULL )                       -- snapshot: commit already present
    ( WHERE git.ref IS NOT NULL AND git.commit IS NULL     -- delta: attach the commit
        | LOOKUP JOIN sourcerer-refs ON git.host, git.org, git.repo, git.ref )
  • Filter by commit (git_commit = "a1b2c3d..."): matches snapshot rows, exactly like before — no
    join needed, the commit is already on the row.
  • Filter by ref (git_ref = "main"): matches delta rows, and the LOOKUP JOIN attaches that
    ref's current commit so every result is still citable to an exact commit.

Existing commit-filter usage is unchanged; ref-filtering is the new capability. There is no
synthetic join key — delta rows join back to their refs doc on (host, org, repo, ref) directly.

Breaking Changes

Bumps to v3.0.0:

  • Indices: backing indices rename sourcerer-v2-* to sourcerer-v3-*. No automatic
    migration — run sourcerer setup to create the v3 templates, then re-index every source. The
    old sourcerer-v2-* indices can be deleted once you've re-indexed.
  • Agent Builder tools (additive): content tools (sourcerer.code.search, sourcerer.code.grep,
    sourcerer.files.*) gain two new optional wildcard filters, git_ref and git_ref_type;
    git_commit is unchanged, so existing calls keep working. Re-run sourcerer setup to push the
    updated tool definitions.
  • Config (additive): a branch source may set mode: delta; omitting mode keeps the
    historical snapshot behavior, so existing sourcerer.yml files work unmodified.

…dexing

Adds a `update: <mode>` source knob (`snapshot` default, `incremental` branch-only) so a
fast-moving branch that deploys off `main` can stay current with delta updates instead of a
full commit-addressed re-index on every HEAD advance.

- New `git.ref_key` field on every content doc: the bare commit SHA for snapshot content, or
  `{host}~{org}~{repo}~{ref}` for incremental content (no `git.commit` of its own).
- A new refs "join doc" kind, `_id = git.ref_key`, carrying the citable commit for both modes
  (snapshot: one per commit; incremental: one per branch, holding the live HEAD).
- Delta indexer for incremental branches: `git diff --name-status` between the previously
  completed commit and the new tip drives which paths are deleted/reindexed; a missing diff
  base (force-push, GC'd, first index) triggers a full branch-namespace rebuild.
- Two-phase incremental publication (`indexing` -> `ready`/`failed`) so a crash mid-update
  leaves the prior commit and content in place.
- Every Agent Builder content tool now runs one universal query shape --
  `WHERE git.ref_key == ?git_ref_key | LOOKUP JOIN sourcerer-refs ON git.ref_key` -- replacing
  the old `git_commit` wildcard param with a required, exact `git_ref_key`.
- One-time, default-on upgrade backfill (`--no-backfill` to opt out): stamps `git.ref_key`/
  `update_mode` onto pre-existing snapshot content, migrates the files/lines/refs index mappings
  in place, and creates the missing per-commit join docs.
- A post-index uniqueness gate verifies every content `git.ref_key` resolves to exactly one refs
  join doc, exiting non-zero on any violation.
- Docs (`README.md`, `AGENTS.md`, `sourcerer.example.yml`) and the ref-resolution skill updated
  for the new `update:` knob and the universal join query.

Verified end-to-end against a local-dev cluster: a pre-upgrade snapshot baseline
(`elastic/sourcerer@v2.5.0`) upgrades in place, a new incremental source
(`elastic/serverless-gitops@main`) indexes ref-addressed and is idempotent on re-run, the
universal join query resolves a commit for both modes, and the uniqueness gate exits 0 for both.
- `sourcerer.refs.list`'s default `status: "complete"` filter silently omitted every
  `update: incremental` branch, whose join doc status is `"ready"`/`"indexing"`, never
  `"complete"`. The default (no-arg) call now also matches `status == "ready"`.
- Reject `update: incremental` combined with `index.level: commit` at config-parse time.
  Incremental content carries no `git.commit`, so a commit-level index name (which requires
  one) could never be built, previously failing at runtime on every indexing attempt instead
  of being rejected up front.
git.ref_key is a storage/join implementation detail (how a content doc finds its refs join doc
via LOOKUP JOIN) -- it never needed to be something an agent constructs or passes as a query
param. Replace the `git_ref_key` param on all 9 Agent Builder content tools with `git_ref`:
an exact commit SHA or branch/tag name, matched via `(git.commit == ?git_ref OR git.ref ==
?git_ref)` before the LOOKUP JOIN, which still resolves `git.ref_key` internally to attach the
citable commit. `sourcerer.refs.list` no longer surfaces `git.ref_key` in its output either.

This restores the pre-incremental mental model for an agent: resolve a ref via `refs.list`,
then pass the resolved value straight through to a content tool -- no new field to learn, no
construction, no per-mode branching in the agent's own reasoning. Docs (AGENTS.md, README.md,
ref-resolution SKILL.md) and tests updated accordingly.
This PR's incremental (ref-addressed) branch indexing is a big enough addition to the index
schema (new update:incremental mode, ref_key join field, incremental refs join docs) to warrant
its own index generation rather than folding it silently into v2. Rename every backing index
constant, template file, and doc reference from sourcerer-v2-* to sourcerer-v3-*:

- src/sourcerer/indices.py: FILES_INDEX_PREFIX, LINES_INDEX_PREFIX, REFS_INDEX now sourcerer-v3-*.
  Read aliases (sourcerer-files/-lines/-refs) are unaffected.
- Index templates renamed sourcerer-v2-{files,lines,refs}.json -> sourcerer-v3-{...}.json, with
  matching index_patterns.
- Doc/comment references across planner.py, queries.py, markers.py, schedule.py, prune/*, tests,
  AGENTS.md, README.md, specs/sourcerer-yml.md, sourcerer.example.yml updated to v3.
- Added an "Upgrading from v2 to v3" section to AGENTS.md alongside the existing v1->v2 section,
  covering the index rename and the git_ref Agent Builder tool param.

Existing sourcerer-v2-* installations require running `sourcerer setup` (to create the v3
templates) followed by a full re-index; the old v2-* indices can be deleted afterward. No config
schema change.
@simianhacker simianhacker changed the title Add incremental (ref-addressed) branch indexing Add incremental (ref-addressed) branch indexing (v3.0.0) Aug 19, 2026
@davemoore-

Copy link
Copy Markdown
Collaborator

Thanks @simianhacker, acknowledging receipt of this PR. I've been testing it pretty heavily over the last couple days. Some minor fixes, possibly some semantic changes we can review, but most importantly, I'm almost certain I can adjust it to avoid introducing a ref_key on all the files and lines. I'll keep working on that and scale testing it, and will share the changes here. Hoping to have it ready in the next day or two.

…ent breaking changes when new fields are added to the mappings
…with snapshot indexing. Show the number of files and lines actually indexed in the progress bar for incremental indexing.
… the refs join doc write so it's visible before the INV-011 gate runs
…t_ish param (resolved via a sourcerer-refs subquery) in the agent tools, and guard content reads with post-join status=='complete'
…n (serendipitously improving search speed, too). Make incremental indexing compatible with sources[i].index.level and sources[i].index.suffix.
…ng, delete old copy, extend prune with Class D-I backstop
…ode, which better describes the behaviors of both 'snapshot' and 'incremental' since snapshots generally aren't updated.
…ase on index_level, index_suffix, and index_strategy in refs index.
@davemoore-

davemoore- commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

@simianhacker I've added my contributions to this PR for review.

Good news: I was able to remove ref_key (benefits are described in more detail below) and prevent double ref markers for snapshot-indexed sources. I still consider this PR to be breaking change that warrants v3 due to the introduction of the ref field in the files and lines indices.

More details on the notable changes:

Semantic changes – worth understanding before reviewing changes:

  • Renamed update_mode to index_strategy (and in sourcerer.yml: sources[i].index.strategy) which better captures the overarching behaviors of "snapshot" and "incremental" since snapshots generally aren't updated.
    • I actually prefer index_mode, but that's overloaded with Elasticsearch's index.mode setting. A "strategy" feels like two or more approaches that converge on the same outcome, and that's not really what this setting is doing. So I'm open to exploring this name a little longer. Maybe I'm overthinking the collision with Elasticsearch's index.mode. If it's never likely to cause confusion, then I think index_mode / sources[i].index.mode with the values of "snapshot" and "incremental" is most natural. @simianhacker thoughts?

Significant changes – worth reviewing and understanding in depth:

  • Removed ref_key and instead modified the queries to use subqueries, FORKs, and JOINs to resolve git.commit, git.ref, and git.ref_type regardless of whether the files or lines were indexed as a snapshot or incrementally (see example of new query syntax).
    • Benefits:
      • Files and lines no longer carry duplicate information in a ref_key, keeping space and indexing throughput optimal.
      • sourcerer-v3-refs no longer needs nor uses two ref markers per snapshot-indexed ref.
      • Agents no longer have to think about a non-standard ref_key syntax when scoping searches.
      • Wildcards ("*") are still allowed on all git_* scoping params.
      • Coincidentally, the queries are faster now by roughly an order of magnitude, despite having more complex syntax.
    • Trade-offs:
      • Requires Elastic v9.5.0+ to use the IN ( FROM ... ) subqueries, and bets that this subquery syntax eventually will be promoted from tech-preview to GA.

Smaller changes:

  • Incrementally-indexed sources now support sources[i].index.level and sources[i].index.suffix.
  • Queries filter by status == "complete" to prevent reading from incomplete, partially indexed refs. This makes incrementally-indexed sources briefly unreadable while they're being updated (which should be a couple seconds in most cases), which I judge to be a small price to pay for consistency.
  • Set the status field in sourcerer-v3-refs to "complete" instead of "ready" for consistency with snapshot indexing.
  • Removed the unused ref_type and update_mode field from sourcerer-v3-files and sourcerer-v3-lines to save space. The same information is kept in sourcerer-v3-refs and used at query time with efficient lookups.
  • Updated the CLI output to show the number of files and lines indexed during incremental indexing, rather than the total number of files and lines that exist in the repo.
  • Fixed a false fail message in the CLI output when indexing snapshots.

@davemoore-

Copy link
Copy Markdown
Collaborator

One more semantic change:

  • Renamed index_strategy and sources[i].index.strategy (originally update_mode in your PR) to simply mode and sources[i].mode, keeping the values "snapshot" and "incremental". As a top-level field of a source, it implies that the overall handling of a source is modal, and can take the form of either lifecycled commit snapshots or a non-lifecycled, incrementally updated HEAD. It's a sibling of since and retain since it dictates whether those get used, and I don't think it loses anything by being outside of the index section which currently just describes where the data lives, not how indexing behaves. And it doesn't collide with Elasticsearch's own index.mode setting. Overall, I think this naming convention works.

@davemoore-

Copy link
Copy Markdown
Collaborator

One last semantic change pushed: I think the public-facing values for mode should be "snapshot" and "head" (changed from "incremental" to "head"), so that it's more obvious to an agent what the ref "is." Since the term only appears in the config file and to an agent as a ref marker, I think it's more important to the agent to see that a ref either represents a historical snapshot or the current head of a branch, rather than how the ref was indexed.

@davemoore-

davemoore- commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Note: I might walk back the choice of "head" before we release this and return to "incremental" (or "delta" for brevity) to future-proof the feature to support fast-moving tags later (e.g. Kibana's deploy@ tags), which wouldn't fit the "head" metaphor.

davemoore- and others added 4 commits August 24, 2026 10:34
Delta mode now accepts git.ref_type: tag in addition to branch. This
enables cheap indexing of fast-moving tags like elastic/kibana's
deploy@{major} Serverless promotion tags: instead of minting a full
snapshot per force-update, a SHA-to-SHA diff touches only changed paths.

Changes:
- config.py: lift branch-only restriction to accept branch or tag
- utils.py: build_ref_key now takes ref_type, making branch and
  same-named tag produce distinct join-doc _ids
- markers.py: thread ref_type through write_incremental_*, read,
  delete_incremental_paths/branch, and count functions; replace
  hardcoded "branch" literal in join doc body
- documents.py: add ref_type to build_incremental_file_doc,
  iter_incremental_line_docs, _init_worker_incremental, and
  index_incremental_paths; carry it into content doc git.ref_type field
  and make_doc_id call
- command.py: derive ref_type from unit.kind; dispatch checkout_ref for
  tags vs checkout_branch for branches; thread ref_type into all callers
- queries.py: add git.ref_type to prune sweep tuple keys (5-tuple) and
  check_join_uniqueness composite agg; add
  _enumerate_incremental_content_ref_pairs helper
- planner.py: update OrphanPlan and plan_orphans type annotations to
  5-tuple (host, org, repo, ref_type, ref)
- prune/execute.py: unpack ref_type from 5-tuple and add to
  delete-by-query filter so tag cleanup never touches same-named branch
- 9 content tool YAMLs: extend LOOKUP JOIN to include git.ref_type,
  preventing join fan-out when a branch and tag share a name in delta mode
- index templates (files + lines): add git.ref_type keyword mapping
- Tests: update all markers/documents/config/incremental_index tests for
  new ref_type params; add tag-kind test variants and checkout dispatch
  assertions (254 tests pass)
- Docs: update AGENTS.md, README.md, sourcerer.example.yml,
  specs/sourcerer-yml.md, and progress.py comment from "branch-only" to
  "branch or tag"

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…plicate of git.ref. Drop git.ref from delta content docs and index sort. Replace error/failed_at with status:'failed'
…tify a commit as an orphan when its status is 'indexing'
@davemoore-

Copy link
Copy Markdown
Collaborator

Update: I was able to test and confirm that delta mode works as expected for tag refs.

I tested this on the elastic/kibana repo using a match pattern of "deploy@{major}".

  1. Before testing it, I patched the index command to exclude the most recent tag that matched that pattern.
  2. Then I ran the index command, which backfilled elastic/kibana for the second-most recent "deploy@{major}" tag.
  3. Then I removed my patch and re-ran the index command. As expected, only the files and lines that were added/changed/removed in the most recent tag were indexed, and then git.commit and git.ref were correctly updated to their latest values in the ref marker in sourcerer-v3-refs (as well as everything else that should have been updated either permanently or temporarily: status, indexed_at, indexing_started_at, git.commit_target).
  4. I repeated the test by patching the index command to exclude the two most recent tags, indexing the backfill, removing the patch, running index one more time, and confirmed that the deltas for those two commits were added to the backfill. All as expected.

I did observe that it took a couple minutes for the deltas to even begin to be indexed, which is unlike what I've seen for branch refs indexed in delta mode. I suspect that's because there were many more deltas to resolve in my tests of elastic/kibana, which had several thousand files added/removed/changed rather than just the small handful that changed on my branch ref test. I'd rather optimize that later and release the working feature now, given that indexing tag refs in delta mode will be the least common combination of git.ref_type and mode.

@davemoore-

davemoore- commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Quite a bit has changed since this PR was opened, so I want to summarize my understanding of where it is now.

How things worked before this PR

All sources defined in sourcerer.yml had their refs indexed as whole commit snapshots. This made it possible to search the entire, materialized state of a repository at a given commit point. It works very well for searching the state of "shipped" code at specific release tags, and can be easily lifecycled using retention policies that prune old commits as they reach a certain age, count, or semantic version.

It also works for indexing the commit points of branches. But branches tend to have different behaviors than release tags. That leads us to...

The problem before this PR

Indexing whole commit snapshots makes it difficult to keep up with branches that have frequent, small updates. For example, documentation repos are frequently updated on their default branch, even for changes as small as fixing a typo. A change that small would still trigger the indexing of another whole commit snapshot of that repo - just to reflect that one typo change. This is an unreasonable amount of processing and storage for such a small change, especially for a repo where the head of the default branch is typically all that matters to search, rather than specific points in its history. It would be better to keep a single snapshot of those repos and then add/update/remove just the files and lines that changed from the latest indexed commit to the most recent commit (i.e. their deltas). IaC has the same behavior: frequent, small updates to a branch where the current state usually matters more than its history.

How this PR solves the problem

This PR introduces the concept of a source "mode" (defined in sourcerer.yml as sources[i].mode) to allow sources to be indexed either as immutable commit snapshots, or as a mutable snapshot whose files and lines are added/updated/removed based on the deltas between commits.

Here's how the two modes work:

  1. snapshot mode (sources[i].mode: "snapshot") (the default mode):

    • When a new ref matches sources[i].match, the index command indexes all the files and lines of the ref at that commit point.
    • The ref's files and lines are immutable and addressed by git.host > git.org > git.repo > git.commit.
    • The ref is lifecycled (they support sources[i].since and sources[i].retain). Old commit snapshots can be pruned by age, count, or semantic version.
    • A source can be indexed in snapshot mode if its git.ref_type is "tag", "branch", or "commit".
  2. delta mode (sources[i].mode: "delta"):

    • When a new ref matches sources[i].match, the index command indexes only the differing files and lines between the ref at that commit point and the current state of the indexed ref. This also means that a whole snapshot of the repo is indexed; but unlike snapshot mode, that one snapshot is updated incrementally over time, rather than indexing an entirely new snapshot for each new revision of the ref.
    • The ref's files and lines are mutable and addressed by git.host > git.org > git.repo > git.ref_pattern > git.ref_type (where git.ref_pattern is the value of sources[i].match in sourcerer.yml). They do not contain git.commit or git.ref, because those can change between indexing runs and we want to minimize the number of documents that are updated and tombstoned for each indexing run. Instead, the latest git.commit and git.ref is kept in sourcerer-v3-refs and joined at query time, which has proven to be performant in our tests.
    • The ref is not lifecycled (it doesn't support sources[i].since or sources[i].retain because there's no history to manage; there's only ever one snapshot, and it changes over time).
    • A source can be indexed in delta mode if its git.ref_type is "branch" or "tag" (not "commit" which is immutable by definition and therefore cannot have deltas). We expect that "branch" will be a much more common use case for delta mode, but there were valid-enough reasons to support "tag" as well.

The Agent Builder ES|QL tools have been modified to support both new modes seamlessly, without requiring an agent to know if it's searching content that was indexed in snapshot mode or delta mode. Agents can still search broadly across repositories or selectively by commit hashes or ref names, and use wildcards ("*") on any git_* scoping parameter. Our implementation of subqueries, forks, and joins correctly resolves the files and lines to their respective commits and ref names, regardless of whether the files or lines have the shape of snapshot or delta, and does so in a performant way. The main trade-off of this approach is that it requires Elastic v9.5.0+ to make use of a new subquery syntax in ES|QL (specifically IN ( FROM ... )), which is also in tech preview.

Ultimately, I believe this PR successfully refactors Sourcerer to support two very different modes of source curation, while keeping storage, indexing throughput, and query speeds optimal; and keeping tool interfaces intuitive to agents. There's room to make further optimizations in ways that wouldn't incur breaking changes from the current state of this PR. Therefore, I feel comfortable merging this PR and releasing it as v3.0.0, and postponing any non-breaking optimizations to future minor/patch releases.

# Conflicts:
#	.claude-plugin/marketplace.json
#	README.md
#	pyproject.toml
#	uv.lock
The INV-00N references pointed at a private planning spec that outside
readers of this repo cannot see or link to. Strip every label while
keeping the plain-English rule each one annotated, so the comments stay
self-explanatory on their own.
@simianhacker
simianhacker marked this pull request as ready for review August 25, 2026 23:18
@davemoore-
davemoore- self-requested a review August 25, 2026 23:28

@davemoore- davemoore- left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, validated with lots of offline testing. Great working with you @simianhacker

@davemoore-
davemoore- merged commit 1728dbd into elastic:main Aug 25, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants