diff --git a/panel/backend/app/metrics_retention.py b/panel/backend/app/metrics_retention.py index 73e033d..9f9168b 100644 --- a/panel/backend/app/metrics_retention.py +++ b/panel/backend/app/metrics_retention.py @@ -62,7 +62,11 @@ def _bucket(ts: str, width: str) -> str: - """Ключ бакета по строке ISO-8601 (`2026-10-05T08:59:31.759686+00:00`).""" + """Ключ бакета по строке ISO-8601 (`2026-10-05T08:59:31.759686+00:00`). + + На ts не нашего формата бросает ValueError — вызывающий такую строку + пропускает (см. `_thin_server_tier`). + """ if width == "hour": return ts[:13] if width == "5min": @@ -70,17 +74,25 @@ return ts[:16] -def _bucket_bounds(key: str, width: str) -> tuple[str, str]: - """ISO-границы бакета (включительно) — удаляем по ним, не держа id в памяти.""" - if width == "hour": - start = datetime.fromisoformat(key + ":00:00+00:00") - step = timedelta(hours=1) - elif width == "5min": - start = datetime.fromisoformat(f"{key[:14]}{int(key[14:]) * 5:02d}:00+00:00") - step = timedelta(minutes=5) - else: # minute - start = datetime.fromisoformat(key + ":00+00:00") - step = timedelta(minutes=1) +def _bucket_bounds(key: str, width: str) -> tuple[str, str] | None: + """ISO-границы бакета (включительно) — удаляем по ним, не держа id в памяти. + + `None` — если ключ не разбирается (в БД попал ts не нашего формата): такой + бакет не трогаем, чтобы одна битая строка не роняла весь проход, — и + границы бакета, склеенные из чужого формата, не совпали бы со строками БД. + """ + try: + if width == "hour": + start = datetime.fromisoformat(key + ":00:00+00:00") + step = timedelta(hours=1) + elif width == "5min": + start = datetime.fromisoformat(f"{key[:14]}{int(key[14:]) * 5:02d}:00+00:00") + step = timedelta(minutes=5) + else: # minute + start = datetime.fromisoformat(key + ":00+00:00") + step = timedelta(minutes=1) + except ValueError: + return None end = start + step - timedelta(microseconds=1) return start.isoformat(), end.isoformat() @@ -167,7 +179,10 @@ ) buckets: dict[str, _Bucket] = {} async for row in cursor: - key = _bucket(row["ts"], width) + try: + key = _bucket(row["ts"], width) + except ValueError: + continue # битый ts: строку не трогаем и в дайджест не берём bucket = buckets.get(key) if bucket is None: buckets[key] = _Bucket(row) @@ -183,10 +198,13 @@ for key, bucket in buckets.items(): if bucket.n <= 1: continue # схлопывать нечего (и повторный прогон ничего не переписывает) + bounds = _bucket_bounds(key, width) + if bounds is None: + continue # ключ из битого ts — бакет не трогаем целиком await db.execute( "UPDATE metrics SET agg_json = ? WHERE id = ?", (bucket.digest(exp), bucket.survivor) ) - start, end = _bucket_bounds(key, width) + start, end = bounds cursor = await db.execute( """DELETE FROM metrics WHERE server_id = ? AND ts >= ? AND ts <= ? AND id != ?""", (server_id, start, end, bucket.survivor), diff --git a/panel/backend/app/schema.sql b/panel/backend/app/schema.sql index 31ee7c8..291a5ab 100644 --- a/panel/backend/app/schema.sql +++ b/panel/backend/app/schema.sql @@ -69,6 +69,9 @@ ); CREATE INDEX IF NOT EXISTS idx_events_server_ts ON events(server_id, ts); +-- по одному ts: чистка журнала по возрасту (events.offline_loop, раз в 15 с) +-- иначе каждый прогон сканирует всю таблицу +CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts); -- Сетевые хранилища: шард-пути, примонтированные к машине с hard-panel. -- Панель сама опрашивает их os.statvfs (share_samples.ok=0 — путь недоступен).