Skip to content

[Repo Assist] Fix traverseOptionAsync/traverseChoiceAsync — avoid wasteful MoveNext on failure#284

Draft
github-actions[bot] wants to merge 2 commits intomainfrom
repo-assist/improve-traverse-funcs-2026-03-17-64ee7add01bad603
Draft

[Repo Assist] Fix traverseOptionAsync/traverseChoiceAsync — avoid wasteful MoveNext on failure#284
github-actions[bot] wants to merge 2 commits intomainfrom
repo-assist/improve-traverse-funcs-2026-03-17-64ee7add01bad603

Conversation

@github-actions
Copy link
Contributor

🤖 This PR was created by Repo Assist, an automated AI assistant.

Summary

Refactors AsyncSeq.traverseOptionAsync and AsyncSeq.traverseChoiceAsync to fix a subtle over-enumeration bug and improve readability.

The bug

Both functions used a while loop structured like this:

while b.Value.IsSome && not fail.Value do
    let! vOpt = f b.Value.Value
    match vOpt with
    | Some v -> buffer.Add v
    | None -> b := None; fail := true   // set "stop" flags ...
    let! moven = ie.MoveNext()          // ... but STILL read next element!
    b := moven

When f returns None (or Choice2Of2), the code correctly set the exit flags — but then unconditionally called ie.MoveNext() anyway, reading one extra element from the source that would never be used. For sequences with observable side effects on enumeration (logging, side-effecting generators) this was surprising and incorrect.

The fix

The MoveNext() call is moved inside the success branch so it only runs when we actually need the next element:

while current.IsSome && not failed do
    let! vOpt = f current.Value
    match vOpt with
    | Some v ->
        buffer.Add v
        let! next = ie.MoveNext()   // only advance when still consuming
        current <- next
    | None ->
        failed <- true              // exit without reading further

ref cells are also replaced with mutable locals, and ValueOption is used for the error holder in traverseChoiceAsync to avoid heap-allocating a Some wrapper on every run.

New tests

Test Description
traverseOptionAsync returns Some sequence when all elements succeed Happy path — mapped values are correct
traverseOptionAsync does not read past failing element Source enumerator is advanced exactly N times when failure occurs at element N
traverseChoiceAsync returns Choice1Of2 sequence when all elements succeed Happy path
traverseChoiceAsync does not read past failing element Same no-over-read guarantee

Test Status

✅ Build succeeded (0 errors, pre-existing warnings only — NU1605, FS9999 for groupByAsync).

✅ All 325 tests pass — 4 new, 321 pre-existing.

Generated by Repo Assist ·

To install this agentic workflow, run

gh aw add githubnext/agentics/workflows/repo-assist.md@346204513ecfa08b81566450d7d599556807389f

…l MoveNext on failure

Both functions previously called ie.MoveNext() unconditionally at the end
of each loop iteration. When the mapping function f returned failure
(None / Choice2Of2), the loop set the sentinel variables and then still
called MoveNext(), reading one element from the source that would never
be used. For sequences with observable side effects on enumeration this
was unexpected behaviour.

Fix: restructure the loop so MoveNext() is only called in the success
branch. Also modernise mutable state from ref cells to mutable locals.

Tests: 4 new tests covering
- success path for both functions (returns all mapped values)
- 'does not read past failing element' assertion (enumerator read count)

All 325 tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants