17 KiB
Ревью iot-service (bridge → SQS → consumer → iotpg)
Ответ Opus на промпт «код-ревью IoT». Дословно, без правок.
Код не запускал и тесты не писал. Поведение paho проверил по исходникам v1.5.1 в module cache. emqx/emqx.conf вне вашего списка, но я цитирую его строки, где от них зависит вывод.
🔴 Критично
-
Подтверждение SQS до фактической записи (гонка с фоновым flusher).
iot_telemetry_store.go:127-134:flushAllзабирает буфер целиком, а фоновый flusher (:117) идёт параллельно и игнорирует ошибку.Flush(:164), вызванный изconsumer.go:116, видит пустой буфер и сразу возвращаетnil. Тогдаconsumer.go:120удаляет сообщения, пока INSERT ещё в полёте или уже провалился.- Если провал постоянный (
:149-154), строки выброшены, а сообщения уже подтверждены. Это потеря данных. - Тот же эффект в
InsertTelemetry(:345-354). При полном батче и постоянной ошибке теряются до 99 строк от сообщений, которые уже лежат вpendingи будут подтверждены. - Нужна сериализация flush и ожидание in-flight записи, а
Flushдолжен возвращать ошибку и фонового прохода.
-
Ядовитое сообщение (poison pill) валит весь батч, и его никуда не отправляют.
:144-154и:345-354классифицируют ошибку на уровне батча, а не строки. Одна плохая строка выбрасывает до 100 валидных строк того же tenant или вечно блокирует их.- Триггеры, которые проходят проверки:
- envelope без
payload(processor.go:39) даёт NULL и ошибку 23502; \u0000в JSON-строке:json.Validвhandler.go:47пропускает, JSONB отвергает (22P05);- пустые
namespaceиdevice_id:processor.go:23-27проверяет только синтаксис JSON, аEnsureTenantDB(:234) создаёт БДtenant_.
- envelope без
- В
consumer.go:116-119сообщение не подтверждается и не уходит в DLQ. СчётчикApproximateReceiveCountне читается, поэтому оно крутится вечно, если на очереди нет RedrivePolicy. - Нужны построчный fallback при постоянной ошибке, семантическая валидация envelope как
errInvalidEnvelope, и отправка в DLQ после N приёмов.
-
Остаток буфера bridge при shutdown не доставляется.
bridge.go:40-41стартует воркеры сctxот сигнала, аbridge.go:54-55вызываетcloseAndWaitуже после его отмены.sender.go:124и:132получаютcontext canceledна первой попытке, и все накопленные сообщения логируются как «dropping».- Комментарий в
main.go:123-126про «flush SQS» не соответствует поведению. - Дополнительно после
close(d.ch)(sender.go:87) поздний колбэк paho паникует наsend on closed channel(:72). Disconnect с quiesce 2 с не гарантирует остановку обработчиков.
-
Короткая недоступность SQS ведёт к потере данных.
sender.go:31и:119-145: 3 попытки с паузами 1 и 2 с, затем сообщение дропается, хотя буфер на 10 000 почти пуст.- У
SendMessage(:124) нет таймаута на попытку. Зависшее соединение блокирует все 4 воркера, канал переполняется иenqueueдропает (:70-83). - MQTT QoS1 подтверждается брокеру сразу после
enqueue(handler.go:52), то есть до записи в SQS. Сквозной гарантии at-least-once нет. - Нужны ретраи до дедлайна или shutdown и таймаут на попытку. Опционально отложенный
msg.Ack()после успешной отправки.
-
Пайплайн необратимо умирает после временной ошибки при старте, а
/healthостаётся 200.bridge.go:27-29иconsumer.go:29-32: ошибкаGetQueueUrlфатальна без ретрая.bridge.go:105-111: таймаут подключения 30 с приConnectRetry=trueвозвращает ошибку, но paho продолжает подключаться в фоне. Это утечка клиента и повод для паники на закрытом канале из п.3.main.go:109-120только логирует «stopped». Процесс не завершается и не перезапускается, а/healthвrouter.go:69всегда отдаёт 200.- Нужны ретрай старта с backoff или завершение процесса с кодом ≠ 0.
-
Bridge «глохнет» после неудачной подписки.
bridge.go:92и:97вызываютc.Disconnect(100)«для реконнекта». В paho это явное отключение (client.go:460), автореконнект после него не срабатывает.- Подписки нет, а
/readyостаётся true.
-
DLQ и подтверждение битых envelope (
consumer.go:33-39,:81-94).- Любая ошибка
ResolveQueueURLдля DLQ, даже временная, навсегда превращаетdlqURLв"", и битые сообщения подтверждаются и теряются (:88-92). - Сбой
sendToDLQ(:85-87) только логируется, затем следует подтверждение. Это потеря.
- Любая ошибка
-
MQTT-пароль устройства нигде не возвращается.
devices.go:152отдаётdeviceToResponse(device, ""), хотя комментарии (:37-39,:78) обещают отдать пароль один раз при создании. GET тоже отдаёт"".- Сгенерированный пароль не получить, устройства не смогут подключиться. Это блокер и для Terraform.
🟡 Важно
-
JWT (
middleware/auth.go).:140при отсутствииexpтокен вечный. Нужно требоватьexp, желательно проверятьnbf,issиaud.:57отдаёт 403 на невалидный и просроченный токен. Должно быть 401, иначе клиент не отличит протухший токен от чужого namespace.:38-42иconfig.go:121:AUTH_TEST_MODEобходит проверку namespace и работает даже при заданном секрете. Лучше запретить при заданномJWT_HMAC_SECRETили громко логировать.:92в тестовом режиме JWT-подобные токены с пустым секретом принимаются без подписи.:61жёсткоеsub == namespace. Проверьте, что платформа выдаёт именно такие токены, иначе Terraform получит 403.
-
Изоляция tenant и валидация входа.
iot_telemetry_store.go:618-620:a-bиa_bдают одну БДtenant_a_b. Длинные namespace (>56 символов) усекаются PG до 63 байт и тоже сливаются.devices.go:106-113: нет проверкиname,device_idи namespace.device_idвида+,#,/превращает ACL-топик (emqx.conf:75) в wildcard._в namespace делает usernamens_deviceнеоднозначным (emqx.conf:46,LIMIT 1безORDER BY). Станет 🔴, если namespace допускают_.- Префикс
__bridgeне зарезервирован.
-
Consumer: давление и дубли.
consumer.go:99-105: паузы 1…60 с внутри цикла по батчу при visibility 120 с (config.go:116) дают redelivery ещё не обработанных сообщений. Спасает только дедуп.:116-119: после ошибки flush нет backoff, цикл крутится «горячо» и вычерпывает очередь в память. Во время простоя PG буфер растёт и накапливает дубликаты строк (store:147,:348). Он не ограничен.processor.go:30-32: ошибкиEnsureTenantDBне классифицируются, постоянные ошибки крутятся бесконечно.
-
tsи порядок.handler.go:57пишетreceived_atс точностью до секунды.iot_telemetry_store.go:410и:415:ORDER BY ts DESCбез tiebreaker плюс OFFSET даёт нестабильную пагинацию. Нужны RFC3339Nano иid DESC.
-
Дедуп.
handler.go:58:message_idсоздаётся на каждый приём из MQTT, поэтому дубль QoS1 (DUP после реконнекта) получает новый uuid и не дедуплицируется. Дедуп защищает только от повторной доставки SQS и ретраев отправки.store:190-192: пустойmessage_idпревращается в NULL, дедупа нет. Лучше отвергать такие envelope или братьMessageIdиз SQS как запасной ключ.
-
Readiness.
main.go:107и:116выставляютready=trueдо старта, а не после подключения MQTT и подписки.- Потеря соединения MQTT (
bridge.go:81), PG и SQS на/readyне влияют.
-
Shutdown.
main.go:129не ограничиваетwg.Waitпо времени, а платформа убьёт процесс раньше.main.go:138:os.Exit(1)обходит defer-ы, то есть Close и flush.iot_telemetry_store.go:546:Closeделает одну попытку flush, строки после retryable-ошибки теряются при выходе.Closeне идемпотентен.
-
MQTT.
bridge.go:75-77:MaxReconnectIntervalпо умолчанию 10 минут. Нужно ~30 с.- Фиксированный
ClientIDпри rolling-деплое с перекрытием даёт takeover и флаппинг двух инстансов.
-
replaceDSNDatabase(:596-613).- Работает только с URL-DSN. Для key=value DSN молча подставляет management-БД, и телеметрия всех tenant смешивается.
LastIndex("/")ломается наsslrootcert=/pathв query (типично для RDS).- Пулы tenant (
:559-591) не вытесняются, по 2 idle-соединения на tenant, и это упрётся вmax_connections.
-
AWS-совместимость (
sqsclient.go:19-27).- Только статические ключи: нет session token, IRSA и default chain.
SQS_ENDPOINTнельзя оставить пустым (config.go:109), без override реальный AWS не используется.- Нет таймаутов HTTP и настроек ретрайера.
SQS_LONG_POLL_SECONDSне ограничен 20 (consumer.go:50).- Envelope >250 КБ дропается без DLQ (
sender.go:101-110).
-
store/devices.go:19-21: INSERT не пишетphase.- Устройство, созданное с
enabled=false, лежит в БД какActive. - При этом ответ POST считает
Disabled(devices.go:127-129), и GET потом даёт другое значение.
- Устройство, созданное с
-
P0-3 (коммент).
- Хеширование пароля затронет
EnsureBridgeDevice(bridge.go:20-26, перезапись при каждом старте) иpassword_hash_algorithmвemqx.conf:42-46. Менять нужно согласованно. tenant_credentials.pg_password(:285) тоже хранится открыто и нигде не используется для подключения.
- Хеширование пароля затронет
🟢 Мелочи
handler/mqtt_auth.goне подключён вrouter.go, то есть это мёртвый код. Если его подключить, он небезопасен: доверяет клиентскомуclientid(:123) и разрешает subscribe на#(:144). Лучше удалить.admin.go:23: токен сравнивается не constant-time.- Нет
MaxBytesReaderиDisallowUnknownFields(devices.go:102,:233). main.go:95-99: нетWriteTimeoutиIdleTimeout.processor.go:43пишет «telemetry saved», хотя данные только буферизованы. Это Info на каждое сообщение.logging.go:27логирует пробы здоровья.consumer.go:75-79: пустое тело подтверждается без DLQ.consumer.go:120: сообщения удаляются по одному. ЕстьDeleteMessageBatch.- При ошибке flush DLQ-сообщения отправляются повторно, и в DLQ появляются дубли.
defaults.go:6обещает/metrics, маршрута нет. Нет счётчиков дропов (sender.go:45), глубины буфера и DLQ.- Мёртвый код:
ns=="default"вdevices.go, nil-проверкиIoTPG,NewFromEnv. iot_telemetry_store.go:279-291: сбой междуCREATE DATABASEи записью credentials делает tenant невидимым вGetAdminStats(:460).
Что стабилизировать в REST для Terraform
- Пароль. Вернуть
mqtt_passwordодин раз в 201 на POST, иначе ресурс не создать (п.8). Добавить ротацию (отдельный endpoint) или пароль, задаваемый клиентом. Решите заранее, что sensitive и что считается drift. - Идентичность ответов POST, GET и PATCH.
- Сейчас при создании
created_atпуст, потому что устройство не перечитывается из БД (devices.go:152). created_atв формате2006-01-02 15:04:05 UTC(:73) нужно привести к RFC3339, какlast_connected.phaseдолжен совпадать (см. 🟡 про INSERT).- Пустой
metadata({}) не должен пропадать: из-заomitempty(:50) возникает drift{}против null.
- Сейчас при создании
- Поля.
- Детерминированные,
ForceNew:namespace,name,device_id. Вычисляемые, стабильные:mqtt_username(ns_device) иphase. - Нестабильное, только computed:
last_connected. topic_prefix(:66) даётns/, а реальный топикns/telemetry/{device_id}. Нужен явныйtelemetry_topic.
- Детерминированные,
- Изменяемость. PATCH меняет только
enabled,metadataобновить нельзя. Либо добавить update метаданных, либо зафиксироватьmetadataкакForceNew. - Контракт ошибок.
- Токен: 401 против 403 (п. JWT выше).
- Ошибки middleware (
auth.go:27,:57) идут какtext/plain, а не JSON. - 409 не различает конфликт
nameиdevice_id. - Валидация входа возвращает 400 с понятным кодом и полем.
- Нужны единые
error-структуры и идемпотентный DELETE (404 = «уже удалено»).
- Валидация. Регулярки для
nameиdevice_id, максимальная длина, запрет+,#,/и_в namespace. - Import. GET по
{namespace}/{name}уже есть, нужен стабильный ID. - List и telemetry. Ограничения
limitиoffsetмолча игнорируются при ошибке парсинга (devices.go:163-164). Нужны явный потолок,totalили cursor и tiebreaker сортировки. - Delete. Телеметрия при удалении устройства остаётся, это нужно зафиксировать в документации API.