Skip to content

Commit 433e3d0

Browse files
committed
feat: add observer callback for state mutations
Expose a WithObserver option so callers can hook into successful Create, Update, and Destroy operations and observe the resource type along with the marshaled payload size. Signed-off-by: Oguz Kilcan <oguz.kilcan@siderolabs.com>
1 parent 5f82f81 commit 433e3d0

4 files changed

Lines changed: 339 additions & 5 deletions

File tree

pkg/state/impl/etcd/etcd.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"errors"
1212
"fmt"
1313
"iter"
14+
"runtime/debug"
1415
"sort"
1516
"strconv"
1617
"time"
@@ -37,6 +38,7 @@ type Client interface {
3738
type State struct {
3839
cli Client
3940
marshaler store.Marshaler
41+
observer ObserverFunc
4042
keyPrefix string
4143
salt []byte
4244
}
@@ -55,11 +57,26 @@ func NewState(cli Client, marshaler store.Marshaler, opts ...StateOption) *State
5557
return &State{
5658
cli: cli,
5759
marshaler: marshaler,
60+
observer: options.observer,
5861
keyPrefix: options.keyPrefix,
5962
salt: options.salt,
6063
}
6164
}
6265

66+
func (st *State) observe(ctx context.Context, eventType state.EventType, resourceType resource.Type, phase, previousPhase resource.Phase, marshaledBytes int) (err error) {
67+
if st.observer == nil {
68+
return nil
69+
}
70+
71+
defer func() {
72+
if r := recover(); r != nil {
73+
err = fmt.Errorf("observer panicked: %v\n%s", r, debug.Stack())
74+
}
75+
}()
76+
77+
return st.observer(ctx, eventType, resourceType, phase, previousPhase, marshaledBytes)
78+
}
79+
6380
// Get a resource.
6481
func (st *State) Get(ctx context.Context, resourcePointer resource.Pointer, opts ...state.GetOption) (resource.Resource, error) { //nolint:ireturn
6582
ctx = st.clearIncomingContext(ctx)
@@ -185,6 +202,10 @@ func (st *State) Create(ctx context.Context, res resource.Resource, opts ...stat
185202
// purposes.
186203
*res.Metadata() = *resCopy.Metadata()
187204

205+
if err := st.observe(ctx, state.Created, resCopy.Metadata().Type(), resCopy.Metadata().Phase(), resCopy.Metadata().Phase(), len(data)); err != nil {
206+
return fmt.Errorf("observer error after create %q: %w", resCopy.Metadata(), err)
207+
}
208+
188209
return nil
189210
}
190211

@@ -277,6 +298,10 @@ func (st *State) Update(ctx context.Context, res resource.Resource, opts ...stat
277298
// purposes.
278299
*res.Metadata() = *resCopy.Metadata()
279300

301+
if err := st.observe(ctx, state.Updated, resCopy.Metadata().Type(), resCopy.Metadata().Phase(), curResource.Metadata().Phase(), len(data)); err != nil {
302+
return fmt.Errorf("observer error after update %q: %w", resCopy.Metadata(), err)
303+
}
304+
280305
return nil
281306
}
282307

@@ -316,6 +341,8 @@ func (st *State) Destroy(ctx context.Context, resourcePointer resource.Pointer,
316341
return fmt.Errorf("failed to destroy: %w", ErrPendingFinalizers(*curResource.Metadata()))
317342
}
318343

344+
deletedBytes := len(resp.Kvs[0].Value)
345+
319346
txnResp, err := st.cli.Txn(ctx).If(
320347
clientv3.Compare(clientv3.Version(etcdKey), "=", etcdVersion),
321348
).Then(
@@ -328,6 +355,10 @@ func (st *State) Destroy(ctx context.Context, resourcePointer resource.Pointer,
328355
}
329356

330357
if txnResp.Succeeded {
358+
if err := st.observe(ctx, state.Destroyed, resourcePointer.Type(), curResource.Metadata().Phase(), curResource.Metadata().Phase(), deletedBytes); err != nil {
359+
return fmt.Errorf("observer error after destroy %q: %w", resourcePointer, err)
360+
}
361+
331362
return nil
332363
}
333364

pkg/state/impl/etcd/etcd_test.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,15 +126,16 @@ func TestClearGRPCMetadata(t *testing.T) {
126126
})
127127
}
128128

