Introduce GEMINI project documentation and enhance AGENTS.md

This commit adds comprehensive documentation for the new GEMINI project, detailing its architecture, tech stack, key commands, and development workflow. The documentation emphasizes the Clean Architecture principles and modular design of the application.

Additionally, AGENTS.md is updated to include information about the composite parser pattern and core domain types related to aviation telegrams and weather reports, enhancing clarity on the project's structure and design choices.

New Files:
- GEMINI.md: Detailed project overview and architecture documentation.

Updates:
- AGENTS.md: Added sections on parsers and core domain types.

These changes aim to improve onboarding and understanding of the project's architecture and functionality.
This commit is contained in:
windyboy
2025-12-27 12:20:06 +08:00
parent c0a66cf845
commit c398e51779
13 changed files with 542 additions and 1344 deletions
+181
View File
@@ -0,0 +1,181 @@
package nats
import (
"caatsm/internal/infra/config"
obsmetrics "caatsm/internal/infra/metrics"
"context"
"sync"
"time"
"github.com/nats-io/nats.go"
"go.uber.org/zap"
)
// ConsumerMonitor handles health monitoring and stats emission for the consumer.
type ConsumerMonitor struct {
logger *zap.Logger
cfg *config.Config
js nats.JetStreamContext
// Configuration
streamName string
consumerName string
monitorInterval time.Duration
// State
lastMessageTime time.Time
lastMessageSequence uint64
messageGapMutex sync.RWMutex
}
// NewConsumerMonitor creates a new ConsumerMonitor.
func NewConsumerMonitor(
logger *zap.Logger,
cfg *config.Config,
js nats.JetStreamContext,
streamName string,
consumerName string,
monitorInterval time.Duration,
) *ConsumerMonitor {
return &ConsumerMonitor{
logger: logger,
cfg: cfg,
js: js,
streamName: streamName,
consumerName: consumerName,
monitorInterval: monitorInterval,
}
}
// Start begins the monitoring loop.
func (m *ConsumerMonitor) Start(ctx context.Context) {
ticker := time.NewTicker(m.monitorInterval)
defer ticker.Stop()
// Record initial metric (0) to ensure it appears in Prometheus even before first tick
m.logger.Info("Starting NATS consumer stats emission goroutine, recording initial pending metric",
zap.String("stream", m.streamName),
zap.String("consumer", m.consumerName),
zap.Duration("interval", m.monitorInterval),
)
obsmetrics.RecordNATSConsumerPending(m.streamName, m.consumerName, 0)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
m.emitStats()
}
}
}
// emitStats gathers and records consumer statistics.
func (m *ConsumerMonitor) emitStats() {
if m.js != nil {
info, err := m.js.ConsumerInfo(m.streamName, m.consumerName)
if err != nil {
m.logger.Warn("Failed to fetch consumer info for pending messages metric",
zap.String("stream", m.streamName),
zap.String("consumer", m.consumerName),
zap.Error(err),
)
} else {
// Record pending messages for monitoring
m.logger.Debug("Recording NATS consumer pending messages metric",
zap.String("stream", m.streamName),
zap.String("consumer", m.consumerName),
zap.Uint64("pending", info.NumPending),
)
obsmetrics.RecordNATSConsumerPending(m.streamName, m.consumerName, info.NumPending)
}
}
// Record AFTN health metrics
gapSeconds := m.GetMessageGapSeconds()
healthy := m.IsHealthy()
obsmetrics.RecordMessageGap(m.streamName, m.consumerName, gapSeconds)
obsmetrics.RecordSerialReaderHealth(m.streamName, m.consumerName, healthy)
if !healthy {
m.logger.Warn("Serial reader appears stalled - no messages received recently",
zap.String("stream", m.streamName),
zap.String("consumer", m.consumerName),
zap.Float64("gap_seconds", gapSeconds),
zap.Duration("threshold", m.cfg.AFTN.MessageGapThreshold),
)
}
}
// TrackMessage updates health metrics based on a received message.
func (m *ConsumerMonitor) TrackMessage(msg *nats.Msg) {
if msg == nil {
return
}
m.messageGapMutex.Lock()
defer m.messageGapMutex.Unlock()
now := time.Now()
m.lastMessageTime = now
// Extract sequence number from message metadata
if meta, err := msg.Metadata(); err == nil {
currentSeq := meta.Sequence.Stream
// Detect sequence gaps if we have a previous sequence
if m.lastMessageSequence > 0 && m.cfg.AFTN.EnableSequenceGapDetection {
if currentSeq > m.lastMessageSequence+1 {
gapSize := currentSeq - m.lastMessageSequence - 1
m.logger.Warn("Message sequence gap detected",
zap.String("stream", m.streamName),
zap.String("consumer", m.consumerName),
zap.Uint64("last_sequence", m.lastMessageSequence),
zap.Uint64("current_sequence", currentSeq),
zap.Uint64("gap_size", gapSize),
)
obsmetrics.RecordSequenceGap(m.streamName, m.consumerName, gapSize)
}
}
m.lastMessageSequence = currentSeq
}
}
// GetMessageGapSeconds returns the number of seconds since the last message was received.
func (m *ConsumerMonitor) GetMessageGapSeconds() float64 {
m.messageGapMutex.RLock()
defer m.messageGapMutex.RUnlock()
if m.lastMessageTime.IsZero() {
return 0
}
return time.Since(m.lastMessageTime).Seconds()
}
// IsHealthy returns true if messages are being received within the threshold.
func (m *ConsumerMonitor) IsHealthy() bool {
m.messageGapMutex.RLock()
defer m.messageGapMutex.RUnlock()
// If we haven't received any messages yet, consider it healthy (initial state)
if m.lastMessageTime.IsZero() {
return true
}
gap := time.Since(m.lastMessageTime)
return gap < m.cfg.AFTN.MessageGapThreshold
}
// RecordInitialPending logs and records the initial pending messages count.
// This is exposed to allow recording immediately upon startup.
func (m *ConsumerMonitor) RecordInitialPending(pending uint64) {
m.logger.Info("Recording initial NATS consumer pending messages metric",
zap.String("stream", m.streamName),
zap.String("consumer", m.consumerName),
zap.Uint64("pending", pending),
)
obsmetrics.RecordNATSConsumerPending(m.streamName, m.consumerName, pending)
}