feat: backend service
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
// Package events translates inbound `pushv1.PushEvent` frames received
|
||||
// from backend into actions on the gateway-side push hub. It replaces
|
||||
// the Stage <6.2 Redis Stream subscribers (`session_events`,
|
||||
// `client_events`) with a single dispatcher driven by the gRPC
|
||||
// SubscribePush stream.
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
pushv1 "galaxy/backend/proto/push/v1"
|
||||
"galaxy/gateway/internal/push"
|
||||
"galaxy/gateway/internal/telemetry"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// SessionInvalidator closes every active push subscription bound to a
|
||||
// (device_session_id) or every session of a user when the backend emits
|
||||
// a SessionInvalidation frame. *push.Hub satisfies this contract.
|
||||
type SessionInvalidator interface {
|
||||
RevokeDeviceSession(deviceSessionID string)
|
||||
RevokeAllForUser(userID string)
|
||||
}
|
||||
|
||||
// EventPublisher fans out a translated client event to active push
|
||||
// subscriptions. *push.Hub satisfies this contract.
|
||||
type EventPublisher interface {
|
||||
Publish(event push.Event)
|
||||
}
|
||||
|
||||
// Dispatcher converts inbound `pushv1.PushEvent` frames into either a
|
||||
// hub Publish or a hub revocation. Malformed frames are dropped and
|
||||
// counted via telemetry; observability mirrors the previous
|
||||
// RecordInternalEventDrop semantics.
|
||||
type Dispatcher struct {
|
||||
publisher EventPublisher
|
||||
invalidator SessionInvalidator
|
||||
logger *zap.Logger
|
||||
metrics *telemetry.Runtime
|
||||
}
|
||||
|
||||
// NewDispatcher constructs a Dispatcher. publisher and invalidator are
|
||||
// required; logger and metrics may be nil.
|
||||
func NewDispatcher(publisher EventPublisher, invalidator SessionInvalidator, logger *zap.Logger, metrics *telemetry.Runtime) *Dispatcher {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &Dispatcher{
|
||||
publisher: publisher,
|
||||
invalidator: invalidator,
|
||||
logger: logger.Named("push_dispatcher"),
|
||||
metrics: metrics,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle implements backendclient.EventHandler. It is safe for
|
||||
// concurrent use; the caller serialises ev within its goroutine.
|
||||
func (d *Dispatcher) Handle(ctx context.Context, ev *pushv1.PushEvent) {
|
||||
if d == nil || ev == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch kind := ev.GetKind().(type) {
|
||||
case *pushv1.PushEvent_ClientEvent:
|
||||
d.handleClientEvent(ctx, kind.ClientEvent, ev.GetCursor())
|
||||
case *pushv1.PushEvent_SessionInvalidation:
|
||||
d.handleSessionInvalidation(kind.SessionInvalidation)
|
||||
default:
|
||||
d.logger.Warn("dropped malformed push event",
|
||||
zap.String("cursor", ev.GetCursor()),
|
||||
zap.String("reason", "unknown_kind"),
|
||||
)
|
||||
d.recordDrop(ctx, "unknown_kind")
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dispatcher) handleClientEvent(ctx context.Context, ce *pushv1.ClientEvent, cursor string) {
|
||||
if ce == nil || d.publisher == nil {
|
||||
return
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(ce.GetUserId())
|
||||
kind := strings.TrimSpace(ce.GetKind())
|
||||
eventID := strings.TrimSpace(ce.GetEventId())
|
||||
if userID == "" || kind == "" || eventID == "" {
|
||||
d.logger.Warn("dropped malformed client event",
|
||||
zap.String("cursor", cursor),
|
||||
zap.String("user_id", userID),
|
||||
zap.String("kind", kind),
|
||||
zap.String("event_id", eventID),
|
||||
)
|
||||
d.recordDrop(ctx, "malformed_client_event")
|
||||
return
|
||||
}
|
||||
|
||||
d.publisher.Publish(push.Event{
|
||||
UserID: userID,
|
||||
DeviceSessionID: strings.TrimSpace(ce.GetDeviceSessionId()),
|
||||
EventType: kind,
|
||||
EventID: eventID,
|
||||
PayloadBytes: cloneBytes(ce.GetPayload()),
|
||||
RequestID: ce.GetRequestId(),
|
||||
TraceID: ce.GetTraceId(),
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Dispatcher) handleSessionInvalidation(si *pushv1.SessionInvalidation) {
|
||||
if si == nil || d.invalidator == nil {
|
||||
return
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(si.GetUserId())
|
||||
deviceSessionID := strings.TrimSpace(si.GetDeviceSessionId())
|
||||
|
||||
switch {
|
||||
case deviceSessionID != "":
|
||||
d.invalidator.RevokeDeviceSession(deviceSessionID)
|
||||
case userID != "":
|
||||
d.invalidator.RevokeAllForUser(userID)
|
||||
default:
|
||||
d.logger.Warn("dropped malformed session_invalidation: user_id and device_session_id both empty")
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dispatcher) recordDrop(ctx context.Context, reason string) {
|
||||
if d.metrics == nil {
|
||||
return
|
||||
}
|
||||
d.metrics.RecordInternalEventDrop(ctx,
|
||||
attribute.String("component", "push_dispatcher"),
|
||||
attribute.String("reason", reason),
|
||||
)
|
||||
}
|
||||
|
||||
func cloneBytes(in []byte) []byte {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]byte, len(in))
|
||||
copy(out, in)
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user