Files
logs/internal/ingest/alert_outbox.go

300 lines
8.9 KiB
Go
Raw Normal View History

2026-06-26 12:51:50 +08:00
package ingest
import (
"encoding/json"
2026-07-21 02:32:18 +08:00
"errors"
2026-06-26 12:51:50 +08:00
"fmt"
2026-07-21 02:32:18 +08:00
"log"
"os"
2026-06-26 12:51:50 +08:00
"strings"
"time"
"git.apinb.com/ops/logs/internal/config"
"git.apinb.com/ops/logs/internal/impl"
"git.apinb.com/ops/logs/internal/models"
2026-07-21 02:32:18 +08:00
"gorm.io/gorm"
"gorm.io/gorm/clause"
2026-06-26 12:51:50 +08:00
)
const (
2026-07-21 02:32:18 +08:00
outboxStatusPending = "pending"
outboxStatusProcessing = "processing"
outboxStatusRetrying = "retrying"
outboxStatusSent = "sent"
outboxStatusDead = "dead"
2026-06-26 12:51:50 +08:00
)
2026-07-21 02:32:18 +08:00
func enqueueAlertWithDB(db *gorm.DB, logEventID uint, body AlertReceiveBody) (uint, error) {
2026-06-26 12:51:50 +08:00
payload, err := json.Marshal(body)
if err != nil {
2026-07-21 02:32:18 +08:00
return 0, err
2026-06-26 12:51:50 +08:00
}
2026-07-21 02:32:18 +08:00
return enqueuePayloadWithDB(db, logEventID, string(payload))
2026-06-26 12:51:50 +08:00
}
2026-07-21 02:32:18 +08:00
func enqueueRawEventWithDB(db *gorm.DB, logEventID uint, body AlertReceiveBody, parseStatus string) (uint, error) {
2026-06-26 12:51:50 +08:00
payload, err := json.Marshal(buildRawEventIngestBody(body, parseStatus))
if err != nil {
2026-07-21 02:32:18 +08:00
return 0, err
2026-06-26 12:51:50 +08:00
}
2026-07-21 02:32:18 +08:00
return enqueuePayloadWithDB(db, logEventID, string(payload))
2026-06-26 12:51:50 +08:00
}
2026-07-21 02:32:18 +08:00
func enqueuePayloadWithDB(db *gorm.DB, logEventID uint, payloadJSON string) (uint, error) {
if db == nil {
return 0, fmt.Errorf("database is not initialized")
}
now, err := databaseNow(db)
if err != nil {
return 0, err
}
2026-06-26 12:51:50 +08:00
row := models.AlertOutbox{
LogEventID: logEventID,
PayloadJSON: payloadJSON,
Status: outboxStatusPending,
RetryCount: 0,
2026-07-21 02:32:18 +08:00
NextRetryAt: now,
2026-06-26 12:51:50 +08:00
LastError: "",
}
2026-07-21 02:32:18 +08:00
if err := db.Create(&row).Error; err != nil {
return 0, err
}
return row.ID, nil
2026-06-26 12:51:50 +08:00
}
func StartAlertDispatcher() {
2026-07-21 02:32:18 +08:00
owner := dispatcherOwner()
2026-06-26 12:51:50 +08:00
go func() {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for range ticker.C {
2026-07-21 02:32:18 +08:00
if _, err := ProcessAlertOutboxBatch(impl.DBService, 20, owner); err != nil {
log.Printf("logs: alert outbox dispatch: %v", err)
}
2026-06-26 12:51:50 +08:00
}
}()
}
2026-07-21 02:32:18 +08:00
func dispatcherOwner() string {
host, _ := os.Hostname()
return fmt.Sprintf("%s:%d:%d", host, os.Getpid(), time.Now().UnixNano())
}
// ProcessAlertOutboxBatch 原子领取并处理一批任务,可安全用于多实例 worker。
func ProcessAlertOutboxBatch(db *gorm.DB, limit int, owner string) (int, error) {
if db == nil {
return 0, fmt.Errorf("database is not initialized")
}
2026-06-26 12:51:50 +08:00
if limit <= 0 {
limit = 20
}
2026-07-21 02:32:18 +08:00
owner = strings.TrimSpace(owner)
if owner == "" {
return 0, fmt.Errorf("outbox lease owner is required")
2026-06-26 12:51:50 +08:00
}
2026-07-21 02:32:18 +08:00
processed := 0
for processed < limit {
rows, err := claimAlertOutboxBatch(db, 1, owner)
if err != nil {
return processed, err
}
if len(rows) == 0 {
break
}
if err := processOneOutbox(db, rows[0]); err != nil {
return processed, err
}
processed++
2026-06-26 12:51:50 +08:00
}
2026-07-21 02:32:18 +08:00
return processed, nil
2026-06-26 12:51:50 +08:00
}
2026-07-21 02:32:18 +08:00
func claimAlertOutboxBatch(db *gorm.DB, limit int, owner string) ([]models.AlertOutbox, error) {
var rows []models.AlertOutbox
now, err := databaseNow(db)
if err != nil {
return nil, err
}
leaseUntil := now.Add(30 * time.Second)
err = db.Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
Where("(status IN ? AND next_retry_at <= ?) OR (status = ? AND lease_until IS NOT NULL AND lease_until <= ?)", []string{outboxStatusPending, outboxStatusRetrying}, now, outboxStatusProcessing, now).
Order("id asc").Limit(limit).Find(&rows).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
ids := make([]uint, 0, len(rows))
for i := range rows {
ids = append(ids, rows[i].ID)
}
if err := tx.Model(&models.AlertOutbox{}).Where("id IN ?", ids).Updates(map[string]interface{}{
"status": outboxStatusProcessing,
"lease_until": leaseUntil,
"lease_owner": owner,
}).Error; err != nil {
return err
}
for i := range rows {
rows[i].Status = outboxStatusProcessing
rows[i].LeaseUntil = &leaseUntil
rows[i].LeaseOwner = owner
}
return nil
})
return rows, err
}
func processOneOutbox(db *gorm.DB, row models.AlertOutbox) error {
2026-06-26 12:51:50 +08:00
var body AlertReceiveBody
if err := json.Unmarshal([]byte(row.PayloadJSON), &body); err != nil {
2026-07-21 02:32:18 +08:00
now, nowErr := databaseNow(db)
if nowErr != nil {
return nowErr
}
return markOutboxDead(db, row, row.RetryCount, "invalid_payload: "+err.Error(), now)
2026-06-26 12:51:50 +08:00
}
2026-07-21 02:32:18 +08:00
forwardErr := forwardOutboxPayload(row.PayloadJSON, body)
now, err := databaseNow(db)
if err != nil {
return err
2026-06-26 12:51:50 +08:00
}
2026-07-21 02:32:18 +08:00
if forwardErr != nil {
if errors.Is(forwardErr, errAlertForwardDisabled) {
return markOutboxDead(db, row, row.RetryCount, forwardErr.Error(), now)
}
return markOutboxRetry(db, row, forwardErr.Error(), now)
}
return markOutboxSent(db, row, now)
}
func databaseNow(db *gorm.DB) (time.Time, error) {
var now time.Time
if err := db.Raw("SELECT CURRENT_TIMESTAMP").Scan(&now).Error; err != nil {
return time.Time{}, err
}
return now.UTC(), nil
2026-06-26 12:51:50 +08:00
}
func forwardOutboxPayload(payloadJSON string, legacyBody AlertReceiveBody) error {
var rawEvent RawEventIngestBody
if err := json.Unmarshal([]byte(payloadJSON), &rawEvent); err == nil && rawEvent.SourceType != "" && len(rawEvent.RawPayload) > 0 {
return forwardRawEvent(rawEvent)
}
return forwardAlert(legacyBody)
}
func forwardRawEvent(body RawEventIngestBody) error {
cfg := config.Spec.AlertForward
if cfg == nil || !cfg.Enabled || cfg.BaseURL == "" {
2026-07-21 02:32:18 +08:00
return errAlertForwardDisabled
2026-06-26 12:51:50 +08:00
}
if len(body.RawPayload) == 0 {
return fmt.Errorf("raw_payload 不能为空")
}
raw, err := json.Marshal(body)
if err != nil {
return err
}
2026-07-21 02:32:18 +08:00
result, err := postAlertPayload(cfg, "/Alert/v1/raw-events/ingest", raw)
if err != nil {
return err
}
var details struct {
ID uint `json:"id"`
RawEventID uint `json:"raw_event_id"`
AlertRecordID uint `json:"alert_record_id"`
IncidentID uint `json:"incident_id"`
ParseStatus string `json:"parse_status"`
Status string `json:"status"`
Fingerprint string `json:"fingerprint"`
}
if err := json.Unmarshal(result.Details, &details); err != nil {
return fmt.Errorf("Alert 原始事件响应 details 无效:%v请稍后重试", err)
}
if body.ParseStatus == "unparsed" {
if details.ID == 0 || (details.ParseStatus != "unparsed" && details.ParseStatus != "replayed") {
return fmt.Errorf("Alert 原始事件响应缺少持久化结果,无法确认转发成功;请稍后重试")
}
return nil
}
rawEventID := details.RawEventID
if rawEventID == 0 {
rawEventID = details.ID
}
if rawEventID == 0 || details.AlertRecordID == 0 || details.IncidentID == 0 ||
(details.ID != 0 && details.RawEventID != 0 && details.ID != details.RawEventID) ||
(details.Status != "firing" && details.Status != "resolved") || !validFingerprint(details.Fingerprint) {
return fmt.Errorf("Alert 原始事件响应缺少完整处理结果,无法确认转发成功;请稍后重试")
}
return nil
}
func markOutboxSent(db *gorm.DB, row models.AlertOutbox, now time.Time) error {
return db.Transaction(func(tx *gorm.DB) error {
result := tx.Model(&models.AlertOutbox{}).
Where("id = ? AND status = ? AND lease_owner = ?", row.ID, outboxStatusProcessing, row.LeaseOwner).
Updates(map[string]interface{}{"status": outboxStatusSent, "last_error": "", "next_retry_at": now, "lease_until": nil, "lease_owner": ""})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return nil
}
return tx.Model(&models.LogEvent{}).Where("id = ? AND dispatch_outbox_id = ?", row.LogEventID, row.ID).Updates(map[string]interface{}{
"alert_sent": true, "dispatch_status": "sent",
}).Error
})
2026-06-26 12:51:50 +08:00
}
2026-07-21 02:32:18 +08:00
func markOutboxRetry(db *gorm.DB, row models.AlertOutbox, msg string, now time.Time) error {
2026-06-26 12:51:50 +08:00
retry := row.RetryCount + 1
const maxRetry = 5
if retry > maxRetry {
2026-07-21 02:32:18 +08:00
return markOutboxDead(db, row, retry, msg, now)
2026-06-26 12:51:50 +08:00
}
backoff := time.Duration(retry*retry) * time.Second
if backoff > 60*time.Second {
backoff = 60 * time.Second
}
2026-07-21 02:32:18 +08:00
return updateClaimedOutbox(db, row, map[string]interface{}{
"status": outboxStatusRetrying, "retry_count": retry, "next_retry_at": now.Add(backoff),
"last_error": truncateError(msg, 1024), "lease_until": nil, "lease_owner": "",
}, "retrying")
2026-06-26 12:51:50 +08:00
}
2026-07-21 02:32:18 +08:00
func markOutboxDead(db *gorm.DB, row models.AlertOutbox, retry int, msg string, now time.Time) error {
return updateClaimedOutbox(db, row, map[string]interface{}{
"status": outboxStatusDead, "retry_count": retry, "next_retry_at": now,
"last_error": truncateError(msg, 1024), "lease_until": nil, "lease_owner": "",
}, "dead")
}
func updateClaimedOutbox(db *gorm.DB, row models.AlertOutbox, updates map[string]interface{}, eventStatus string) error {
return db.Transaction(func(tx *gorm.DB) error {
result := tx.Model(&models.AlertOutbox{}).
Where("id = ? AND status = ? AND lease_owner = ?", row.ID, outboxStatusProcessing, row.LeaseOwner).
Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return nil
}
return tx.Model(&models.LogEvent{}).Where("id = ? AND dispatch_outbox_id = ?", row.LogEventID, row.ID).Update("dispatch_status", eventStatus).Error
})
2026-06-26 12:51:50 +08:00
}
func truncateError(s string, n int) string {
s = strings.TrimSpace(s)
2026-07-21 02:32:18 +08:00
if n <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= n {
2026-06-26 12:51:50 +08:00
return s
}
2026-07-21 02:32:18 +08:00
return string(runes[:n])
2026-06-26 12:51:50 +08:00
}