fix: review findings (async SQS dispatcher, backoff, batch insert, shutdown order, pagination, HMAC) + loadtest fixes; v0.1.6

This commit is contained in:
“Naeel”
2026-08-16 18:24:09 +04:00
parent 090cedca67
commit 3fedf9317c
27 changed files with 581 additions and 122 deletions
@@ -36,7 +36,7 @@ func (h *Handler) ListIoTTelemetry(w http.ResponseWriter, r *http.Request) {
}
}
rows, err := h.IoTPG.QueryTelemetry(r.Context(), ns, deviceID, limit)
rows, err := h.IoTPG.QueryTelemetry(r.Context(), ns, deviceID, limit, 0)
if err != nil {
h.Log.Error("query IoT telemetry", "namespace", ns, "device", deviceID, "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to query telemetry"))
+17 -5
View File
@@ -15,6 +15,7 @@ import (
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/store"
@@ -72,12 +73,19 @@ func deviceToResponse(d *store.Device, password string) iotDeviceResponse {
}
// generateMQTTPassword — 32 случайных байта в hex (как старый контроллер).
// crypto/rand ошибку даёт только при сбое системного PRNG — 3 попытки
// (ревью 2026-08-16: не возвращать пустой пароль).
func generateMQTTPassword() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err == nil {
return hex.EncodeToString(buf), nil
} else {
lastErr = err
}
}
return hex.EncodeToString(buf), nil
return "", lastErr
}
// CreateIoTDevice — POST /v1/namespaces/{ns}/iot/devices.
@@ -142,13 +150,17 @@ func (h *Handler) CreateIoTDevice(w http.ResponseWriter, r *http.Request) {
}
// ListIoTDevices — GET /v1/namespaces/{ns}/iot/devices (без паролей).
// Пагинация: ?limit=N&offset=M (0 = без ограничения).
func (h *Handler) ListIoTDevices(w http.ResponseWriter, r *http.Request) {
ns := pathVar(r, "namespace")
if ns == "" {
ns = "default"
}
devices, err := h.Devices.List(r.Context(), ns)
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
devices, err := h.Devices.List(r.Context(), ns, limit, offset)
if err != nil {
h.Log.Error("list devices", "namespace", ns, "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to list devices"))
+7 -1
View File
@@ -25,8 +25,14 @@ func (h *Handler) ListIoTTelemetry(w http.ResponseWriter, r *http.Request) {
limit = n
}
}
offset := 0
if os := r.URL.Query().Get("offset"); os != "" {
if n, err := strconv.Atoi(os); err == nil && n >= 0 {
offset = n
}
}
rows, err := h.IoTPG.QueryTelemetry(r.Context(), ns, deviceID, limit)
rows, err := h.IoTPG.QueryTelemetry(r.Context(), ns, deviceID, limit, offset)
if err != nil {
h.Log.Error("query IoT telemetry", "namespace", ns, "device", deviceID, "err", err)
writeJSON(w, http.StatusInternalServerError, errResp("failed to query telemetry"))
+34 -9
View File
@@ -2,6 +2,8 @@
package middleware
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"log/slog"
"net/http"
@@ -12,11 +14,10 @@ import (
//
// authTestMode (env AUTH_TEST_MODE, дефолт false):
// - true — принимается любая строка без пробелов (для локальных тестов);
// - false — структурная проверка JWT (sub + exp), как в старом коде.
//
// В новой архитектуре подпись JWT не проверяется (как и раньше): внешний
// периметр обеспечивает платформа, полная валидация — на стороне шлюза.
func Auth(authTestMode bool, log *slog.Logger, next http.Handler) http.Handler {
// - false — проверка JWT: sub + exp; если задан hmacSecret — обязательна
// HS256-подпись (HMAC-SHA256, env JWT_HMAC_SECRET). Без секрета —
// структурная проверка (периметр обеспечивает платформа).
func Auth(authTestMode bool, hmacSecret string, log *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization")
if header == "" {
@@ -38,7 +39,7 @@ func Auth(authTestMode bool, log *slog.Logger, next http.Handler) http.Handler {
return
}
if err := validateJWT(token); err != nil {
if err := validateJWT(token, hmacSecret); err != nil {
log.Warn("auth: invalid token", "remote", r.RemoteAddr, "path", r.URL.Path, "reason", err.Error())
http.Error(w, `{"error":"invalid token"}`, http.StatusForbidden)
return
@@ -60,13 +61,37 @@ type jwtError struct{ msg string }
func (e *jwtError) Error() string { return e.msg }
// validateJWT проверяет структуру JWT: три части, корректный payload, sub и exp.
// Подпись НЕ проверяется — см. комментарий пакета.
func validateJWT(token string) error {
// validateJWT проверяет структуру JWT (sub + exp) и, если задан secret,
// HS256-подпись. Без secret подпись не проверяется — см. комментарий Auth.
func validateJWT(token, secret string) error {
jwtParts := strings.Split(token, ".")
if len(jwtParts) != 3 {
return &jwtError{"not a JWT: expected 3 parts"}
}
if secret != "" {
var header struct {
Alg string `json:"alg"`
}
headerBytes, err := base64.RawURLEncoding.DecodeString(jwtParts[0])
if err != nil {
headerBytes, err = base64.StdEncoding.DecodeString(jwtParts[0])
}
if err != nil || jsonUnmarshal(headerBytes, &header) != nil || header.Alg != "HS256" {
return &jwtError{"JWT must be HS256 when JWT_HMAC_SECRET is set"}
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(jwtParts[0] + "." + jwtParts[1]))
expected := mac.Sum(nil)
sig, err := base64.RawURLEncoding.DecodeString(jwtParts[2])
if err != nil {
return &jwtError{"cannot decode JWT signature"}
}
if !hmac.Equal(expected, sig) {
return &jwtError{"JWT signature mismatch"}
}
}
payload := jwtParts[1]
switch len(payload) % 4 {
case 2:
+2 -2
View File
@@ -26,7 +26,7 @@ func corsMiddleware(next http.Handler) http.Handler {
}
// NewRouter собирает все маршруты сервиса.
func NewRouter(h *handler.Handler, log *slog.Logger, authTestMode bool, version string) http.Handler {
func NewRouter(h *handler.Handler, log *slog.Logger, authTestMode bool, jwtHMACSecret string, version string) http.Handler {
r := mux.NewRouter()
// Health — для платформенных проверок контейнера.
@@ -54,7 +54,7 @@ func NewRouter(h *handler.Handler, log *slog.Logger, authTestMode bool, version
v1.HandleFunc("/namespaces/{namespace}/iot/telemetry", h.ListIoTTelemetry).Methods(http.MethodGet)
v1.Use(func(next http.Handler) http.Handler {
return middleware.Auth(authTestMode, log, next)
return middleware.Auth(authTestMode, jwtHMACSecret, log, next)
})
return corsMiddleware(middleware.Logging(log, r))
+13 -2
View File
@@ -34,16 +34,25 @@ func Run(ctx context.Context, cfg *config.Config, sqsClient *sqs.Client, log *sl
// OnConnectHandler, т.е. повторяется при каждом (ре)подключении.
// Иначе после потери сессии EMQX бридж оставался бы без подписки
// (прецедент 2026-08-16: реконнект без resubscribe → телеметрия терялась).
handler := newMessageHandler(ctx, sqsClient, queueURL, log)
//
// Отправка в SQS — через диспатчер (канал + worker-пул): колбэк MQTT
// не блокируется сетью (фикс CRITICAL из ревью 2026-08-16).
dispatcher := newSQSDispatcher(sqsClient, queueURL, log)
dispatcher.start(ctx)
handler := newMessageHandler(dispatcher, log)
client, err := connectMQTT(ctx, cfg, log, handler)
if err != nil {
dispatcher.closeAndWait()
return err
}
defer client.Disconnect(250)
<-ctx.Done()
log.Info("bridge: shutting down")
// Порядок: сначала отключить MQTT (источник), затем слить остаток в SQS.
client.Disconnect(2000)
dispatcher.closeAndWait()
return nil
}
@@ -80,10 +89,12 @@ func connectMQTT(ctx context.Context, cfg *config.Config, log *slog.Logger, hand
token := c.Subscribe(telemetryTopicFilter, 1, handler)
if !token.WaitTimeout(10 * time.Second) {
log.Error("bridge: resubscribe timeout", "filter", telemetryTopicFilter)
c.Disconnect(100) // принудительный реконнект (подписка обязательна)
return
}
if token.Error() != nil {
log.Error("bridge: resubscribe failed", "filter", telemetryTopicFilter, "err", token.Error())
c.Disconnect(100) // принудительный реконнект (подписка обязательна)
return
}
log.Info("bridge: subscribed", "filter", telemetryTopicFilter)
+10 -25
View File
@@ -1,15 +1,15 @@
// handler.go — обработчик MQTT-сообщений: envelope → SQS SendMessage.
// handler.go — обработчик MQTT-сообщений: envelope → канал диспатчера SQS.
//
// Колбэк НЕ делает сетевых вызовов: только парсинг топика и неблокирующий
// enqueue. Отправка в SQS — worker-пул sqsDispatcher (см. sender.go).
package bridge
import (
"context"
"encoding/json"
"log/slog"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/sqs"
mqtt "github.com/eclipse/paho.mqtt.golang"
)
@@ -22,15 +22,16 @@ type TelemetryEnvelope struct {
ReceivedAt string `json:"received_at"`
}
// newMessageHandler возвращает обработчик MQTT-сообщений.
func newMessageHandler(ctx context.Context, sqsClient *sqs.Client, queueURL string, log *slog.Logger) mqtt.MessageHandler {
// newMessageHandler возвращает обработчик MQTT-сообщений:
// валидация топика "{namespace}/telemetry/{deviceId}" → enqueue в диспатчер.
func newMessageHandler(dispatcher *sqsDispatcher, log *slog.Logger) mqtt.MessageHandler {
return func(_ mqtt.Client, msg mqtt.Message) {
topic := msg.Topic()
payload := msg.Payload()
// Топик: "{namespace}/telemetry/{deviceId}".
// Топик: "{namespace}/telemetry/{deviceId}". Пустые части — мусор.
parts := strings.SplitN(topic, "/", 3)
if len(parts) != 3 {
if len(parts) != 3 || parts[0] == "" || parts[2] == "" || parts[1] != "telemetry" {
log.Warn("bridge: unexpected topic format, skipping", "topic", topic)
return
}
@@ -44,28 +45,12 @@ func newMessageHandler(ctx context.Context, sqsClient *sqs.Client, queueURL stri
rawPayload = json.RawMessage(quoted)
}
envelope := TelemetryEnvelope{
dispatcher.enqueue(TelemetryEnvelope{
Namespace: ns,
DeviceID: deviceID,
Topic: topic,
Payload: rawPayload,
ReceivedAt: time.Now().UTC().Format(time.RFC3339),
}
body, err := json.Marshal(envelope)
if err != nil {
log.Error("bridge: marshal envelope", "topic", topic, "err", err)
return
}
_, err = sqsClient.SendMessage(ctx, &sqs.SendMessageInput{
QueueUrl: aws.String(queueURL),
MessageBody: aws.String(string(body)),
})
if err != nil {
log.Error("bridge: SQS SendMessage failed", "topic", topic, "err", err)
return
}
log.Info("bridge: forwarded telemetry to SQS",
"mqtt_topic", topic, "namespace", ns, "device", deviceID)
}
}
+145
View File
@@ -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
}
+8 -1
View File
@@ -43,6 +43,9 @@ type Config struct {
// Безопасность
AuthTestMode bool
// JwtHMACSecret — если задан, JWT проверяется по HS256 (HMAC-SHA256).
// Пусто — структурная проверка (как раньше, периметр = платформа).
JwtHMACSecret string
// Логирование
LogLevel slog.Level
@@ -108,12 +111,13 @@ func Load() (*Config, error) {
SQSQueueName: getEnv("SQS_QUEUE_NAME", DefaultSQSQueueName),
SQSRegion: getEnv("SQS_REGION", DefaultSQSRegion),
SQSLongPollSeconds: getEnvInt("SQS_LONG_POLL_SECONDS", 20),
SQSVisibilityTimeout: getEnvInt("SQS_VISIBILITY_TIMEOUT", 30),
SQSVisibilityTimeout: getEnvInt("SQS_VISIBILITY_TIMEOUT", 120),
MQTTUsername: os.Getenv("MQTT_USERNAME"),
MQTTPassword: os.Getenv("MQTT_PASSWORD"),
MQTTClientID: getEnv("MQTT_CLIENT_ID", DefaultMQTTClientID),
AdminStatsToken: os.Getenv("ADMIN_STATS_TOKEN"),
AuthTestMode: getEnvBool("AUTH_TEST_MODE", false),
JwtHMACSecret: os.Getenv("JWT_HMAC_SECRET"),
LogLevel: parseLogLevel(os.Getenv("LOG_LEVEL"), slog.LevelInfo),
}
@@ -124,6 +128,9 @@ func Load() (*Config, error) {
port := getEnv("MQTT_PORT", DefaultMQTTPort)
path := getEnv("MQTT_WS_PATH", DefaultMQTTWSPath)
cfg.MQTTBrokerURL = "ws://" + host + ":" + port + path
} else if !strings.HasPrefix(cfg.MQTTBrokerURL, "ws://") &&
!strings.HasPrefix(cfg.MQTTBrokerURL, "wss://") {
return nil, fmt.Errorf("MQTT_BROKER_URL must start with ws:// or wss://, got %q", cfg.MQTTBrokerURL)
}
var missing []string
+20 -1
View File
@@ -20,6 +20,9 @@ import (
// errorBackoff — пауза между попытками при ошибках SQS.
const errorBackoff = 5 * time.Second
// maxProcessBackoff — потолок backoff после ошибок обработки (PG).
const maxProcessBackoff = 60 * time.Second
// Run — бесконечный long-poll цикл потребителя.
func Run(ctx context.Context, cfg *config.Config, sqsClient *sqs.Client, store *iotpg.IoTPostgresStore, log *slog.Logger) error {
queueURL, err := sqsclient.ResolveQueueURL(ctx, sqsClient, cfg.SQSQueueName)
@@ -50,15 +53,31 @@ func Run(ctx context.Context, cfg *config.Config, sqsClient *sqs.Client, store *
continue
}
processBackoff := time.Second
for _, msg := range resp.Messages {
if msg.Body == nil {
if msg.Body == nil || *msg.Body == "" {
log.Warn("consumer: empty message body, deleting",
"message_id", aws.ToString(msg.MessageId))
if err := deleteMessage(ctx, sqsClient, queueURL, msg.ReceiptHandle, log); err != nil {
log.Error("consumer: DeleteMessage failed", "err", err, "message_id", aws.ToString(msg.MessageId))
}
continue
}
if err := processTelemetry(ctx, *msg.Body, store, log); err != nil {
log.Error("consumer: process telemetry", "err", err, "message_id", aws.ToString(msg.MessageId))
// Сообщение вернётся в очередь после visibility timeout.
// Backoff с jitter — не долбить упавший PG в цикле
// (фикс HIGH из ревью 2026-08-16: лавина ретраев).
if !sleepCtx(ctx, processBackoff) {
break
}
processBackoff *= 2
if processBackoff > maxProcessBackoff {
processBackoff = maxProcessBackoff
}
continue
}
processBackoff = time.Second
if err := deleteMessage(ctx, sqsClient, queueURL, msg.ReceiptHandle, log); err != nil {
log.Error("consumer: DeleteMessage failed", "err", err, "message_id", aws.ToString(msg.MessageId))
}
+15 -5
View File
@@ -29,12 +29,22 @@ VALUES ($1, $2, $3, $4, $5, $6)`,
return nil
}
// List возвращает устройства namespace (без сортировки по паролям — пароли на месте, но API их не отдаёт).
func (s *DeviceStore) List(ctx context.Context, namespace string) ([]Device, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT namespace, name, device_id, enabled, mqtt_password, metadata, phase,
// List возвращает устройства namespace (без паролей — API их не отдаёт).
// limit <= 0 — без ограничения; offset — смещение (пагинация API).
func (s *DeviceStore) List(ctx context.Context, namespace string, limit, offset int) ([]Device, error) {
query := `SELECT namespace, name, device_id, enabled, mqtt_password, metadata, phase,
last_connected, created_at
FROM iot_devices WHERE namespace = $1 ORDER BY name`, namespace)
FROM iot_devices WHERE namespace = $1 ORDER BY name`
args := []any{namespace}
if limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", len(args)+1)
args = append(args, limit)
}
if offset > 0 {
query += fmt.Sprintf(" OFFSET $%d", len(args)+1)
args = append(args, offset)
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("store: list devices: %w", err)
}
+11 -1
View File
@@ -4,6 +4,8 @@ import (
"context"
"database/sql"
"fmt"
"os"
"strconv"
"time"
)
@@ -39,7 +41,15 @@ func Open(ctx context.Context, dsn string) (*DeviceStore, error) {
if err != nil {
return nil, fmt.Errorf("store: open DB: %w", err)
}
db.SetMaxOpenConns(5)
// Пул устройств: дефолт 20 (ревью 2026-08-16: 5 мало для API+MQTT auth),
// переопределяется IOT_PG_MAX_CONNS.
maxConns := 20
if v := os.Getenv("IOT_PG_MAX_CONNS"); v != "" {
if n, convErr := strconv.Atoi(v); convErr == nil && n > 0 {
maxConns = n
}
}
db.SetMaxOpenConns(maxConns)
db.SetMaxIdleConns(2)
db.SetConnMaxLifetime(5 * time.Minute)
+169 -34
View File
@@ -37,9 +37,29 @@ type IoTPostgresStore struct {
adminDB *sql.DB
adminDSN string
tenants sync.Map
tenantMu sync.Map // namespace → *sync.Mutex (дедупликация открытия)
ensured sync.Map // namespace → struct{} (EnsureTenantDB уже выполнен)
log *slog.Logger
// батчинг вставок (фикс MEDIUM ревью 2026-08-16: 1000 msg/s = 1000 INSERT)
mu sync.Mutex
batches map[string][]telemetryInsert
stopFlush chan struct{}
flushWG sync.WaitGroup
}
// telemetryInsert — строка для батч-вставки.
type telemetryInsert struct {
deviceID string
payload []byte
}
// telemetryBatchSize — размер батча перед синхронным flush.
const telemetryBatchSize = 100
// telemetryFlushInterval — период фонового flush неполных батчей.
const telemetryFlushInterval = 200 * time.Millisecond
// TelemetryRow — одна запись телеметрии из таблицы iot_telemetry.
type TelemetryRow struct {
ID int64 `json:"id"`
@@ -60,19 +80,90 @@ func New(adminDSN string, log *slog.Logger) (*IoTPostgresStore, error) {
db.Close()
return nil, fmt.Errorf("iotpg: ping admin DB: %w", err)
}
db.SetMaxOpenConns(5)
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(2)
db.SetConnMaxLifetime(5 * time.Minute)
store := &IoTPostgresStore{adminDB: db, adminDSN: adminDSN, log: log}
store := &IoTPostgresStore{
adminDB: db,
adminDSN: adminDSN,
batches: make(map[string][]telemetryInsert),
stopFlush: make(chan struct{}),
log: log,
}
if err := store.initManagementSchema(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("iotpg: init management schema: %w", err)
}
store.startFlusher()
log.Info("iotpg: connected to IoT Postgres management DB")
return store, nil
}
// startFlusher — фоновая горутина периодического flush неполных батчей.
func (s *IoTPostgresStore) startFlusher() {
s.flushWG.Add(1)
go func() {
defer s.flushWG.Done()
t := time.NewTicker(telemetryFlushInterval)
defer t.Stop()
for {
select {
case <-s.stopFlush:
return
case <-t.C:
s.flushAll(false)
}
}
}()
}
// flushAll — flush всех накопленных батчей. reportErr=false: только лог.
func (s *IoTPostgresStore) flushAll(reportErr bool) error {
s.mu.Lock()
if len(s.batches) == 0 {
s.mu.Unlock()
return nil
}
batches := s.batches
s.batches = make(map[string][]telemetryInsert)
s.mu.Unlock()
var firstErr error
for ns, rows := range batches {
if err := s.flushTenant(ns, rows); err != nil {
s.log.Error("iotpg: flush batch", "namespace", ns, "rows", len(rows), "err", err)
if firstErr == nil {
firstErr = err
}
}
}
return firstErr
}
// flushTenant — INSERT батча строк одного тенанта (multi-VALUES).
func (s *IoTPostgresStore) flushTenant(ns string, rows []telemetryInsert) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
tenantDB, err := s.getTenantDB(ctx, ns)
if err != nil {
return err
}
// INSERT INTO iot_telemetry (device_id, payload) VALUES ($1,$2),($3,$4),...
var sb strings.Builder
sb.WriteString("INSERT INTO iot_telemetry (device_id, payload) VALUES ")
args := make([]any, 0, len(rows)*2)
for i, r := range rows {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString(fmt.Sprintf("($%d,$%d)", i*2+1, i*2+2))
args = append(args, r.deviceID, r.payload)
}
_, err = tenantDB.ExecContext(ctx, sb.String(), args...)
return err
}
// NewFromEnv создаёт store из env var IOT_PG_DSN.
// Возвращает (nil, nil) если переменная не задана — IoT Postgres опционален.
func NewFromEnv(log *slog.Logger) (*IoTPostgresStore, error) {
@@ -97,9 +188,12 @@ created_at TIMESTAMPTZ DEFAULT now()
}
// EnsureTenantDB создаёт DATABASE, USER и таблицу iot_telemetry для namespace.
// Идемпотентен — повторный вызов безопасен.
// Вызывается mqtt-bridge при первом сообщении от нового tenant.
// Идемпотентен — повторный вызов безопасен (кэш ensured пропускает проверки).
func (s *IoTPostgresStore) EnsureTenantDB(ctx context.Context, namespace string) error {
if _, ok := s.ensured.Load(namespace); ok {
return nil
}
dbName := tenantDBName(namespace)
userName := dbName
@@ -114,28 +208,39 @@ func (s *IoTPostgresStore) EnsureTenantDB(ctx context.Context, namespace string)
if !exists {
password := uuid.New().String()
// CREATE USER через DO block — pg не поддерживает CREATE USER IF NOT EXISTS
_, err = s.adminDB.ExecContext(ctx, fmt.Sprintf(
`DO $$ BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '%s') THEN
CREATE USER %s WITH PASSWORD '%s';
END IF;
END $$`, userName, userName, password,
))
// Роль создаём только если её нет (DO-блоки НЕ принимают параметры —
// прецедент 2026-08-16: "got 2 parameters but the statement requires 0").
var roleExists bool
err = s.adminDB.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = $1)`, userName,
).Scan(&roleExists)
if err != nil {
return fmt.Errorf("iotpg: create user %s: %w", userName, err)
return fmt.Errorf("iotpg: check role %s: %w", userName, err)
}
if !roleExists {
_, err = s.adminDB.ExecContext(ctx,
`CREATE USER `+pq.QuoteIdentifier(userName)+` WITH PASSWORD `+pq.QuoteLiteral(password),
)
if err != nil {
var pqErr *pq.Error
if errors.As(err, &pqErr) && pqErr.Code == "42710" { // duplicate_object
// гонка: роль создал параллельный вызов — ок
} else {
return fmt.Errorf("iotpg: create user %s: %w", userName, err)
}
}
}
// PG15+: GRANT role TO current_user перед CREATE DATABASE ... OWNER
if _, err = s.adminDB.ExecContext(ctx,
fmt.Sprintf(`GRANT %s TO CURRENT_USER`, userName),
`GRANT `+pq.QuoteIdentifier(userName)+` TO CURRENT_USER`,
); err != nil {
return fmt.Errorf("iotpg: grant role %s: %w", userName, err)
}
// CREATE DATABASE нельзя в транзакции
if _, err = s.adminDB.ExecContext(ctx,
fmt.Sprintf(`CREATE DATABASE %s OWNER %s`, dbName, userName),
`CREATE DATABASE `+pq.QuoteIdentifier(dbName)+` OWNER `+pq.QuoteIdentifier(userName),
); err != nil {
return fmt.Errorf("iotpg: create database %s: %w", dbName, err)
}
@@ -165,32 +270,50 @@ payload JSONB NOT NULL
CREATE INDEX IF NOT EXISTS idx_iot_telemetry_device_ts
ON iot_telemetry (device_id, ts DESC);
`)
return err
if err != nil {
return err
}
s.ensured.Store(namespace, struct{}{})
return nil
}
// InsertTelemetry записывает строку телеметрии в tenant DB.
// InsertTelemetry ставит строку в батч-буфер тенанта.
// При накоплении telemetryBatchSize строк батч пишется синхронно (ошибка
// возвращается вызывающему); неполные батчи дописывает фоновый flusher.
func (s *IoTPostgresStore) InsertTelemetry(ctx context.Context, namespace, deviceID string, payload json.RawMessage) error {
tenantDB, err := s.getTenantDB(ctx, namespace)
if err != nil {
return fmt.Errorf("iotpg: get tenant DB for insert: %w", err)
s.mu.Lock()
s.batches[namespace] = append(s.batches[namespace], telemetryInsert{
deviceID: deviceID,
payload: append([]byte(nil), payload...),
})
full := len(s.batches[namespace]) >= telemetryBatchSize
var rows []telemetryInsert
if full {
rows = s.batches[namespace]
delete(s.batches, namespace)
}
_, err = tenantDB.ExecContext(ctx,
`INSERT INTO iot_telemetry (device_id, payload) VALUES ($1, $2)`,
deviceID, []byte(payload),
)
return err
s.mu.Unlock()
if full {
return s.flushTenant(namespace, rows)
}
return nil
}
// QueryTelemetry читает телеметрию из tenant DB (ts DESC).
// deviceID — фильтр (пустая строка = все устройства). limit — max записей (50..1000).
// Если tenant DB не существует (данных ещё нет) — возвращает пустой срез без ошибки.
func (s *IoTPostgresStore) QueryTelemetry(ctx context.Context, namespace, deviceID string, limit int) ([]TelemetryRow, error) {
// deviceID — фильтр (пустая строка = все устройства). limit — max записей
// (50..1000), offset — смещение для пагинации.
// Если tenant DB не существует (данных ещё нет) — пустой срез без ошибки.
func (s *IoTPostgresStore) QueryTelemetry(ctx context.Context, namespace, deviceID string, limit, offset int) ([]TelemetryRow, error) {
if limit <= 0 {
limit = 50
}
if limit > 1000 {
limit = 1000
}
if offset < 0 {
offset = 0
}
tenantDB, err := s.getTenantDB(ctx, namespace)
if err != nil {
// Если DB не существует — тенант ещё не отправлял данные, это нормально
@@ -204,14 +327,14 @@ func (s *IoTPostgresStore) QueryTelemetry(ctx context.Context, namespace, device
if deviceID != "" {
rows, err = tenantDB.QueryContext(ctx,
`SELECT id, device_id, ts, payload FROM iot_telemetry
WHERE device_id = $1 ORDER BY ts DESC LIMIT $2`,
deviceID, limit,
WHERE device_id = $1 ORDER BY ts DESC LIMIT $2 OFFSET $3`,
deviceID, limit, offset,
)
} else {
rows, err = tenantDB.QueryContext(ctx,
`SELECT id, device_id, ts, payload FROM iot_telemetry
ORDER BY ts DESC LIMIT $1`,
limit,
ORDER BY ts DESC LIMIT $1 OFFSET $2`,
limit, offset,
)
}
if err != nil {
@@ -308,7 +431,6 @@ FROM iot_telemetry`).Scan(&stats.Total, &stats.Last1h, &stats.Last24h)
latestRows, err := tenantDB.QueryContext(ctx,
`SELECT id, device_id, ts, payload FROM iot_telemetry ORDER BY ts DESC LIMIT 5`)
if err == nil {
defer latestRows.Close()
for latestRows.Next() {
var r TelemetryRow
var rawPayload []byte
@@ -317,6 +439,7 @@ FROM iot_telemetry`).Scan(&stats.Total, &stats.Last1h, &stats.Last24h)
stats.Latest = append(stats.Latest, r)
}
}
latestRows.Close() // закрываем сразу, не defer в цикле
}
result.Tenants = append(result.Tenants, stats)
@@ -336,8 +459,11 @@ func isDBNotExistErr(err error) bool {
return false
}
// Close закрывает все подключения (admin + tenant кэш).
// Close останавливает flusher, дописывает остаток и закрывает все подключения.
func (s *IoTPostgresStore) Close() error {
close(s.stopFlush)
s.flushWG.Wait()
_ = s.flushAll(true)
s.tenants.Range(func(_, value any) bool {
if db, ok := value.(*sql.DB); ok {
db.Close()
@@ -348,7 +474,16 @@ func (s *IoTPostgresStore) Close() error {
}
// getTenantDB возвращает *sql.DB для tenant DB из кэша или открывает новый.
// Открытие дедуплицируется per-namespace мьютексом (гонка Load→LoadOrStore
// из ревью 2026-08-16).
func (s *IoTPostgresStore) getTenantDB(ctx context.Context, namespace string) (*sql.DB, error) {
if cached, ok := s.tenants.Load(namespace); ok {
return cached.(*sql.DB), nil
}
m, _ := s.tenantMu.LoadOrStore(namespace, &sync.Mutex{})
mu := m.(*sync.Mutex)
mu.Lock()
defer mu.Unlock()
if cached, ok := s.tenants.Load(namespace); ok {
return cached.(*sql.DB), nil
}