rill

package module
v0.1.1-debug1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 11, 2024 License: MIT Imports: 4 Imported by: 16

README

Rill GoDoc Go Report Card codecov

Rill (noun: a small stream) is a comprehensive Go toolkit for streaming, parallel processing, and pipeline construction. Designed to reduce boilerplate and simplify usage, it empowers developers to focus on core logic without getting bogged down by the complexity of concurrency.

Key features

  • Lightweight: fast and modular, can be easily integrated into existing projects
  • Easy to use: the complexity of managing goroutines, wait groups, and error handling is abstracted away
  • Concurrent: control the level of concurrency for all operations
  • Batching: provides a simple way to organize and process data in batches
  • Error Handling: provides a structured way to handle errors in concurrent apps
  • Streaming: handles real-time data streams or large datasets with a minimal memory footprint
  • Order Preservation: offers functions that preserve the original order of data, while still allowing for concurrent processing
  • Efficient Resource Use: the number of goroutines and allocations does not depend on the data size
  • Generic: all operations are type-safe and can be used with any data type
  • Functional Programming: based on functional programming concepts, making operations like map, filter, flatMap and others available for channel-based workflows

Installation

go get github.com/destel/rill

Example usage

Consider function that fetches keys from multiple URLs, retrieves their values from a key-value database, and prints them. This example demonstrates the library's strengths in handling concurrent tasks, error propagation, batching and data streaming, all while maintaining simplicity and efficiency.

See a full runnable example at examples/kv-read

type KV struct {
    Key   string
    Value string
}


func printValuesFromDB(ctx context.Context, urls []string) error {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel() // In case of error, this ensures all http and DB operations are canceled
    
    // Convert urls into a channel
    urlsChan := rill.FromSlice(urls, nil)
    
    // Fetch and stream keys from each URL concurrently
    keys := rill.FlatMap(urlsChan, 10, func(url string) <-chan rill.Try[string] {
        return streamLines(ctx, url)
    })
    
    // Exclude any empty keys from the stream
    keys = rill.Filter(keys, 5, func(key string) (bool, error) {
        return key != "", nil
    })
    
    // Organize keys into manageable batches of 10 for bulk operations
    keyBatches := rill.Batch(keys, 10, 1*time.Second)
    
    // Fetch values from DB for each batch of keys
    resultBatches := rill.Map(keyBatches, 5, func(keys []string) ([]KV, error) {
        values, err := dbMultiGet(ctx, keys...)
        if err != nil {
            return nil, err
        }
        
        results := make([]KV, len(keys))
        for i, key := range keys {
            results[i] = KV{Key: key, Value: values[i]}
        }
        
        return results, nil
    })
    
    // Convert batches back to a single items for final processing
    results := rill.Unbatch(resultBatches)
    
    // Exclude any empty values from the stream
    results = rill.Filter(results, 5, func(kv KV) (bool, error) {
        return kv.Value != "<nil>", nil
    })
    
    // Iterate over each key-value pair and print
    cnt := 0
    err := rill.ForEach(results, 1, func(kv KV) error {
        fmt.Println(kv.Key, "=>", kv.Value)
        cnt++
        return nil
    })
    if err != nil {
        return err
    }
    
    fmt.Println("Total keys:", cnt)
    return nil
}

// streamLines reads a file from the given URL line by line and returns a channel of lines
func streamLines(ctx context.Context, url string) <-chan rill.Try[string] {
    // ...
}

// dbMultiGet does a batch read from a key-value database. It returns the values for the given keys.
func dbMultiGet(ctx context.Context, keys ...string) ([]string, error) {
    // ...
}




Testing strategy

Rill has a test coverage of over 95%, with testing focused on:

  • Correctness: ensuring that functions produce accurate results at different levels of concurrency
  • Concurrency: confirming that correct number of goroutines is spawned and utilized
  • Ordering: ensuring that ordered versions of functions preserve the order, while basic versions do not

Design philosophy

At the heart of rill lies a simple yet powerful concept: operating on channels of wrapped values, encapsulated by the Try structure. Such channels can be created manually or through utilities like FromSlice or FromChan, and then transformed via operations such as Map, Filter, FlatMap and others. Finally, when all processing stages are completed, the data can be consumed by ForEach, ToSlice or manually by iterating over the resulting channel.

Batching

Batching is a common pattern in concurrent processing, especially when dealing with external services or databases. Rill provides a Batch function that organizes a stream of items into batches of a specified size. It's also possible to specify a timeout, after which the batch is emitted even if it's not full. This is useful for keeping an application reactive when input stream is slow or sparse.

Fan-In and Fan-Out

