Skip to content

Commit 0ca7f5c

Browse files
authored
feat!: implement gracegroup (#2)
1 parent 3f8a7e9 commit 0ca7f5c

File tree

10 files changed

+564
-1
lines changed

10 files changed

+564
-1
lines changed

.github/workflows/main.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,5 @@ jobs:
2424
- name: lint
2525
uses: golangci/golangci-lint-action@v8
2626
with:
27-
version: v2.1
27+
version: v2.2
2828

.golangci.yaml

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
version: "2"
2+
linters:
3+
default: none
4+
enable:
5+
- errcheck
6+
- ineffassign
7+
- unused
8+
- asasalint
9+
- asciicheck
10+
- bidichk
11+
- containedctx
12+
- decorder
13+
- errname
14+
- errorlint
15+
- exhaustive
16+
- copyloopvar
17+
- forcetypeassert
18+
- gochecknoinits
19+
- gocognit
20+
- goconst
21+
- godot
22+
- mnd
23+
- lll
24+
- makezero
25+
- nakedret
26+
- nestif
27+
- nilerr
28+
- nilnil
29+
- nlreturn
30+
- nolintlint
31+
- paralleltest
32+
- reassign
33+
- revive
34+
- staticcheck
35+
- gosec
36+
- unconvert
37+
- unparam
38+
- whitespace
39+
- wsl_v5
40+
- wastedassign
41+
- dupl
42+
43+
settings:
44+
errcheck:
45+
check-type-assertions: true
46+
check-blank: true
47+
goconst:
48+
min-len: 2
49+
min-occurrences: 2
50+
nakedret:
51+
max-func-lines: 30
52+
nlreturn:
53+
block-size: 4
54+
nolintlint:
55+
require-explanation: true
56+
require-specific: true
57+
wsl_v5:
58+
allow-first-in-block: true
59+
allow-whole-block: false
60+
branch-max-lines: 2
61+
enable:
62+
- err
63+
disable:
64+
- assign
65+
- decl
66+
gocognit:
67+
min-complexity: 20
68+
revive:
69+
rules:
70+
- name: use-any
71+
- name: unexported-naming
72+
- name: comment-spacings
73+
- name: enforce-repeated-arg-type-style
74+
arguments:
75+
- funcArgStyle: "full"
76+
- funcRetValStyle: "short"
77+
exclusions:
78+
paths-except:
79+
- example/
80+
81+
formatters:
82+
enable:
83+
- gofumpt
84+
- gci
85+
- golines
86+
settings:
87+
gci:
88+
sections:
89+
- standard
90+
- default
91+
- localmodule
92+
golines:
93+
max-len: 120
94+
chain-split-dots: true
95+

CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* @dro-sh

README.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Gracegroup
2+
3+
Gracegroup is a package to execute processes and gracefully shutdown them. API is simple:
4+
5+
1. Add functions to start process and shutdown functions to stop process.
6+
2. Wait for shutdown.
7+
8+
## Quickstart
9+
10+
Install **gracegroup**:
11+
12+
```bash
13+
go get -u github.com/dro-sh/gracegroup
14+
```
15+
16+
Simple example with http server with start and graceful shutdown:
17+
18+
```go
19+
package main
20+
21+
import (
22+
"context"
23+
"net/http"
24+
"os/signal"
25+
"syscall"
26+
27+
"github.com/dro-sh/gracegroup"
28+
)
29+
30+
func main() {
31+
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
32+
defer stop()
33+
34+
srv := http.Server{}
35+
36+
group, err := gracegroup.New(gracegroup.DefaultConfig)
37+
if err != nil {
38+
panic(err)
39+
}
40+
41+
group.Add(srv.ListenAndServe, srv.Shutdown)
42+
43+
if err := group.Wait(ctx); err != nil {
44+
panic(err)
45+
}
46+
}
47+
```
48+
49+
## Motivation
50+
51+
Almost all services contain a few executable processes and some of them need to be stopped gracefully, e.g, http server. So there is a need start and gracefully shutdown them. But each process should be start on its own goroutine and each shutdown must meet timeout for shutdown, and each process could initiate shutdown (on error, for example) and shutdown function should not affect to other shutdown functions. And this package indents to make it easy.
52+
53+
How it works:
54+
55+
- Add start and shutdown functions to `gracegroup.Group`.
56+
- Wait while some of start function returns error on passed context will be canceled.

config.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package gracegroup
2+
3+
import "time"
4+
5+
var DefaultShutdownTimeout = 5 * time.Second
6+
7+
type Config struct {
8+
// ShutdownTimeout is the maximum amount of time to wait for execution of all shutdown functions.
9+
// After this period, the shutdown process wont be waiting to finish.
10+
ShutdownTimeout time.Duration
11+
}
12+
13+
var DefaultConfig = Config{
14+
ShutdownTimeout: DefaultShutdownTimeout,
15+
}

example/servers.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"log"
6+
"net/http"
7+
"os/signal"
8+
"syscall"
9+
"time"
10+
11+
"github.com/dro-sh/gracegroup"
12+
)
13+
14+
func main() {
15+
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
16+
defer stop()
17+
18+
// some initialization code with db, logger, etc
19+
// that has defer functions to close connections, flush buffers, etc
20+
21+
http.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
22+
if _, err := w.Write([]byte("pong")); err != nil {
23+
log.Printf("failed to write response: %v", err)
24+
}
25+
})
26+
27+
srv := http.Server{
28+
Addr: ":8080",
29+
Handler: http.DefaultServeMux,
30+
ReadHeaderTimeout: 1 * time.Second,
31+
}
32+
33+
group := gracegroup.New(gracegroup.DefaultConfig)
34+
35+
group.Add(srv.ListenAndServe, srv.Shutdown)
36+
37+
if err := group.Wait(ctx); err != nil {
38+
panic(err) // dont fatal because upper could be defer functions
39+
}
40+
41+
log.Println("server stopped")
42+
}

