«Настройка» без 10-секундного ожидания: статистика npr.history по первичному ключу
Каждый рендер «Настройки» ждал /section службы bj-quikexport, а та считала min(ts) и count(*) за сутки по npr.history (18,5 млн строк, индекса по ts нет): 6 + 27 с на 28, срез по таймауту 10 с. Теперь начало истории — первая строка по id, строки за сутки/час — двоичный поиск по id (история только дописывается): ~45 мс. То же в /admin/npr/api/freshness. bj-server ждёт раздел QUIK-экспорта не дольше 3 с и иначе отдаёт последний удачный; прогрев версий, пользователей ЛК и раздела после запуска. «Обновления» перезагружают страницу, только если что-то изменилось. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,14 +60,33 @@ func quikExportDo(ctx context.Context, rc *RuntimeConfig, method, uri string, bo
|
|||||||
return quikExportHTTP.Do(req)
|
return quikExportHTTP.Do(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// qxSection — последний ответ службы: страница «Настройка» не ждёт её дольше qxSectionWait, а отдаёт
|
||||||
|
// последний удачный раздел (служба могла задуматься над статистикой базы).
|
||||||
|
var qxSection struct {
|
||||||
|
sync.Mutex
|
||||||
|
v QuikExportView
|
||||||
|
at time.Time
|
||||||
|
ro bool
|
||||||
|
}
|
||||||
|
|
||||||
|
const qxSectionWait = 3 * time.Second
|
||||||
|
|
||||||
func quikExportView(rc *RuntimeConfig, showRO bool) QuikExportView {
|
func quikExportView(rc *RuntimeConfig, showRO bool) QuikExportView {
|
||||||
uri := "/section"
|
uri := "/section"
|
||||||
if showRO {
|
if showRO {
|
||||||
uri += "?reveal_ro=1"
|
uri += "?reveal_ro=1"
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), qxSectionWait)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
resp, err := quikExportDo(ctx, rc, http.MethodGet, uri, nil, "")
|
resp, err := quikExportDo(ctx, rc, http.MethodGet, uri, nil, "")
|
||||||
|
if err != nil {
|
||||||
|
qxSection.Lock()
|
||||||
|
v, at, ro := qxSection.v, qxSection.at, qxSection.ro
|
||||||
|
qxSection.Unlock()
|
||||||
|
if !at.IsZero() && ro == showRO && time.Since(at) < 30*time.Minute {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
b, rerr := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
b, rerr := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
@@ -76,7 +96,11 @@ func quikExportView(rc *RuntimeConfig, showRO bool) QuikExportView {
|
|||||||
case resp.StatusCode != http.StatusOK:
|
case resp.StatusCode != http.StatusOK:
|
||||||
err = fmt.Errorf("служба ответила %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
err = fmt.Errorf("служба ответила %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||||
default:
|
default:
|
||||||
return QuikExportView{HTML: template.HTML(b), Ind: resp.Header.Get("X-BJ-Indicator")}
|
v := QuikExportView{HTML: template.HTML(b), Ind: resp.Header.Get("X-BJ-Indicator")}
|
||||||
|
qxSection.Lock()
|
||||||
|
qxSection.v, qxSection.at, qxSection.ro = v, time.Now(), showRO
|
||||||
|
qxSection.Unlock()
|
||||||
|
return v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return QuikExportView{Ind: "err", HTML: template.HTML(`<h1>QUIK — Экспорт биржевой информации</h1>`) + moduleMessage(
|
return QuikExportView{Ind: "err", HTML: template.HTML(`<h1>QUIK — Экспорт биржевой информации</h1>`) + moduleMessage(
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ type adminTemplates struct {
|
|||||||
// registerSetup вешает /admin/setup и /admin/setup/* (POST) на mux.
|
// registerSetup вешает /admin/setup и /admin/setup/* (POST) на mux.
|
||||||
func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service) {
|
func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service) {
|
||||||
h := &setupHandlers{rc: rc, tpl: &adminTemplates{a: a}, svc: svc}
|
h := &setupHandlers{rc: rc, tpl: &adminTemplates{a: a}, svc: svc}
|
||||||
|
go func() { // прогрев: версии компонентов, пользователи ЛК, раздел QUIK-экспорта
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
systemVersions(rc)
|
||||||
|
lkUsersForSetup(rc)
|
||||||
|
quikExportView(rc, false)
|
||||||
|
}()
|
||||||
mux.HandleFunc("/admin/setup", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/admin/setup", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||||
|
|||||||
@@ -189,11 +189,15 @@
|
|||||||
try{ tried=+sessionStorage.getItem('bj-upd-auto')||0; }catch(e){}
|
try{ tried=+sessionStorage.getItem('bj-upd-auto')||0; }catch(e){}
|
||||||
if(!force && (now-last<600 || now-tried<600)) return; // проверяли меньше 10 минут назад (или уже пробовали сами)
|
if(!force && (now-last<600 || now-tried<600)) return; // проверяли меньше 10 минут назад (или уже пробовали сами)
|
||||||
try{ sessionStorage.setItem('bj-upd-auto',String(Math.floor(now))); }catch(e){}
|
try{ sessionStorage.setItem('bj-upd-auto',String(Math.floor(now))); }catch(e){}
|
||||||
busy=true; var b=box.querySelector('.stc'); if(b){ b.className='stc stc-run'; var t=b.querySelector('b'); if(t) t.textContent='Проверяю обновления…'; }
|
busy=true; var b=box.querySelector('.stc'), t=b&&b.querySelector('b'), was=b&&[b.className,t?t.textContent:''];
|
||||||
|
if(b){ b.className='stc stc-run'; if(t) t.textContent='Проверяю обновления…'; }
|
||||||
|
function restore(){ busy=false; if(b){ b.className=was[0]; if(t) t.textContent=was[1]; } }
|
||||||
fetch('/admin/setup/update/check',{method:'POST',headers:{'X-BJ-Ajax':'1'},cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
|
fetch('/admin/setup/update/check',{method:'POST',headers:{'X-BJ-Ajax':'1'},cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
|
||||||
box.dataset.last=String(Math.floor(Date.now()/1000));
|
box.dataset.last=String(Math.floor(Date.now()/1000));
|
||||||
|
// ничего нового — страницу не перерисовываем (полный рендер «Настройки» дорогой)
|
||||||
|
if(d && d.ok && (d.has_update?'1':'0')===box.dataset.has && (!d.has_update || box.textContent.indexOf(d.available||'\u0000')>=0)){ restore(); return; }
|
||||||
location.reload(); // состояние компонентов — с сервера
|
location.reload(); // состояние компонентов — с сервера
|
||||||
}).catch(function(){ busy=false; location.reload(); });
|
}).catch(function(){ restore(); });
|
||||||
}
|
}
|
||||||
box.addEventListener('click',function(e){ if(e.target.closest('[data-upd-check]')) check(true); });
|
box.addEventListener('click',function(e){ if(e.target.closest('[data-upd-check]')) check(true); });
|
||||||
document.querySelectorAll('.settings-nav [data-sec="update"]').forEach(function(b){ b.addEventListener('click',function(){ setTimeout(function(){ check(false); },0); }); });
|
document.querySelectorAll('.settings-nav [data-sec="update"]').forEach(function(b){ b.addEventListener('click',function(){ setTimeout(function(){ check(false); },0); }); });
|
||||||
|
|||||||
+31
-1
@@ -454,7 +454,18 @@ func (s *Server) Handler() http.Handler {
|
|||||||
_ = p.QueryRow(ctx, "SELECT last_tick, tick_count FROM npr.heartbeat WHERE id=1").Scan(&lastTick, &ticks)
|
_ = p.QueryRow(ctx, "SELECT last_tick, tick_count FROM npr.heartbeat WHERE id=1").Scan(&lastTick, &ticks)
|
||||||
var lastTS *time.Time
|
var lastTS *time.Time
|
||||||
var total, hour, day int64
|
var total, hour, day int64
|
||||||
_ = p.QueryRow(ctx, `SELECT max(ts), count(*), count(*) FILTER (WHERE ts >= NOW() - INTERVAL '1 hour'), count(*) FILTER (WHERE ts >= NOW() - INTERVAL '24 hours') FROM npr.history`).Scan(&lastTS, &total, &hour, &day)
|
// По первичному ключу, без полного прохода npr.history (десятки миллионов строк, индекса по ts нет):
|
||||||
|
// последнее событие — max(id), всего — оценка планировщика, за час/сутки — двоичный поиск по id.
|
||||||
|
var minID, maxID *int64
|
||||||
|
_ = p.QueryRow(ctx, `SELECT (SELECT ts FROM npr.history ORDER BY id DESC LIMIT 1), COALESCE((SELECT reltuples::bigint FROM pg_class WHERE oid='npr.history'::regclass),0), (SELECT min(id) FROM npr.history), (SELECT max(id) FROM npr.history)`).Scan(&lastTS, &total, &minID, &maxID)
|
||||||
|
if minID != nil && maxID != nil {
|
||||||
|
now := time.Now()
|
||||||
|
hour = *maxID - nprHistIDAt(ctx, p, now.Add(-time.Hour), *minID, *maxID+1) + 1
|
||||||
|
day = *maxID - nprHistIDAt(ctx, p, now.Add(-24*time.Hour), *minID, *maxID+1) + 1
|
||||||
|
if total < day {
|
||||||
|
total = day
|
||||||
|
}
|
||||||
|
}
|
||||||
out := map[string]any{"last_tick": nil, "tick_count": ticks, "last_event_ts": nil, "total_rows": total, "events_last_hour": hour, "events_last_24h": day, "server_now": time.Now().Format(time.RFC3339)}
|
out := map[string]any{"last_tick": nil, "tick_count": ticks, "last_event_ts": nil, "total_rows": total, "events_last_hour": hour, "events_last_24h": day, "server_now": time.Now().Format(time.RFC3339)}
|
||||||
if lastTick != nil {
|
if lastTick != nil {
|
||||||
out["last_tick"] = lastTick.Format(time.RFC3339)
|
out["last_tick"] = lastTick.Format(time.RFC3339)
|
||||||
@@ -582,3 +593,22 @@ func (s *Server) Handler() http.Handler {
|
|||||||
})
|
})
|
||||||
return modkit.RequireKey(s.cfg.KeyPath, mux)
|
return modkit.RequireKey(s.cfg.KeyPath, mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nprHistIDAt — первый id в npr.history со временем не раньше t. История только дописывается (id растёт
|
||||||
|
// вместе с ts), поэтому двоичный поиск по первичному ключу: ~25 коротких запросов вместо полного прохода
|
||||||
|
// таблицы (индекса по ts нет). lo, hi — min(id) и max(id).
|
||||||
|
func nprHistIDAt(ctx context.Context, p *pgxpool.Pool, t time.Time, lo, hi int64) int64 {
|
||||||
|
for lo < hi {
|
||||||
|
mid := lo + (hi-lo)/2
|
||||||
|
var ts time.Time
|
||||||
|
if err := p.QueryRow(ctx, `SELECT ts FROM npr.history WHERE id >= $1 ORDER BY id LIMIT 1`, mid).Scan(&ts); err != nil {
|
||||||
|
return hi
|
||||||
|
}
|
||||||
|
if ts.Before(t) {
|
||||||
|
lo = mid + 1
|
||||||
|
} else {
|
||||||
|
hi = mid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lo
|
||||||
|
}
|
||||||
|
|||||||
@@ -920,6 +920,25 @@ func quikExportPool(s QuikExportSettings) (*pgxpool.Pool, error) {
|
|||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nprHistIDAt — первый id в npr.history со временем не раньше t. История только дописывается (id растёт
|
||||||
|
// вместе с ts), поэтому двоичный поиск по первичному ключу: ~25 коротких запросов вместо полного прохода
|
||||||
|
// таблицы (индекса по ts нет). lo, hi — min(id) и max(id).
|
||||||
|
func nprHistIDAt(ctx context.Context, p *pgxpool.Pool, t time.Time, lo, hi int64) int64 {
|
||||||
|
for lo < hi {
|
||||||
|
mid := lo + (hi-lo)/2
|
||||||
|
var ts time.Time
|
||||||
|
if err := p.QueryRow(ctx, `SELECT ts FROM npr.history WHERE id >= $1 ORDER BY id LIMIT 1`, mid).Scan(&ts); err != nil {
|
||||||
|
return hi
|
||||||
|
}
|
||||||
|
if ts.Before(t) {
|
||||||
|
lo = mid + 1
|
||||||
|
} else {
|
||||||
|
hi = mid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lo
|
||||||
|
}
|
||||||
|
|
||||||
func quikExportStats(s QuikExportSettings) quikExportDBStats {
|
func quikExportStats(s QuikExportSettings) quikExportDBStats {
|
||||||
st := quikExportDBStats{ExporterUser: s.DBUser}
|
st := quikExportDBStats{ExporterUser: s.DBUser}
|
||||||
if s.ROPassword == "" {
|
if s.ROPassword == "" {
|
||||||
@@ -954,10 +973,18 @@ func quikExportStats(s QuikExportSettings) quikExportDBStats {
|
|||||||
}
|
}
|
||||||
var histBytes int64
|
var histBytes int64
|
||||||
var from *time.Time
|
var from *time.Time
|
||||||
if err := p.QueryRow(ctx, `SELECT COALESCE((SELECT reltuples::bigint FROM pg_class WHERE oid='npr.history'::regclass),0), pg_total_relation_size('npr.history'), (SELECT min(ts) FROM npr.history), (SELECT count(*) FROM npr.history WHERE ts >= now() - interval '24 hours')`).Scan(&st.HistoryRows, &histBytes, &from, &st.Rows24h); err == nil {
|
var minID, maxID *int64
|
||||||
|
// Начало истории и строки за сутки — по первичному ключу (см. nprHistIDAt), без прохода по таблице.
|
||||||
|
if err := p.QueryRow(ctx, `SELECT COALESCE((SELECT reltuples::bigint FROM pg_class WHERE oid='npr.history'::regclass),0), pg_total_relation_size('npr.history'), (SELECT ts FROM npr.history ORDER BY id LIMIT 1), (SELECT min(id) FROM npr.history), (SELECT max(id) FROM npr.history)`).Scan(&st.HistoryRows, &histBytes, &from, &minID, &maxID); err == nil {
|
||||||
if from != nil {
|
if from != nil {
|
||||||
st.HistoryFrom = *from
|
st.HistoryFrom = *from
|
||||||
}
|
}
|
||||||
|
if minID != nil && maxID != nil {
|
||||||
|
st.Rows24h = *maxID - nprHistIDAt(ctx, p, time.Now().Add(-24*time.Hour), *minID, *maxID+1) + 1
|
||||||
|
if st.Rows24h < 0 {
|
||||||
|
st.Rows24h = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
if st.HistoryRows > 0 {
|
if st.HistoryRows > 0 {
|
||||||
st.GrowthMBDay = float64(histBytes) / float64(st.HistoryRows) * float64(st.Rows24h) / (1 << 20)
|
st.GrowthMBDay = float64(histBytes) / float64(st.HistoryRows) * float64(st.Rows24h) / (1 << 20)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user