The library offers mechanisms for fanning in and out data streams. Fan-in is done with the Merge function, which consolidates multiple data streams into a single unified channel. Fan-out is done with the Split2 function, that divides a single input stream into two distinct output channels. This division is based on a discriminator function, allowing parallel processing paths based on data characteristics.

Error handling

In the examples above errors are handled using ForEach, which is good for most use cases. ForEach stops processing on the first error and returns it. If you need to handle errors in the middle of a pipeline, and/or continue processing after an error, there is a Catch function that can be used for that.

results := rill.Map(input, 10, func(item int) (int, error) {
    // do some processing
})

results = rill.Catch(results, 5, func(err error) {
    if errors.Is(err, sql.ErrNoRows) {
        return nil // ignore this error
    } else {
        return fmt.Errorf("error processing item: %w", err) // wrap other errors
    }
})

err := rill.ForEach(results, 1, func(item int) error {
    // process results as usual
})

Termination and resource leaks

In Go concurrent applications, if there are no readers for a channel, writers can become stuck, leading to potential goroutine and memory leaks. This issue extends to rill pipelines, which are built on Go channels; if any stage in a pipeline lacks a consumer, the whole chain of producers upstream may become blocked. Therefore, it's vital to ensure that pipelines are fully consumed, especially in cases where errors lead to early termination. The example below demonstrates a situation where the final processing stage exits upon the first encountered error, risking a blocked pipeline state.

func doWork(ctx context.Context) error {
    // Initialize the first stage of the pipeline
    ids := streamIDs(ctx)
    
    // Define other pipeline stages...
	
    // Final stage processing
    for value := range results {
        // Process value...
        if someCondition {
            return fmt.Errorf("some error") // Early exit on error
        }
    }
    return nil
}

To prevent such issues, it's advisable to ensure the results channel is drained in the event of an error. A straightforward approach is to use defer to invoke DrainNB:

func doWork(ctx context.Context) error {
    // Initialize the first stage of the pipeline
    ids := streamIDs(ctx)
    
    // Define other pipeline stages...
	
    // Ensure pipeline is drained in case of failure
    defer rill.DrainNB(results)
	
    // Final stage processing
    for value := range results {
        // Process value...
        if someCondition {
            return fmt.Errorf("some error") // Early exit on error
        }
    }
    return nil
}

Utilizing functions like ForEach or ToSlice, which incorporate built-in draining mechanisms, can simplify the code and enhance readability:

func doWork(ctx context.Context) error {
    // Initialize the first stage of the pipeline
    ids := streamIDs(ctx)
    
    // Define other pipeline stages...

    // Final stage processing
    return rill.ForEach(results, 5, func(value string) error {
        // Process value...
        if someCondition {
            return fmt.Errorf("some error") // Early exit on error, with automatic draining
        }
        return nil
    })
}

While these measures are effective in preventing leaks, the pipeline may continue to operate in the background as long as the initial stage produces values. A best practice is to manage the first stage (and potentially others) with a context, allowing for a controlled shutdown:

func doWork(ctx context.Context) error {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel() // Ensures first stage is cancelled upon function exit

    // Initialize the first stage of the pipeline
    ids := streamIDs(ctx)

    // Define other pipeline stages...

    // Final stage processing
    return rill.ForEach(results, 5, func(value string) error {
        // Process value
        if someCondition {
            return fmt.Errorf("some error") // Early exit on error, with automatic draining
        }
        return nil
    })
}

Order preservation

In concurrent environments, maintaining the original sequence of processed items is challenging due to the nature of parallel execution. When values are read from an input channel, processed through a function f, and written to an output channel, their order might not mirror the input sequence. To address this, rill provides ordered versions of its core functions, such as OrderedMap, OrderedFilter, and others. These ensure that if value x precedes value y in the input channel, then f(x) will precede f(y) in the output, preserving the original order. It's important to note that these ordered functions incur a small overhead compared to their unordered counterparts, due to the additional logic required to maintain order.

Order preservation is vital in scenarios where the sequence of data impacts the outcome. Take, for instance, a function that retrieves daily temperature measurements over a specific period and calculates the change in temperature from one day to the next. Although fetching the data in parallel boosts efficiency, processing it in the original order is crucial for accurate computation of temperature variations.

See a full runnable example at examples/weather

type Measurement struct {
    Date   time.Time
    Temp   float64
    Change float64
}

