Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
117 changes: 80 additions & 37 deletions action/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ import (

var ErrTypeAssertion = errors.New("critical type assertion failure")

type execStateKey struct{}

type execState struct {
fromCache bool
}

type BuiltAction[Req, Res any] struct {
meta *Meta
exec Fn[Req, Res]
Expand All @@ -41,16 +47,22 @@ func (a *BuiltAction[Req, Res]) GetMeta() *Meta {
cp.RequiredFeatures = slices.Clone(a.meta.RequiredFeatures)
return &cp
}

func (a *BuiltAction[Req, Res]) GetBindings() []Binding {
return append([]Binding(nil), a.bindings...)
}

func (a *BuiltAction[Req, Res]) GetAnyHooks() []AnyHook {
return append([]AnyHook(nil), a.anyHooksSnapshot()...)
}

func (a *BuiltAction[Req, Res]) Describe() *Meta {
return a.GetMeta()
}
func (a *BuiltAction[Req, Res]) History() *History[Req, Res] { return a.history }

func (a *BuiltAction[Req, Res]) History() *History[Req, Res] {
return a.history
}

func (a *BuiltAction[Req, Res]) anyHooksSnapshot() []AnyHook {
set := a.anyHooks.Load()
Expand All @@ -60,11 +72,34 @@ func (a *BuiltAction[Req, Res]) anyHooksSnapshot() []AnyHook {
return set.hooks
}

func hasOnExecutedHook[Req, Res any](hooks []Hook[Req, Res], anyHooks []AnyHook) bool {
for i := range hooks {
if hooks[i].OnExecuted != nil {
return true
}
}
for i := range anyHooks {
if anyHooks[i].OnExecuted != nil {
return true
}
}
return false
}

func (a *BuiltAction[Req, Res]) Do(ctx context.Context, req Req) (res Res, err error) {
var anyHooksRan, typedHooksRan int
finalCtx := ctx

anyHooks := a.anyHooksSnapshot()

needExecState := hasOnExecutedHook(a.hooks, anyHooks)

var state *execState
finalCtx := ctx
if needExecState {
state = &execState{}
finalCtx = context.WithValue(ctx, execStateKey{}, state)
}

defer func() {
if r := recover(); r != nil {
err = xerr.PanicRecovery(r)
Expand All @@ -80,64 +115,68 @@ func (a *BuiltAction[Req, Res]) Do(ctx context.Context, req Req) (res Res, err e

for i := typedHooksRan - 1; i >= 0; i-- {
h := a.hooks[i]
if h.After != nil {
h := h
callHook(a.meta, "After", func() {
h.After(finalCtx, req, res, err, a.meta)
})
}
if err != nil {
if errors.Is(err, context.Canceled) {
if h.OnCancel != nil {
h := h
callHook(a.meta, "OnCancel", func() {
h.OnCancel(finalCtx, req, a.meta)
})
}
} else {
if h.OnError != nil {
h := h
callHook(a.meta, "OnError", func() {
h.OnError(finalCtx, req, err, a.meta)
})
}

switch {
case err != nil && errors.Is(err, context.Canceled):
if h.OnCancel != nil {
h := h
callHook(a.meta, "OnCancel", func() {
h.OnCancel(finalCtx, req, a.meta)
})
}
case err != nil:
if h.OnError != nil {
h := h
callHook(a.meta, "OnError", func() {
h.OnError(finalCtx, req, err, a.meta)
})
}
} else if h.OnExecuted != nil {
case h.OnExecuted != nil && state != nil && !state.fromCache:
h := h
callHook(a.meta, "OnExecuted", func() {
h.OnExecuted(finalCtx, req, res, nil, a.meta)
})
}

if h.After != nil {
h := h
callHook(a.meta, "After", func() {
h.After(finalCtx, req, res, err, a.meta)
})
}
}

for i := anyHooksRan - 1; i >= 0; i-- {
h := anyHooks[i]
if errors.Is(err, context.Canceled) {

switch {
case errors.Is(err, context.Canceled):
if h.OnCancel != nil {
h := h
callHook(a.meta, "OnCancel", func() {
h.OnCancel(finalCtx, any(req), a.meta)
})
}
continue
case err != nil:
if h.OnError != nil {
h := h
callHook(a.meta, "OnError", func() {
h.OnError(finalCtx, any(req), err, a.meta)
})
}
case h.OnExecuted != nil && state != nil && !state.fromCache:
h := h
callHook(a.meta, "OnExecuted", func() {
h.OnExecuted(finalCtx, any(req), any(res), nil, a.meta)
})
}

if h.After != nil {
h := h
callHook(a.meta, "After", func() {
h.After(finalCtx, any(req), any(res), err, a.meta)
})
}
if err != nil && h.OnError != nil {
h := h
callHook(a.meta, "OnError", func() {
h.OnError(finalCtx, any(req), err, a.meta)
})
} else if err == nil && h.OnExecuted != nil {
h := h
callHook(a.meta, "OnExecuted", func() {
h.OnExecuted(finalCtx, any(req), any(res), nil, a.meta)
})
}
}
}()

Expand Down Expand Up @@ -175,6 +214,10 @@ func (a *BuiltAction[Req, Res]) Do(ctx context.Context, req Req) (res Res, err e
}

func (a *BuiltAction[Req, Res]) OnCacheHit(ctx context.Context, req Req, res Res) {
if s, ok := ctx.Value(execStateKey{}).(*execState); ok && s != nil {
s.fromCache = true
}

for _, h := range a.hooks {
if h.OnCacheHit != nil {
h.OnCacheHit(ctx, req, res, a.meta)
Expand Down
165 changes: 109 additions & 56 deletions action/action_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,87 +3,140 @@ package action_test
import (
"context"
"errors"
"fmt"
"sync/atomic"
"testing"

"github.com/nexssp/kernel/action"
"github.com/nexssp/kernel/xerr"
)

func TestExecuteDecoded_PointerRequest(t *testing.T) {
type Req struct{ A int }
act := action.New("test", func(ctx context.Context, req *Req) (int, error) {
return req.A, nil
}).Build()
res, err := act.ExecuteDecoded(context.Background(), func(v any) error {
req, ok := v.(*Req)
if !ok {
return fmt.Errorf("expected *Req, got %T", v)
func TestBuiltAction_Do_LifecycleOrder(t *testing.T) {
t.Parallel()

var steps []string
act := action.New("order.test", func(ctx context.Context, req string) (string, error) {
steps = append(steps, "handler")
return "result_" + req, nil
}).
HookBefore(func(ctx context.Context, req string, meta *action.Meta) (context.Context, error) {
steps = append(steps, "before")
return ctx, nil
}).
HookAfter(func(ctx context.Context, req, res string, err error, meta *action.Meta) {
steps = append(steps, "after")
}).
HookExecuted(func(ctx context.Context, req, res string, meta *action.Meta) {
steps = append(steps, "executed")
}).
Build()

res, err := act.Do(context.Background(), "input")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res != "result_input" {
t.Fatalf("expected 'result_input', got %q", res)
}

expectedSteps := []string{"before", "handler", "executed", "after"}
if len(steps) != len(expectedSteps) {
t.Fatalf("expected steps %v, got %v", expectedSteps, steps)
}
for i, step := range steps {
if step != expectedSteps[i] {
t.Errorf("step %d: expected %q, got %q", i, expectedSteps[i], step)
}
*req = Req{A: 42}
return nil
})
if err != nil || res != 42 {
t.Fail()
}
}

func TestExecuteDecoded_ValueRequest(t *testing.T) {
func TestBuiltAction_Do_ErrorLifecycle(t *testing.T) {
t.Parallel()
type Req struct{ A int } // Value type, not pointer
act := action.New("test.value", func(ctx context.Context, req Req) (int, error) {
return req.A, nil
}).Build()

res, err := act.ExecuteDecoded(context.Background(), func(v any) error {
// v is passed as a pointer to the value type by ExecuteDecoded
req, ok := v.(*Req)
if !ok {
return fmt.Errorf("expected *Req, got %T", v)
}
*req = Req{A: 99}
return nil
})
if err != nil || res != 99 {
t.Fatalf("expected 99, got %v (err: %v)", res, err)
var errorHookCalled, executedHookCalled bool
act := action.New("error.test", func(ctx context.Context, req string) (string, error) {
return "", errors.New("business failure")
}).
HookError(func(ctx context.Context, req string, err error, meta *action.Meta) {
errorHookCalled = true
}).
HookExecuted(func(ctx context.Context, req, res string, meta *action.Meta) {
executedHookCalled = true
}).
Build()

_, err := act.Do(context.Background(), "req")
if err == nil {
t.Fatal("expected error, got nil")
}
if !errorHookCalled {
t.Fatal("expected HookError to be called")
}
if executedHookCalled {
t.Fatal("HookExecuted must not be called when execution fails")
}
}

func TestRace_AllFailures(t *testing.T) {
func TestBuiltAction_Do_PanicRecovery(t *testing.T) {
t.Parallel()
act := action.New("race.fail", func(ctx context.Context, req int) (int, error) {
return 0, errors.New("always fails")
}).Build()

res, err := action.Race(context.Background(), act, []int{1, 2})
var panicHookRan atomic.Bool
act := action.New("panic.test", func(ctx context.Context, req string) (string, error) {
panic("fatal unexpected crash")
}).
AnyHook(action.AnyHook{
OnPanic: func(ctx context.Context, req, recovered any, meta *action.Meta) {
panicHookRan.Store(true)
},
}).
Build()

res, err := act.Do(context.Background(), "req")
if res != "" {
t.Fatalf("expected empty result on panic, got %q", res)
}
if err == nil {
t.Fatal("expected error, got nil")
t.Fatal("expected error from recovered panic, got nil")
}

var appErr *xerr.AppError
if !errors.As(err, &appErr) || appErr.Kind != xerr.KindInternal {
t.Fatalf("expected xerr.KindInternal, got %v", err)
}
if res != 0 {
t.Fatalf("expected zero value, got %d", res)
if !panicHookRan.Load() {
t.Fatal("expected AnyHook.OnPanic to execute")
}
}

func TestAction_ContextCancelTriggersOnCancel(t *testing.T) {
func TestBuiltAction_ExecuteDecoded(t *testing.T) {
t.Parallel()
var canceled bool

act := action.New("test.cancel", func(ctx context.Context, req int) (int, error) {
return 0, context.Canceled
}).Hook(action.Hook[int, int]{
OnCancel: func(ctx context.Context, req int, m *action.Meta) {
canceled = true
},
OnError: func(ctx context.Context, req int, err error, m *action.Meta) {
t.Fatal("OnError should not be called when context is canceled")
},

type RequestDto struct {
Name string `json:"name"`
}
type ResponseDto struct {
Greeting string `json:"greeting"`
}

act := action.New("decode.test", func(ctx context.Context, req RequestDto) (ResponseDto, error) {
return ResponseDto{Greeting: "Hello, " + req.Name}, nil
}).Build()

_, err := act.Do(context.Background(), 1)
if err == nil || !errors.Is(err, context.Canceled) {
t.Fatalf("expected context.Canceled error, got %v", err)
decodeFunc := func(target any) error {
req, ok := target.(*RequestDto)
if !ok {
return errors.New("invalid target type")
}
req.Name = "Tester"
return nil
}

rawRes, err := act.ExecuteDecoded(context.Background(), decodeFunc)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if !canceled {
t.Fatal("expected OnCancel to be called")
res, ok := rawRes.(ResponseDto)
if !ok || res.Greeting != "Hello, Tester" {
t.Fatalf("unexpected result: %+v", rawRes)
}
}
3 changes: 3 additions & 0 deletions action/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,5 +192,8 @@ func (b *Builder[Req, Res]) Build() *BuiltAction[Req, Res] {

func (b *Builder[Req, Res]) LogSlowWhen(d time.Duration) *Builder[Req, Res] {
b.meta.LogSlowThreshold = d
if d > 0 {
return b.Use(SlowLogMiddleware[Req, Res](d, b.meta.Name))
}
return b
}
Loading
Loading