Skip to content

♻️ Make forEach a resource for guaranteed stream subscription#209

Open
taras wants to merge 1 commit intomainfrom
refactor/foreach-resource
Open

♻️ Make forEach a resource for guaranteed stream subscription#209
taras wants to merge 1 commit intomainfrom
refactor/foreach-resource

Conversation

@taras
Copy link
Copy Markdown
Member

@taras taras commented Apr 5, 2026

Motivation

forEach as a blocking Operation<TClose> requires callers to wrap it in spawn() + sleep(0) when used as a background consumer — the sleep is needed to yield control so the spawned task can subscribe before values are sent. This pattern appears in throttle.test.ts, batch.test.ts, and watch.test.ts.

As discussed in #207, making forEach a resource eliminates this by guaranteeing the stream subscription is established before the caller resumes.

Approach

Rewrote forEach to return Operation<Task<TClose>> using Effection's resource():

  1. Subscribe to the stream (before provide)
  2. Spawn the consumption loop as a child task (before provide)
  3. Provide the Task<TClose> handle — caller resumes with subscription active

Background consumers become simpler — no spawn() or sleep(0) needed:

// before
yield* spawn(() => forEach(fn, stream));
yield* sleep(0);

// after
yield* forEach(fn, stream);

Blocking consumers await the task:

// before
const close = yield* forEach(fn, stream);

// after
const close = yield* (yield* forEach(fn, stream));

Files changed:

  • stream-helpers/for-each.ts — resource rewrite
  • stream-helpers/for-each.test.ts — double yield (2 sites)
  • stream-helpers/batch.test.ts — remove spawn+sleep (2 sites)
  • stream-helpers/reduce.test.ts — double yield (3 sites)
  • stream-helpers/take.test.ts — double yield (5 sites)
  • stream-helpers/take-while.test.ts — double yield (5 sites)
  • stream-helpers/take-until.test.ts — double yield (5 sites)
  • watch/test/watch.test.ts — remove spawn wrapper (1 site)

Summary by CodeRabbit

  • Refactoring

    • Modified forEach helper to return a Task handle instead of close value directly.
    • Simplified control flow in stream helper tests by removing unnecessary task spawning.
  • Tests

    • Updated test generators across multiple stream helpers to align with refactored control flow.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 5, 2026

📝 Walkthrough

Walkthrough

The forEach stream helper is refactored to return an Operation<Task<TClose>> instead of Operation<TClose>, using a background task to consume the stream. Test files are updated to use double yield* syntax to unwrap the task, and explicit spawn() calls around forEach() are removed since spawning is now handled internally.

Changes

Cohort / File(s) Summary
forEach API Change
stream-helpers/for-each.ts
Return type changed from Operation<TClose> to Operation<Task<TClose>>. Implementation refactored to use resource that spawns a background task to consume the stream and returns the task handle instead of the close value directly.
Test Updates for New forEach Return Type
stream-helpers/for-each.test.ts, stream-helpers/reduce.test.ts, stream-helpers/take-until.test.ts, stream-helpers/take-while.test.ts, stream-helpers/take.test.ts
Updated yield* forEach(...) to yield* yield* forEach(...) across all invocations to properly unwrap the new Task<TClose> return type and access the close value.
Simplified Stream Consumption
stream-helpers/batch.test.ts, watch/test/watch.test.ts
Removed explicit spawn() wrapping around forEach() calls; replaced with direct yield* forEach(...) invocation. In batch.test.ts, also removed subsequent yield* sleep(0) that was used to yield control to spawned task.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: making forEach a resource to guarantee stream subscription, which is the central refactoring goal.
Description check ✅ Passed The description includes both required sections (Motivation and Approach) with clear explanations of the problem, solution, and specific code changes across all modified files.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Policy Compliance ✅ Passed The PR passes all Strict policies required by .policies/index.md. No Agent Marketing policy is satisfied with no AI agent attribution. Package.json Metadata policy is satisfied with valid description field.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/foreach-resource

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 5, 2026

Open in StackBlitz

npm i https://pkg.pr.new/@effectionx/stream-helpers@209
npm i https://pkg.pr.new/@effectionx/watch@209

commit: 1b336b7

