Skip to content

Commit 3d0ba14

Browse files
authored
Add Generate() function for ergonomic stream creation (#42)
1 parent 514ea1c commit 3d0ba14

File tree

5 files changed

+186
-95
lines changed

5 files changed

+186
-95
lines changed

README.md

Lines changed: 36 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ Pipelines typically consist of a sequence of non-blocking channel transformation
210210
The general rule is: any error occurring anywhere in a pipeline is propagated down to the final stage,
211211
where it's caught by some blocking function and returned to the caller.
212212

213-
Rill provides a wide selection of blocking functions. Some of them are:
213+
Rill provides a wide selection of blocking functions. Here are some commonly used ones:
214214

215215
- **ForEach:** Concurrently applies a user function to each item in the stream.
216216
[Example](https://pkg.go.dev/github.com/destel/rill#example-ForEach)
@@ -233,6 +233,7 @@ all goroutines feeding the stream are allowed to complete.
233233
Rill is context-agnostic, meaning that it does not enforce any specific context usage.
234234
However, it's recommended to make user-defined pipeline stages context-aware.
235235
This is especially important for the initial stage, as it allows to stop feeding the pipeline with new items after the context cancellation.
236+
In practice the first stage is often naturally context-aware through Go's standard APIs for databases, HTTP clients, and other external sources.
236237

237238
In the example below the `CheckAllUsersExist` function uses several concurrent workers to check if all users
238239
from the given list exist. When an error occurs (like a non-existent user), the function returns that error
@@ -244,7 +245,7 @@ func main() {
244245
ctx := context.Background()
245246

246247
// ID 999 doesn't exist, so fetching will stop after hitting it.
247-
err := CheckAllUsersExist(ctx, 3, []int{1, 2, 3, 4, 5, 999, 7, 8, 9, 10})
248+
err := CheckAllUsersExist(ctx, 3, []int{1, 2, 3, 4, 5, 999, 7, 8, 9, 10, 11, 12, 13, 14, 15})
248249
fmt.Printf("Check result: %v\n", err)
249250
}
250251

@@ -273,6 +274,22 @@ func CheckAllUsersExist(ctx context.Context, concurrency int, ids []int) error {
273274
}
274275
```
275276

277+
In the example above only the second stage (`mockapi.GetUser`) of the pipeline is context-aware.
278+
**FromSlice** works well here since the input is small, iteration is fast and context cancellation prevents expensive API calls regardless.
279+
The following code demonstrates how to replace **FromSlice** with **Generate** when full context awareness becomes important.
280+
281+
```go
282+
idsStream := rill.Generate(func(send func(int), sendErr func(error)) {
283+
for _, id := range ids {
284+
if ctx.Err() != nil {
285+
return
286+
}
287+
send(id)
288+
}
289+
})
290+
```
291+
292+
276293

277294
## Order Preservation (Ordered Fan-In)
278295
Concurrent processing can boost performance, but since tasks take different amounts of time to complete,
@@ -299,12 +316,12 @@ func main() {
299316
// The string to search for in the downloaded files
300317
needle := []byte("26")
301318

302-
// Start with a stream of numbers from 0 to 999
303-
fileIDs := streamNumbers(ctx, 0, 1000)
304-
305-
// Generate a stream of URLs from http://example.com/file-0.txt to http://example.com/file-999.txt
306-
urls := rill.OrderedMap(fileIDs, 1, func(id int) (string, error) {
307-
return fmt.Sprintf("https://example.com/file-%d.txt", id), nil
319+
// Generate a stream of URLs from https://example.com/file-0.txt
320+
// to https://example.com/file-999.txt
321+
urls := rill.Generate(func(send func(string), sendErr func(error)) {
322+
for i := 0; i < 1000 && ctx.Err() == nil; i++ {
323+
send(fmt.Sprintf("https://example.com/file-%d.txt", i))
324+
}
308325
})
309326

310327
// Download and process the files
@@ -335,22 +352,6 @@ func main() {
335352
fmt.Println("Not found")
336353
}
337354
}
338-
339-
// helper function that creates a stream of numbers [start, end) and respects the context
340-
func streamNumbers(ctx context.Context, start, end int) <-chan rill.Try[int] {
341-
out := make(chan rill.Try[int])
342-
go func() {
343-
defer close(out)
344-
for i := start; i < end; i++ {
345-
select {
346-
case <-ctx.Done():
347-
return
348-
case out <- rill.Try[int]{Value: i}:
349-
}
350-
}
351-
}()
352-
return out
353-
}
354355
```
355356

356357

@@ -389,24 +390,21 @@ func main() {
389390
}
390391

391392
// StreamUsers is a reusable streaming wrapper around the mockapi.ListUsers function.
392-
// It iterates through all listing pages and returns a stream of users.
393+
// It iterates through all listing pages and uses [Generate] to simplify sending users and errors to the resulting stream.
393394
// This function is useful both on its own and as part of larger pipelines.
394395
func StreamUsers(ctx context.Context, query *mockapi.UserQuery) <-chan rill.Try[*mockapi.User] {
395-
res := make(chan rill.Try[*mockapi.User])
396-
397-
if query == nil {
398-
query = &mockapi.UserQuery{}
399-
}
400-
401-
go func() {
402-
defer close(res)
396+
return rill.Generate(func(send func(*mockapi.User), sendErr func(error)) {
397+
var currentQuery mockapi.UserQuery
398+
if query != nil {
399+
currentQuery = *query
400+
}
403401

404402
for page := 0; ; page++ {
405-
query.Page = page
403+
currentQuery.Page = page
406404

407-
users, err := mockapi.ListUsers(ctx, query)
405+
users, err := mockapi.ListUsers(ctx, &currentQuery)
408406
if err != nil {
409-
res <- rill.Wrap[*mockapi.User](nil, err)
407+
sendErr(err)
410408
return
411409
}
412410

@@ -415,12 +413,10 @@ func StreamUsers(ctx context.Context, query *mockapi.UserQuery) <-chan rill.Try[
415413
}
416414

417415
for _, user := range users {
418-
res <- rill.Wrap(user, nil)
416+
send(user)
419417
}
420418
}
421-
}()
422-
423-
return res
419+
})
424420
}
425421
```
426422

example_test.go

Lines changed: 54 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -170,12 +170,12 @@ func Example_ordering() {
170170
// The string to search for in the downloaded files
171171
needle := []byte("26")
172172

173-
// Start with a stream of numbers from 0 to 999
174-
fileIDs := streamNumbers(ctx, 0, 1000)
175-
176-
// Generate a stream of URLs from http://example.com/file-0.txt to http://example.com/file-999.txt
177-
urls := rill.OrderedMap(fileIDs, 1, func(id int) (string, error) {
178-
return fmt.Sprintf("https://example.com/file-%d.txt", id), nil
173+
// Generate a stream of URLs from https://example.com/file-0.txt
174+
// to https://example.com/file-999.txt
175+
urls := rill.Generate(func(send func(string), sendErr func(error)) {
176+
for i := 0; i < 1000 && ctx.Err() == nil; i++ {
177+
send(fmt.Sprintf("https://example.com/file-%d.txt", i))
178+
}
179179
})
180180

181181
// Download and process the files
@@ -267,24 +267,21 @@ func Example_flatMap() {
267267
}
268268

269269
// StreamUsers is a reusable streaming wrapper around the mockapi.ListUsers function.
270-
// It iterates through all listing pages and returns a stream of users.
270+
// It iterates through all listing pages and uses [Generate] to simplify sending users and errors to the resulting stream.
271271
// This function is useful both on its own and as part of larger pipelines.
272272
func StreamUsers(ctx context.Context, query *mockapi.UserQuery) <-chan rill.Try[*mockapi.User] {
273-
res := make(chan rill.Try[*mockapi.User])
274-
275-
if query == nil {
276-
query = &mockapi.UserQuery{}
277-
}
278-
279-
go func() {
280-
defer close(res)
273+
return rill.Generate(func(send func(*mockapi.User), sendErr func(error)) {
274+
var currentQuery mockapi.UserQuery
275+
if query != nil {
276+
currentQuery = *query
277+
}
281278

282279
for page := 0; ; page++ {
283-
query.Page = page
280+
currentQuery.Page = page
284281

285-
users, err := mockapi.ListUsers(ctx, query)
282+
users, err := mockapi.ListUsers(ctx, &currentQuery)
286283
if err != nil {
287-
res <- rill.Wrap[*mockapi.User](nil, err)
284+
sendErr(err)
288285
return
289286
}
290287

@@ -293,12 +290,10 @@ func StreamUsers(ctx context.Context, query *mockapi.UserQuery) <-chan rill.Try[
293290
}
294291

295292
for _, user := range users {
296-
res <- rill.Wrap(user, nil)
293+
send(user)
297294
}
298295
}
299-
}()
300-
301-
return res
296+
})
302297
}
303298

304299
// This example demonstrates how to gracefully stop a pipeline on the first error.
@@ -308,7 +303,7 @@ func Example_context() {
308303
ctx := context.Background()
309304

310305
// ID 999 doesn't exist, so fetching will stop after hitting it.
311-
err := CheckAllUsersExist(ctx, 3, []int{1, 2, 3, 4, 5, 999, 7, 8, 9, 10})
306+
err := CheckAllUsersExist(ctx, 3, []int{1, 2, 3, 4, 5, 999, 7, 8, 9, 10, 11, 12, 13, 14, 15})
312307
fmt.Printf("Check result: %v\n", err)
313308
}
314309

@@ -319,7 +314,15 @@ func CheckAllUsersExist(ctx context.Context, concurrency int, ids []int) error {
319314
defer cancel()
320315

321316
// Convert the slice into a stream
322-
idsStream := rill.FromSlice(ids, nil)
317+
// Use Generate instead of FromSlice to make the first stage context-aware
318+
idsStream := rill.Generate(func(send func(int), sendErr func(error)) {
319+
for _, id := range ids {
320+
if ctx.Err() != nil {
321+
return
322+
}
323+
send(id)
324+
}
325+
})
323326

324327
// Fetch users concurrently.
325328
users := rill.Map(idsStream, concurrency, func(id int) (*mockapi.User, error) {
@@ -615,6 +618,33 @@ func ExampleForEach_ordered() {
615618
fmt.Println("Error:", err)
616619
}
617620

621+
// Generate a stream of URLs from https://example.com/file-0.txt to https://example.com/file-9.txt
622+
func ExampleGenerate() {
623+
urls := rill.Generate(func(send func(string), sendErr func(error)) {
624+
for i := 0; i < 10; i++ {
625+
send(fmt.Sprintf("https://example.com/file-%d.txt", i))
626+
}
627+
})
628+
629+
printStream(urls)
630+
}
631+
632+
// Generate an infinite stream of natural numbers (1, 2, 3, ...).
633+
// New numbers are sent to the stream every 500ms until the context is canceled
634+
func ExampleGenerate_context() {
635+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
636+
defer cancel()
637+
638+
numbers := rill.Generate(func(send func(int), sendErr func(error)) {
639+
for i := 1; ctx.Err() == nil; i++ {
640+
send(i)
641+
time.Sleep(500 * time.Millisecond)
642+
}
643+
})
644+
645+
printStream(numbers)
646+
}
647+
618648
func ExampleMap() {
619649
// Convert a slice of numbers into a stream
620650
numbers := rill.FromSlice([]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, nil)
@@ -746,22 +776,6 @@ func square(x int) int {
746776
return x * x
747777
}
748778

749-
// helper function that creates a stream of numbers [start, end) and respects the context
750-
func streamNumbers(ctx context.Context, start, end int) <-chan rill.Try[int] {
751-
out := make(chan rill.Try[int])
752-
go func() {
753-
defer close(out)
754-
for i := start; i < end; i++ {
755-
select {
756-
case <-ctx.Done():
757-
return
758-
case out <- rill.Try[int]{Value: i}:
759-
}
760-
}
761-
}()
762-
return out
763-
}
764-
765779
// printStream prints all items from a stream (one per line) and an error if any.
766780
func printStream[A any](stream <-chan rill.Try[A]) {
767781
fmt.Println("Result:")

0 commit comments

Comments
 (0)