perf(ui): кап буфера логов 10MB, O(1) вытеснение, дебаунс рендера
Some checks failed
CI / test (pull_request) Failing after 1m21s
CI / build-and-package (amd64, linux) (pull_request) Failing after 1m9s
CI / build-and-package (amd64, windows) (pull_request) Successful in 31s

- LogBuffer: ленивое вытеснение через start + инкрементальный total убирает
  O(n^2) копирование entries[1:] при большом числе записей
- LogPanel: лимит 10MB (maxLogBytes), перерисовка ленты коалесцируется
  (200мс) — строка не пересобирается на каждый append
- build-publish-ui.ps1: корень проекта задаётся через PROJECT_PATH в
  .gitea-creds (относительно scripts/), без него — папка рядом со скриптом
- тесты: живые записи Entries/Render, кап по байтам
This commit is contained in:
ki.sagidullin
2026-08-21 23:01:54 +05:00
parent a762a1df80
commit ee47785e1a
4 changed files with 147 additions and 30 deletions

View File

@@ -4,6 +4,7 @@ package desktop
import ( import (
"strings" "strings"
"time"
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/container" "fyne.io/fyne/v2/container"
@@ -12,8 +13,13 @@ import (
"github.com/kamelion/ratatoskr-go/internal/ui" "github.com/kamelion/ratatoskr-go/internal/ui"
) )
// maxLogLen — обрезка буфера панели «Логи», чтобы не расти бесконечно. // maxLogBytes — кап суммарного объёма текста буфера «Логи» (байты), чтобы
const maxLogLen = 200_000 // не расти бесконечно и не копить мегабайты в памяти.
const maxLogBytes = 10 * 1024 * 1024
// renderFlushMs — дебаунс-интервал перерисовки ленты (мс): при всплеске
// логов рендер выполняется не на каждую запись, а раз в этот интервал.
const renderFlushMs = 200
// LogPanel — Fyne-реализация ui.LogPanel (спец 12.8, 12.9): поток системных // LogPanel — Fyne-реализация ui.LogPanel (спец 12.8, 12.9): поток системных
// логов с фильтром по уровням (error/warning/info/debug). // логов с фильтром по уровням (error/warning/info/debug).
@@ -24,25 +30,31 @@ const maxLogLen = 200_000
// widget.PopUp с widget.Check по каждому уровню; изменение набора сразу // widget.PopUp с widget.Check по каждому уровню; изменение набора сразу
// перерисовывает ленту — и по уже загруженным записям, и для новых. // перерисовывает ленту — и по уже загруженным записям, и для новых.
// //
// Состояние (buf/selected) доступается только с потока Fyne: Append приходит // Состояние (buf/selected/pending/flushScheduled/lastFlush) доступается только
// из горутин Hub и заворачивается в fyne.Do, колбэки чекбоксов Fyne вызывает // с потока Fyne: Append приходит из горутин Hub и заворачивается в fyne.Do,
// сам на своём потоке. // колбэки чекбоксов Fyne вызывает сам на своём потоке. Перерисовка ленты
// коалесцируется (см. scheduleFlush), т.к. строка ленты пересобирается
// целиком и при капе в 10MB на каждый append была бы дорогой.
type LogPanel struct { type LogPanel struct {
label *widget.Label label *widget.Label
btn *widget.Button // кнопка-дропдаун фильтра уровней btn *widget.Button // кнопка-дропдаун фильтра уровней
buf *ui.LogBuffer // буфер записей, старые вытесняются buf *ui.LogBuffer // буфер записей, старые вытесняются
selected map[string]bool // уровень → показывать (по умолчанию все) selected map[string]bool // уровень → показывать (по умолчанию все)
pending bool // буфер изменился, рендер отложен (поток Fyne)
flushScheduled bool // фоновый флаш уже запущен (поток Fyne)
lastFlush int64 // мс последнего рендера (поток Fyne)
} }
// NewLogPanel создаёт LogPanel со всеми включёнными уровнями. // NewLogPanel создаёт LogPanel с уровнями error/warning/info (debug скрыт).
func NewLogPanel() *LogPanel { func NewLogPanel() *LogPanel {
p := &LogPanel{ p := &LogPanel{
buf: ui.NewLogBuffer(maxLogLen), buf: ui.NewLogBuffer(maxLogBytes),
selected: make(map[string]bool, len(ui.LogLevels)), selected: make(map[string]bool, len(ui.LogLevels)),
} }
for _, lvl := range ui.LogLevels { for _, lvl := range ui.LogLevels {
p.selected[lvl] = true p.selected[lvl] = lvl != ui.LogLevelDebug
} }
p.label = widget.NewLabel("") p.label = widget.NewLabel("")
@@ -68,11 +80,42 @@ func (p *LogPanel) Append(level, text string) {
dropped := p.buf.Append(lvl, text) dropped := p.buf.Append(lvl, text)
// Перерисовка нужна, если запись видима или вытеснение задело ленту. // Перерисовка нужна, если запись видима или вытеснение задело ленту.
if p.selected[lvl] || dropped { if p.selected[lvl] || dropped {
p.render() p.pending = true
p.scheduleFlush()
} }
}) })
} }
// scheduleFlush планирует отложенный рендер через renderFlushMs (или сразу,
// если с прошлого рендера прошло больше интервала). Все вызовы — с потока
// Fyne; фоновый поток только спит и по таймеру отправляет flush через fyne.Do,
// поэтому защита от повторного планирования (flushScheduled) работает без
// блокировок.
func (p *LogPanel) scheduleFlush() {
if p.flushScheduled {
return
}
p.flushScheduled = true
delay := int64(renderFlushMs) - (time.Now().UnixMilli() - p.lastFlush)
if delay < 1 {
delay = 1
}
go func() {
time.Sleep(time.Duration(delay) * time.Millisecond)
fyne.Do(p.flush)
}()
}
// flush выполняет отложенную перерисовку ленты (на потоке Fyne).
func (p *LogPanel) flush() {
p.flushScheduled = false
if p.pending {
p.pending = false
p.lastFlush = time.Now().UnixMilli()
p.render()
}
}
// render перестраивает текст ленты из буфера с учётом выбранного набора // render перестраивает текст ленты из буфера с учётом выбранного набора
// уровней. Пустой набор → пустая лента (без ошибок). // уровней. Пустой набор → пустая лента (без ошибок).
func (p *LogPanel) render() { func (p *LogPanel) render() {

View File

@@ -70,6 +70,12 @@ type LogEntry struct {
Text string Text string
} }
// compactThreshold — порог, при котором «мёртвые» (вытесненные сдвигом)
// записи физически убираются из буфера пачкой. Вытеснение лениво: оно лишь
// инкрементирует start, поэтому не копирует хвост массива на каждую
// вытесненную запись (иначе trim был бы O(n²)). Уборка выполняется редко.
const compactThreshold = 256
// LogBuffer — ограниченный буфер записей лога с фильтрацией по уровням // LogBuffer — ограниченный буфер записей лога с фильтрацией по уровням
// (спец 12.9). Чистая логика без Fyne: используется Fyne-реализацией // (спец 12.9). Чистая логика без Fyne: используется Fyne-реализацией
// LogPanel и тестируется в internal/ui. // LogPanel и тестируется в internal/ui.
@@ -78,10 +84,12 @@ type LogEntry struct {
// на потоке Fyne). // на потоке Fyne).
type LogBuffer struct { type LogBuffer struct {
entries []LogEntry entries []LogEntry
maxLen int // лимит суммарного объёма текста записей, символы start int // индекс первого «живого» элемента (ленивое вытеснение)
total int // суммарный объём текста живых записей [start..], байты
maxLen int // лимит суммарного объёма текста записей, байты
} }
// NewLogBuffer создаёт буфер ёмкостью maxLen символов текста записей. // NewLogBuffer создаёт буфер ёмкостью maxLen байт текста записей.
func NewLogBuffer(maxLen int) *LogBuffer { func NewLogBuffer(maxLen int) *LogBuffer {
return &LogBuffer{maxLen: maxLen} return &LogBuffer{maxLen: maxLen}
} }
@@ -95,42 +103,51 @@ func (b *LogBuffer) Append(level, text string) bool {
Level: NormalizeLogLevel(level), Level: NormalizeLogLevel(level),
Text: strings.TrimRight(text, "\r\n"), Text: strings.TrimRight(text, "\r\n"),
}) })
b.total += len(b.entries[len(b.entries)-1].Text) + 1
return b.trim() return b.trim()
} }
// trim удерживает суммарный объём текста записей в пределах maxLen, // trim удерживает суммарный объём текста живых записей в пределах maxLen,
// отбрасывая старые записи; одиночная запись длиннее лимита обрезается // лениво вытесняя старые (инкремент start); одиночная запись длиннее лимита
// до последних maxLen символов. Сообщает, было ли вытеснение/обрезка. // обрезается до последних maxLen байт. Сообщает, было ли вытеснение/обрезка.
func (b *LogBuffer) trim() bool { func (b *LogBuffer) trim() bool {
total := 0
for _, e := range b.entries {
total += len(e.Text) + 1
}
dropped := false dropped := false
for total > b.maxLen && len(b.entries) > 1 { live := len(b.entries) - b.start
total -= len(b.entries[0].Text) + 1 for b.total > b.maxLen && live > 1 {
b.entries = b.entries[1:] b.total -= len(b.entries[b.start].Text) + 1
b.start++
live--
dropped = true dropped = true
} }
if n := len(b.entries); n > 0 && len(b.entries[n-1].Text) > b.maxLen { // Компакция «мёртвых» записей пачкой, а не на каждом append.
if b.start >= compactThreshold {
b.entries = b.entries[b.start:]
b.start = 0
}
if n := len(b.entries); n > b.start && len(b.entries[n-1].Text) > b.maxLen {
b.entries[n-1].Text = b.entries[n-1].Text[len(b.entries[n-1].Text)-b.maxLen:] b.entries[n-1].Text = b.entries[n-1].Text[len(b.entries[n-1].Text)-b.maxLen:]
b.total = b.maxLen + 1
dropped = true dropped = true
} }
return dropped return dropped
} }
// Entries возвращает копию списка записей буфера. // Entries возвращает копию списка живых записей буфера.
func (b *LogBuffer) Entries() []LogEntry { func (b *LogBuffer) Entries() []LogEntry {
out := make([]LogEntry, len(b.entries)) out := make([]LogEntry, len(b.entries)-b.start)
copy(out, b.entries) copy(out, b.entries[b.start:])
return out return out
} }
// Render строит текст ленты только из записей уровней, отмеченных в // Render строит текст ленты только из живых записей уровней, отмеченных в
// selected (уровень → показывать). Пустой или nil набор → пустая лента. // selected (уровень → показывать). Пустой или nil набор → пустая лента.
func (b *LogBuffer) Render(selected map[string]bool) string { func (b *LogBuffer) Render(selected map[string]bool) string {
if selected == nil {
return ""
}
var s strings.Builder var s strings.Builder
for _, e := range b.entries { for i := b.start; i < len(b.entries); i++ {
e := b.entries[i]
if !selected[e.Level] { if !selected[e.Level] {
continue continue
} }

View File

@@ -185,3 +185,47 @@ func TestLogBufferBounded(t *testing.T) {
t.Fatalf("oversized entry text = %q, want %q", got, "efgh") t.Fatalf("oversized entry text = %q, want %q", got, "efgh")
} }
} }
// Буфер держит только живые записи: ленивое вытеснение (start) не даёт
// «мёртвым» записям попасть в Entries/Render.
func TestLogBufferLiveEntriesOnly(t *testing.T) {
b := NewLogBuffer(30)
for i := 0; i < 3; i++ {
b.Append(LogLevelInfo, "0123456789") // 10 байт + \n каждая
}
// сумма 33 > 30 → вытесняется первая запись, остаются две живые
entries := b.Entries()
if len(entries) != 2 {
t.Fatalf("len(Entries) = %d, want 2 (мёртвая запись скрыта)", len(entries))
}
if entries[0].Text != "0123456789" || entries[1].Text != "0123456789" {
t.Fatalf("unexpected live entries: %+v", entries)
}
if got, want := b.Render(allLevels()), "0123456789\n0123456789\n"; got != want {
t.Fatalf("Render = %q, want %q", got, want)
}
}
// Кап считается по байтам текста, а не по числу записей: короткие записи
// не вытесняются, пока их суммарный объём не превысит лимит.
func TestLogBufferBytesCap(t *testing.T) {
b := NewLogBuffer(100)
for i := 0; i < 3; i++ {
b.Append(LogLevelInfo, "hello") // 5 байт + \n
}
if got := len(b.Entries()); got != 3 {
t.Fatalf("len(Entries) = %d, want 3 (все помещаются)", got)
}
// одна большая запись вытесняет всё старое (live=1) и обрезается
b2 := NewLogBuffer(8)
b2.Append(LogLevelInfo, "short")
b2.Append(LogLevelInfo, "verylongtext")
entries := b2.Entries()
if len(entries) != 1 {
t.Fatalf("len(Entries) = %d, want 1", len(entries))
}
if entries[0].Text != "longtext" {
t.Fatalf("oversized entry text = %q, want %q", entries[0].Text, "longtext")
}
}

View File

@@ -15,6 +15,9 @@ CI собирает ТОЛЬКО headless-linux (см. .gitea/workflows/ci.yaml)
GITEA_TOKEN=write:packages-токен GITEA_TOKEN=write:packages-токен
UPDATE_TOKEN=read:package-токен UPDATE_TOKEN=read:package-токен
GIT_MAIN_URL=http://gitea.hal9000.home GIT_MAIN_URL=http://gitea.hal9000.home
PROJECT_PATH=..\..\foo (опционально) корень собираемого проекта
относительно папки scripts/; без него —
папка рядом со скриптом
- Ветка уже запушена в `main` (VERSION = commit-<sha7> от текущего HEAD). - Ветка уже запушена в `main` (VERSION = commit-<sha7> от текущего HEAD).
Пример: Пример:
@@ -24,14 +27,11 @@ CI собирает ТОЛЬКО headless-linux (см. .gitea/workflows/ci.yaml)
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$RepoRoot = Split-Path -Parent $ScriptDir
$CredsFile = Join-Path $ScriptDir ".gitea-creds" $CredsFile = Join-Path $ScriptDir ".gitea-creds"
$Package = "ratatoskr" $Package = "ratatoskr"
$Owner = "kamelion" $Owner = "kamelion"
$Filename = "ratatoskr-windows-amd64.exe" $Filename = "ratatoskr-windows-amd64.exe"
Set-Location $RepoRoot
# --- 0. Проверка C-тулчейна --- # --- 0. Проверка C-тулчейна ---
if ($env:CGO_ENABLED -eq "0") { Write-Error "CGO_ENABLED=0 — нужен C-компилятор (MinGW). Уберите его из env." } if ($env:CGO_ENABLED -eq "0") { Write-Error "CGO_ENABLED=0 — нужен C-компилятор (MinGW). Уберите его из env." }
if (-not (Get-Command gcc -ErrorAction SilentlyContinue)) { if (-not (Get-Command gcc -ErrorAction SilentlyContinue)) {
@@ -59,6 +59,19 @@ $giteaUrl = $creds["GIT_MAIN_URL"]
if (-not $giteaToken) { Write-Error "В $CredsFile нет GITEA_TOKEN (нужен write:packages)." } if (-not $giteaToken) { Write-Error "В $CredsFile нет GITEA_TOKEN (нужен write:packages)." }
if (-not $giteaUrl) { Write-Error "В $CredsFile нет GIT_MAIN_URL." } if (-not $giteaUrl) { Write-Error "В $CredsFile нет GIT_MAIN_URL." }
# --- 1a. Корень целевого проекта ---
# PROJECT_PATH из .gitea-creds (относительно папки scripts/) либо, если не задан,
# папка рядом со скриптом (прежнее поведение).
$projPath = $creds["PROJECT_PATH"]
if ($projPath -ne "" -and $projPath) {
$RepoRoot = (Resolve-Path -LiteralPath (Join-Path $ScriptDir $projPath)).Path
if (-not $RepoRoot) { Write-Error "PROJECT_PATH не найден: $projPath (относительно $ScriptDir)" }
} else {
$RepoRoot = Split-Path -Parent $ScriptDir
}
Set-Location $RepoRoot
Write-Host "[root] $RepoRoot"
# --- 2. Версия = commit-<sha7> текущего HEAD (совпадает с именованием CI) --- # --- 2. Версия = commit-<sha7> текущего HEAD (совпадает с именованием CI) ---
$sha = (git rev-parse --short HEAD).Trim() $sha = (git rev-parse --short HEAD).Trim()
if (-not $sha) { Write-Error "git rev-parse --short HEAD не дал хэш." } if (-not $sha) { Write-Error "git rev-parse --short HEAD не дал хэш." }