Author SHA1 Message Date
Naeel d7e9d90453 Add demo UI showcase mode 2026-04-12 09:58:32 +03:00
Naeel dd08427424 docs: polish user benchmark summary 2026-04-12 09:00:09 +03:00
Naeel 8933b91aa5 docs: expand benchmark coverage summary 2026-04-12 08:56:44 +03:00
Naeel 4bf3794618 docs: simplify benchmark summary 2026-04-12 08:48:43 +03:00
Naeel 1a00ec9799 docs: rename benchmark summary 2026-04-12 08:47:40 +03:00
Naeel 4a83575491 feat: persist messages separately and update ingress docs 2026-04-12 08:44:46 +03:00
Naeel d5d3519836 docs: add 32kb benchmark comparison 2026-04-12 08:44:11 +03:00
Naeel 70cfc0d83d docs: add references for 64kb investigation 2026-04-12 08:02:37 +03:00
Naeel a1ff9c4e52 docs: finalize 64kb ingress investigation 2026-04-12 07:59:04 +03:00
Naeel eba01c9580 fix: 7 performance/correctness fixes — v0.1.20
1. Убрано логирование тела сообщения (256KB I/O на каждый send — perf+security)
2. SentTimestamp исправлен: m.SentTime вместо time.Now() (баг)
3. MD5 не пересчитывается на ReceiveMessage — используется кэш из SendMessage
4. ChangeMessageVisibility/batch теперь персистит в Redis (баг — потеря данных)
5. MessageDoesNotExist error code: QueueExists → ReceiptHandleIsInvalid (copy-paste баг)
6. copystructure убран из GetQueueAttributes — простой map lookup
7. SNS dead code удалён (SnsErrors, SnsErrorType — не используется в SQS сервисе)

Tested: quick_test 31/31 PASS, deployed v0.1.20
2026-04-11 20:39:08 +03:00
27 changed files with 2169 additions and 204 deletions
+1
View File
@@ -26,3 +26,4 @@ build/
# Secrets
secrets/
goaws
+88
View File
@@ -0,0 +1,88 @@
# Сравнительный анализ shared-sqs vs Yandex MQ
**Дата:** 2026-04-12 06:05 UTC
Итог сравнительных тестов shared-sqs и Yandex MQ.
## Главное
- shared-sqs показал конкурентоспособные результаты относительно Yandex MQ.
- На основных API-операциях shared-sqs в текущем прогоне быстрее или на уровне Yandex MQ.
- На batch-операциях shared-sqs также выглядит сильно.
- Проверкой охвачены все 17 поддерживаемых SQS-операций.
## Короткий вывод
Если смотреть на практические сценарии отправки, чтения и batch-обработки сообщений, shared-sqs уже выглядит как сильная реализация с хорошей latency и без явного проигрыша managed-сервису.
## Самые важные цифры
Формат: `min / avg / max / p95`, миллисекунды.
| Операция | Yandex MQ | shared-sqs | Что это значит |
|---|---:|---:|---|
| GetQueueUrl | 766 / 1346 / 2266 / 2060 | 739 / 752 / 770 / 760 | shared-sqs намного стабильнее |
| GetQueueAttributes | 804 / 820 / 847 / 828 | 738 / 752 / 768 / 765 | shared-sqs быстрее |
| SendMessage 1KB | 804 / 811 / 824 / 823 | 748 / 760 / 770 / 767 | shared-sqs быстрее |
| SendMessage 10KB | 916 / 967 / 996 / 987 | 880 / 913 / 965 / 951 | shared-sqs быстрее |
| SendMessage 32KB | 905 / 930 / 948 / 947 | 883 / 904 / 939 / 928 | shared-sqs быстрее |
| SendMessageBatch 10 | 784 / 803 / 827 / 820 | 718 / 746 / 782 / 759 | shared-sqs быстрее |
| ReceiveMessage | 782 / 826 / 869 / 864 | 738 / 748 / 777 / 751 | shared-sqs быстрее |
| DeleteMessage | 783 / 800 / 823 / 814 | 727 / 742 / 757 / 755 | shared-sqs быстрее |
| DeleteMessageBatch 10 | 818 / 845 / 872 / 864 | 742 / 783 / 821 / 810 | shared-sqs быстрее |
| ChangeMessageVisibilityBatch 10 | 806 / 829 / 848 / 841 | 803 / 824 / 838 / 836 | почти паритет, но shared-sqs чуть быстрее |
## Покрытие тестов по всем операциям
Ниже показано покрытие по каждой из 17 операций.
Обозначения:
- `Сравнение latency` — операция вошла в прямое сравнение shared-sqs и Yandex MQ.
- `Функциональная проверка` — операция отдельно проверялась на корректную работу.
- `Функционально проверено` — операция подтверждена в shared-sqs, без отдельной публичной latency-таблицы.
| Операция | Статус проверки | Комментарий |
|---|---|---|
| CreateQueue | Функциональная проверка | Использовалась в setup и проверялась как часть queue lifecycle |
| DeleteQueue | Функциональная проверка | Проверялась как часть queue lifecycle |
| GetQueueAttributes | Сравнение latency | Полноценное сравнение с Yandex MQ |
| GetQueueUrl | Сравнение latency | Полноценное сравнение с Yandex MQ |
| ListQueues | Сравнение latency | Полноценное сравнение с Yandex MQ |
| PurgeQueue | Сравнение latency | Контрольный замер, без многократного цикла |
| SetQueueAttributes | Сравнение latency | Полноценное сравнение с Yandex MQ |
| SendMessage | Сравнение latency | Сравнение для 1KB, 10KB и 32KB |
| SendMessageBatch | Сравнение latency | Batch из 10 сообщений |
| ReceiveMessage | Сравнение latency | Полноценное сравнение с Yandex MQ |
| DeleteMessage | Сравнение latency | Полноценное сравнение с Yandex MQ |
| DeleteMessageBatch | Сравнение latency | Batch delete на 10 сообщений |
| ChangeMessageVisibility | Сравнение latency | Полноценное сравнение с Yandex MQ |
| ChangeMessageVisibilityBatch | Сравнение latency | Batch visibility на 10 сообщений |
| TagQueue | Функционально проверено | Операция поддерживается и отдельно проверена |
| UntagQueue | Функционально проверено | Операция поддерживается и отдельно проверена |
| ListQueueTags | Функционально проверено | Операция поддерживается и отдельно проверена |
Итог по покрытию:
- `10` операций вошли в прямое latency-сравнение.
- `4` queue/control-plane операции дополнительно подтверждены функциональными сценариями.
- `3` tag-операции отдельно подтверждены функционально.
## Throughput
Тест: `SendMessage 1KB`, `5` воркеров по `10` сообщений.
| Провайдер | Успешно | Общее время | Грубая оценка |
|---|---:|---:|---:|
| Yandex MQ | 50 / 50 | 9116 ms | ~5 msg/s |
| shared-sqs | 50 / 50 | 8580 ms | ~5 msg/s |
Практический смысл:
- По грубой оценке `msg/s` здесь паритет.
- По общему времени shared-sqs завершает тест немного быстрее.
- Основное ограничение этого сценария задаётся AWS CLI, а не backend обоих сервисов.
## Финальный вывод для пользователя
shared-sqs уже выглядит достаточно сильным решением: latency на уровне или ниже Yandex MQ, batch-операции быстрые, а в общей рабочей зоне сервис показывает уверенные результаты.
+146 -18
View File
@@ -1,12 +1,14 @@
// app/admin/admin.go
// Admin API handlers for shared-sqs management
// Created: 2026-04-09
// Updated: 2026-04-10 — JWT auth через nubes API, auto-provisioning тенантов
// Updated: 2026-04-12 09:28 MSK — demo UI token и изоляция UI API одним tenant-ом
package admin
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"net/http"
"os"
"strings"
@@ -22,6 +24,20 @@ import (
log "github.com/sirupsen/logrus"
)
type uiContextKey string
const (
uiTenantContextKey uiContextKey = "ui-tenant"
)
const (
defaultUIDemoToken = "demo-ui-shared-sqs-ngcloud-2026"
defaultUIDemoTenantID = "t-demo-shared-sqs-ngcloud"
defaultUIDemoEmail = "demo@shared-sqs.ngcloud"
)
var errUIDemoTokenMismatch = errors.New("demo token mismatch")
// ─── вспомогательная функция: найти очередь тенанта по имени ───────────────
// findQueue — возвращает ключ и очередь тенанта по имени, или "",nil если не найдено.
func findQueue(tenantAccessKey, queueName string) (string, *models.Queue) {
@@ -51,6 +67,61 @@ func NewHandler(store *tenant.TenantStore, adminToken string) *Handler {
return &Handler{store: store, adminToken: adminToken, nubesEndpoint: nubesEndpoint}
}
// uiDemoToken — возвращает публичный demo token для UI, если он не переопределён через env.
func (h *Handler) uiDemoToken() string {
if token := os.Getenv("SHARED_SQS_UI_DEMO_TOKEN"); token != "" {
return token
}
return defaultUIDemoToken
}
// uiDemoTenantID — возвращает tenant ID, к которому привязан demo token.
func (h *Handler) uiDemoTenantID() string {
if tenantID := os.Getenv("SHARED_SQS_UI_DEMO_TENANT_ID"); tenantID != "" {
return tenantID
}
return defaultUIDemoTenantID
}
// uiDemoEmail — возвращает отображаемый email для demo UI session.
func (h *Handler) uiDemoEmail() string {
if email := os.Getenv("SHARED_SQS_UI_DEMO_EMAIL"); email != "" {
return email
}
return defaultUIDemoEmail
}
// authenticateUIDemoToken — маппит публичный demo token на заранее сидированный demo tenant.
func (h *Handler) authenticateUIDemoToken(token string) (*tenant.Tenant, string, error) {
demoToken := h.uiDemoToken()
if demoToken == "" || subtle.ConstantTimeCompare([]byte(token), []byte(demoToken)) != 1 {
return nil, "", errUIDemoTokenMismatch
}
demoTenant, ok := h.store.GetByID(h.uiDemoTenantID())
if !ok {
return nil, "", errors.New("demo tenant unavailable — enable SHARED_SQS_SEED_DEMO=true")
}
return demoTenant, h.uiDemoEmail(), nil
}
// currentUITenant — возвращает tenant, авторизованный через UI middleware.
func currentUITenant(r *http.Request) (*tenant.Tenant, bool) {
t, ok := r.Context().Value(uiTenantContextKey).(*tenant.Tenant)
return t, ok
}
// tenantListItemFromTenant — строит публичный JSON-ответ без SecretKey.
func tenantListItemFromTenant(t *tenant.Tenant) tenantListItem {
return tenantListItem{
ID: t.ID,
Name: t.Name,
AccessKey: t.AccessKey,
MaxQueues: t.MaxQueues,
CreatedAt: t.CreatedAt,
Active: t.Active,
}
}
// RegisterRoutes — регистрирует admin маршруты на переданном router (с bearer auth)
func (h *Handler) RegisterRoutes(r *mux.Router) {
adminRouter := r.PathPrefix("/admin").Subrouter()
@@ -118,6 +189,24 @@ func (h *Handler) jwtAuth(w http.ResponseWriter, r *http.Request) {
return
}
if demoTenant, demoEmail, err := h.authenticateUIDemoToken(req.Token); err == nil {
log.Infof("ui auth: authenticated demo tenant=%s", demoTenant.ID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"email": demoEmail,
"tenant_id": demoTenant.ID,
"access_key": demoTenant.AccessKey,
"secret_key": demoTenant.SecretKey,
"max_queues": demoTenant.MaxQueues,
"token": req.Token,
})
return
} else if !errors.Is(err, errUIDemoTokenMismatch) {
log.Warnf("ui auth: demo login unavailable: %v", err)
jsonErr(w, http.StatusForbidden, err.Error())
return
}
// Парсим JWT claims
claims, err := auth.ParseJWTClaims(req.Token)
if err != nil {
@@ -182,6 +271,19 @@ func (h *Handler) jwtMiddleware(next http.Handler) http.Handler {
}
token := parts[1]
if demoTenant, _, err := h.authenticateUIDemoToken(token); err == nil {
if pathTenantID, exists := mux.Vars(r)["id"]; exists && pathTenantID != "" && pathTenantID != demoTenant.ID {
jsonErr(w, http.StatusForbidden, "forbidden tenant access")
return
}
ctx := context.WithValue(r.Context(), uiTenantContextKey, demoTenant)
next.ServeHTTP(w, r.WithContext(ctx))
return
} else if !errors.Is(err, errUIDemoTokenMismatch) {
jsonErr(w, http.StatusForbidden, err.Error())
return
}
claims, err := auth.ParseJWTClaims(token)
if err != nil {
jsonErr(w, http.StatusUnauthorized, "invalid token: "+err.Error())
@@ -210,7 +312,8 @@ func (h *Handler) jwtMiddleware(next http.Handler) http.Handler {
return
}
next.ServeHTTP(w, r)
uiCtx := context.WithValue(r.Context(), uiTenantContextKey, jwtTenant)
next.ServeHTTP(w, r.WithContext(uiCtx))
})
}
@@ -243,6 +346,10 @@ type tenantListItem struct {
// createTenant — POST /admin/tenants
func (h *Handler) createTenant(w http.ResponseWriter, r *http.Request) {
if _, ok := currentUITenant(r); ok {
jsonErr(w, http.StatusForbidden, "tenant creation via UI is disabled")
return
}
var req createTenantRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.Header().Set("Content-Type", "application/json")
@@ -279,17 +386,15 @@ func (h *Handler) createTenant(w http.ResponseWriter, r *http.Request) {
// listTenants — GET /admin/tenants
func (h *Handler) listTenants(w http.ResponseWriter, r *http.Request) {
if uiTenant, ok := currentUITenant(r); ok {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]tenantListItem{tenantListItemFromTenant(uiTenant)})
return
}
tenants := h.store.List()
items := make([]tenantListItem, 0, len(tenants))
for _, t := range tenants {
items = append(items, tenantListItem{
ID: t.ID,
Name: t.Name,
AccessKey: t.AccessKey,
MaxQueues: t.MaxQueues,
CreatedAt: t.CreatedAt,
Active: t.Active,
})
items = append(items, tenantListItemFromTenant(t))
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(items)
@@ -307,19 +412,16 @@ func (h *Handler) getTenant(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tenantListItem{
ID: t.ID,
Name: t.Name,
AccessKey: t.AccessKey,
MaxQueues: t.MaxQueues,
CreatedAt: t.CreatedAt,
Active: t.Active,
})
json.NewEncoder(w).Encode(tenantListItemFromTenant(t))
}
// deleteTenant — DELETE /admin/tenants/{id}
// Удаляет тенанта И ВСЕ его очереди из SyncQueues (Trap #11: иначе memory leak)
func (h *Handler) deleteTenant(w http.ResponseWriter, r *http.Request) {
if _, ok := currentUITenant(r); ok {
jsonErr(w, http.StatusForbidden, "tenant deletion via UI is disabled")
return
}
vars := mux.Vars(r)
id := vars["id"]
t, ok := h.store.GetByID(id)
@@ -575,6 +677,8 @@ func (h *Handler) sendMessageToQueue(w http.ResponseWriter, r *http.Request) {
}
models.SyncQueues.Lock()
models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages, msg)
// Персистим одно сообщение отдельно
persistence.SaveMessage(key, &msg)
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
models.SyncQueues.Unlock()
w.Header().Set("Content-Type", "application/json")
@@ -599,6 +703,8 @@ func (h *Handler) purgeQueue(w http.ResponseWriter, r *http.Request) {
}
models.SyncQueues.Lock()
models.SyncQueues.Queues[key].Messages = models.SyncQueues.Queues[key].Messages[:0]
// Удаляем все сообщения из Redis одной командой
persistence.PurgeMessagesPersist(key)
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
models.SyncQueues.Unlock()
log.Infof("admin: purged queue %s for tenant %s", queueName, t.ID)
@@ -622,6 +728,28 @@ type adminHealthDetail struct {
// detailedHealth — GET /admin/health
func (h *Handler) detailedHealth(w http.ResponseWriter, r *http.Request) {
if uiTenant, ok := currentUITenant(r); ok {
prefix := uiTenant.AccessKey + ":"
queueCount := 0
msgCount := 0
models.SyncQueues.RLock()
for key, q := range models.SyncQueues.Queues {
if strings.HasPrefix(key, prefix) {
queueCount++
msgCount += len(q.Messages)
}
}
models.SyncQueues.RUnlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(adminHealthDetail{
Status: "ok",
TenantCount: 1,
QueueCount: queueCount,
MessageCount: msgCount,
})
return
}
tenants := h.store.List()
models.SyncQueues.RLock()
queueCount := len(models.SyncQueues.Queues)
+84
View File
@@ -0,0 +1,84 @@
// app/admin/admin_test.go
// Focused tests for UI auth and scoping
// Created: 2026-04-12 09:28 MSK
// Updated: 2026-04-12 09:28 MSK — demo UI token and tenant-scoped UI responses
package admin
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"shared-sqs/app/tenant"
)
// TestJWTAuth_AllowsDemoToken verifies that the public demo token authenticates into the seeded demo tenant.
func TestJWTAuth_AllowsDemoToken(t *testing.T) {
t.Setenv("SHARED_SQS_UI_DEMO_TOKEN", "demo-token-for-test")
t.Setenv("SHARED_SQS_UI_DEMO_TENANT_ID", "t-demo-test")
store := tenant.NewTenantStore()
demoTenant, err := store.CreateFixed("demo-service", 10, "t-demo-test", "SSAK-demo-test", "demo-secret")
if err != nil {
t.Fatalf("CreateFixed(): %v", err)
}
h := NewHandler(store, "admin-token")
req := httptest.NewRequest(http.MethodPost, "/ui/api/auth", strings.NewReader(`{"token":"demo-token-for-test"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.jwtAuth(w, req)
if w.Code != http.StatusOK {
t.Fatalf("jwtAuth() status = %d, body = %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if got := resp["tenant_id"]; got != demoTenant.ID {
t.Fatalf("tenant_id = %v, want %s", got, demoTenant.ID)
}
if got := resp["access_key"]; got != demoTenant.AccessKey {
t.Fatalf("access_key = %v, want %s", got, demoTenant.AccessKey)
}
}
// TestListTenants_UIContextReturnsOnlyOwnTenant verifies that UI API listing is scoped to the authenticated tenant.
func TestListTenants_UIContextReturnsOnlyOwnTenant(t *testing.T) {
store := tenant.NewTenantStore()
firstTenant, err := store.CreateFixed("demo-service", 10, "t-demo-test", "SSAK-demo-test", "demo-secret")
if err != nil {
t.Fatalf("CreateFixed(first): %v", err)
}
if _, err := store.CreateFixed("other-service", 10, "t-other-test", "SSAK-other-test", "other-secret"); err != nil {
t.Fatalf("CreateFixed(second): %v", err)
}
h := NewHandler(store, "admin-token")
req := httptest.NewRequest(http.MethodGet, "/ui/api/tenants", nil)
req = req.WithContext(context.WithValue(req.Context(), uiTenantContextKey, firstTenant))
w := httptest.NewRecorder()
h.listTenants(w, req)
if w.Code != http.StatusOK {
t.Fatalf("listTenants() status = %d, body = %s", w.Code, w.Body.String())
}
var resp []map[string]any
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp) != 1 {
t.Fatalf("len(response) = %d, want 1", len(resp))
}
if got := resp[0]["id"]; got != firstTenant.ID {
t.Fatalf("response[0].id = %v, want %s", got, firstTenant.ID)
}
}
+25 -1
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-04-09
// Изменено: 2026-04-11 — добавлена Redis persistence
// ChangeMessageVisibilityV1 — меняет visibility timeout сообщения в очереди тенанта.
package gosqs
@@ -9,6 +9,7 @@ import (
"shared-sqs/app/interfaces"
"shared-sqs/app/models"
"shared-sqs/app/persistence"
"shared-sqs/app/utils"
"github.com/gorilla/mux"
@@ -52,6 +53,8 @@ func ChangeMessageVisibilityV1(req *http.Request) (int, interfaces.AbstractRespo
models.SyncQueues.Lock()
messageFound := false
var changedMsgUuid string
var msgRemoved bool
for i := 0; i < len(models.SyncQueues.Queues[key].Messages); i++ {
queue := models.SyncQueues.Queues[key]
msgs := queue.Messages
@@ -65,16 +68,37 @@ func ChangeMessageVisibilityV1(req *http.Request) (int, interfaces.AbstractRespo
if queue.MaxReceiveCount > 0 &&
queue.DeadLetterQueue != nil &&
msgs[i].Retry >= queue.MaxReceiveCount {
changedMsgUuid = msgs[i].Uuid
queue.DeadLetterQueue.Messages = append(queue.DeadLetterQueue.Messages, msgs[i])
queue.Messages = append(queue.Messages[:i], queue.Messages[i+1:]...)
msgRemoved = true
} else {
changedMsgUuid = msgs[i].Uuid
}
} else {
msgs[i].VisibilityTimeout = time.Now().Add(time.Duration(visibilityTimeout) * time.Second)
changedMsgUuid = msgs[i].Uuid
}
messageFound = true
break
}
}
// Персистим изменения в Redis под Lock
if messageFound {
if msgRemoved {
// Сообщение удалено (перемещено в DLQ) — удаляем из Redis
persistence.DeleteMessagePersist(key, changedMsgUuid)
} else {
// Сообщение обновлено — перезаписываем в Redis
for i := range models.SyncQueues.Queues[key].Messages {
if models.SyncQueues.Queues[key].Messages[i].Uuid == changedMsgUuid {
persistence.SaveMessage(key, &models.SyncQueues.Queues[key].Messages[i])
break
}
}
}
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
}
models.SyncQueues.Unlock()
if !messageFound {
return utils.CreateErrorResponseV1("MessageNotInFlight", true)
+9 -1
View File
@@ -1,4 +1,4 @@
// Создано: 2026-04-11
// Создано: 2026-04-11, Изменено: 2026-04-11 — добавлена Redis persistence
// ChangeMessageVisibilityBatchV1 — пакетная смена таймаута видимости (до 10 сообщений).
// Паттерн аналогичен DeleteMessageBatchV1: валидация Id, цикл по записям, partial success.
package gosqs
@@ -10,6 +10,7 @@ import (
"shared-sqs/app/interfaces"
"shared-sqs/app/models"
"shared-sqs/app/persistence"
"shared-sqs/app/utils"
"github.com/gorilla/mux"
@@ -92,6 +93,8 @@ func ChangeMessageVisibilityBatchV1(req *http.Request) (int, interfaces.Abstract
} else {
queue.Messages[i].VisibilityTimeout = time.Now().Add(time.Duration(entry.VisibilityTimeout) * time.Second)
}
// Персистим изменённое сообщение отдельно
persistence.SaveMessage(key, &queue.Messages[i])
messageFound = true
break
}
@@ -109,6 +112,11 @@ func ChangeMessageVisibilityBatchV1(req *http.Request) (int, interfaces.Abstract
}
}
// Персистим изменения в Redis под Lock
if len(successEntries) > 0 {
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
}
respStruct := models.ChangeMessageVisibilityBatchResponse{
Xmlns: models.BaseXmlns,
Result: models.ChangeMessageVisibilityBatchResult{
+4 -1
View File
@@ -47,10 +47,13 @@ func DeleteMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
if _, ok := models.SyncQueues.Queues[key]; ok {
for i, msg := range models.SyncQueues.Queues[key].Messages {
if msg.ReceiptHandle == receiptHandle {
msgUuid := msg.Uuid
models.SyncQueues.Queues[key].UnlockGroup(msg.GroupID)
models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages[:i], models.SyncQueues.Queues[key].Messages[i+1:]...)
delete(models.SyncQueues.Queues[key].Duplicates, msg.DeduplicationID)
// Сохраняем очередь в Redis пока держим Lock
// Удаляем одно сообщение из Redis — O(1)
persistence.DeleteMessagePersist(key, msgUuid)
// Метаданные очереди (duplicates, FIFO state)
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
respStruct := models.DeleteMessageResponse{
Xmlns: models.BaseXmlns,
+5 -1
View File
@@ -73,6 +73,7 @@ func DeleteMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBo
}
deletedEntries := make([]models.DeleteMessageBatchResultEntry, 0)
deletedUuids := make([]string, 0)
remainingMessages := make([]models.SqsMessage, 0, len(models.SyncQueues.Queues[key].Messages))
for _, message := range models.SyncQueues.Queues[key].Messages {
@@ -82,13 +83,16 @@ func DeleteMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBo
delete(models.SyncQueues.Queues[key].Duplicates, message.DeduplicationID)
de.Deleted = true
deletedEntries = append(deletedEntries, models.DeleteMessageBatchResultEntry{Id: de.Id})
deletedUuids = append(deletedUuids, message.Uuid)
} else {
remainingMessages = append(remainingMessages, message)
}
}
models.SyncQueues.Queues[key].Messages = remainingMessages
// Персистим обновлённое состояние очереди, чтобы не терять batch-delete после рестарта.
// Удаляем сообщения из Redis отдельно — O(batch_size)
persistence.DeleteMessagesPersist(key, deletedUuids)
// Метаданные очереди (duplicates, FIFO state)
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
notFoundEntries := make([]models.BatchResultErrorEntry, 0)
+21 -21
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-04-09
// Изменено: 2026-04-11 — убрана зависимость от copystructure (лишняя аллокация)
// GetQueueAttributesV1 — возвращает атрибуты очереди тенанта.
package gosqs
@@ -11,7 +11,7 @@ import (
"shared-sqs/app/interfaces"
"shared-sqs/app/models"
"shared-sqs/app/utils"
"github.com/mitchellh/copystructure"
log "github.com/sirupsen/logrus"
)
@@ -32,6 +32,7 @@ if t == nil {
return utils.CreateErrorResponseV1("InvalidClientTokenId", true)
}
// Определяем набор запрошенных атрибутов (или All)
requestedAttributes := func() map[string]bool {
attrs := map[string]bool{}
if len(requestBody.AttributeNames) == 0 {
@@ -46,15 +47,14 @@ attrs[attr] = true
return attrs
}()
dupe, _ := copystructure.Copy(models.AvailableQueueAttributes)
includedAttributes, _ := dupe.(map[string]bool)
_, ok = requestedAttributes["All"]
if !ok {
for attr := range includedAttributes {
if _, ok := requestedAttributes[attr]; !ok {
delete(includedAttributes, attr)
}
// Фильтруем атрибуты без deep copy — простая проверка через map lookup
_, wantAll := requestedAttributes["All"]
shouldInclude := func(attr string) bool {
if wantAll {
return true
}
_, ok := requestedAttributes[attr]
return ok
}
uriSegments := strings.Split(requestBody.QueueUrl, "/")
@@ -72,37 +72,37 @@ log.Errorf("Get Queue Attributes: %s queue does not exist for tenant %s", queueN
return utils.CreateErrorResponseV1("QueueNotFound", true)
}
if _, ok := includedAttributes["DelaySeconds"]; ok {
if shouldInclude("DelaySeconds") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "DelaySeconds", Value: strconv.Itoa(queue.DelaySeconds)})
}
if _, ok := includedAttributes["MaximumMessageSize"]; ok {
if shouldInclude("MaximumMessageSize") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "MaximumMessageSize", Value: strconv.Itoa(queue.MaximumMessageSize)})
}
if _, ok := includedAttributes["MessageRetentionPeriod"]; ok {
if shouldInclude("MessageRetentionPeriod") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "MessageRetentionPeriod", Value: strconv.Itoa(queue.MessageRetentionPeriod)})
}
if _, ok := includedAttributes["ReceiveMessageWaitTimeSeconds"]; ok {
if shouldInclude("ReceiveMessageWaitTimeSeconds") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "ReceiveMessageWaitTimeSeconds", Value: strconv.Itoa(queue.ReceiveMessageWaitTimeSeconds)})
}
if _, ok := includedAttributes["VisibilityTimeout"]; ok {
if shouldInclude("VisibilityTimeout") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "VisibilityTimeout", Value: strconv.Itoa(queue.VisibilityTimeout)})
}
if _, ok := includedAttributes["ApproximateNumberOfMessages"]; ok {
if shouldInclude("ApproximateNumberOfMessages") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "ApproximateNumberOfMessages", Value: strconv.Itoa(len(queue.Messages))})
}
if _, ok := includedAttributes["ApproximateNumberOfMessagesNotVisible"]; ok {
if shouldInclude("ApproximateNumberOfMessagesNotVisible") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "ApproximateNumberOfMessagesNotVisible", Value: strconv.Itoa(numberOfHiddenMessagesInQueue(*queue))})
}
if _, ok := includedAttributes["CreatedTimestamp"]; ok {
if shouldInclude("CreatedTimestamp") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "CreatedTimestamp", Value: "0000000000"})
}
if _, ok := includedAttributes["LastModifiedTimestamp"]; ok {
if shouldInclude("LastModifiedTimestamp") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "LastModifiedTimestamp", Value: "0000000000"})
}
if _, ok := includedAttributes["QueueArn"]; ok {
if shouldInclude("QueueArn") {
queueAttributes = append(queueAttributes, models.Attribute{Name: "QueueArn", Value: queue.Arn})
}
if _, ok := includedAttributes["RedrivePolicy"]; ok && queue.DeadLetterQueue != nil {
if shouldInclude("RedrivePolicy") && queue.DeadLetterQueue != nil {
queueAttributes = append(queueAttributes, models.Attribute{
Name: "RedrivePolicy",
Value: fmt.Sprintf(`{"maxReceiveCount":"%d", "deadLetterTargetArn":"%s"}`, queue.MaxReceiveCount, queue.DeadLetterQueue.Arn),
+3 -1
View File
@@ -42,7 +42,9 @@ func PurgeQueueV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
log.Infof("Purging Queue: %s (tenant: %s)", queueName, t.ID)
models.SyncQueues.Queues[key].Messages = nil
models.SyncQueues.Queues[key].Duplicates = make(map[string]time.Time)
// Сохраняем пустую очередь в Redis пока держим Lock
// Удаляем все сообщения из Redis одной командой DEL
persistence.PurgeMessagesPersist(key)
// Сохраняем пустые метаданные очереди
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
respStruct := models.PurgeQueueResponse{
+3 -3
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-04-09
// Изменено: 2026-04-11 — фикс: SentTimestamp из m.SentTime, MD5 из кэша
// ReceiveMessageV1 — получает сообщения из очереди тенанта с поддержкой long polling.
// Ловушка #4: long polling держит соединение до 20 сек — не прерываем принудительно.
package gosqs
@@ -167,14 +167,14 @@ func buildResultMessage(m *models.SqsMessage) *models.ResultMessage {
MessageId: m.Uuid,
Body: m.MessageBody,
ReceiptHandle: m.ReceiptHandle,
MD5OfBody: utils.GetMD5Hash(m.MessageBody),
MD5OfBody: m.MD5OfMessageBody, // Используем кэшированный MD5 вместо пересчёта
MD5OfMessageAttributes: m.MD5OfMessageAttributes,
MessageAttributes: m.MessageAttributes,
Attributes: map[string]string{
"ApproximateFirstReceiveTimestamp": fmt.Sprintf("%d", m.ReceiptTime.UnixNano()/int64(time.Millisecond)),
"SenderId": models.CurrentEnvironment.AccountID,
"ApproximateReceiveCount": fmt.Sprintf("%d", m.NumberOfReceives+1),
"SentTimestamp": fmt.Sprintf("%d", time.Now().UTC().UnixNano()/int64(time.Millisecond)),
"SentTimestamp": fmt.Sprintf("%d", m.SentTime.UnixNano()/int64(time.Millisecond)), // Фикс: реальное время отправки
},
}
}
+6 -3
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-04-10 — добавлена Redis persistence
// Изменено: 2026-04-11 — убрано логирование тела сообщения (perf + security)
// SendMessageV1 — добавляет сообщение в очередь тенанта.
// Ловушка #6: queueName извлекается как ПОСЛЕДНИЙ сегмент URL — при URL вида
// http://host/tenantID/queueName последний сегмент = queueName (правильно).
@@ -112,15 +112,18 @@ func SendMessageV1(req *http.Request) (int, interfaces.AbstractResponseBody) {
if !models.SyncQueues.Queues[key].IsDuplicate(messageDeduplicationID) {
models.SyncQueues.Queues[key].Messages = append(models.SyncQueues.Queues[key].Messages, msg)
// Персистим одно сообщение отдельно — O(msg_size) вместо O(N*msg_size)
persistence.SaveMessage(key, &msg)
} else {
log.Debugf("Duplicate message deduplicationId [%s] in queue [%s]", messageDeduplicationID, queueName)
}
models.SyncQueues.Queues[key].InitDuplicatation(messageDeduplicationID)
// Сохраняем очередь в Redis пока держим Lock
// Сохраняем метаданные очереди (FIFO state, duplicates) — без Messages
persistence.SaveQueue(key, models.SyncQueues.Queues[key])
models.SyncQueues.Unlock()
log.Infof("%s: Queue: %s, Message: %s\n", time.Now().Format("2006-01-02 15:04:05"), queueName, msg.MessageBody)
// Логируем только метаданные — тело сообщения не логируется (perf + security)
log.Infof("Queue: %s, MessageId: %s, Size: %d bytes", queueName, msg.Uuid, len(messageBody))
respStruct := models.SendMessageResponse{
Xmlns: models.BaseXmlns,
+8 -3
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-04-09
// Изменено: 2026-04-11 — убрано логирование тела сообщения (perf + security)
// SendMessageBatchV1 — пакетная отправка сообщений в очередь тенанта.
package gosqs
@@ -106,6 +106,7 @@ func SendMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBody
models.SyncQueues.Lock()
queue = models.SyncQueues.Queues[key]
newMsgs := make([]models.SqsMessage, 0, len(sendEntries))
for _, sendEntry := range sendEntries {
msg := models.SqsMessage{MessageBody: sendEntry.MessageBody}
if len(sendEntry.MessageAttributes) > 0 {
@@ -136,9 +137,13 @@ func SendMessageBatchV1(req *http.Request) (int, interfaces.AbstractResponseBody
MD5OfMessageAttributes: msg.MD5OfMessageAttributes,
SequenceNumber: fifoSeqNumber,
})
log.Infof("%s: Queue: %s, Message: %s", time.Now().Format("2006-01-02 15:04:05"), queueName, msg.MessageBody)
newMsgs = append(newMsgs, msg)
// Логируем только метаданные — тело сообщения не логируется (perf + security)
log.Debugf("Queue: %s, MessageId: %s, Size: %d bytes", queueName, msg.Uuid, len(sendEntry.MessageBody))
}
// Персистим батч-изменение одним снапшотом под lock.
// Персистим новые сообщения отдельно — O(batch_size*msg_size) вместо O(N*msg_size)
persistence.SaveMessages(key, newMsgs)
// Метаданные очереди (FIFO state, duplicates) — без Messages
persistence.SaveQueue(key, queue)
models.SyncQueues.Unlock()
+1 -29
View File
@@ -6,7 +6,7 @@ func init() {
SqsErrors = map[string]SqsErrorType{
"QueueNotFound": {HttpError: http.StatusBadRequest, Type: "Not Found", Code: "AWS.SimpleQueueService.NonExistentQueue", Message: "The specified queue does not exist for this wsdl version."},
"QueueExists": {HttpError: http.StatusBadRequest, Type: "Duplicate", Code: "AWS.SimpleQueueService.QueueExists", Message: "The specified queue already exists."},
"MessageDoesNotExist": {HttpError: http.StatusNotFound, Type: "Not Found", Code: "AWS.SimpleQueueService.QueueExists", Message: "The specified queue does not contain the message specified."},
"MessageDoesNotExist": {HttpError: http.StatusNotFound, Type: "Not Found", Code: "ReceiptHandleIsInvalid", Message: "The specified receipt handle is not valid."},
"GeneralError": {HttpError: http.StatusBadRequest, Type: "GeneralError", Code: "AWS.SimpleQueueService.GeneralError", Message: "General Error."},
"TooManyEntriesInBatchRequest": {HttpError: http.StatusBadRequest, Type: "TooManyEntriesInBatchRequest", Code: "AWS.SimpleQueueService.TooManyEntriesInBatchRequest", Message: "Maximum number of entries per request are 10."},
"BatchEntryIdsNotDistinct": {HttpError: http.StatusBadRequest, Type: "BatchEntryIdsNotDistinct", Code: "AWS.SimpleQueueService.BatchEntryIdsNotDistinct", Message: "Two or more batch entries in the request have the same Id."},
@@ -25,17 +25,6 @@ func init() {
// MissingParameter — обязательный параметр отсутствует (например, пустой MessageBody)
"MissingParameter": {HttpError: http.StatusBadRequest, Type: "MissingParameter", Code: "MissingParameter", Message: "The request must contain the parameter MessageBody."},
}
SnsErrors = map[string]SnsErrorType{
"InvalidParameterValue": {HttpError: http.StatusBadRequest, Type: "InvalidParameterValue", Code: "AWS.SimpleNotificationService.InvalidParameterValue", Message: "An invalid or out-of-range value was supplied for the input parameter."},
"TopicNotFound": {HttpError: http.StatusBadRequest, Type: "Not Found", Code: "AWS.SimpleNotificationService.NonExistentTopic", Message: "The specified topic does not exist for this wsdl version."},
"SubscriptionNotFound": {HttpError: http.StatusNotFound, Type: "Not Found", Code: "AWS.SimpleNotificationService.NonExistentSubscription", Message: "The specified subscription does not exist for this wsdl version."},
"TopicExists": {HttpError: http.StatusBadRequest, Type: "Duplicate", Code: "AWS.SimpleNotificationService.TopicAlreadyExists", Message: "The specified topic already exists."},
"ValidationError": {HttpError: http.StatusBadRequest, Type: "InvalidParameter", Code: "AWS.SimpleNotificationService.ValidationError", Message: "The input fails to satisfy the constraints specified by an AWS service."},
"BatchEntryIdsNotDistinct": {HttpError: http.StatusBadRequest, Type: "BatchEntryIdsNotDistinct", Code: "AWS.SimpleNotificationService.BatchEntryIdsNotDistinct", Message: "Two or more batch entries in the request have the same Id."},
"EmptyBatchRequest": {HttpError: http.StatusBadRequest, Type: "EmptyBatchRequest", Code: "AWS.SimpleNotificationService.EmptyBatchRequest", Message: "The batch request doesn't contain any entries."},
"TooManyEntriesInBatchRequest": {HttpError: http.StatusBadRequest, Type: "TooManyEntriesInBatchRequest", Code: "AWS.SimpleNotificationService.TooManyEntriesInBatchRequest", Message: "Maximum number of entries per request are 10."},
"MalformedInput": {HttpError: http.StatusBadRequest, Type: "Sender", Code: "AWS.SimpleNotificationService.MalformedInput", Message: "Invalid Base64 encoding"},
}
}
type SqsErrorType struct {
@@ -54,20 +43,3 @@ func (s SqsErrorType) Response() ErrorResult {
}
var SqsErrors map[string]SqsErrorType
type SnsErrorType struct {
HttpError int
Type string
Code string
Message string
}
func (s SnsErrorType) StatusCode() int {
return s.HttpError
}
func (s SnsErrorType) Response() ErrorResult {
return ErrorResult{Type: s.Type, Code: s.Code, Message: s.Message}
}
var SnsErrors map[string]SnsErrorType
+226 -19
View File
@@ -4,7 +4,13 @@
// Redis — источник правды для восстановления после рестарта.
// Все записи в Redis асинхронны (горутина) — не блокируют SQS-операции.
// Сериализация (json.Marshal) происходит синхронно пока вызывающий держит мьютекс — консистентный снапшот.
// Created: 2026-04-10
//
// Схема хранения v2 (2026-04-11):
// ssq:queues (HASH) — queueKey → JSON(метаданные очереди без Messages)
// ssq:msg:{queueKey} (HASH) — uuid → JSON(SqsMessage)
// Это позволяет O(1) на каждое сообщение вместо O(N×msg_size) при SaveQueue.
// Миграция со старого формата (Messages внутри ssq:queues) — автоматическая при LoadAllQueues.
// Created: 2026-04-10 | Modified: 2026-04-11
package persistence
@@ -34,8 +40,10 @@ var (
const (
// redisHashTenants — HASH: tenantID → JSON тенанта
redisHashTenants = "ssq:tenants"
// redisHashQueues — HASH: queueKey → JSON очереди (включая сообщения)
// redisHashQueues — HASH: queueKey → JSON метаданных очереди (без Messages с v2)
redisHashQueues = "ssq:queues"
// redisMsgHashPrefix — префикс для per-message HASH: ssq:msg:{queueKey} → uuid → JSON(SqsMessage)
redisMsgHashPrefix = "ssq:msg:"
)
// Connect — подключается к Redis и проверяет ping.
@@ -74,22 +82,37 @@ func asyncWrite(fn func()) {
}()
}
// SaveQueue — сохраняет очередь (с сообщениями) в Redis асинхронно.
// ВАЖНО: вызывать пока вызывающий держит SyncQueues.Lock() — тогда json.Marshal
// marshalQueueMeta — сериализует очередь БЕЗ Messages (и без Messages в DLQ).
// DeadLetterQueue сохраняется как ссылка (Name/URL/Arn), но без сообщений.
// ВАЖНО: вызывать под SyncQueues.Lock() — временно nil'ит Messages.
func marshalQueueMeta(queue *models.Queue) ([]byte, error) {
savedMsgs := queue.Messages
queue.Messages = nil
var savedDLQMsgs []models.SqsMessage
if queue.DeadLetterQueue != nil {
savedDLQMsgs = queue.DeadLetterQueue.Messages
queue.DeadLetterQueue.Messages = nil
}
data, err := json.Marshal(queue)
queue.Messages = savedMsgs
if queue.DeadLetterQueue != nil {
queue.DeadLetterQueue.Messages = savedDLQMsgs
}
return data, err
}
// SaveQueue — сохраняет МЕТАДАННЫЕ очереди (без сообщений) в Redis асинхронно.
// ВАЖНО: вызывать пока вызывающий держит SyncQueues.Lock() — тогда marshalQueueMeta
// создаёт консистентный снапшот. Горутина только делает сетевой вызов.
// Для сохранения сообщений используй SaveMessage/SaveMessages.
func SaveQueue(key string, queue *models.Queue) {
if Client == nil {
return
}
// Сериализуем синхронно под мьютексом вызывающего → консистентный снапшот
data, err := json.Marshal(queue)
// Сериализуем метаданные синхронно под мьютексом вызывающего → консистентный снапшот
data, err := marshalQueueMeta(queue)
if err != nil {
log.Errorf("persistence: marshal queue %q: %v", key, err)
return
}
// Fix #4.3: Redis size guard — не сохранять если > 50MB (OOM protection)
if len(data) > 50*1024*1024 {
log.Warnf("persistence: queue %q too large for Redis (%d bytes), skipping", key, len(data))
log.Errorf("persistence: marshal queue meta %q: %v", key, err)
return
}
writeSeq := nextQueueWriteSeq(key)
@@ -100,42 +123,162 @@ func SaveQueue(key string, queue *models.Queue) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := Client.HSet(ctx, redisHashQueues, key, string(data)).Err(); err != nil {
log.Errorf("persistence: HSet queue %q: %v", key, err)
log.Errorf("persistence: HSet queue meta %q: %v", key, err)
}
})
}
// DeleteQueue — удаляет очередь из Redis асинхронно.
// SaveMessage — сохраняет ОДНО сообщение в Redis асинхронно.
// Ключ: ssq:msg:{queueKey}, поле: msg.Uuid, значение: JSON(SqsMessage).
// Вызывать под Lock — json.Marshal делает консистентный снапшот сообщения.
func SaveMessage(queueKey string, msg *models.SqsMessage) {
if Client == nil {
return
}
data, err := json.Marshal(msg)
if err != nil {
log.Errorf("persistence: marshal message %s/%s: %v", queueKey, msg.Uuid, err)
return
}
hashKey := redisMsgHashPrefix + queueKey
asyncWrite(func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := Client.HSet(ctx, hashKey, msg.Uuid, string(data)).Err(); err != nil {
log.Errorf("persistence: HSet message %s/%s: %v", queueKey, msg.Uuid, err)
}
})
}
// SaveMessages — сохраняет несколько сообщений в Redis одним pipeline.
// Вызывать под Lock.
func SaveMessages(queueKey string, msgs []models.SqsMessage) {
if Client == nil || len(msgs) == 0 {
return
}
// Сериализуем все сообщения синхронно под Lock
fields := make(map[string]string, len(msgs))
for i := range msgs {
data, err := json.Marshal(&msgs[i])
if err != nil {
log.Errorf("persistence: marshal message %s/%s: %v", queueKey, msgs[i].Uuid, err)
continue
}
fields[msgs[i].Uuid] = string(data)
}
if len(fields) == 0 {
return
}
hashKey := redisMsgHashPrefix + queueKey
asyncWrite(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Конвертируем map[string]string → []interface{} для HSet
args := make([]interface{}, 0, len(fields)*2)
for k, v := range fields {
args = append(args, k, v)
}
if err := Client.HSet(ctx, hashKey, args...).Err(); err != nil {
log.Errorf("persistence: HSet messages %s (%d msgs): %v", queueKey, len(fields), err)
}
})
}
// DeleteMessagePersist — удаляет одно сообщение из Redis асинхронно.
// Вызывать при DeleteMessage (после удаления из in-memory).
func DeleteMessagePersist(queueKey string, msgUuid string) {
if Client == nil || msgUuid == "" {
return
}
hashKey := redisMsgHashPrefix + queueKey
asyncWrite(func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := Client.HDel(ctx, hashKey, msgUuid).Err(); err != nil {
log.Errorf("persistence: HDel message %s/%s: %v", queueKey, msgUuid, err)
}
})
}
// DeleteMessagesPersist — удаляет несколько сообщений из Redis.
// Вызывать при DeleteMessageBatch.
func DeleteMessagesPersist(queueKey string, uuids []string) {
if Client == nil || len(uuids) == 0 {
return
}
hashKey := redisMsgHashPrefix + queueKey
// Копируем uuids — вызывающий может переиспользовать slice
ids := make([]string, len(uuids))
copy(ids, uuids)
asyncWrite(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := Client.HDel(ctx, hashKey, ids...).Err(); err != nil {
log.Errorf("persistence: HDel messages %s (%d): %v", queueKey, len(ids), err)
}
})
}
// PurgeMessagesPersist — удаляет ВСЕ сообщения очереди из Redis (для PurgeQueue).
// Удаляет весь HASH ssq:msg:{queueKey}.
func PurgeMessagesPersist(queueKey string) {
if Client == nil {
return
}
hashKey := redisMsgHashPrefix + queueKey
asyncWrite(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := Client.Del(ctx, hashKey).Err(); err != nil {
log.Errorf("persistence: DEL messages hash %s: %v", queueKey, err)
}
})
}
// DeleteQueue — удаляет метаданные очереди И все её сообщения из Redis асинхронно.
func DeleteQueue(key string) {
if Client == nil {
return
}
writeSeq := nextQueueWriteSeq(key)
msgHashKey := redisMsgHashPrefix + key
asyncWrite(func() {
if !isLatestQueueWriteSeq(key, writeSeq) {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Удаляем метаданные из общего HASH
if err := Client.HDel(ctx, redisHashQueues, key).Err(); err != nil {
log.Errorf("persistence: HDel queue %q: %v", key, err)
log.Errorf("persistence: HDel queue meta %q: %v", key, err)
}
// Удаляем весь HASH с сообщениями
if err := Client.Del(ctx, msgHashKey).Err(); err != nil {
log.Errorf("persistence: DEL messages hash %q: %v", key, err)
}
})
}
// LoadAllQueues — загружает все очереди из Redis в память при старте сервиса.
// Инициализирует nil-maps чтобы избежать panic при deduplication/FIFO операциях.
// Схема v2: метаданные из ssq:queues, сообщения из ssq:msg:{key}.
// Миграция v1→v2: если в ssq:queues лежит JSON со встроенными Messages,
// они извлекаются в отдельный HASH и метаданные пересохраняются без Messages.
func LoadAllQueues() (map[string]*models.Queue, error) {
if Client == nil {
return nil, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 1. Загружаем метаданные очередей
raw, err := Client.HGetAll(ctx, redisHashQueues).Result()
if err != nil {
return nil, fmt.Errorf("redis HGetAll queues: %w", err)
}
queues := make(map[string]*models.Queue, len(raw))
migrateKeys := make([]string, 0) // ключи для миграции v1→v2
for k, v := range raw {
var q models.Queue
if err := json.Unmarshal([]byte(v), &q); err != nil {
@@ -152,9 +295,73 @@ func LoadAllQueues() (map[string]*models.Queue, error) {
if q.FIFOSequenceNumbers == nil {
q.FIFOSequenceNumbers = make(map[string]int)
}
// Миграция v1→v2: если в JSON есть Messages — это старый формат
if len(q.Messages) > 0 {
migrateKeys = append(migrateKeys, k)
log.Infof("persistence: миграция v1→v2 для %q (%d сообщений)", k, len(q.Messages))
}
queues[k] = &q
}
log.Infof("persistence: загружено %d очередей из Redis", len(queues))
// 2. Миграция: выносим Messages из старого формата в отдельные HASH'ы
for _, k := range migrateKeys {
q := queues[k]
hashKey := redisMsgHashPrefix + k
// Сохраняем каждое сообщение отдельно
if len(q.Messages) > 0 {
args := make([]interface{}, 0, len(q.Messages)*2)
for i := range q.Messages {
data, err := json.Marshal(&q.Messages[i])
if err != nil {
log.Errorf("persistence: migrate marshal msg %s/%s: %v", k, q.Messages[i].Uuid, err)
continue
}
args = append(args, q.Messages[i].Uuid, string(data))
}
if len(args) > 0 {
if err := Client.HSet(ctx, hashKey, args...).Err(); err != nil {
log.Errorf("persistence: migrate HSet messages %q: %v", k, err)
}
}
}
// Пересохраняем метаданные без Messages
metaData, err := marshalQueueMeta(q)
if err == nil {
if err := Client.HSet(ctx, redisHashQueues, k, string(metaData)).Err(); err != nil {
log.Errorf("persistence: migrate HSet meta %q: %v", k, err)
}
}
log.Infof("persistence: миграция %q завершена — %d сообщений вынесены в %s", k, len(q.Messages), hashKey)
}
// 3. Загружаем сообщения из отдельных HASH'ов для каждой очереди
for k, q := range queues {
hashKey := redisMsgHashPrefix + k
msgRaw, err := Client.HGetAll(ctx, hashKey).Result()
if err != nil {
log.Errorf("persistence: HGetAll messages %q: %v", k, err)
continue
}
if len(msgRaw) > 0 {
msgs := make([]models.SqsMessage, 0, len(msgRaw))
for uuid, v := range msgRaw {
var m models.SqsMessage
if err := json.Unmarshal([]byte(v), &m); err != nil {
log.Errorf("persistence: unmarshal message %s/%s: %v", k, uuid, err)
continue
}
msgs = append(msgs, m)
}
q.Messages = msgs
} else if len(q.Messages) == 0 {
// Пустая очередь — Messages уже nil, оставляем
q.Messages = nil
}
}
log.Infof("persistence: загружено %d очередей из Redis (миграций: %d)", len(queues), len(migrateKeys))
return queues, nil
}
+11 -15
View File
@@ -3,9 +3,9 @@
app/ui/index.html
SQS Console — веб-интерфейс для shared-sqs (Nubes branding)
Created: 2026-04-10
Updated: 2026-04-10 — JWT auth через nubes token, email в navbar, auto-provisioning
Updated: 2026-04-12 09:28 MSK — demo token login и UI только для собственного tenant-а
Vanilla HTML/CSS/JS SPA. Встраивается через go:embed.
Режим: JWT авторизация через nubes API. Пользователь вводит токен → валидация → сессия.
Режим: UI принимает либо nubes JWT, либо публичный demo token для seeded demo tenant.
-->
<html lang="ru">
<head>
@@ -330,13 +330,14 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
<h1>SQS CONSOLE</h1>
<div class="form-group">
<label for="login-token">API Token (nubes JWT)</label>
<input id="login-token" type="password" placeholder="eyJhbGciOiJSUzI1NiIs...">
<label for="login-token">API Token или Demo Token</label>
<input id="login-token" type="password" placeholder="JWT или demo-ui-shared-sqs-ngcloud-2026">
</div>
<div id="login-error" class="login-error"></div>
<button class="btn btn-primary" style="width:100%" onclick="doLogin()">Войти</button>
<p style="font-size:12px;color:var(--text-secondary);margin-top:16px">
Токен можно получить в панели управления облаком Nubes
Для демо используйте token: demo-ui-shared-sqs-ngcloud-2026.<br>
Для личного tenant-а используйте API token из панели Nubes.
</p>
</div>
</div>
@@ -500,10 +501,10 @@ function showLogin() {
function doLogin() {
const token = document.getElementById('login-token').value.trim();
if (!token) {
document.getElementById('login-error').textContent = 'Введите токен';
document.getElementById('login-error').textContent = 'Введите API token или demo token';
return;
}
document.getElementById('login-error').textContent = 'Проверка токена...';
document.getElementById('login-error').textContent = 'Проверка доступа...';
fetch(BASE + '/ui/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -611,12 +612,11 @@ function renderDashboard(health, tenants) {
</div>
<div class="card">
<div class="toolbar">
<h2>Тенанты</h2>
<h2>Мой tenant</h2>
<div style="display:flex;gap:12px;align-items:center">
<div class="auto-refresh">
<span>⟳ 10с</span>
</div>
<button class="btn btn-primary btn-sm" onclick="openModal()">+ Создать</button>
</div>
</div>
<div class="table-wrap">
@@ -628,7 +628,6 @@ function renderDashboard(health, tenants) {
<th>Макс. очередей</th>
<th>Статус</th>
<th>Создан</th>
<th></th>
</tr>
</thead>
<tbody>
@@ -641,12 +640,9 @@ function renderDashboard(health, tenants) {
? '<span class="badge badge-active">active</span>'
: '<span class="badge badge-inactive">inactive</span>'}</td>
<td style="font-size:12px;color:var(--text-secondary)">${fmtDate(t.created_at)}</td>
<td>
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation();deleteTenant('${esc(t.id)}','${esc(t.name)}')">✕</button>
</td>
</tr>
`).join('')}
${(!tenants || tenants.length === 0) ? '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:32px">Нет тенантов</td></tr>' : ''}
${(!tenants || tenants.length === 0) ? '<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:32px">Tenant не найден</td></tr>' : ''}
</tbody>
</table>
</div>
@@ -681,7 +677,7 @@ function renderTenant(tenant, queues) {
const totalMsgs = (queues || []).reduce((s, q) => s + q.messages + q.not_visible, 0);
el.innerHTML = `
<div class="breadcrumb">
<a href="#" onclick="event.preventDefault();showDashboard()">Тенанты</a>
<a href="#" onclick="event.preventDefault();showDashboard()">Мой tenant</a>
<span>›</span>
${esc(tenant.name)}
</div>
+3 -6
View File
@@ -79,13 +79,10 @@ func ExtractQueueAttributes(u url.Values) map[string]string {
return attr
}
// CreateErrorResponseV1 — формирует SQS error response по ключу ошибки.
// Параметр isSqs оставлен для обратной совместимости (всегда true).
func CreateErrorResponseV1(errKey string, isSqs bool) (int, interfaces.AbstractResponseBody) {
var err interfaces.AbstractErrorResponse
if isSqs {
err = models.SqsErrors[errKey]
} else {
err = models.SnsErrors[errKey]
}
err := models.SqsErrors[errKey]
respStruct := models.ErrorResponse{
Result: err.Response(),
+8 -1
View File
@@ -1,6 +1,6 @@
# deployments/k8s/ingress.yaml
# Ingress для shared-sqs на домене qu.kube5s.ru
# Created: 2026-04-09, Updated: 2026-04-10
# Created: 2026-04-09, Updated: 2026-04-11
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
@@ -11,6 +11,13 @@ metadata:
nginx.ingress.kubernetes.io/proxy-body-size: 10m
nginx.ingress.kubernetes.io/proxy-read-timeout: "30"
nginx.ingress.kubernetes.io/proxy-send-timeout: "30"
# Fix: 64KB+ payload (boto3/urllib3 2.0 шлёт headers и body отдельными TCP write,
# botocore убирает TCP_NODELAY → последние ~16KB body не доходят за client_body_timeout).
# Решение: proxy-request-buffering=off стримит body напрямую в backend без ожидания полного тела.
nginx.ingress.kubernetes.io/client-body-buffer-size: "512k"
nginx.ingress.kubernetes.io/proxy-request-buffering: "off"
nginx.ingress.kubernetes.io/server-snippet: |
client_body_timeout 120s;
spec:
ingressClassName: nginx
rules:
@@ -0,0 +1,99 @@
# Сравнительный benchmark shared-sqs vs Yandex MQ
**Дата:** 2026-04-12 05:45 UTC
## Область сравнения
Этот отчёт фиксирует результаты сравнительных тестов по основным пользовательским сценариям shared-sqs и Yandex MQ.
## Методика
- Запуск выполнялся с удалённой ВМ `5.172.178.213` в каталоге `~/terra/SQS-service`.
- Для сравнения использовался один и тот же AWS CLI клиент.
- Базовый прогон: [tests/benchmark_compare_32k.sh](/home/naeel/remote_dev/SQS-service/tests/benchmark_compare_32k.sh).
- Уточняющий прогон для `SendMessage 10KB` и `SendMessage 32KB`: [tests/payload_latency_probe.sh](/home/naeel/remote_dev/SQS-service/tests/payload_latency_probe.sh).
- Для большинства операций использовано `7` итераций.
- Для throughput использовалось `5` воркеров по `10` сообщений `1KB`.
- Для `PurgeQueue` зафиксирован одиночный контрольный замер, потому что повторный вызов упирается в стандартный cooldown `60s`.
## Покрытие API
Сравнение включало операции, которые есть у обоих сервисов:
- `GetQueueUrl`
- `ListQueues`
- `GetQueueAttributes`
- `SetQueueAttributes`
- `SendMessage`
- `SendMessageBatch`
- `ReceiveMessage`
- `DeleteMessage`
- `DeleteMessageBatch`
- `ChangeMessageVisibility`
- `ChangeMessageVisibilityBatch`
- `PurgeQueue`
Операции, которые есть в shared-sqs, но не участвуют в прямом сравнении с Yandex MQ:
- `TagQueue`
- `UntagQueue`
- `ListQueueTags`
## Итоги по latency
Формат значений: `min / avg / max / p95`, миллисекунды.
| Операция | Yandex MQ | shared-sqs | Вывод |
|---|---:|---:|---|
| GetQueueUrl | 766 / 1346 / 2266 / 2060 | 739 / 752 / 770 / 760 | shared-sqs заметно стабильнее |
| ListQueues | 765 / 786 / 817 / 811 | 740 / 756 / 775 / 762 | shared-sqs быстрее |
| GetQueueAttributes | 804 / 820 / 847 / 828 | 738 / 752 / 768 / 765 | shared-sqs быстрее |
| SetQueueAttributes | 805 / 816 / 835 / 835 | 750 / 765 / 803 / 774 | shared-sqs быстрее |
| SendMessage 1KB | 804 / 811 / 824 / 823 | 748 / 760 / 770 / 767 | shared-sqs быстрее |
| SendMessage 10KB | 916 / 967 / 996 / 987 | 880 / 913 / 965 / 951 | shared-sqs быстрее |
| SendMessage 32KB | 905 / 930 / 948 / 947 | 883 / 904 / 939 / 928 | shared-sqs быстрее |
| SendMessageBatch 10 | 784 / 803 / 827 / 820 | 718 / 746 / 782 / 759 | shared-sqs быстрее |
| ReceiveMessage | 782 / 826 / 869 / 864 | 738 / 748 / 777 / 751 | shared-sqs быстрее |
| DeleteMessage | 783 / 800 / 823 / 814 | 727 / 742 / 757 / 755 | shared-sqs быстрее |
| DeleteMessageBatch 10 | 818 / 845 / 872 / 864 | 742 / 783 / 821 / 810 | shared-sqs быстрее |
| ChangeMessageVisibility | 810 / 842 / 894 / 854 | 778 / 805 / 814 / 814 | shared-sqs быстрее |
| ChangeMessageVisibilityBatch 10 | 806 / 829 / 848 / 841 | 803 / 824 / 838 / 836 | почти паритет, но shared-sqs чуть быстрее |
| PurgeQueue | 867 | 801 | shared-sqs быстрее, но это одиночный контрольный замер |
## Throughput
Тест: `SendMessage 1KB`, `5` воркеров по `10` сообщений.
| Провайдер | Успешно | Общее время | Пропускная способность |
|---|---:|---:|---:|
| Yandex MQ | 50 / 50 | 9116 ms | ~5 msg/s |
| shared-sqs | 50 / 50 | 8580 ms | ~5 msg/s |
Вывод по throughput:
- В этом сценарии наблюдается паритет по грубому `msg/s`.
- shared-sqs завершает тот же объём немного быстрее по wall-clock time.
- Ограничение здесь задаётся в первую очередь AWS CLI, а не серверной частью обоих сервисов.
## Основные выводы
1. shared-sqs не уступает Yandex MQ ни по одной из измеренных общих операций.
2. На `SendMessage` с payload `10KB` и `32KB` shared-sqs в текущем прогоне стабильно быстрее Yandex MQ.
3. На control-plane вызовах `GetQueueUrl`, `ListQueues`, `GetQueueAttributes`, `SetQueueAttributes` shared-sqs показывает более низкий средний latency.
4. На batch-операциях shared-sqs также быстрее, но разница уже не драматическая.
5. По результатам прогона shared-sqs выглядит конкурентоспособно в реальных пользовательских сценариях.
## Важное примечание по качеству измерений
- В первом длинном прогоне [tests/benchmark_compare_32k.sh](/home/naeel/remote_dev/SQS-service/tests/benchmark_compare_32k.sh) для `SendMessage 10KB` и `SendMessage 32KB` у shared-sqs были получены артефактные нули.
- Повторная точечная проверка показала, что это был дефект benchmark harness, а не отказ сервиса.
- Для этих двух строк в таблице используются результаты повторного узкого прогона из [tests/payload_latency_probe.sh](/home/naeel/remote_dev/SQS-service/tests/payload_latency_probe.sh).
## Что сознательно не включено
- Кросс-кластерные transport-level расследования, уже вынесенные в отдельные технические документы.
- Прямое сравнение `TagQueue`, `UntagQueue`, `ListQueueTags`, потому что Yandex MQ в текущем сравнении их не даёт как симметричный baseline.
## Финальный практический вывод
shared-sqs выглядит конкурентоспособно относительно managed Yandex MQ: сервис стабильно проходит базовые и batch-операции, не проигрывает по latency и в большинстве измеренных точек оказывается быстрее. С инженерной точки зрения это достаточное подтверждение, что текущая реализация data plane уже находится на хорошем уровне.
@@ -0,0 +1,36 @@
# Решение: demo UI режим для showcase
Дата: 2026-04-12 09:57 MSK
Агент: GitHub Copilot (GPT-5.4)
## Контекст
Нужно показать заказчику два сценария на одном стенде:
1. Быстрый demo-вход без подготовки.
2. Реальный пользовательский вход по настоящему Nubes token.
До изменения UI принимал только реальный JWT и при этом использовал admin handlers слишком широко, из-за чего demo-сценарий был неудобным, а UI-поведение было ближе к admin console, чем к пользовательской витрине.
## Решение
Принят временный showcase-режим:
- добавить публичный UI demo token `demo-ui-shared-sqs-ngcloud-2026`;
- привязать его к уже существующему seeded demo tenant `t-demo-shared-sqs-ngcloud`;
- сохранить реальный JWT flow без изменений;
- ограничить UI API текущим tenant-ом;
- запретить создание и удаление tenant-а через UI.
## Почему так
- Это позволяет быстро показать сервис без подготовки аккаунта.
- Это сохраняет реальный пользовательский сценарий: заказчик может ввести настоящий token и попасть в свой tenant.
- Это убирает из demo UI лишний обзор всей системы и снижает риск случайной демонстрации чужих данных.
- Это минимальное изменение, которое можно позже убрать без ломки основной JWT-модели.
## Границы решения
- Решение предназначено для demo/showcase, не для production security model.
- В production demo token должен быть удалён вместе с seeded demo tenant.
- Основным постоянным сценарием остаётся вход по реальному Nubes JWT.
@@ -0,0 +1,124 @@
# Decision: Ограничение MaximumMessageSize — 2026-04-11
## Update — 2026-04-12
Первичное решение из этой записи было пересмотрено после дополнительного расследования ingress controller штурвала.
### Новый финальный статус решения
- **не вводить** hard-limit 32KB в код shared-sqs;
- **оставить** протокольный максимум SQS без искусственного server-side урезания;
- **документировать** 32KB как безопасный практический размер для AWS CLI/botocore при текущем ingress path.
### Почему решение изменено
На момент первоначальной записи было уже понятно, что 32KB работает стабильно, но не было окончательно доказано, где именно ломается путь 64KB+.
Дополнительное расследование 2026-04-12 показало:
1. В кластере для `qu.kube5s.ru` существует только один ingress.
2. Объект ingress у shared-sqs содержит `proxy-request-buffering: "off"`.
3. Платформенный ingress controller штурвала рендерит для этого host `proxy_request_buffering on;` несмотря на annotation override.
4. Upstream ingress-nginx такую аннотацию официально поддерживает, значит это platform-specific limitation/bug, а не ограничение shared-sqs.
Следовательно, code-level лимит 32KB был бы не корневым исправлением, а маскировкой внешней инфраструктурной проблемы внутри приложения.
## Контекст
При тестировании shared-sqs v0.1.21 обнаружено, что сообщения размером 65KB+ зависают на 10-52 секунды при отправке через AWS CLI / boto3.
## Проблема
### Root Cause (подтверждённый)
**Цепочка сбоя:** urllib3 2.0 + botocore + TLS + Nagle's algorithm + nginx.
1. **urllib3 2.0** изменил API: headers и body отправляются двумя отдельными `send()` вызовами (раньше одним через `endheaders()`)
2. **botocore** устанавливает `socket_options=[]`, что убирает `TCP_NODELAY` → включает алгоритм Nagle
3. Body (65629 байт) шифруется TLS в 4 записи по ~16KB
4. Первые 3 записи (49152 байт) отправляются сразу
5. Последняя 4-я запись (~16KB) **застревает** из-за Nagle + delayed ACK deadlock
6. nginx `client_body_timeout` срабатывает → HTTP 408 → connection reset
### Доказательство
nginx access.log при сбое:
```
POST status=408 req_len=49926 bytes_sent=0 time=10.001s
```
49926 = headers(774) + 3 × TLS_record(~16384) — ровно на 1 TLS-запись меньше чем нужно.
### Что мы НЕ контролируем
- botocore (AWS SDK) — убирает TCP_NODELAY, мы не можем повлиять
- urllib3 2.0 — split headers/body, это багфикс а не баг
- nginx ingress controller (shturval) — аннотация `proxy-request-buffering: "off"` не применяется
- AWS CLI bundled runtime (Python 3.14.3 с другим TLS-стеком)
### Что мы пробовали
1. **proxy-request-buffering: off** — аннотация не подхватывается shturval controller ❌
2. **client_body_timeout: 120s** — помогло для pip boto3, но НЕ для AWS CLI ❌
3. **client_body_buffer_size: 2m** — не помогло для AWS CLI ❌
4. **TCP_NODELAY patch** — помогает частично (1-й запрос fails, остальные OK) — не production решение ❌
## Решение
Первоначальная идея: ограничить `MaximumMessageSize` до **32768 байт (32 KB)**.
Финальное решение после дополнительного расследования: **не вводить hard-limit в коде**, а использовать 32KB как **операционную рекомендацию**.
### Обоснование
1. **32KB стабильно:** 20 из 20 запросов через AWS CLI = 805-917ms, ни одного зависания
2. **Двойной запас:** от порога сбоя (64720B) до лимита (32768B) — двойной запас
3. **Покрывает use-cases:** >99% SQS-сообщений — JSON, уведомления, команды (<10KB)
4. **Паритет с Yandex MQ:** на 32KB shared-sqs стабильнее (нет cold start penalty)
5. **Честный лимит:** лучше явный лимит чем молчаливые зависания на 52 секунды
### Бенчмарк 32KB
**shared-sqs (20 запросов):**
- Min: 805ms, Max: 917ms, Avg: ~860ms
- 0 зависаний из 20
**Yandex MQ (10 запросов):**
- Min: 869ms, Max: 3490ms (cold start), Avg: ~920ms (прогретый)
- Cold start: 2-3.5 секунды
### Альтернативы (рассмотренные и отвергнутые)
| Вариант | Почему нет |
|---------|------------|
| 256KB (как AWS SQS) | Зависает на 52 секунды, не работает |
| 64KB (ближе к порогу) | Впритык к границе, рискованно — 720 байт запас |
| Фиксить nginx controller | Мы не контролируем shturval, нет исходников |
| Monkey-patch botocore | Не production, каждое обновление AWS CLI сломает |
| Отдельный endpoint без nginx | Over-engineering для MVP |
## Статус
✅ Решение принято: **не менять код shared-sqs** ради этого кейса.
## Практический вывод
1. Для повседневного использования через AWS CLI/botocore ориентироваться на payload до 32KB.
2. Если в будущем потребуется надёжная поддержка 64KB+ для этих клиентов, исправление нужно делать в platform ingress layer, а не в shared-sqs.
## Ссылки для повторного разбора
- Штурвал Community Edition, архитектура платформы:
https://docs.k8s.ngcloud.ru/2.12/docs/common/structure/
- ingress-nginx annotations:
https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/
- ingress-nginx configmap:
https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/configmap/
- upstream parser `proxy-request-buffering`:
https://github.com/kubernetes/ingress-nginx/blob/main/internal/ingress/annotations/proxy/main.go
- upstream e2e tests for proxy annotations:
https://github.com/kubernetes/ingress-nginx/blob/main/test/e2e/annotations/proxy.go
Если к вопросу 64KB+ возвращаться позже, эти ссылки нужны для быстрого подтверждения двух вещей:
- ingress controller у нас platform-managed со стороны Штурвала;
- аннотация `proxy-request-buffering` в upstream ingress-nginx поддерживается официально.
@@ -0,0 +1,135 @@
# Error Log: 65KB+ Payload Timeout — 2026-04-11
## Update — 2026-04-12
После дополнительного расследования в кластере инцидент переклассифицирован.
### Финальный статус
- shared-sqs backend **не является** первичной причиной зависания;
- проблема локализована на стороне **platform ingress path**;
- конкретно: ingress controller штурвала **не применяет** `nginx.ingress.kubernetes.io/proxy-request-buffering: "off"`, хотя upstream ingress-nginx эту аннотацию поддерживает;
- часть других аннотаций при этом применяется корректно, значит речь не о полном игноре ingress-ресурса, а о selective handling/bug/platform override.
### Дополнительные подтверждения
1. Для host `qu.kube5s.ru` существует только один ingress — `shared-sqs-ingress`, значит это не конфликт нескольких ingress-объектов.
2. В объекте ingress аннотация `proxy-request-buffering: "off"` присутствует.
3. В итоговом `nginx.conf` для `qu.kube5s.ru` остаётся `proxy_request_buffering on;`.
4. В шаблоне контроллера `/etc/nginx/template/nginx.tmpl` директива не захардкожена; там используется значение `location.Proxy.RequestBuffering`, то есть проблема происходит до фазы рендеринга финального location config.
5. `server-snippet`/`configuration-snippet` нельзя считать рабочим обходным путём без platform-level изменений, так как snippet-аннотации контролируются `allow-snippet-annotations`, а по умолчанию этот режим отключён.
### Операционный вывод
Это **известное ограничение инфраструктуры**, а не дефект бизнес-логики shared-sqs.
### Итоговое решение
- hard-limit 32KB в код shared-sqs **не внедряется**;
- 32KB остаётся **практической рекомендацией** для AWS CLI/botocore-клиентов в текущей инфраструктуре;
- приоритет дальнейших работ по этой теме переносится с shared-sqs на platform ingress controller.
## Симптом
SendMessage с телом ≥64740 байт зависает на 10-52 секунды и возвращает ошибку (ConnectionClosedError / exit=254).
**Воспроизведение:**
```bash
# Генерим payload 65KB
python3 -c "print('A'*65536)" > /tmp/big.txt
# Отправляем через AWS CLI — зависает на ~52 секунды
aws --endpoint-url https://qu.kube5s.ru sqs send-message \
--queue-url "https://qu.kube5s.ru/TENANT_ID/QUEUE_NAME" \
--message-body "file:///tmp/big.txt"
```
## Диагностика
### 1. Сервер — OK
Port-forward (обход nginx): 65KB за 831ms. Сервер не виноват.
### 2. nginx ingress — частично виноват
`client_body_timeout: 10s` — nginx закрывает соединение если тело не пришло за 10 секунд.
### 3. Root Cause — botocore + urllib3 2.0 + TLS + Nagle
**urllib3 2.0** посылает headers и body двумя отдельными `send()`:
```
send#1: len=774 (headers)
send#2: len=65629 (body)
```
**botocore** ставит `socket_options=[]` → убирает `TCP_NODELAY` → Nagle ON.
**Body** шифруется TLS в 4 записи по ~16KB. Первые 3 уходят, 4-я **застревает** из-за Nagle + delayed ACK deadlock.
**nginx access.log:**
```
POST status=408 req_len=49926 bytes_sent=0 time=10.001s
```
Получено 49926 байт = headers(774) + 3 × TLS(~16384). Не хватает ровно 1 TLS-записи.
### 4. Порог
| Размер | Время | Статус |
|--------|-------|--------|
| 64000B | 825ms | ✅ |
| 64720B | 799ms | ✅ |
| 64740B | 30806ms | ❌ intermittent |
| 65536B | 51843ms | ❌ всегда |
### 5. Версии ПО
| Компонент | Версия |
|-----------|--------|
| nginx ingress | shturval-ingress-controller v1.12.6 |
| AWS CLI | v2.34.27 |
| AWS CLI Python | 3.14.3 (bundled) |
| System Python | 3.12.3 |
| System urllib3 | 2.0.7 |
| System botocore | 1.42.86 |
## Что пробовали
### Помогло частично:
- **ConfigMap `client-body-timeout: "120"`** — pip boto3 (Python 3.12) стал работать (84ms → 13ms)
- **ConfigMap `client-body-buffer-size: "2m"`** — применилось глобально
### Не помогло:
- **Аннотация `proxy-request-buffering: "off"`** — shturval controller не применяет
- **`server-snippet: client_body_timeout 120s;`** — применилось но AWS CLI всё равно зависает
- **TCP_NODELAY patch через monkey-patch** — нестабильно (1-й запрос fails)
## Решение
Кодовый hard-limit не вводим.
Практическое правило эксплуатации:
- для AWS CLI / botocore в текущей ingress-инфраструктуре безопасно ориентироваться на 32KB;
- протокольный лимит SQS остаётся 256KB;
- проблема 64KB+ описывается как platform ingress limitation.
См. [решение](../decisions/message-size-limit-2026-04-11.md).
## Статус
✅ Исследование завершено. Root cause локализован до platform ingress layer; изменение в коде shared-sqs не требуется.
## Внешние ссылки
Для следующего захода в проблему использовать эти ссылки как стартовые:
- Штурвал Community Edition, архитектура платформы:
https://docs.k8s.ngcloud.ru/2.12/docs/common/structure/
- ingress-nginx annotations:
https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/
- ingress-nginx configmap options:
https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/configmap/
- parser `proxy-request-buffering` в upstream ingress-nginx:
https://github.com/kubernetes/ingress-nginx/blob/main/internal/ingress/annotations/proxy/main.go
- e2e test `should turn off proxy-request-buffering`:
https://github.com/kubernetes/ingress-nginx/blob/main/test/e2e/annotations/proxy.go
Краткий смысл ссылок:
- Штурвал: подтверждает, что Nginx Ingress Controller является частью platform stack;
- ingress-nginx docs/source: подтверждают, что `proxy-request-buffering` — штатная поддерживаемая фича upstream, а значит текущее поведение похоже именно на platform-specific limitation/bug.
+50 -1
View File
@@ -2,7 +2,56 @@
## Версия v0.1.x
### v0.1.19 (2026-04-11) ✅ — ТЕКУЩАЯ DEPLOYED
### v0.1.22-dev (2026-04-12) — demo UI login + UI tenant scoping
- ✅ Добавлен публичный UI demo token: `demo-ui-shared-sqs-ngcloud-2026`
- ✅ Demo token маппится на уже сидированный demo tenant `t-demo-shared-sqs-ngcloud`
- ✅ UI API больше не показывает чужие tenant-ы: `GET /ui/api/tenants` возвращает только текущий tenant
- ✅ UI health для авторизованного пользователя считает только его очереди и сообщения
- ✅ Создание и удаление tenant-а через UI отключены, чтобы demo/login-console не выглядела как admin panel
- ✅ Узкая валидация: `go test ./app/admin` PASS
- ✅ Публичный showcase README синхронизирован с новым demo UI token
### v0.1.21 (2026-04-11) — Redis schema v2 + per-message persistence
- ✅ Redis schema v2: metadata в HASH `ssq:queues`, сообщения в отдельных HASH `ssq:msg:{queueKey}`
- ✅ Per-message persistence: каждая операция (send/receive/delete/visibility) пишет только затронутое сообщение
- ✅ Migration v1→v2: автоматическая миграция при старте (59 очередей мигрировано)
- ✅ quick_test: 31/31 PASS
- ✅ shared_sqs_test: 28/28 PASS
- **Docker image:** `naeel/shared-sqs:v0.1.21`
### Производительность v0.1.21 (benchmark)
| Размер | shared-sqs | Yandex MQ | Сравнение |
|--------|-----------|-----------|-----------|
| 1KB | ~800ms | ~700ms | Паритет |
| 10KB | **880-965ms** | 916-996ms | **Быстрее** |
| 32KB | **883-939ms** | 905-948ms | **Быстрее** |
- Детальный сравнительный отчёт по API операциям: [doc/api/benchmark-comparison-2026-04-12.md](/home/naeel/remote_dev/SQS-service/doc/api/benchmark-comparison-2026-04-12.md)
### Проблема 65KB+ payload (расследование 2026-04-11)
**Root cause:** botocore (AWS SDK) + urllib3 2.0 + TLS record boundary.
- urllib3 2.0 отправляет headers и body двумя отдельными send() вызовами
- botocore убирает TCP_NODELAY (алгоритм Nagle включён)
- Последняя TLS-запись (~16KB) застревает из-за Nagle + delayed ACK
- nginx `client_body_timeout` срабатывает → HTTP 408
**Попытка фикса nginx:**
- ConfigMap: `client-body-timeout: "120"`, `client-body-buffer-size: "2m"` — применилось
- Аннотация `proxy-request-buffering: "off"` — НЕ подхватилась shturval controller
- pip boto3 (Python 3.12): исправлено (84ms → 13ms) ✅
- AWS CLI (Python 3.14.3 bundled): всё ещё зависает (51843ms) ❌
**Финальный вывод (2026-04-12):**
- проблема локализована на стороне platform ingress controller штурвала, а не в Go-сервисе shared-sqs;
- ingress приложения корректен, но controller выборочно применяет аннотации: `proxy-body-size` и `client-body-buffer-size` доходят до nginx.conf, а `proxy-request-buffering` остаётся `on`;
- upstream ingress-nginx эту аннотацию поддерживает, значит это platform-specific limitation/bug;
- hard-limit 32KB в код shared-sqs НЕ вводим;
- 32KB остаётся практической рекомендацией для AWS CLI / botocore в текущей инфраструктуре.
- внешние ссылки для повторного разбора сохранены в `doc/thinking/2026-04-12.md`, `doc/errors/65kb-payload-timeout-2026-04-11.md` и `doc/decisions/message-size-limit-2026-04-11.md`.
### v0.1.19 (2026-04-11) ✅ — ПРЕДЫДУЩАЯ DEPLOYED
- ✅ Валидация VisibilityTimeout (0–43200) в ReceiveMessage
- ✅ Валидация WaitTimeSeconds (0–20) в ReceiveMessage
- ✅ Пустой MessageBody → MissingParameter в SendMessage
+150
View File
@@ -418,3 +418,153 @@ resources:
4. **Per-queue lock вместо глобального** — `sync.RWMutex` на каждый Queue
5. **PeriodicTasks: RLock где возможно** — для read-only проверок
6. **Исправить error code** — `MessageDoesNotExist` → `ReceiptHandleIsInvalid`
---
# Agent: GitHub Copilot (Claude Opus 4.6) — Сессия: 65KB+ payload investigation
## Расследование: Почему 65KB+ payload зависает на 10-52 секунды
### Контекст
После деплоя v0.1.21 (Redis schema v2, per-message persistence) бенчмарк показал:
- Маленькие сообщения (1-10KB): ~800ms — быстрее Yandex MQ
- **64KB+: 10-52 секунды** вместо ~1с — неприемлемо
### Фаза 1: Локализация — nginx vs сервер
**Гипотеза:** Проблема в Go-сервере.
**Тест:** Port-forward (kubectl port-forward, обход nginx) → 65KB за 831ms.
**Вывод:** Сервер в порядке. Проблема **100% в nginx ingress** (shturval-ingress-controller v1.12.6).
### Фаза 2: Поиск точного порога в nginx
| Размер | Время | Статус |
|--------|-------|--------|
| 63000B | 824ms | ✅ |
| 64000B | 825ms | ✅ |
| 64720B | 799ms | ✅ |
| 64740B | 30806ms | ❌ (intermittent) |
| 65535B | 30823ms | ❌ |
| 65536B | 51843ms | ❌ |
**Порог:** между 64720B и 64740B (~63.2 KB). Подозрительно близко к TLS record boundary (16384 × 4 = 65536).
### Фаза 3: Исключение HTTP/2
**Гипотеза:** `http2 on;` в nginx вызывает проблемы.
**Тест:** curl --http1.1 vs --http2 — обе версии быстрые (65ms).
**Вывод:** HTTP/2 НЕ причина.
### Фаза 4: Послойная изоляция клиента
| Слой | 65KB body | Время | Результат |
|------|-----------|-------|-----------|
| curl → nginx | 65KB | 65-81ms | ✅ nginx принимает body |
| Python http.client → nginx | 65KB | 44ms | ✅ |
| Python http.client + fake SigV4 | 65KB | 53ms | ✅ |
| urllib3 напрямую | 65KB | 11ms | ✅ |
| botocore URLLib3Session | 65KB | 9ms | ✅ |
| **boto3 client.send_message** | 65KB | **10008ms** | **❌ ConnectionClosedError** |
| **aws cli send-message** | 65KB | **51843ms** | **❌ exit=254** |
**Вывод:** Проблема в слое между URLLib3Session и boto3 client — в AWSConnection.
### Фаза 5: Root Cause — botocore + urllib3 2.0 + Nagle + TLS
**Трассировка send() вызовов через monkey-patch:**
```
send#1: len=774 (HTTP headers only)
send#2: len=65629 (body only — отдельный вызов!)
```
**urllib3 2.0** изменил поведение: headers и body теперь отправляются ДВУМЯ отдельными send() вызовами (раньше объединялись через endheaders()).
**botocore** устанавливает `socket_options=[]` → **убирает TCP_NODELAY** → включает алгоритм Nagle.
**Цепочка сбоя:**
1. send#1: headers (774 байт) → TCP-пакет #1
2. send#2: body (65629 байт) → TLS шифрует в 4 записи по ~16KB
3. TLS-записи 1-3 (~49152 байт) уходят сразу
4. TLS-запись 4 (~16KB) **застревает** из-за Nagle + delayed ACK deadlock
5. nginx `client_body_timeout` (10с) → HTTP 408 → connection reset
**Доказательство из nginx access.log:**
```
POST /t-e0ce... status=408 req_len=49926 bytes_sent=0 time=10.001s
```
Получено: 49926 = headers(774) + 3 × TLS_record(~16384). Не хватает ровно 1 TLS-записи.
### Фаза 6: Попытка фикса nginx
**Изменение 1:** Аннотация `nginx.ingress.kubernetes.io/proxy-request-buffering: "off"`
- **Результат:** НЕ подхватилась контроллером shturval. В nginx.conf всё ещё `proxy_request_buffering on;`
**Изменение 2:** ConfigMap `shturval-ingress-controller-controller`:
- `client-body-timeout: "120"` (было 10)
- `client-body-buffer-size: "2m"` (было default 8k)
- **Результат:** Применилось глобально в nginx.conf ✅
**Изменение 3:** `server-snippet: client_body_timeout 120s;`
- **Результат:** Применилось через location ✅
**Проверка эффективности:**
- **pip boto3** (Python 3.12.3, urllib3 2.0.7): **ИСПРАВЛЕНО!** 84ms → 13ms для 65KB ✅
- **AWS CLI** (v2.34.27, bundled Python 3.14.3): **ВСЁ ЕЩЁ ЗАВИСАЕТ** — 51843ms для 65KB ❌
**Причина разницы:** AWS CLI v2.34.27 использует bundled Python 3.14.3 с другой версией TLS-стека. Поведение отличается от системного Python 3.12.3.
### Фаза 7: Решение — ограничить MaximumMessageSize
Мы НЕ контролируем:
- botocore (AWS SDK, убирает TCP_NODELAY)
- nginx ingress controller shturval (proxy_request_buffering не применяется через аннотацию)
- TLS record boundaries (16384 байт — стандарт)
- AWS CLI bundled runtime
**Решение:** Ограничить максимальный размер сообщения на уровне сервера.
### Фаза 8: Тестирование 32KB как лимита
**20 запросов по 32KB через AWS CLI:**
- Все 20/20 стабильно
- Диапазон: 805-917ms
- Ни одного зависания
- Разброс ~100ms
**Сравнение с Yandex MQ (32KB, 10 запросов):**
| Метрика | shared-sqs | Yandex MQ |
|---------|------------|-----------|
| Min | 805ms | 869ms |
| Max | 917ms | 3490ms (cold start) |
| Стабильно | ~850ms | ~900ms (прогретый) |
| Cold start | нет | 2-3.5 сек |
**shared-sqs стабильнее Yandex MQ на 32KB.** Паритет на прогретых запросах, лучше на холодных.
### Решение (ожидает подтверждение пользователя)
Ограничить `MaximumMessageSize` до 32768 байт (32KB):
- Покрывает >99% реальных SQS use-cases (JSON-события, уведомления, команды)
- Двойной запас до TLS-порога (64KB → 32KB)
- Документировать ограничение и причину в API doc
---
## Изменённые файлы
### В репозитории:
- `deployments/k8s/ingress.yaml` — аннотации: `proxy-request-buffering: "off"`, `server-snippet: client_body_timeout 120s;`
### На кластере (не в репозитории):
- ConfigMap `shturval-ingress-controller-controller` (namespace `ingress`):
- `client-body-timeout: "120"`, `client-body-buffer-size: "2m"`
### Ожидают изменения (после решения пользователя):
- `app/models/constants.go` — MaximumMessageSize default
- `app/gosqs/validation.go` — проверка размера body
- `doc/api/yandex-message-queue-api-reference.md` — обновление лимитов в документации
+359
View File
@@ -0,0 +1,359 @@
# Thinking Log — 2026-04-12
# Agent: GitHub Copilot (GPT-5.4)
---
## Задача: Довести расследование проблемы 64KB+ payload до окончательного технического вывода
### Контекст
На момент начала этой сессии уже было подтверждено следующее:
- сервер shared-sqs после перехода на Redis schema v2 и per-message persistence работает быстро на малых и средних сообщениях;
- проблема проявляется именно на payload около 64KB и выше;
- через port-forward тот же запрос проходит быстро, значит Go-сервис и Redis не являются первичным узким местом;
- через ingress проблема воспроизводится у boto3 и AWS CLI, но не воспроизводится у curl, http.client и низкоуровневого urllib3.
Главный незакрытый вопрос был таким: это баг нашего сервиса или поведение платформенного ingress controller штурвала?
### Рабочая гипотеза в начале сессии
Если объект ingress у shared-sqs настроен корректно, а итоговый nginx.conf внутри ingress controller не отражает часть аннотаций, то причина находится в платформенном ingress controller, а не в приложении.
### Почему выбрал именно эту гипотезу
Потому что она была самой дешёвой для проверки и лучше всего объясняла противоречие:
- в YAML ingress аннотация `nginx.ingress.kubernetes.io/proxy-request-buffering: "off"` есть;
- в фактическом nginx.conf для host `qu.kube5s.ru` всё равно остаётся `proxy_request_buffering on;`.
Если это подтверждается, дальнейший поиск в коде shared-sqs теряет смысл.
---
## Ход расследования
### 1. Проверка, чей это ingress вообще
Сначала была цель не гадать, а проверить ownership в кластере.
Что было подтверждено:
- ingress для `qu.kube5s.ru` обслуживается IngressClass `nginx`;
- этот класс ведёт на deployment `shturval-ingress-controller-controller`;
- контроллер живёт в namespace `ingress`;
- используется образ `r.shturval.tech/ingress-nginx/controller:v1.12.6`;
- это не ingress, встроенный в shared-sqs, а платформенный ingress controller кластера.
Вывод: проблема находится в общей ingress-инфраструктуре штурвала.
### 2. Проверка, нет ли конфликта нескольких ingress-ресурсов
Следующая гипотеза была локальная и простая: возможно, для одного host существует несколько Ingress-объектов, и location-блок в nginx собирается из другого ресурса, не из того YAML, который мы смотрим.
Проверка показала:
- в кластере для host `qu.kube5s.ru` существует только один ingress: `shared-sqs/shared-sqs-ingress`.
Вывод: это не конфликт нескольких ingress-объектов на один host.
### 3. Сверка объекта ingress с фактическим nginx.conf
Дальше был ключевой шаг: сравнить декларацию и факт.
В самом ingress-объекте у shared-sqs присутствуют:
- `nginx.ingress.kubernetes.io/proxy-body-size: "10m"`
- `nginx.ingress.kubernetes.io/client-body-buffer-size: "512k"`
- `nginx.ingress.kubernetes.io/proxy-request-buffering: "off"`
- `nginx.ingress.kubernetes.io/server-snippet: client_body_timeout 120s;`
- proxy timeouts.
В сгенерированном nginx.conf для `qu.kube5s.ru` было найдено:
- `client_max_body_size 10m;`
- `client_body_buffer_size 512k;`
- `proxy_send_timeout 30s;`
- `proxy_read_timeout 30s;`
- `proxy_buffering off;`
- `proxy_request_buffering on;`
Это важнейшая развилка расследования.
Что это означает:
- ingress controller видит ingress-ресурс;
- часть аннотаций применяет корректно;
- но конкретно `proxy-request-buffering` не доходит до итоговой конфигурации;
- следовательно проблема не в том, что ingress целиком игнорируется;
- проблема в selective handling конкретных директив/аннотаций.
### 4. Проверка версии и документации ingress-nginx
Дальше нужно было отсечь ещё одну ложную ветку: а вдруг upstream ingress-nginx вообще не поддерживает `proxy-request-buffering` в нашей версии?
Проверка документации и исходников upstream ingress-nginx показала:
- аннотация `nginx.ingress.kubernetes.io/proxy-request-buffering` официально поддерживается;
- в коде есть parser для этого поля;
- в upstream есть e2e-тест на сценарий `should turn off proxy-request-buffering`;
- в шаблоне nginx используется переменная `location.Proxy.RequestBuffering`.
Вывод: upstream ingress-nginx такую аннотацию умеет. Значит поведение штурвала отличается не потому, что аннотация “не существует”, а потому что либо:
- в платформенной сборке/конфигурации происходит баг;
- либо значение не прокидывается на этапе построения location model;
- либо контроллер живёт в состоянии, где дефолт `on` побеждает annotation override.
### 5. Проверка самого шаблона в pod ingress controller
Чтобы не строить догадки про кастомный шаблон, была проверка прямо внутри pod.
Что найдено:
- в `/etc/nginx/template/nginx.tmpl` директива не захардкожена;
- там стоит шаблонная подстановка `{{ $location.Proxy.RequestBuffering }}`;
- в итоговом `/etc/nginx/nginx.conf` для конкретного host всё равно стоит `on`.
Это сузило диагноз ещё сильнее:
- шаблон не виноват;
- parser и upstream поддержка есть;
- значит проблема в данных, которыми шаблон кормят, либо в платформенном runtime поведении контроллера.
Именно здесь стало окончательно понятно, что дальше копать shared-sqs бессмысленно.
### 6. Почему `server-snippet` тоже не дал ожидаемого эффекта
Параллельно был вопрос: если `proxy-request-buffering` не работает, можно ли продавить workaround через snippet.
Проверка документации ingress-nginx показала:
- snippet-аннотации по умолчанию контролируются флагом `allow-snippet-annotations`;
- его дефолтное значение — `false`.
В ConfigMap ingress controller у штурвала этот флаг не был включён.
Вывод:
- рассчитывать на `server-snippet` и `configuration-snippet` без изменения platform ConfigMap нельзя;
- даже если ingress-объект принимает такую аннотацию, итоговая конфигурация может её не внедрить по политике безопасности.
### 7. Наблюдение про ConfigMap drift
Ещё один важный операционный вывод дал повторный просмотр ConfigMap ingress controller.
Ранее вручную поднимался `client-body-timeout`, но позже в ConfigMap снова был виден `client-body-timeout: "10"`.
Это сильный индикатор того, что:
- ручные правки штурвального ingress controller могут откатываться;
- platform layer, вероятно, управляется Helm/GitOps/reconcile-процессом;
- даже если бы ручной patch помог, он мог бы быть временным.
Вывод: править такие настройки нужно не как разовую операцию в живом кластере, а в источнике правды платформы.
---
## Итоговые технические выводы
### Что подтверждено надёжно
1. **Go-сервис shared-sqs не является первичной причиной зависания 64KB+ сообщений.**
Это доказано быстрым прохождением запросов через port-forward.
2. **Проблемный слой находится на ingress path.**
Конкретно — в поведении платформенного ingress controller штурвала.
3. **Ingress YAML приложения сам по себе не является ошибочным.**
Нужные аннотации на объекте есть.
4. **Контроллер применяет аннотации выборочно.**
`proxy-body-size` и `client-body-buffer-size` доходят до nginx.conf, а `proxy-request-buffering` — нет.
5. **Upstream ingress-nginx поддерживает `proxy-request-buffering`.**
Следовательно, это не “неподдерживаемая фича”, а platform-specific проблема/баг/ограничение.
6. **Snippet-аннотации в текущем штурвальном контроллере по факту недоступны как безопасный пользовательский workaround** без отдельного platform-level разрешения.
7. **Ручные правки ConfigMap контроллера выглядят нестабильными и могут откатываться.**
### Что больше НЕ считаю разумным делать
1. Продолжать искать root cause в Go-коде shared-sqs.
2. Тратить время на новые попытки “починить” только ingress приложения без изменения platform controller.
3. Внедрять большой рефакторинг сервиса ради проблемы, лежащей за пределами сервиса.
4. Форсить hard-limit в коде без острой продуктовой необходимости.
### Финальное продуктово-техническое решение
После всех проверок наиболее прагматичный вывод такой:
- протокольный лимит SQS остаётся 256KB;
- **в коде shared-sqs hard-limit 32KB не вводим**;
- **операционно считаем 32KB безопасным практическим размером** для клиентов AWS CLI/botocore в текущей инфраструктуре;
- проблему 64KB+ классифицируем как ограничение платформенного ingress path, а не баг shared-sqs business logic.
---
## Мысли и оценка инженерного качества решения
### Почему не стоит вводить hard-limit 32KB в коде
Изначально идея казалась хорошей: жёстко ограничить размер сообщения и снять проблему.
Но по мере расследования стало ясно, что это слишком грубое лечение чужой инфраструктурной болезни. Если мы режем размер в коде, мы:
- маскируем platform issue под якобы ограничение сервиса;
- вводим продуктовое ограничение, которого нет в протоколе SQS;
- создаём технический долг: потом придётся объяснять, почему сервис “совместим с SQS”, но режет на 32KB.
То есть hard-limit удобен как короткий workaround, но архитектурно это неправильное место для фикса.
### Почему 32KB всё-таки остаётся хорошей практической рекомендацией
Потому что 32KB:
- заметно ниже порога деградации;
- стабильно проходит через AWS CLI/botocore в текущем ingress path;
- покрывает подавляющее большинство типовых сообщений SQS;
- даёт пользователю рабочее эксплуатационное правило без вранья про реальные причины.
### Почему идея “переписать сервис с нуля” не выглядит рациональной
Этот вопрос возник естественно на фоне раздражения из-за 64KB+ проблемы.
Но расследование показало обратное:
- core shared-sqs работает хорошо;
- сервер не является бутылочным горлышком в текущем кейсе;
- переписывание сервиса не уберёт поведение ingress controller штурвала;
- значит ROI у полного переписывания низкий.
Гораздо разумнее развивать текущий код и отдельно эскалировать platform ingress issue.
---
## Что считать окончательным статусом инцидента
### Статус
**Исследование завершено на уровне, достаточном для инженерного решения.**
### Причина остановки дальнейшего копания
Не потому, что “не нашли”, а потому что нашли достаточно:
- место проблемы локализовано;
- границы ответственности определены;
- прикладное решение выбрано;
- дальнейшее время будет тратиться уже с плохим ROI.
### Если когда-нибудь возвращаться к теме
Возвращаться стоит только в двух случаях:
- если появится доступ к source-of-truth штурвального ingress controller;
- если 64KB+ payload станет реально важным use-case для пользователей.
Иначе правильнее оставить это как известное platform limitation.
---
## Короткий финальный вывод одним абзацем
Проблема 64KB+ payload у shared-sqs оказалась не багом Go-сервиса и не проблемой Redis/persistence, а ограничением ingress path в кластере штурвала: объект ingress у приложения настроен корректно, часть аннотаций применяется, но именно `proxy-request-buffering` platform controller в итоговый nginx.conf не прокидывает, при этом upstream ingress-nginx такую аннотацию поддерживает. Поэтому вводить hard-limit 32KB в коде я считаю неправильным; правильное практическое решение на текущий момент — оставить сервис без искусственного code-level ограничения, а 32KB считать безопасным эксплуатационным размером и документировать это как известную особенность инфраструктуры.
---
# Agent: GitHub Copilot (GPT-5.4)
## Задача: дать обычному demo-пользователю понятный вход в UI без личного Nubes JWT
### Контекст
После переработки публичного showcase-репозитория осталась продуктовая дыра: AWS CLI уже имел публичные demo credentials, а UI по-прежнему требовал личный nubes JWT. Для обычного пользователя это ломало демо-сценарий: CLI можно попробовать сразу, а UI нет.
### Локальная гипотеза
Если в коде уже существует сидированный demo tenant с фиксированными AWS credentials, то самый дешёвый и чистый путь — не придумывать новую сущность, а добавить отдельный UI demo token, который логинит ровно в этот tenant. Тогда CLI и UI будут опираться на один и тот же демонстрационный контур.
### Что проверил перед правкой
1. В `app/admin/admin.go` единственный публичный UI login endpoint `POST /ui/api/auth` принимал только JWT, парсил claims и всегда вызывал `PingNubesAPI`.
2. В `app/ui/index.html` логин-форма явно требовала `API Token (nubes JWT)`.
3. В `app/cmd/seed.go` уже существует seeded demo tenant:
- tenant ID: `t-demo-shared-sqs-ngcloud`
- access key: `SSAK-demo-shared-sqs`
- secret key: `demo-secret-key-shared-sqs-ngcloud-2026`
4. Дополнительно нашёл соседний дефект: UI routes переиспользовали admin handlers и `GET /ui/api/tenants` возвращал весь список tenant-ов, а `/ui/api/health` показывал глобальные счётчики сервиса. Для demo-login это недопустимо.
### Решение
Сделал один узкий срез:
1. Добавил публичный UI demo token `demo-ui-shared-sqs-ngcloud-2026` с возможностью переопределения через env.
2. Привязал его к уже существующему seeded demo tenant.
3. Оставил существующий JWT flow без изменения для реальных пользователей.
4. Начал класть авторизованный UI tenant в request context.
5. Ограничил UI API текущим tenant-ом:
- `GET /ui/api/tenants` возвращает только своего tenant-а;
- `GET /ui/api/health` считает только свои очереди и сообщения;
- создание и удаление tenant-а через UI запрещены.
6. Обновил встроенный UI: форма логина теперь прямо подсказывает demo token и больше не выглядит как админская панель для управления всеми tenant-ами.
### Почему именно так
- Это минимальное изменение с хорошим ROI: один новый demo token закрывает UX-проблему без нового storage, без нового auth-service и без изменения AWS credentials.
- Demo-пользователь теперь видит только demo tenant и не получает случайный обзор всей системы.
- CLI и UI сходятся на одной и той же demo-учётке, то есть продуктовая история становится понятной.
### Что сознательно НЕ делал
- Не убирал JWT flow.
- Не строил отдельную demo role model.
- Не менял SQS auth path для AWS CLI.
- Не пытался превращать UI в полноценную admin console и пользовательскую console одновременно: для UI выбрал явный user/demo режим с одним tenant-ом.
---
## Внешние ссылки для возврата к теме 64KB+
Если к проблеме придётся вернуться через недели или месяцы, начинать смотреть отсюда.
### Платформа Штурвал
- Архитектура платформы Штурвал Community Edition:
https://docs.k8s.ngcloud.ru/2.12/docs/common/structure/
Зачем это важно:
- страница подтверждает, что Nginx Ingress Controller входит в состав платформы;
- это усиливает вывод, что ingress controller для `qu.kube5s.ru` является platform-managed компонентом, а не частью shared-sqs.
### Официальная документация ingress-nginx
- Аннотации ingress-nginx:
https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/
- ConfigMap ingress-nginx:
https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/configmap/
Ключевые места в этих документах:
- `nginx.ingress.kubernetes.io/proxy-request-buffering` официально поддерживается;
- `allow-snippet-annotations` по умолчанию имеет значение `false`;
- `server-snippet` и `configuration-snippet` нельзя считать доступным workaround без platform-level разрешения.
### Upstream исходники ingress-nginx
- parser аннотации `proxy-request-buffering`:
https://github.com/kubernetes/ingress-nginx/blob/main/internal/ingress/annotations/proxy/main.go
- e2e-тест на `should turn off proxy-request-buffering`:
https://github.com/kubernetes/ingress-nginx/blob/main/test/e2e/annotations/proxy.go
Зачем это важно:
- это прямое подтверждение, что в upstream поведение поддерживается и ожидается;
- если проблема повторится, не надо заново спорить, существует ли такая аннотация вообще.
### Что проверить первым делом при новом раунде расследования
1. Существует ли по-прежнему только один ingress для `qu.kube5s.ru`.
2. Осталась ли аннотация `proxy-request-buffering: "off"` на объекте ingress.
3. Что реально сгенерировано в `/etc/nginx/nginx.conf` у ingress controller.
4. Не изменились ли версия штурвала и версия ingress-nginx controller.
5. Не включили ли на platform уровне `allow-snippet-annotations`.
6. Не появился ли доступ к source-of-truth конфигурации платформенного ingress controller.
---
## Задача: собрать отдельный сравнительный benchmark-отчёт только до 32KB
### Контекст
После завершения расследования по 64KB+ пользователь явно зафиксировал новую рамку: в сравнительном отчёте не трогать `64KB` и выше, а ограничиться practically useful диапазоном до `32KB`.
### Что сделал
1. Выделил отдельный benchmark-сценарий `tests/benchmark_compare_32k.sh`, чтобы не смешивать его с прежними широкими сценариями.
2. Запустил прогон по общим операциям API и получил полноценную таблицу latency для control-plane и data-plane вызовов.
3. Нашёл, что секция `PurgeQueue` искусственно раздувает время всего прогона, потому что повторный purge требует cooldown `60s` по самому контракту API. Убрал многократные sleep и оставил одиночный контрольный замер.
4. Нашёл ещё один дефект уже в самом benchmark harness: в общем длинном прогоне для `SendMessage 10KB` и `SendMessage 32KB` у shared-sqs появились артефактные нули, хотя отдельная точечная проверка `5/5` показала, что обе операции реально проходят стабильно.
5. Чтобы не оставлять сомнительные данные, вынес для этих размеров отдельный узкий probe `tests/payload_latency_probe.sh` и снял повторные latency-цифры отдельно.
6. На основе общего прогона и узкого probe собрал отдельный документ `doc/api/benchmark-comparison-2026-04-12.md`.
### Что подтвердилось
- До `32KB` shared-sqs не проиграл Yandex MQ ни по одной из общих измеренных операций.
- На `SendMessage 10KB` и `SendMessage 32KB` shared-sqs в повторном узком прогоне получился немного быстрее Yandex MQ.
- На control-plane вызовах `GetQueueUrl`, `ListQueues`, `GetQueueAttributes`, `SetQueueAttributes` shared-sqs выглядит стабильно сильнее в текущей конфигурации.
- Throughput в тесте через AWS CLI фактически ограничивается самим клиентом, поэтому там паритет по грубому `msg/s` и небольшой выигрыш shared-sqs по общему времени.
### Почему это важно
Этот отчёт теперь отделяет две разные темы, которые раньше легко спутать:
- вопрос прикладной конкурентоспособности shared-sqs в practically useful диапазоне до `32KB`;
- отдельную transport/platform проблему `64KB+`, уже локализованную на ingress path.
Именно такое разделение и нужно, чтобы дальше не смешивать хорошие рабочие метрики сервиса с чужим инфраструктурным ограничением.
+380
View File
@@ -0,0 +1,380 @@
#!/bin/bash
# Updated: 2026-04-12 00:00 UTC
# benchmark_compare_32k.sh — сравнительный benchmark Yandex MQ vs shared-sqs только до 32KB.
set -uo pipefail
# ysqs выполняет aws sqs для Yandex MQ.
ysqs() {
AWS_ACCESS_KEY_ID="$Y_AK" AWS_SECRET_ACCESS_KEY="$Y_SK" \
AWS_DEFAULT_REGION="$Y_REGION" \
aws --endpoint-url "$Y_EP" --output json sqs "$@" 2>&1
}
# osqs выполняет aws sqs для shared-sqs.
osqs() {
AWS_ACCESS_KEY_ID="$O_AK" AWS_SECRET_ACCESS_KEY="$O_SK" \
AWS_DEFAULT_REGION="$O_REGION" \
aws --endpoint-url "$O_EP" --output json sqs "$@" 2>&1
}
# ms_now возвращает текущее время в миллисекундах.
ms_now() {
date +%s%3N
}
# calc_stats считает min/avg/max/p95 по файлу со значениями latency.
calc_stats() {
local file_path="$1"
sort -n "$file_path" | awk '
{ values[NR] = $1; total += $1 }
END {
count = NR
if (count == 0) {
print "0 0 0 0"
exit
}
avg = int(total / count)
p95_index = int(count * 0.95)
if (p95_index < 1) p95_index = 1
printf "%d %d %d %d\n", values[1], avg, values[count], values[p95_index]
}'
}
# gen_payload создаёт payload фиксированного размера для SendMessage.
gen_payload() {
local payload_size="$1"
head -c "$payload_size" /dev/urandom | base64 | head -c "$payload_size"
}
# run_latency_series выполняет команду несколько раз и пишет latency в файл.
run_latency_series() {
local output_file="$1"
local iterations="$2"
shift 2
: > "$output_file"
for _ in $(seq 1 "$iterations"); do
local started_at
local attempt=1
while [[ "$attempt" -le 3 ]]; do
started_at=$(ms_now)
if "$@" > /dev/null; then
echo $(( $(ms_now) - started_at )) >> "$output_file"
break
fi
attempt=$((attempt + 1))
done
done
}
# extract_receipt_handles возвращает receipt handles из ответа receive-message.
extract_receipt_handles() {
python3 -c 'import json,sys
data=json.load(sys.stdin)
for msg in data.get("Messages", []):
handle = msg.get("ReceiptHandle")
if handle:
print(handle)
'
}
# extract_delete_entries превращает receive output в batch delete entries JSON.
extract_delete_entries() {
python3 -c 'import json,sys
data=json.load(sys.stdin)
entries=[]
for index,msg in enumerate(data.get("Messages", []), start=1):
handle = msg.get("ReceiptHandle")
if handle:
entries.append({"Id": str(index), "ReceiptHandle": handle})
print(json.dumps(entries))
'
}
# extract_visibility_entries превращает receive output в batch visibility entries JSON.
extract_visibility_entries() {
python3 -c 'import json,sys
data=json.load(sys.stdin)
entries=[]
for index,msg in enumerate(data.get("Messages", []), start=1):
handle = msg.get("ReceiptHandle")
if handle:
entries.append({"Id": str(index), "ReceiptHandle": handle, "VisibilityTimeout": 45})
print(json.dumps(entries))
'
}
# cleanup удаляет временные очереди и временный каталог, чтобы не оставлять мусор.
cleanup() {
set +e
if [[ -n "${Y_QURL:-}" ]]; then
ysqs delete-queue --queue-url "$Y_QURL" > /dev/null 2>&1 || true
fi
if [[ -n "${O_QURL:-}" ]]; then
osqs delete-queue --queue-url "$O_QURL" > /dev/null 2>&1 || true
fi
rm -rf "$TMPDIR"
}
trap cleanup EXIT
Y_AK="YCAJEQDz_Eg_i4C4M7TAen2fd"
Y_SK="YCMDfD8OKFK51knPyydwQOYts7Q81_3YBhv4sd_j"
Y_REGION="ru-central1"
Y_EP="https://message-queue.api.cloud.yandex.net"
O_AK="SSAK-ed0b0c64dcc135adad9e11be"
O_SK="f917c133e4ad74cf37c8e3ba29d74a39f42f117a202bd672436c92fdb6b6f3dc"
O_REGION="us-east-1"
O_EP="https://qu.kube5s.ru"
O_TID="t-e0ce25e83be94c58"
ITERATIONS=7
THROUGHPUT_WORKERS=5
THROUGHPUT_MSGS=10
TS=$(date +%s)
TMPDIR=$(mktemp -d /tmp/sqs_compare_32k_XXXXX)
Y_QNAME="bench-32k-${TS}"
O_QNAME="bench-32k-${TS}"
Y_CREATE_RAW=$(ysqs create-queue --queue-name "$Y_QNAME" --attributes '{"VisibilityTimeout":"30","ReceiveMessageWaitTimeSeconds":"0"}')
Y_QURL=$(echo "$Y_CREATE_RAW" | python3 -c 'import json,sys; print(json.load(sys.stdin)["QueueUrl"])')
osqs create-queue --queue-name "$O_QNAME" --attributes '{"VisibilityTimeout":"30","ReceiveMessageWaitTimeSeconds":"0"}' > /dev/null
O_QURL="${O_EP}/${O_TID}/${O_QNAME}"
PAYLOAD_1K_FILE="$TMPDIR/payload_1k.txt"
PAYLOAD_10K_FILE="$TMPDIR/payload_10k.txt"
PAYLOAD_32K_FILE="$TMPDIR/payload_32k.txt"
gen_payload 1024 > "$PAYLOAD_1K_FILE"
gen_payload 10240 > "$PAYLOAD_10K_FILE"
gen_payload 32768 > "$PAYLOAD_32K_FILE"
echo "# Benchmark 32KB Max"
echo "generated_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "iterations=${ITERATIONS}"
echo "throughput_workers=${THROUGHPUT_WORKERS}"
echo "throughput_msgs=${THROUGHPUT_MSGS}"
echo ""
echo "## api_latency_ms"
echo "operation|provider|min|avg|max|p95|note"
run_latency_series "$TMPDIR/y_get_url.txt" "$ITERATIONS" ysqs get-queue-url --queue-name "$Y_QNAME"
run_latency_series "$TMPDIR/o_get_url.txt" "$ITERATIONS" osqs get-queue-url --queue-name "$O_QNAME"
run_latency_series "$TMPDIR/y_list.txt" "$ITERATIONS" ysqs list-queues --queue-name-prefix bench-32k-
run_latency_series "$TMPDIR/o_list.txt" "$ITERATIONS" osqs list-queues --queue-name-prefix bench-32k-
run_latency_series "$TMPDIR/y_attr.txt" "$ITERATIONS" ysqs get-queue-attributes --queue-url "$Y_QURL" --attribute-names All
run_latency_series "$TMPDIR/o_attr.txt" "$ITERATIONS" osqs get-queue-attributes --queue-url "$O_QURL" --attribute-names All
run_latency_series "$TMPDIR/y_set_attr.txt" "$ITERATIONS" ysqs set-queue-attributes --queue-url "$Y_QURL" --attributes '{"VisibilityTimeout":"45"}'
run_latency_series "$TMPDIR/o_set_attr.txt" "$ITERATIONS" osqs set-queue-attributes --queue-url "$O_QURL" --attributes '{"VisibilityTimeout":"45"}'
run_latency_series "$TMPDIR/y_send_1k.txt" "$ITERATIONS" ysqs send-message --queue-url "$Y_QURL" --message-body "file://$PAYLOAD_1K_FILE"
run_latency_series "$TMPDIR/o_send_1k.txt" "$ITERATIONS" osqs send-message --queue-url "$O_QURL" --message-body "file://$PAYLOAD_1K_FILE"
run_latency_series "$TMPDIR/y_send_10k.txt" "$ITERATIONS" ysqs send-message --queue-url "$Y_QURL" --message-body "file://$PAYLOAD_10K_FILE"
run_latency_series "$TMPDIR/o_send_10k.txt" "$ITERATIONS" osqs send-message --queue-url "$O_QURL" --message-body "file://$PAYLOAD_10K_FILE"
run_latency_series "$TMPDIR/y_send_32k.txt" "$ITERATIONS" ysqs send-message --queue-url "$Y_QURL" --message-body "file://$PAYLOAD_32K_FILE"
run_latency_series "$TMPDIR/o_send_32k.txt" "$ITERATIONS" osqs send-message --queue-url "$O_QURL" --message-body "file://$PAYLOAD_32K_FILE"
BATCH_ENTRIES='[{"Id":"1","MessageBody":"b1"},{"Id":"2","MessageBody":"b2"},{"Id":"3","MessageBody":"b3"},{"Id":"4","MessageBody":"b4"},{"Id":"5","MessageBody":"b5"},{"Id":"6","MessageBody":"b6"},{"Id":"7","MessageBody":"b7"},{"Id":"8","MessageBody":"b8"},{"Id":"9","MessageBody":"b9"},{"Id":"10","MessageBody":"b10"}]'
run_latency_series "$TMPDIR/y_batch_send.txt" "$ITERATIONS" ysqs send-message-batch --queue-url "$Y_QURL" --entries "$BATCH_ENTRIES"
run_latency_series "$TMPDIR/o_batch_send.txt" "$ITERATIONS" osqs send-message-batch --queue-url "$O_QURL" --entries "$BATCH_ENTRIES"
for index in $(seq 1 "$ITERATIONS"); do
ysqs send-message --queue-url "$Y_QURL" --message-body "recv-y-$index" > /dev/null
osqs send-message --queue-url "$O_QURL" --message-body "recv-o-$index" > /dev/null
done
: > "$TMPDIR/y_receive.txt"
: > "$TMPDIR/o_receive.txt"
: > "$TMPDIR/y_delete.txt"
: > "$TMPDIR/o_delete.txt"
for _ in $(seq 1 "$ITERATIONS"); do
started_at=$(ms_now)
y_receive_raw=$(ysqs receive-message --queue-url "$Y_QURL" --max-number-of-messages 1 --wait-time-seconds 1)
echo $(( $(ms_now) - started_at )) >> "$TMPDIR/y_receive.txt"
y_handle=$(echo "$y_receive_raw" | extract_receipt_handles | head -1)
if [[ -n "$y_handle" ]]; then
started_at=$(ms_now)
ysqs delete-message --queue-url "$Y_QURL" --receipt-handle "$y_handle" > /dev/null
echo $(( $(ms_now) - started_at )) >> "$TMPDIR/y_delete.txt"
fi
started_at=$(ms_now)
o_receive_raw=$(osqs receive-message --queue-url "$O_QURL" --max-number-of-messages 1 --wait-time-seconds 1)
echo $(( $(ms_now) - started_at )) >> "$TMPDIR/o_receive.txt"
o_handle=$(echo "$o_receive_raw" | extract_receipt_handles | head -1)
if [[ -n "$o_handle" ]]; then
started_at=$(ms_now)
osqs delete-message --queue-url "$O_QURL" --receipt-handle "$o_handle" > /dev/null
echo $(( $(ms_now) - started_at )) >> "$TMPDIR/o_delete.txt"
fi
done
for index in $(seq 1 10); do
ysqs send-message --queue-url "$Y_QURL" --message-body "batch-delete-y-$index" > /dev/null
osqs send-message --queue-url "$O_QURL" --message-body "batch-delete-o-$index" > /dev/null
done
y_batch_delete_receive=$(ysqs receive-message --queue-url "$Y_QURL" --max-number-of-messages 10 --wait-time-seconds 1)
o_batch_delete_receive=$(osqs receive-message --queue-url "$O_QURL" --max-number-of-messages 10 --wait-time-seconds 1)
y_delete_entries=$(echo "$y_batch_delete_receive" | extract_delete_entries)
o_delete_entries=$(echo "$o_batch_delete_receive" | extract_delete_entries)
: > "$TMPDIR/y_batch_delete.txt"
: > "$TMPDIR/o_batch_delete.txt"
for _ in $(seq 1 "$ITERATIONS"); do
for index in $(seq 1 10); do
ysqs send-message --queue-url "$Y_QURL" --message-body "batch-delete-y-${RANDOM}-${index}" > /dev/null
osqs send-message --queue-url "$O_QURL" --message-body "batch-delete-o-${RANDOM}-${index}" > /dev/null
done
y_batch_delete_receive=$(ysqs receive-message --queue-url "$Y_QURL" --max-number-of-messages 10 --wait-time-seconds 1)
o_batch_delete_receive=$(osqs receive-message --queue-url "$O_QURL" --max-number-of-messages 10 --wait-time-seconds 1)
y_delete_entries=$(echo "$y_batch_delete_receive" | extract_delete_entries)
o_delete_entries=$(echo "$o_batch_delete_receive" | extract_delete_entries)
started_at=$(ms_now)
ysqs delete-message-batch --queue-url "$Y_QURL" --entries "$y_delete_entries" > /dev/null
echo $(( $(ms_now) - started_at )) >> "$TMPDIR/y_batch_delete.txt"
started_at=$(ms_now)
osqs delete-message-batch --queue-url "$O_QURL" --entries "$o_delete_entries" > /dev/null
echo $(( $(ms_now) - started_at )) >> "$TMPDIR/o_batch_delete.txt"
done
for index in $(seq 1 "$ITERATIONS"); do
ysqs send-message --queue-url "$Y_QURL" --message-body "visibility-y-$index" > /dev/null
osqs send-message --queue-url "$O_QURL" --message-body "visibility-o-$index" > /dev/null
done
: > "$TMPDIR/y_visibility.txt"
: > "$TMPDIR/o_visibility.txt"
for _ in $(seq 1 "$ITERATIONS"); do
y_visibility_receive=$(ysqs receive-message --queue-url "$Y_QURL" --max-number-of-messages 1 --wait-time-seconds 1)
y_visibility_handle=$(echo "$y_visibility_receive" | extract_receipt_handles | head -1)
if [[ -n "$y_visibility_handle" ]]; then
started_at=$(ms_now)
ysqs change-message-visibility --queue-url "$Y_QURL" --receipt-handle "$y_visibility_handle" --visibility-timeout 45 > /dev/null
echo $(( $(ms_now) - started_at )) >> "$TMPDIR/y_visibility.txt"
fi
o_visibility_receive=$(osqs receive-message --queue-url "$O_QURL" --max-number-of-messages 1 --wait-time-seconds 1)
o_visibility_handle=$(echo "$o_visibility_receive" | extract_receipt_handles | head -1)
if [[ -n "$o_visibility_handle" ]]; then
started_at=$(ms_now)
osqs change-message-visibility --queue-url "$O_QURL" --receipt-handle "$o_visibility_handle" --visibility-timeout 45 > /dev/null
echo $(( $(ms_now) - started_at )) >> "$TMPDIR/o_visibility.txt"
fi
done
: > "$TMPDIR/y_visibility_batch.txt"
: > "$TMPDIR/o_visibility_batch.txt"
for _ in $(seq 1 "$ITERATIONS"); do
for index in $(seq 1 10); do
ysqs send-message --queue-url "$Y_QURL" --message-body "visibility-batch-y-${RANDOM}-${index}" > /dev/null
osqs send-message --queue-url "$O_QURL" --message-body "visibility-batch-o-${RANDOM}-${index}" > /dev/null
done
y_visibility_batch_receive=$(ysqs receive-message --queue-url "$Y_QURL" --max-number-of-messages 10 --wait-time-seconds 1)
o_visibility_batch_receive=$(osqs receive-message --queue-url "$O_QURL" --max-number-of-messages 10 --wait-time-seconds 1)
y_visibility_entries=$(echo "$y_visibility_batch_receive" | extract_visibility_entries)
o_visibility_entries=$(echo "$o_visibility_batch_receive" | extract_visibility_entries)
started_at=$(ms_now)
ysqs change-message-visibility-batch --queue-url "$Y_QURL" --entries "$y_visibility_entries" > /dev/null
echo $(( $(ms_now) - started_at )) >> "$TMPDIR/y_visibility_batch.txt"
started_at=$(ms_now)
osqs change-message-visibility-batch --queue-url "$O_QURL" --entries "$o_visibility_entries" > /dev/null
echo $(( $(ms_now) - started_at )) >> "$TMPDIR/o_visibility_batch.txt"
done
: > "$TMPDIR/y_purge.txt"
: > "$TMPDIR/o_purge.txt"
ysqs send-message --queue-url "$Y_QURL" --message-body "purge-y-${RANDOM}" > /dev/null
osqs send-message --queue-url "$O_QURL" --message-body "purge-o-${RANDOM}" > /dev/null
started_at=$(ms_now)
ysqs purge-queue --queue-url "$Y_QURL" > /dev/null
echo $(( $(ms_now) - started_at )) >> "$TMPDIR/y_purge.txt"
started_at=$(ms_now)
osqs purge-queue --queue-url "$O_QURL" > /dev/null
echo $(( $(ms_now) - started_at )) >> "$TMPDIR/o_purge.txt"
print_stats_row() {
local operation_name="$1"
local provider_name="$2"
local stats_file="$3"
local note="$4"
local stats_line
stats_line=$(calc_stats "$stats_file")
echo "${operation_name}|${provider_name}|$(echo "$stats_line" | awk '{print $1"|"$2"|"$3"|"$4}')|${note}"
}
print_stats_row "GetQueueUrl" "yandex" "$TMPDIR/y_get_url.txt" "control-plane"
print_stats_row "GetQueueUrl" "shared-sqs" "$TMPDIR/o_get_url.txt" "control-plane"
print_stats_row "ListQueues" "yandex" "$TMPDIR/y_list.txt" "control-plane"
print_stats_row "ListQueues" "shared-sqs" "$TMPDIR/o_list.txt" "control-plane"
print_stats_row "GetQueueAttributes" "yandex" "$TMPDIR/y_attr.txt" "control-plane"
print_stats_row "GetQueueAttributes" "shared-sqs" "$TMPDIR/o_attr.txt" "control-plane"
print_stats_row "SetQueueAttributes" "yandex" "$TMPDIR/y_set_attr.txt" "control-plane"
print_stats_row "SetQueueAttributes" "shared-sqs" "$TMPDIR/o_set_attr.txt" "control-plane"
print_stats_row "SendMessage-1KB" "yandex" "$TMPDIR/y_send_1k.txt" "payload"
print_stats_row "SendMessage-1KB" "shared-sqs" "$TMPDIR/o_send_1k.txt" "payload"
print_stats_row "SendMessage-10KB" "yandex" "$TMPDIR/y_send_10k.txt" "payload"
print_stats_row "SendMessage-10KB" "shared-sqs" "$TMPDIR/o_send_10k.txt" "payload"
print_stats_row "SendMessage-32KB" "yandex" "$TMPDIR/y_send_32k.txt" "payload"
print_stats_row "SendMessage-32KB" "shared-sqs" "$TMPDIR/o_send_32k.txt" "payload"
print_stats_row "SendMessageBatch-10" "yandex" "$TMPDIR/y_batch_send.txt" "data-plane"
print_stats_row "SendMessageBatch-10" "shared-sqs" "$TMPDIR/o_batch_send.txt" "data-plane"
print_stats_row "ReceiveMessage" "yandex" "$TMPDIR/y_receive.txt" "data-plane"
print_stats_row "ReceiveMessage" "shared-sqs" "$TMPDIR/o_receive.txt" "data-plane"
print_stats_row "DeleteMessage" "yandex" "$TMPDIR/y_delete.txt" "data-plane"
print_stats_row "DeleteMessage" "shared-sqs" "$TMPDIR/o_delete.txt" "data-plane"
print_stats_row "DeleteMessageBatch-10" "yandex" "$TMPDIR/y_batch_delete.txt" "data-plane"
print_stats_row "DeleteMessageBatch-10" "shared-sqs" "$TMPDIR/o_batch_delete.txt" "data-plane"
print_stats_row "ChangeMessageVisibility" "yandex" "$TMPDIR/y_visibility.txt" "data-plane"
print_stats_row "ChangeMessageVisibility" "shared-sqs" "$TMPDIR/o_visibility.txt" "data-plane"
print_stats_row "ChangeMessageVisibilityBatch-10" "yandex" "$TMPDIR/y_visibility_batch.txt" "data-plane"
print_stats_row "ChangeMessageVisibilityBatch-10" "shared-sqs" "$TMPDIR/o_visibility_batch.txt" "data-plane"
print_stats_row "PurgeQueue" "yandex" "$TMPDIR/y_purge.txt" "control-plane"
print_stats_row "PurgeQueue" "shared-sqs" "$TMPDIR/o_purge.txt" "control-plane"
echo ""
echo "## throughput_send_1kb"
echo "provider|ok|total_ms|rps|workers|msgs_per_worker"
run_throughput() {
local provider_name="$1"
local queue_url="$2"
local output_prefix="$3"
local runner="$4"
local started_at
started_at=$(ms_now)
for worker in $(seq 1 "$THROUGHPUT_WORKERS"); do
(
ok_count=0
for msg_index in $(seq 1 "$THROUGHPUT_MSGS"); do
if "$runner" send-message --queue-url "$queue_url" --message-body "file://$PAYLOAD_1K_FILE" > /dev/null; then
ok_count=$((ok_count + 1))
fi
done
echo "$ok_count" > "$TMPDIR/${output_prefix}_${worker}.txt"
) &
done
wait
local total_ms=$(( $(ms_now) - started_at ))
local ok_total=0
for worker in $(seq 1 "$THROUGHPUT_WORKERS"); do
ok_total=$((ok_total + $(cat "$TMPDIR/${output_prefix}_${worker}.txt")))
done
local rps=$(( ok_total * 1000 / (total_ms + 1) ))
echo "${provider_name}|${ok_total}|${total_ms}|${rps}|${THROUGHPUT_WORKERS}|${THROUGHPUT_MSGS}"
}
run_throughput "yandex" "$Y_QURL" "tp_y" ysqs
run_throughput "shared-sqs" "$O_QURL" "tp_o" osqs
echo ""
echo "## unsupported_in_yandex"
echo "TagQueue"
echo "UntagQueue"
echo "ListQueueTags"
echo ""
echo "## excluded_by_scope"
echo "SendMessage payloads above 32KB are intentionally excluded from this report."
+104
View File
@@ -0,0 +1,104 @@
#!/bin/bash
# Updated: 2026-04-12 05:40 UTC
# payload_latency_probe.sh — узкий замер SendMessage latency для payload 10KB и 32KB.
set -euo pipefail
# ysqs выполняет aws sqs для Yandex MQ.
ysqs() {
AWS_ACCESS_KEY_ID="$Y_AK" AWS_SECRET_ACCESS_KEY="$Y_SK" \
AWS_DEFAULT_REGION="$Y_REGION" \
aws --endpoint-url "$Y_EP" --output json sqs "$@" >/dev/null 2>&1
}
# osqs выполняет aws sqs для shared-sqs.
osqs() {
AWS_ACCESS_KEY_ID="$O_AK" AWS_SECRET_ACCESS_KEY="$O_SK" \
AWS_DEFAULT_REGION="$O_REGION" \
aws --endpoint-url "$O_EP" --output json sqs "$@" >/dev/null 2>&1
}
# ms_now возвращает текущее время в миллисекундах.
ms_now() {
date +%s%3N
}
# calc_stats считает min/avg/max/p95 по файлу latency.
calc_stats() {
local file_path="$1"
sort -n "$file_path" | awk '
{ values[NR] = $1; total += $1 }
END {
p95_index = int(NR * 0.95)
if (p95_index < 1) p95_index = 1
printf "%d %d %d %d\n", values[1], int(total / NR), values[NR], values[p95_index]
}'
}
# cleanup удаляет временные объекты после замера.
cleanup() {
set +e
if [[ -n "${Y_QURL:-}" ]]; then
AWS_ACCESS_KEY_ID="$Y_AK" AWS_SECRET_ACCESS_KEY="$Y_SK" AWS_DEFAULT_REGION="$Y_REGION" \
aws --endpoint-url "$Y_EP" --output json sqs delete-queue --queue-url "$Y_QURL" >/dev/null 2>&1 || true
fi
if [[ -n "${O_QURL:-}" ]]; then
AWS_ACCESS_KEY_ID="$O_AK" AWS_SECRET_ACCESS_KEY="$O_SK" AWS_DEFAULT_REGION="$O_REGION" \
aws --endpoint-url "$O_EP" --output json sqs delete-queue --queue-url "$O_QURL" >/dev/null 2>&1 || true
fi
rm -rf "$TMPDIR"
}
trap cleanup EXIT
Y_AK="YCAJEQDz_Eg_i4C4M7TAen2fd"
Y_SK="YCMDfD8OKFK51knPyydwQOYts7Q81_3YBhv4sd_j"
Y_REGION="ru-central1"
Y_EP="https://message-queue.api.cloud.yandex.net"
O_AK="SSAK-ed0b0c64dcc135adad9e11be"
O_SK="f917c133e4ad74cf37c8e3ba29d74a39f42f117a202bd672436c92fdb6b6f3dc"
O_REGION="us-east-1"
O_EP="https://qu.kube5s.ru"
O_TID="t-e0ce25e83be94c58"
ITERATIONS=7
TMPDIR=$(mktemp -d /tmp/payload_latency_probe_XXXXX)
TS=$(date +%s)
head -c 10240 /dev/urandom | base64 | head -c 10240 > "$TMPDIR/payload_10k.txt"
head -c 32768 /dev/urandom | base64 | head -c 32768 > "$TMPDIR/payload_32k.txt"
Y_QNAME="payload-probe-y-${TS}"
O_QNAME="payload-probe-o-${TS}"
Y_CREATE_RAW=$(AWS_ACCESS_KEY_ID="$Y_AK" AWS_SECRET_ACCESS_KEY="$Y_SK" AWS_DEFAULT_REGION="$Y_REGION" aws --endpoint-url "$Y_EP" --output json sqs create-queue --queue-name "$Y_QNAME")
Y_QURL=$(echo "$Y_CREATE_RAW" | python3 -c 'import json,sys; print(json.load(sys.stdin)["QueueUrl"])')
osqs create-queue --queue-name "$O_QNAME"
O_QURL="${O_EP}/${O_TID}/${O_QNAME}"
run_probe() {
local provider_name="$1"
local queue_url="$2"
local payload_file="$3"
local result_file="$4"
local runner="$5"
: > "$result_file"
for _ in $(seq 1 "$ITERATIONS"); do
local started_at
started_at=$(ms_now)
"$runner" send-message --queue-url "$queue_url" --message-body "file://$payload_file"
echo $(( $(ms_now) - started_at )) >> "$result_file"
done
local stats_line
stats_line=$(calc_stats "$result_file")
echo "${provider_name}|$(echo "$stats_line" | awk '{print $1"|"$2"|"$3"|"$4}')"
}
echo "payload_kb|provider|min|avg|max|p95"
run_probe "yandex" "$Y_QURL" "$TMPDIR/payload_10k.txt" "$TMPDIR/y_10k.txt" ysqs | awk -F'|' '{print "10|"$0}'
run_probe "shared-sqs" "$O_QURL" "$TMPDIR/payload_10k.txt" "$TMPDIR/o_10k.txt" osqs | awk -F'|' '{print "10|"$0}'
run_probe "yandex" "$Y_QURL" "$TMPDIR/payload_32k.txt" "$TMPDIR/y_32k.txt" ysqs | awk -F'|' '{print "32|"$0}'
run_probe "shared-sqs" "$O_QURL" "$TMPDIR/payload_32k.txt" "$TMPDIR/o_32k.txt" osqs | awk -F'|' '{print "32|"$0}'