perf(chat,update): пул воркеров per-user вместо сериальной очереди + HEAD-проба обновлений
- chat.Router: ограниченный пул chatWorkers=4 воркеров + FIFO-очереди per-user (userState/workerLoop/runUser). Порядок сообщений одного UserID сохраняется; разные пользователи обрабатываются параллельно (до 4 одновременных LLM-вызовов), long-poll Telegram не блокируется чужим аналитиком. Backpressure по jobs — только на перегруженного пользователя. - app.FreeChat: sessions под sync.Mutex (защита от data race при параллельных воркерах роутера). - update: ResolveLatest проверяет наличие бинаря HEAD-пробой без скачивания тела (fallback GET Range 0-0 при 405/501), сортировка версий по id убыв.; один общий http.Client (keep-alive) вместо нового на каждый запрос. - тесты: порядок/параллелизм per-user в router, HEAD-без-тела и фоллбэк на версию без бинаря в update. - память Serena: инварианты Router/update, примечания по форматированию на Windows.
This commit is contained in:
@@ -31,7 +31,9 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -50,6 +52,11 @@ type Updater struct {
|
||||
// Dir — каталог рядом с бинарём (для .new/.old и companion-метаданных).
|
||||
// Ставится app из os.Executable(); если пуст — используется каталог Workdir.
|
||||
Dir string
|
||||
|
||||
// client — общий HTTP-клиент (keep-alive), чтобы проверки/скачивания
|
||||
// переиспользовали соединения, а не создавали новое на каждый запрос.
|
||||
client *http.Client
|
||||
clientMu sync.Mutex
|
||||
}
|
||||
|
||||
// Result — результат Check.
|
||||
@@ -118,16 +125,26 @@ func (u *Updater) versionsURL() string {
|
||||
return base + "/api/v1/packages/" + url.PathEscape(u.Owner) + "/generic/" + url.PathEscape(u.Package)
|
||||
}
|
||||
|
||||
// httpClient возвращает общий клиент (keep-alive), инициализируя лениво.
|
||||
func (u *Updater) httpClient() *http.Client {
|
||||
u.clientMu.Lock()
|
||||
defer u.clientMu.Unlock()
|
||||
if u.client == nil {
|
||||
u.client = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
return u.client
|
||||
}
|
||||
|
||||
// httpGet скачивает файл по URL бэкенда. При Token непустом — Basic/токен-заголовок.
|
||||
func (u *Updater) httpGet(url string) ([]byte, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
func (u *Updater) httpGet(ctx context.Context, url string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Token != "" {
|
||||
req.Header.Set("Authorization", "token "+u.Token)
|
||||
}
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
||||
resp, err := u.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -138,6 +155,61 @@ func (u *Updater) httpGet(url string) ([]byte, error) {
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// fileExists проверяет наличие файла по URL без скачивания тела: HEAD,
|
||||
// а при 405/501 (сервер не поддерживает HEAD) — fallback на GET с Range байт 0-0.
|
||||
// Возвращает (false, nil) при 404/410 — файла нет.
|
||||
func (u *Updater) fileExists(ctx context.Context, url string) (bool, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if u.Token != "" {
|
||||
req.Header.Set("Authorization", "token "+u.Token)
|
||||
}
|
||||
resp, err := u.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
resp.Body.Close()
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone:
|
||||
return false, nil
|
||||
case resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusPartialContent:
|
||||
return true, nil
|
||||
case resp.StatusCode == http.StatusMethodNotAllowed || resp.StatusCode == http.StatusNotImplemented:
|
||||
// Gitea может не отвечать на HEAD — проверяем GET с Range 0-0 без чтения тела.
|
||||
return u.fileExistsByRange(ctx, url)
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// fileExistsByRange проверяет наличие файла GET'ом с Range: bytes=0-0.
|
||||
// Тело не читается: достаточно лишь первых байт заголовков ответа.
|
||||
func (u *Updater) fileExistsByRange(ctx context.Context, url string) (bool, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if u.Token != "" {
|
||||
req.Header.Set("Authorization", "token "+u.Token)
|
||||
}
|
||||
req.Header.Set("Range", "bytes=0-0")
|
||||
resp, err := u.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
resp.Body.Close()
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone:
|
||||
return false, nil
|
||||
case resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusPartialContent:
|
||||
return true, nil
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check определяет, есть ли более свежая версия в Gitea Packages.
|
||||
// Не скачивает бинарь. Ошибка (U1) возвращается в Result.Err — вызывающий
|
||||
// решает, логировать и пропустить.
|
||||
@@ -167,8 +239,12 @@ type pkgVersion struct {
|
||||
// ResolveLatest определяет идентификатор новейшей применимой версии пакета.
|
||||
// Бинарь/метаданные читаем из КОНКРЕТНОЙ версии, а не из pseudo-`latest`,
|
||||
// чтобы companion-файлы и бинарь всегда брались из одного снимка.
|
||||
//
|
||||
// Применимость версии проверяем НАЛИЧИЕМ бинаря платформы (HEAD без тела),
|
||||
// а не скачиванием полного файла: при N версиях это N запросов заголовков
|
||||
// вместо N×(размер бинаря) байт.
|
||||
func (u *Updater) ResolveLatest(ctx context.Context) (string, error) {
|
||||
b, err := u.httpGet(u.versionsURL())
|
||||
b, err := u.httpGet(ctx, u.versionsURL())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -176,30 +252,28 @@ func (u *Updater) ResolveLatest(ctx context.Context) (string, error) {
|
||||
if err := json.Unmarshal(b, &vers); err != nil {
|
||||
return "", ue(U1, "list "+u.versionsURL(), err)
|
||||
}
|
||||
// новые версии — с большим ID; идём с новейшей и берём первую с бинарём.
|
||||
sort.SliceStable(vers, func(i, j int) bool { return vers[i].ID > vers[j].ID })
|
||||
want := PlatformFilename()
|
||||
// выбираем самую свежую версию (макс. id) класса commit-*, в которой есть бинарь.
|
||||
best := ""
|
||||
var bestID int64
|
||||
for _, v := range vers {
|
||||
if !strings.HasPrefix(v.Ver, "commit-") {
|
||||
continue
|
||||
}
|
||||
// версия применима, только если в ней опубликован бинарь нашей платформы
|
||||
if _, err := u.httpGet(u.fileURL(v.Ver, want)); err != nil {
|
||||
continue
|
||||
exists, ferr := u.fileExists(ctx, u.fileURL(v.Ver, want))
|
||||
if ferr != nil {
|
||||
continue // сетевые ошибки пробы не роняют проверку
|
||||
}
|
||||
if v.ID > bestID {
|
||||
bestID = v.ID
|
||||
best = v.Ver
|
||||
if exists {
|
||||
return v.Ver, nil
|
||||
}
|
||||
}
|
||||
return best, nil
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Download скачивает бинарь конкретной версии во временный файл и возвращает
|
||||
// путь к нему. Файл: <Dir>/.ratatoskr.<ver>.new.
|
||||
func (u *Updater) Download(ctx context.Context, version string) (string, error) {
|
||||
b, err := u.httpGet(u.fileURL(version, PlatformFilename()))
|
||||
b, err := u.httpGet(ctx, u.fileURL(version, PlatformFilename()))
|
||||
if err != nil {
|
||||
return "", err // уже U3
|
||||
}
|
||||
@@ -216,7 +290,7 @@ func (u *Updater) Download(ctx context.Context, version string) (string, error)
|
||||
|
||||
// versionSum256 читает companion-файл контрольной суммы конкретной версии.
|
||||
func (u *Updater) versionSum256(ctx context.Context, version string) (string, error) {
|
||||
b, err := u.httpGet(u.fileURL(version, PlatformFilename()+".sha256"))
|
||||
b, err := u.httpGet(ctx, u.fileURL(version, PlatformFilename()+".sha256"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -352,4 +426,4 @@ func (u *Updater) SwapAndRestart(file string) error {
|
||||
// успешный старт нового процесса — текущий завершаем
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -41,7 +42,7 @@ func mockGitea(t *testing.T, bin []byte, version, checksum string) *httptest.Ser
|
||||
_ = json.NewEncoder(w).Encode(versions)
|
||||
})
|
||||
mux.HandleFunc("/api/packages/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
@@ -58,7 +59,10 @@ func mockGitea(t *testing.T, bin []byte, version, checksum string) *httptest.Ser
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(body)
|
||||
if r.Method == http.MethodGet {
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
// HEAD — просто 200, тело не пишем
|
||||
})
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
@@ -133,6 +137,104 @@ func TestCheck_ServerDown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveLatest_HeadNotFullDownload проверяет, что ResolveLatest проверяет
|
||||
// наличие бинаря HEAD'ом и НЕ скачивает полное тело бинаря (прошлая версия
|
||||
// читала каждый файл целиком — O(N)×размер бинаря).
|
||||
func TestResolveLatest_HeadNotFullDownload(t *testing.T) {
|
||||
name := PlatformFilename()
|
||||
_ = name
|
||||
var headReqs, bodyReqs int64
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/packages/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode([]map[string]any{
|
||||
{"id": 1, "version": "commit-aaa1111"},
|
||||
{"id": 2, "version": "commit-abc1234"},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/api/packages/", func(w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||
if len(parts) < 6 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
ver := parts[len(parts)-2]
|
||||
fileName := parts[len(parts)-1]
|
||||
if ver != "commit-abc1234" || fileName != name {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodHead:
|
||||
atomic.AddInt64(&headReqs, 1)
|
||||
case http.MethodGet:
|
||||
atomic.AddInt64(&bodyReqs, 1)
|
||||
default:
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
u := &Updater{BaseURL: srv.URL, Owner: "k", Package: "p", CurrentVersion: "v1", Dir: t.TempDir()}
|
||||
ver, err := u.ResolveLatest(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveLatest err = %v", err)
|
||||
}
|
||||
if ver != "commit-abc1234" {
|
||||
t.Errorf("ResolveLatest = %q, want commit-abc1234", ver)
|
||||
}
|
||||
if atomic.LoadInt64(&headReqs) == 0 {
|
||||
t.Error("ResolveLatest не делал HEAD-проб на файлы")
|
||||
}
|
||||
if atomic.LoadInt64(&bodyReqs) != 0 {
|
||||
t.Errorf("ResolveLatest скачал тело бинарника: %d полных GET", atomic.LoadInt64(&bodyReqs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveLatest_SkipsBinarylessVersion проверяет фоллбэк: новейшая версия
|
||||
// без бинаря (разные матрицы платформ публикуются не все сразу) пропускается,
|
||||
// берётся следующая, где файл есть.
|
||||
func TestResolveLatest_SkipsBinarylessVersion(t *testing.T) {
|
||||
name := PlatformFilename()
|
||||
bin := []byte("binary")
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/v1/packages/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode([]map[string]any{
|
||||
{"id": 1, "version": "commit-aaa1111"},
|
||||
{"id": 2, "version": "commit-mid2222"},
|
||||
{"id": 3, "version": "commit-new3333"},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/api/packages/", func(w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||
if len(parts) < 6 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
ver := parts[len(parts)-2]
|
||||
fileName := parts[len(parts)-1]
|
||||
// бинарь есть только у commit-mid2222 — новейшие 1 и 3 пропускаются
|
||||
if ver != "commit-mid2222" || fileName != name {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodHead {
|
||||
_, _ = w.Write(bin)
|
||||
}
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
u := &Updater{BaseURL: srv.URL, Owner: "k", Package: "p", CurrentVersion: "v0", Dir: t.TempDir()}
|
||||
ver, err := u.ResolveLatest(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveLatest err = %v", err)
|
||||
}
|
||||
if ver != "commit-mid2222" {
|
||||
t.Errorf("ResolveLatest = %q, want commit-mid2222 (фоллбэк от версии без бинаря)", ver)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownload_And_Verify_Good(t *testing.T) {
|
||||
bin := []byte("ratatoskr-binary-content-v2")
|
||||
u, dir := testUpdater(t, bin, "commit-new12345", "")
|
||||
@@ -249,4 +351,4 @@ func errorsAs(err error, target **Error) bool {
|
||||
err = c.Unwrap()
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user