Skip to content

Commit ab07918

Browse files
refactor: fix traverseOptionAsync/traverseChoiceAsync — avoid wasteful 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>
1 parent 3249128 commit ab07918

File tree

2 files changed

+82
-31
lines changed

2 files changed

+82
-31
lines changed

src/FSharp.Control.AsyncSeq/AsyncSeq.fs

Lines changed: 35 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2332,43 +2332,47 @@ module AsyncSeq =
23322332

23332333
let traverseOptionAsync (f:'T -> Async<'U option>) (source:AsyncSeq<'T>) : Async<AsyncSeq<'U> option> = async {
23342334
use ie = source.GetEnumerator()
2335-
let! move = ie.MoveNext()
2336-
let b = ref move
2335+
let! first = ie.MoveNext()
2336+
let mutable current = first
2337+
let mutable failed = false
23372338
let buffer = ResizeArray<_>()
2338-
let fail = ref false
2339-
while b.Value.IsSome && not fail.Value do
2340-
let! vOpt = f b.Value.Value
2341-
match vOpt with
2342-
| Some v -> buffer.Add v
2343-
| None -> b := None; fail := true
2344-
let! moven = ie.MoveNext()
2345-
b := moven
2346-
if fail.Value then
2347-
return None
2339+
while current.IsSome && not failed do
2340+
let! vOpt = f current.Value
2341+
match vOpt with
2342+
| Some v ->
2343+
buffer.Add v
2344+
let! next = ie.MoveNext()
2345+
current <- next
2346+
| None ->
2347+
failed <- true
2348+
if failed then
2349+
return None
23482350
else
2349-
let res = buffer.ToArray()
2350-
return Some (asyncSeq { for v in res do yield v })
2351-
}
2351+
let res = buffer.ToArray()
2352+
return Some (asyncSeq { for v in res do yield v })
2353+
}
23522354

23532355
let traverseChoiceAsync (f:'T -> Async<Choice<'U, 'e>>) (source:AsyncSeq<'T>) : Async<Choice<AsyncSeq<'U>, 'e>> = async {
23542356
use ie = source.GetEnumerator()
2355-
let! move = ie.MoveNext()
2356-
let b = ref move
2357+
let! first = ie.MoveNext()
2358+
let mutable current = first
2359+
let mutable failWith = ValueNone
23572360
let buffer = ResizeArray<_>()
2358-
let fail = ref None
2359-
while b.Value.IsSome && fail.Value.IsNone do
2360-
let! vOpt = f b.Value.Value
2361-
match vOpt with
2362-
| Choice1Of2 v -> buffer.Add v
2363-
| Choice2Of2 err -> b := None; fail := Some err
2364-
let! moven = ie.MoveNext()
2365-
b := moven
2366-
match fail.Value with
2367-
| Some err -> return Choice2Of2 err
2368-
| None ->
2369-
let res = buffer.ToArray()
2370-
return Choice1Of2 (asyncSeq { for v in res do yield v })
2371-
}
2361+
while current.IsSome && failWith.IsNone do
2362+
let! vOpt = f current.Value
2363+
match vOpt with
2364+
| Choice1Of2 v ->
2365+
buffer.Add v
2366+
let! next = ie.MoveNext()
2367+
current <- next
2368+
| Choice2Of2 err ->
2369+
failWith <- ValueSome err
2370+
match failWith with
2371+
| ValueSome err -> return Choice2Of2 err
2372+
| ValueNone ->
2373+
let res = buffer.ToArray()
2374+
return Choice1Of2 (asyncSeq { for v in res do yield v })
2375+
}
23722376

23732377
#if (NETSTANDARD || NET)
23742378
#if !FABLE_COMPILER

tests/FSharp.Control.AsyncSeq.Tests/AsyncSeqTests.fs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1211,6 +1211,53 @@ let ``AsyncSeq.traverseChoiceAsync``() =
12111211
Assert.AreEqual("oh no", e)
12121212
Assert.True(([1;2] = (seen |> List.ofSeq)))
12131213

1214+
[<Test>]
1215+
let ``AsyncSeq.traverseOptionAsync returns Some sequence when all elements succeed``() =
1216+
let s = [1;2;3] |> AsyncSeq.ofSeq
1217+
let f i = Some (i * 10) |> async.Return
1218+
let r = AsyncSeq.traverseOptionAsync f s |> Async.RunSynchronously
1219+
match r with
1220+
| None -> Assert.Fail("Expected Some")
1221+
| Some result ->
1222+
let values = result |> AsyncSeq.toListAsync |> Async.RunSynchronously
1223+
Assert.AreEqual([10;20;30], values)
1224+
1225+
[<Test>]
1226+
let ``AsyncSeq.traverseOptionAsync does not read past failing element``() =
1227+
let readCount = ref 0
1228+
let s = asyncSeq {
1229+
for i in 1..10 do
1230+
incr readCount
1231+
yield i
1232+
}
1233+
let f i = (if i <= 3 then Some i else None) |> async.Return
1234+
let _r = AsyncSeq.traverseOptionAsync f s |> Async.RunSynchronously
1235+
// f returns None on element 4; only elements 1..4 should be read from source
1236+
Assert.AreEqual(4, readCount.Value)
1237+
1238+
[<Test>]
1239+
let ``AsyncSeq.traverseChoiceAsync returns Choice1Of2 sequence when all elements succeed``() =
1240+
let s = [1;2;3] |> AsyncSeq.ofSeq
1241+
let f i = Choice1Of2 (i * 10) |> async.Return
1242+
let r = AsyncSeq.traverseChoiceAsync f s |> Async.RunSynchronously
1243+
match r with
1244+
| Choice2Of2 _ -> Assert.Fail("Expected Choice1Of2")
1245+
| Choice1Of2 result ->
1246+
let values = result |> AsyncSeq.toListAsync |> Async.RunSynchronously
1247+
Assert.AreEqual([10;20;30], values)
1248+
1249+
[<Test>]
1250+
let ``AsyncSeq.traverseChoiceAsync does not read past failing element``() =
1251+
let readCount = ref 0
1252+
let s = asyncSeq {
1253+
for i in 1..10 do
1254+
incr readCount
1255+
yield i
1256+
}
1257+
let f i = (if i <= 3 then Choice1Of2 i else Choice2Of2 "stop") |> async.Return
1258+
let _r = AsyncSeq.traverseChoiceAsync f s |> Async.RunSynchronously
1259+
// f returns Choice2Of2 on element 4; only elements 1..4 should be read from source
1260+
Assert.AreEqual(4, readCount.Value)
12141261

12151262
[<Test>]
12161263
let ``AsyncSeq.toBlockingSeq does not hung forever and rethrows exception``() =

0 commit comments

Comments
 (0)