Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b45c410
Add closer README and document the package
a-menshchikov Oct 29, 2026
d549730
Add ctxkey README and document the package
a-menshchikov Oct 29, 2026
7d61df8
Add deferred README and document the package
a-menshchikov Oct 29, 2026
9772f85
Add protomap README and document the package
a-menshchikov Oct 29, 2026
7e7974a
Add strcase README and document the package
a-menshchikov Oct 29, 2026
2a90eac
Add sync README and document the package
a-menshchikov Oct 29, 2026
49f8c06
Add time README and document the package
a-menshchikov Oct 29, 2026
f2afebb
Document the dispatcher package
a-menshchikov Oct 29, 2026
a2ed8f4
Add the module index to the root README
a-menshchikov Oct 29, 2026
4268876
Accept the whole numbers in YAML and cover the invalid values
a-menshchikov Oct 30, 2026
a360e28
Fix the wording in the deferred README
a-menshchikov Oct 30, 2026
e5aaa09
Format the strcase README
a-menshchikov Oct 30, 2026
6b89c8d
Format the sync README
a-menshchikov Oct 30, 2026
c819847
Format the module index in the root README
a-menshchikov Oct 30, 2026
6e0ab54
Rename the context key variable in the ctxkey README
a-menshchikov Oct 30, 2026
2eb6217
Rework the deferred README
a-menshchikov Oct 30, 2026
8602b27
Update the deferred description in the root README
a-menshchikov Oct 30, 2026
55b60d7
Format the closer README
a-menshchikov Oct 30, 2026
51740da
Adjust the fractional duration in the time tests
a-menshchikov Oct 30, 2026
d5ef3f9
Clarify the StopPropagationError description
a-menshchikov Oct 30, 2026
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
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,37 @@
# pkg
# pkg

A collection of small, standalone Go packages.

Every package is a separate Go module with its own dependencies, so importing
one of them does not pull anything else in.

## Modules

| Module | Description |
|----------------------------|-------------------------------------------------------------------------------------------------|
| [closer](./closer) | Collection of cleanup functions, run once — explicitly, or on a context cancel or an OS signal. |
| [ctxkey](./ctxkey) | Unique, pointer-backed context keys. |
| [deferred](./deferred) | Helpers for the `defer` calls. |
| [dispatcher](./dispatcher) | Routing of keyed events to handlers, with priorities, propagation control and wildcards. |
| [protomap](./protomap) | Deserialization of protobuf messages by their descriptor name. |
| [strcase](./strcase) | Detection of the case of a string and conversion between the case styles. |
| [sync](./sync) | A `sync.Once` that keeps retrying until the guarded function succeeds. |
| [time](./time) | A duration type that is serialized as a human-readable string. |

Each module has its own README with the description of the API and the
examples of the usage.

## Usage

The packages are imported by their module path:

```go
import (
"github.com/nbgrp/pkg/closer"
"github.com/nbgrp/pkg/strcase"
)
```

## License

[BSD 3-Clause](./LICENSE)
172 changes: 172 additions & 0 deletions closer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
# closer

A small, dependency-free Go package for collecting cleanup functions and running
them exactly once — either explicitly, or automatically when a context is canceled
or an OS signal arrives.

The package has two layers:

- a set of package-level functions operating on a global closer, useful for
"process-wide" cleanup;
- the [`Closer`](./closer.go) interface and its implementation, useful when the
cleanup scope is explicit and must be bound to a particular context.

## Public API

### Types

```go
type CloseFn func(ctx context.Context) error

type Closer interface {
Adder
// SetContext sets context which will be passed into the close functions without cancel.
SetContext(ctx context.Context)
// Done returns signal channel.
Done() <-chan struct{}
// Err returns a joint error based on close functions errors.
Err() error
// CloseAll runs close functions in arbitrary order.
CloseAll()
}

type Adder interface {
// Add adds close function.
Add(CloseFn)
}
```