go.mod

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module github.com/dro-sh/gracegroup
2+
3+
go 1.23
4+
5+
require golang.org/x/sync v0.11.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
2+
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=

group.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package gracegroup
2+
3+
import (
4+
"context"
5+
"errors"
6+
"sync"
7+
8+
"golang.org/x/sync/errgroup"
9+
)
10+
11+
type (
12+
// StartFn does not accept context as argument because most of start functions do not need it
13+
// because then context canceled shutdown functions should be called.
14+
// If start fn returns error then group will be shutdown, otherwise it won't.
15+
StartFn func() error
16+
// ShutdownFn accepts context as argument to handle shutdown timeout and must
17+
// support handling timed out context.
18+
// If function returns error it wont affect to other shutdown functions.
19+
ShutdownFn func(ctx context.Context) error
20+
)
21+
22+
// Gracegroup is a managare to execute processes and functions to shutdown processes.
23+
type Group struct {
24+
mu sync.Mutex
25+
26+
cfg Config
27+
startFns []StartFn
28+
shutdownFns []ShutdownFn
29+
}
30+
31+
func New(cfg Config) *Group {
32+
return &Group{
33+
cfg: cfg,
34+
startFns: make([]StartFn, 0),
35+
shutdownFns: make([]ShutdownFn, 0),
36+
}
37+
}
38+
39+
// Add adds a start function and a shutdown function to the group.
40+
// Func does not invoke start func immediately, it will wait for Wait method.
41+
func (r *Group) Add(start StartFn, shutdown ShutdownFn) {
42+
r.mu.Lock()
43+
defer r.mu.Unlock()
44+
45+
r.startFns = append(r.startFns, start)
46+
r.shutdownFns = append(r.shutdownFns, shutdown)
47+
}
48+
49+
// Wait does next things:
50+
// 1. invokes all start functions concurrently,
51+
// 2. waits one of next condition:
52+
// a. passed to Wait context is canceled,
53+
// b. one of start functions returns error,
54+
// 3. invokes all shutdown functions concurrently and waiting while all of them will be finished.
55+
//
56+
// If any of start functions return nil then group won't initiate shutdown.
57+
// If any of shutdown functions returns error it wont stop shutdown process. Shutdown function
58+
// must support context DeadlineExceeded error and exit on it. Group does not forcelly stop shutdown
59+
// then context deadline exceeded.
60+
//
61+
// Wait could return error from:
62+
// 1. one of start functions,
63+
// 2. one of shutdown functions,
64+
// 3. error from Wait context if it is not context.Calceled error,
65+
// 4. context.DeadlineExceeded if cfg.ShutdownTimeout is exceeded on shutdown.
66+
func (r *Group) Wait(ctx context.Context) error {
67+
g, ctx := errgroup.WithContext(ctx)
68+
69+
for _, start := range r.startFns {
70+
g.Go(start)
71+
}
72+
73+
err := r.wait(ctx, g)
74+
75+
shutdownError := r.shutdown()
76+
77+
return errors.Join(shutdownError, err)
78+
}
79+
80+
func (r *Group) wait(ctx context.Context, g *errgroup.Group) error {
81+
done := make(chan struct{})
82+
83+
go func() {
84+
//nolint:errcheck,gosec // err will be set on errgroup context cause
85+
g.Wait()
86+
87+
close(done)
88+
}()
89+
90+
// no needs set error from ctx or errgroup
91+
// because errgroup set error cause to context on wait method
92+
// or argument context has error cause
93+
select {
94+
case <-done:
95+
case <-ctx.Done():
96+
}
97+
98+
if err := context.Cause(ctx); !errors.Is(err, context.Canceled) {
99+
return err
100+
}
101+
102+
return nil
103+
}
104+
105+
func (r *Group) shutdown() error {
106+
ctx, cancel := context.WithCancel(context.Background())
107+
108+
if r.cfg.ShutdownTimeout > 0 {
109+
ctx, cancel = context.WithTimeout(ctx, r.cfg.ShutdownTimeout)
110+
}
111+
defer cancel()
112+
113+
g := &errgroup.Group{}
114+
115+
for _, shutdownFn := range r.shutdownFns {
116+
g.Go(func() error {
117+
return shutdownFn(ctx)
118+
})
119+
}
120+
121+
return g.Wait()
122+
}

0 commit comments

Comments
 (0)