502 lines
15 KiB
Go
502 lines
15 KiB
Go
package schemas
|
|
|
|
import (
|
|
"context"
|
|
"slices"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
var NoDeadline time.Time
|
|
|
|
var reservedKeys = []any{
|
|
BifrostContextKeyVirtualKey,
|
|
BifrostContextKeyAPIKeyName,
|
|
BifrostContextKeyAPIKeyID,
|
|
BifrostContextKeyRequestID,
|
|
BifrostContextKeyFallbackRequestID,
|
|
BifrostContextKeyDirectKey,
|
|
BifrostContextKeySelectedKeyID,
|
|
BifrostContextKeySelectedKeyName,
|
|
BifrostContextKeyNumberOfRetries,
|
|
BifrostContextKeyFallbackIndex,
|
|
BifrostContextKeySkipKeySelection,
|
|
BifrostContextKeyURLPath,
|
|
BifrostContextKeyDeferTraceCompletion,
|
|
BifrostContextKeyAttemptTrail,
|
|
}
|
|
|
|
// pluginLogStore holds plugin log entries accumulated during request processing.
|
|
// It is shared between the root BifrostContext and all scoped contexts derived from it.
|
|
// Uses a flat slice (not map) to minimize heap allocations.
|
|
type pluginLogStore struct {
|
|
mu sync.Mutex
|
|
logs []PluginLogEntry
|
|
}
|
|
|
|
// pluginLogStorePool pools pluginLogStore instances to reduce per-request allocations.
|
|
var pluginLogStorePool = sync.Pool{
|
|
New: func() any {
|
|
return &pluginLogStore{logs: make([]PluginLogEntry, 0, 8)}
|
|
},
|
|
}
|
|
|
|
// pluginScopePool pools BifrostContext instances used as scoped plugin contexts.
|
|
var pluginScopePool = sync.Pool{
|
|
New: func() any {
|
|
return &BifrostContext{}
|
|
},
|
|
}
|
|
|
|
// BifrostContext is a custom context.Context implementation that tracks user-set values.
|
|
// It supports deadlines, can be derived from other contexts, and provides layered
|
|
// value inheritance when derived from another BifrostContext.
|
|
type BifrostContext struct {
|
|
parent context.Context
|
|
deadline time.Time
|
|
hasDeadline bool
|
|
done chan struct{}
|
|
doneOnce sync.Once
|
|
err error
|
|
errMu sync.RWMutex
|
|
userValues map[any]any
|
|
valuesMu sync.RWMutex
|
|
blockRestrictedWrites atomic.Bool
|
|
|
|
// Plugin scoping fields
|
|
pluginScope *string // Non-nil when this is a scoped plugin context
|
|
pluginLogs atomic.Pointer[pluginLogStore] // Shared log store; lazily initialized on root, shared by scoped contexts
|
|
valueDelegate *BifrostContext // For scoped contexts: delegate Value/SetValue to this root context
|
|
}
|
|
|
|
// NewBifrostContext creates a new BifrostContext with the given parent context and deadline.
|
|
// If the deadline is zero, no deadline is set on this context (though the parent may have one).
|
|
// The context will be cancelled when the deadline expires or when the parent context is cancelled.
|
|
func NewBifrostContext(parent context.Context, deadline time.Time) *BifrostContext {
|
|
if parent == nil {
|
|
parent = context.Background()
|
|
}
|
|
ctx := &BifrostContext{
|
|
parent: parent,
|
|
deadline: deadline,
|
|
hasDeadline: !deadline.IsZero(),
|
|
done: make(chan struct{}),
|
|
userValues: make(map[any]any),
|
|
blockRestrictedWrites: atomic.Bool{},
|
|
}
|
|
ctx.blockRestrictedWrites.Store(false)
|
|
// Only start goroutine if there's something to watch:
|
|
// - If we have a deadline, we need the timer
|
|
// - If parent can be cancelled (Done() != nil) AND is not a non-cancelling context
|
|
// - If parent has a deadline, we need a timer (parent may not properly cancel via Done())
|
|
_, parentHasDeadline := parent.Deadline()
|
|
parentCanCancel := parent.Done() != nil && !isNonCancellingContext(parent)
|
|
if ctx.hasDeadline || parentCanCancel || parentHasDeadline {
|
|
go ctx.watchCancellation()
|
|
}
|
|
return ctx
|
|
}
|
|
|
|
// NewBifrostContextWithValue creates a new BifrostContext with the given value set.
|
|
func NewBifrostContextWithValue(parent context.Context, deadline time.Time, key any, value any) *BifrostContext {
|
|
ctx := NewBifrostContext(parent, deadline)
|
|
ctx.SetValue(key, value)
|
|
return ctx
|
|
}
|
|
|
|
// NewBifrostContextWithTimeout creates a new BifrostContext with a timeout duration.
|
|
// This is a convenience wrapper around NewBifrostContext.
|
|
// Returns the context and a cancel function that should be called to release resources.
|
|
func NewBifrostContextWithTimeout(parent context.Context, timeout time.Duration) (*BifrostContext, context.CancelFunc) {
|
|
ctx := NewBifrostContext(parent, time.Now().Add(timeout))
|
|
return ctx, func() { ctx.Cancel() }
|
|
}
|
|
|
|
// NewBifrostContextWithCancel creates a new BifrostContext with a cancel function.
|
|
// This is a convenience wrapper around NewBifrostContext.
|
|
// Returns the context and a cancel function that should be called to release resources.
|
|
func NewBifrostContextWithCancel(parent context.Context) (*BifrostContext, context.CancelFunc) {
|
|
ctx := NewBifrostContext(parent, NoDeadline)
|
|
return ctx, func() { ctx.Cancel() }
|
|
}
|
|
|
|
// WithValue returns a new context with the given value set.
|
|
func (bc *BifrostContext) WithValue(key any, value any) *BifrostContext {
|
|
bc.SetValue(key, value)
|
|
return bc
|
|
}
|
|
|
|
// BlockRestrictedWrites returns true if restricted writes are blocked.
|
|
func (bc *BifrostContext) BlockRestrictedWrites() {
|
|
bc.blockRestrictedWrites.Store(true)
|
|
}
|
|
|
|
// UnblockRestrictedWrites unblocks restricted writes.
|
|
func (bc *BifrostContext) UnblockRestrictedWrites() {
|
|
bc.blockRestrictedWrites.Store(false)
|
|
}
|
|
|
|
// Cancel cancels the context, closing the Done channel and setting the error to context.Canceled.
|
|
func (bc *BifrostContext) Cancel() {
|
|
bc.cancel(context.Canceled)
|
|
}
|
|
|
|
// watchCancellation monitors for deadline expiration and parent cancellation.
|
|
func (bc *BifrostContext) watchCancellation() {
|
|
var timer <-chan time.Time
|
|
|
|
// Use effective deadline (considers both own and parent deadlines)
|
|
// This handles cases where parent has a deadline but doesn't properly
|
|
// cancel via Done() (e.g., fasthttp.RequestCtx)
|
|
if effectiveDeadline, hasDeadline := bc.Deadline(); hasDeadline {
|
|
duration := time.Until(effectiveDeadline)
|
|
if duration <= 0 {
|
|
// Deadline already passed
|
|
bc.cancel(context.DeadlineExceeded)
|
|
return
|
|
}
|
|
t := time.NewTimer(duration)
|
|
defer t.Stop()
|
|
timer = t.C
|
|
}
|
|
|
|
// Don't watch parent.Done() for contexts known to never close it
|
|
// (e.g., fasthttp.RequestCtx pools contexts and never cancels them)
|
|
if isNonCancellingContext(bc.parent) {
|
|
select {
|
|
case <-timer:
|
|
bc.cancel(context.DeadlineExceeded)
|
|
case <-bc.done:
|
|
// Already cancelled
|
|
}
|
|
return
|
|
}
|
|
|
|
select {
|
|
case <-bc.parent.Done():
|
|
bc.cancel(bc.parent.Err())
|
|
case <-timer:
|
|
bc.cancel(context.DeadlineExceeded)
|
|
case <-bc.done:
|
|
// Already cancelled
|
|
}
|
|
}
|
|
|
|
// cancel closes the done channel and sets the error.
|
|
func (bc *BifrostContext) cancel(err error) {
|
|
bc.doneOnce.Do(func() {
|
|
bc.errMu.Lock()
|
|
bc.err = err
|
|
bc.errMu.Unlock()
|
|
close(bc.done)
|
|
})
|
|
}
|
|
|
|
// Deadline returns the deadline for this context.
|
|
// For scoped contexts, delegates to the root context.
|
|
// If both this context and the parent have deadlines, the earlier one is returned.
|
|
func (bc *BifrostContext) Deadline() (time.Time, bool) {
|
|
if bc.valueDelegate != nil {
|
|
return bc.valueDelegate.Deadline()
|
|
}
|
|
parentDeadline, parentHasDeadline := bc.parent.Deadline()
|
|
|
|
if !bc.hasDeadline && !parentHasDeadline {
|
|
return time.Time{}, false
|
|
}
|
|
|
|
if !bc.hasDeadline {
|
|
return parentDeadline, true
|
|
}
|
|
|
|
if !parentHasDeadline {
|
|
return bc.deadline, true
|
|
}
|
|
|
|
// Both have deadlines, return the earlier one
|
|
if bc.deadline.Before(parentDeadline) {
|
|
return bc.deadline, true
|
|
}
|
|
return parentDeadline, true
|
|
}
|
|
|
|
// Done returns a channel that is closed when the context is cancelled.
|
|
func (bc *BifrostContext) Done() <-chan struct{} {
|
|
return bc.done
|
|
}
|
|
|
|
// Err returns the error explaining why the context was cancelled.
|
|
// For scoped contexts, delegates to the root context.
|
|
// Returns nil if the context has not been cancelled.
|
|
func (bc *BifrostContext) Err() error {
|
|
if bc.valueDelegate != nil {
|
|
return bc.valueDelegate.Err()
|
|
}
|
|
bc.errMu.RLock()
|
|
defer bc.errMu.RUnlock()
|
|
return bc.err
|
|
}
|
|
|
|
// Value returns the value associated with the key.
|
|
// For scoped contexts, delegates to the root context via valueDelegate.
|
|
// Otherwise checks the internal userValues map, then delegates to the parent context.
|
|
func (bc *BifrostContext) Value(key any) any {
|
|
if bc.valueDelegate != nil {
|
|
return bc.valueDelegate.Value(key)
|
|
}
|
|
bc.valuesMu.RLock()
|
|
if val, ok := bc.userValues[key]; ok {
|
|
bc.valuesMu.RUnlock()
|
|
return val
|
|
}
|
|
bc.valuesMu.RUnlock()
|
|
|
|
if bc.parent == nil {
|
|
return nil
|
|
}
|
|
|
|
return bc.parent.Value(key)
|
|
}
|
|
|
|
// SetValue sets a value in the internal userValues map.
|
|
// For scoped contexts, delegates to the root context via valueDelegate.
|
|
// This is thread-safe and can be called concurrently.
|
|
func (bc *BifrostContext) SetValue(key, value any) {
|
|
if bc.valueDelegate != nil {
|
|
bc.valueDelegate.SetValue(key, value)
|
|
return
|
|
}
|
|
// Check if the key is a reserved key
|
|
if bc.blockRestrictedWrites.Load() && slices.Contains(reservedKeys, key) {
|
|
// we silently drop writes for these reserved keys
|
|
return
|
|
}
|
|
bc.valuesMu.Lock()
|
|
defer bc.valuesMu.Unlock()
|
|
if bc.userValues == nil {
|
|
bc.userValues = make(map[any]any)
|
|
}
|
|
bc.userValues[key] = value
|
|
}
|
|
|
|
// ClearValue clears a value from the internal userValues map.
|
|
// For scoped contexts, delegates to the root context via valueDelegate.
|
|
func (bc *BifrostContext) ClearValue(key any) {
|
|
if bc.valueDelegate != nil {
|
|
bc.valueDelegate.ClearValue(key)
|
|
return
|
|
}
|
|
// Check if the key is a reserved key
|
|
if bc.blockRestrictedWrites.Load() && slices.Contains(reservedKeys, key) {
|
|
// we silently drop writes for these reserved keys
|
|
return
|
|
}
|
|
bc.valuesMu.Lock()
|
|
defer bc.valuesMu.Unlock()
|
|
if bc.userValues != nil {
|
|
bc.userValues[key] = nil
|
|
}
|
|
}
|
|
|
|
// GetAndSetValue gets a value from the internal userValues map and sets it.
|
|
// For scoped contexts, delegates to the root context via valueDelegate.
|
|
func (bc *BifrostContext) GetAndSetValue(key any, value any) any {
|
|
if bc.valueDelegate != nil {
|
|
return bc.valueDelegate.GetAndSetValue(key, value)
|
|
}
|
|
bc.valuesMu.Lock()
|
|
defer bc.valuesMu.Unlock()
|
|
// Check if the key is a reserved key
|
|
if bc.blockRestrictedWrites.Load() && slices.Contains(reservedKeys, key) {
|
|
// we silently drop writes for these reserved keys
|
|
return bc.userValues[key]
|
|
}
|
|
if bc.userValues == nil {
|
|
bc.userValues = make(map[any]any)
|
|
}
|
|
oldValue := bc.userValues[key]
|
|
bc.userValues[key] = value
|
|
return oldValue
|
|
}
|
|
|
|
// GetUserValues returns a copy of all user-set values in this context.
|
|
// If the parent is also a PluginContext, the values are merged with parent values
|
|
// (this context's values take precedence over parent values).
|
|
func (bc *BifrostContext) GetUserValues() map[any]any {
|
|
result := make(map[any]any)
|
|
|
|
// First, get parent's user values if parent is a PluginContext
|
|
if parentCtx, ok := bc.parent.(*BifrostContext); ok {
|
|
for k, v := range parentCtx.GetUserValues() {
|
|
result[k] = v
|
|
}
|
|
}
|
|
|
|
// Then overlay with our own values (our values take precedence)
|
|
bc.valuesMu.RLock()
|
|
for k, v := range bc.userValues {
|
|
result[k] = v
|
|
}
|
|
bc.valuesMu.RUnlock()
|
|
|
|
return result
|
|
}
|
|
|
|
// GetParentCtxWithUserValues returns a copy of the parent context with all user-set values merged in.
|
|
func (bc *BifrostContext) GetParentCtxWithUserValues() context.Context {
|
|
parentCtx := bc.parent
|
|
bc.valuesMu.RLock()
|
|
for k, v := range bc.userValues {
|
|
parentCtx = context.WithValue(parentCtx, k, v)
|
|
}
|
|
bc.valuesMu.RUnlock()
|
|
return parentCtx
|
|
}
|
|
|
|
// AppendRoutingEngineLog appends a routing engine log entry to the context.
|
|
// Parameters:
|
|
// - ctx: The Bifrost context
|
|
// - engineName: Name of the routing engine (e.g., "governance", "routing-rule")
|
|
// - message: Human-readable log message describing the decision/action
|
|
func (bc *BifrostContext) AppendRoutingEngineLog(engineName string, message string) {
|
|
entry := RoutingEngineLogEntry{
|
|
Engine: engineName,
|
|
Message: message,
|
|
Timestamp: time.Now().UnixMilli(),
|
|
}
|
|
AppendToContextList(bc, BifrostContextKeyRoutingEngineLogs, entry)
|
|
}
|
|
|
|
// GetRoutingEngineLogs retrieves all routing engine logs from the context.
|
|
// Parameters:
|
|
// - ctx: The Bifrost context
|
|
//
|
|
// Returns:
|
|
// - []RoutingEngineLogEntry: Slice of routing engine log entries (nil if none)
|
|
func (bc *BifrostContext) GetRoutingEngineLogs() []RoutingEngineLogEntry {
|
|
if val := bc.Value(BifrostContextKeyRoutingEngineLogs); val != nil {
|
|
if logs, ok := val.([]RoutingEngineLogEntry); ok {
|
|
return logs
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AppendToContextList appends a value to the context list value.
|
|
// Parameters:
|
|
// - ctx: The Bifrost context
|
|
// - key: The key to append the value to
|
|
// - value: The value to append
|
|
func AppendToContextList[T any](ctx *BifrostContext, key BifrostContextKey, value T) {
|
|
if ctx == nil {
|
|
return
|
|
}
|
|
existingValues, ok := ctx.Value(key).([]T)
|
|
if !ok {
|
|
existingValues = []T{}
|
|
}
|
|
ctx.SetValue(key, append(existingValues, value))
|
|
}
|
|
|
|
// WithPluginScope returns a lightweight scoped BifrostContext from the pool.
|
|
// The scoped context shares the root's pluginLogs store and delegates all
|
|
// Value/SetValue operations to the root context.
|
|
// Call ReleasePluginScope() when done to return the scoped context to the pool.
|
|
func (bc *BifrostContext) WithPluginScope(name *string) *BifrostContext {
|
|
// Lazily initialize the plugin log store on the root context (CAS to avoid race)
|
|
if bc.pluginLogs.Load() == nil {
|
|
newStore := pluginLogStorePool.Get().(*pluginLogStore)
|
|
if !bc.pluginLogs.CompareAndSwap(nil, newStore) {
|
|
// Another goroutine initialized first — return unused store to pool
|
|
pluginLogStorePool.Put(newStore)
|
|
}
|
|
}
|
|
|
|
scoped := pluginScopePool.Get().(*BifrostContext)
|
|
scoped.parent = bc.parent
|
|
scoped.done = bc.done
|
|
scoped.pluginScope = name
|
|
scoped.pluginLogs.Store(bc.pluginLogs.Load())
|
|
scoped.valueDelegate = bc
|
|
return scoped
|
|
}
|
|
|
|
// ReleasePluginScope returns a scoped context to the pool.
|
|
// Safe no-op if called on a non-scoped context.
|
|
// Do not use the scoped context after calling this method.
|
|
func (bc *BifrostContext) ReleasePluginScope() {
|
|
if bc.valueDelegate == nil {
|
|
return // not a scoped context
|
|
}
|
|
bc.parent = nil
|
|
bc.done = nil
|
|
bc.pluginScope = nil
|
|
bc.pluginLogs.Store(nil)
|
|
bc.valueDelegate = nil
|
|
pluginScopePool.Put(bc)
|
|
}
|
|
|
|
// Log appends a structured log entry for the current plugin scope.
|
|
// No-op if the context is not scoped to a plugin or has no log store.
|
|
func (bc *BifrostContext) Log(level LogLevel, msg string) {
|
|
store := bc.pluginLogs.Load()
|
|
if bc.pluginScope == nil || store == nil {
|
|
return
|
|
}
|
|
store.mu.Lock()
|
|
store.logs = append(store.logs, PluginLogEntry{
|
|
PluginName: *bc.pluginScope,
|
|
Level: level,
|
|
Message: msg,
|
|
Timestamp: time.Now().UnixMilli(),
|
|
})
|
|
store.mu.Unlock()
|
|
}
|
|
|
|
// GetPluginLogs returns a deep copy of all accumulated plugin log entries.
|
|
// Thread-safe. Returns nil if no logs have been recorded.
|
|
func (bc *BifrostContext) GetPluginLogs() []PluginLogEntry {
|
|
store := bc.pluginLogs.Load()
|
|
if store == nil {
|
|
return nil
|
|
}
|
|
store.mu.Lock()
|
|
defer store.mu.Unlock()
|
|
if len(store.logs) == 0 {
|
|
return nil
|
|
}
|
|
copied := make([]PluginLogEntry, len(store.logs))
|
|
copy(copied, store.logs)
|
|
return copied
|
|
}
|
|
|
|
// DrainPluginLogs transfers ownership of the plugin log slice to the caller.
|
|
// The internal log store is returned to the pool after draining.
|
|
// Returns nil if no logs have been recorded.
|
|
// This should be called once on the root context after all plugin hooks have completed.
|
|
func (bc *BifrostContext) DrainPluginLogs() []PluginLogEntry {
|
|
if bc.valueDelegate != nil {
|
|
return nil // scoped contexts must not drain the shared log store
|
|
}
|
|
store := bc.pluginLogs.Load()
|
|
if store == nil {
|
|
return nil
|
|
}
|
|
bc.pluginLogs.Store(nil)
|
|
|
|
store.mu.Lock()
|
|
logs := store.logs
|
|
// Reset with fresh pre-allocated slice before returning to pool
|
|
store.logs = make([]PluginLogEntry, 0, 8)
|
|
store.mu.Unlock()
|
|
|
|
// Return the store to the pool for reuse
|
|
pluginLogStorePool.Put(store)
|
|
|
|
if len(logs) == 0 {
|
|
return nil
|
|
}
|
|
return logs
|
|
}
|