Skip to content
Merged

V2 #11

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 31 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![CI](https://github.com/floatdrop/debounce/actions/workflows/ci.yaml/badge.svg)](https://github.com/floatdrop/debounce/actions/workflows/ci.yaml)
[![Go Report Card](https://goreportcard.com/badge/github.com/floatdrop/debounce)](https://goreportcard.com/report/github.com/floatdrop/debounce)
[![Go Coverage](https://github.com/floatdrop/debounce/wiki/coverage.svg)](https://raw.githack.com/wiki/floatdrop/debounce/coverage.html)
[![Go Reference](https://pkg.go.dev/badge/github.com/floatdrop/debounce.svg)](https://pkg.go.dev/github.com/floatdrop/debounce)
[![Go Reference](https://pkg.go.dev/badge/github.com/floatdrop/debounce/v2.svg)](https://pkg.go.dev/github.com/floatdrop/debounce/v2)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

A simple, thread-safe debounce library for Go that delays function execution until after a specified duration has elapsed since the last invocation. Perfect for rate limiting, reducing redundant operations, and optimizing performance in high-frequency scenarios.
Expand All @@ -12,31 +12,51 @@ A simple, thread-safe debounce library for Go that delays function execution unt

- **Zero allocations**: No allocations on sunbsequent debounce calls
- **Thread-safe**: Safe for concurrent use across multiple goroutines
- **Configurable delays and limits**: Set custom behaviour with [WithMaxCalls](https://pkg.go.dev/github.com/floatdrop/debounce#WithMaxCalls) and [WithMaxWait](https://pkg.go.dev/github.com/floatdrop/debounce#WithMaxWait) options
- **Channel support**: Can be used on top of `chan` with [Chan](https://pkg.go.dev/github.com/floatdrop/debounce/v2#Chan) function.
- **Configurable delays and limits**: Set custom behaviour with [WithDelay](https://pkg.go.dev/github.com/floatdrop/debounce/v2#WithDelay) and [WithLimit](https://pkg.go.dev/github.com/floatdrop/debounce/v2#WithLimit) options
- **Zero dependencies**: Built using only Go standard library

## Installation

```bash
go get github.com/floatdrop/debounce
go get github.com/floatdrop/debounce/v2
```

## Usage

https://github.com/floatdrop/debounce/blob/770f96180424dabfea45ca421cce5aa8e57a46f5/example_test.go#L29-L43
```golang
import (
"fmt"
"time"

"github.com/floatdrop/debounce/v2"
)

func main() {
debouncer := debounce.New(debounce.WithDelay(200 * time.Millisecond))
debouncer.Do(func() { fmt.Println("Hello") })
debouncer.Do(func() { fmt.Println("World") })
time.Sleep(time.Second)
// Output: World
}
```

## Benchmarks

```bash
go test -bench=BenchmarkSingleCall -benchmem
go test -bench=. -benchmem
```

| Benchmark | Iterations | Time per Op | Bytes per Op | Allocs per Op |
|----------------------------------|------------|--------------|--------------|---------------|
| BenchmarkSingleCall-14 | 47227514 | 25.24 ns/op | 0 B/op | 0 allocs/op |

- ~25ns per debounced call
- Constant memory usage regardless of call frequency
```
goos: darwin
goarch: arm64
pkg: github.com/floatdrop/debounce/v2
cpu: Apple M3 Max
BenchmarkDebounce_Insert-14 3318151 341.9 ns/op 0 B/op 0 allocs/op
BenchmarkDebounce_Do-14 4025568 393.9 ns/op 0 B/op 0 allocs/op
PASS
ok github.com/floatdrop/debounce/v2 8.574s
```

## Contributing

Expand Down
96 changes: 96 additions & 0 deletions v2/debounce.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package debounce

import (
"time"
)

// Option is a functional option for configuring the debouncer.
type Option func(*debounceOptions)

type debounceOptions struct {
limit int
delay time.Duration
}

// WithLimit sets the maximum number of incoming inputs before
// passing most recent value downstream.
func WithLimit(limit int) Option {
return func(options *debounceOptions) {
options.limit = limit
}
}

// WithDelay sets time.Duration specifying how long to wait after the last input
// before sending the most recent value downstream.
func WithDelay(d time.Duration) Option {
return func(options *debounceOptions) {
options.delay = d
}
}

// Chan wraps incoming channel and returns channel that emits the last value only
// after no new values are received for the given delay or limit.
//
// If no delay provided - zero delay assumed, so function returns in chan as result.
func Chan[T any](in <-chan T, opts ...Option) <-chan T {
var options debounceOptions
for _, opt := range opts {
opt(&options)
}

// If there is no duration - every incoming element must be passed downstream.
if options.delay == 0 {
return in
}

out := make(chan T, 1)
go func() {
defer close(out)

var (
timer *time.Timer = time.NewTimer(options.delay)
value T
hasVal bool
count int
)

// Function to return the timer channel or nil if timer is not set
// This avoids blocking on the timer channel if no timer is set
timerOrNil := func() <-chan time.Time {
if timer != nil && hasVal {
return timer.C
}
return nil
}

for {
select {
case v, ok := <-in:
if !ok { // Input channel is closed, wrapping up
if hasVal {
out <- value
}
return
}

if options.limit != 0 { // If WithLimit specified as non-zero value start counting and emitting
count++
if count >= options.limit {
out <- v
hasVal = false
timer.Stop()
continue
}
}

value = v
hasVal = true
timer.Reset(options.delay)
case <-timerOrNil():
out <- value
hasVal = false
}
}
}()
return out
}
128 changes: 128 additions & 0 deletions v2/debounce_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package debounce_test

import (
"testing"
"time"

"github.com/floatdrop/debounce/v2"

"github.com/stretchr/testify/assert"
)

// helper to collect output with a timeout
func collect[T any](ch <-chan T, timeout time.Duration) []T {
var results []T
timer := time.NewTimer(timeout)
for {
select {
case v, ok := <-ch:
if !ok {
return results
}
results = append(results, v)
case <-timer.C:
return results
}
}
}

func TestDebounce_LastValueOnly(t *testing.T) {
in := make(chan int)
out := debounce.Chan(in, debounce.WithDelay(200*time.Millisecond))

go func() {
in <- 1
time.Sleep(50 * time.Millisecond)
in <- 2
time.Sleep(50 * time.Millisecond)
in <- 3
time.Sleep(50 * time.Millisecond)
in <- 4
time.Sleep(300 * time.Millisecond) // wait longer than debounce delay
close(in)
}()

result := collect(out, 1*time.Second)
assert.Equal(t, []int{4}, result)
}

func TestDebounce_MultipleValuesSpacedOut(t *testing.T) {
in := make(chan int)
out := debounce.Chan(in, debounce.WithDelay(100*time.Millisecond))

go func() {
in <- 1
time.Sleep(150 * time.Millisecond)
in <- 2
time.Sleep(150 * time.Millisecond)
in <- 3
time.Sleep(150 * time.Millisecond)
close(in)
}()

result := collect(out, 1*time.Second)
assert.Equal(t, []int{1, 2, 3}, result)
}

func TestDebounce_WithLimit(t *testing.T) {
in := make(chan int)
out := debounce.Chan(in, debounce.WithDelay(200*time.Millisecond), debounce.WithLimit(3))

go func() {
in <- 1
time.Sleep(50 * time.Millisecond)
in <- 2
time.Sleep(50 * time.Millisecond)
in <- 3
time.Sleep(50 * time.Millisecond)
in <- 4
time.Sleep(300 * time.Millisecond) // wait longer than debounce delay
close(in)
}()

result := collect(out, 1*time.Second)
assert.Equal(t, []int{3, 4}, result)
}

func TestDebounce_ChannelCloses(t *testing.T) {
in := make(chan int)
out := debounce.Chan(in, debounce.WithDelay(100*time.Millisecond))

go func() {
in <- 42
close(in)
}()

result := collect(out, 1*time.Second)
assert.Equal(t, []int{42}, result)
}

func TestDebounce_EmptyChannelCloses(t *testing.T) {
in := make(chan int)
out := debounce.Chan(in, debounce.WithDelay(100*time.Millisecond))

go func() {
close(in)
}()

result := collect(out, 1*time.Second)
assert.Equal(t, []int(nil), result)
}

func TestDebounce_ZeroDelay(t *testing.T) {
in := make(chan int)
out := debounce.Chan(in)
assert.Equal(t, (<-chan int)(in), out)
}

func BenchmarkDebounce_Insert(b *testing.B) {
in := make(chan int)
_ = debounce.Chan(in, debounce.WithDelay(100*time.Millisecond))

b.ResetTimer()
for i := 0; i < b.N; i++ {
in <- i
}
b.StopTimer()
close(in)
}
40 changes: 40 additions & 0 deletions v2/debouncer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package debounce

type Debouncer struct {
inputCh chan func()
debouncedCh <-chan func()
}

// Creates new Debouncer instance that will call provided functions with debounce.
func New(opts ...Option) *Debouncer {
inputCh := make(chan func())
debouncedCh := Chan(inputCh, opts...)

go func() {
for f := range debouncedCh {
go f() // Do not block reading channel for f execution
}
}()

return &Debouncer{
inputCh: inputCh,
debouncedCh: debouncedCh,
}
}

// Do adds function f to be executed with debounce.
func (d *Debouncer) Do(f func()) {
d.inputCh <- f
}

// Func returns func wrapper of function f, that will execute function f with debounce on call.
func (d *Debouncer) Func(f func()) func() {
return func() {
d.inputCh <- f
}
}

// Closes underlying channel in Debouncer instance.
func (d *Debouncer) Close() {
close(d.inputCh)
}
19 changes: 19 additions & 0 deletions v2/debouncer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package debounce_test

import (
"testing"
"time"

"github.com/floatdrop/debounce/v2"
)

func BenchmarkDebounce_Do(b *testing.B) {
debouncer := debounce.New(debounce.WithDelay(100 * time.Millisecond))
f := func() {}
b.ResetTimer()
for i := 0; i < b.N; i++ {
debouncer.Do(f)
}
b.StopTimer()
debouncer.Close()
}
Loading
Loading