This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
package live
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSampleOutputQueueDropsWhenFull(t *testing.T) {
|
||||
session := &session{outbound: make(chan []byte, 1)}
|
||||
session.outbound <- []byte("queued")
|
||||
message := SamplesMessage{SchemaVersion: 1, Type: "samples", SubscriptionID: "sub", Sequence: 1, Samples: []Sample{}}
|
||||
if err := session.send(message); err != nil {
|
||||
t.Fatalf("sample drop should preserve session health: %v", err)
|
||||
}
|
||||
if len(session.outbound) != 1 {
|
||||
t.Fatalf("queue length=%d, want bounded length 1", len(session.outbound))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/itworx/pulse/internal/auth"
|
||||
"github.com/itworx/pulse/internal/correlation"
|
||||
"github.com/itworx/pulse/internal/queryplan"
|
||||
)
|
||||
|
||||
const (
|
||||
schemaVersion = 1
|
||||
defaultMaxMessage = 64 << 10
|
||||
defaultMaxSubs = 50
|
||||
defaultRateWindow = time.Minute
|
||||
defaultRateMessages = 120
|
||||
defaultHeartbeat = 15 * time.Second
|
||||
defaultIdleTimeout = 45 * time.Second
|
||||
defaultWriteTimeout = 5 * time.Second
|
||||
maxSubscriptionID = 128
|
||||
maxDetailLength = 500
|
||||
defaultOutboundMessages = 32
|
||||
)
|
||||
|
||||
var (
|
||||
ErrMessageTooLarge = errors.New("live message exceeds size limit")
|
||||
ErrRateLimited = errors.New("live message rate limit exceeded")
|
||||
)
|
||||
|
||||
type Incoming struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Type string `json:"type"`
|
||||
SubscriptionID string `json:"subscriptionId,omitempty"`
|
||||
Query json.RawMessage `json:"query,omitempty"`
|
||||
IntervalSeconds int `json:"intervalSeconds,omitempty"`
|
||||
Nonce string `json:"nonce,omitempty"`
|
||||
}
|
||||
|
||||
type Sample struct {
|
||||
Series string `json:"series"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Value *float64 `json:"value"`
|
||||
Freshness string `json:"freshness"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
}
|
||||
|
||||
type SamplesMessage struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Type string `json:"type"`
|
||||
SubscriptionID string `json:"subscriptionId"`
|
||||
Sequence uint64 `json:"sequence"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
Samples []Sample `json:"samples"`
|
||||
Coalesced bool `json:"coalesced,omitempty"`
|
||||
}
|
||||
|
||||
type StatusMessage struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Type string `json:"type"`
|
||||
SubscriptionID string `json:"subscriptionId"`
|
||||
State string `json:"state"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
type ErrorMessage struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Type string `json:"type"`
|
||||
SubscriptionID string `json:"subscriptionId,omitempty"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
CorrelationID string `json:"correlationId"`
|
||||
}
|
||||
|
||||
type PongMessage struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Type string `json:"type"`
|
||||
Nonce string `json:"nonce"`
|
||||
}
|
||||
|
||||
type Sampler interface {
|
||||
Sample(context.Context, queryplan.Request) ([]Sample, error)
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
Planner *queryplan.Planner
|
||||
Sampler Sampler
|
||||
AllowedOrigins []string
|
||||
MaxSubscriptions int
|
||||
MaxMessages int
|
||||
RateWindow time.Duration
|
||||
HeartbeatInterval time.Duration
|
||||
IdleTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
Now func() time.Time
|
||||
Registry *Registry
|
||||
}
|
||||
|
||||
type subscription struct {
|
||||
id string
|
||||
request queryplan.Request
|
||||
interval time.Duration
|
||||
nextAt time.Time
|
||||
sequence uint64
|
||||
lease *Lease
|
||||
}
|
||||
|
||||
type session struct {
|
||||
conn *websocket.Conn
|
||||
principal auth.Principal
|
||||
planner *queryplan.Planner
|
||||
sampler Sampler
|
||||
maxSubs int
|
||||
maxMessages int
|
||||
rateWindow time.Duration
|
||||
heartbeat time.Duration
|
||||
idleTimeout time.Duration
|
||||
writeTimeout time.Duration
|
||||
registry *Registry
|
||||
now func() time.Time
|
||||
correlationID string
|
||||
mu sync.Mutex
|
||||
subscriptions map[string]*subscription
|
||||
rateStarted time.Time
|
||||
rateCount int
|
||||
outbound chan []byte
|
||||
}
|
||||
|
||||
func (h Handler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
|
||||
principal, ok := auth.PrincipalFromContext(request.Context())
|
||||
if !ok {
|
||||
http.Error(response, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !auth.Allows(principal.Role, auth.PermissionView) {
|
||||
http.Error(response, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
conn, err := websocket.Accept(response, request, &websocket.AcceptOptions{OriginPatterns: h.AllowedOrigins})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
conn.SetReadLimit(int64(defaultMaxMessage))
|
||||
registry := h.Registry
|
||||
if registry == nil {
|
||||
registry = NewRegistry(h.Sampler, RegistryOptions{})
|
||||
}
|
||||
// Keep the upgraded transport attached to the authenticated request. The
|
||||
// session middleware cancels this context on logout and at the finite
|
||||
// absolute session deadline; server/request cancellation must also release
|
||||
// subscriptions and the socket.
|
||||
ctx, cancel := context.WithCancel(request.Context())
|
||||
correlationID := correlation.FromContext(request.Context())
|
||||
if correlationID == "" {
|
||||
correlationID = correlation.New()
|
||||
}
|
||||
s := &session{
|
||||
conn: conn, principal: principal, planner: h.Planner, sampler: h.Sampler,
|
||||
maxSubs: positiveOr(h.MaxSubscriptions, defaultMaxSubs),
|
||||
maxMessages: positiveOr(h.MaxMessages, defaultRateMessages),
|
||||
rateWindow: durationOr(h.RateWindow, defaultRateWindow),
|
||||
heartbeat: durationOr(h.HeartbeatInterval, defaultHeartbeat),
|
||||
idleTimeout: durationOr(h.IdleTimeout, defaultIdleTimeout),
|
||||
writeTimeout: durationOr(h.WriteTimeout, defaultWriteTimeout),
|
||||
registry: registry,
|
||||
now: h.Now, correlationID: correlationID, subscriptions: make(map[string]*subscription), outbound: make(chan []byte, defaultOutboundMessages),
|
||||
}
|
||||
if s.now == nil {
|
||||
s.now = func() time.Time { return time.Now().UTC() }
|
||||
}
|
||||
defer func() {
|
||||
cancel()
|
||||
s.releaseAll()
|
||||
conn.CloseNow()
|
||||
}()
|
||||
go s.writeLoop(ctx, cancel)
|
||||
go s.heartbeatLoop(ctx, cancel)
|
||||
go s.sampleLoop(ctx, cancel)
|
||||
s.readLoop(ctx)
|
||||
}
|
||||
|
||||
func positiveOr(value, fallback int) int {
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func durationOr(value, fallback time.Duration) time.Duration {
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func (s *session) readLoop(ctx context.Context) {
|
||||
for {
|
||||
readCtx, cancel := context.WithTimeout(ctx, s.idleTimeout)
|
||||
_, payload, err := s.conn.Read(readCtx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := s.acceptMessage(ctx, payload); err != nil {
|
||||
code, detail := "LIVE_MESSAGE_INVALID", "Live bericht is ongeldig."
|
||||
if errors.Is(err, ErrMessageTooLarge) {
|
||||
code, detail = "LIVE_MESSAGE_LIMIT", "Live bericht overschrijdt de maximale berichtgrootte."
|
||||
} else if errors.Is(err, ErrRateLimited) {
|
||||
code, detail = "LIVE_RATE_LIMIT", "Te veel live berichten; verbind opnieuw na een korte pauze."
|
||||
}
|
||||
_ = s.send(ErrorMessage{SchemaVersion: schemaVersion, Type: "error", Code: code, Message: detail, CorrelationID: s.correlationID})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *session) acceptMessage(ctx context.Context, payload []byte) error {
|
||||
if len(payload) > defaultMaxMessage {
|
||||
return ErrMessageTooLarge
|
||||
}
|
||||
s.mu.Lock()
|
||||
now := s.now()
|
||||
if s.rateStarted.IsZero() || now.Sub(s.rateStarted) >= s.rateWindow {
|
||||
s.rateStarted = now
|
||||
s.rateCount = 0
|
||||
}
|
||||
s.rateCount++
|
||||
limited := s.rateCount > s.maxMessages
|
||||
s.mu.Unlock()
|
||||
if limited {
|
||||
return ErrRateLimited
|
||||
}
|
||||
var message Incoming
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&message); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
return errors.New("live message has trailing data")
|
||||
}
|
||||
if message.SchemaVersion != schemaVersion || !validTypeFields(payload, message.Type) {
|
||||
return errors.New("live message schema is invalid")
|
||||
}
|
||||
switch message.Type {
|
||||
case "subscribe":
|
||||
return s.subscribe(ctx, message)
|
||||
case "unsubscribe":
|
||||
return s.unsubscribe(message.SubscriptionID)
|
||||
case "ping":
|
||||
if !validNonce(message.Nonce) {
|
||||
return errors.New("ping nonce is invalid")
|
||||
}
|
||||
return s.send(PongMessage{SchemaVersion: schemaVersion, Type: "pong", Nonce: message.Nonce})
|
||||
case "pong":
|
||||
if !validNonce(message.Nonce) {
|
||||
return errors.New("pong nonce is invalid")
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return errors.New("live message type is unsupported")
|
||||
}
|
||||
}
|
||||
|
||||
func validTypeFields(payload []byte, typ string) bool {
|
||||
var fields map[string]json.RawMessage
|
||||
if json.Unmarshal(payload, &fields) != nil {
|
||||
return false
|
||||
}
|
||||
allowed := map[string]map[string]struct{}{
|
||||
"subscribe": {"schemaVersion": {}, "type": {}, "subscriptionId": {}, "query": {}, "intervalSeconds": {}},
|
||||
"unsubscribe": {"schemaVersion": {}, "type": {}, "subscriptionId": {}},
|
||||
"ping": {"schemaVersion": {}, "type": {}, "nonce": {}},
|
||||
"pong": {"schemaVersion": {}, "type": {}, "nonce": {}},
|
||||
}
|
||||
known, ok := allowed[typ]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for key := range fields {
|
||||
if _, ok := known[key]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validSubscriptionID(id string) bool {
|
||||
return id != "" && utf8.RuneCountInString(id) <= maxSubscriptionID
|
||||
}
|
||||
|
||||
func validNonce(nonce string) bool {
|
||||
return utf8.RuneCountInString(nonce) <= maxSubscriptionID
|
||||
}
|
||||
|
||||
func (s *session) subscribe(ctx context.Context, message Incoming) error {
|
||||
if !validSubscriptionID(message.SubscriptionID) || len(message.Query) == 0 || message.IntervalSeconds < 1 || message.IntervalSeconds > 300 {
|
||||
return s.sendError(message.SubscriptionID, "LIVE_SUBSCRIPTION_INVALID", "Abonnement, query of interval is ongeldig.")
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(message.Query, &fields); err != nil || len(fields) > 20 {
|
||||
return s.sendError(message.SubscriptionID, "LIVE_QUERY_INVALID", "Live query moet een begrensd JSON-object zijn.")
|
||||
}
|
||||
var request queryplan.Request
|
||||
decoder := json.NewDecoder(bytes.NewReader(message.Query))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
return s.sendError(message.SubscriptionID, "LIVE_QUERY_INVALID", "Live query bevat onbekende of ongeldige velden.")
|
||||
}
|
||||
if s.planner == nil {
|
||||
return s.sendError(message.SubscriptionID, "LIVE_QUERY_UNAVAILABLE", "Live queryvalidatie is tijdelijk niet beschikbaar.")
|
||||
}
|
||||
plan, err := s.planner.Plan(auth.WithPrincipal(ctx, s.principal), request)
|
||||
if err != nil {
|
||||
return s.sendError(message.SubscriptionID, "LIVE_SUBSCRIPTION_DENIED", queryErrorDetail(err))
|
||||
}
|
||||
now := s.now()
|
||||
interval := time.Duration(message.IntervalSeconds) * time.Second
|
||||
s.mu.Lock()
|
||||
duplicate := false
|
||||
sessionLimit := len(s.subscriptions) >= s.maxSubs
|
||||
if _, exists := s.subscriptions[message.SubscriptionID]; exists {
|
||||
duplicate = true
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if duplicate {
|
||||
return s.sendError(message.SubscriptionID, "LIVE_SUBSCRIPTION_CONFLICT", "Subscription-ID bestaat al.")
|
||||
}
|
||||
if sessionLimit {
|
||||
return s.sendError(message.SubscriptionID, "LIVE_SUBSCRIPTION_LIMIT", "Maximum aantal live abonnementen bereikt.")
|
||||
}
|
||||
lease, err := s.registry.Acquire(plan.Request, interval)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrRegistryLimit) {
|
||||
return s.sendError(message.SubscriptionID, "LIVE_REGISTRY_LIMIT", "Maximum aantal gedeelde live queries bereikt.")
|
||||
}
|
||||
return s.sendError(message.SubscriptionID, "LIVE_QUERY_INVALID", "Live query kon niet worden genormaliseerd.")
|
||||
}
|
||||
s.mu.Lock()
|
||||
if _, exists := s.subscriptions[message.SubscriptionID]; exists || len(s.subscriptions) >= s.maxSubs {
|
||||
s.mu.Unlock()
|
||||
lease.Release()
|
||||
return s.sendError(message.SubscriptionID, "LIVE_SUBSCRIPTION_CONFLICT", "Subscription-ID bestaat al.")
|
||||
}
|
||||
s.subscriptions[message.SubscriptionID] = &subscription{id: message.SubscriptionID, request: plan.Request, interval: interval, nextAt: now.Add(interval), lease: lease}
|
||||
s.mu.Unlock()
|
||||
return s.send(StatusMessage{SchemaVersion: schemaVersion, Type: "status", SubscriptionID: message.SubscriptionID, State: "subscribed"})
|
||||
}
|
||||
|
||||
func queryErrorDetail(err error) string {
|
||||
var plannerError queryplan.Error
|
||||
if errors.As(err, &plannerError) {
|
||||
return plannerError.Detail
|
||||
}
|
||||
return "Live query kon niet worden geautoriseerd."
|
||||
}
|
||||
|
||||
func (s *session) unsubscribe(id string) error {
|
||||
if !validSubscriptionID(id) {
|
||||
return s.sendError(id, "LIVE_SUBSCRIPTION_INVALID", "Subscription-ID is ongeldig.")
|
||||
}
|
||||
s.mu.Lock()
|
||||
sub, exists := s.subscriptions[id]
|
||||
delete(s.subscriptions, id)
|
||||
s.mu.Unlock()
|
||||
if exists && sub.lease != nil {
|
||||
sub.lease.Release()
|
||||
}
|
||||
return s.send(StatusMessage{SchemaVersion: schemaVersion, Type: "status", SubscriptionID: id, State: "unsubscribed"})
|
||||
}
|
||||
|
||||
func (s *session) releaseAll() {
|
||||
s.mu.Lock()
|
||||
subscriptions := make([]*subscription, 0, len(s.subscriptions))
|
||||
for _, sub := range s.subscriptions {
|
||||
subscriptions = append(subscriptions, sub)
|
||||
}
|
||||
s.subscriptions = make(map[string]*subscription)
|
||||
s.mu.Unlock()
|
||||
for _, sub := range subscriptions {
|
||||
if sub.lease != nil {
|
||||
sub.lease.Release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *session) sendError(subscriptionID, code, message string) error {
|
||||
if len(message) > maxDetailLength {
|
||||
message = message[:maxDetailLength]
|
||||
}
|
||||
return s.send(ErrorMessage{SchemaVersion: schemaVersion, Type: "error", SubscriptionID: subscriptionID, Code: code, Message: message, CorrelationID: s.correlationID})
|
||||
}
|
||||
|
||||
func (s *session) send(message any) error {
|
||||
payload, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(payload) > defaultMaxMessage {
|
||||
return ErrMessageTooLarge
|
||||
}
|
||||
sample := false
|
||||
switch message.(type) {
|
||||
case SamplesMessage, *SamplesMessage:
|
||||
sample = true
|
||||
}
|
||||
if !sample {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), s.writeTimeout)
|
||||
defer cancel()
|
||||
return s.conn.Write(ctx, websocket.MessageText, payload)
|
||||
}
|
||||
if s.outbound == nil {
|
||||
return ErrOutboundBackpressure
|
||||
}
|
||||
select {
|
||||
case s.outbound <- payload:
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *session) writeLoop(ctx context.Context, cancel context.CancelFunc) {
|
||||
for {
|
||||
select {
|
||||
case payload := <-s.outbound:
|
||||
writeCtx, writeCancel := context.WithTimeout(ctx, s.writeTimeout)
|
||||
err := s.conn.Write(writeCtx, websocket.MessageText, payload)
|
||||
writeCancel()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *session) heartbeatLoop(ctx context.Context, cancel context.CancelFunc) {
|
||||
ticker := time.NewTicker(s.heartbeat)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
pingCtx, pingCancel := context.WithTimeout(ctx, s.writeTimeout)
|
||||
err := s.conn.Ping(pingCtx)
|
||||
pingCancel()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *session) sampleLoop(ctx context.Context, cancel context.CancelFunc) {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
now := s.now()
|
||||
s.mu.Lock()
|
||||
due := make([]*subscription, 0, len(s.subscriptions))
|
||||
for _, sub := range s.subscriptions {
|
||||
if !now.Before(sub.nextAt) {
|
||||
sub.nextAt = now.Add(sub.interval)
|
||||
due = append(due, sub)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
for _, sub := range due {
|
||||
samples, err := s.registry.Sample(ctx, sub.request)
|
||||
if err != nil {
|
||||
_ = s.sendError(sub.id, "LIVE_SAMPLE_UNAVAILABLE", "Live sample is tijdelijk niet beschikbaar.")
|
||||
continue
|
||||
}
|
||||
s.mu.Lock()
|
||||
current, exists := s.subscriptions[sub.id]
|
||||
if !exists {
|
||||
s.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
current.sequence++
|
||||
sequence := current.sequence
|
||||
s.mu.Unlock()
|
||||
if len(samples) > 10000 {
|
||||
_ = s.sendError(sub.id, "LIVE_SAMPLE_LIMIT", "Live sample bevat te veel punten.")
|
||||
continue
|
||||
}
|
||||
if err := s.send(SamplesMessage{SchemaVersion: schemaVersion, Type: "samples", SubscriptionID: sub.id, Sequence: sequence, ServerTime: now.UTC(), Samples: samples}); err != nil {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package live_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
"github.com/itworx/pulse/internal/auth"
|
||||
"github.com/itworx/pulse/internal/live"
|
||||
"github.com/itworx/pulse/internal/metriccatalog"
|
||||
"github.com/itworx/pulse/internal/queryplan"
|
||||
)
|
||||
|
||||
type sampler struct{}
|
||||
|
||||
func (sampler) Sample(_ context.Context, request queryplan.Request) ([]live.Sample, error) {
|
||||
value := float64(request.MaxPoints)
|
||||
return []live.Sample{{Series: "test", Timestamp: time.Now().UTC(), Value: &value, Freshness: "fresh"}}, nil
|
||||
}
|
||||
|
||||
func plannerForTest(t *testing.T) *queryplan.Planner {
|
||||
t.Helper()
|
||||
registry, err := metriccatalog.DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
planner := queryplan.NewPlanner(registry, queryplan.Limits{})
|
||||
return &planner
|
||||
}
|
||||
|
||||
func validQuery() map[string]any {
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
return map[string]any{
|
||||
"metric": "container.cpu.utilization",
|
||||
"scope": map[string]string{"containerId": "media_server"},
|
||||
"range": map[string]any{"from": now.Add(-time.Minute).Format(time.RFC3339), "to": now.Format(time.RFC3339), "stepSeconds": 15},
|
||||
"aggregation": "avg",
|
||||
"maxSeries": 1,
|
||||
"maxPoints": 60,
|
||||
}
|
||||
}
|
||||
|
||||
func authorizedServer(t *testing.T, handler live.Handler) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r = r.WithContext(auth.WithPrincipal(r.Context(), auth.Principal{Subject: "viewer", Role: auth.RoleViewer}))
|
||||
handler.ServeHTTP(w, r)
|
||||
}))
|
||||
}
|
||||
|
||||
func TestHandlerRejectsUnauthenticatedBeforeUpgrade(t *testing.T) {
|
||||
server := httptest.NewServer(live.Handler{})
|
||||
defer server.Close()
|
||||
_, response, err := websocket.Dial(context.Background(), "ws"+server.URL[4:]+"/api/v1/live", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected unauthenticated upgrade rejection")
|
||||
}
|
||||
if response == nil || response.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("response=%v err=%v", response, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerRejectsCrossOriginUpgrade(t *testing.T) {
|
||||
server := authorizedServer(t, live.Handler{Planner: plannerForTest(t)})
|
||||
defer server.Close()
|
||||
options := &websocket.DialOptions{HTTPHeader: http.Header{"Origin": []string{"https://evil.example"}}}
|
||||
_, response, err := websocket.Dial(context.Background(), "ws"+server.URL[4:]+"/api/v1/live", options)
|
||||
if err == nil {
|
||||
t.Fatal("expected cross-origin rejection")
|
||||
}
|
||||
if response == nil || response.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("response=%v err=%v", response, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerClosesWhenAuthenticatedRequestIsCancelled(t *testing.T) {
|
||||
cancelled := make(chan context.CancelFunc, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithCancel(auth.WithPrincipal(r.Context(), auth.Principal{Subject: "viewer", Role: auth.RoleViewer}))
|
||||
cancelled <- cancel
|
||||
live.Handler{}.ServeHTTP(w, r.WithContext(ctx))
|
||||
}))
|
||||
defer server.Close()
|
||||
conn, _, err := websocket.Dial(context.Background(), "ws"+server.URL[4:]+"/api/v1/live", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.CloseNow()
|
||||
(<-cancelled)()
|
||||
readCtx, cancelRead := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancelRead()
|
||||
if _, _, err := conn.Read(readCtx); err == nil {
|
||||
t.Fatal("live connection survived authenticated request cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerAuthorizesSubscriptionAndSequencesSamples(t *testing.T) {
|
||||
server := authorizedServer(t, live.Handler{Planner: plannerForTest(t), Sampler: sampler{}})
|
||||
defer server.Close()
|
||||
conn, _, err := websocket.Dial(context.Background(), "ws"+server.URL[4:]+"/api/v1/live", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close(websocket.StatusNormalClosure, "")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
if err := wsjson.Write(ctx, conn, map[string]any{"schemaVersion": 1, "type": "subscribe", "subscriptionId": "sub-1", "query": validQuery(), "intervalSeconds": 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var status live.StatusMessage
|
||||
if err := wsjson.Read(ctx, conn, &status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.State != "subscribed" {
|
||||
t.Fatalf("status=%+v", status)
|
||||
}
|
||||
var first, second live.SamplesMessage
|
||||
if err := wsjson.Read(ctx, conn, &first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := wsjson.Read(ctx, conn, &second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Sequence != 1 || second.Sequence != 2 || second.Sequence <= first.Sequence {
|
||||
t.Fatalf("sequences=%d,%d", first.Sequence, second.Sequence)
|
||||
}
|
||||
if len(first.Samples) != 1 || first.Samples[0].Freshness != "fresh" {
|
||||
t.Fatalf("samples=%+v", first.Samples)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReportsMissingSamplerInsteadOfEmptySamples(t *testing.T) {
|
||||
server := authorizedServer(t, live.Handler{Planner: plannerForTest(t)})
|
||||
defer server.Close()
|
||||
conn, _, err := websocket.Dial(context.Background(), "ws"+server.URL[4:]+"/api/v1/live", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
if err := wsjson.Write(ctx, conn, map[string]any{"schemaVersion": 1, "type": "subscribe", "subscriptionId": "sub-1", "query": validQuery(), "intervalSeconds": 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var status live.StatusMessage
|
||||
if err := wsjson.Read(ctx, conn, &status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status.State != "subscribed" {
|
||||
t.Fatalf("status=%+v", status)
|
||||
}
|
||||
var failure live.ErrorMessage
|
||||
if err := wsjson.Read(ctx, conn, &failure); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failure.Type != "error" || failure.Code != "LIVE_SAMPLE_UNAVAILABLE" || failure.SubscriptionID != "sub-1" {
|
||||
t.Fatalf("failure=%+v", failure)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerDeniesInvalidSubscriptionAndLimitsMessages(t *testing.T) {
|
||||
server := authorizedServer(t, live.Handler{Planner: plannerForTest(t), MaxMessages: 2, RateWindow: time.Minute})
|
||||
defer server.Close()
|
||||
conn, _, err := websocket.Dial(context.Background(), "ws"+server.URL[4:]+"/api/v1/live", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close(websocket.StatusNormalClosure, "")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if err := wsjson.Write(ctx, conn, map[string]any{"schemaVersion": 1, "type": "subscribe", "subscriptionId": "bad", "query": map[string]any{"metric": "unknown"}, "intervalSeconds": 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var problem live.ErrorMessage
|
||||
if err := wsjson.Read(ctx, conn, &problem); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if problem.Code != "LIVE_SUBSCRIPTION_DENIED" {
|
||||
t.Fatalf("problem=%+v", problem)
|
||||
}
|
||||
if err := wsjson.Write(ctx, conn, map[string]any{"schemaVersion": 1, "type": "ping", "nonce": "one"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var pong live.PongMessage
|
||||
if err := wsjson.Read(ctx, conn, &pong); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := wsjson.Write(ctx, conn, map[string]any{"schemaVersion": 1, "type": "ping", "nonce": "two"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var limited live.ErrorMessage
|
||||
if err := wsjson.Read(ctx, conn, &limited); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if limited.Code != "LIVE_RATE_LIMIT" {
|
||||
t.Fatalf("problem=%+v", limited)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerClosesOversizedMessage(t *testing.T) {
|
||||
server := authorizedServer(t, live.Handler{Planner: plannerForTest(t)})
|
||||
defer server.Close()
|
||||
conn, _, err := websocket.Dial(context.Background(), "ws"+server.URL[4:]+"/api/v1/live", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close(websocket.StatusNormalClosure, "")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := wsjson.Write(ctx, conn, map[string]any{"schemaVersion": 1, "type": "ping", "nonce": strings.Repeat("x", 70000)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var response map[string]any
|
||||
if err := wsjson.Read(ctx, conn, &response); err == nil {
|
||||
t.Fatal("expected oversized message connection to close")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/queryplan"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRegistryEntries = 256
|
||||
defaultRegistryConcurrency = 16
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRegistryLimit = errors.New("live subscription registry limit reached")
|
||||
ErrLeaseReleased = errors.New("live subscription lease is released")
|
||||
ErrOutboundBackpressure = errors.New("live outbound queue is full")
|
||||
ErrSamplerUnavailable = errors.New("live sampler is not configured")
|
||||
)
|
||||
|
||||
type RegistryOptions struct {
|
||||
MaxEntries int
|
||||
MaxConcurrent int
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
sampler Sampler
|
||||
now func() time.Time
|
||||
maxEntries int
|
||||
sem chan struct{}
|
||||
mu sync.Mutex
|
||||
entries map[string]*registryEntry
|
||||
}
|
||||
|
||||
type registryEntry struct {
|
||||
key string
|
||||
request queryplan.Request
|
||||
references int
|
||||
minInterval time.Duration
|
||||
lastAt time.Time
|
||||
lastSamples []Sample
|
||||
inFlight *sampleCall
|
||||
}
|
||||
|
||||
type sampleCall struct {
|
||||
done chan struct{}
|
||||
samples []Sample
|
||||
err error
|
||||
}
|
||||
|
||||
type Lease struct {
|
||||
registry *Registry
|
||||
key string
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// NewRegistry builds the shared live subscription registry. A nil sampler keeps
|
||||
// subscription lifecycle traffic working but is not a usable data source: every
|
||||
// Sample then fails with ErrSamplerUnavailable instead of silently reporting an
|
||||
// empty successful result.
|
||||
func NewRegistry(sampler Sampler, options RegistryOptions) *Registry {
|
||||
maxEntries := options.MaxEntries
|
||||
if maxEntries <= 0 {
|
||||
maxEntries = defaultRegistryEntries
|
||||
}
|
||||
maxConcurrent := options.MaxConcurrent
|
||||
if maxConcurrent <= 0 {
|
||||
maxConcurrent = defaultRegistryConcurrency
|
||||
}
|
||||
return &Registry{
|
||||
sampler: sampler,
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
maxEntries: maxEntries,
|
||||
sem: make(chan struct{}, maxConcurrent),
|
||||
entries: make(map[string]*registryEntry),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) Acquire(request queryplan.Request, interval time.Duration) (*Lease, error) {
|
||||
if r == nil || interval <= 0 {
|
||||
return nil, ErrLeaseReleased
|
||||
}
|
||||
key, err := normalizedKey(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
entry, exists := r.entries[key]
|
||||
if !exists {
|
||||
if len(r.entries) >= r.maxEntries {
|
||||
return nil, ErrRegistryLimit
|
||||
}
|
||||
entry = ®istryEntry{key: key, request: request, minInterval: interval}
|
||||
r.entries[key] = entry
|
||||
} else if interval < entry.minInterval {
|
||||
entry.minInterval = interval
|
||||
}
|
||||
entry.references++
|
||||
return &Lease{registry: r, key: key}, nil
|
||||
}
|
||||
|
||||
func (l *Lease) Release() {
|
||||
if l == nil || l.registry == nil {
|
||||
return
|
||||
}
|
||||
l.once.Do(func() {
|
||||
l.registry.release(l.key)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Registry) release(key string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
entry, ok := r.entries[key]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if entry.references > 0 {
|
||||
entry.references--
|
||||
}
|
||||
if entry.references == 0 && entry.inFlight == nil {
|
||||
delete(r.entries, key)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) Sample(ctx context.Context, request queryplan.Request) ([]Sample, error) {
|
||||
if r == nil {
|
||||
return nil, ErrLeaseReleased
|
||||
}
|
||||
if r.sampler == nil {
|
||||
return nil, ErrSamplerUnavailable
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := normalizedKey(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.mu.Lock()
|
||||
entry, ok := r.entries[key]
|
||||
if !ok || entry.references == 0 {
|
||||
r.mu.Unlock()
|
||||
return nil, ErrLeaseReleased
|
||||
}
|
||||
now := r.now()
|
||||
if !entry.lastAt.IsZero() && now.Sub(entry.lastAt) < entry.minInterval {
|
||||
samples := cloneSamples(entry.lastSamples)
|
||||
r.mu.Unlock()
|
||||
return samples, nil
|
||||
}
|
||||
if entry.inFlight != nil {
|
||||
call := entry.inFlight
|
||||
r.mu.Unlock()
|
||||
select {
|
||||
case <-call.done:
|
||||
return cloneSamples(call.samples), call.err
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
call := &sampleCall{done: make(chan struct{})}
|
||||
entry.inFlight = call
|
||||
requestCopy := entry.request
|
||||
r.mu.Unlock()
|
||||
|
||||
select {
|
||||
case r.sem <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
r.finish(key, entry, call, nil, ctx.Err())
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
samples, sampleErr := r.sampler.Sample(ctx, requestCopy)
|
||||
<-r.sem
|
||||
r.finish(key, entry, call, samples, sampleErr)
|
||||
return cloneSamples(samples), sampleErr
|
||||
}
|
||||
|
||||
func (r *Registry) finish(key string, entry *registryEntry, call *sampleCall, samples []Sample, err error) {
|
||||
r.mu.Lock()
|
||||
call.samples = cloneSamples(samples)
|
||||
call.err = err
|
||||
if err == nil {
|
||||
entry.lastAt = r.now()
|
||||
entry.lastSamples = cloneSamples(samples)
|
||||
}
|
||||
entry.inFlight = nil
|
||||
if entry.references == 0 {
|
||||
delete(r.entries, key)
|
||||
}
|
||||
close(call.done)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *Registry) Active() (entries, references int) {
|
||||
if r == nil {
|
||||
return 0, 0
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, entry := range r.entries {
|
||||
entries++
|
||||
references += entry.references
|
||||
}
|
||||
return entries, references
|
||||
}
|
||||
|
||||
func normalizedKey(request queryplan.Request) (string, error) {
|
||||
payload, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
digest := sha256.Sum256(payload)
|
||||
return hex.EncodeToString(digest[:]), nil
|
||||
}
|
||||
|
||||
func cloneSamples(samples []Sample) []Sample {
|
||||
if samples == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]Sample, len(samples))
|
||||
for index, sample := range samples {
|
||||
cloned[index] = sample
|
||||
if sample.Value != nil {
|
||||
value := *sample.Value
|
||||
cloned[index].Value = &value
|
||||
}
|
||||
if sample.Labels != nil {
|
||||
cloned[index].Labels = make(map[string]string, len(sample.Labels))
|
||||
for key, value := range sample.Labels {
|
||||
cloned[index].Labels[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package live_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/live"
|
||||
"github.com/itworx/pulse/internal/queryplan"
|
||||
)
|
||||
|
||||
type blockingSampler struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (s *blockingSampler) Sample(_ context.Context, _ queryplan.Request) ([]live.Sample, error) {
|
||||
s.mu.Lock()
|
||||
s.calls++
|
||||
if s.calls == 1 {
|
||||
close(s.started)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
<-s.release
|
||||
value := 1.0
|
||||
return []live.Sample{{Series: "shared", Timestamp: time.Now().UTC(), Value: &value, Freshness: "fresh"}}, nil
|
||||
}
|
||||
|
||||
func (s *blockingSampler) Calls() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.calls
|
||||
}
|
||||
|
||||
func sharedRequest() queryplan.Request {
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
return queryplan.Request{
|
||||
Metric: "container.cpu.utilization",
|
||||
Scope: map[string]string{"container": "media_server"},
|
||||
Range: queryplan.Range{From: now.Add(-time.Minute), To: now, StepSeconds: 15},
|
||||
Aggregation: "avg",
|
||||
MaxSeries: 1,
|
||||
MaxPoints: 60,
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryDeduplicatesOverlappingSamplesAndReleasesReferences(t *testing.T) {
|
||||
source := &blockingSampler{started: make(chan struct{}), release: make(chan struct{})}
|
||||
registry := live.NewRegistry(source, live.RegistryOptions{MaxEntries: 4, MaxConcurrent: 1})
|
||||
request := sharedRequest()
|
||||
firstLease, err := registry.Acquire(request, time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondLease, err := registry.Acquire(request, time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, references := registry.Active()
|
||||
if entries != 1 || references != 2 {
|
||||
t.Fatalf("active=%d references=%d", entries, references)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
first := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := registry.Sample(ctx, request)
|
||||
first <- err
|
||||
}()
|
||||
<-source.started
|
||||
second := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := registry.Sample(ctx, request)
|
||||
second <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-second:
|
||||
t.Fatalf("second sample completed before shared source: %v", err)
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
close(source.release)
|
||||
if err := <-first; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := <-second; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls := source.Calls(); calls != 1 {
|
||||
t.Fatalf("upstream calls=%d, want 1", calls)
|
||||
}
|
||||
|
||||
firstLease.Release()
|
||||
entries, references = registry.Active()
|
||||
if entries != 1 || references != 1 {
|
||||
t.Fatalf("after first release active=%d references=%d", entries, references)
|
||||
}
|
||||
secondLease.Release()
|
||||
entries, references = registry.Active()
|
||||
if entries != 0 || references != 0 {
|
||||
t.Fatalf("after final release active=%d references=%d", entries, references)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryWithoutSamplerFailsVisiblyInsteadOfReturningEmptySamples(t *testing.T) {
|
||||
registry := live.NewRegistry(nil, live.RegistryOptions{})
|
||||
request := sharedRequest()
|
||||
lease, err := registry.Acquire(request, time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer lease.Release()
|
||||
samples, err := registry.Sample(context.Background(), request)
|
||||
if !errors.Is(err, live.ErrSamplerUnavailable) {
|
||||
t.Fatalf("nil sampler reported err=%v", err)
|
||||
}
|
||||
if samples != nil {
|
||||
t.Fatalf("nil sampler returned samples=%+v", samples)
|
||||
}
|
||||
}
|
||||
|
||||
type trackingSampler struct {
|
||||
mu sync.Mutex
|
||||
active int
|
||||
maxActive int
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (s *trackingSampler) Sample(_ context.Context, _ queryplan.Request) ([]live.Sample, error) {
|
||||
s.mu.Lock()
|
||||
s.active++
|
||||
if s.active > s.maxActive {
|
||||
s.maxActive = s.active
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.started <- struct{}{}
|
||||
<-s.release
|
||||
s.mu.Lock()
|
||||
s.active--
|
||||
s.mu.Unlock()
|
||||
return []live.Sample{}, nil
|
||||
}
|
||||
|
||||
func (s *trackingSampler) MaxActive() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.maxActive
|
||||
}
|
||||
|
||||
func TestRegistryBoundsEntriesAndUpstreamConcurrency(t *testing.T) {
|
||||
source := &trackingSampler{started: make(chan struct{}, 2), release: make(chan struct{})}
|
||||
registry := live.NewRegistry(source, live.RegistryOptions{MaxEntries: 1, MaxConcurrent: 1})
|
||||
firstRequest := sharedRequest()
|
||||
firstLease, err := registry.Acquire(firstRequest, time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer firstLease.Release()
|
||||
secondRequest := firstRequest
|
||||
secondRequest.Metric = "host.cpu.utilization"
|
||||
if _, err := registry.Acquire(secondRequest, time.Second); err == nil {
|
||||
t.Fatal("expected registry entry limit")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
first := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := registry.Sample(ctx, firstRequest)
|
||||
first <- err
|
||||
}()
|
||||
<-source.started
|
||||
close(source.release)
|
||||
if err := <-first; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if source.MaxActive() != 1 {
|
||||
t.Fatalf("max upstream concurrency=%d", source.MaxActive())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user