ADR-0932: iter.Seq[T] companion APIs for single-pass Go collections¶
- Status: Accepted
- Date: 2026-05-31
- Deciders: lusoris
- Tags:
go,api,performance,ergonomics
Context¶
The fork's Go packages (pkg/bisect, pkg/ladder, pkg/ai, and cmd/vmafx-controller/nodes) historically expose collections as []T return values or as exported slice fields. Every caller that wants to walk the collection — whether to render a chart, stream a gRPC response, or pattern-match for "first node matching X" — pays for a full slice allocation up front, even when the walk would break out after the first hit or when the result is going straight into a streaming wire-format.
Two concrete pressure points motivated the change:
- The vmafx-controller is moving toward a streaming gRPC API (
ListNodesserver-streaming RPC, planned for Phase 4b.2). The handler currently callsRegistry.All()and then ranges over the slice to send messages — twice the work the iterator would do. bisect.Result.Samplesis consumed once linearly by the chart renderer and by JSON marshalling, but a hypothetical long per-title-ladder run (12 bisect iterations × N ladder cells) keeps thousands ofSamplestructs live for the entire dispatch even though no caller re-iterates them.
Go 1.23 added iter.Seq[T] (the project pins Go 1.25 in go.mod), so the language now offers a first-class single-pass adapter that costs the caller a single function-pointer indirection per yield. The standard library's slices.Collect is the bridge back to []T for callers that do need the slice form.
Decision¶
Add iter.Seq[T] companion APIs to four touched packages, alongside the existing slice APIs. The new surfaces are:
| Package | New API | Old API status |
|---|---|---|
pkg/bisect | Result.IterSamples() iter.Seq[Sample] | Result.Samples field unchanged (JSON-load-bearing) |
pkg/ladder | LadderResult.IterCloud() iter.Seq[Point] | LadderResult.Cloud field unchanged (JSON-load-bearing) |
pkg/ladder | LadderResult.IterHull() iter.Seq[Point] | LadderResult.Hull field unchanged (JSON-load-bearing) |
cmd/vmafx-controller/nodes | Registry.AllSeq() iter.Seq[*Node] | Registry.All() reduced to slices.Collect(r.AllSeq()) shim, marked // Deprecated:, removal targeted for v3.x.y-lusoris.N+2 |
pkg/ai | Registry.ListModelsSeq() iter.Seq[string] | Registry.ListModels() reduced to slices.Collect(r.ListModelsSeq()) shim, marked // Deprecated:, removal targeted for v3.x.y-lusoris.N+2 |
Two of the five existing surfaces (Registry.All(), Registry.ListModels()) get the deprecation marker because they have zero non-test callers; the ladder and bisect fields stay un-deprecated because they are part of the JSON schema that external tooling consumes (ADR-0705 forbids removing schema fields without a major-version bump).
The iterator implementations are textbook one-shot adapters: they range over the underlying slice (or map) and call yield, honouring the return false break contract.
Alternatives considered¶
| Option | Pros | Cons | Why not chosen |
|---|---|---|---|
Replace []T with iter.Seq[T] outright | Smaller surface; one obvious way to consume the data | Breaks the JSON-marshalled fields on Result / LadderResult (json:"cloud" etc. cannot marshal an iter.Seq); requires an unrelated schema-forward review | Schema-breaking; rejected |
| Add iterators only on the deprecated slice methods, leave fields alone | Smallest blast radius | Misses the long-walk wins on Samples / Cloud / Hull where the streaming consumer pattern is the actual motivator | Half-measure; rejected |
Channels (<-chan T) instead of iter.Seq | Pre-1.23 idiom familiar to older Go reviewers | Mandatory goroutine, double-allocation, cancellation requires context.Context, no break-out without leaking the producer goroutine | Strictly worse; iter.Seq is the modern replacement |
Add an Iterate(fn func(T) bool) callback method | No new language feature; works on Go 1.21 | Loses for v := range coll.Iter() ergonomics; tooling (linters, debuggers) doesn't recognise it as a range expression | Pre-1.23 workaround; rejected |
| Status quo (slices everywhere) | Zero churn | Misses the streaming gRPC opportunity; long-walk allocations stay | Rejected — explicit user direction to modernise |
Consequences¶
- Positive: callers that walk a collection linearly with early-break semantics (e.g. "first NVIDIA node", "first failing sample") avoid the intermediate
make([]T, 0, len(src))+ per-element copy. The streaming-gRPC handler can hand the iterator straight to astream.Sendloop. New code has a single, idiomaticfor v := range coll.Iter()pattern that matchesslices.All/maps.Valuesshape. - Negative: two API surfaces per touched package instead of one; reviewers have to decide which form to use. Callers that want the slice still pay one
slices.Collectround-trip (one extra allocation vs. the old direct return) — the deprecation shim makes this explicit. - Neutral / follow-ups: drop the
// Deprecated:shims (Registry.All,Registry.ListModels) in v3.x.y-lusoris.N+2. If/when a major-version bump permits, revisit the JSON-backed fields onResult/LadderResultand consider replacing the slices with iterator-only access.
References¶
- Go 1.23 release notes —
iterpackage andrange funcsupport. - Project user direction (verbatim): "Modernization #14: replace single-pass slice returns with
iter.Seq[T]iterators in Go pkg." Paraphrased per CLAUDE.md user-quote rule; sourcereq. - ADR-0700 (Go binaries layout) — establishes
pkg/as the canonical Go module location. - ADR-0705 (JSON schema-forward invariant) — explains why the
cloud/hull/samplesJSON fields stay as slices. - ADR-0713 (vmafx-node Go worker binary) — context for the
pkg/ai.Registrysurface being a hot loop on the worker.