129-
func withEtcd(t *testing.T, f func(state.State)) {
129+
func withEtcd(t *testing.T, f func(state.State), opts ...etcd.StateOption) {
130130
withEtcdAndClient(t, func(st state.State, _ *clientv3.Client) {
131131
f(st)
132-
})
132+
}, opts...)
133133
}
134134

135-
func withEtcdAndClient(t *testing.T, f func(state.State, *clientv3.Client)) {
135+
func withEtcdAndClient(t *testing.T, f func(state.State, *clientv3.Client), opts ...etcd.StateOption) {
136136
testhelpers.WithEtcd(t, func(cli *clientv3.Client) {
137-
etcdState := etcd.NewState(cli, store.ProtobufMarshaler{}, etcd.WithSalt([]byte("test123")))
137+
opts = append([]etcd.StateOption{etcd.WithSalt([]byte("test123"))}, opts...)
138+
etcdState := etcd.NewState(cli, store.ProtobufMarshaler{}, opts...)
138139
st := state.WrapCore(etcdState)
139140

140141
f(st, cli)
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
// This Source Code Form is subject to the terms of the Mozilla Public
2+
// License, v. 2.0. If a copy of the MPL was not distributed with this
3+
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
4+
5+
package etcd_test
6+
7+
import (
8+
"context"
9+
"errors"
10+
"slices"
11+
"sync"
12+
"testing"
13+
14+
"github.com/cosi-project/runtime/pkg/resource"
15+
"github.com/cosi-project/runtime/pkg/state"
16+
"github.com/cosi-project/runtime/pkg/state/conformance"
17+
"github.com/stretchr/testify/assert"
18+
"github.com/stretchr/testify/require"
19+
20+
"github.com/cosi-project/state-etcd/pkg/state/impl/etcd"
21+
)
22+
23+
type observation struct {
24+
rType resource.Type
25+
phase resource.Phase
26+
previousPhase resource.Phase
27+
eventType state.EventType
28+
bytes int
29+
}
30+
31+
type recordingObserver struct {
32+
observed []observation
33+
mu sync.Mutex
34+
}
35+
36+
func (r *recordingObserver) record(_ context.Context, eventType state.EventType, rType resource.Type, phase, previousPhase resource.Phase, marshaledBytes int) error {
37+
r.mu.Lock()
38+
defer r.mu.Unlock()
39+
40+
r.observed = append(r.observed, observation{
41+
rType: rType,
42+
phase: phase,
43+
previousPhase: previousPhase,
44+
eventType: eventType,
45+
bytes: marshaledBytes,
46+
})
47+
48+
return nil
49+
}
50+
51+
func (r *recordingObserver) snapshot() []observation {
52+
r.mu.Lock()
53+
defer r.mu.Unlock()
54+
55+
return slices.Clone(r.observed)
56+
}
57+
58+
func TestObserverFiresOnSuccess(t *testing.T) {
59+
t.Parallel()
60+
61+
obs := &recordingObserver{}
62+
63+
withEtcd(t, func(s state.State) {
64+
ctx, cancel := context.WithCancel(t.Context())
65+
defer cancel()
66+
67+
res := conformance.NewPathResource("default", "/observer-success")
68+
69+
require.NoError(t, s.Create(ctx, res))
70+
require.NoError(t, s.Update(ctx, res))
71+
require.NoError(t, s.Destroy(ctx, res.Metadata()))
72+
73+
got := obs.snapshot()
74+
require.Len(t, got, 3)
75+
76+
assert.Equal(t, state.Created, got[0].eventType)
77+
assert.Equal(t, res.Metadata().Type(), got[0].rType)
78+
assert.Equal(t, resource.PhaseRunning, got[0].phase)
79+
assert.Equal(t, got[0].phase, got[0].previousPhase)
80+
assert.Positive(t, got[0].bytes)
81+
82+
assert.Equal(t, state.Updated, got[1].eventType)
83+
assert.Equal(t, res.Metadata().Type(), got[1].rType)
84+
assert.Equal(t, resource.PhaseRunning, got[1].phase)
85+
assert.Equal(t, resource.PhaseRunning, got[1].previousPhase)
86+
assert.Positive(t, got[1].bytes)
87+
88+
assert.Equal(t, state.Destroyed, got[2].eventType)
89+
assert.Equal(t, res.Metadata().Type(), got[2].rType)
90+
assert.Equal(t, resource.PhaseRunning, got[2].phase)
91+
assert.Equal(t, got[2].phase, got[2].previousPhase)
92+
// destroy reports the size of the previously stored resource
93+
assert.Equal(t, got[1].bytes, got[2].bytes)
94+
}, etcd.WithObserver(obs.record))
95+
}
96+
97+
func TestObserverPhaseOnTeardown(t *testing.T) {
98+
t.Parallel()
99+
100+
obs := &recordingObserver{}
101+
102+
withEtcd(t, func(s state.State) {
103+
ctx, cancel := context.WithCancel(t.Context())
104+
defer cancel()
105+
106+
res := conformance.NewPathResource("default", "/observer-teardown")
107+
108+
require.NoError(t, s.Create(ctx, res))
109+
110+
// Teardown is implemented as an Update that flips the phase to tearing-down.
111+
// previousPhase must reflect the running phase so callers can distinguish the actual
112+
// transition from updates that happen while already in tearing-down (see below).
113+
_, err := s.Teardown(ctx, res.Metadata())
114+
require.NoError(t, err)
115+
116+
// Add and remove a finalizer while the resource is in tearing-down. Each operation is
117+
// an Update where both phase and previousPhase are PhaseTearingDown — observers should
118+
// not mistake these for a teardown transition.
119+
require.NoError(t, s.AddFinalizer(ctx, res.Metadata(), "fin"))
120+
require.NoError(t, s.RemoveFinalizer(ctx, res.Metadata(), "fin"))
121+
122+
require.NoError(t, s.Destroy(ctx, res.Metadata()))
123+
124+
got := obs.snapshot()
125+
require.Len(t, got, 5)
126+
127+
assert.Equal(t, state.Created, got[0].eventType)
128+
assert.Equal(t, resource.PhaseRunning, got[0].phase)
129+
assert.Equal(t, got[0].phase, got[0].previousPhase)
130+
131+
// Teardown — phase transitions running → tearingDown.
132+
assert.Equal(t, state.Updated, got[1].eventType)
133+
assert.Equal(t, resource.PhaseTearingDown, got[1].phase)
134+
assert.Equal(t, resource.PhaseRunning, got[1].previousPhase)
135+
136+
// AddFinalizer — Update while in tearing-down. No transition.
137+
assert.Equal(t, state.Updated, got[2].eventType)
138+
assert.Equal(t, resource.PhaseTearingDown, got[2].phase)
139+
assert.Equal(t, resource.PhaseTearingDown, got[2].previousPhase)
140+
141+
// RemoveFinalizer — Update while in tearing-down. No transition.
142+
assert.Equal(t, state.Updated, got[3].eventType)
143+
assert.Equal(t, resource.PhaseTearingDown, got[3].phase)
144+
assert.Equal(t, resource.PhaseTearingDown, got[3].previousPhase)
145+
146+
assert.Equal(t, state.Destroyed, got[4].eventType)
147+
assert.Equal(t, resource.PhaseTearingDown, got[4].phase)
148+
assert.Equal(t, got[4].phase, got[4].previousPhase)
149+
}, etcd.WithObserver(obs.record))
150+
}
151+
152+
func TestObserverDoesNotFireOnFailure(t *testing.T) {
153+
t.Parallel()
154+
155+
obs := &recordingObserver{}
156+
157+
withEtcd(t, func(s state.State) {
158+
ctx, cancel := context.WithCancel(t.Context())
159+
defer cancel()
160+
161+
res := conformance.NewPathResource("default", "/observer-failure")
162+
163+
// create twice - second should fail with already exists, no observation
164+
require.NoError(t, s.Create(ctx, res))
165+
166+
err := s.Create(ctx, res)
167+
require.Error(t, err)
168+
assert.True(t, state.IsConflictError(err))
169+
170+
// update a non-existent resource - no observation
171+
missing := conformance.NewPathResource("default", "/observer-missing")
172+
err = s.Update(ctx, missing)
173+
require.Error(t, err)
174+
assert.True(t, state.IsNotFoundError(err))
175+
176+
// destroy a non-existent resource - no observation
177+
err = s.Destroy(ctx, missing.Metadata())
178+
require.Error(t, err)
179+
assert.True(t, state.IsNotFoundError(err))
180+
181+
// only the first successful create should be observed
182+
got := obs.snapshot()
183+
require.Len(t, got, 1)
184+
assert.Equal(t, state.Created, got[0].eventType)
185+
}, etcd.WithObserver(obs.record))
186+
}
187+
188+
func TestObserverErrorPropagates(t *testing.T) {
189+
t.Parallel()
190+
191+
observerErr := errors.New("observer broke")
192+
193+
failing := func(context.Context, state.EventType, resource.Type, resource.Phase, resource.Phase, int) error {
194+
return observerErr
195+
}
196+
197+
withEtcd(t, func(s state.State) {
198+
ctx, cancel := context.WithCancel(t.Context())
199+
defer cancel()
200+
201+
// Create: observer fires post-success, error propagates back. The resource is in etcd.
202+
res := conformance.NewPathResource("default", "/observer-error")
203+
204+
err := s.Create(ctx, res)
205+
require.Error(t, err)
206+
assert.ErrorIs(t, err, observerErr)
207+
208+
// The underlying mutation succeeded even though the observer error propagated.
209+
got, getErr := s.Get(ctx, res.Metadata())
210+
require.NoError(t, getErr)
211+
assert.Equal(t, res.Metadata().ID(), got.Metadata().ID())
212+
213+
// Update: observer error propagates again; mutation still committed.
214+
err = s.Update(ctx, got)
215+
require.Error(t, err)
216+
assert.ErrorIs(t, err, observerErr)
217+
218+
// Destroy: observer error propagates; resource is gone from etcd.
219+
err = s.Destroy(ctx, res.Metadata())
220+
require.Error(t, err)
221+
assert.ErrorIs(t, err, observerErr)
222+
223+
_, getErr = s.Get(ctx, res.Metadata())
224+
assert.True(t, state.IsNotFoundError(getErr))
225+
}, etcd.WithObserver(failing))
226+
}
227+
228+
func TestObserverPanicPropagates(t *testing.T) {
229+
t.Parallel()
230+
231+
panicking := func(context.Context, state.EventType, resource.Type, resource.Phase, resource.Phase, int) error {
232+
panic("observer boom")
233+
}
234+
235+
withEtcd(t, func(s state.State) {
236+
ctx, cancel := context.WithCancel(t.Context())
237+
defer cancel()
238+
239+
res := conformance.NewPathResource("default", "/observer-panic")
240+
241+
// Create: observer panic is recovered and surfaced as an error; mutation is committed.
242+
err := s.Create(ctx, res)
243+
require.Error(t, err)
244+
assert.Contains(t, err.Error(), "observer panicked")
245+
assert.Contains(t, err.Error(), "observer boom")
246+
247+
got, getErr := s.Get(ctx, res.Metadata())
248+
require.NoError(t, getErr)
249+
assert.Equal(t, res.Metadata().ID(), got.Metadata().ID())
250+
251+
// Update: same behavior.
252+
err = s.Update(ctx, got)
253+
require.Error(t, err)
254+
assert.Contains(t, err.Error(), "observer panicked")
255+
256+
// Destroy: same behavior; resource is gone from etcd.
257+
err = s.Destroy(ctx, res.Metadata())
258+
require.Error(t, err)
259+
assert.Contains(t, err.Error(), "observer panicked")
260+
261+
_, getErr = s.Get(ctx, res.Metadata())
262+
assert.True(t, state.IsNotFoundError(getErr))
263+
}, etcd.WithObserver(panicking))
264+
}
265+
266+
func TestObserverNilNoop(t *testing.T) {
267+
t.Parallel()
268+
269+
// no observer configured: operations succeed without panic
270+
withEtcd(t, func(s state.State) {
271+
ctx, cancel := context.WithCancel(t.Context())
272+
defer cancel()
273+
274+
res := conformance.NewPathResource("default", "/observer-nil")
275+
276+
require.NoError(t, s.Create(ctx, res))
277+
require.NoError(t, s.Update(ctx, res))
278+
require.NoError(t, s.Destroy(ctx, res.Metadata()))
279+
})
280+
}

0 commit comments

Comments
 (0)