func printTemperatureChanges(ctx context.Context, city string, startDate, endDate time.Time) error {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel() // In case of error, this ensures all pending operations are canceled
    
    // Make a channel that emits all the days between startDate and endDate
    days := make(chan rill.Try[time.Time])
    go func() {
        defer close(days)
        for date := startDate; date.Before(endDate); date = date.AddDate(0, 0, 1) {
            days <- rill.Wrap(date, nil)
        }
    }()
    
    // Download the temperature for each day in parallel and in order
    measurements := rill.OrderedMap(days, 10, func(date time.Time) (Measurement, error) {
        temp, err := getTemperature(ctx, city, date)
        return Measurement{Date: date, Temp: temp}, err
    })
    
    // Calculate the temperature changes. Use a single goroutine
    prev := Measurement{Temp: math.NaN()}
    measurements = rill.OrderedMap(measurements, 1, func(m Measurement) (Measurement, error) {
        m.Change = m.Temp - prev.Temp
        prev = m
        return m, nil
    })
    
    // Iterate over the measurements and print the results
    err := rill.ForEach(measurements, 1, func(m Measurement) error {
        fmt.Printf("%s: %.1f°C (change %+.1f°C)\n", m.Date.Format("2006-01-02"), m.Temp, m.Change)
        prev = m
        return nil
    })
    
    return err
}

// getTemperature does a network request to fetch the temperature for a given city and date.
func getTemperature(ctx context.Context, city string, date time.Time) (float64, error) {
    // ...
}

Documentation

Overview

Package rill is a Go toolkit designed for efficient and straightforward streaming, parallel processing, and pipeline construction. It abstracts away the complexities of concurrency management, enabling developers to focus on core logic. With features like lightweight integration, batch processing, error handling, and support for functional programming paradigms, rill enhances productivity in building concurrent applications. It offers type-safe operations, and minimizes memory usage even for large data sets.

Example (ForEach)
package main

import (
	"fmt"
	"math/rand"
	"time"

	"github.com/destel/rill"
)

func main() {
	items := rill.FromSlice([]string{"item1", "item2", "item3", "item4", "item5", "item6", "item7", "item8", "item9", "item10"}, nil)

	err := rill.ForEach(items, 3, func(item string) error {
		randomSleep(1 * time.Second) // emulate long processing
		fmt.Println(item)
		return nil
	})
	if err != nil {
		fmt.Println(err)
	}
}

func randomSleep(max time.Duration) {
	time.Sleep(time.Duration(rand.Intn(int(max))))
}

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func Batch

func Batch[A any](in <-chan Try[A], n int, timeout time.Duration) <-chan Try[[]A]

Batch groups items from an input channel into batches based on a maximum size and a timeout. A batch is emitted when it reaches the maximum size, the timeout expires, or the input channel closes. To emit batches only when full, set the timeout to -1. This function never emits empty batches. The timeout countdown starts when the first item is added to a new batch. Zero timeout is not supported and will panic.

func Buffer

func Buffer[A any](in <-chan A, n int) <-chan A

Buffer takes a channel of items and returns a buffered channel of exact same items in the same order. This is useful when you want to write to the input channel without blocking the writer.

Typical use case would look like

ids = Buffer(ids, 100)
// Now up to 100 ids can be buffered if subsequent stages of the pipeline are slow

func Catch

func Catch[A any](in <-chan Try[A], n int, f func(error) error) <-chan Try[A]

Catch allows handling errors from the input channel using n goroutines for concurrency. When f returns nil, error is considered handled and filtered out; otherwise it is replaced by the result of f. The output order is not guaranteed: results are written to the output as soon as they're ready. Use OrderedCatch to preserve the input order.

func Drain

func Drain[A any](in <-chan A)

Drain consumes and discards all items from an input channel, blocking until the channel is closed

func DrainNB

func DrainNB[A any](in <-chan A)

DrainNB is a non-blocking version of Drain.

func Filter

func Filter[A any](in <-chan Try[A], n int, f func(A) (bool, error)) <-chan Try[A]

Filter removes items that do not meet a specified condition, using n goroutines for concurrency. If an error is encountered, either from the function f itself or from upstream it is forwarded to the output for further handling. The output order is not guaranteed: results are written to the output as soon as they're ready. Use OrderedFilter to preserve the input order.

func FlatMap

func FlatMap[A, B any](in <-chan Try[A], n int, f func(A) <-chan Try[B]) <-chan Try[B]

FlatMap applies a function to each item in an input channel, where the function returns a channel of items. These items are then flattened into a single output channel using n goroutines for concurrency. The output order is not guaranteed: results are written to the output as soon as they're ready. Use OrderedFlatMap to preserve the input order.

func ForEach

func ForEach[A any](in <-chan Try[A], n int, f func(A) error) error

