Skip to content

docs(rfc): Add Tango conflict analyzer design - #845

Open
manjari25 wants to merge 6 commits into
mainfrom
manjari/tango-conflict-analyzer-rfc
Open

manjari25 wants to merge 6 commits into
mainfrom
manjari/tango-conflict-analyzer-rfc

Conversation

@manjari25

Copy link
Copy Markdown
Contributor

Why?

Provides a way to perform conflict analysis rooted in a build graph as opposed to file overlaps or all conflicts.

What?

Adds an RFC for a Tango-backed conflict.Analyzer that stores each batch's changed targets as a signature of 64-bit IDs in a durable store with an in-memory cache, and treats batches as conflicting when their signatures share an ID.

Test Plan

Docs only change - Implementation to follow.

Issue

@manjari25
manjari25 requested review from a team, behinddwalls and sbalabanov as code owners October 12, 2026 19:50
| 2 | The analyzer gets a signature for each batch |
| 3a | Look in the in-memory cache |
| 3b | On a cache miss, look in the signature store; a hit fills the cache |
| 3c | On a store miss, take the Tango path |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'd prefer to keep signature store as fail close and if it fails, error out instead of Tango fallback. This may mask the original issue and only extends the agony as Tango would unlikely be able to process the storm of the requests in case of systematic issue.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

To implement this, the only call to Tango should be for the new batch only and became #2 Step, it would also populate the local cache and signature store for this batch. We can drive the request through local cache / signature store if we believe it could be seen before, which I think can only happen if some next step fails with infra failure and we retry the same batch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated. Tango is called only for the new batch, and the result goes to the signature store and cache. In-flight batches are read from the cache or store only, and a miss or error fails the analysis. A retry of the new batch uses its existing record and doesn't call Tango. A queue must have no in-flight batches when switched to tango.

| --- | --- | --- | --- |
| **Entity** | `TargetSignature` | `TargetLabel` | same `TargetSignature` |
| **Holds** | Per batch: sorted `(ID, distance)` entries, base revision | Per ID: its label (more than one only on a hash collision) | Signatures this process has seen |
| **Key** | `(queue, batch ID)` | `(queue, ID, label)` | `(queue, batch ID)` |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(queue, ID, label) isn't label the value, and {queue, ID} the key ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated. The key is now (queue, ID) and the label is the value. I had put the label in the key to keep both labels on a hash collision. Collisions are rare, so the first label stays and a mismatch is counted and logged. Is this what you had in mind?

| **Holds** | Per batch: sorted `(ID, distance)` entries, base revision | Per ID: its label (more than one only on a hash collision) | Signatures this process has seen |
| **Key** | `(queue, batch ID)` | `(queue, ID, label)` | `(queue, batch ID)` |
| **Durable** | Yes | Yes | No |
| **Written** | Once per batch, create-if-absent, before the batch is promoted to `Created` | When a Tango response is decoded; asynchronous, best-effort, idempotent | On a miss, after the store or Tango answers |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

plz explain why dictionary update is best effort? If it fails open, every op that uses the dictionary should be fail open.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The conflict check never reads the target dictionary, so a missing label can't change a decision. A fail-closed write would put a store with no reader yet, intended for debugging, on the hot path. Its writes could be large, since responses can have millions of targets. A dropped write is usually repaired later, because rows are shared across batches and any later batch touching the same target rewrites the label.

Every dictionary reader shows the ID when no label is found, and failed writes are counted and logged.

My expectation is that conflict relaxation, the only reader I can think of right now, can work from just the IDs and distances in the signature, so it shouldn't need labels. Thoughts?

| **Durable** | Yes | Yes | No |
| **Written** | Once per batch, create-if-absent, before the batch is promoted to `Created` | When a Tango response is decoded; asynchronous, best-effort, idempotent | On a miss, after the store or Tango answers |
| **Read by** | The analyzer; relaxation | Debugging and conflict explanations; never the conflict check | The analyzer |
| **Lifetime** | Created once, never updated | Created once, never updated | Evicted when a batch leaves the in-flight set, or LRU when over the memory budget |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

target dictionary should likely be the one per queue, and updated with each batch's new targets?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, it's intended per queue. Each batch adds rows for the targets it brings that the queue hasn't seen before. Rows already there are left alone, since a label is a pure function of its ID and never needs updating. "Created once, never updated" referred to the rows, not the dictionary as a whole. I'll reword that cell.

// TargetLabel is one label and the ID it hashes to.
type TargetLabel struct {
// Queue is the queue the label was seen in. Unique together with ID and Label.
Queue string

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If we store a lot of target labels in some sort of a dictionary, does it makes sense to have a Queue as a field in each one of them?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Every orchestrator table leads its key with queue, enforced by the queueshard linter (make lint-queue-shard), so that each queue's rows can be sharded and isolated. The dictionary follows the same rule.

- The change type is `INVALID`.
- Tango returns any other error or times out.

Any of these fails the analysis, and a failure never becomes "no conflicts", because dropping a target would hide a conflict. A queue-configured fallback to `all` is a possible later option.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

let's not mention this fallback; I suggest to make conflict analysis fail-close
fallback, if any, is to be implemented outside of SQ ecosystem

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated.

@manjari25
manjari25 force-pushed the manjari/tango-conflict-analyzer-rfc branch from 8ac91f0 to 746d206 Compare October 14, 2026 01:16
@manjari25
manjari25 requested a review from sbalabanov October 14, 2026 01:17
| | Signature store | Target dictionary | In-memory cache |
| --- | --- | --- | --- |
| **Entity** | `TargetSignature` | `TargetLabel` | same `TargetSignature` |
| **Holds** | Per batch: sorted `(ID, distance)` entries, base revision | Per ID: its label | Signatures this process has seen |

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.

base revision

what is base revision?


## Stores and caches

| | Signature store | Target dictionary | In-memory cache |

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.

Target dictionary

is it needed? i thought we would just convert string targets to int64 IDs by converting the unit64(hash(target))


## Entities

`TargetSignature` and `TargetLabel` are immutable records under `submitqueue/entity/`. `SignatureTarget` is a value inside a signature. Each store instance serves a single queue, so a read takes only the batch ID or target ID.

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.

submitqueue/entity/

should they be in entity at all? these specific to an impl of conflictAnalyzer but not really the part of core extension itself.. right?


`TargetSignature` and `TargetLabel` are immutable records under `submitqueue/entity/`. `SignatureTarget` is a value inside a signature. Each store instance serves a single queue, so a read takes only the batch ID or target ID.

```go

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.

BaseRevision string

should this be represented using git changeURI and for all purposes we could just say BaseChangeURI


The `direct_dependencies` column lists the local IDs of what each target depends on; the signature does not use it. Collect keeps the pairs (3, 0), (1, 1), (4, 2), (2, 0) and (5, 0). Hash maps each local ID to its label and hashes it. Merge sorts by ID, giving the signature stored for the batch:

| ID (hex) | Label | Distance |

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.

ID (hex)

did we decide on inheritence or not? do we have any configurability to choose between no inheritence (current V1 behaviours) and with inheritence?


Any of these fails the analysis. The analyzer fails closed, and any fallback belongs outside SubmitQueue.

## Algorithm

@behinddwalls behinddwalls Oct 14, 2026 •

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.

Algorithm

i think it's fine to skip it from the rfc.. maybe just raw steps are fine, but no code as such

This branch has not been deployed

No deployments
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.

3 participants