fix(iot): слить остаток буфера bridge при shutdown + безопасное закрытие канала (🔴3)
- Диспатчер получил собственный ctx (не сигнальный): при shutdown воркеры успевают отправить накопленное в SQS (раньше сразу возвращали «context canceled» и дропали остаток). Флаш ограничен bridgeFlushTimeout=10с. - enqueue/closeAndWait синхронизированы мьютексом (closed-флаг): поздний колбэк paho больше не паникует на «send on closed channel».
This commit is contained in:
@@ -42,7 +42,7 @@ func Run(ctx context.Context, cfg *config.Config, sqsClient *sqs.Client, log *sl
|
|||||||
// Отправка в SQS — через диспатчер (канал + worker-пул): колбэк MQTT
|
// Отправка в SQS — через диспатчер (канал + worker-пул): колбэк MQTT
|
||||||
// не блокируется сетью (фикс CRITICAL из ревью 2026-08-16).
|
// не блокируется сетью (фикс CRITICAL из ревью 2026-08-16).
|
||||||
dispatcher := newSQSDispatcher(sqsClient, queueURL, log)
|
dispatcher := newSQSDispatcher(sqsClient, queueURL, log)
|
||||||
dispatcher.start(ctx)
|
dispatcher.start()
|
||||||
|
|
||||||
handler := newMessageHandler(dispatcher, log)
|
handler := newMessageHandler(dispatcher, log)
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ const (
|
|||||||
// sendMaxBackoff — потолок паузы между попытками (экспоненциальный backoff).
|
// sendMaxBackoff — потолок паузы между попытками (экспоненциальный backoff).
|
||||||
sendMaxBackoff = 8 * time.Second
|
sendMaxBackoff = 8 * time.Second
|
||||||
|
|
||||||
|
// bridgeFlushTimeout — сколько ждать слива остатка буфера в SQS при shutdown.
|
||||||
|
// Если SQS лежит и ретраи не завершаются, по этому таймауту ctx отменяется,
|
||||||
|
// воркеры обрывают отправку (остаток логируется как дропнутый).
|
||||||
|
bridgeFlushTimeout = 10 * time.Second
|
||||||
|
|
||||||
// maxSQSBodyBytes — потолок размера envelope для SQS.
|
// maxSQSBodyBytes — потолок размера envelope для SQS.
|
||||||
// SQS (и shared-sqs) режут сообщения >256KB — запас на оверхед.
|
// SQS (и shared-sqs) режут сообщения >256KB — запас на оверхед.
|
||||||
maxSQSBodyBytes = 250 * 1024
|
maxSQSBodyBytes = 250 * 1024
|
||||||
@@ -48,31 +53,54 @@ type sqsDispatcher struct {
|
|||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
dropped atomic.Int64
|
dropped atomic.Int64
|
||||||
|
|
||||||
|
// mu защищает закрытие канала: не даёт enqueue писать в уже закрытый ch
|
||||||
|
// (паника «send on closed channel» от позднего колбэка paho — ревью 🔴3).
|
||||||
|
mu sync.Mutex
|
||||||
|
closed bool
|
||||||
|
|
||||||
|
// ctx для отправки НЕ связан с сигнальным ctx bridge: иначе при shutdown
|
||||||
|
// воркеры сразу вернули бы «context canceled» и дропнули остаток буфера
|
||||||
|
// (ревью 🔴3). Отменяется в closeAndWait по bridgeFlushTimeout, если воркеры
|
||||||
|
// не успели слить остаток.
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
// newSQSDispatcher создаёт диспатчер. start() запускает worker-пул.
|
// newSQSDispatcher создаёт диспатчер. start() запускает worker-пул.
|
||||||
func newSQSDispatcher(client *sqs.Client, queueURL string, log *slog.Logger) *sqsDispatcher {
|
func newSQSDispatcher(client *sqs.Client, queueURL string, log *slog.Logger) *sqsDispatcher {
|
||||||
|
// ctx независимый от сигнала bridge: см. поле ctx. Отменяется только в
|
||||||
|
// closeAndWait (по таймауту), чтобы при shutdown воркеры успели слить буфер.
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
return &sqsDispatcher{
|
return &sqsDispatcher{
|
||||||
ch: make(chan TelemetryEnvelope, dispatcherQueueSize),
|
ch: make(chan TelemetryEnvelope, dispatcherQueueSize),
|
||||||
client: client,
|
client: client,
|
||||||
queueURL: queueURL,
|
queueURL: queueURL,
|
||||||
log: log,
|
log: log,
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// start запускает worker-пул.
|
// start запускает worker-пул. Воркеры используют d.ctx (независимый от сигнала).
|
||||||
func (d *sqsDispatcher) start(ctx context.Context) {
|
func (d *sqsDispatcher) start() {
|
||||||
for i := 0; i < dispatcherWorkers; i++ {
|
for i := 0; i < dispatcherWorkers; i++ {
|
||||||
d.wg.Add(1)
|
d.wg.Add(1)
|
||||||
go func(wid int) {
|
go func(wid int) {
|
||||||
defer d.wg.Done()
|
defer d.wg.Done()
|
||||||
d.worker(ctx, wid)
|
d.worker(wid)
|
||||||
}(i)
|
}(i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// enqueue кладёт envelope в канал НЕблокирующе. false = буфер полон (дроп).
|
// enqueue кладёт envelope в канал НЕблокирующе. false = буфер полон (дроп) или
|
||||||
|
// канал уже закрыт (shutdown). Под mu, чтобы не писать в закрытый ch.
|
||||||
func (d *sqsDispatcher) enqueue(e TelemetryEnvelope) bool {
|
func (d *sqsDispatcher) enqueue(e TelemetryEnvelope) bool {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
if d.closed {
|
||||||
|
return false
|
||||||
|
}
|
||||||
select {
|
select {
|
||||||
case d.ch <- e:
|
case d.ch <- e:
|
||||||
return true
|
return true
|
||||||
@@ -87,15 +115,25 @@ func (d *sqsDispatcher) enqueue(e TelemetryEnvelope) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// closeAndWait закрывает канал и ждёт завершения worker'ов (flush).
|
// closeAndWait закрывает канал и ждёт, пока воркеры сольют остаток буфера в SQS.
|
||||||
|
// Закрытие — под mu (см. поле mu): после него поздний колбэк paho не упадёт на
|
||||||
|
// «send on closed channel». Флаш ограничен bridgeFlushTimeout: если SQS лежит,
|
||||||
|
// отменяем d.ctx, чтобы sendWithRetry оборвался и wg.Wait() вернулся.
|
||||||
func (d *sqsDispatcher) closeAndWait() {
|
func (d *sqsDispatcher) closeAndWait() {
|
||||||
|
d.mu.Lock()
|
||||||
|
d.closed = true
|
||||||
close(d.ch)
|
close(d.ch)
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
t := time.AfterFunc(bridgeFlushTimeout, d.cancel)
|
||||||
d.wg.Wait()
|
d.wg.Wait()
|
||||||
|
t.Stop()
|
||||||
d.log.Info("bridge: SQS dispatcher stopped", "dropped_total", d.dropped.Load())
|
d.log.Info("bridge: SQS dispatcher stopped", "dropped_total", d.dropped.Load())
|
||||||
}
|
}
|
||||||
|
|
||||||
// worker — цикл отправки из канала с ограниченными ретраями.
|
// worker — цикл отправки из канала с ретраями. Использует d.ctx (не сигнальный),
|
||||||
func (d *sqsDispatcher) worker(ctx context.Context, wid int) {
|
// чтобы при shutdown остаток буфера успел сойти в SQS.
|
||||||
|
func (d *sqsDispatcher) worker(wid int) {
|
||||||
for e := range d.ch {
|
for e := range d.ch {
|
||||||
body, err := json.Marshal(e)
|
body, err := json.Marshal(e)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -113,7 +151,7 @@ func (d *sqsDispatcher) worker(ctx context.Context, wid int) {
|
|||||||
"limit_bytes", maxSQSBodyBytes)
|
"limit_bytes", maxSQSBodyBytes)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := d.sendWithRetry(ctx, string(body)); err != nil {
|
if err := d.sendWithRetry(d.ctx, string(body)); err != nil {
|
||||||
d.log.Error("bridge: SQS send failed after retries, dropping",
|
d.log.Error("bridge: SQS send failed after retries, dropping",
|
||||||
"worker", wid, "namespace", e.Namespace,
|
"worker", wid, "namespace", e.Namespace,
|
||||||
"device", e.DeviceID, "err", err)
|
"device", e.DeviceID, "err", err)
|
||||||
|
|||||||
Reference in New Issue
Block a user