|
| 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. |
0 commit comments