Skip to content

Commit

Permalink
Merge pull request #1 from qpoint-io/add-time-duration-debouncer
Browse files Browse the repository at this point in the history
Add time duration debouncer
  • Loading branch information
jonfriesen authored Jun 13, 2024
2 parents 9caf5cf + 39a3f4e commit dc69df4
Show file tree
Hide file tree
Showing 8 changed files with 244 additions and 33 deletions.
2 changes: 0 additions & 2 deletions .github/FUNDING.yml

This file was deleted.

13 changes: 3 additions & 10 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
on:
push:
branches: [ master ]
branches: [ main ]
pull_request:
workflow_dispatch:
name: Test
Expand All @@ -10,8 +10,8 @@ jobs:
test:
strategy:
matrix:
go-version: [1.18.x]
os: [ubuntu-latest, windows-latest]
go-version: [1.22.x]
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Install Go
Expand Down Expand Up @@ -41,10 +41,3 @@ jobs:
run: golint ./...
- name: Test
run: go test -race .
- name: Upload coverage
if: success() && matrix.os == 'ubuntu-latest'
run: |
curl -s https://codecov.io/bash | bash
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
shell: bash
9 changes: 3 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
# Go Debounce

[![Tests on Linux, MacOS and Windows](https://github.com/bep/debounce/workflows/Test/badge.svg)](https://github.com/bep/debounce/actions?query=workflow:Test)
[![GoDoc](https://godoc.org/github.com/bep/debounce?status.svg)](https://godoc.org/github.com/bep/debounce)
[![Go Report Card](https://goreportcard.com/badge/github.com/bep/debounce)](https://goreportcard.com/report/github.com/bep/debounce)
[![codecov](https://codecov.io/gh/bep/debounce/branch/master/graph/badge.svg)](https://codecov.io/gh/bep/debounce)
[![Release](https://img.shields.io/github/release/bep/debounce.svg?style=flat-square)](https://github.com/bep/debounce/releases/latest)
[![Tests on Linux, MacOS and Windows](https://github.com/qpoint-io/debounce/workflows/Test/badge.svg)](https://github.com/qpoint-io/debounce/actions?query=workflow:Test)
[![GoDoc](https://godoc.org/github.com/qpoint-io/debounce?status.svg)](https://godoc.org/github.com/qpoint-io/debounce)

## Example

Expand All @@ -16,7 +13,7 @@ func ExampleNew() {
atomic.AddUint64(&counter, 1)
}

debounced := debounce.New(100 * time.Millisecond)
debounced := debounce.New(100 * time.Millisecond, 50)

for i := 0; i < 3; i++ {
for j := 0; j < 10; j++ {
Expand Down
48 changes: 40 additions & 8 deletions debounce.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,31 +13,63 @@ import (
"time"
)

// New returns a debounced function that takes another functions as its argument.
// New returns a debounced function that takes another function as its argument.
// This function will be called when the debounced function stops being called
// for the given duration.
// for the given duration, provided the maximum count hasn't been exceeded.
// Once the maximum count is exceeded, the function is executed one last time
// and the debouncer is reset.
// The debounced function can be invoked with different functions, if needed,
// the last one will win.
func New(after time.Duration) func(f func()) {
d := &debouncer{after: after}
func New(after time.Duration, countLimit uint64) func(f func()) {
d := &debouncer{
after: after,
countLimit: countLimit,
}

return func(f func()) {
d.add(f)
}
}

type debouncer struct {
mu sync.Mutex
after time.Duration
timer *time.Timer
mu sync.Mutex
after time.Duration
timer *time.Timer
count uint64
countLimit uint64
}

func (d *debouncer) add(f func()) {
d.mu.Lock()
defer d.mu.Unlock()

// Increment the count
d.count++

// If count exceeds maxCount, execute the function and reset
if d.count > d.countLimit {
if d.timer != nil {
d.timer.Stop()
d.timer = nil
}

f()

// Reset the count for the next iteration
d.count = 0
return
}

if d.timer != nil {
d.timer.Stop()
}
d.timer = time.AfterFunc(d.after, f)
d.timer = time.AfterFunc(d.after, func() {
d.mu.Lock()
defer d.mu.Unlock()

f()

// Reset the count after the function is executed
d.count = 0
})
}
34 changes: 28 additions & 6 deletions debounce_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
"testing"
"time"

"github.com/bep/debounce"
"github.com/qpoint-io/debounce"
)

func TestDebounce(t *testing.T) {
Expand All @@ -28,7 +28,7 @@ func TestDebounce(t *testing.T) {
atomic.AddUint64(&counter2, 2)
}

debounced := debounce.New(100 * time.Millisecond)
debounced := debounce.New(100*time.Millisecond, 1000)

for i := 0; i < 3; i++ {
for j := 0; j < 10; j++ {
Expand Down Expand Up @@ -64,7 +64,7 @@ func TestDebounceConcurrentAdd(t *testing.T) {

var flag uint64

debounced := debounce.New(100 * time.Millisecond)
debounced := debounce.New(100*time.Millisecond, 1000)

for i := 0; i < 10; i++ {
wg.Add(1)
Expand Down Expand Up @@ -95,7 +95,7 @@ func TestDebounceDelayed(t *testing.T) {
atomic.AddUint64(&counter1, 1)
}

debounced := debounce.New(100 * time.Millisecond)
debounced := debounce.New(100*time.Millisecond, 1000)

time.Sleep(110 * time.Millisecond)

Expand All @@ -117,7 +117,7 @@ func BenchmarkDebounce(b *testing.B) {
atomic.AddUint64(&counter, 1)
}

debounced := debounce.New(100 * time.Millisecond)
debounced := debounce.New(100*time.Millisecond, 1000)

b.ResetTimer()
for i := 0; i < b.N; i++ {
Expand All @@ -137,7 +137,7 @@ func ExampleNew() {
atomic.AddUint64(&counter, 1)
}

debounced := debounce.New(100 * time.Millisecond)
debounced := debounce.New(100*time.Millisecond, 1000)

for i := 0; i < 3; i++ {
for j := 0; j < 10; j++ {
Expand All @@ -152,3 +152,25 @@ func ExampleNew() {
fmt.Println("Counter is", c)
// Output: Counter is 3
}

func TestDebouncerCountLimit(t *testing.T) {
after := 1 * time.Hour
countLimit := uint64(3)

var execCount uint64

f := func() {
atomic.AddUint64(&execCount, 1)
}

debounced := debounce.New(after, countLimit)

for i := uint64(0); i < countLimit+1; i++ {
debounced(f)
}

expectedExecCount := uint64(1)
if atomic.LoadUint64(&execCount) != expectedExecCount {
t.Errorf("Expected function to be executed %d time(s), but got %d", expectedExecCount, atomic.LoadUint64(&execCount))
}
}
75 changes: 75 additions & 0 deletions debounce_time.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Copyright © 2024 Jon Friesen <[email protected]>.
//
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.

package debounce

import (
"sync"
"time"
)

var now = time.Now

// NewDebounceByDuration returns a debounced function that takes another function as its argument.
// This function will be called at the given interval, but no more than the max duration
// from the first call.
func NewDebounceByDuration(interval, maxDuration time.Duration) func(f func()) {
d := &durationDebouncer{
interval: interval,
maxDuration: maxDuration,
}

return func(f func()) {
d.add(f)
}
}

type durationDebouncer struct {
mu sync.Mutex
interval time.Duration
maxDuration time.Duration
timer *time.Timer
firstCall bool
startTime time.Time
}

func (d *durationDebouncer) add(f func()) {
d.mu.Lock()
defer d.mu.Unlock()

now := now()
if !d.firstCall {
d.firstCall = true
d.startTime = now
}

if d.timer != nil {
d.timer.Stop()
}

remainingDuration := d.maxDuration - time.Since(d.startTime)
if remainingDuration <= 0 {
d.reset()
f()
return
}

d.timer = time.AfterFunc(d.interval, func() {
d.mu.Lock()
defer d.mu.Unlock()

f()
d.reset()
})
}

func (d *durationDebouncer) reset() {
d.firstCall = false
if d.timer != nil {
d.timer.Stop()
d.timer = nil
}
d.startTime = time.Time{}
}
92 changes: 92 additions & 0 deletions debounce_time_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package debounce

import (
"sync"
"testing"
"time"
)

var (
mockNowFunc func() time.Time
mockNowMutex sync.Mutex
)

func mockNow() time.Time {
mockNowMutex.Lock()
defer mockNowMutex.Unlock()
return mockNowFunc()
}

func setMockNow(t time.Time) {
mockNowMutex.Lock()
defer mockNowMutex.Unlock()
mockNowFunc = func() time.Time {
return t
}
}

func init() {
now = mockNow
}

func TestTimeDebounce(t *testing.T) {
tests := []struct {
name string
interval time.Duration
maxDuration time.Duration
timeSteps []time.Duration
}{
{
name: "Single call within interval",
interval: 100 * time.Millisecond,
maxDuration: 500 * time.Millisecond,
timeSteps: []time.Duration{100 * time.Millisecond, 50 * time.Millisecond},
},
{
name: "Multiple calls within interval",
interval: 100 * time.Millisecond,
maxDuration: 500 * time.Millisecond,
timeSteps: []time.Duration{50 * time.Millisecond, 50 * time.Millisecond, 200 * time.Millisecond},
},
{
name: "Single call at max duration",
interval: 100 * time.Millisecond,
maxDuration: 200 * time.Millisecond,
timeSteps: []time.Duration{100 * time.Millisecond, 100 * time.Millisecond},
},
{
name: "Multiple calls at max duration",
interval: 150 * time.Millisecond,
maxDuration: 300 * time.Millisecond,
timeSteps: []time.Duration{50 * time.Millisecond, 50 * time.Millisecond, 200 * time.Millisecond},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
callCount := 0
mu := sync.Mutex{}
f := func() {
mu.Lock()
defer mu.Unlock()
callCount++
}

d := NewDebounceByDuration(tt.interval, tt.maxDuration)
start := time.Now()
setMockNow(start)

for _, step := range tt.timeSteps {
setMockNow(start.Add(step))
d(f)
time.Sleep(step)
}

mu.Lock()
if callCount != 1 {
t.Errorf("expected 1 calls, got %d", callCount)
}
mu.Unlock()
})
}
}
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
module github.com/bep/debounce
module github.com/qpoint-io/debounce

go 1.22.0

0 comments on commit dc69df4

Please sign in to comment.