fix: review findings (async SQS dispatcher, backoff, batch insert, shutdown order, pagination, HMAC) + loadtest fixes; v0.1.6
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
// sender.go — асинхронная отправка envelope в SQS из бриджа.
|
||||
//
|
||||
// Мотивация (Sonnet-ревью 2026-08-16, находка CRITICAL): SendMessage был
|
||||
// синхронным внутри MQTT-колбэка paho. При недоступности SQS (таймаут ~30с)
|
||||
// блокировался приём ВСЕХ MQTT-сообщений → потери телеметрии.
|
||||
//
|
||||
// Теперь: колбэк кладёт envelope в буферизованный канал НЕблокирующе
|
||||
// (переполнение — дроп со счётчиком), worker-пул отправляет в SQS с
|
||||
// ограниченными ретраями и backoff.
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/sqs"
|
||||
)
|
||||
|
||||
// Константы диспатчера.
|
||||
const (
|
||||
// dispatcherQueueSize — буфер envelope перед SQS (~100 msg/s * 100с буфера).
|
||||
dispatcherQueueSize = 10000
|
||||
// dispatcherWorkers — горутин, отправляющих в SQS.
|
||||
dispatcherWorkers = 4
|
||||
// sendMaxAttempts — попыток SendMessage на одно сообщение.
|
||||
sendMaxAttempts = 3
|
||||
|
||||
// maxSQSBodyBytes — потолок размера envelope для SQS.
|
||||
// SQS (и shared-sqs) режут сообщения >256KB — запас на оверхед.
|
||||
maxSQSBodyBytes = 250 * 1024
|
||||
)
|
||||
|
||||
// sqsDispatcher — канал + worker-пул для отправки в SQS.
|
||||
type sqsDispatcher struct {
|
||||
ch chan TelemetryEnvelope
|
||||
client *sqs.Client
|
||||
queueURL string
|
||||
log *slog.Logger
|
||||
wg sync.WaitGroup
|
||||
dropped atomic.Int64
|
||||
}
|
||||
|
||||
// newSQSDispatcher создаёт диспатчер. start() запускает worker-пул.
|
||||
func newSQSDispatcher(client *sqs.Client, queueURL string, log *slog.Logger) *sqsDispatcher {
|
||||
return &sqsDispatcher{
|
||||
ch: make(chan TelemetryEnvelope, dispatcherQueueSize),
|
||||
client: client,
|
||||
queueURL: queueURL,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// start запускает worker-пул.
|
||||
func (d *sqsDispatcher) start(ctx context.Context) {
|
||||
for i := 0; i < dispatcherWorkers; i++ {
|
||||
d.wg.Add(1)
|
||||
go func(wid int) {
|
||||
defer d.wg.Done()
|
||||
d.worker(ctx, wid)
|
||||
}(i)
|
||||
}
|
||||
}
|
||||
|
||||
// enqueue кладёт envelope в канал НЕблокирующе. false = буфер полон (дроп).
|
||||
func (d *sqsDispatcher) enqueue(e TelemetryEnvelope) bool {
|
||||
select {
|
||||
case d.ch <- e:
|
||||
return true
|
||||
default:
|
||||
n := d.dropped.Add(1)
|
||||
if n%100 == 1 {
|
||||
d.log.Error("bridge: SQS dispatch queue full, dropping",
|
||||
"namespace", e.Namespace, "device", e.DeviceID,
|
||||
"dropped_total", n)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// closeAndWait закрывает канал и ждёт завершения worker'ов (flush).
|
||||
func (d *sqsDispatcher) closeAndWait() {
|
||||
close(d.ch)
|
||||
d.wg.Wait()
|
||||
d.log.Info("bridge: SQS dispatcher stopped", "dropped_total", d.dropped.Load())
|
||||
}
|
||||
|
||||
// worker — цикл отправки из канала с ограниченными ретраями.
|
||||
func (d *sqsDispatcher) worker(ctx context.Context, wid int) {
|
||||
for e := range d.ch {
|
||||
body, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
d.log.Error("bridge: marshal envelope", "worker", wid,
|
||||
"namespace", e.Namespace, "err", err)
|
||||
continue
|
||||
}
|
||||
if len(body) > maxSQSBodyBytes {
|
||||
// Лимит SQS 256KB — такие сообщения не пройдут в принципе
|
||||
// (проверено 2026-08-16: InvalidParameterValue message size
|
||||
// exceeds the limit). Дроп с явным логом.
|
||||
d.log.Error("bridge: payload too large for SQS, dropping",
|
||||
"worker", wid, "namespace", e.Namespace,
|
||||
"device", e.DeviceID, "bytes", len(body),
|
||||
"limit_bytes", maxSQSBodyBytes)
|
||||
continue
|
||||
}
|
||||
if err := d.sendWithRetry(ctx, string(body)); err != nil {
|
||||
d.log.Error("bridge: SQS send failed after retries, dropping",
|
||||
"worker", wid, "namespace", e.Namespace,
|
||||
"device", e.DeviceID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendWithRetry — SendMessage с backoff 1с/2с/4с.
|
||||
func (d *sqsDispatcher) sendWithRetry(ctx context.Context, body string) error {
|
||||
var lastErr error
|
||||
backoff := time.Second
|
||||
for attempt := 1; attempt <= sendMaxAttempts; attempt++ {
|
||||
_, err := d.client.SendMessage(ctx, &sqs.SendMessageInput{
|
||||
QueueUrl: aws.String(d.queueURL),
|
||||
MessageBody: aws.String(body),
|
||||
})
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
if ctx.Err() != nil {
|
||||
return lastErr
|
||||
}
|
||||
if attempt < sendMaxAttempts {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return lastErr
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
backoff *= 2
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
Reference in New Issue
Block a user