Tango (Target Analyzer in Go) is a standalone library and service that fetches and compares Bazel target graphs across revisions of a repository. It answers two related questions:
- What does the target graph look like at a given revision?
- Which targets changed between two revisions, and what is each changed target's BFS distance from the nearest direct cause?
It is designed to run independently of the monorepo it analyzes — the only inputs are a remote URL, a base SHA, and optionally a set of change requests (PR/diff URLs + commit SHAs) to layer on top.
- Content identity and deterministic computation — the materialized git treehash identifies source content, while cache keys also include the computation-affecting inputs represented by each artifact's key schema. Canonical change URIs pin change requests to exact commits. Any new input that can change computed output requires a cache-key review; output-only options stay outside cache keys and are applied while sending.
- Value-oriented identities and safe handoffs — repository identities and computed results should be treated as immutable after they are handed to another layer or goroutine. Tango still uses mutable workspaces, graph builders, slices, and maps internally; copy mutable collections before normalization or concurrent use when the caller may retain them.
- Scoped eventual consistency — eventual consistency applies to the asynchronous, best-effort compared-target cache. Graph and treehash writes that occur while storing a computed graph are synchronous and request-bound, so they must succeed before that graph is returned.
- Streaming, chunked responses — target graphs and change results are split into chunks to stay within gRPC per-message limits. Metadata mappings (target IDs → names, rule types, tags, attributes) may also span multiple chunks; consumers must merge them before use.
- ID-mapped payloads — over the wire, targets reference each other (and their rule types, tags, attributes) by
int32IDs into per-stream metadata maps. Comparison code re-maps both inputs into a canonical per-call namespace and prunes unreferenced metadata entries before sending. IDs are not guaranteed to be consistent across multiple target graphs. - Always-on cancellation — both request-bound and application-bound cancellation signals are honored. Long-running loops whose iteration count can be large check
ctx.Err()periodically. A client disconnect cancels request work, while application shutdown cancels both request work and application-lifetime background work.
tango/ # repo root (Go module github.com/uber/tango)
├── proto/ # Proto definitions (.proto files)
├── tangopb/ # Generated proto code (committed)
│ └── tangopbmock/ # Generated mocks for YARPC server interfaces
├── controller/ # YARPC service implementation (business logic, transport-adjacent)
├── orchestrator/ # Cross-component coordinator: workspace lease, checkout, graph compute, cache I/O
│ └── orchestratormock/
├── graphrunner/ # Strategy-pluggable target-graph computation
│ └── mock/
├── entity/ # Domain value types (BuildDescription, ChangedTargets, TargetGraph, etc.)
├── mapper/ # Proto-to-entity and entity-to-proto conversion
├── internal/ # Internal-only packages (not importable by external consumers)
│ ├── mapper/ # Internal mapping helpers
│ ├── streaming/ # Chunked stream assembly
│ ├── targetdiff/ # Per-target change classification
│ └── url/ # URL normalization and hashing
├── config/ # YAML config parsing and validation (storage, service, repository)
├── core/ # Reusable infrastructure with no domain dependencies
│ ├── git/ # Git CLI wrapper (clone, fetch, checkout, rev-parse, ...)
│ ├── repomanager/ # Per-repo worker-pool / clone manager
│ ├── storage/ # Blob storage interface and impls (in-memory, disk)
│ ├── workspace/ # Workspace abstraction over a git checkout, request application
│ ├── cachekey/ # Cache path/key construction for graphs, treehashes, compared-target results
│ ├── errors/ # TangoError type and ErrorCode classification
│ └── ... # bazel, execcmd, itg, targethasher, ...
├── observability/ # Cross-cutting observability
│ └── metrics/ # Tally-backed metrics emitter, Begin/Complete lifecycle, bucket definitions
├── integration/ # Integration tests and benchmarks (requires a local repo)
├── docs/ # Supplementary documentation (error taxonomy, observability notes)
├── example/ # Runnable server + client and benchmark CLI
│ ├── client/
│ └── cmd/query-bench/
└── tools/ # Bazelisk wrapper and tooling
The top-level split is by responsibility, not by domain: controller/ handles the RPC surface, orchestrator/ drives workspace materialization and graph production, graphrunner/ computes a graph from a materialized workspace, and core/ holds reusable infrastructure. Interfaces are used at behavioral extension seams so implementations can be mocked or replaced without forcing every cross-layer dependency into an interface.
The controller is the YARPC service implementation. It owns transport-adjacent concerns: request validation, metrics, cancellation linkage, response streaming, output filtering, comparison fan-out, and compared-target caching. It does not own workspace creation, git operations, or graph computation.
Every RPC records operation metrics, classifies invalid input as a user error, preserves error chains, attempts applicable cache reads before expensive work, delegates graph production to the orchestrator, and converts the final error at the wire boundary.
The orchestrator provides the target graph, typically by running Bazel. An implementation may perform the work on the local host or delegate it to another stateful subsystem, such as CI infrastructure, that manages checkout and computation.
graphrunner.GraphRunner computes a target graph from an already-materialized workspace.Workspace. The orchestrator resolves repository identity and owns cache behavior; the graph runner turns the resulting source tree into a targethasher.Result without knowing about storage, transport, or the triggering request.
Keep the contract narrow and strategy-agnostic. New computation implementations satisfy the same interface and receive all required dependencies through construction rather than reaching into controller or storage concerns.
Entities are Tango's request, result, and storage data models; they are not persistent versioned aggregates.
- Describe data, not choreography — comments state what a type or field means, its units, optionality, identity, and invariants. Component ownership and write paths belong in the architecture sections.
- Prefer values for identities and configuration — use value structs for
BuildDescription, requests, configs, and constructor params. Pointers are appropriate for optional payloads, mutation, or shared ownership. - Keep wire conversion at the mapper boundary — protobuf validation and proto/entity conversion belong in
internal/mapperormapper, not in the orchestrator or graph algorithms.
Behavioral interfaces live with the capability they describe and have a single responsibility:
core/storage.Storage— blob get/put/exists/list keyed by stringcore/git.Interface— git CLI operationscore/repomanager.RepoManager— workspace lease/releasecore/workspace.Workspace+core/workspace.Request— checkout, applygraphrunner.GraphRunner— compute a graph from a workspaceorchestrator.Orchestrator— top-level entry point
Design interfaces for the technology space, not the implementation in front of you. The contract must be cheaply satisfiable by every plausible backend, not just the one being built today. For example, the Storage interface offers Get/Put/Exists/List keyed by a string — primitives that a disk, an in-memory map, S3, GCS, or a CDN can all satisfy without contortion.
Common over-constraints to avoid:
- Server-side filters or queries — push filtering and aggregation to the caller; keep storage responsible only for get/put-by-key semantics.
- Batch atomicity — many backends cannot provide multi-blob transactions. Prefer single-blob primitives, caller loops, and deterministic content identities.
- Strict ordering or exactly-once background work — make cache writes retry-safe and deterministic instead of depending on execution order.
- Assumed-local latency — design remote-capable operations for cancellation, retry classification, and backend-owned timeouts. The controller must not encode one backend's I/O budget.
Concrete backend construction and deployment-level routing belong in the composition root, such as example/main.go, which has the configuration and lifecycle context needed to choose implementations. Per-request computation selection remains orchestrator behavior because the requested strategy is part of BuildDescription.
When in doubt, ask: "If the next implementation were S3, GCS, a remote RPC service, or an in-memory map, could it satisfy this signature without contortion?" If the answer is no, simplify the contract.
Paths follow the directory layout under github.com/uber/tango/:
- Service:
github.com/uber/tango/controller,.../orchestrator,.../graphrunner - Proto (generated):
github.com/uber/tango/tangopb - Generated mocks:
github.com/uber/tango/{package}/mockor.../{package}mock(see Naming Conventions) - Config:
github.com/uber/tango/config - Reusable infra:
github.com/uber/tango/core/{pkg}(e.g..../core/storage,.../core/git,.../core/repomanager)
Bazel with Bzlmod (NOT WORKSPACE).
- Dependencies:
MODULE.bazel+go.mod— both must be updated. Add direct Go dependencies explicitly toMODULE.bazel. - Bazel wrapper:
./tools/bazel(Bazelisk). With direnv (.envrc), usebazeldirectly. - BUILD files: Every Go package needs
BUILD.bazel. Runmake gazelleafter adding/removing Go files or imports. - CI enforces BUILD files are in sync — always run
make gazellebefore committing. - After adding an external dependency, run
bazel mod tidyto register it.
Generated proto files are committed to the repo. When modifying proto/tango.proto:
- Edit
proto/tango.proto. make proto(generates gogoslick, gRPC, and YARPC bindings undertangopb/).- Commit all generated files.
- Regenerate any mocks that depend on the changed interfaces (
mockgen), thenmake gazelle.
make clean-proto && make proto regenerates from scratch — only needed when in doubt about the state of generated files.
Mocks use go.uber.org/mock (mockgen) and are checked in. The conventions:
- Subdirectory under the package being mocked:
core/git/gitmock/,core/storage/storagemock/,core/repomanager/mock/,graphrunner/mock/,orchestrator/orchestratormock/. Either{pkg}mockormockis used — match whatever already exists in the parent package. - Package-level mocks for cross-cutting interfaces (e.g. generated YARPC server types in
tangopb/tangopbmock/).
To regenerate or add a mock:
mockgen -destination=<destdir>/<file>.go <package path> <Interface>,<Interface>
# or in the current package:
mockgen -destination=<destdir>/<file>.go . <Interface>After regenerating, run make gazelle so the new file is picked up by Bazel.
- Directories: singular (
mock/,controller/, notmocks/,controllers/). - Files:
{method}.gofor RPC handlers (e.g.getchangedtargets.go,gettargetgraph.go),{package}.gofor the package's main type,{file}_test.gofor tests. - Proto files:
{service}.proto. - Mock packages:
{pkg}mockormocksubdirectory — match the surrounding package's existing convention rather than introducing a new one. - README files: Do not duplicate interface or type definitions as code blocks in READMEs. Describe behavior in prose and let readers navigate to the source. Only include code samples when explicitly instructed.
- Markdown prose width: Do not hard-wrap prose in Markdown docs (READMEs, design notes). Write one line per paragraph and one line per list item, and let the editor soft-wrap — hard wrapping at a fixed column renders as a narrow fixed-width column regardless of window size. Code blocks, tables, and ASCII diagrams keep their own line breaks.
The Makefile has a hand-written help target that lists available targets with descriptions. New targets should include a comment explaining their purpose.
make build # Build all targets
make test # Run all tests
make test-integration # Run integration tests (requires a local repo; slow)
make bench # Run GetChangedTargets benchmarks (measurement only, not in CI)
make lint # Run golangci-lint
make cover # Run tests with coverage, write cover.out
make gazelle # Update BUILD.bazel files
make proto # Regenerate protobuf files
make clean # Clean Bazel cache
make clean-proto # Remove generated proto files
make run-server # Run the Tango YARPC server (127.0.0.1:8081)
make run-client-get-graph # Drive the client against the running server
make run-client-changed-targets # Drive get-changed-targets against the running server
make version # Show Bazel version
make help # Show all targets with descriptionsCI builds and tests every PR via GitHub Actions. Before committing, validate locally:
make build— ensures Bazel and Go compile.make test— runs the full unit test suite.make gazelle— ensureBUILD.bazelfiles are up to date.
If you modified .proto files or interface signatures, also run make proto and regenerate the relevant mocks.
Commit and PR titles must follow the Conventional Commits specification. Use a type prefix (feat, fix, docs, refactor, test, chore, build, ci, perf, style) followed by a scope and a short imperative subject — e.g. feat(orchestrator): support remote build execution, fix(controller): surface readTreehash errors, docs(architecture): clarify cache ownership. Breaking changes use ! after the type/scope (e.g. feat(storage)!: ...) and explain the break in the body. This keeps the commit history machine-parseable for changelogs and release automation.
- Structured logging — use
zap.Loggerwith explicitzap.Fields. Never usezap.SugaredLogger,Printf, or unstructuredfmtlogging. - Interfaces for behavior, structs for data — interfaces for behavioral contracts (
Storage,RepoManager,Workspace,GraphRunner,Orchestrator). Structs for data containers, configs, and params (Config,Params,WorkspaceParams). - Value-oriented identities and configuration — prefer values for identity structs, configs, params, and ordinary results. Use
(T, bool)for optional value results when practical; pointers remain appropriate for optional payloads, mutation, or shared ownership. Paramsstructs — every non-trivial constructor takes aParamsvalue (e.g.controller.Params,orchestrator.Params,repomanager.Params). New optional fields go onParamswith a documented default, not as constructor overloads.- Errors for failures, not control flow — reserve
errorfor unexpected or infrastructure failures. For expected outcomes use result types or(T, bool). Avoid sentinel errors that represent non-failure states;storage.ErrNotFoundexists specifically because "not found" is a legitimate cache-miss signal that callers must distinguish from real failures viastorage.IsNotFound(err).
Errors are classified by origin (user vs infra) for metrics. The contract lives in core/errors/errors.go:
TangoError— the internal error type, carrying the underlying error and anErrorCode.ErrorCodevalues:ErrorUser(caused by client input),ErrorInfra(caused by infrastructure),ErrorInfraRetryable(transient infra failure the client may retry),ErrorCancelled(context cancellation, detected automatically byGetErrorCode).- Constructors:
NewUser(err),NewInfra(err),NewInfraRetryable(err).
Key rules:
- Classify at the deepest boundary with semantic context. Request validation and mapping classify bad client input as user errors. Orchestrator classifiers map known lease, git, and Bazel causes to user, infra, or retryable infra outcomes. Lower capability packages return plain wrapped errors and sentinels.
- Unclassified errors default to infrastructure failures. Retryability must be supported by a known transient cause; do not mark an error retryable merely because repeating the request is convenient.
- Preserve the chain with
%w.TangoErrorimplementsUnwrap, soerrors.Isanderrors.Ascontinue to find lower-level sentinels through classification wrappers. - Complete the standard metrics lifecycle and convert errors at the wire boundary.
metrics.Op.Completederives the finish histogram'sresulttag fromtangoerrors.GetErrorCode(err).String(). The controller maps the final classified error to the RPC boundary, whileerrors.Fieldsprovides structured log fields.
The cache is the single most performance-sensitive piece of Tango. Cache identity and ownership are artifact-specific:
| Artifact | Current key dimensions | Read/write ownership |
|---|---|---|
| Treehash mapping | Short remote, base SHA, ordered change-request URL list | Controller reads for fast paths; orchestrator writes synchronously when storing a newly computed graph. |
| Target graph | Short remote, treehash, computation strategy, sorted extra exclusion regexes | Controller performs the initial read; orchestrator reads again after deriving the treehash and synchronously writes a computed miss. |
| Compared targets | Short remote, ordered treehash pair, sorted extra exclusion regexes | Controller reads with miss/corruption fallback and writes asynchronously, best-effort. |
Key rules:
- Use
core/cachekeyhelpers exclusively. Never construct cache paths inline. When a new input changes computed graph or comparison output, update or version the relevant key and add tests. Current keys do not encode every repository configuration or algorithm/schema version, so such changes require deliberate compatibility or invalidation decisions. - Keep output-only options outside cache identities. Store full graph and comparison payloads, then apply distance and field filtering while sending so presentation choices cannot poison shared entries.
- Distinguish misses from failures. A not-found or corrupt entry is an expected recomputation path. Prefer to fail fast when either target-graph or compared-target cache access fails with a genuine infrastructure error rather than silently degrading cache reliability.
- Expect duplicate work on concurrent misses. Tango does not promise singleflight, compare-and-swap, or exactly-once computation. Deterministic computation plus complete content keys makes repeated overwrites converge on the same value.
bypass_cacheskips reads, not writes. It forces recomputation and overwrites the existing entry under the deterministic key.
- Pass
ctxeverywhere and checkctx.Err()periodically inside any loop whose body is cheap but iteration count is large (graph walks, BFS, metadata merges). The shared constant iscancelCheckInterval. - Cancellation is cooperative. When fanning out (e.g. parallel graph fetches in
GetChangedTargets), derive each goroutine's context from the parent and cancel siblings when one fails so resources aren't wasted on a result that will be discarded. - Background work that must outlive the request (cache writes) uses the controller's
appCtx— the application-lifetime context passed toNewController, which is cancelled on process shutdown but not by client disconnect. This ensures a cache write is not aborted mid-flight when a client disconnects, but is still cleaned up on graceful shutdown. Per-operation deadlines are the storage backend's responsibility, not the controller's. Goroutines usingappCtxmust be self-contained: their inputs must not be mutated by the foreground after the goroutine starts.
- Table-driven tests — prefer
t.Runsubtests over individual test functions for related cases. - Avoid asserting on error messages — assert on error type or use
require.Error. Do notassert.Contains(t, err.Error(), message). - No change detector tests — don't assert on internal structure, field-for-field equality of generated types, or defaults that can shift without behavior changing. Test what the code does.
- No
time.Sleepfor synchronization — use channels, callbacks, condition variables. - Use testify for value assertions — use
assert/requirefor values and errors;t.Fatalremains appropriate for test control flow such as failed setup or timeout/select guards. - Mocks via
go.uber.org/mock— generated mocks (*mocksubpackages) for interface-driven dependencies. Inline test doubles only when the interface is small and used by exactly one test. - Goroutine leaks — long-running tests should use
goleak.VerifyNone(t)(orgoleak.VerifyTestMain) to catch leaked goroutines from fan-out or background cache writes.
Add a new RPC method:
- Edit
proto/tango.proto→make proto. - Add the handler in
controller/{method}.goand tests incontroller/{method}_test.go. - Wire the handler into the YARPC dispatcher in
example/main.go(the generatedBuildTangoYARPCProceduresalready covers new methods on the service interface). - Regenerate any affected mocks; run
make gazelle.
Add a new storage backend:
- Create
core/storage/{impl}/{impl}.goimplementingstorage.Storageand a constructor that returns the concrete type. - Add a config branch under
config.StorageConfigand acaseinnewStorageinexample/main.go. - Add tests under
core/storage/{impl}/{impl}_test.go; reuse the conformance tests if/when extracted. Runmake gazelle.
Add a new graph computation strategy:
- Implement
graphrunner.GraphRunnerin a new file undergraphrunner/. - Add a
ComputationStrategyenum value inproto/tango.proto,make proto. - Wire selection in the orchestrator's runner construction.
Add a new entity field:
- Add a comment describing the field's data semantics, optionality, units, and invariants.
- Update proto and mapper boundaries if the field crosses the RPC surface, then add behavior-focused tests for every affected path.
Add a new config field:
- Add the field to the relevant struct in
config/with a YAML tag, a Go comment, and validation inconfig.Parseor the relevant validator. - If the field has a default, document it on the field and set it during parsing so callers do not observe an ambiguous zero value.