Skip to content

Commit f774263

Browse files
authored
Add periodic package (#34)
1 parent 305d487 commit f774263

3 files changed

Lines changed: 139 additions & 6 deletions

File tree

periodic/periodic.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package periodic
2+
3+
import (
4+
"context"
5+
"time"
6+
)
7+
8+
// Run spawns a goroutine that executes fn immediately after the jitter delay,
9+
// then repeats it at the given interval until ctx is cancelled.
10+
func Run(ctx context.Context, interval time.Duration, jitter time.Duration, fn func()) {
11+
go func() {
12+
if jitter > 0 {
13+
jitterTimer := time.NewTimer(jitter)
14+
defer jitterTimer.Stop()
15+
select {
16+
case <-ctx.Done():
17+
return
18+
case <-jitterTimer.C:
19+
}
20+
}
21+
22+
ticker := time.NewTicker(interval)
23+
defer ticker.Stop()
24+
25+
fn()
26+
for {
27+
select {
28+
case <-ctx.Done():
29+
return
30+
case <-ticker.C:
31+
fn()
32+
}
33+
}
34+
}()
35+
}

periodic/periodic_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package periodic_test
2+
3+
import (
4+
"context"
5+
"sync/atomic"
6+
"testing"
7+
"time"
8+
9+
"github.com/cego/go-lib/v2/periodic"
10+
"github.com/stretchr/testify/assert"
11+
)
12+
13+
func TestRunExecutesImmediately(t *testing.T) {
14+
ctx, cancel := context.WithCancel(context.Background())
15+
defer cancel()
16+
17+
var count atomic.Int32
18+
periodic.Run(ctx, time.Hour, 0, func() {
19+
count.Add(1)
20+
})
21+
22+
time.Sleep(50 * time.Millisecond)
23+
assert.Equal(t, int32(1), count.Load())
24+
}
25+
26+
func TestRunExecutesPeriodically(t *testing.T) {
27+
ctx, cancel := context.WithCancel(context.Background())
28+
defer cancel()
29+
30+
var count atomic.Int32
31+
periodic.Run(ctx, 50*time.Millisecond, 0, func() {
32+
count.Add(1)
33+
})
34+
35+
time.Sleep(200 * time.Millisecond)
36+
assert.GreaterOrEqual(t, count.Load(), int32(3))
37+
}
38+
39+
func TestRunStopsOnContextCancel(t *testing.T) {
40+
ctx, cancel := context.WithCancel(context.Background())
41+
42+
var count atomic.Int32
43+
periodic.Run(ctx, 50*time.Millisecond, 0, func() {
44+
count.Add(1)
45+
})
46+
47+
time.Sleep(100 * time.Millisecond)
48+
cancel()
49+
countAtCancel := count.Load()
50+
51+
time.Sleep(200 * time.Millisecond)
52+
assert.Equal(t, countAtCancel, count.Load())
53+
}
54+
55+
func TestRunAppliesJitter(t *testing.T) {
56+
ctx, cancel := context.WithCancel(context.Background())
57+
defer cancel()
58+
59+
var count atomic.Int32
60+
periodic.Run(ctx, time.Hour, 200*time.Millisecond, func() {
61+
count.Add(1)
62+
})
63+
64+
time.Sleep(50 * time.Millisecond)
65+
assert.Equal(t, int32(0), count.Load())
66+
67+
time.Sleep(200 * time.Millisecond)
68+
assert.Equal(t, int32(1), count.Load())
69+
}
70+
71+
func TestRunDoesNotBlock(t *testing.T) {
72+
ctx, cancel := context.WithCancel(context.Background())
73+
defer cancel()
74+
75+
returned := make(chan struct{})
76+
go func() {
77+
periodic.Run(ctx, time.Hour, time.Hour, func() { /* no-op: testing that Run returns immediately */ })
78+
close(returned)
79+
}()
80+
81+
select {
82+
case <-returned:
83+
case <-time.After(50 * time.Millisecond):
84+
t.Fatal("Run blocked the caller")
85+
}
86+
}

readme.md

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/cego/go-lib/v2/forwardauth"
2020
"github.com/cego/go-lib/v2/headers"
2121
"github.com/cego/go-lib/v2/serve"
22+
"github.com/cego/go-lib/v2/periodic"
2223
)
2324
```
2425

@@ -40,6 +41,10 @@ l := logger.NewWithLevel(slog.LevelInfo)
4041

4142
// Set as global slog default
4243
slog.SetDefault(l)
44+
45+
// Testing with mock logger
46+
l := logger.NewMock()
47+
r := renderer.New(l)
4348
```
4449

4550
## Using Renderer with builtin logging
@@ -77,12 +82,6 @@ mux.Handle("/data", fa.HandlerFunc(func (w http.ResponseWriter, req *http.Reques
7782
}))
7883
```
7984

80-
## Testing with Mock Logger
81-
```go
82-
l := logger.NewMock()
83-
r := renderer.New(l)
84-
```
85-
8685
## Headers
8786
```go
8887
req.Header.Get(headers.Authorization)
@@ -91,6 +90,19 @@ req.Header.Get(headers.XForwardedFor)
9190

9291
Available constants: `XForwardedProto`, `XForwardedMethod`, `XForwardedHost`, `XForwardedUri`, `XForwardedFor`, `Accept`, `UserAgent`, `Cookie`, `Authorization`, `RemoteUser`, `ContentType`
9392

93+
## Using Periodic
94+
95+
Context-aware periodic task execution with jitter support.
96+
97+
```go
98+
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
99+
defer stop()
100+
101+
periodic.Run(ctx, 2*time.Second, time.Duration(rand.Intn(1000))*time.Millisecond, func() {
102+
fmt.Println("runs every 2 seconds until ctx is cancelled")
103+
})
104+
```
105+
94106
## Using Serve (Graceful Shutdown)
95107

96108
Graceful HTTP server shutdown with a configurable delay for load balancer deregistration.

0 commit comments

Comments
 (0)