feat(go): add realtime pipeline observability
This commit is contained in:
@@ -195,6 +195,141 @@ func (r *Repository) IsOnline(ctx context.Context, vehicleKey string) (OnlineSta
|
||||
return best, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListOnline(ctx context.Context, query OnlineListQuery) ([]OnlineStatus, int64, error) {
|
||||
query = normalizeOnlineListQuery(query)
|
||||
total, err := r.onlineTotal(ctx, query.Protocol)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items := make([]OnlineStatus, 0, query.Limit)
|
||||
start := int64(query.Offset)
|
||||
if query.Protocol != "" {
|
||||
start = 0
|
||||
}
|
||||
skipped := 0
|
||||
batchSize := int64(query.Limit * 5)
|
||||
if batchSize < 100 {
|
||||
batchSize = 100
|
||||
}
|
||||
for len(items) < query.Limit {
|
||||
stop := start + batchSize - 1
|
||||
members, err := r.client.ZRevRangeWithScores(ctx, "vehicle:last_seen", start, stop).Result()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(members) == 0 {
|
||||
break
|
||||
}
|
||||
for _, member := range members {
|
||||
protocol, vin, ok := splitOnlineMember(fmt.Sprint(member.Member))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if query.Protocol != "" && protocol != query.Protocol {
|
||||
continue
|
||||
}
|
||||
if query.Protocol != "" && skipped < query.Offset {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
status, err := r.onlineStatusFor(ctx, protocol, vin, int64(member.Score))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items = append(items, status)
|
||||
if len(items) >= query.Limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
if int64(len(members)) < batchSize {
|
||||
break
|
||||
}
|
||||
start += batchSize
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (r *Repository) PipelineSummary(ctx context.Context) (PipelineSummary, error) {
|
||||
out := PipelineSummary{
|
||||
Protocols: make([]ProtocolPipelineSummary, 0, len(knownRealtimeProtocols())),
|
||||
UpdatedMS: time.Now().UnixMilli(),
|
||||
}
|
||||
for _, protocol := range knownRealtimeProtocols() {
|
||||
indexed, err := r.client.SCard(ctx, realtimeIndexKey(protocol)).Result()
|
||||
if err != nil {
|
||||
return PipelineSummary{}, err
|
||||
}
|
||||
onlineCount, latestSeen, err := r.protocolOnlineStats(ctx, protocol)
|
||||
if err != nil {
|
||||
return PipelineSummary{}, err
|
||||
}
|
||||
out.Protocols = append(out.Protocols, ProtocolPipelineSummary{
|
||||
Protocol: protocol,
|
||||
IndexedCount: indexed,
|
||||
OnlineCount: onlineCount,
|
||||
LatestSeenMS: latestSeen,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repository) onlineTotal(ctx context.Context, protocol envelope.Protocol) (int64, error) {
|
||||
if protocol != "" {
|
||||
return r.client.SCard(ctx, realtimeIndexKey(protocol)).Result()
|
||||
}
|
||||
return r.client.ZCard(ctx, "vehicle:last_seen").Result()
|
||||
}
|
||||
|
||||
func (r *Repository) protocolOnlineStats(ctx context.Context, protocol envelope.Protocol) (int64, int64, error) {
|
||||
members, err := r.client.SMembers(ctx, realtimeIndexKey(protocol)).Result()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
var onlineCount int64
|
||||
var latestSeen int64
|
||||
for _, vin := range members {
|
||||
status, err := r.onlineStatusFor(ctx, protocol, vin, 0)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if status.Online {
|
||||
onlineCount++
|
||||
}
|
||||
if status.LastSeenMS > latestSeen {
|
||||
latestSeen = status.LastSeenMS
|
||||
}
|
||||
}
|
||||
return onlineCount, latestSeen, nil
|
||||
}
|
||||
|
||||
func (r *Repository) onlineStatusFor(ctx context.Context, protocol envelope.Protocol, vin string, scoreMS int64) (OnlineStatus, error) {
|
||||
key := onlineKey(protocol, vin)
|
||||
stateKey := onlineStateKey(protocol, vin)
|
||||
state, err := r.client.HGetAll(ctx, stateKey).Result()
|
||||
if err != nil {
|
||||
return OnlineStatus{}, err
|
||||
}
|
||||
ttl := r.client.TTL(ctx, key).Val()
|
||||
status := OnlineStatus{
|
||||
VehicleKey: firstNonEmptyString(state["vehicle_key"], vin),
|
||||
VIN: firstNonEmptyString(state["vin"], vin),
|
||||
Protocol: protocol,
|
||||
Online: ttl > 0,
|
||||
LastSeenMS: firstPositiveInt64(parseInt64(state["last_seen_ms"]), scoreMS),
|
||||
OfflineAfterMS: parseInt64(state["offline_after_ms"]),
|
||||
SourceEndpoint: state["source_endpoint"],
|
||||
Protocols: []envelope.Protocol{protocol},
|
||||
TTLSeconds: int64(ttl.Seconds()),
|
||||
}
|
||||
if status.TTLSeconds < 0 {
|
||||
status.TTLSeconds = 0
|
||||
}
|
||||
if status.OfflineAfterMS <= 0 && status.LastSeenMS > 0 {
|
||||
status.OfflineAfterMS = status.LastSeenMS + r.cfg.ttl().Milliseconds()
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (r *Repository) getSnapshot(ctx context.Context, key string) (Snapshot, error) {
|
||||
var snapshot Snapshot
|
||||
payload, err := r.client.Get(ctx, key).Bytes()
|
||||
@@ -741,3 +876,48 @@ func knownRealtimeProtocols() []envelope.Protocol {
|
||||
func protocolsKey(vehicleKey string) string {
|
||||
return "vehicle:protocols:" + strings.TrimSpace(vehicleKey)
|
||||
}
|
||||
|
||||
func normalizeOnlineListQuery(query OnlineListQuery) OnlineListQuery {
|
||||
query.Protocol = envelope.Protocol(strings.ToUpper(strings.TrimSpace(string(query.Protocol))))
|
||||
if query.Limit <= 0 {
|
||||
query.Limit = 100
|
||||
}
|
||||
if query.Limit > 1000 {
|
||||
query.Limit = 1000
|
||||
}
|
||||
if query.Offset < 0 {
|
||||
query.Offset = 0
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func splitOnlineMember(member string) (envelope.Protocol, string, bool) {
|
||||
protocolText, vin, ok := strings.Cut(strings.TrimSpace(member), ":")
|
||||
if !ok || strings.TrimSpace(protocolText) == "" || strings.TrimSpace(vin) == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return envelope.Protocol(protocolText), strings.TrimSpace(vin), true
|
||||
}
|
||||
|
||||
func parseInt64(value string) int64 {
|
||||
parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||
return parsed
|
||||
}
|
||||
|
||||
func firstPositiveInt64(values ...int64) int64 {
|
||||
for _, value := range values {
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user