Skip to content

Commit 4ff6225

Browse files
committed
Add dispatcher pkg
1 parent 7c7e3ba commit 4ff6225

8 files changed

Lines changed: 1543 additions & 2 deletions

File tree

dispatcher/.golangci.yml

Lines changed: 315 additions & 0 deletions
Large diffs are not rendered by default.

dispatcher/README.md

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
# dispatcher
2+
3+
A small, dependency-free Go package for routing keyed events to one or more handlers, with
4+
optional priorities, propagation control, and wildcard matching.
5+
6+
The package is split into a thin contract layer and backend implementation:
7+
8+
- [`github.com/nbgrp/pkg/dispatcher`](./dispatcher.go) — public types and interfaces.
9+
- [`github.com/nbgrp/pkg/dispatcher/trie`](./trie/trie.go) — the current implementation, a
10+
prefix-tree (trie) keyed by the event path.
11+
12+
## Public API
13+
14+
The root package defines the contracts every backend implements.
15+
16+
### Types
17+
18+
```go
19+
type Handler func(ctx context.Context, payload ...any) error
20+
21+
type Dispatcher interface {
22+
Dispatch(ctx context.Context, key string, payload ...any) error
23+
}
24+
25+
type Listener interface {
26+
Listen(keyPattern string, handler Handler) (cancel func(), err error)
27+
}
28+
29+
type PriorityListener interface {
30+
ListenWithPriority(keyPattern string, handler Handler, priority int) (cancel func(), err error)
31+
}
32+
```
33+
34+
- `Handler` is the user-supplied callback invoked for a matching `Dispatch`.
35+
- `Dispatcher.Dispatch` runs all handlers registered for `key` and returns their joined errors.
36+
`payload` is forwarded to every handler.
37+
- `Listener.Listen` and `PriorityListener.ListenWithPriority` register a handler on `key`
38+
and return a `cancel` function that removes the registration. A backend must return
39+
an error for invalid keys (empty, leading/trailing/duplicated separators) and a `nil`
40+
handler.
41+
42+
A concrete dispatcher typically satisfies `Listener` and `PriorityListener` in addition
43+
to `Dispatcher`; users cast or interface-assert to whichever subset they need.
44+
45+
### Stopping the chain
46+
47+
```go
48+
type StopPropagationError struct {
49+
Inner error
50+
}
51+
```
52+
53+
A handler may return a `*StopPropagationError` to short-circuit dispatch and (optionally)
54+
propagate an inner error. See each backend's documentation for the exact semantics — in
55+
particular, `ModePriority` and `ModeConcurrent` interpret it differently.
56+
57+
## Implementations
58+
59+
### `trie`
60+
61+
[`github.com/nbgrp/pkg/dispatcher/trie`](./trie/trie.go) is a prefix-tree implementation
62+
that supports wildcards and two execution modes (`Priority` and `Concurrent`).
63+
64+
#### Quickstart
65+
66+
```go
67+
import (
68+
"context"
69+
"fmt"
70+
71+
"github.com/nbgrp/pkg/dispatcher/trie"
72+
)
73+
74+
func main() {
75+
d, err := trie.NewDispatcher() // defaults: ModePriority, '.' separator, '*' wildcard
76+
if err != nil {
77+
panic(err)
78+
}
79+
80+
cancel, err := d.Listen("user.created", func(_ context.Context, payload ...any) error {
81+
fmt.Printf("user.created: %v\n", payload)
82+
return nil
83+
})
84+
if err != nil {
85+
panic(err)
86+
}
87+
defer cancel()
88+
89+
_ = d.Dispatch(context.Background(), "user.created", 42, "alice")
90+
// Output: user.created: [42 alice]
91+
}
92+
```
93+
94+
Keys are split by the configured separator (default `.`) and matched segment by segment.
95+
Handlers are registered on concrete keys; use the wildcard segment `*` to subscribe to
96+
"any value at this position" (see [Wildcards](#wildcards-trie)).
97+
98+
#### Modes
99+
100+
The dispatcher can run handlers in one of two modes. The mode is selected at construction
101+
time and cannot be changed afterwards.
102+
103+
##### `trie.ModePriority` (default)
104+
105+
Handlers are sorted in **descending** order of their `priority` and called sequentially.
106+
Handlers registered with the same priority keep their registration order (sort is stable).
107+
Returning a `*dispatcher.StopPropagationError` from a handler stops the chain: handlers
108+
with lower priority are **not** called. If `StopPropagationError.Inner` is non-nil, it is
109+
returned by `Dispatch`; otherwise `Dispatch` returns `nil`.
110+
111+
```go
112+
d, _ := trie.NewDispatcher(trie.WithMode(trie.ModePriority))
113+
114+
_, _ = d.ListenWithPriority("evt", high, 10)
115+
_, _ = d.Listen("evt", mid) // priority 0
116+
_, _ = d.ListenWithPriority("evt", low, -5)
117+
118+
// Dispatch order: high, mid, low
119+
```
120+
121+
##### `trie.ModeConcurrent`
122+
123+
All matching handlers are launched concurrently via `sync.WaitGroup.Go` and the
124+
dispatcher waits for every one of them to return. The dispatch error is the `errors.Join`
125+
of every handler's return value.
126+
127+
`StopPropagationError` has **no interrupting effect** in this mode: every matching
128+
handler has already been scheduled by the time one of them returns it. It is still
129+
treated specially for error reporting: with a non-nil `Inner` the inner error is joined
130+
into the result; without an `Inner` the stop-propagation signal is dropped from the
131+
joined error (i.e. it is treated as success).
132+
133+
```go
134+
d, _ := trie.NewDispatcher(trie.WithMode(trie.ModeConcurrent))
135+
136+
_, _ = d.Listen("evt.a", h1)
137+
_, _ = d.Listen("evt.a", h2)
138+
_, _ = d.Listen("evt.b", h3)
139+
140+
_ = d.Dispatch(ctx, "evt.a") // h1 and h2 run in parallel
141+
_ = d.Dispatch(ctx, "evt.b") // h3 runs alone
142+
```
143+
144+
#### Priorities and cancellation
145+
146+
`ListenWithPriority(key, handler, priority)` accepts an `int` priority — the higher the
147+
number, the earlier the handler runs in `ModePriority`. Priorities are ignored in
148+
`ModeConcurrent`.
149+
150+
`Listen` and `ListenWithPriority` both return a `cancel` function. Calling it marks the
151+
registration as deleted. The handler is not removed from the trie eagerly; instead the
152+
dispatcher filters out deleted handlers on every `Dispatch` and lazily prunes them from
153+
the trie on the next match. This makes `cancel` safe to call concurrently with `Dispatch`.
154+
155+
```go
156+
cancel, err := d.Listen("evt", handler)
157+
if err != nil { /* invalid key, nil handler, ... */ }
158+
159+
cancel() // handler will not be called for any future Dispatch("evt", ...)
160+
```
161+
162+
##### Valid keys
163+
164+
The constructor options `WithKeySeparator` and `WithWildcardMark` default to `.` and `*`
165+
respectively. The following keys are rejected with an error:
166+
167+
- empty string;
168+
- starting or ending with the separator (e.g. `.a`, `a.`);
169+
- containing consecutive separators (e.g. `a..b`);
170+
- a `nil` handler.
171+
172+
#### <a id="wildcards-trie"></a>Wildcards
173+
174+
A key segment equal to the wildcard mark (default `*`) matches **any single segment** of
175+
the dispatched key. A registered handler is invoked when every segment of its key —
176+
wildcards and concrete segments alike — matches the dispatched key.
177+
178+
The wildcard can appear in any position: leading (`*.created`), middle
179+
(`user.*.updated`), or trailing (`user.deleted.*`). Concrete and wildcard handlers on
180+
overlapping paths are both invoked.
181+
182+
```go
183+
d, _ := trie.NewDispatcher()
184+
185+
_, _ = d.Listen("user.*.updated", onUserUpdated)
186+
_, _ = d.Listen("*.created", onAnythingCreated)
187+
_, _ = d.Listen("user.deleted", onUserDeleted)
188+
189+
_ = d.Dispatch(ctx, "user.alice.updated") // onUserUpdated
190+
_ = d.Dispatch(ctx, "order.created") // onAnythingCreated
191+
_ = d.Dispatch(ctx, "user.deleted") // onUserDeleted
192+
```
193+
194+
In `ModePriority`, handlers contributed by a wildcard and a concrete match on the same
195+
dispatched key are interleaved by their priority. Handlers reached through wildcards are
196+
added to the candidate set in the order the trie walk encounters them (wildcard child is
197+
checked before concrete child at each node), and `slices.SortStableFunc` then reorders by
198+
priority while preserving the relative order of equal-priority handlers.
199+
200+
#### Constructor options
201+
202+
```go
203+
d, err := trie.NewDispatcher(
204+
trie.WithMode(trie.ModeConcurrent), // default: trie.ModePriority
205+
trie.WithKeySeparator('/'), // default: '.'
206+
trie.WithWildcardMark('#'), // default: '*'
207+
)
208+
```
209+
210+
The three option functions are the only configuration knobs: mode, key separator, and
211+
wildcard mark. Anything else is fixed at construction time.
212+
213+
#### Errors
214+
215+
`Dispatch` returns `errors.Join(errs...)` of every matched handler's return value (after
216+
unwrapping `StopPropagationError` per the rules above). It returns `nil` only when every
217+
handler returned `nil` (or a `StopPropagationError` without an `Inner`).
218+
219+
A handler may short-circuit the chain in `ModePriority` by returning:
220+
221+
```go
222+
return &dispatcher.StopPropagationError{} // stop, no error
223+
return &dispatcher.StopPropagationError{Inner: someError} // stop, propagate error
224+
```
225+
226+
#### Concurrency
227+
228+
`Listen`, `ListenWithPriority`, `Dispatch`, and `cancel` are safe for concurrent use. The
229+
implementation lazily prunes cancelled handlers during dispatch, so the cost of a `cancel`
230+
is constant and independent of trie size.

dispatcher/dispatcher.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// SPDX-License-Identifier: BSD-3-Clause
2+
3+
package dispatcher
4+
5+
import (
6+
"context"
7+
)
8+
9+
type StopPropagationError struct {
10+
Inner error
11+
}
12+
13+
func (e *StopPropagationError) Error() string {
14+
msg := "stop propagation"
15+
if e.Inner != nil {
16+
msg += ": " + e.Inner.Error()
17+
}
18+
return msg
19+
}
20+
21+
type Handler func(context.Context, ...any) error
22+
23+
type Dispatcher interface {
24+
Dispatch(ctx context.Context, key string, payload ...any) error
25+
}
26+
27+
type Listener interface {
28+
Listen(keyPattern string, handler Handler) (cancel func(), err error)
29+
}
30+
31+
type PriorityListener interface {
32+
ListenWithPriority(keyPattern string, handler Handler, priority int) (cancel func(), err error)
33+
}

dispatcher/go.mod

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
module github.com/nbgrp/pkg/dispatcher
2+
3+
go 1.26.0
4+
5+
require github.com/stretchr/testify v1.11.1
6+
7+
require (
8+
github.com/davecgh/go-spew v1.1.1 // indirect
9+
github.com/pmezard/go-difflib v1.0.0 // indirect
10+
gopkg.in/yaml.v3 v3.0.1 // indirect
11+
)

dispatcher/go.sum

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
4+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
5+
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
6+
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
7+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
8+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
9+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
10+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

0 commit comments

Comments
 (0)