- `CloseFn` is a cleanup function. It receives a context, but not the one the
closer was bound to — the cancellation is stripped away (see
[The close context](#the-close-context)).
- `Adder` is the registration half of `Closer`: it only knows how to register
a close function.
- `Closer` adds the lifecycle to it: setting the close context, waiting for the
cleanup to finish (`Done`), reading the result (`Err`) and triggering it
(`CloseAll`).

### The global closer

```go
func SetContext(ctx context.Context)
func Add(f CloseFn)
func Done() <-chan struct{}
func Err() error
func CloseAll()
```

These operate on a package-level closer that is created on initialization. They
are handy for libraries that need to register a cleanup without owning the
application's lifecycle: the application calls `closer.CloseAll()` on shutdown
and inspects `closer.Err()` afterwards.

The global closer is created with `context.Background()`, so
`closer.SetContext(...)` is the way to give the close functions access to the
application context.

### Creating a closer

```go
func New(ctx context.Context, opts ...Option) (context.Context, *closer)
```

`New` returns the closer together with the context it is bound to. The context
is returned because it may be replaced by `New` itself: with `WithSignals` the
returned context is a signal-notifiable one, and it is this returned value —
not the one passed in — that has to be used by the application.

```go
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

ctx, cl := closer.New(ctx, closer.WithContextCancel())
defer cl.CloseAll()
```

`New` panics if `ctx` is nil.

## Options

```go
func WithContextCancel() Option
func WithSignals(signals ...os.Signal) Option
```

- `WithContextCancel` closes the closer automatically as soon as the context
passed to `New` is done.
- `WithSignals` does the same, but the context is first wrapped into a
signal-notifiable one, so that the closer is closed when any of the given
signals arrives. It implies `WithContextCancel`.

```go
ctx, cl := closer.New(context.Background(), closer.WithSignals(syscall.SIGINT, syscall.SIGTERM))

cl.Add(func(ctx context.Context) error {
fmt.Println("shutting down")
return nil
})

<-ctx.Done() // the context is canceled on SIGINT/SIGTERM
<-cl.Done() // ... and the closer is closed right after that
```

## Closing

`CloseAll` runs every registered close function and then closes the `Done`
channel. It is idempotent: only the first call has an effect, subsequent calls
return immediately.

The close functions run **concurrently**, in an arbitrary order, and each of them
gets its own goroutine. There is no ordering guarantee between them, so a close
function must not depend on another one having finished.

`Done` is closed after all close functions have returned, which makes it the
right place to wait for the cleanup to complete:

```go
cl.CloseAll()
<-cl.Done()

if err := cl.Err(); err != nil {
// at least one close function failed
}
```

## Errors

`Err` returns the `errors.Join` of all errors returned by the close functions.
It returns `nil` while the closer is not closed yet, and `nil` after it has been
closed if every close function succeeded.

Errors are collected, not short-circuited: a failing close function does not
prevent the others from running.

## The close context

Close functions receive a context that carries the **values** of the context
set on the closer, but is **not** canceled when that context is canceled
(the implementation uses `context.WithoutCancel`).

This is what makes the automatic shutdown work: with `WithContextCancel` the
closer is closed exactly because the context is done, and passing that canceled
context to the cleanup functions would make them abort immediately.

The context can be replaced at any time with `SetContext`:

```go
cl.SetContext(context.WithValue(ctx, key, "value"))
```

The closer reads the context at the moment it is closed, so the last value set
before `CloseAll` is the one the close functions receive.

## Concurrency

`Add`, `SetContext`, `Done`, `Err` and `CloseAll` are safe for concurrent use.

`Add` is lock-free: the functions are stored in an atomically swapped slice, so
registering a cleanup never blocks the caller. Registering a function after the
closer has been closed has no effect — the function is never called.

`CloseAll` runs the close functions concurrently and waits for all of them
before closing the `Done` channel.
30 changes: 29 additions & 1 deletion closer/closer.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import (
"sync/atomic"
)

// CloseFn is a cleanup function registered in a Closer.
//
// The context passed to it carries the values of the context set on the
// Closer, but is detached from its cancellation, so that the cleanup is able
// to finish its work even after the context has been canceled.
type CloseFn func(ctx context.Context) error

// Closer cleans up resources.
Expand Down Expand Up @@ -39,6 +44,7 @@ type closer struct {
once sync.Once
}

// global is the Closer used by the package-level functions.
var _, global = New(context.Background())

// SetContext sets context into the global Closer.
Expand Down Expand Up @@ -66,7 +72,14 @@ func CloseAll() {
global.CloseAll()
}

// New returns Closer implementation.
// New returns a Closer implementation together with the context it is bound to.
//
// The returned context is the one passed in, wrapped into a signal-notifiable
// context when [WithSignals] is used. When [WithContextCancel] or
// [WithSignals] is used, the closer is closed automatically as soon as that
// context is done.
//
// New panics if ctx is nil.
func New(ctx context.Context, opts ...Option) (context.Context, *closer) {
o := options{}
for _, opt := range opts {
Expand Down Expand Up @@ -98,13 +111,18 @@ func New(ctx context.Context, opts ...Option) (context.Context, *closer) {
return ctx, c
}

// SetContext sets the context passed to the close functions.
func (c *closer) SetContext(ctx context.Context) {
if ctx == nil {
panic("cannot set nil context")
}
c.ctx.Store(&ctx)
}

// Add registers a close function.
//
// The function is called exactly once, when the closer is closed. Registering
// a function in an already closed closer has no effect.
func (c *closer) Add(f CloseFn) {
var funcs []CloseFn
old := c.funcs.Load()
Expand All @@ -126,10 +144,14 @@ func (c *closer) Add(f CloseFn) {
}
}

// Done returns a channel that is closed after all close functions have finished.
func (c *closer) Done() <-chan struct{} {
return c.done
}

// Err returns the joined error of the close functions.
//
// It returns nil until the closer is closed.
func (c *closer) Err() error {
select {
case <-c.done:
Expand All @@ -140,6 +162,12 @@ func (c *closer) Err() error {
}
}

// CloseAll runs all registered close functions concurrently and closes the done
// channel.
//
// It is safe to call it multiple times: only the first call has an effect. The
// close functions receive a context detached from the cancellation of the
// context set on the closer, so that they are able to complete their work.
func (c *closer) CloseAll() { //nolint:gocognit
c.once.Do(func() {
defer close(c.done)
Expand Down
1 change: 1 addition & 0 deletions closer/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type options struct {
ctxCancel bool
}

// Option configures a Closer created by [New].
type Option func(options) options

// WithContextCancel allows to call closer CloseAll on context cancel implicitly.
Expand Down
Loading
Loading