Skip to content

Commit 517e1d7

Browse files
authored
V2 (#11)
1 parent 5bd0cec commit 517e1d7

File tree

8 files changed

+391
-11
lines changed

8 files changed

+391
-11
lines changed

README.md

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
[![CI](https://github.com/floatdrop/debounce/actions/workflows/ci.yaml/badge.svg)](https://github.com/floatdrop/debounce/actions/workflows/ci.yaml)
44
[![Go Report Card](https://goreportcard.com/badge/github.com/floatdrop/debounce)](https://goreportcard.com/report/github.com/floatdrop/debounce)
55
[![Go Coverage](https://github.com/floatdrop/debounce/wiki/coverage.svg)](https://raw.githack.com/wiki/floatdrop/debounce/coverage.html)
6-
[![Go Reference](https://pkg.go.dev/badge/github.com/floatdrop/debounce.svg)](https://pkg.go.dev/github.com/floatdrop/debounce)
6+
[![Go Reference](https://pkg.go.dev/badge/github.com/floatdrop/debounce/v2.svg)](https://pkg.go.dev/github.com/floatdrop/debounce/v2)
77
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
88

99
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.
@@ -12,31 +12,51 @@ A simple, thread-safe debounce library for Go that delays function execution unt
1212

1313
- **Zero allocations**: No allocations on sunbsequent debounce calls
1414
- **Thread-safe**: Safe for concurrent use across multiple goroutines
15-
- **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
15+
- **Channel support**: Can be used on top of `chan` with [Chan](https://pkg.go.dev/github.com/floatdrop/debounce/v2#Chan) function.
16+
- **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
1617
- **Zero dependencies**: Built using only Go standard library
1718

1819
## Installation
1920

2021
```bash
21-
go get github.com/floatdrop/debounce
22+
go get github.com/floatdrop/debounce/v2
2223
```
2324

2425
## Usage
2526

26-
https://github.com/floatdrop/debounce/blob/770f96180424dabfea45ca421cce5aa8e57a46f5/example_test.go#L29-L43
27+
```golang
28+
import (
29+
"fmt"
30+
"time"
31+
32+
"github.com/floatdrop/debounce/v2"
33+
)
34+
35+
func main() {
36+
debouncer := debounce.New(debounce.WithDelay(200 * time.Millisecond))
37+
debouncer.Do(func() { fmt.Println("Hello") })
38+
debouncer.Do(func() { fmt.Println("World") })
39+
time.Sleep(time.Second)
40+
// Output: World
41+
}
42+
```
2743

2844
## Benchmarks
2945

3046
```bash
31-
go test -bench=BenchmarkSingleCall -benchmem
47+
go test -bench=. -benchmem
3248
```
3349

34-
| Benchmark | Iterations | Time per Op | Bytes per Op | Allocs per Op |
35-
|----------------------------------|------------|--------------|--------------|---------------|
36-
| BenchmarkSingleCall-14 | 47227514 | 25.24 ns/op | 0 B/op | 0 allocs/op |
37-
38-
- ~25ns per debounced call
39-
- Constant memory usage regardless of call frequency
50+
```
51+
goos: darwin
52+
goarch: arm64
53+
pkg: github.com/floatdrop/debounce/v2
54+
cpu: Apple M3 Max
55+
BenchmarkDebounce_Insert-14 3318151 341.9 ns/op 0 B/op 0 allocs/op
56+
BenchmarkDebounce_Do-14 4025568 393.9 ns/op 0 B/op 0 allocs/op
57+
PASS
58+
ok github.com/floatdrop/debounce/v2 8.574s
59+
```
4060

4161
## Contributing
4262

v2/debounce.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package debounce
2+
3+
import (
4+
"time"
5+
)
6+
7+
// Option is a functional option for configuring the debouncer.
8+
type Option func(*debounceOptions)
9+
10+
type debounceOptions struct {
11+
limit int
12+
delay time.Duration
13+
}
14+
15+
// WithLimit sets the maximum number of incoming inputs before
16+
// passing most recent value downstream.
17+
func WithLimit(limit int) Option {
18+
return func(options *debounceOptions) {
19+
options.limit = limit
20+
}
21+
}
22+
23+
// WithDelay sets time.Duration specifying how long to wait after the last input
24+
// before sending the most recent value downstream.
25+
func WithDelay(d time.Duration) Option {
26+
return func(options *debounceOptions) {
27+
options.delay = d
28+
}
29+
}
30+
31+
// Chan wraps incoming channel and returns channel that emits the last value only
32+
// after no new values are received for the given delay or limit.
33+
//
34+
// If no delay provided - zero delay assumed, so function returns in chan as result.
35+
func Chan[T any](in <-chan T, opts ...Option) <-chan T {
36+
var options debounceOptions
37+
for _, opt := range opts {
38+
opt(&options)
39+
}
40+
41+
// If there is no duration - every incoming element must be passed downstream.
42+
if options.delay == 0 {
43+
return in
44+
}
45+
46+
out := make(chan T, 1)
47+
go func() {
48+
defer close(out)
49+
50+
var (
51+
timer *time.Timer = time.NewTimer(options.delay)
52+
value T
53+
hasVal bool
54+
count int
55+
)
56+
57+
// Function to return the timer channel or nil if timer is not set
58+
// This avoids blocking on the timer channel if no timer is set
59+
timerOrNil := func() <-chan time.Time {
60+
if timer != nil && hasVal {
61+
return timer.C
62+
}
63+
return nil
64+
}
65+
66+
for {
67+
select {
68+
case v, ok := <-in:
69+
if !ok { // Input channel is closed, wrapping up
70+
if hasVal {
71+
out <- value
72+
}
73+
return
74+
}
75+
76+
if options.limit != 0 { // If WithLimit specified as non-zero value start counting and emitting
77+
count++
78+
if count >= options.limit {
79+
out <- v
80+
hasVal = false
81+
timer.Stop()
82+
continue
83+
}
84+
}
85+
86+
value = v
87+
hasVal = true
88+
timer.Reset(options.delay)
89+
case <-timerOrNil():
90+
out <- value
91+
hasVal = false
92+
}
93+
}
94+
}()
95+
return out
96+
}

v2/debounce_test.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package debounce_test
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/floatdrop/debounce/v2"
8+
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
// helper to collect output with a timeout
13+
func collect[T any](ch <-chan T, timeout time.Duration) []T {
14+
var results []T
15+
timer := time.NewTimer(timeout)
16+
for {
17+
select {
18+
case v, ok := <-ch:
19+
if !ok {
20+
return results
21+
}
22+
results = append(results, v)
23+
case <-timer.C:
24+
return results
25+
}
26+
}
27+
}
28+
29+
func TestDebounce_LastValueOnly(t *testing.T) {
30+
in := make(chan int)
31+
out := debounce.Chan(in, debounce.WithDelay(200*time.Millisecond))
32+
33+
go func() {
34+
in <- 1
35+
time.Sleep(50 * time.Millisecond)
36+
in <- 2
37+
time.Sleep(50 * time.Millisecond)
38+
in <- 3
39+
time.Sleep(50 * time.Millisecond)
40+
in <- 4
41+
time.Sleep(300 * time.Millisecond) // wait longer than debounce delay
42+
close(in)
43+
}()
44+
45+
result := collect(out, 1*time.Second)
46+
assert.Equal(t, []int{4}, result)
47+
}
48+
49+
func TestDebounce_MultipleValuesSpacedOut(t *testing.T) {
50+
in := make(chan int)
51+
out := debounce.Chan(in, debounce.WithDelay(100*time.Millisecond))
52+
53+
go func() {
54+
in <- 1
55+
time.Sleep(150 * time.Millisecond)
56+
in <- 2
57+
time.Sleep(150 * time.Millisecond)
58+
in <- 3
59+
time.Sleep(150 * time.Millisecond)
60+
close(in)
61+
}()
62+
63+
result := collect(out, 1*time.Second)
64+
assert.Equal(t, []int{1, 2, 3}, result)
65+
}
66+
67+
func TestDebounce_WithLimit(t *testing.T) {
68+
in := make(chan int)
69+
out := debounce.Chan(in, debounce.WithDelay(200*time.Millisecond), debounce.WithLimit(3))
70+
71+
go func() {
72+
in <- 1
73+
time.Sleep(50 * time.Millisecond)
74+
in <- 2
75+
time.Sleep(50 * time.Millisecond)
76+
in <- 3
77+
time.Sleep(50 * time.Millisecond)
78+
in <- 4
79+
time.Sleep(300 * time.Millisecond) // wait longer than debounce delay
80+
close(in)
81+
}()
82+
83+
result := collect(out, 1*time.Second)
84+
assert.Equal(t, []int{3, 4}, result)
85+
}
86+
87+
func TestDebounce_ChannelCloses(t *testing.T) {
88+
in := make(chan int)
89+
out := debounce.Chan(in, debounce.WithDelay(100*time.Millisecond))
90+
91+
go func() {
92+
in <- 42
93+
close(in)
94+
}()
95+
96+
result := collect(out, 1*time.Second)
97+
assert.Equal(t, []int{42}, result)
98+
}
99+
100+
func TestDebounce_EmptyChannelCloses(t *testing.T) {
101+
in := make(chan int)
102+
out := debounce.Chan(in, debounce.WithDelay(100*time.Millisecond))
103+
104+
go func() {
105+
close(in)
106+
}()
107+
108+
result := collect(out, 1*time.Second)
109+
assert.Equal(t, []int(nil), result)
110+
}
111+
112+
func TestDebounce_ZeroDelay(t *testing.T) {
113+
in := make(chan int)
114+
out := debounce.Chan(in)
115+
assert.Equal(t, (<-chan int)(in), out)
116+
}
117+
118+
func BenchmarkDebounce_Insert(b *testing.B) {
119+
in := make(chan int)
120+
_ = debounce.Chan(in, debounce.WithDelay(100*time.Millisecond))
121+
122+
b.ResetTimer()
123+
for i := 0; i < b.N; i++ {
124+
in <- i
125+
}
126+
b.StopTimer()
127+
close(in)
128+
}

v2/debouncer.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package debounce
2+
3+
type Debouncer struct {
4+
inputCh chan func()
5+
debouncedCh <-chan func()
6+
}
7+
8+
// Creates new Debouncer instance that will call provided functions with debounce.
9+
func New(opts ...Option) *Debouncer {
10+
inputCh := make(chan func())
11+
debouncedCh := Chan(inputCh, opts...)
12+
13+
go func() {
14+
for f := range debouncedCh {
15+
go f() // Do not block reading channel for f execution
16+
}
17+
}()
18+
19+
return &Debouncer{
20+
inputCh: inputCh,
21+
debouncedCh: debouncedCh,
22+
}
23+
}
24+
25+
// Do adds function f to be executed with debounce.
26+
func (d *Debouncer) Do(f func()) {
27+
d.inputCh <- f
28+
}
29+
30+
// Func returns func wrapper of function f, that will execute function f with debounce on call.
31+
func (d *Debouncer) Func(f func()) func() {
32+
return func() {
33+
d.inputCh <- f
34+
}
35+
}
36+
37+
// Closes underlying channel in Debouncer instance.
38+
func (d *Debouncer) Close() {
39+
close(d.inputCh)
40+
}

v2/debouncer_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package debounce_test
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/floatdrop/debounce/v2"
8+
)
9+
10+
func BenchmarkDebounce_Do(b *testing.B) {
11+
debouncer := debounce.New(debounce.WithDelay(100 * time.Millisecond))
12+
f := func() {}
13+
b.ResetTimer()
14+
for i := 0; i < b.N; i++ {
15+
debouncer.Do(f)
16+
}
17+
b.StopTimer()
18+
debouncer.Close()
19+
}

0 commit comments

Comments
 (0)