Skip to content

Commit

Permalink
feat: Implement supporting types for hooks. (#125)
Browse files Browse the repository at this point in the history
This PR should be reviewed before other hooks PRs.

This PR adds supporting types for hooks, but does not use them from the
client.

---------

Co-authored-by: Casey Waldren <[email protected]>
  • Loading branch information
kinyoklion and cwaldren-ld committed Apr 3, 2024
1 parent 41dec51 commit 7f06147
Show file tree
Hide file tree
Showing 12 changed files with 1,095 additions and 0 deletions.
62 changes: 62 additions & 0 deletions internal/hooks/iterator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package hooks

import (
"github.com/launchdarkly/go-server-sdk/v7/ldhooks"
)

type iterator struct {
reverse bool
cursor int
collection []ldhooks.Hook
}

// newIterator creates a new hook iterator which can iterate hooks forward or reverse.
//
// The collection being iterated should not be modified during iteration.
//
// Example:
// it := newIterator(false, hooks)
//
// for it.hasNext() {
// hook := it.getNext()
// }
func newIterator(reverse bool, hooks []ldhooks.Hook) *iterator {
cursor := -1
if reverse {
cursor = len(hooks)
}
return &iterator{
reverse: reverse,
cursor: cursor,
collection: hooks,
}
}

func (it *iterator) hasNext() bool {
nextCursor := it.getNextIndex()
return it.inBounds(nextCursor)
}

func (it *iterator) inBounds(nextCursor int) bool {
inBounds := nextCursor < len(it.collection) && nextCursor >= 0
return inBounds
}

func (it *iterator) getNextIndex() int {
var nextCursor int
if it.reverse {
nextCursor = it.cursor - 1
} else {
nextCursor = it.cursor + 1
}
return nextCursor
}

func (it *iterator) getNext() (int, ldhooks.Hook) {
i := it.getNextIndex()
if it.inBounds(i) {
it.cursor = i
return it.cursor, it.collection[it.cursor]
}
return it.cursor, nil
}
63 changes: 63 additions & 0 deletions internal/hooks/iterator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package hooks

import (
"fmt"
"testing"

"github.com/launchdarkly/go-server-sdk/v7/internal/sharedtest"
"github.com/launchdarkly/go-server-sdk/v7/ldhooks"
"github.com/stretchr/testify/assert"
)

func TestIterator(t *testing.T) {
testCases := []bool{false, true}
for _, reverse := range testCases {
t.Run(fmt.Sprintf("reverse: %v", reverse), func(t *testing.T) {
t.Run("empty collection", func(t *testing.T) {

var hooks []ldhooks.Hook
it := newIterator(reverse, hooks)

assert.False(t, it.hasNext())

_, value := it.getNext()
assert.Zero(t, value)

})

t.Run("collection with items", func(t *testing.T) {
hooks := []ldhooks.Hook{
sharedtest.NewTestHook("a"),
sharedtest.NewTestHook("b"),
sharedtest.NewTestHook("c"),
}

it := newIterator(reverse, hooks)

var cursor int
count := 0
if reverse {
cursor = 2
} else {
cursor += 0
}
for it.hasNext() {
index, value := it.getNext()
assert.Equal(t, cursor, index)
assert.Equal(t, hooks[cursor].Metadata().Name(), value.Metadata().Name())

count += 1

if reverse {
cursor -= 1
} else {
cursor += 1
}

}
assert.Equal(t, 3, count)
assert.False(t, it.hasNext())
})
})
}
}
2 changes: 2 additions & 0 deletions internal/hooks/package_info.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Package hooks is an internal package containing implementations to run hooks.
package hooks
141 changes: 141 additions & 0 deletions internal/hooks/runner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package hooks

import (
"context"
"sync"

"github.com/launchdarkly/go-sdk-common/v3/ldcontext"
"github.com/launchdarkly/go-sdk-common/v3/ldlog"
"github.com/launchdarkly/go-sdk-common/v3/ldreason"
"github.com/launchdarkly/go-sdk-common/v3/ldvalue"
"github.com/launchdarkly/go-server-sdk/v7/ldhooks"
)

// Runner manages the registration and execution of hooks.
type Runner struct {
hooks []ldhooks.Hook
loggers ldlog.Loggers
mutex *sync.RWMutex
}

// EvaluationExecution represents the state of a running series of evaluation stages.
type EvaluationExecution struct {
hooks []ldhooks.Hook
data []ldhooks.EvaluationSeriesData
context ldhooks.EvaluationSeriesContext
}

func (e EvaluationExecution) withData(data []ldhooks.EvaluationSeriesData) EvaluationExecution {
return EvaluationExecution{
hooks: e.hooks,
context: e.context,
data: data,
}
}

// NewRunner creates a new hook runner.
func NewRunner(loggers ldlog.Loggers, hooks []ldhooks.Hook) *Runner {
return &Runner{
loggers: loggers,
hooks: hooks,
mutex: &sync.RWMutex{},
}
}

// AddHooks adds hooks to the runner.
func (h *Runner) AddHooks(hooks ...ldhooks.Hook) {
h.mutex.Lock()
defer h.mutex.Unlock()

h.hooks = append(h.hooks, hooks...)
}

// getHooks returns a copy of the hooks. This copy is suitable for use when executing a series. This keeps the set
// of hooks stable for the duration of the series. This prevents things like calling the AfterEvaluation method for
// a hook that didn't have the BeforeEvaluation method called.
func (h *Runner) getHooks() []ldhooks.Hook {
h.mutex.RLock()
defer h.mutex.RUnlock()
copiedHooks := make([]ldhooks.Hook, len(h.hooks))
copy(copiedHooks, h.hooks)
return copiedHooks
}

// PrepareEvaluationSeries creates an EvaluationExecution suitable for executing evaluation stages and gets a copy
// of hooks to use during series execution.
//
// For an invocation of a series the same set of hooks should be used. For instance a hook added mid-evaluation should
// not be executed during the "AfterEvaluation" stage of that evaluation.
func (h *Runner) PrepareEvaluationSeries(
flagKey string,
evalContext ldcontext.Context,
defaultVal ldvalue.Value,
method string,
) EvaluationExecution {
hooksForEval := h.getHooks()

returnData := make([]ldhooks.EvaluationSeriesData, len(hooksForEval))
for i := range hooksForEval {
returnData[i] = ldhooks.EmptyEvaluationSeriesData()
}
return EvaluationExecution{
hooks: hooksForEval,
data: returnData,
context: ldhooks.NewEvaluationSeriesContext(flagKey, evalContext, defaultVal, method),
}
}

// BeforeEvaluation executes the BeforeEvaluation stage of registered hooks.
func (h *Runner) BeforeEvaluation(ctx context.Context, execution EvaluationExecution) EvaluationExecution {
return h.executeStage(
execution,
false,
"BeforeEvaluation",
func(hook ldhooks.Hook, data ldhooks.EvaluationSeriesData) (ldhooks.EvaluationSeriesData, error) {
return hook.BeforeEvaluation(ctx, execution.context, data)
})
}

// AfterEvaluation executes the AfterEvaluation stage of registered hooks.
func (h *Runner) AfterEvaluation(
ctx context.Context,
execution EvaluationExecution,
detail ldreason.EvaluationDetail,
) EvaluationExecution {
return h.executeStage(
execution,
true,
"AfterEvaluation",
func(hook ldhooks.Hook, data ldhooks.EvaluationSeriesData) (ldhooks.EvaluationSeriesData, error) {
return hook.AfterEvaluation(ctx, execution.context, data, detail)
})
}

func (h *Runner) executeStage(
execution EvaluationExecution,
reverse bool,
stageName string,
fn func(
hook ldhooks.Hook,
data ldhooks.EvaluationSeriesData,
) (ldhooks.EvaluationSeriesData, error)) EvaluationExecution {
returnData := make([]ldhooks.EvaluationSeriesData, len(execution.hooks))
iterator := newIterator(reverse, execution.hooks)
for iterator.hasNext() {
i, hook := iterator.getNext()

outData, err := fn(hook, execution.data[i])
if err != nil {
returnData[i] = execution.data[i]
h.loggers.Errorf(
"During evaluation of flag \"%s\", an error was encountered in \"%s\" of the \"%s\" hook: %s",
execution.context.FlagKey(),
stageName,
hook.Metadata().Name(),
err.Error())
continue
}
returnData[i] = outData
}
return execution.withData(returnData)
}
Loading

0 comments on commit 7f06147

Please sign in to comment.