372 lines
13 KiB
Go
372 lines
13 KiB
Go
package inbound
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Recovery only replays API-owned pending deliveries; Redis recovery locks and
|
|
// presence snapshots prevent multiple Gateway instances from racing the replay.
|
|
|
|
type pendingDeliveryRequest struct {
|
|
Account string `json:"account"`
|
|
Limit int `json:"limit,omitempty"`
|
|
ClaimID string `json:"claimId"`
|
|
LeaseMS int `json:"leaseMs"`
|
|
}
|
|
|
|
type pendingDelivery struct {
|
|
ID string `json:"id"`
|
|
DeliveryType string `json:"deliveryType"`
|
|
Payload json.RawMessage `json:"payload"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
ClaimID string `json:"claimId"`
|
|
}
|
|
|
|
type pendingFlushResult struct {
|
|
Account string
|
|
Deliveries int
|
|
DeliveredCount int
|
|
FailedCount int
|
|
WaitingCount int
|
|
LastError string
|
|
}
|
|
|
|
type pendingFlushCall struct {
|
|
done chan struct{}
|
|
result pendingFlushResult
|
|
err error
|
|
}
|
|
|
|
var pendingFlushSingleflight = struct {
|
|
sync.Mutex
|
|
byAccount map[string]*pendingFlushCall
|
|
}{byAccount: make(map[string]*pendingFlushCall)}
|
|
|
|
type pendingFlushSchedule struct {
|
|
first time.Time
|
|
timer *time.Timer
|
|
}
|
|
|
|
var pendingFlushDebouncer = struct {
|
|
sync.Mutex
|
|
byAccount map[string]*pendingFlushSchedule
|
|
}{byAccount: make(map[string]*pendingFlushSchedule)}
|
|
|
|
func (s Server) schedulePendingFlush(account string, logger *log.Logger) {
|
|
account = strings.TrimSpace(account)
|
|
if account == "" {
|
|
return
|
|
}
|
|
pendingFlushDebouncer.Lock()
|
|
if scheduled := pendingFlushDebouncer.byAccount[account]; scheduled != nil {
|
|
if time.Since(scheduled.first) < 250*time.Millisecond {
|
|
scheduled.timer.Reset(25 * time.Millisecond)
|
|
}
|
|
pendingFlushDebouncer.Unlock()
|
|
return
|
|
}
|
|
scheduled := &pendingFlushSchedule{first: time.Now()}
|
|
scheduled.timer = time.AfterFunc(25*time.Millisecond, func() {
|
|
pendingFlushDebouncer.Lock()
|
|
if pendingFlushDebouncer.byAccount[account] == scheduled {
|
|
delete(pendingFlushDebouncer.byAccount, account)
|
|
}
|
|
pendingFlushDebouncer.Unlock()
|
|
if _, err := s.flushPending(account, logger); err != nil {
|
|
logger.Printf("cmpp inbound event=scheduled_pending_flush_failed account=%s error=%q", account, err.Error())
|
|
}
|
|
})
|
|
pendingFlushDebouncer.byAccount[account] = scheduled
|
|
pendingFlushDebouncer.Unlock()
|
|
}
|
|
|
|
func (s Server) flushPending(account string, logger *log.Logger) (pendingFlushResult, error) {
|
|
account = strings.TrimSpace(account)
|
|
result := pendingFlushResult{Account: account}
|
|
if account == "" {
|
|
return result, nil
|
|
}
|
|
pendingFlushSingleflight.Lock()
|
|
if active := pendingFlushSingleflight.byAccount[account]; active != nil {
|
|
pendingFlushSingleflight.Unlock()
|
|
<-active.done
|
|
return active.result, active.err
|
|
}
|
|
call := &pendingFlushCall{done: make(chan struct{})}
|
|
pendingFlushSingleflight.byAccount[account] = call
|
|
pendingFlushSingleflight.Unlock()
|
|
call.result, call.err = s.flushPendingClaimed(account, logger)
|
|
pendingFlushSingleflight.Lock()
|
|
delete(pendingFlushSingleflight.byAccount, account)
|
|
close(call.done)
|
|
pendingFlushSingleflight.Unlock()
|
|
return call.result, call.err
|
|
}
|
|
|
|
func (s Server) flushPendingClaimed(account string, logger *log.Logger) (pendingFlushResult, error) {
|
|
result := pendingFlushResult{Account: account}
|
|
claimID := fmt.Sprintf("%s:%s:%d", s.gatewayInstanceID(), account, time.Now().UnixNano())
|
|
var deliveries []pendingDelivery
|
|
if err := s.post(context.Background(), "/gateway/events/downstream/pending", pendingDeliveryRequest{Account: account, Limit: 100, ClaimID: claimID, LeaseMS: 30000}, &deliveries); err != nil {
|
|
logger.Printf("cmpp inbound pending delivery fetch failed account=%s err=%v", account, err)
|
|
result.LastError = err.Error()
|
|
return result, err
|
|
}
|
|
result.Deliveries = len(deliveries)
|
|
for _, delivery := range deliveries {
|
|
delivery.ClaimID = defaultString(delivery.ClaimID, claimID)
|
|
sendResult, err := s.pushPendingDelivery(account, delivery)
|
|
if err != nil {
|
|
result.FailedCount++
|
|
result.LastError = err.Error()
|
|
_ = s.post(context.Background(), "/gateway/events/downstream/failed", map[string]any{
|
|
"id": delivery.ID, "claimId": delivery.ClaimID, "errorMessage": err.Error(), "failureType": "send_failed",
|
|
"connectionId": sendResult.ConnectionID, "sequenceId": sendResult.SequenceID,
|
|
"messageId": sendResult.MessageID, "sentAt": sendResult.SentAt,
|
|
}, nil)
|
|
continue
|
|
}
|
|
if sendResult.Sent {
|
|
result.DeliveredCount++
|
|
continue
|
|
}
|
|
if sendResult.ReasonCode == "SUBMIT_RESPONSE_PENDING" {
|
|
result.WaitingCount++
|
|
_ = s.releasePendingClaim(delivery, sendResult)
|
|
continue
|
|
}
|
|
errorMessage := defaultString(sendResult.ErrorMessage, "gateway did not complete downstream delivery")
|
|
failureType := "unrecoverable"
|
|
if sendResult.Retryable {
|
|
failureType = "send_failed"
|
|
result.WaitingCount++
|
|
} else {
|
|
result.FailedCount++
|
|
}
|
|
result.LastError = errorMessage
|
|
if sendResult.Retryable {
|
|
_ = s.releasePendingClaim(delivery, sendResult)
|
|
continue
|
|
}
|
|
_ = s.post(context.Background(), "/gateway/events/downstream/failed", map[string]any{
|
|
"id": delivery.ID, "claimId": delivery.ClaimID, "errorMessage": errorMessageWithCode(errorMessage, sendResult.ReasonCode),
|
|
"failureType": failureType, "connectionId": sendResult.ConnectionID,
|
|
"sequenceId": sendResult.SequenceID, "messageId": sendResult.MessageID, "sentAt": sendResult.SentAt,
|
|
}, nil)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s Server) releasePendingClaim(delivery pendingDelivery, sendResult DownstreamSendResult) error {
|
|
return s.post(context.Background(), "/gateway/events/downstream/failed", map[string]any{
|
|
"id": delivery.ID, "claimId": delivery.ClaimID,
|
|
"errorMessage": errorMessageWithCode(defaultString(sendResult.ErrorMessage, "Gateway released downstream delivery claim"), sendResult.ReasonCode),
|
|
"failureType": "claim_released",
|
|
}, nil)
|
|
}
|
|
|
|
func (s Server) pushPendingDelivery(account string, delivery pendingDelivery) (DownstreamSendResult, error) {
|
|
switch delivery.DeliveryType {
|
|
case "receipt":
|
|
var event DownstreamReceipt
|
|
if err := json.Unmarshal(delivery.Payload, &event); err != nil {
|
|
return DownstreamSendResult{}, err
|
|
}
|
|
event.DeliveryID = delivery.ID
|
|
event.ClaimID = delivery.ClaimID
|
|
event.Account = defaultString(event.Account, account)
|
|
allowRecovery := !delivery.CreatedAt.IsZero() && time.Since(delivery.CreatedAt) >= 5*time.Second
|
|
return pushReceiptWithResult(event, allowRecovery)
|
|
case "uplink":
|
|
var event DownstreamUplink
|
|
if err := json.Unmarshal(delivery.Payload, &event); err != nil {
|
|
return DownstreamSendResult{}, err
|
|
}
|
|
event.DeliveryID = delivery.ID
|
|
event.ClaimID = delivery.ClaimID
|
|
event.Account = defaultString(event.Account, account)
|
|
return PushUplinkWithResult(event)
|
|
default:
|
|
return DownstreamSendResult{}, fmt.Errorf("unsupported downstream delivery type %q", delivery.DeliveryType)
|
|
}
|
|
}
|
|
|
|
func (s Server) runPendingFlusher(logger *log.Logger) {
|
|
ticker := time.NewTicker(s.pendingFlushInterval())
|
|
defer ticker.Stop()
|
|
for range ticker.C {
|
|
s.flushOnlineAccounts(logger)
|
|
s.recoverPendingCandidates(logger)
|
|
}
|
|
}
|
|
|
|
func (s Server) flushOnlineAccounts(logger *log.Logger) {
|
|
for _, account := range onlineAccounts() {
|
|
_, _ = s.flushPending(account, logger)
|
|
}
|
|
}
|
|
|
|
func (s Server) recoverPendingCandidates(logger *log.Logger) {
|
|
candidates, err := ListRecoveryCandidates(context.Background(), s.PresenceStore)
|
|
if err != nil {
|
|
logger.Printf("cmpp inbound recovery candidate refresh failed err=%v", err)
|
|
return
|
|
}
|
|
for _, candidate := range candidates {
|
|
account := strings.TrimSpace(candidate.Account)
|
|
if account == "" {
|
|
continue
|
|
}
|
|
activeRecovery := DownstreamRecoveryStatus{
|
|
Account: account,
|
|
GatewayInstanceID: s.gatewayInstanceID(),
|
|
}
|
|
if s.RecoveryStore != nil {
|
|
decision, recoveryErr := s.RecoveryStore.StartAccountRecovery(context.Background(), account, s.gatewayInstanceID())
|
|
if recoveryErr != nil {
|
|
logger.Printf("cmpp inbound recovery start failed account=%s err=%v", account, recoveryErr)
|
|
continue
|
|
}
|
|
if !decision.Allowed {
|
|
logger.Printf("cmpp inbound recovery skipped account=%s reason=%s", account, decision.SkipReason)
|
|
decision.Status.Account = account
|
|
decision.Status.GatewayInstanceID = s.gatewayInstanceID()
|
|
decision.Status.State = defaultString(decision.Status.State, "failed")
|
|
decision.Status.LastSkipReason = defaultString(decision.Status.LastSkipReason, decision.SkipReason)
|
|
if decision.Status.FailureCategory == "" {
|
|
decision.Status.FailureCategory = recoveryFailureCategory(decision.Status.State, "", decision.Status.LastSkipReason)
|
|
}
|
|
s.syncRecoveryStatus(logger, account, decision.Status)
|
|
continue
|
|
}
|
|
activeRecovery = decision.Status
|
|
activeRecovery.Account = account
|
|
activeRecovery.GatewayInstanceID = s.gatewayInstanceID()
|
|
}
|
|
result, flushErr := s.flushPending(account, logger)
|
|
if s.RecoveryStore != nil {
|
|
status := DownstreamRecoveryStatus{
|
|
Account: account,
|
|
GatewayInstanceID: s.gatewayInstanceID(),
|
|
LockToken: activeRecovery.LockToken,
|
|
LockOwner: defaultString(activeRecovery.LockOwner, s.gatewayInstanceID()),
|
|
LockAcquiredAt: activeRecovery.LockAcquiredAt,
|
|
LockExpiresAt: activeRecovery.LockExpiresAt,
|
|
LastAttemptAt: activeRecovery.LastAttemptAt,
|
|
}
|
|
switch {
|
|
case flushErr != nil:
|
|
status.State = "failed"
|
|
status.LastError = flushErr.Error()
|
|
status.FailureCategory = recoveryFailureCategory(status.State, status.LastError, status.LastSkipReason)
|
|
case result.WaitingCount > 0 && result.DeliveredCount == 0 && result.FailedCount == 0:
|
|
status.State = "waiting_connection"
|
|
status.LastError = "downstream client is not connected"
|
|
status.FailureCategory = recoveryFailureCategory(status.State, status.LastError, status.LastSkipReason)
|
|
case result.FailedCount > 0 && result.DeliveredCount > 0:
|
|
status.State = "partial"
|
|
status.LastError = result.LastError
|
|
status.FailureCategory = recoveryFailureCategory(status.State, status.LastError, status.LastSkipReason)
|
|
case result.FailedCount > 0:
|
|
status.State = "failed"
|
|
status.LastError = result.LastError
|
|
status.FailureCategory = recoveryFailureCategory(status.State, status.LastError, status.LastSkipReason)
|
|
default:
|
|
status.State = "success"
|
|
status.LastError = ""
|
|
status.FailureCategory = ""
|
|
}
|
|
if err := s.RecoveryStore.CompleteAccountRecovery(context.Background(), status); err != nil {
|
|
logger.Printf("cmpp inbound recovery completion failed account=%s err=%v", account, err)
|
|
if err == ErrRecoveryLockLost {
|
|
status.State = "failed"
|
|
status.LastSkipReason = "lock_lost"
|
|
status.FailureCategory = "lock_lost"
|
|
s.syncRecoveryStatus(logger, account, status)
|
|
}
|
|
} else if persisted, err := s.RecoveryStore.GetAccountRecoveryStatus(context.Background(), account); err != nil {
|
|
logger.Printf("cmpp inbound recovery status fetch failed account=%s err=%v", account, err)
|
|
} else {
|
|
s.syncRecoveryStatus(logger, account, persisted)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s Server) syncRecoveryStatus(logger *log.Logger, account string, status DownstreamRecoveryStatus) {
|
|
if err := s.post(context.Background(), "/gateway/events/downstream/recovery-status", map[string]any{
|
|
"account": status.Account,
|
|
"gatewayInstanceId": status.GatewayInstanceID,
|
|
"state": status.State,
|
|
"lockOwner": status.LockOwner,
|
|
"lockExpiresAt": formatRFC3339Nano(status.LockExpiresAt),
|
|
"lastAttemptAt": formatRFC3339Nano(status.LastAttemptAt),
|
|
"lastSuccessAt": formatRFC3339Nano(status.LastSuccessAt),
|
|
"lastFailureAt": formatRFC3339Nano(status.LastFailureAt),
|
|
"nextRetryAt": formatRFC3339Nano(status.NextRetryAt),
|
|
"attemptCount": status.AttemptCount,
|
|
"failureCategory": status.FailureCategory,
|
|
"lastError": status.LastError,
|
|
"lastSkipReason": status.LastSkipReason,
|
|
}, nil); err != nil {
|
|
logger.Printf("cmpp inbound recovery status sync failed account=%s err=%v", account, err)
|
|
}
|
|
}
|
|
|
|
func recoveryFailureCategory(state string, lastError string, lastSkipReason string) string {
|
|
if state == "success" || state == "running" {
|
|
return ""
|
|
}
|
|
if lastSkipReason == "backoff" {
|
|
return "backoff"
|
|
}
|
|
if lastSkipReason == "locked" {
|
|
return "lock_contended"
|
|
}
|
|
if lastSkipReason == "lock_lost" {
|
|
return "lock_lost"
|
|
}
|
|
if state == "waiting_connection" {
|
|
return "client_disconnected"
|
|
}
|
|
if state == "partial" {
|
|
return "partial_delivery_failed"
|
|
}
|
|
if state == "failed" && strings.TrimSpace(lastError) != "" {
|
|
return "flush_failed"
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
func (s Server) pendingFlushInterval() time.Duration {
|
|
if s.PendingFlushInterval > 0 {
|
|
return s.PendingFlushInterval
|
|
}
|
|
return time.Minute
|
|
}
|
|
|
|
func (s Server) logRecoveryCandidates(logger *log.Logger) {
|
|
candidates, err := ListRecoveryCandidates(context.Background(), s.PresenceStore)
|
|
if err != nil {
|
|
logger.Printf("cmpp inbound recovery candidates load failed err=%v", err)
|
|
return
|
|
}
|
|
if len(candidates) == 0 {
|
|
logger.Printf("cmpp inbound recovery candidates loaded count=0")
|
|
return
|
|
}
|
|
accounts := make([]string, 0, len(candidates))
|
|
for _, item := range candidates {
|
|
if strings.TrimSpace(item.Account) != "" {
|
|
accounts = append(accounts, item.Account)
|
|
}
|
|
}
|
|
logger.Printf("cmpp inbound recovery candidates loaded count=%d accounts=%s", len(candidates), strings.Join(accounts, ","))
|
|
}
|