From f787ab352a0b3f86294d1df4ac56f88c3f289752 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 01:11:55 +0000 Subject: [PATCH 1/5] perf: use while! in iter/fold/reduce/mapFold/tryLast/Drop/Truncate Replace the manual 'go' flag + initial MoveNextAsync pre-advance pattern with while! in iter, fold, reduce, mapFold, tryLast, and the Drop/Truncate cases in skipOrTake. The old pattern: let! step = e.MoveNextAsync() go <- step while go do ... let! step = e.MoveNextAsync() go <- step becomes: while! e.MoveNextAsync() do ... This matches the pattern already established by sum, sumBy, average, averageBy, lengthBy, etc. Benefits: - Removes one mutable bool (go) per function - Removes the initial redundant pre-advance call before the loop - Unifies style across the module Also simplifies the Drop and Truncate loop bodies to use explicit i/yielded counters instead of a pos variable with an inline if/else. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- release-notes.txt | 1 + src/FSharp.Control.TaskSeq/TaskSeqInternal.fs | 113 ++++++------------ 2 files changed, 35 insertions(+), 79 deletions(-) diff --git a/release-notes.txt b/release-notes.txt index d07e4fc..888256a 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -3,6 +3,7 @@ Release notes: 1.0.0 - adds taskSeqDynamic computation expression and TaskSeqDynamic/TaskSeqDynamicInfo types for dynamic (FSI-compatible) resumable code, fixing issue where taskSeq would raise NotImplementedException in F# Interactive, #246 + - perf: simplify iter, fold, reduce, mapFold, tryLast, skipOrTake (Drop/Truncate) to use while! and remove manual go-flag and initial MoveNextAsync pre-advance, matching the pattern already used by sum/sumBy/average - perf: TaskSeq.chunkBy and chunkByAsync reuse the ResizeArray buffer between chunks, reducing allocations on sequences with many chunk boundaries - fixes: TaskSeq.insertAt, insertManyAt, removeAt, removeManyAt, updateAt now raise ArgumentNullException (not NullReferenceException) when given a null source; insertManyAt also validates the values argument - refactor: simplify lengthBy and lengthBeforeMax to use while! and remove the redundant mutable 'go' and initial MoveNextAsync diff --git a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs index 261dbba..44de771 100644 --- a/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs +++ b/src/FSharp.Control.TaskSeq/TaskSeqInternal.fs @@ -357,43 +357,31 @@ module internal TaskSeqInternal = task { use e = source.GetAsyncEnumerator CancellationToken.None - let mutable go = true - let! step = e.MoveNextAsync() - go <- step - // this ensures that the inner loop is optimized for the closure - // though perhaps we need to split into individual functions after all to use - // InlineIfLambda? + // Each branch keeps its own while! loop so the match dispatch is hoisted out and + // the JIT sees a tight, single-case loop (same pattern as sum/sumBy etc.). match action with | CountableAction action -> let mutable i = 0 - while go do - do action i e.Current - let! step = e.MoveNextAsync() + while! e.MoveNextAsync() do + action i e.Current i <- i + 1 - go <- step | SimpleAction action -> - while go do - do action e.Current - let! step = e.MoveNextAsync() - go <- step + while! e.MoveNextAsync() do + action e.Current | AsyncCountableAction action -> let mutable i = 0 - while go do + while! e.MoveNextAsync() do do! action i e.Current - let! step = e.MoveNextAsync() i <- i + 1 - go <- step | AsyncSimpleAction action -> - while go do + while! e.MoveNextAsync() do do! action e.Current - let! step = e.MoveNextAsync() - go <- step } let fold folder initial (source: TaskSeq<_>) = @@ -401,24 +389,17 @@ module internal TaskSeqInternal = task { use e = source.GetAsyncEnumerator CancellationToken.None - let mutable go = true let mutable result = initial - let! step = e.MoveNextAsync() - go <- step match folder with | FolderAction folder -> - while go do + while! e.MoveNextAsync() do result <- folder result e.Current - let! step = e.MoveNextAsync() - go <- step | AsyncFolderAction folder -> - while go do + while! e.MoveNextAsync() do let! tempResult = folder result e.Current result <- tempResult - let! step = e.MoveNextAsync() - go <- step return result } @@ -457,22 +438,16 @@ module internal TaskSeqInternal = raiseEmptySeq () let mutable result = e.Current - let! step = e.MoveNextAsync() - let mutable go = step match folder with | FolderAction folder -> - while go do + while! e.MoveNextAsync() do result <- folder result e.Current - let! step = e.MoveNextAsync() - go <- step | AsyncFolderAction folder -> - while go do + while! e.MoveNextAsync() do let! tempResult = folder result e.Current result <- tempResult - let! step = e.MoveNextAsync() - go <- step return result } @@ -482,28 +457,21 @@ module internal TaskSeqInternal = task { use e = source.GetAsyncEnumerator CancellationToken.None - let mutable go = true let mutable state = initial let results = ResizeArray() - let! step = e.MoveNextAsync() - go <- step match folder with | MapFolderAction folder -> - while go do + while! e.MoveNextAsync() do let result, newState = folder state e.Current results.Add result state <- newState - let! step = e.MoveNextAsync() - go <- step | AsyncMapFolderAction folder -> - while go do + while! e.MoveNextAsync() do let! (result, newState) = folder state e.Current results.Add result state <- newState - let! step = e.MoveNextAsync() - go <- step return results.ToArray(), state } @@ -804,15 +772,10 @@ module internal TaskSeqInternal = task { use e = source.GetAsyncEnumerator CancellationToken.None - let mutable go = true let mutable last = ValueNone - let! step = e.MoveNextAsync() - go <- step - while go do + while! e.MoveNextAsync() do last <- ValueSome e.Current - let! step = e.MoveNextAsync() - go <- step match last with | ValueSome value -> return Some value @@ -1233,24 +1196,19 @@ module internal TaskSeqInternal = else taskSeq { use e = source.GetAsyncEnumerator CancellationToken.None + let mutable i = 0 + let mutable cont = true - let! step = e.MoveNextAsync() - let mutable cont = step - let mutable pos = 0 - - // skip, or stop looping if we reached the end - while cont do - pos <- pos + 1 - - if pos < count then - let! moveNext = e.MoveNextAsync() - cont <- moveNext - else - cont <- false + // advance past 'count' elements; stop early if the source is shorter + while cont && i < count do + let! hasMore = e.MoveNextAsync() + if hasMore then i <- i + 1 else cont <- false - // return the rest - while! e.MoveNextAsync() do - yield e.Current + // return remaining elements; enumerator is at element (count-1) so one + // more MoveNext is needed to reach element (count) + if cont then + while! e.MoveNextAsync() do + yield e.Current } | Take -> @@ -1277,19 +1235,16 @@ module internal TaskSeqInternal = else taskSeq { use e = source.GetAsyncEnumerator CancellationToken.None + let mutable yielded = 0 + let mutable cont = true - let! step = e.MoveNextAsync() - let mutable cont = step - let mutable pos = 0 - - // return items until we've exhausted the seq - while cont do - yield e.Current - pos <- pos + 1 + // yield up to 'count' elements; stop when exhausted or limit reached + while cont && yielded < count do + let! hasMore = e.MoveNextAsync() - if pos < count then - let! moveNext = e.MoveNextAsync() - cont <- moveNext + if hasMore then + yield e.Current + yielded <- yielded + 1 else cont <- false From aa99205de5c27b6e87cb68010d0537d8de17378c Mon Sep 17 00:00:00 2001 From: Don Syme Date: Mon, 13 Apr 2026 12:18:34 +1000 Subject: [PATCH 2/5] Update release-notes.txt --- release-notes.txt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/release-notes.txt b/release-notes.txt index 58162ce..cf0c875 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -1,7 +1,7 @@ Release notes: -1.0.0 +1.0.1 - adds taskSeqDynamic computation expression and TaskSeqDynamic/TaskSeqDynamicInfo types for dynamic (FSI-compatible) resumable code, fixing issue where taskSeq would raise NotImplementedException in F# Interactive, #246 - perf: simplify iter, fold, reduce, mapFold, tryLast, skipOrTake (Drop/Truncate) to use while! and remove manual go-flag and initial MoveNextAsync pre-advance, matching the pattern already used by sum/sumBy/average - perf: toResizeArrayAsync (and therefore toArrayAsync, toListAsync, toResizeArrayAsync, toIListAsync) uses a direct loop instead of going through iter, avoiding a lambda and DU allocation per call @@ -10,7 +10,6 @@ Release notes: - fixes: TaskSeq.insertAt, insertManyAt, removeAt, removeManyAt, updateAt now raise ArgumentNullException (not NullReferenceException) when given a null source; insertManyAt also validates the values argument - refactor: simplify lengthBy and lengthBeforeMax to use while! and remove the redundant mutable 'go' and initial MoveNextAsync - adds TaskSeq.distinctUntilChangedWith and TaskSeq.distinctUntilChangedWithAsync, #345 - - adds TaskSeq.withCancellation, #167 - adds TaskSeq.replicateInfinite, replicateInfiniteAsync, replicateUntilNoneAsync, #345 - adds TaskSeq.firstOrDefault, lastOrDefault, #345 - adds TaskSeq.splitAt, #345 @@ -21,6 +20,9 @@ Release notes: - docs: adds missing XML documentation tags to singleton, isEmpty, length, lengthOrMax, lengthBy, and lengthByAsync - test: adds 70 new tests to TaskSeq.Fold.Tests.fs covering call-count assertions, folder-not-called-on-empty, ordering, null initial state, and fold/foldAsync equivalence +1.0.0 + - adds TaskSeq.withCancellation, #167 + 0.7.0 - performance: TaskSeq.exists, existsAsync, contains no longer allocate an intermediate Option value - test: adds 67 tests for TaskSeq.lengthOrMax (previously untested) From 3e5c2d8605922fea8ad06951fcf3629aafc9977f Mon Sep 17 00:00:00 2001 From: Don Syme Date: Mon, 13 Apr 2026 12:19:07 +1000 Subject: [PATCH 3/5] Update Version.props --- Version.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Version.props b/Version.props index c424649..28785c5 100644 --- a/Version.props +++ b/Version.props @@ -1,6 +1,6 @@ - 1.0.0 + 1.0.1 - \ No newline at end of file + From ef3b4579faa5dc271414aa5daa0febd6c1e1b0e7 Mon Sep 17 00:00:00 2001 From: Don Syme Date: Mon, 13 Apr 2026 12:26:26 +1000 Subject: [PATCH 4/5] update version logic --- AGENTS.md | 24 ++++++++++++++++++++---- Version.props | 4 ++-- release-notes.txt | 2 +- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f40a85b..8debae2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ FSharp.Control.TaskSeq is an F# library providing a `taskSeq` computation expres - `src/FSharp.Control.TaskSeq.Test/` — xUnit test project (net9.0) - `src/FSharp.Control.TaskSeq.SmokeTests/` — Smoke/integration tests - `src/FSharp.Control.TaskSeq.sln` — Solution file -- `Version.props` — Single source of truth for the package version +- `Version.props` — Package version (derived automatically from `release-notes.txt`) - `build.cmd` — Windows build/test script used by CI ## Build @@ -102,12 +102,28 @@ All workflows are in `.github/workflows/`: ## Release Notes -**Required**: Every PR that adds features, fixes bugs, or makes user-visible changes **must** include an update to `release-notes.txt`. Add a bullet under the appropriate version heading (currently `0.5.0`). The format is: +`release-notes.txt` is the **single source of truth** for the package version. `Version.props` extracts the version automatically by finding the first line that matches a `X.Y.Z` semver pattern. The `Unreleased` section at the top of the file is skipped because it does not match this pattern. + +**Format requirements:** + +- The file **must** always begin with a heading line `Unreleased` (after the optional `Release notes:` header). This section holds in-progress changes before they are assigned a version number. It must always be present, even if empty. +- Below `Unreleased`, versioned sections are listed in descending order (`1.0.0`, `0.7.0`, …). The topmost versioned section determines the package version. +- To bump the version, add a new version heading between `Unreleased` and the previous version. + +Example: ``` -0.5.0 +Release notes: + +Unreleased + - upcoming change description + +1.1.0 - adds TaskSeq.myFunction and TaskSeq.myFunctionAsync, # - fixes , # + +1.0.0 + - adds TaskSeq.withCancellation, #167 ``` -If you are bumping to a new version, also update `Version.props`. PRs that touch library source (`src/FSharp.Control.TaskSeq/`) without updating `release-notes.txt` are incomplete. +**Required**: Every PR that adds features, fixes bugs, or makes user-visible changes **must** add a bullet under the `Unreleased` heading in `release-notes.txt`. PRs that touch library source (`src/FSharp.Control.TaskSeq/`) without updating `release-notes.txt` are incomplete. diff --git a/Version.props b/Version.props index 28785c5..1bea20e 100644 --- a/Version.props +++ b/Version.props @@ -1,6 +1,6 @@ - - 1.0.1 + + $([System.Text.RegularExpressions.Regex]::Match($([System.IO.File]::ReadAllText(`$(MSBuildThisFileDirectory)release-notes.txt`)), `(?m)^(\d+\.\d+\.\d+)`).Groups.get_Item(1).Value) diff --git a/release-notes.txt b/release-notes.txt index cf0c875..03abb79 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -1,7 +1,7 @@ Release notes: -1.0.1 +Unreleased - adds taskSeqDynamic computation expression and TaskSeqDynamic/TaskSeqDynamicInfo types for dynamic (FSI-compatible) resumable code, fixing issue where taskSeq would raise NotImplementedException in F# Interactive, #246 - perf: simplify iter, fold, reduce, mapFold, tryLast, skipOrTake (Drop/Truncate) to use while! and remove manual go-flag and initial MoveNextAsync pre-advance, matching the pattern already used by sum/sumBy/average - perf: toResizeArrayAsync (and therefore toArrayAsync, toListAsync, toResizeArrayAsync, toIListAsync) uses a direct loop instead of going through iter, avoiding a lambda and DU allocation per call From e3780793315a88c60f433550a571ffd02547e101 Mon Sep 17 00:00:00 2001 From: Don Syme Date: Mon, 13 Apr 2026 12:27:10 +1000 Subject: [PATCH 5/5] update version logic --- release-notes.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/release-notes.txt b/release-notes.txt index 03abb79..87ae35d 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -2,6 +2,8 @@ Release notes: Unreleased + +1.1.0 - adds taskSeqDynamic computation expression and TaskSeqDynamic/TaskSeqDynamicInfo types for dynamic (FSI-compatible) resumable code, fixing issue where taskSeq would raise NotImplementedException in F# Interactive, #246 - perf: simplify iter, fold, reduce, mapFold, tryLast, skipOrTake (Drop/Truncate) to use while! and remove manual go-flag and initial MoveNextAsync pre-advance, matching the pattern already used by sum/sumBy/average - perf: toResizeArrayAsync (and therefore toArrayAsync, toListAsync, toResizeArrayAsync, toIListAsync) uses a direct loop instead of going through iter, avoiding a lambda and DU allocation per call