Skip to content

Commit 98ce9e1

Browse files
authored
refactor(core): dedup middlewareContainer by per-instance Key() not type (#672) (#697)
## Summary \`core.middlewareContainer.Use()\` previously deduped by \`reflect.TypeOf(m).String()\`, silently dropping any second instance of the same Go type. This was the structural root cause of [#670](#670) (multiple per-job webhooks collapsing into one), worked around at the webhook layer in PR [#671](#671) with the \`WebhookMiddleware\` composite. The composite stays — removing it is a separate PR per the [#672 scope guidance](#672) — but the structural bug is now closed at the core layer. Adds an OPTIONAL \`keyedMiddleware\` interface (\`Key() string\`). \`Use()\` computes the dedup key via type assertion: implementer returns a non-empty string → use it; otherwise fall back to \`reflect.TypeOf(m).String()\`. Closes [#672](#672). ## Design choice: opt-in via type assertion (not interface contract change) Considered adding \`Key()\` to the \`Middleware\` interface; rejected because every implementer (in-repo AND downstream) would need a stub method providing no value for the common 1-per-type case. The assertion-based opt-in keeps the contract small and lets the type-string fallback do the work it's always done. **Implementer matrix:** - \`*middlewares.Webhook\` → \`Key() = Config.Name\` (multiple per job, distinct names coexist) - \`Slack\`, \`Mail\`, \`Save\`, \`Overlap\`, \`WebhookMiddleware\` → no \`Key()\` method (legacy type dedup preserved) - Downstream custom middleware → no changes required ## Why pointer-identity dedup was rejected The [#672 issue body](#672 (comment)) listed pointer-identity as Option 3. Rejected because scheduler-to-job propagation at \`core/scheduler.go:315\`/\`:631\`/\`config_webhook.go:289\` calls \`j.Use(s.Middlewares()...)\`: with pointer-identity dedup, a scheduler-level Slack middleware would coexist with a job-level Slack middleware of the same type (different pointers), causing every Slack notification to fire twice. \`Key()\`-based dedup with type-string fallback preserves the existing "first wins per type" semantics for Slack/Mail/Save/Overlap and enables multi-Webhook by construction. ## Tests (TDD) Six new test functions in \`core/middleware_container_test.go\` (plus \`TestWebhook_Key\` in \`middlewares/webhook_test.go\`): - \`TestMiddlewareContainer_Use_DistinctKeysKeepsBoth\` — regression test for #670 at the core layer. Confirmed failing on pre-fix code. - \`TestMiddlewareContainer_Use_SameKeyDedups\` — same-name propagation contract. - \`TestMiddlewareContainer_Use_UnkeyedFallsBackToType\` — Slack/Mail/Save/Overlap semantics preserved. - \`TestMiddlewareContainer_Use_KeyedAndUnkeyedCoexist\` — key-namespace isolation. - \`TestMiddlewareContainer_Use_EmptyKeyFallsBackToType\` — explicit opt-out via \`Key() returning ""\` gets legacy dedup (used by \`WebhookMiddleware\` composite if it ever needs an explicit opt-out). - \`TestWebhook_Key\` — pins \`Webhook.Key() == Config.Name\`. ## Out of scope (follow-up PRs) - Removing the \`WebhookMiddleware\` composite from PR #671 (the workaround is now redundant for dedup but the composite still serves the \`ContinueOnStop()\` execution-ordering contract — needs its own analysis). - Per-instance dedup for any future N-instance middleware (Slack channels, Mail recipients, etc.) — just implement \`Key() string\` returning the operator-supplied name. ## Test plan - [x] \`go test ./...\` passes (full repo, 14 packages, ~58s) - [x] \`golangci-lint run\` clean - [x] \`go vet ./...\` clean - [ ] CI green ## References - Structural root cause of: [#670](#670) (fixed at webhook layer by [#671](#671)) - Tracks: [#672](#672)
2 parents 4dc8b73 + be16248 commit 98ce9e1

5 files changed

Lines changed: 415 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- `PresetLoader.AddLocalPresetDir` now scans the registered directory for `*.yaml` files whose stem collides with a bundled preset name (`slack`, `discord`, `teams`, `matrix`, `ntfy`, `ntfy-token`, `pushover`, `pagerduty`, `gotify`, `json-post`) and emits a startup `slog.Warn` per collision. Pre-fix, `PresetLoader.Load` resolved bundled presets first and never fell through, so a file at `$LOCAL_DIR/json-post.yaml` placed hoping to override the bundled `json-post` was silently ignored at attach time. The warning matches `Load`'s `.yaml`-only resolution path so a `.yml` rename suggestion never misleads operators. The lookup order is documented in `docs/webhooks.md` under "Preset Lookup Order"; inverting the order to prefer local files is deliberately rejected (a local typo shadowing `slack.yaml` would silently break Slack delivery host-wide). Closes [#679](https://github.com/netresearch/ofelia/issues/679).
1313

14+
- `core.middlewareContainer.Use()` now dedups per-instance via an optional `Key() string` interface instead of by `reflect.TypeOf(m).String()`. Pre-fix, two `*middlewares.Webhook` instances handed to the same job collapsed into the first and silently dropped the rest — the failure mode tracked in [#670](https://github.com/netresearch/ofelia/issues/670) and worked around at the webhook layer by PR [#671](https://github.com/netresearch/ofelia/pull/671) with the `WebhookMiddleware` composite. The composite stays in place (no behavior change there), but the structural bug is now closed at the core layer so any future N-instance middleware (e.g. multiple Slack channels, multiple Mail recipient sets) gets correct semantics by construction. Existing 1-per-type middlewares (`Slack`, `Mail`, `Save`, `Overlap`, `WebhookMiddleware`) do not implement `Key()`, so they fall back to the legacy type-string dedup — `j.Use(s.Middlewares()...)` scheduler-to-job propagation still de-duplicates them as before. `*middlewares.Webhook` returns `Config.Name` from `Key()`, so the same-name propagation case (scheduler-level webhook re-propagated to a job that already has it) still de-duplicates correctly. Opt-in via type assertion means downstream consumers with custom middleware implementations need no changes. Closes [#672](https://github.com/netresearch/ofelia/issues/672).
15+
1416
### Removed
1517

1618
- **BREAKING (source-only, pre-1.0):** Removed unused `core/adapters/docker.ClientConfig.HTTPClient` field that was declared in [#681](https://github.com/netresearch/ofelia/pull/681) but never read — a caller setting `cfg.HTTPClient = someClient` silently got the auto-constructed transport instead of theirs. Downstream Go consumers that referenced the field in named struct literals or assignments will see a compile-time error after upgrade (semantically a no-op since the field was already ignored at runtime); permitted under SemVer for the current 0.y.z line (cf. [SemVer §4](https://semver.org/#spec-item-4)). Removing the field turns the silent footgun into a loud compile-time error rather than preserving it as a deprecated no-op. If you need a transport-level injection seam, file a feature request with the use case so the suppression of `disableHTTP2AutoConfig` on caller-supplied transports (the [#668](https://github.com/netresearch/ofelia/issues/668) invariant) can be wired in correctly. ([#693](https://github.com/netresearch/ofelia/pull/693), closes [#684](https://github.com/netresearch/ofelia/issues/684))

core/common.go

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,53 @@ type Middleware interface {
295295
ContinueOnStop() bool
296296
}
297297

298+
// keyedMiddleware is an OPTIONAL interface that middleware implementations
299+
// can satisfy to opt into per-instance dedup keys in middlewareContainer.
300+
// Implementers that legitimately need multiple instances per job (e.g.
301+
// multiple Webhook destinations with distinct Config.Name) return a stable,
302+
// non-empty key per instance — typically the operator-supplied name from
303+
// configuration. Returning "" or not implementing Key() at all falls back
304+
// to the legacy reflect.TypeOf(m).String() dedup, preserving the
305+
// 1-per-type semantics of Slack, Mail, Save, Overlap, and the
306+
// WebhookMiddleware composite from PR #671.
307+
//
308+
// Why opt-in via type assertion (no interface contract change): adding
309+
// Key() to the Middleware interface would force every implementer (in
310+
// this repo and any downstream consumer) to add a stub method, providing
311+
// no value for the common 1-per-type case. The assertion-based opt-in
312+
// keeps the contract small and lets the type-string fallback do the work
313+
// it has always done.
314+
//
315+
// Tracks the structural fix for
316+
// https://github.com/netresearch/ofelia/issues/672; sibling to #670 /
317+
// PR #671 which worked around the multi-webhook case at the webhook layer.
318+
type keyedMiddleware interface {
319+
Key() string
320+
}
321+
322+
// middlewareKey computes the dedup key for a middleware. Implementers of
323+
// keyedMiddleware that return a non-empty string get that string as the
324+
// key; everyone else (including keyedMiddleware implementers that
325+
// explicitly return "") falls back to reflect.TypeOf(m).String().
326+
func middlewareKey(m Middleware) string {
327+
if k, ok := m.(keyedMiddleware); ok {
328+
if key := k.Key(); key != "" {
329+
return key
330+
}
331+
}
332+
return reflect.TypeOf(m).String()
333+
}
334+
335+
// middlewareContainer holds a job's (or scheduler's) middleware chain with
336+
// per-key dedup so the same logical middleware is not invoked twice when
337+
// scheduler-level middlewares are propagated to a job via
338+
// j.Use(s.Middlewares()...). See middlewareKey for the dedup-key strategy.
339+
//
340+
// Not safe for concurrent use; callers must serialize. In practice
341+
// (*Scheduler).Use happens under Scheduler.mu, and (*BareJob).Use is
342+
// invoked from config-parse paths and from AddJob before the job is
343+
// published to the cron runner — there is no in-flight reader during
344+
// mutation.
298345
type middlewareContainer struct {
299346
m map[string]Middleware
300347
order []string
@@ -310,13 +357,13 @@ func (c *middlewareContainer) Use(ms ...Middleware) {
310357
continue
311358
}
312359

313-
t := reflect.TypeOf(m).String()
314-
if _, ok := c.m[t]; ok {
360+
k := middlewareKey(m)
361+
if _, ok := c.m[k]; ok {
315362
continue
316363
}
317364

318-
c.order = append(c.order, t)
319-
c.m[t] = m
365+
c.order = append(c.order, k)
366+
c.m[k] = m
320367
}
321368
}
322369

core/middleware_container_test.go

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
// Copyright (c) 2025-2026 Netresearch DTT GmbH
2+
// SPDX-License-Identifier: MIT
3+
4+
package core
5+
6+
import (
7+
"testing"
8+
)
9+
10+
// keyedFakeMiddleware exercises the Key()-based dedup path: two distinct
11+
// instances of the same Go type can coexist in a middlewareContainer when
12+
// they declare distinct keys. Mirrors the *middlewares.Webhook contract
13+
// from #670 / #672 at the core layer, without depending on the middlewares
14+
// package (which would create an import cycle).
15+
type keyedFakeMiddleware struct {
16+
key string
17+
}
18+
19+
func (k *keyedFakeMiddleware) Run(_ *Context) error { return nil }
20+
func (k *keyedFakeMiddleware) ContinueOnStop() bool { return false }
21+
func (k *keyedFakeMiddleware) Key() string { return k.key }
22+
23+
// unkeyedFakeMiddleware exercises the legacy type-string fallback path.
24+
// It does NOT implement Key(); middlewareContainer.Use must then dedup by
25+
// reflect.TypeOf(m).String() so the existing 1-per-type middlewares
26+
// (Slack, Mail, Save, Overlap) keep their current semantics.
27+
type unkeyedFakeMiddleware struct {
28+
name string
29+
}
30+
31+
func (u *unkeyedFakeMiddleware) Run(_ *Context) error { return nil }
32+
func (u *unkeyedFakeMiddleware) ContinueOnStop() bool { return false }
33+
34+
// otherUnkeyedFakeMiddleware is a SECOND legacy-style middleware type used
35+
// to discriminate "empty-key fallback ran" from "fallback always returns
36+
// the same string here". With two distinct Go types, the type-string
37+
// fallback returns two distinct keys and they coexist — which is the
38+
// observation that kills the `if key != ""` ↔ `if key == ""` mutation.
39+
type otherUnkeyedFakeMiddleware struct{}
40+
41+
func (o *otherUnkeyedFakeMiddleware) Run(_ *Context) error { return nil }
42+
func (o *otherUnkeyedFakeMiddleware) ContinueOnStop() bool { return false }
43+
44+
// emptyKeyedFake implements Middleware + Key() returning "". Used to lock
45+
// the opt-out path: a middleware that explicitly declines a custom key
46+
// gets the legacy type-string dedup, NOT a distinct empty-string-keyed
47+
// slot. Mirrors what any keyed-by-default middleware can do if it wants
48+
// to defer per-instance dedup to a wrapper / composite type.
49+
type emptyKeyedFake struct{}
50+
51+
func (e *emptyKeyedFake) Run(_ *Context) error { return nil }
52+
func (e *emptyKeyedFake) ContinueOnStop() bool { return false }
53+
func (e *emptyKeyedFake) Key() string { return "" }
54+
55+
// TestMiddlewareContainer_Use_DistinctKeysKeepsBoth pins the fix for
56+
// https://github.com/netresearch/ofelia/issues/672 at the core layer.
57+
// Pre-fix, middlewareContainer.Use deduped by reflect.TypeOf(m).String(),
58+
// silently dropping any second instance of the same Go type. PR #671
59+
// worked around this at the webhook layer with a composite type; the
60+
// structural fix is here.
61+
//
62+
// Two keyedFakeMiddleware instances with distinct keys ("alpha", "beta")
63+
// must both end up in Middlewares() in insertion order.
64+
func TestMiddlewareContainer_Use_DistinctKeysKeepsBoth(t *testing.T) {
65+
t.Parallel()
66+
67+
var c middlewareContainer
68+
a := &keyedFakeMiddleware{key: "alpha"}
69+
b := &keyedFakeMiddleware{key: "beta"}
70+
c.Use(a, b)
71+
72+
got := c.Middlewares()
73+
if len(got) != 2 {
74+
t.Fatalf("Middlewares() len = %d, want 2 (distinct keys must coexist)", len(got))
75+
}
76+
if got[0] != a {
77+
t.Errorf("Middlewares()[0] = %p, want %p (alpha; insertion order)", got[0], a)
78+
}
79+
if got[1] != b {
80+
t.Errorf("Middlewares()[1] = %p, want %p (beta; insertion order)", got[1], b)
81+
}
82+
}
83+
84+
// TestMiddlewareContainer_Use_SameKeyDedups pins that adding two distinct
85+
// instances with the SAME key still dedups to the first (the existing
86+
// "first wins" semantics from the type-string-keyed dedup). Without this
87+
// guarantee, scheduler-level webhook propagation via
88+
// j.Use(s.Middlewares()...) would double-fire when the job already has a
89+
// webhook with the same Config.Name.
90+
func TestMiddlewareContainer_Use_SameKeyDedups(t *testing.T) {
91+
t.Parallel()
92+
93+
var c middlewareContainer
94+
a := &keyedFakeMiddleware{key: "shared"}
95+
b := &keyedFakeMiddleware{key: "shared"}
96+
c.Use(a, b)
97+
98+
got := c.Middlewares()
99+
if len(got) != 1 {
100+
t.Fatalf("Middlewares() len = %d, want 1 (same key must dedup)", len(got))
101+
}
102+
if got[0] != a {
103+
t.Errorf("Middlewares()[0] = %p, want %p (first wins)", got[0], a)
104+
}
105+
}
106+
107+
// TestMiddlewareContainer_Use_UnkeyedFallsBackToType pins the legacy
108+
// behavior for middlewares that do not implement Key(): two distinct
109+
// instances of the same Go type collapse into the first. This preserves
110+
// the Slack/Mail/Save/Overlap "1-per-type" semantics so j.Use(s.Middlewares()...)
111+
// propagation does not silently double-send notifications.
112+
func TestMiddlewareContainer_Use_UnkeyedFallsBackToType(t *testing.T) {
113+
t.Parallel()
114+
115+
var c middlewareContainer
116+
a := &unkeyedFakeMiddleware{name: "first"}
117+
b := &unkeyedFakeMiddleware{name: "second"}
118+
c.Use(a, b)
119+
120+
got := c.Middlewares()
121+
if len(got) != 1 {
122+
t.Fatalf("Middlewares() len = %d, want 1 (unkeyed same-type must dedup via reflect.TypeOf)", len(got))
123+
}
124+
if got[0] != a {
125+
t.Errorf("Middlewares()[0] = %p, want %p (first wins on type-fallback dedup)", got[0], a)
126+
}
127+
}
128+
129+
// TestMiddlewareContainer_Use_KeyedAndUnkeyedCoexist pins that a keyed
130+
// middleware and an unkeyed one share no namespace: the keyed middleware
131+
// is indexed under its Key() string, the unkeyed one under its
132+
// reflect.TypeOf string. They cannot collide by construction.
133+
func TestMiddlewareContainer_Use_KeyedAndUnkeyedCoexist(t *testing.T) {
134+
t.Parallel()
135+
136+
var c middlewareContainer
137+
k := &keyedFakeMiddleware{key: "alpha"}
138+
u := &unkeyedFakeMiddleware{name: "first"}
139+
c.Use(k, u)
140+
141+
got := c.Middlewares()
142+
if len(got) != 2 {
143+
t.Fatalf("Middlewares() len = %d, want 2 (different key namespaces must coexist)", len(got))
144+
}
145+
}
146+
147+
// TestMiddlewareContainer_Use_EmptyKeyFallsBackToType pins that a
148+
// middleware that implements Key() but returns the empty string is
149+
// treated the same as an unkeyed middleware (type-string fallback). The
150+
// WebhookMiddleware composite added in PR #671 uses this opt-out: it
151+
// implements Key() returning "" so it stays 1-per-job (the composite
152+
// wraps multiple webhooks internally).
153+
func TestMiddlewareContainer_Use_EmptyKeyFallsBackToType(t *testing.T) {
154+
t.Parallel()
155+
156+
a := &emptyKeyedFake{}
157+
b := &emptyKeyedFake{}
158+
159+
var c middlewareContainer
160+
c.Use(a, b)
161+
162+
got := c.Middlewares()
163+
if len(got) != 1 {
164+
t.Fatalf("Middlewares() len = %d, want 1 (empty Key() must fall back to type dedup)", len(got))
165+
}
166+
if got[0] != a {
167+
t.Errorf("Middlewares()[0] = %p, want %p (first wins on empty-key type fallback)", got[0], a)
168+
}
169+
}
170+
171+
// TestMiddlewareContainer_Use_EmptyKeyMixWithUnkeyed strengthens the
172+
// empty-key fallback assertion: an `emptyKeyedFake` (Key() == "") and an
173+
// `unkeyedFakeMiddleware` of a different type must coexist. They cannot
174+
// collapse because their reflect.TypeOf strings are distinct. This is
175+
// the discriminator that kills the `if key != ""` ↔ `if key == ""`
176+
// mutation cleanly — the prior _EmptyKeyFallsBackToType test used two
177+
// instances of the SAME type and would have passed under either branch.
178+
func TestMiddlewareContainer_Use_EmptyKeyMixWithUnkeyed(t *testing.T) {
179+
t.Parallel()
180+
181+
a := &emptyKeyedFake{}
182+
b := &otherUnkeyedFakeMiddleware{}
183+
184+
var c middlewareContainer
185+
c.Use(a, b)
186+
187+
got := c.Middlewares()
188+
if len(got) != 2 {
189+
t.Fatalf("Middlewares() len = %d, want 2 (empty-key fallback + unkeyed of different type must coexist via distinct type strings)", len(got))
190+
}
191+
}
192+
193+
// TestMiddlewareContainer_Use_InsertionOrderWithDuplicate pins that the
194+
// order slice tracks unique keys only — adding [a-key, b-key, a-dup-key]
195+
// must produce [a, b] in insertion order, with a-dup-key skipped from
196+
// BOTH c.m and c.order. A regression that appended the duplicate to
197+
// c.order while skipping the map update would yield 3 entries from
198+
// Middlewares() (the third lookup hitting the wrong map entry).
199+
func TestMiddlewareContainer_Use_InsertionOrderWithDuplicate(t *testing.T) {
200+
t.Parallel()
201+
202+
var c middlewareContainer
203+
a := &keyedFakeMiddleware{key: "alpha"}
204+
b := &keyedFakeMiddleware{key: "beta"}
205+
aDup := &keyedFakeMiddleware{key: "alpha"}
206+
c.Use(a, b, aDup)
207+
208+
got := c.Middlewares()
209+
if len(got) != 2 {
210+
t.Fatalf("Middlewares() len = %d, want 2 (duplicate key must not append to order slice)", len(got))
211+
}
212+
if got[0] != a {
213+
t.Errorf("Middlewares()[0] = %p, want %p (alpha first-wins)", got[0], a)
214+
}
215+
if got[1] != b {
216+
t.Errorf("Middlewares()[1] = %p, want %p (beta in insertion order, not displaced by aDup)", got[1], b)
217+
}
218+
}
219+
220+
// TestMiddlewareContainer_Use_SkipsNil pins that nil middlewares are
221+
// silently dropped — a defensive guard that has lived in Use since the
222+
// pre-#672 implementation. Without this, a caller building a slice via
223+
// `var ms []Middleware; ms = append(ms, NewWebhook(nil, l))` (where
224+
// NewWebhook returns (nil, nil) for a nil config) would panic at the
225+
// type assertion inside middlewareKey. Closes the coverage hole flagged
226+
// by the test-effectiveness review on #697.
227+
func TestMiddlewareContainer_Use_SkipsNil(t *testing.T) {
228+
t.Parallel()
229+
230+
var c middlewareContainer
231+
a := &keyedFakeMiddleware{key: "alpha"}
232+
c.Use(nil, a, nil)
233+
234+
got := c.Middlewares()
235+
if len(got) != 1 {
236+
t.Fatalf("Middlewares() len = %d, want 1 (nil entries must be skipped)", len(got))
237+
}
238+
if got[0] != a {
239+
t.Errorf("Middlewares()[0] = %p, want %p (the only non-nil entry)", got[0], a)
240+
}
241+
}
242+
243+
// TestMiddlewareContainer_ResetMiddlewares_ClearsThenRebuilds pins the
244+
// behavior of ResetMiddlewares, which is called on every config hot-
245+
// reload at cli/config.go and cli/config_webhook.go. A regression that
246+
// clears c.m but not c.order (or vice versa) would leave the container
247+
// in an inconsistent state where Middlewares() returns dangling nil
248+
// values. Closes a critical coverage hole flagged by the test-
249+
// effectiveness review on #697 (ResetMiddlewares had 0% coverage).
250+
func TestMiddlewareContainer_ResetMiddlewares_ClearsThenRebuilds(t *testing.T) {
251+
t.Parallel()
252+
253+
var c middlewareContainer
254+
old := &keyedFakeMiddleware{key: "old"}
255+
c.Use(old)
256+
if len(c.Middlewares()) != 1 {
257+
t.Fatalf("pre-Reset Middlewares() len = %d, want 1", len(c.Middlewares()))
258+
}
259+
260+
// Reset with a fresh middleware set.
261+
newA := &keyedFakeMiddleware{key: "new-a"}
262+
newB := &keyedFakeMiddleware{key: "new-b"}
263+
c.ResetMiddlewares(newA, newB)
264+
265+
got := c.Middlewares()
266+
if len(got) != 2 {
267+
t.Fatalf("post-Reset Middlewares() len = %d, want 2 (old entry cleared, two new entries added)", len(got))
268+
}
269+
if got[0] != newA || got[1] != newB {
270+
t.Errorf("post-Reset order = [%p, %p], want [%p, %p]", got[0], got[1], newA, newB)
271+
}
272+
// Verify old is fully gone — its key must not resolve in the dedup map.
273+
c.Use(&keyedFakeMiddleware{key: "old"})
274+
got = c.Middlewares()
275+
if len(got) != 3 {
276+
t.Fatalf("Use(old-key) after Reset Middlewares() len = %d, want 3 (Reset must have cleared the dedup map so the same key inserts fresh)", len(got))
277+
}
278+
}

middlewares/webhook.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,25 @@ func (w *Webhook) ContinueOnStop() bool {
129129
return true
130130
}
131131

132+
// Key returns the per-instance dedup key used by core.middlewareContainer
133+
// so multiple Webhook instances with distinct Config.Name can coexist in a
134+
// single job's middleware chain. Pre-#672, the container deduped by
135+
// reflect.TypeOf(m).String() and silently dropped any second *Webhook;
136+
// PR #671 worked around this with the WebhookMiddleware composite. With
137+
// the Key() opt-in, two distinct *Webhook instances (e.g. "wh-success"
138+
// and "wh-error") survive insertion into the chain by themselves.
139+
//
140+
// Returning Config.Name (instead of, say, a UUID) preserves the existing
141+
// "first wins" dedup contract for the scheduler-to-job propagation path
142+
// at core/scheduler.go:315 / config_webhook.go:289: if the scheduler-level
143+
// webhook with the same Config.Name is re-propagated to a job that
144+
// already has it, the second insertion is correctly skipped.
145+
//
146+
// See https://github.com/netresearch/ofelia/issues/672.
147+
func (w *Webhook) Key() string {
148+
return w.Config.Name
149+
}
150+
132151
// Run executes the webhook notification
133152
func (w *Webhook) Run(ctx *core.Context) error {
134153
err := ctx.Next()

0 commit comments

Comments
 (0)