@taras taras requested a review from cowboyd April 5, 2026 11:06
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@stream-helpers/for-each.ts`:
- Around line 42-53: The spawned Task currently doesn't own the subscription
because subscription is acquired in the outer resource scope (subscription is
set before spawn), so halt() on the returned Task stops the child but leaves the
subscription alive; move the subscription acquisition into the spawned child
(i.e., call yield* stream.next()/subscribe inside the generator passed to spawn)
so the spawned Operation<TClose> owns and cleans up the subscription, mirroring
drain()’s semantics (see resource(), spawn(), subscription, task), and add a
unit test that calls task.halt() early to assert the subscription is closed when
the task halts.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5d8da522-afc1-4044-a920-1878860256d0

📥 Commits

Reviewing files that changed from the base of the PR and between 8e9b532 and 1b336b7.

📒 Files selected for processing (8)
  • stream-helpers/batch.test.ts
  • stream-helpers/for-each.test.ts
  • stream-helpers/for-each.ts
  • stream-helpers/reduce.test.ts
  • stream-helpers/take-until.test.ts
  • stream-helpers/take-while.test.ts
  • stream-helpers/take.test.ts
  • watch/test/watch.test.ts

Comment on lines +42 to +53
return resource(function* (provide) {
const subscription = yield* stream;
const task = yield* spawn(function* (): Operation<TClose> {
let next = yield* subscription.next();
while (!next.done) {
yield* fn(next.value);
next = yield* subscription.next();
}
return next.value;
},
};
});
yield* provide(task);
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find and examine the for-each.ts file
find . -name "for-each.ts" -type f

Repository: thefrontside/effectionx

Length of output: 95


🏁 Script executed:

# Also locate any test files for forEach
find . -name "*for-each*" -o -name "*forEach*" | grep -E "\.(test|spec)\.(ts|tsx)?"

Repository: thefrontside/effectionx

Length of output: 100


🏁 Script executed:

# Check the directory structure for stream-helpers
ls -la stream-helpers/ 2>/dev/null || find . -type d -name "stream-helpers"

Repository: thefrontside/effectionx

Length of output: 2466


🏁 Script executed:

cat -n stream-helpers/for-each.ts

Repository: thefrontside/effectionx

Length of output: 2071


🏁 Script executed:

cat -n stream-helpers/for-each.test.ts

Repository: thefrontside/effectionx

Length of output: 1521


🏁 Script executed:

cat -n stream-helpers/drain.ts

Repository: thefrontside/effectionx

Length of output: 1482


🏁 Script executed:

# Search for patterns where provide() is used with spawn
rg "provide\(" stream-helpers/ -B 5 -A 2

Repository: thefrontside/effectionx

Length of output: 890


🏁 Script executed:

# Look for tests that halt or cancel tasks from stream operations
rg "\.halt\(\)" stream-helpers/ -B 3 -A 3

Repository: thefrontside/effectionx

Length of output: 523


🏁 Script executed:

# Check other stream operations for comparison with forEach pattern
cat -n stream-helpers/reduce.ts

Repository: thefrontside/effectionx

Length of output: 1868


🏁 Script executed:

# Look for documentation or tests about resource lifecycle
rg "resource\(" stream-helpers/ -B 2 -A 5

Repository: thefrontside/effectionx

Length of output: 1046


🏁 Script executed:

# Check policies directory for guidance
find . -path "*policies*" -name "*.md" | head -20

Repository: thefrontside/effectionx

Length of output: 702


🏁 Script executed:

# Look at the stream-helpers README
cat stream-helpers/README.md | head -100

Repository: thefrontside/effectionx

Length of output: 2706


🏁 Script executed:

# Search for other resource patterns with spawn
rg "spawn\(" . -B 10 -A 10 | grep -A 15 "resource"

Repository: thefrontside/effectionx

Length of output: 6048


🏁 Script executed:

# Check if there are examples of task.halt() in tests
rg "task\.halt\|\.halt\(\)" . --type ts -B 5 -A 5 | head -80

Repository: thefrontside/effectionx

Length of output: 49


🏁 Script executed:

# Read the relevant policies
cat .policies/ergonomics.md

Repository: thefrontside/effectionx

Length of output: 4267


🏁 Script executed:

cat .policies/structured-concurrency.md

Repository: thefrontside/effectionx

Length of output: 3956


🏁 Script executed:

# Look for any examples of Task halt behavior in tests
rg "\.halt\(\)" . --type ts -A 3 -B 3

Repository: thefrontside/effectionx

Length of output: 4099


🏁 Script executed:

# Check parallel.ts to see how similar resource/spawn patterns handle this
cat -n ./fx/parallel.ts | head -80

Repository: thefrontside/effectionx

Length of output: 3030


🏁 Script executed:

# Verify Effection's resource lifecycle semantics
rg "resource\(" . --type ts -A 8 | grep -A 8 "provide" | head -50

Repository: thefrontside/effectionx

Length of output: 2468


🏁 Script executed:

# Check if there are any comments or docs about this pattern in Effection
rg "resource.*spawn.*provide" . -C 5

Repository: thefrontside/effectionx

Length of output: 49


🏁 Script executed:

# Look for any issues or discussions about task.halt with resources
find . -name "*.md" -type f -exec grep -l "halt\|cancellation\|cleanup" {} \; | grep -E "README|GUIDE|DESIGN"

Repository: thefrontside/effectionx

Length of output: 187


🏁 Script executed:

# Check the README files for any discussion of resource/task lifecycle
cat ./effect-ts/README.md | grep -A 10 -B 5 "resource\|halt\|cleanup" | head -40

Repository: thefrontside/effectionx

Length of output: 1403


🏁 Script executed:

# Look for how forEach should work by checking if there are any examples
rg "forEach" . --type ts -B 5 -A 10 | grep -E "(yield|task|halt)" -A 3 -B 3 | head -80

Repository: thefrontside/effectionx

Length of output: 3895


The returned Task doesn't own the subscription lifetime.

Line 43 subscribes in the resource scope, but line 44 spawns a child task that only consumes from the subscription. Halting the returned Task stops the drain loop but leaves the resource scope mounted until the caller's scope exits, keeping the subscription alive. This violates .policies/ergonomics.md ("Helpers must preserve structured concurrency semantics") and .policies/structured-concurrency.md ("Cancellation, cleanup, and task lifetimes should be obvious and enforced").

Compare this to drain() (line 27–32), where the subscription lifetime equals the operation lifetime—clear and correct. Move the subscription acquisition into the spawned child task, or document that the returned Task doesn't own cleanup and callers must scope the entire operation. Add a test for early task.halt() to verify the intended semantics.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@stream-helpers/for-each.ts` around lines 42 - 53, The spawned Task currently
doesn't own the subscription because subscription is acquired in the outer
resource scope (subscription is set before spawn), so halt() on the returned
Task stops the child but leaves the subscription alive; move the subscription
acquisition into the spawned child (i.e., call yield* stream.next()/subscribe
inside the generator passed to spawn) so the spawned Operation<TClose> owns and
cleans up the subscription, mirroring drain()’s semantics (see resource(),
spawn(), subscription, task), and add a unit test that calls task.halt() early
to assert the subscription is closed when the task halts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant