Skip to content

Commit 1cdb5f6

Browse files
committed
Expand testpolicy design for shard_count and integer flaky.
Document timeout, flaky, and shard_count as execution-reflecting attrs; add shard_count recommender (future), integer flaky semantics from figma/bazel#13, and attr-policy maxValue for shard_count after figma/bazel#12.
1 parent 7f978ff commit 1cdb5f6

1 file changed

Lines changed: 101 additions & 31 deletions

File tree

docs/attribute-policy-and-test-tuning-design.md

Lines changed: 101 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,19 @@ We want two related capabilities for Bazel `BUILD` files:
1212

1313
1. **Static attribute-policy linting** in `buildifier` — enforce declarative rules about
1414
attribute values, e.g. forbid `timeout = "eternal"` unless the target is on an
15-
approved allow-list, forbid `exclusive` in a test's `tags`, and similar
16-
attribute/rule-kind constraints. Purely static, config-driven, no runtime data.
15+
approved allow-list, forbid `exclusive` in a test's `tags`, cap `shard_count` at 50,
16+
and similar attribute/rule-kind constraints. Purely static, config-driven, no runtime data.
1717

1818
2. **Empirical test-tuning** in a **new, separate tool** (`testpolicy`) — read
1919
historical test-execution stats from a metrics warehouse, compute recommended
20-
`timeout` and `flaky` values (and a flakiness score), and **emit the `buildozer`
20+
`timeout`, `flaky`, and (in theory) `shard_count` values, and **emit the `buildozer`
2121
commands** that would repair the repo. `buildifier` never touches the warehouse.
2222

23+
`timeout`, `flaky`, and `shard_count` are the three test attributes meant to reflect
24+
**how the test currently executes**, not how it was designed. `shard_count` tuning is
25+
feasible only if telemetry can separate fixture/SUT setup time from assertion time;
26+
otherwise the tool cannot tell whether more shards would shorten the critical path.
27+
2328
These are split deliberately: `buildifier` stays a fast, hermetic, offline static
2429
linter; all data-dependent analysis lives in a tool that queries a warehouse and
2530
produces buildozer commands. **The tool's job ends when those commands are produced**
@@ -34,6 +39,16 @@ whatever CI/automation invokes the tool.
3439
| How empirical recommendations are applied | Tool emits `buildozer` commands (its terminal deliverable); applying/PRs are downstream. `buildifier` stays purely static |
3540
| Where policy config lives | Extend the existing `.buildifier.json` config |
3641

42+
### Motivating use cases
43+
44+
| Policy | Encoding | Context |
45+
|---|---|---|
46+
| No `timeout = "eternal"` without approval | `forbidValues` + `allowlist` | Initial ask |
47+
| No `exclusive` in test `tags` | `forbidListItems` | Initial ask |
48+
| No `local = True` on tests | `forbidValues: ["True"]` | Hermetic-test policy |
49+
| No `execution_requirements["no-cache"] = "1"` | `forbidDictEntries` | Cache-bypass policy |
50+
| `shard_count` ≤ 50 on tests | `maxValue: 50` | [figma/bazel#12](https://github.com/figma/bazel/pull/12) removed Bazel's hardcoded 50-shard cap; repos that want to keep that limit (or any other bound) can enforce it in buildifier instead of forking Bazel |
51+
3752
---
3853

3954
## 2. Background: how `buildifier` warnings work today
@@ -78,8 +93,8 @@ whatever CI/automation invokes the tool.
7893
A **single generalized** warning, `attr-policy`, driven entirely by config. It covers
7994
the initial asks (eternal-timeout allow-list, no `exclusive` on tests) and any future
8095
"attribute constrained on rule-kind unless allow-listed" rule **without new Go code**
81-
including boolean literals (`local = True`) and dict attributes
82-
(`execution_requirements = {"no-cache": "1"}`).
96+
including boolean literals (`local = True`), dict attributes
97+
(`execution_requirements = {"no-cache": "1"}`), and numeric bounds (`shard_count` ≤ 50).
8398

8499
Rationale for one generalized warning vs. many hardcoded ones: buildifier warnings are
85100
individually toggleable by name, but the *policy content* here is site-specific and
@@ -121,6 +136,14 @@ generic and lets each repo express its own policy.
121136
"no-cache": "1"
122137
},
123138
"message": "Do not set execution_requirements['no-cache'] = '1'."
139+
},
140+
{
141+
"name": "max-shard-count",
142+
"ruleKinds": ["*_test"],
143+
"attr": "shard_count",
144+
"maxValue": 50, // numeric range constraint (see below)
145+
"allowlist": ["//huge_suite:..."],
146+
"message": "shard_count must not exceed 50; add the target to the allowlist for larger suites."
124147
}
125148
]
126149
}
@@ -141,6 +164,8 @@ generic and lets each repo express its own policy.
141164
| `forbidDictEntries` | object (string→string) | Dict attr must not contain any of these key→value pairs. |
142165
| `requireDictEntries` | object (string→string) | Dict attr must contain all of these key→value pairs. |
143166
| `forbidDictKeys` | []string | Dict attr must not contain any of these keys (value ignored). |
167+
| `minValue` | int | Numeric attr must be ≥ this (inclusive). At least one of `minValue` / `maxValue` required for the numeric family. |
168+
| `maxValue` | int | Numeric attr must be ≤ this (inclusive). |
144169
| `required` | bool | Attr must be present at all. |
145170
| `allowlist` | []string | Target patterns exempt from this rule (see grammar below). |
146171
| `suppressible` | bool (default `true`) | Whether `# buildifier: disable=attr-policy` silences this rule. `false` = a "hard" rule enforced authoritatively by CI regardless of in-file comments (see §6). |
@@ -164,10 +189,19 @@ Dict values are normalized the same way as scalars (string literal or
164189
presence of a key regardless of its value (useful when any non-default value is
165190
undesirable but the exact value varies).
166191

192+
**Numeric matching (`minValue` / `maxValue`).** Covers integer attributes such as
193+
`shard_count`. Read the attribute with `rule.AttrLiteral` (a `*build.LiteralExpr`
194+
whose `Token` is the decimal representation) and parse with `strconv.Atoi`. An absent
195+
attribute does not violate `maxValue` / `minValue` — only an explicitly set value
196+
outside the range is flagged. This is the mechanism repos use to re-impose limits
197+
Bazel no longer enforces globally (e.g. the former 50-shard cap removed in
198+
[figma/bazel#12](https://github.com/figma/bazel/pull/12)).
199+
167200
Exactly one *constraint family* (`forbidValues`/`requireValues` **or**
168201
`forbidListItems`/`requireListItems` **or**
169-
`forbidDictEntries`/`requireDictEntries`/`forbidDictKeys`) should be set per
170-
rule; `required` is orthogonal. Validation enforces this (see 3.5).
202+
`forbidDictEntries`/`requireDictEntries`/`forbidDictKeys` **or**
203+
`minValue`/`maxValue`) should be set per rule; `required` is orthogonal. Validation
204+
enforces this (see 3.5).
171205

172206
**Allow-list pattern grammar.** Entries are Bazel-style target patterns (a subset of
173207
what users already know from `bazel build`):
@@ -216,6 +250,8 @@ type AttrPolicyRule struct {
216250
ForbidDictEntries map[string]string `json:"forbidDictEntries,omitempty"`
217251
RequireDictEntries map[string]string `json:"requireDictEntries,omitempty"`
218252
ForbidDictKeys []string `json:"forbidDictKeys,omitempty"`
253+
MinValue *int `json:"minValue,omitempty"` // pointer: absent vs set-to-zero
254+
MaxValue *int `json:"maxValue,omitempty"`
219255
Required bool `json:"required,omitempty"`
220256
Allowlist []string `json:"allowlist,omitempty"`
221257
Suppressible *bool `json:"suppressible,omitempty"` // pointer so absent ⇒ default true
@@ -260,7 +296,9 @@ On load, reject:
260296
- duplicate `name`s,
261297
- empty `name` or empty `attr`,
262298
- a rule with no constraint set,
263-
- a rule mixing scalar, list, and dict constraint families,
299+
- a rule mixing scalar, list, dict, and numeric constraint families,
300+
- a numeric rule with neither `minValue` nor `maxValue` set, or with `minValue` >
301+
`maxValue` when both are set,
264302
- malformed globs in `ruleKinds` / malformed target patterns in `allowlist` (must
265303
match the §3.2 grammar; reject a bare `...`, repository-qualified entries, etc.).
266304

@@ -299,6 +337,8 @@ func attrPolicyWarning(f *build.File) []*LinterFinding {
299337
- **Dicts:** `rule.Attr(attr)` as `*build.DictExpr`; `edit.DictionaryGet` per
300338
key; normalize values like scalars. `forbidDictKeys` flags any listed key
301339
that is present.
340+
- **Numerics:** `strconv.Atoi(rule.AttrLiteral(attr))`; flag if value `< minValue`
341+
or `> maxValue` (bounds inclusive; absent bound ignored).
302342
Returns `makeLinterFinding(node, msg)` anchored on the offending attr node (or a
303343
specific dict entry's value node when possible).
304344
- Scope: BUILD files only (targets live there). Return `nil` for other file types.
@@ -314,7 +354,7 @@ func attrPolicyWarning(f *build.File) []*LinterFinding {
314354
harmless in the default set too; pick opt-in to be conservative).
315355
- Docs: add an entry to `WARNINGS.md` and `warn/docs/warnings.textproto` describing the
316356
warning **and** the `attrPolicy` config block (with the example rules, including
317-
boolean and dict constraints).
357+
boolean, dict, and numeric constraints).
318358
- Add a `-config=example` sample entry so `buildifier -config=example` prints an
319359
`attrPolicy` stub (see `config.Example()`).
320360
- Tests `warn/warn_attr_policy_test.go`:
@@ -325,14 +365,16 @@ func attrPolicyWarning(f *build.File) []*LinterFinding {
325365
non-match; missing-when-`required`; `# buildifier: disable=attr-policy` suppression;
326366
forbidden boolean literal (`local = True`); forbidden dict entry
327367
(`execution_requirements = {"no-cache": "1"}`); `forbidDictKeys` on a dict key;
328-
empty config = no findings.
368+
`shard_count` above `maxValue` flagged, within range OK, absent OK; allow-listed
369+
high-shard target exempt; empty config = no findings.
329370
- Config tests in `buildifier/config/config_test.go`: parse the sample JSON;
330371
validation rejects malformed rules.
331372

332373
### 3.8 Acceptance criteria (Workstream A)
333374

334375
- `buildifier --lint=warn --warnings=attr-policy BUILD` flags eternal timeout on
335-
non-allow-listed test targets and `exclusive` tags on tests, given the sample config.
376+
non-allow-listed test targets, `exclusive` tags on tests, and `shard_count > 50`,
377+
given the sample config.
336378
- Zero findings when `attrPolicy` is absent.
337379
- All new + existing `warn` and `config` tests pass; `WARNINGS.md` regeneration (if
338380
applicable) is consistent.
@@ -354,7 +396,8 @@ testpolicy/
354396
bigquery.go // concrete impl (behind a build tag / flag)
355397
analyze/
356398
timeout.go // timeout recommender
357-
flaky.go // flakiness scoring + flaky (bool) recommender
399+
flaky.go // flakiness scoring + flaky recommender
400+
shard.go // (future) shard_count recommender — needs setup vs assertion timing
358401
emit/
359402
buildozer.go // emit buildozer command script (terminal deliverable)
360403
report/ // human-readable + machine (JSON) report
@@ -375,6 +418,10 @@ type TargetStats struct {
375418
// Per-attempt outcomes for flakiness math:
376419
Attempts int // total attempts observed
377420
PassByAttempt []float64 // PassByAttempt[k] = P(pass by attempt k+1), empirical
421+
// Per-shard timing (optional; needed for shard_count recommender):
422+
ShardCount int // declared shard_count, if any
423+
ShardDurationP95 []time.Duration // per-shard P95, when available
424+
SetupDurationP95 time.Duration // fixture/SUT setup time P95, when separable from assertions
378425
}
379426

380427
type Source interface {
@@ -431,28 +478,46 @@ Answers "does a single retry likely pass, or does it need multiple?".
431478
from "retries rarely help", which drives the `flaky` recommendation:
432479
| `N` | Meaning | Recommendation |
433480
|---|---|---|
434-
| ≤ 1 | not flaky | `flaky = False` (or leave unset) |
435-
| 2–3 | retries reliably recover it | `flaky = True` |
436-
| > 3 | retries rarely recover it | report for owner; `flaky` won't reliably help |
481+
| ≤ 1 | not flaky | `flaky = 0` (or leave unset) |
482+
| 2–3 | retries reliably recover it | `flaky = N - 1` (extra retries beyond the first attempt) |
483+
| > 3 | retries rarely recover it | report for owner; more retries won't reliably help |
437484
| very low `a` | chronically broken | **do not** mask with retries; report for owner |
438485

439-
- **Reality check (must be in tool output & docs):** Bazel's `flaky` is a **boolean**;
440-
the tool only ever recommends `flaky = True` / `flaky = False`. `N` is an internal
441-
signal for classification, not something written into the BUILD file. There is no way
442-
today to express *how* flaky a target is (a per-target retry count) in a BUILD file:
443-
the `flaky` attribute and the `--flaky_test_attempts` flag are disconnected, so
444-
per-target attempt counts require unwieldy regex lists on the flag. This limitation is
445-
tracked upstream in [bazelbuild/bazel#30108](https://github.com/bazelbuild/bazel/issues/30108);
446-
if/when it lands, the classifier's `N` becomes directly expressible and this section
447-
should be revisited.
486+
- **Semantics (with integer `flaky`):** When `--flaky_test_attempts=default`, Bazel runs
487+
`1 + flaky` total attempts for flaky targets (`flaky = 0` ⇒ one attempt). The tool
488+
writes the integer directly: `buildozer 'set flaky 2' //pkg:target` for three total
489+
attempts. This is implemented in [figma/bazel#13](https://github.com/figma/bazel/pull/13);
490+
upstream tracking in [bazelbuild/bazel#30108](https://github.com/bazelbuild/bazel/issues/30108).
491+
When CI sets `--flaky_test_attempts=N` (bare integer), that flag overrides the attribute
492+
for all targets — the tool should surface that in its report so owners know BUILD-file
493+
edits may have no effect.
448494

449-
### 4.5 Emit (`emit/`)
495+
### 4.5 Shard-count recommender (`analyze/shard.go`) — future
496+
497+
`shard_count` is the third execution-reflecting test attribute alongside `timeout` and
498+
`flaky`: all three describe **how the test currently executes**, not how it was designed.
499+
500+
Tuning `shard_count` is feasible **only if** telemetry can separate fixture/SUT setup time
501+
from assertion (test-case) time. Without that split, the tool cannot tell whether more
502+
shards would shorten the critical path or just duplicate expensive setup across shards.
503+
504+
When per-shard data is available:
505+
506+
- Recommend raising `shard_count` when per-shard assertion time is high and setup is
507+
amortizable (e.g. many cases per shard, setup ≪ case time).
508+
- Recommend lowering `shard_count` when shards are mostly idle or setup dominates.
509+
- Respect `attr-policy` caps (e.g. `shard_count` ≤ 50) from Workstream A.
510+
511+
Output per target: `{current, recommended, reason}`; emit `buildozer 'set shard_count N'`.
512+
513+
### 4.6 Emit (`emit/`)
450514

451515
The tool's **terminal output** is a `buildozer` command script that would repair the
452516
repo, printed to stdout/file, e.g.:
453517
```
454518
buildozer 'set timeout "short"' //pkg:target
455-
buildozer 'set flaky True' //pkg:other
519+
buildozer 'set flaky 2' //pkg:other
520+
buildozer 'set shard_count 4' //pkg:sharded
456521
```
457522
- Also emit a machine-readable JSON report (recommendations + reasons + effective
458523
`timeoutBuckets`) for auditability.
@@ -461,7 +526,7 @@ buildozer 'set flaky True' //pkg:other
461526
of whatever CI/automation calls `testpolicy`. Keeping the boundary here makes the tool
462527
trivially testable (assert on emitted commands) and reusable by any apply/review flow.
463528

464-
### 4.6 Safety / guardrails
529+
### 4.7 Safety / guardrails
465530

466531
- Require a minimum `Runs` sample size before recommending (default e.g. 20); otherwise
467532
report "insufficient data".
@@ -475,10 +540,11 @@ buildozer 'set flaky True' //pkg:other
475540
- Respect the eternal allow-list from `.buildifier.json` so the two systems agree.
476541
- Dry-run is the default mode.
477542

478-
### 4.7 Acceptance criteria (Workstream B)
543+
### 4.8 Acceptance criteria (Workstream B)
479544

480-
- With a fake `Source`, `testpolicy` produces correct timeout & flaky recommendations
481-
and a valid buildozer command script for a fixture dataset.
545+
- With a fake `Source`, `testpolicy` produces correct `timeout`, `flaky`, and (when
546+
fixture data includes per-shard timing) `shard_count` recommendations and a valid
547+
buildozer command script for a fixture dataset.
482548
- No warehouse credentials required for tests (fake source).
483549
- Output is deterministic (stable ordering) so the emitted script can be asserted on.
484550

@@ -578,12 +644,16 @@ Each task is independently ownable; dependencies noted. "AC" = acceptance criter
578644
- **B1. Tool scaffold + CLI + fake source**`testpolicy/main.go`, `source` interface,
579645
in-memory fake. *(no deps)*
580646
- **B2. Timeout recommender** — §4.3 + tests on fixtures. *(dep: B1)*
581-
- **B3. buildozer emitter + report** — §4.5 dry-run path. *(dep: B1)*
647+
- **B3. buildozer emitter + report** — §4.6 dry-run path. *(dep: B1)*
582648

583649
### Phase 3 — flakiness + real data source
584650
- **B4. Flakiness scoring** — §4.4 + tests. *(dep: B1)*
585651
- **B5. Warehouse (BigQuery/DB) source impl** — behind flag. *(dep: B1)*
586652

653+
### Phase 4 — shard_count tuning (future)
654+
- **B6. Shard-count recommender** — §4.5; requires per-shard telemetry with setup vs
655+
assertion split. *(dep: B1, B5)*
656+
587657
### Phase 1b — enforcement (Workstream A, in parallel with Phase 2)
588658
- **A7. Authoritative CI gate** — evaluate policy ignoring `disable=` for
589659
`suppressible: false` rules (§6.1/§6.2); non-zero exit on violation. *(dep: A1–A4)*

0 commit comments

Comments
 (0)