ForEach applies a function f to each item in an input channel using n goroutines for parallel processing. The function blocks until all items are processed or an error is encountered, either from the function f itself or from upstream. In case of an error leading to early termination, ForEach ensures the input channel is drained to avoid goroutine leaks, making it safe for use in environments where cleanup is crucial. The function returns the first encountered error, or nil if all items were processed successfully. While this function does not guarantee the order of item processing due to its concurrent nature, using n = 1 results in sequential processing, as in a simple for-range loop.

func FromChan

func FromChan[A any](values <-chan A, err error) <-chan Try[A]

FromChan converts a regular channel into a channel of values wrapped in a Try container. Additionally, this function can take an error, that will be added to the output channel alongside the values. If both values and error are nil, the function returns nil.

func FromChans

func FromChans[A any](values <-chan A, errs <-chan error) <-chan Try[A]

FromChans converts a regular channel into a channel of values wrapped in a Try container. Additionally, this function can take a channel of errors, which will be added to the output channel alongside the values. If both values and errors are nil, the function returns nil.

func FromSlice

func FromSlice[A any](slice []A, err error) <-chan Try[A]

FromSlice converts a slice into a channel of Try containers. If err is not nil function returns a single Try container with the error.

func Map

func Map[A, B any](in <-chan Try[A], n int, f func(A) (B, error)) <-chan Try[B]

Map applies a transformation function to each item in an input channel, using n goroutines for concurrency. If an error is encountered, either from the function f itself or from upstream it is forwarded to the output for further handling. The output order is not guaranteed: results are written to the output as soon as they're ready. Use OrderedMap to preserve the input order.

func Merge

func Merge[A any](ins ...<-chan A) <-chan A

Merge combines multiple input channels into a single output channel. Items are emitted as soon as they're available, so the output order is not defined.

func OrderedCatch

func OrderedCatch[A any](in <-chan Try[A], n int, f func(error) error) <-chan Try[A]

OrderedCatch is similar to Catch, but it guarantees that the output order is the same as the input order.

func OrderedFilter

func OrderedFilter[A any](in <-chan Try[A], n int, f func(A) (bool, error)) <-chan Try[A]

OrderedFilter is similar to Filter, but it guarantees that the output order is the same as the input order.

func OrderedFlatMap

func OrderedFlatMap[A, B any](in <-chan Try[A], n int, f func(A) <-chan Try[B]) <-chan Try[B]

OrderedFlatMap is similar to FlatMap, but it guarantees that the output order is the same as the input order.

func OrderedMap

func OrderedMap[A, B any](in <-chan Try[A], n int, f func(A) (B, error)) <-chan Try[B]

OrderedMap is similar to Map, but it guarantees that the output order is the same as the input order.

func OrderedSplit2

func OrderedSplit2[A any](in <-chan Try[A], n int, f func(A) (bool, error)) (outTrue <-chan Try[A], outFalse <-chan Try[A])

OrderedSplit2 is similar to Split2, but it guarantees that the order of the outputs matches the order of the input.

func Split2

func Split2[A any](in <-chan Try[A], n int, f func(A) (bool, error)) (outTrue <-chan Try[A], outFalse <-chan Try[A])

Split2 divides the input channel into two output channels based on the discriminator function f, using n goroutines for concurrency. The function f takes an item from the input and decides which output channel (outTrue or outFalse) it should go to by returning a boolean. If an error is encountered, either from the function f itself or from upstream it is intentionally sent to one of the output channels in a non-deterministic manner. The output order is not guaranteed: results are written to the outputs as soon as they're ready. Use OrderedSplit2 to preserve the input order.

func ToChans

func ToChans[A any](in <-chan Try[A]) (<-chan A, <-chan error)

ToChans splits a channel of Try containers into a channel of values and a channel of errors. It's an inverse of FromChans. Returns two nil channels if the input is nil.

func ToSlice

func ToSlice[A any](in <-chan Try[A]) ([]A, error)

ToSlice converts a channel of Try containers into a slice of values and an error. It's an inverse of FromSlice. The function blocks until the whole channel is processed or an error is encountered. In case of an error leading to early termination, ToSlice ensures the input channel is drained to avoid goroutine leaks.

func Unbatch

func Unbatch[A any](in <-chan Try[[]A]) <-chan Try[A]

Unbatch is the inverse of Batch. It takes a channel of batches and emits individual items.

Types

type Try

type Try[A any] struct {
	Value A
	Error error
}

Try is a container for a value or an error

func Wrap

func Wrap[A any](value A, err error) Try[A]

Wrap converts a value and/or error into a Try container. It's a convenience function to avoid creating a Try container manually and benefit from type inference.

Directories

Path Synopsis
examples
kv-read command
weather command
internal
th
Package th provides basic test helpers.
Package th provides basic test helpers.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL