P0-8: идемпотентность телеметрии (dedup по message_id)
Envelope получил MessageID (UUID); таблица iot_telemetry — столбец message_id + UNIQUE(device_id,message_id); батч-INSERT ON CONFLICT DO NOTHING. Дубли при at-least-once повторе из SQS больше не вставляются. Пустой message_id → NULL (без ложного дедупа).
This commit is contained in:
@@ -50,9 +50,20 @@
|
|||||||
- `cmd/iot-service/main.go` — bridge/consumer поднимают/опускают свои флаги;
|
- `cmd/iot-service/main.go` — bridge/consumer поднимают/опускают свои флаги;
|
||||||
если горутина завершилась (ошибка/падение), `/ready` вернёт 503.
|
если горутина завершилась (ошибка/падение), `/ready` вернёт 503.
|
||||||
|
|
||||||
|
### P0-8 — идемпотентность телеметрии (dedup по message_id)
|
||||||
|
- `internal/service/bridge/handler.go`: в `TelemetryEnvelope` добавлен `MessageID`
|
||||||
|
(UUID на каждое MQTT-сообщение).
|
||||||
|
- `internal/service/consumer/processor.go`: `MessageID` передаётся в `InsertTelemetry`.
|
||||||
|
- `internal/storage/iotpg/iot_telemetry_store.go`: в таблицу добавлен столбец
|
||||||
|
`message_id` + UNIQUE INDEX `(device_id, message_id)`; батч-INSERT теперь
|
||||||
|
`ON CONFLICT DO NOTHING`. Пустой message_id → NULL (NULL в unique-индексе не
|
||||||
|
конфликтует → без ложного дедупа). При at-least-once повторе из SQS дубликат
|
||||||
|
не вставляется.
|
||||||
|
- Легаси `cmd/sqs-consumer`: вызов обновлён (`""`), файл помечен к удалению (P2-14).
|
||||||
|
|
||||||
## Осталось (не сделано)
|
## Осталось (не сделано)
|
||||||
- P0-3 часть 1 (хэш паролей + EMQX), P0-8 (идемпотентность), P1-11/P1-12
|
- P0-3 часть 1 (хэш паролей + EMQX), P1-11/P1-12 (тесты/CI), P2-14
|
||||||
(тесты/CI), P2-14 (вычистить легаси `internal/api/`).
|
(вычистить легаси `internal/api/`, `cmd/sqs-consumer`).
|
||||||
- Платформенные проблемы (wss ~150с, MSS/MTU) — вне кода.
|
- Платформенные проблемы (wss ~150с, MSS/MTU) — вне кода.
|
||||||
|
|
||||||
## Проверки
|
## Проверки
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ func processSQSTelemetry(ctx context.Context, body string, store *iotpg.IoTPostg
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := store.InsertTelemetry(ctx, envelope.Namespace, envelope.DeviceID, envelope.Payload); err != nil {
|
if err := store.InsertTelemetry(ctx, envelope.Namespace, envelope.DeviceID, "", envelope.Payload); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TelemetryEnvelope — сообщение, перекладываемое в SQS (разбирается consumer).
|
// TelemetryEnvelope — сообщение, перекладываемое в SQS (разбирается consumer).
|
||||||
@@ -20,6 +21,9 @@ type TelemetryEnvelope struct {
|
|||||||
Topic string `json:"topic"`
|
Topic string `json:"topic"`
|
||||||
Payload json.RawMessage `json:"payload"`
|
Payload json.RawMessage `json:"payload"`
|
||||||
ReceivedAt string `json:"received_at"`
|
ReceivedAt string `json:"received_at"`
|
||||||
|
// MessageID — уникальный id сообщения. Нужен для идемпотентности: SQS
|
||||||
|
// at-least-once может доставить дубликат — consumer дедуплицирует по нему.
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// newMessageHandler возвращает обработчик MQTT-сообщений:
|
// newMessageHandler возвращает обработчик MQTT-сообщений:
|
||||||
@@ -51,6 +55,7 @@ func newMessageHandler(dispatcher *sqsDispatcher, log *slog.Logger) mqtt.Message
|
|||||||
Topic: topic,
|
Topic: topic,
|
||||||
Payload: rawPayload,
|
Payload: rawPayload,
|
||||||
ReceivedAt: time.Now().UTC().Format(time.RFC3339),
|
ReceivedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
MessageID: uuid.NewString(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func processTelemetry(ctx context.Context, body string, store *iotpg.IoTPostgres
|
|||||||
if err := store.EnsureTenantDB(ctx, envelope.Namespace); err != nil {
|
if err := store.EnsureTenantDB(ctx, envelope.Namespace); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := store.InsertTelemetry(ctx, envelope.Namespace, envelope.DeviceID, envelope.Payload); err != nil {
|
if err := store.InsertTelemetry(ctx, envelope.Namespace, envelope.DeviceID, envelope.MessageID, envelope.Payload); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ type IoTPostgresStore struct {
|
|||||||
// telemetryInsert — строка для батч-вставки.
|
// telemetryInsert — строка для батч-вставки.
|
||||||
type telemetryInsert struct {
|
type telemetryInsert struct {
|
||||||
deviceID string
|
deviceID string
|
||||||
|
messageID string
|
||||||
payload []byte
|
payload []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,17 +158,25 @@ func (s *IoTPostgresStore) flushTenant(ns string, rows []telemetryInsert) error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// INSERT INTO iot_telemetry (device_id, payload) VALUES ($1,$2),($3,$4),...
|
// INSERT INTO iot_telemetry (device_id, payload, message_id) VALUES ($1,$2,$3),...
|
||||||
|
// ON CONFLICT DO NOTHING — дедуп по (device_id, message_id) при at-least-once
|
||||||
|
// повторе из SQS (P0-8). Пустой message_id вставляется как NULL (NULL в
|
||||||
|
// уникальном индексе не конфликтует → без ложного дедупа).
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString("INSERT INTO iot_telemetry (device_id, payload) VALUES ")
|
sb.WriteString("INSERT INTO iot_telemetry (device_id, payload, message_id) VALUES ")
|
||||||
args := make([]any, 0, len(rows)*2)
|
args := make([]any, 0, len(rows)*3)
|
||||||
for i, r := range rows {
|
for i, r := range rows {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
sb.WriteString(",")
|
sb.WriteString(",")
|
||||||
}
|
}
|
||||||
sb.WriteString(fmt.Sprintf("($%d,$%d)", i*2+1, i*2+2))
|
sb.WriteString(fmt.Sprintf("($%d,$%d,$%d)", i*3+1, i*3+2, i*3+3))
|
||||||
args = append(args, r.deviceID, r.payload)
|
var mid any
|
||||||
|
if r.messageID != "" {
|
||||||
|
mid = r.messageID
|
||||||
}
|
}
|
||||||
|
args = append(args, r.deviceID, r.payload, mid)
|
||||||
|
}
|
||||||
|
sb.WriteString(" ON CONFLICT DO NOTHING")
|
||||||
_, err = tenantDB.ExecContext(ctx, sb.String(), args...)
|
_, err = tenantDB.ExecContext(ctx, sb.String(), args...)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -272,11 +281,16 @@ func (s *IoTPostgresStore) EnsureTenantDB(ctx context.Context, namespace string)
|
|||||||
CREATE TABLE IF NOT EXISTS iot_telemetry (
|
CREATE TABLE IF NOT EXISTS iot_telemetry (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
device_id TEXT NOT NULL,
|
device_id TEXT NOT NULL,
|
||||||
|
message_id TEXT,
|
||||||
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
payload JSONB NOT NULL
|
payload JSONB NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_iot_telemetry_device_ts
|
CREATE INDEX IF NOT EXISTS idx_iot_telemetry_device_ts
|
||||||
ON iot_telemetry (device_id, ts DESC);
|
ON iot_telemetry (device_id, ts DESC);
|
||||||
|
-- Миграция существующих таблиц + уникальный ключ дедупликации (P0-8).
|
||||||
|
ALTER TABLE iot_telemetry ADD COLUMN IF NOT EXISTS message_id TEXT;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uniq_iot_telemetry_device_msg
|
||||||
|
ON iot_telemetry (device_id, message_id);
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -288,10 +302,13 @@ ON iot_telemetry (device_id, ts DESC);
|
|||||||
// InsertTelemetry ставит строку в батч-буфер тенанта.
|
// InsertTelemetry ставит строку в батч-буфер тенанта.
|
||||||
// При накоплении telemetryBatchSize строк батч пишется синхронно (ошибка
|
// При накоплении telemetryBatchSize строк батч пишется синхронно (ошибка
|
||||||
// возвращается вызывающему); неполные батчи дописывает фоновый flusher.
|
// возвращается вызывающему); неполные батчи дописывает фоновый flusher.
|
||||||
func (s *IoTPostgresStore) InsertTelemetry(ctx context.Context, namespace, deviceID string, payload json.RawMessage) error {
|
// messageID — уникальный id сообщения для дедупликации (может быть пустым →
|
||||||
|
// тогда строка не участвует в дедупе).
|
||||||
|
func (s *IoTPostgresStore) InsertTelemetry(ctx context.Context, namespace, deviceID, messageID string, payload json.RawMessage) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.batches[namespace] = append(s.batches[namespace], telemetryInsert{
|
s.batches[namespace] = append(s.batches[namespace], telemetryInsert{
|
||||||
deviceID: deviceID,
|
deviceID: deviceID,
|
||||||
|
messageID: messageID,
|
||||||
payload: append([]byte(nil), payload...),
|
payload: append([]byte(nil), payload...),
|
||||||
})
|
})
|
||||||
full := len(s.batches[namespace]) >= telemetryBatchSize
|
full := len(s.batches[namespace]) >= telemetryBatchSize
|
||||||
|
|||||||
Reference in New Issue
Block a user