Compare commits

..
14 Commits
Author SHA1 Message Date
Akizon77 d69effee7a fix: skip temporary staging files and cleanup on context cancel 2026-07-28 17:11:07 +08:00
MoGuQAQandAkizon77 ebe5278183 feat(admin): 备份恢复改为分块上传 (#618)
* feat(api): 添加备份文件分块上传接口

* refactor(backup): 优化备份机制,引入白名单与短时去重

* fix: harden backup chunk uploads

* fix: serialize backup restore operations

* fix: stop cancelled backup merge

* refactor: simplify backup chunk staging

---------

Co-authored-by: Akizon77 <i@akz.moe>
2026-07-28 16:26:26 +08:00
Akizon77 16d6f2670d fix: #605 纯 IPv6 通知发送 #608 离线通知状态初始化遗漏 2026-07-28 15:05:25 +08:00
Akizon77 abc53b3cc0 feat: pprof view 2026-07-28 14:10:24 +08:00
Akizon77 a1ae82169c docs: simplify installation instructions and update sponsor credits 2026-07-28 11:56:31 +08:00
Akizon77 8c849aa423 feat: auto restart after reload a old metric database 2026-07-28 11:17:40 +08:00
Akizon77 0d699951b7 optimize sqlite memory usage 2026-07-28 10:17:18 +08:00
Akizon77 1ffe413eb7 remove low resource mode 2026-07-28 10:00:34 +08:00
Akizon77 c3c3ed8ecf feat(metricstore): extend raw retention to 10 min, block reload if upgrade needed
- Increase DefaultRollupRawRetention from 1 to 10 minutes with lossless byte-encoding.
- Add structure upgrade check before hot-reload to prevent schema issues.
- Update test and refactor migration constants.
2026-07-27 22:43:17 +08:00
Akizon77 0103b99651 fix(metric): defer hot rollup flush for exact upsert window
Ensure closed minute rollups are not persisted while exact samples can still
overwrite, preventing additive t-digest errors from late-upserts. Compute
flushBefore as bucketStartMillis(now - exactRawRetention) instead of the
minute boundary.

Also extend isMissingTableError and isMissingColumnError to recognise
PostgreSQL "relation ... does not exist" and SQLSTATE 42P01 errors for
better portability.
2026-07-27 14:21:49 +08:00
Akizon77 b8c100823f refactor: replace legacy upgrade with database migration loop
- Replace the old 1.2.7 upgrade check and execution with a general database migration loop that includes recovery logic for migration detection failures.
- Add `TestRetryMetricWALCheckpointDefersBusyReader` to verify checkpoint retry when a busy reader holds an open transaction.
- Reduce default TDigest compression from 100 to 30 to keep digests smaller while maintaining acceptable tail error.
- Adjust test parameters (interval, time ranges) and stats precision checks for aggregate tests.
2026-07-27 13:07:17 +08:00
Akizon77 c7819cd96e feat: add weight field to public ping task response 2026-07-24 13:37:06 +08:00
Akizon77 17aa37c64f fix(metricstore): avoid compaction write starvation 2026-07-22 20:44:27 +08:00
Akizon77 045f24ef85 refactor: remove automatic password retrieval, guide user to web setup
The installation script no longer automatically fetches the initial admin password
from system logs. Instead, after starting the service, the user is directed to
the web interface to create an admin account via the installation wizard,
enhancing security and simplifying the initial setup experience.
2026-07-22 16:50:26 +08:00
103 changed files with 11242 additions and 5152 deletions

No files matched your search

+9 -102
View File
@@ -5,7 +5,7 @@
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
[简体中文](./docs/README_zh.md) | [繁體中文](./docs/README_zh-TW.md) | [日本語](./docs/README_ja.md)
[简体中文](./README_zh-cn.md)
Komari is a lightweight, self-hosted server monitoring tool designed to provide a simple and efficient solution for monitoring server performance. It supports viewing server status through a web interface and collects data through a lightweight agent.
@@ -14,7 +14,7 @@ Komari is a lightweight, self-hosted server monitoring tool designed to provide
> Users are solely responsible for how they deploy and operate Komari. The developers do not accept responsibility for unauthorized or abusive use, or for any resulting consequences.
> On Windows, when remote control is enabled, the client displays a Windows notification at each user login to remind the user that Komari is remote control software.
[Documentation](https://komari-document.pages.dev/) | [文档(镜像站 By Geekertao)](https://www.komari.wiki) | [Telegram Group](https://t.me/komari_monitor)
[文档/Documentation](https://komari-document.pages.dev/) | [文档(镜像站 By Geekertao)](https://www.komari.wiki) | [Telegram Group](https://t.me/komari_monitor)
## Features
@@ -22,9 +22,7 @@ Komari is a lightweight, self-hosted server monitoring tool designed to provide
- **Self-hosted**: Complete control over data privacy, easy to deploy.
- **Web Interface**: Intuitive monitoring dashboard, easy to use.
## Quick Start
### 0. One-click Deployment with Cloud Hosting
## One-click Deployment
- Rainyun - CNY 4.5/month
@@ -34,80 +32,7 @@ Komari is a lightweight, self-hosted server monitoring tool designed to provide
Available on 1Panel App Store. Install via **App Store > Utilities > Komari**.
### 1. Use the One-click Install Script
Suitable for distributions using systemd (Ubuntu, Debian...).
```bash
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
chmod +x install-komari.sh
sudo ./install-komari.sh
```
### 2. Docker Deployment
1. Create a data directory:
```bash
mkdir -p ./data
```
2. Run the Docker container:
```bash
docker run -d \
-p 25774:25774 \
-v $(pwd)/data:/app/data \
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. Access `http://<your_server_ip>:25774` in your browser and complete the installation guide. The guide creates the administrator account and configures the site metadata and monitoring database.
### 3. Binary File Deployment
1. Visit Komari's [GitHub Release page](https://github.com/komari-monitor/komari/releases) to download the latest binary for your operating system.
2. Run Komari:
```bash
./komari server -l 0.0.0.0:25774
```
3. Access `http://<your_server_ip>:25774` in your browser. The default port is `25774`.
4. Complete the installation guide to create the administrator account and configure the site metadata and monitoring database.
> [!NOTE]
> Ensure the binary has execute permissions (`chmod +x komari`). Data will be saved in the `data` folder in the running directory.
### Manual Build
#### Dependencies
- Go 1.18+ and Node.js 20+ (for manual build)
1. Build the frontend static files:
```bash
git clone https://github.com/komari-monitor/komari-web
cd komari-web
npm install
npm run build
```
2. Build the backend:
```bash
git clone https://github.com/komari-monitor/komari
cd komari
```
Copy the static files generated in step 1 to the `/web/public/defaultTheme/dist` folder in the root of the `komari` project, and copy `komari-theme.json` + `preview.png`/`perview.png` to `/web/public/defaultTheme`.
```bash
go build -o komari
```
3. Run:
```bash
./komari server -l 0.0.0.0:25774
```
The default listening port is `25774`. Access `http://localhost:25774`.
## Frontend Development Guide
[Komari Theme Development Guide | Komari](https://komari-document.pages.dev/dev/theme.html)
## Client Agent Development Guide
[Komari Agent Information Reporting and Event Handling Documentation](https://komari-document.pages.dev/dev/agent.html)
For Docker, binary, source-build, and update instructions, see the [installation guide](https://komari-document.pages.dev/en/install/quick-start).
## Contributing
@@ -115,26 +40,8 @@ Issues and Pull Requests are welcome!
## Acknowledgements
### 破碎工坊云
[破碎工坊云 - 专业云计算服务平台,提供高效、稳定、安全的高防服务器与CDN解决方案](https://www.crash.work/)
### DreamCloud
[DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
### 🚀 Sponsored by SharonNetworks
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 为您的业务起飞保驾护航!
亚太数据中心提供顶级的中国优化网络接入 · 低延时&高带宽&提供Tbps级本地清洗高防服务, 为您的业务保驾护航, 为您的客户提供极致体验. 加入社区 [Telegram群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用
### The open source software community
All the developers who submitted PRs and created themes
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
| Acknowledgement | Details |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <a href="https://whmcs.as211392.com/aff.php?aff=110"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/dreamcloud.png" alt="Dream Cloud" width="180"></a> | 极高性价比解锁直连亚太高防,真高防,不虚标,打死退款 |
| <a href="https://sharon.io"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp" alt="Sharon Networks" width="180"></a> | 亚太数据中心提供顶级的中国优化网络接入 · 低延时&高带宽&提供Tbps级本地清洗高防服务, 为您的业务保驾护航, 为您的客户提供极致体验. 加入社区 [Telegram群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用 |
| Open-source community | Everyone who has submitted pull requests or created themes. |
+49
View File
@@ -0,0 +1,49 @@
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/komari-monitor/komari)
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
[简体中文](./README_zh-cn.md)
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
> [!WARNING]
> Komari 是一款自托管的监控/控制程序,仅应部署在你拥有或已获得授权管理的系统上。请勿将 Komari 武器化,或在未获授权的情况下部署、访问、持久化、执行命令及从事其他滥用行为。关于现实中的滥用风险,可参考 Huntress 的分析:[Komari C2 agent abuse](https://www.huntress.com/blog/komari-c2-agent-abuse)。
> 用户需要自行承担部署和使用 Komari 的责任。开发者不对未经授权或滥用行为及其后果承担责任。
> 在 Windows 端开启远程控制后,客户端会在每次用户登录时通过 Windows 通知提醒用户 Komari 是一款远程控制软件。
[文档/Documentation](https://www.komari.wiki/) | [Telegram 群](https://t.me/komari_monitor)
## 特性
- **轻量高效**:低资源占用,适合各种规模的服务器。
- **自托管**:完全掌控数据隐私,部署简单。
- **Web 界面**:直观的监控仪表盘,易于使用。
## 快速开始
### 0. 容器云一键部署
- 雨云云应用 - CNY 4.5/月
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- 1Panel 应用商店
已上架1Panel应用商店,应用商店-实用工具-Komari 即可安装
Docker、二进制文件、源码构建和更新说明,请参阅 [安装指南](https://www.komari.wiki/install/quick-start).
## 贡献
欢迎提交 Issue 或 Pull Request!
## 鸣谢
| 赞助商 | 描述 |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <a href="https://whmcs.as211392.com/aff.php?aff=110"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/dreamcloud.png" alt="Dream Cloud" width="180"></a> | 极高性价比解锁直连亚太高防,真高防,不虚标,打死退款 |
| <a href="https://sharon.io"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp" alt="Sharon Networks" width="180"></a> | 亚太数据中心提供顶级的中国优化网络接入 · 低延时&高带宽&提供Tbps级本地清洗高防服务, 为您的业务保驾护航, 为您的客户提供极致体验. 加入社区 [Telegram群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用 |
| 开源社区 | 提交 PR、制作主题的各位开发者 |
+25 -8
View File
@@ -1,6 +1,9 @@
package cmd
import (
"errors"
"os"
appserver "github.com/komari-monitor/komari/internal/server"
logger "github.com/komari-monitor/komari/utils/log"
@@ -51,16 +54,26 @@ func RunServer() {
}
}
required, summary, err := app.LegacyUpgradeRequired()
if err != nil {
_ = app.Shutdown()
logger.Fatalf("server", "server startup failed at %q: %v", "detect-1.2.7-upgrade", err)
}
if required {
completed, err := app.RunLegacyUpgrade(summary)
for {
requirement, err := app.DatabaseMigrationRequired()
if err != nil {
completed, recoveryErr := app.RunMetricStoreRecovery(err)
if recoveryErr != nil {
_ = app.Shutdown()
logger.Fatalf("server", "server startup failed at %q: %v", "database-migration-detection-recovery", recoveryErr)
}
if !completed {
return
}
continue
}
if !requirement.Required() {
break
}
completed, err := app.RunDatabaseMigration(requirement)
if err != nil {
_ = app.Shutdown()
logger.Fatalf("server", "server startup failed at %q: %v", "run-1.2.7-upgrade", err)
logger.Fatalf("server", "server startup failed at %q: %v", "run-database-migration", err)
}
if !completed {
return
@@ -100,6 +113,10 @@ func RunServer() {
}
if err := app.Run(); err != nil {
if errors.Is(err, appserver.ErrRestartRequested) {
logger.Infof("server", "Service stopped for restart: %v", err)
os.Exit(1)
}
logger.Fatalf("server", "server exited with error: %v", err)
}
}
+52 -70
View File
@@ -14,6 +14,7 @@ import (
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/internal/migrations"
"github.com/komari-monitor/komari/internal/sqlitetune"
logger "github.com/komari-monitor/komari/utils/log"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
@@ -175,6 +176,13 @@ var (
// 也避免额外的裸文件依赖。
const SystemVersionKey = "system_version"
const (
mainSQLiteBusyTimeout = 5 * time.Second
mainSQLiteCacheSizeKB = 8 * 1024
mainSQLiteWALAutoCheckpoint = 256
mainSQLiteJournalSizeLimit = 1 << 20
)
// versionID 是当前构建的版本标识,由 SetVersionID 在 Initialize 前注入。
var versionID string
@@ -199,7 +207,7 @@ func resolveDatabaseFile() string {
}
// backupOnVersionUpgrade 在检测到版本升级时,把当前 ./data 打包到
// ./backup/upgrade-{time}.zip,便于升级(含 metrics 迁移)异常时回滚。
// ./data/backup/upgrade-{time}.zip,便于升级(含 metrics 迁移)异常时回滚。
//
// 版本标识存放于配置库(configs 表,键 system_version),因此本函数必须在
// config.SetDb 之后、一次性 metrics 迁移(InitStores)之前调用。
@@ -239,14 +247,15 @@ func backupOnVersionUpgrade() {
instance.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
}
if err := os.MkdirAll("./backup", 0755); err != nil {
backupDir := filepath.Join(".", "data", "backup")
if err := os.MkdirAll(backupDir, 0755); err != nil {
logger.Errorf("dbcore", "[upgrade-backup] failed to create backup dir: %v", err)
return
}
tsName := time.Now().UTC().Format("20060102-150405")
bakPath := filepath.Join("./backup", fmt.Sprintf("upgrade-%s.zip", tsName))
bakPath := filepath.Join(backupDir, fmt.Sprintf("upgrade-%s.zip", tsName))
backupZipPath := filepath.Join(".", "data", "backup.zip")
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}}); zipErr != nil {
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}, backupDir: {}}); zipErr != nil {
logger.Errorf("dbcore", "[upgrade-backup] failed to backup ./data before upgrade (from %q to %q): %v", prevVersion, versionID, zipErr)
return
}
@@ -267,7 +276,7 @@ func buildSQLiteDSN(databaseFile string) string {
databaseFile = "./data/komari.db"
}
params := "_busy_timeout=5000&_txlock=immediate&_journal_mode=WAL&_synchronous=NORMAL"
params := fmt.Sprintf("_busy_timeout=%d&_txlock=immediate", mainSQLiteBusyTimeout.Milliseconds())
separator := "?"
if strings.Contains(databaseFile, "?") {
separator = "&"
@@ -284,6 +293,19 @@ func buildSQLiteDSN(databaseFile string) string {
return "file:" + filepath.ToSlash(databaseFile) + separator + params
}
func mainSQLiteOptions() sqlitetune.Options {
return sqlitetune.Options{
BusyTimeout: mainSQLiteBusyTimeout,
CacheSizeKB: mainSQLiteCacheSizeKB,
MMapSizeBytes: 0,
TempStoreMemory: false,
CacheSpill: true,
WALAutoCheckpoint: mainSQLiteWALAutoCheckpoint,
JournalSizeLimitBytes: mainSQLiteJournalSizeLimit,
Synchronous: sqlitetune.SynchronousNormal,
}
}
// Initialize 显式初始化数据库连接与表结构,仅执行一次。
// 与 GetDBInstance 不同,Initialize 返回错误而非直接退出进程,
// 便于启动生命周期统一处理错误、以及在测试/CLI 命令中做隔离。
@@ -323,21 +345,22 @@ func doInitialize() error {
func() {
backupZipPath := filepath.Join(".", "data", "backup.zip")
if _, statErr := os.Stat(backupZipPath); statErr == nil {
// 4. 把除了 ./data/backup.zip 之外的所有文件压缩到 ./backup/{time}.zip
if err := os.MkdirAll("./backup", 0755); err != nil {
// 4. 将当前数据快照保存到 ./data/backup/,并保留已有归档。
backupDir := filepath.Join(".", "data", "backup")
if err := os.MkdirAll(backupDir, 0755); err != nil {
logger.Errorf("dbcore", "[restore] failed to create backup dir: %v", err)
} else {
tsName := time.Now().UTC().Format("20060102-150405")
bakPath := filepath.Join("./backup", fmt.Sprintf("%s.zip", tsName))
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}}); zipErr != nil {
bakPath := filepath.Join(backupDir, fmt.Sprintf("pre-restore-%s.zip", tsName))
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}, backupDir: {}}); zipErr != nil {
logger.Errorf("dbcore", "[restore] failed to zip current data: %v", zipErr)
} else {
logger.Infof("dbcore", "[restore] current data zipped to %s", bakPath)
}
}
// 5. 删除除了 ./data/backup.zip 之外的所有文件
if delErr := removeAllInDirExcept("./data", map[string]struct{}{backupZipPath: {}}); delErr != nil {
// 5. 删除数据文件,但保留归档目录和待恢复的 backup.zip。
if delErr := removeAllInDirExcept("./data", map[string]struct{}{backupZipPath: {}, backupDir: {}}); delErr != nil {
logger.Errorf("dbcore", "[restore] failed to cleanup data dir: %v", delErr)
}
@@ -378,40 +401,29 @@ func doInitialize() error {
// 根据数据库类型选择不同的连接方式
switch flags.ApplyDatabaseTypeNormalization() {
case flags.DatabaseTypeSQLite:
// SQLite 连接
// 通过 DSN 传入 _busy_timeout / _txlock 等参数,确保连接池中的每一条连接
// 都生效:
// - _busy_timeout=5000:遇到写锁时最多等待 5s 再返回,避免瞬时
// "database is locked" 直接失败(仅靠后续 PRAGMA Exec 只作用于
// 当时执行该语句的单条连接,池内其它连接不生效)。
// - _txlock=immediate:事务一开始即获取写锁,避免「先 SELECT 后写」
// 的锁升级在并发写入下产生死锁式的立即 SQLITE_BUSY。
// - _journal_mode=WAL / _synchronous=NORMAL:与下方 PRAGMA 保持一致,
// 在 DSN 层为所有连接预设。
// _txlock=immediate lets writes acquire their lock before reads can
// turn into a lock-upgrade conflict. sqlitetune applies the remaining
// per-connection PRAGMAs whenever database/sql opens a connection.
dsn := buildSQLiteDSN(flags.DatabaseFile)
instance, err = gorm.Open(sqlite.Open(dsn), logConfig)
sqlDB, dbErr := sqlitetune.Open(dsn, mainSQLiteOptions())
if dbErr != nil {
return fmt.Errorf("open SQLite connection pool: %w", dbErr)
}
// SQLite has one writer. Keeping exactly one durable connection also
// keeps connection-local cache and WAL limits stable for the main DB.
sqlDB.SetMaxOpenConns(1)
sqlDB.SetMaxIdleConns(1)
sqlDB.SetConnMaxLifetime(0)
sqlDB.SetConnMaxIdleTime(0)
instance, err = gorm.Open(sqlite.New(sqlite.Config{Conn: sqlDB}), logConfig)
if err != nil {
_ = sqlDB.Close()
return fmt.Errorf("failed to connect to SQLite3 database: %w", err)
}
if sqlDB, dbErr := instance.DB(); dbErr == nil {
// SQLite 同一时刻只允许一个写者;限制连接数可避免连接池层面的写写竞争。
// 负载历史每分钟会执行包含读和写的事务,若连接池允许多个连接,容易与
// ping 结果等短写入撞锁并导致整批负载记录回滚。
sqlDB.SetMaxOpenConns(1)
sqlDB.SetMaxIdleConns(1)
sqlDB.SetConnMaxLifetime(0)
} else {
logger.Errorf("dbcore", "Failed to access underlying sql.DB for SQLite tuning: %v", dbErr)
if err := instance.Exec("PRAGMA wal_checkpoint(TRUNCATE)").Error; err != nil {
logger.Errorf("dbcore", "Failed to checkpoint SQLite WAL at startup: %v", err)
}
instance.Exec("PRAGMA wal = ON;")
if err := instance.Exec("PRAGMA journal_mode = WAL;").Error; err != nil {
logger.Errorf("dbcore", "Failed to enable WAL mode for SQLite: %v", err)
}
instance.Exec("PRAGMA synchronous = NORMAL;")
instance.Exec("PRAGMA busy_timeout = 5000;")
instance.Exec("PRAGMA cache_size = -65536;")
instance.Exec("PRAGMA temp_store = MEMORY;")
instance.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
default:
return fmt.Errorf("unsupported database type: %s (supported: %s)", flags.DatabaseType, flags.SupportedDatabaseTypes())
}
@@ -463,33 +475,3 @@ func doInitialize() error {
return nil
}
// ConfigureLowResourceMode updates connection-local SQLite memory settings.
// The main database uses one connection, so these PRAGMAs remain effective for
// the lifetime of the pool and can be switched without reopening the database.
func ConfigureLowResourceMode(enabled bool) error {
if instance == nil || flags.ApplyDatabaseTypeNormalization() != flags.DatabaseTypeSQLite {
return nil
}
pragmas := []string{
"PRAGMA synchronous = NORMAL;",
"PRAGMA mmap_size = 0;",
}
if enabled {
pragmas = append(pragmas,
"PRAGMA cache_size = -8192;",
"PRAGMA temp_store = FILE;",
)
} else {
pragmas = append(pragmas,
"PRAGMA cache_size = -65536;",
"PRAGMA temp_store = MEMORY;",
)
}
for _, pragma := range pragmas {
if err := instance.Exec(pragma).Error; err != nil {
return fmt.Errorf("apply SQLite resource setting %q: %w", pragma, err)
}
}
return nil
}
+99
View File
@@ -0,0 +1,99 @@
package dbcore
import (
"database/sql"
"path/filepath"
"strings"
"testing"
"github.com/komari-monitor/komari/internal/sqlitetune"
)
func TestMainSQLiteConnectorAppliesBoundedSettings(t *testing.T) {
databasePath := filepath.Join(t.TempDir(), "komari.db")
db, err := sqlitetune.Open(buildSQLiteDSN(databasePath), mainSQLiteOptions())
if err != nil {
t.Fatalf("open SQLite connector: %v", err)
}
defer db.Close()
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
if got := sqlitePragmaInt(t, db, "busy_timeout"); got != mainSQLiteBusyTimeout.Milliseconds() {
t.Fatalf("busy_timeout = %d, want %d", got, mainSQLiteBusyTimeout.Milliseconds())
}
if got := sqlitePragmaInt(t, db, "synchronous"); got != 1 {
t.Fatalf("synchronous = %d, want NORMAL (1)", got)
}
if got := sqlitePragmaInt(t, db, "cache_size"); got != -mainSQLiteCacheSizeKB {
t.Fatalf("cache_size = %d, want %d", got, -mainSQLiteCacheSizeKB)
}
if got := sqlitePragmaInt(t, db, "mmap_size"); got != 0 {
t.Fatalf("mmap_size = %d, want 0", got)
}
if got := sqlitePragmaInt(t, db, "temp_store"); got != 1 {
t.Fatalf("temp_store = %d, want FILE (1)", got)
}
if got := sqlitePragmaInt(t, db, "cache_spill"); got == 0 {
t.Fatal("cache_spill is disabled")
}
if got := sqlitePragmaInt(t, db, "wal_autocheckpoint"); got != mainSQLiteWALAutoCheckpoint {
t.Fatalf("wal_autocheckpoint = %d, want %d", got, mainSQLiteWALAutoCheckpoint)
}
if got := sqlitePragmaInt(t, db, "journal_size_limit"); got != mainSQLiteJournalSizeLimit {
t.Fatalf("journal_size_limit = %d, want %d", got, mainSQLiteJournalSizeLimit)
}
var journalMode string
if err := db.QueryRow("PRAGMA journal_mode").Scan(&journalMode); err != nil {
t.Fatalf("read journal_mode: %v", err)
}
if !strings.EqualFold(journalMode, "wal") {
t.Fatalf("journal_mode = %q, want WAL", journalMode)
}
}
func TestMainSQLiteAutoCheckpointKeepsWALBounded(t *testing.T) {
databasePath := filepath.Join(t.TempDir(), "komari.db")
db, err := sqlitetune.Open(buildSQLiteDSN(databasePath), mainSQLiteOptions())
if err != nil {
t.Fatalf("open SQLite connector: %v", err)
}
defer db.Close()
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
if _, err := db.Exec("CREATE TABLE checkpoint_test (payload BLOB NOT NULL)"); err != nil {
t.Fatalf("create checkpoint test table: %v", err)
}
// This produces well beyond one auto-checkpoint interval. The checkpoint
// below reports the WAL frame count observed before its own work begins.
for range 1024 {
if _, err := db.Exec("INSERT INTO checkpoint_test (payload) VALUES (zeroblob(4096))"); err != nil {
t.Fatalf("insert checkpoint test row: %v", err)
}
}
var busy, logFrames, checkpointedFrames int
if err := db.QueryRow("PRAGMA wal_checkpoint(PASSIVE)").Scan(&busy, &logFrames, &checkpointedFrames); err != nil {
t.Fatalf("inspect automatic checkpoint: %v", err)
}
if busy != 0 {
t.Fatalf("automatic checkpoint left a busy WAL: %d", busy)
}
if logFrames >= 2*mainSQLiteWALAutoCheckpoint {
t.Fatalf("WAL retained %d frames after %d-frame auto-checkpoint interval", logFrames, mainSQLiteWALAutoCheckpoint)
}
}
func sqlitePragmaInt(t *testing.T, db *sql.DB, pragma string) int64 {
t.Helper()
var value int64
if err := db.QueryRow("PRAGMA " + pragma).Scan(&value); err != nil {
t.Fatalf("read %s: %v", pragma, err)
}
return value
}
+4 -4
View File
@@ -37,10 +37,10 @@ type MaintenanceResult struct {
// InspectStorage reads physical storage information while preventing a store
// reload from closing the active connection underneath the query.
func InspectStorage(ctx context.Context) (StorageInfo, error) {
if err := storeOperations.Acquire(ctx); err != nil {
if err := storeOperations.AcquireShared(ctx); err != nil {
return StorageInfo{}, fmt.Errorf("wait for metric store operations before inspection: %w", err)
}
defer storeOperations.Release()
defer storeOperations.ReleaseShared()
storeMu.RLock()
activeStore := store
@@ -59,8 +59,8 @@ func InspectStorage(ctx context.Context) (StorageInfo, error) {
}
// ReclaimSpace performs the driver-specific physical maintenance operation.
// It shares the exclusive operation lock with Compact so retention compaction
// and a table/file rewrite can never run against the metric store concurrently.
// It takes the exclusive operation lock, so a table/file rewrite cannot run
// concurrently with report writes or compaction.
func ReclaimSpace(ctx context.Context) (MaintenanceResult, error) {
if !storeOperations.TryAcquire() {
storeMu.RLock()
+57 -7
View File
@@ -2,7 +2,9 @@ package metricstore
import (
"context"
"database/sql"
"errors"
"path/filepath"
"testing"
"time"
@@ -40,6 +42,54 @@ func TestInspectAndReclaimStorage(t *testing.T) {
}
}
func TestRetryMetricWALCheckpointDefersBusyReader(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "checkpoint.db")
s, err := metric.Open(ctx, metric.SQLite(path, metric.WithSQLiteWALAutoCheckpoint(1_000_000)))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.Close() })
if err := s.CreateMetric(ctx, metric.Definition{Name: "before", Type: metric.TypeGauge, RetentionDays: 1}); err != nil {
t.Fatal(err)
}
reader, err := sql.Open("sqlite3", path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = reader.Close() })
tx, err := reader.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
if err != nil {
t.Fatal(err)
}
var definitions int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM metric_definitions`).Scan(&definitions); err != nil {
_ = tx.Rollback()
t.Fatal(err)
}
if err := s.CreateMetric(ctx, metric.Definition{Name: "after", Type: metric.TypeGauge, RetentionDays: 1}); err != nil {
_ = tx.Rollback()
t.Fatal(err)
}
previousPending := checkpointPending
checkpointPending = true
t.Cleanup(func() { checkpointPending = previousPending })
retryMetricWALCheckpoint(ctx, s)
if !checkpointPending {
_ = tx.Rollback()
t.Fatal("busy reader unexpectedly cleared pending checkpoint")
}
if err := tx.Rollback(); err != nil {
t.Fatal(err)
}
retryMetricWALCheckpoint(ctx, s)
if checkpointPending {
t.Fatal("checkpoint remained pending after reader released")
}
}
func TestReclaimSpaceReportsBusyStore(t *testing.T) {
s, err := metric.Open(context.Background(), metric.SQLite(":memory:"))
if err != nil {
@@ -47,10 +97,10 @@ func TestReclaimSpaceReportsBusyStore(t *testing.T) {
}
installTestStore(t, s)
if !storeOperations.TryAcquire() {
t.Fatal("acquire store operation gate")
if err := storeOperations.AcquireShared(context.Background()); err != nil {
t.Fatalf("acquire shared store operation gate: %v", err)
}
defer storeOperations.Release()
defer storeOperations.ReleaseShared()
result, err := ReclaimSpace(context.Background())
if !errors.Is(err, ErrStoreBusy) {
@@ -62,10 +112,10 @@ func TestReclaimSpaceReportsBusyStore(t *testing.T) {
if !errors.Is(result.BeforeSizeError, ErrStoreBusy) || !errors.Is(result.AfterSizeError, ErrStoreBusy) {
t.Fatalf("busy result should mark both measurements unavailable: %#v", result)
}
compactCtx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := Compact(compactCtx, time.Now()); !errors.Is(err, context.Canceled) {
t.Fatalf("compact error = %v, want %v", err, context.Canceled)
compactCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if _, err := Compact(compactCtx, time.Now()); err != nil {
t.Fatalf("compact should not wait for report writes: %v", err)
}
if !compactOperations.TryAcquire() {
t.Fatal("acquire compact operation gate")
+108 -37
View File
@@ -23,15 +23,23 @@ var (
storeOperations = newStoreOperationGate()
compactOperations = newStoreOperationGate()
compactAt int
checkpointPending bool
)
var ErrCompactInProgress = errors.New("metric store compact already in progress")
// ErrStructureUpgradeRequired reports that the configured store must be
// migrated by the restricted startup guide before it can be opened normally.
var ErrStructureUpgradeRequired = errors.New("metric store structure upgrade is required")
const (
// DefaultRollupRawRetention keeps a short hot raw window; older samples are
// served from rollups after compaction.
DefaultRollupRawRetention = 15 * time.Minute
// DefaultRollupRawRetention documents the fixed in-memory exact-sample
// window. Samples older than one minute are losslessly byte-encoded;
// older history is served by the persisted rollup ladder.
DefaultRollupRawRetention = 10 * time.Minute
DefaultRollupFinestTier = time.Minute
defaultRollupPointLimit = 600
checkpointRetryTimeout = 250 * time.Millisecond
)
// MetricStoreConfig 保存 metric store 配置。
@@ -39,12 +47,11 @@ const (
// 注意:metric store 现在始终启用(旧的 metric_store_enabled 开关已废弃)。
// 未显式配置时默认使用 SQLite(./data/metrics.db)。
type MetricStoreConfig struct {
Driver string `json:"metric_db_driver" default:"sqlite"` // 数据库类型: sqlite, mysql, postgresql
DSN string `json:"metric_db_dsn" default:"./data/metrics.db"` // 数据库连接串
LowResourceMode bool `json:"low_resource_mode"` // 低资源模式由首次探测或后台设置决定
TablePrefix string `json:"metric_table_prefix" default:"metric_"` // 表名前缀
MaxOpenConns int `json:"metric_max_open_conns" default:"25"` // 最大连接数
MaxIdleConns int `json:"metric_max_idle_conns" default:"5"` // 最大空闲连接数
Driver string `json:"metric_db_driver" default:"sqlite"` // 数据库类型: sqlite, mysql, postgresql
DSN string `json:"metric_db_dsn" default:"./data/metrics.db"` // 数据库连接串
TablePrefix string `json:"metric_table_prefix" default:"metric_"` // 表名前缀
MaxOpenConns int `json:"metric_max_open_conns" default:"25"` // 最大连接数
MaxIdleConns int `json:"metric_max_idle_conns" default:"5"` // 最大空闲连接数
}
// MetricStoreConfigKeys 配置键
@@ -103,17 +110,7 @@ func buildMetricConfig(cfg *MetricStoreConfig, autoMigrate bool) (metric.Config,
// 这里刻意忽略 cfg.MaxOpenConns/MaxIdleConns —— 对 SQLite 而言多写连接
// 只会引入锁竞争而非提升吞吐。
opts = append(opts, metric.WithMaxOpenConns(1), metric.WithMaxIdleConns(1))
if cfg.LowResourceMode {
opts = append(opts,
metric.WithSQLiteProfile(metric.SQLiteProfileBalanced),
metric.WithSQLiteCacheSizeKB(8*1024),
metric.WithSQLiteMMapSize(0),
metric.WithSQLiteTempStoreMemory(false),
metric.WithSQLiteReadPool(0),
)
} else {
opts = append(opts, metric.WithSQLiteReadPool(4))
}
opts = append(opts, metric.WithSQLiteReadPool(2))
return metric.SQLite(dsn, opts...), nil
case metric.DriverMySQL:
opts = append(opts,
@@ -136,10 +133,14 @@ func defaultRollupPolicy() metric.RollupPolicy {
return metric.RollupPolicy{
RawRetention: DefaultRollupRawRetention,
Tiers: []metric.RollupTier{
{Interval: DefaultRollupFinestTier, Retention: 48 * time.Hour},
{Interval: 5 * time.Minute, Retention: 14 * 24 * time.Hour},
{Interval: time.Hour, Retention: 14 * 24 * time.Hour},
{Interval: time.Minute, Retention: time.Minute * defaultRollupPointLimit},
{Interval: 5 * time.Minute, Retention: 5 * time.Minute * defaultRollupPointLimit},
{Interval: time.Hour, Retention: time.Hour * defaultRollupPointLimit},
// Daily buckets form the terminal tier. They remain available until
// the metric's own retention policy removes them.
{Interval: 24 * time.Hour, Retention: 100 * 365 * 24 * time.Hour},
},
Compression: 30,
}
}
@@ -352,7 +353,6 @@ func InitializeStore() error {
store = s
storeFingerprint = targetFingerprint(cfg)
storeMu.Unlock()
setLowResourceMode(cfg.LowResourceMode)
clearStoreClosing()
logger.Infof("metricstore", "Metric store initialized successfully (driver=%s)", ResolveDriverFromConfig(cfg.Driver, cfg.DSN))
@@ -380,6 +380,22 @@ func RecoverStore(ctx context.Context, cfg *MetricStoreConfig) error {
recovered := *cfg
recovered.DSN = strings.TrimSpace(recovered.DSN)
recovered.Driver = string(ResolveDriverFromConfig(recovered.Driver, recovered.DSN))
restructureRequired, err := structureUpgradeRequiredForConfig(ctx, &recovered)
if err != nil {
return err
}
if restructureRequired {
target := targetFingerprint(&recovered)
if err := config.SetMany(map[string]any{
MetricDBDriverKey: recovered.Driver,
MetricDBDSNKey: recovered.DSN,
MigrationTargetKey: target,
}); err != nil {
return fmt.Errorf("save recovered metric store config: %w", err)
}
logger.Infof("metricstore", "Metric store connection recovered; structure upgrade is required (driver=%s)", recovered.Driver)
return nil
}
s, err := openStore(ctx, &recovered)
if err != nil {
return err
@@ -400,7 +416,6 @@ func RecoverStore(ctx context.Context, cfg *MetricStoreConfig) error {
store = s
storeFingerprint = target
storeMu.Unlock()
setLowResourceMode(recovered.LowResourceMode)
clearStoreClosing()
if old != nil {
@@ -432,6 +447,27 @@ func Reload(ctx context.Context) error {
return fmt.Errorf("failed to load metric store config: %w", err)
}
// Do not let the normal hot-reload path run AutoMigrate against an old
// point-backed schema. That schema must go through the authenticated
// structure-upgrade guide first; otherwise index creation can reference
// columns such as resolution_id that do not exist yet.
checkCfg, err := buildMetricConfig(cfg, false)
if err != nil {
return err
}
checkStore, err := metric.Open(ctx, checkCfg)
if err != nil {
return err
}
needsRestructure, err := checkStore.NeedsRestructure(ctx)
_ = checkStore.Close()
if err != nil {
return fmt.Errorf("inspect metric store structure: %w", err)
}
if needsRestructure {
return fmt.Errorf("%w before hot reload", ErrStructureUpgradeRequired)
}
// 用新配置打开并建表(内部已 Ping 校验连接)。
s, err := openStore(ctx, cfg)
if err != nil {
@@ -443,8 +479,6 @@ func Reload(ctx context.Context) error {
store = s
storeFingerprint = targetFingerprint(cfg)
storeMu.Unlock()
setLowResourceMode(cfg.LowResourceMode)
if old != nil {
if cerr := old.Close(); cerr != nil {
logger.Errorf("metricstore", "Failed to close previous metric store on reload: %v", cerr)
@@ -503,17 +537,18 @@ func Compact(ctx context.Context, now time.Time) (int, error) {
return 0, ErrCompactInProgress
}
defer compactOperations.Release()
if err := storeOperations.Acquire(ctx); err != nil {
return 0, fmt.Errorf("wait for metric store operations before compaction: %w", err)
if err := storeOperations.AcquireShared(ctx); err != nil {
return 0, fmt.Errorf("wait for metric store operation before compaction: %w", err)
}
defer storeOperations.Release()
defer storeOperations.ReleaseShared()
storeMu.RLock()
defer storeMu.RUnlock()
activeStore := store
storeMu.RUnlock()
if activeStore == nil {
return 0, fmt.Errorf("metric store not initialized")
}
retryMetricWALCheckpoint(ctx, activeStore)
defs, err := activeStore.ListMetrics(ctx)
if err != nil {
@@ -527,7 +562,10 @@ func Compact(ctx context.Context, now time.Time) (int, error) {
compactAt = 0
}
total := 0
total, err := activeStore.Flush(ctx, now)
if err != nil {
return 0, fmt.Errorf("flush closed metric minutes: %w", err)
}
start := compactAt
failedAt := -1
var compactErrors []error
@@ -543,8 +581,8 @@ func Compact(ctx context.Context, now time.Time) (int, error) {
}
total += n
}
if _, err := activeStore.CleanupExpired(ctx, now); err != nil {
compactErrors = append(compactErrors, fmt.Errorf("clean up expired raw metrics: %w", err))
if err := finishCompactCycle(ctx, activeStore, now); err != nil {
compactErrors = append(compactErrors, err)
}
if failedAt >= 0 {
compactAt = failedAt
@@ -554,6 +592,40 @@ func Compact(ctx context.Context, now time.Time) (int, error) {
return total, errors.Join(compactErrors...)
}
func finishCompactCycle(ctx context.Context, activeStore *metric.Store, now time.Time) error {
var compactErrors []error
if _, err := activeStore.CleanupExpired(ctx, now); err != nil {
compactErrors = append(compactErrors, fmt.Errorf("clean up expired metric data: %w", err))
}
if activeStore.Driver() == metric.DriverSQLite {
checkpointCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
if err := activeStore.CheckpointWAL(checkpointCtx); err != nil {
checkpointPending = true
logger.Warnf("metricstore", "Failed to truncate metric WAL after compaction: %v", err)
} else {
checkpointPending = false
}
cancel()
}
return errors.Join(compactErrors...)
}
func retryMetricWALCheckpoint(ctx context.Context, activeStore *metric.Store) {
if !checkpointPending {
return
}
if activeStore.Driver() != metric.DriverSQLite {
checkpointPending = false
return
}
retryCtx, cancel := context.WithTimeout(ctx, checkpointRetryTimeout)
err := activeStore.CheckpointWAL(retryCtx)
cancel()
if err == nil {
checkpointPending = false
}
}
// CloseStoreContext stops the asynchronous store migration before taking the
// store write lock, so shutdown cannot wait forever on the migration's lease.
func CloseStoreContext(ctx context.Context) error {
@@ -955,9 +1027,8 @@ func GetGPURecordsByClientAndTime(ctx context.Context, clientUUID string, start,
// GetPingRecords 从 metric store 查询兼容旧接口的 ping 记录。
//
// 旧接口过去直接读取 ping_records 的原始点。启用 metric rollup 后,较旧
// 的 raw 点会被压入 rollup 并删除,因此这里使用 Series 走与 queryMetrics
// 相同的 raw/rollup 混合读取路径,并保留 task_id 标签。
// 旧接口过去直接读取 ping_records。这里使用与 queryMetrics 相同的 Series
// 查询路径从保留层级还原记录,并保留 task_id 标签。
func GetPingRecords(ctx context.Context, clientUUID string, taskID int, start, end time.Time) ([]models.PingRecord, error) {
s := GetStore()
if s == nil {
+27 -62
View File
@@ -4,7 +4,6 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"path/filepath"
"testing"
"time"
@@ -22,12 +21,12 @@ func TestDefaultRollupPolicy(t *testing.T) {
if policy.RawRetention != DefaultRollupRawRetention {
t.Fatalf("raw retention = %s, want %s", policy.RawRetention, DefaultRollupRawRetention)
}
if len(policy.Tiers) != 3 {
t.Fatalf("expected 3 rollup tiers, got %d", len(policy.Tiers))
if len(policy.Tiers) != 4 {
t.Fatalf("expected 4 rollup tiers, got %d", len(policy.Tiers))
}
wantIntervals := []time.Duration{time.Minute, 5 * time.Minute, time.Hour}
wantRetentions := []time.Duration{48 * time.Hour, 14 * 24 * time.Hour, 14 * 24 * time.Hour}
wantIntervals := []time.Duration{time.Minute, 5 * time.Minute, time.Hour, 24 * time.Hour}
wantRetentions := []time.Duration{600 * time.Minute, 600 * 5 * time.Minute, 600 * time.Hour, 100 * 365 * 24 * time.Hour}
for i := range wantIntervals {
if policy.Tiers[i].Interval != wantIntervals[i] {
t.Fatalf("tier %d interval = %s, want %s", i, policy.Tiers[i].Interval, wantIntervals[i])
@@ -53,6 +52,9 @@ func TestBuildMetricConfigEnablesDefaultRollupPolicy(t *testing.T) {
if cfg.RollupPolicy.RawRetention != DefaultRollupRawRetention {
t.Fatalf("raw retention = %s, want %s", cfg.RollupPolicy.RawRetention, DefaultRollupRawRetention)
}
if cfg.SQLite.ReadPoolSize != 2 {
t.Fatalf("metric store read pool = %d, want fixed size 2", cfg.SQLite.ReadPoolSize)
}
}
func TestBuildMetricConfigLeavesFinalRetentionToMetricDefinition(t *testing.T) {
@@ -63,7 +65,7 @@ func TestBuildMetricConfigLeavesFinalRetentionToMetricDefinition(t *testing.T) {
if err != nil {
t.Fatalf("build metric config: %v", err)
}
wantRollupRetention := 14 * 24 * time.Hour
wantRollupRetention := 100 * 365 * 24 * time.Hour
lastTier := cfg.RollupPolicy.Tiers[len(cfg.RollupPolicy.Tiers)-1]
if lastTier.Retention != wantRollupRetention {
t.Fatalf("rollup retention = %s, want %s", lastTier.Retention, wantRollupRetention)
@@ -83,32 +85,6 @@ func TestBuildMetricConfigAlwaysEnablesDownsampling(t *testing.T) {
}
}
func TestBuildMetricConfigUsesSmallSQLiteProfileInLowResourceMode(t *testing.T) {
cfg, err := buildMetricConfig(&MetricStoreConfig{
Driver: "sqlite",
DSN: "./data/metrics.db",
LowResourceMode: true,
}, false)
if err != nil {
t.Fatalf("build metric config: %v", err)
}
if cfg.SQLite.CacheSizeKB != 8*1024 {
t.Fatalf("cache size = %dKiB, want 8192KiB", cfg.SQLite.CacheSizeKB)
}
if cfg.SQLite.MMapSizeBytes != 0 {
t.Fatalf("mmap size = %d, want 0", cfg.SQLite.MMapSizeBytes)
}
if cfg.SQLite.TempStoreMemory {
t.Fatal("temp store should use FILE in low resource mode")
}
if cfg.SQLite.ReadPoolSize != 0 {
t.Fatalf("read pool size = %d, want 0", cfg.SQLite.ReadPoolSize)
}
if cfg.SQLite.PerformanceProfile != metric.SQLiteProfileBalanced {
t.Fatalf("SQLite profile = %q, want balanced", cfg.SQLite.PerformanceProfile)
}
}
func TestGetPingRecordsReadsRollupsAfterRawCompaction(t *testing.T) {
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Second)
@@ -353,9 +329,9 @@ func TestSummarizeRetentionDefinitionsRequiresEveryMetricToBePositive(t *testing
}
}
func TestCompactCleansExpiredRawPointsWhenDownsamplingDisabled(t *testing.T) {
func TestCompactCleansPointsOutsideFixedRawWindow(t *testing.T) {
ctx := context.Background()
s, err := metric.Open(ctx, metric.SQLite(":memory:", metric.WithMaxOpenConns(1)))
s, err := metric.Open(ctx, metric.SQLite(":memory:", metric.WithMaxOpenConns(1), metric.WithRollupPolicy(metric.RollupPolicy{})))
if err != nil {
t.Fatalf("open metric store: %v", err)
}
@@ -367,10 +343,10 @@ func TestCompactCleansExpiredRawPointsWhenDownsamplingDisabled(t *testing.T) {
t.Fatalf("upsert metric: %v", err)
}
now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)
now := time.Now().UTC()
if err := s.WriteBatch(ctx, []metric.Point{
{MetricName: "raw.metric", EntityID: "node", Timestamp: now.Add(-48 * time.Hour), Value: 1},
{MetricName: "raw.metric", EntityID: "node", Timestamp: now.Add(-time.Hour), Value: 2},
{MetricName: "raw.metric", EntityID: "node", Timestamp: now.Add(-11 * time.Minute), Value: 1},
{MetricName: "raw.metric", EntityID: "node", Timestamp: now.Add(-30 * time.Second), Value: 2},
}); err != nil {
t.Fatalf("write points: %v", err)
}
@@ -395,7 +371,7 @@ func TestCompactCleansExpiredRawPointsWhenDownsamplingDisabled(t *testing.T) {
points, err := s.Query(ctx, metric.Query{
MetricName: "raw.metric",
EntityID: "node",
Start: now.Add(-72 * time.Hour),
Start: now.Add(-time.Hour),
End: now,
})
if err != nil {
@@ -424,23 +400,25 @@ func TestCompactContinuesAfterOneMetricFails(t *testing.T) {
}
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
old := now.Add(-time.Hour)
if err := s.Write(ctx, metric.Point{MetricName: "b.healthy", EntityID: "node", Timestamp: old, Value: 2}); err != nil {
t.Fatalf("write healthy point: %v", err)
old := now.Add(-48 * time.Hour)
if err := s.WriteBatch(ctx, []metric.Point{
{MetricName: "a.invalid", EntityID: "node", Timestamp: old, Value: 1},
{MetricName: "b.healthy", EntityID: "node", Timestamp: old, Value: 2},
}); err != nil {
t.Fatalf("write compact fixtures: %v", err)
}
rawDB, err := sql.Open("sqlite3", dsn)
if err != nil {
t.Fatalf("open raw sqlite connection: %v", err)
}
_, err = rawDB.ExecContext(ctx, `INSERT INTO metric_points
(metric_name, entity_id, tags_hash, ts_nano, value, tags, labels, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
"a.invalid", "node", "invalid", old.UnixNano(), 1, "not-json", "{}", now.UnixNano(),
)
_, err = rawDB.ExecContext(ctx, `CREATE TRIGGER fail_invalid_metric_rollup
BEFORE DELETE ON metric_rollups
WHEN OLD.series_id IN (SELECT id FROM metric_series WHERE metric_name = 'a.invalid')
BEGIN SELECT RAISE(FAIL, 'forced compact failure'); END`)
_ = rawDB.Close()
if err != nil {
t.Fatalf("insert malformed point: %v", err)
t.Fatalf("create compact failure trigger: %v", err)
}
storeMu.Lock()
@@ -466,10 +444,10 @@ func TestCompactContinuesAfterOneMetricFails(t *testing.T) {
End: now,
})
if err != nil {
t.Fatalf("query healthy raw points: %v", err)
t.Fatalf("query healthy rollups: %v", err)
}
if len(points) != 0 {
t.Fatalf("healthy metric was blocked by another metric failure: %s", fmt.Sprint(points))
t.Fatalf("healthy metric was blocked by another metric failure: %#v", points)
}
}
@@ -569,19 +547,6 @@ func TestGetRecordsByClientAndTimeReadsRollupsAfterRawCompaction(t *testing.T) {
if _, err := s.Compact(ctx, now); err != nil {
t.Fatalf("compact raw into rollup: %v", err)
}
raw, err := s.Query(ctx, metric.Query{
MetricName: MetricCPU,
EntityID: rec.Client,
Start: ts.Add(-time.Minute),
End: now,
})
if err != nil {
t.Fatalf("query raw cpu: %v", err)
}
if len(raw) != 0 {
t.Fatalf("expected old raw cpu point to be deleted after compaction, got %d", len(raw))
}
got, err := GetRecordsByClientAndTime(ctx, rec.Client, ts.Add(-time.Minute), now)
if err != nil {
t.Fatalf("get records: %v", err)
+26 -87
View File
@@ -3,16 +3,13 @@ package metricstore
import (
"context"
"fmt"
logger "github.com/komari-monitor/komari/utils/log"
"strings"
"time"
"github.com/komari-monitor/komari/pkg/metric"
logger "github.com/komari-monitor/komari/utils/log"
)
// storeMigrationWindow 是 store-to-store 迁移时按时间分窗查询的窗口大小。
// 采样点按时间窗口分批拉取,避免一次性把整段序列读入内存。
const storeMigrationWindow = 6 * time.Hour
const storeMigrationBatchSize = 500
// configFromFingerprint 从目标指纹(driver|dsn)重建一个 MetricStoreConfig,
// 用于以只读方式打开“上一次的 metrics 目标库”。表前缀、保留天数、连接数等
@@ -38,10 +35,9 @@ func configFromFingerprint(fingerprint string, base *MetricStoreConfig) (*Metric
// openSourceStore 打开一个已存在的 metrics 目标库作为数据搬运的源库读取。
//
// 使用 autoMigrate=true:GORM 的 AutoMigrate 只新增表/列/索引、从不删除数据,
// 因此对真实旧库幂等无害;而当源库文件/表不存在(例如老快照记录了 completed
// 但 metrics.db 缺失)时,可创建空表让后续 ListMetrics 返回空集而非报
// "no such table",从而把“无历史可迁移”识别为正常情况而非错误。
// 使用 autoMigrate=true 确保规范化表和索引存在;创建语句不会删除已有数据。
// 当源库文件/表不存在(例如老快照记录了 completed 但 metrics.db 缺失)时,
// 创建空表可让后续 ListMetrics 返回空集,把“无历史可迁移”识别为正常情况。
func openSourceStore(ctx context.Context, cfg *MetricStoreConfig) (*metric.Store, error) {
metricCfg, err := buildMetricConfig(cfg, true)
if err != nil {
@@ -57,9 +53,10 @@ func defaultSQLiteFingerprint() string {
}
// migrateFromPreviousStore 打开由 prevFingerprint 指定的上一个 metrics 目标库作为源,
// 把其中的全部指标搬运到当前目标 dst(例如 SQLite metrics.db → MySQL/PostgreSQL)。
// 把其中的全部指标定义和持久化 rollup 搬运到当前目标 dst(例如 SQLite
// metrics.db → MySQL/PostgreSQL)。
//
// 该过程幂等:dst 以 (metric, entity, tags, ts) upsert 写入,中断后重启可安全重跑。
// 该过程幂等:dst 以 (series, resolution, labels, bucket) upsert 写入,中断后重启可安全重跑。
func migrateFromPreviousStore(prevFingerprint string, cfg *MetricStoreConfig, dst *metric.Store) error {
prevCfg, err := configFromFingerprint(prevFingerprint, cfg)
if err != nil {
@@ -78,7 +75,7 @@ func migrateFromPreviousStore(prevFingerprint string, cfg *MetricStoreConfig, ds
if err != nil {
return fmt.Errorf("store-to-store metrics migration failed: %w", err)
}
logger.Infof("metricstore", "Store-to-store metrics migration completed (%d points)", total)
logger.Infof("metricstore", "Store-to-store metrics migration completed (%d rollups)", total)
return nil
}
@@ -86,17 +83,17 @@ func migrateFromPreviousStore(prevFingerprint string, cfg *MetricStoreConfig, ds
// - currentMetric:当前正在搬运的指标名。
// - metricIndex:该指标在全部指标中的序号(0 起),即已完成的指标数。
// - totalMetrics:指标定义总数。
// - addedPoints:本次新写入目标库的采样点数(用于外部累计)。
// - addedPoints:本次新写入目标库的 rollup 行数(用于外部累计)。
type storeMigrationObserver func(currentMetric string, metricIndex, totalMetrics int, addedPoints int64)
// MigrateBetweenStores 把 src metric store 的全部指标定义与采样点搬运到 dst。
// MigrateBetweenStores 把 src metric store 的全部指标定义与持久化 rollup 搬运到 dst。
//
// 用于 metrics 后端切换(例如默认 SQLite metrics.db → MySQL/PostgreSQL):
// - 先在 dst 建立/更新全部指标定义;
// - 再按指标、按时间窗口分批读取源库采样点并写入 dst。
// - 再按指标流式读取源库 rollup 并分批写入 dst。
//
// 采样点在 dst 以 (metric_name, entity_id, tags, ts) upsert 写入,窗口边界重叠也
// 幂等,因此整个过程可安全重试。返回搬运的采样点总数。
// rollup 在 dst 以 (series, resolution, labels, bucket) upsert 写入,因此整个
// 过程可安全重试。返回搬运的 rollup 行数。
func MigrateBetweenStores(ctx context.Context, src, dst *metric.Store) (int64, error) {
return migrateBetweenStores(ctx, src, dst, nil)
}
@@ -127,84 +124,26 @@ func migrateBetweenStores(ctx context.Context, src, dst *metric.Store, observe s
if def.RetentionDays == 0 {
continue
}
earliest, latest, ok, err := metricTimeBounds(ctx, src, def.Name)
if err != nil {
return total, fmt.Errorf("resolve time bounds for metric %q: %w", def.Name, err)
}
if !ok {
// 该指标只有定义没有数据,跳过。
continue
}
var migrated int64
for windowStart := earliest; !windowStart.After(latest); windowStart = windowStart.Add(storeMigrationWindow) {
select {
case <-ctx.Done():
return total, ctx.Err()
default:
_, err := src.ExportRollups(ctx, def.Name, storeMigrationBatchSize, func(batch []metric.PersistedRollup) error {
if err := dst.ImportRollups(ctx, batch); err != nil {
return fmt.Errorf("write metric %q rollup batch to target: %w", def.Name, err)
}
windowEnd := windowStart.Add(storeMigrationWindow)
points, err := src.Query(ctx, metric.Query{
MetricName: def.Name,
Start: windowStart,
End: windowEnd,
Order: metric.OrderAsc,
})
if err != nil {
return total, fmt.Errorf("query metric %q window [%s,%s): %w", def.Name, windowStart, windowEnd, err)
}
if len(points) == 0 {
continue
}
if err := dst.WriteBatch(ctx, points); err != nil {
return total, fmt.Errorf("write metric %q batch to target: %w", def.Name, err)
}
migrated += int64(len(points))
total += int64(len(points))
count := int64(len(batch))
migrated += count
total += count
if observe != nil {
observe(def.Name, i, len(defs), int64(len(points)))
observe(def.Name, i, len(defs), count)
}
return nil
})
if err != nil {
return total, fmt.Errorf("export metric %q rollups: %w", def.Name, err)
}
if migrated > 0 {
logger.Infof("metricstore", "[store-migration] metric %q: migrated %d points", def.Name, migrated)
logger.Infof("metricstore", "[store-migration] metric %q: migrated %d rollups", def.Name, migrated)
}
}
return total, nil
}
// metricTimeBounds 返回某指标在 src 中最早/最晚采样时间。ok=false 表示无数据。
// 通过升/降序各取一条采样点定位边界,避免把整段序列读入内存。
func metricTimeBounds(ctx context.Context, src *metric.Store, name string) (time.Time, time.Time, bool, error) {
wide := metric.Query{
MetricName: name,
Start: time.Unix(0, 0),
End: time.Now().UTC().Add(24 * time.Hour),
}
asc := wide
asc.Order = metric.OrderAsc
asc.Limit = 1
first, err := src.Query(ctx, asc)
if err != nil {
return time.Time{}, time.Time{}, false, err
}
if len(first) == 0 {
return time.Time{}, time.Time{}, false, nil
}
desc := wide
desc.Order = metric.OrderDesc
desc.Limit = 1
last, err := src.Query(ctx, desc)
if err != nil {
return time.Time{}, time.Time{}, false, err
}
if len(last) == 0 {
return time.Time{}, time.Time{}, false, nil
}
return first[0].Timestamp, last[0].Timestamp, true, nil
}
@@ -0,0 +1,152 @@
package metricstore
import (
"context"
"path/filepath"
"reflect"
"testing"
"time"
"github.com/komari-monitor/komari/pkg/metric"
)
func TestMigrateBetweenStoresCopiesPersistedRollupsAfterReopen(t *testing.T) {
ctx := context.Background()
policy := defaultRollupPolicy()
sourcePath := filepath.Join(t.TempDir(), "source.db")
source, err := metric.Open(ctx, metric.SQLite(sourcePath,
metric.WithMaxOpenConns(1),
metric.WithMaxIdleConns(1),
metric.WithRollupPolicy(policy),
))
if err != nil {
t.Fatalf("open source: %v", err)
}
definitions := []metric.Definition{
{Name: "a.metric", Description: "migrated definition", Type: metric.TypeGauge, Unit: "%", RetentionDays: 30, Metadata: map[string]string{"owner": "test"}},
{Name: "z.empty", Type: metric.TypeCounter, Unit: "bytes", RetentionDays: 7, Metadata: map[string]string{}},
}
for _, definition := range definitions {
if err := source.UpsertMetric(ctx, definition); err != nil {
t.Fatalf("upsert source metric %q: %v", definition.Name, err)
}
}
base := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
digest := metric.NewTDigest(policy.Compression)
for _, value := range []float64{10, 20, 30} {
digest.Add(value, 1)
}
sourceRollups := []metric.PersistedRollup{
{
MetricName: "a.metric", EntityID: "agent-1", Tags: map[string]string{"device": "cpu0"}, Labels: map[string]string{"zone": "ap"},
Resolution: time.Minute, Bucket: base, Count: 3, Sum: 60, SumSq: 1400, Min: 10, Max: 30,
FirstValue: 10, FirstTime: base.Add(time.Second), LastValue: 30, LastTime: base.Add(40 * time.Second),
Digest: digest.Encode(), CreatedAt: base.Add(time.Hour),
},
{
MetricName: "a.metric", EntityID: "agent-2", Tags: map[string]string{}, Labels: map[string]string{"zone": "eu"},
Resolution: time.Hour, Bucket: base, Count: 12, Sum: 60, SumSq: 300, Min: 5, Max: 5,
FirstValue: 5, FirstTime: base.Add(time.Minute), LastValue: 5, LastTime: base.Add(50 * time.Minute),
CreatedAt: base.Add(time.Hour),
},
}
if err := source.ImportRollups(ctx, sourceRollups); err != nil {
t.Fatalf("seed source rollups: %v", err)
}
if err := source.Close(); err != nil {
t.Fatalf("close source: %v", err)
}
source, err = metric.Open(ctx, metric.SQLite(sourcePath,
metric.WithMaxOpenConns(1),
metric.WithMaxIdleConns(1),
metric.WithRollupPolicy(policy),
))
if err != nil {
t.Fatalf("reopen source: %v", err)
}
defer source.Close()
raw, err := source.Query(ctx, metric.Query{
MetricName: "a.metric", Start: base.Add(-time.Hour), End: base.Add(2 * time.Hour),
})
if err != nil {
t.Fatalf("query reopened raw window: %v", err)
}
if len(raw) != 0 {
t.Fatalf("reopened source raw points = %d, want 0", len(raw))
}
target, err := metric.Open(ctx, metric.SQLite(filepath.Join(t.TempDir(), "target.db"),
metric.WithMaxOpenConns(1),
metric.WithMaxIdleConns(1),
metric.WithRollupPolicy(policy),
))
if err != nil {
t.Fatalf("open target: %v", err)
}
defer target.Close()
type progressEvent struct {
metricName string
metricIndex, totalMetrics int
added int64
}
var events []progressEvent
total, err := migrateBetweenStores(ctx, source, target, func(name string, index, definitions int, added int64) {
events = append(events, progressEvent{name, index, definitions, added})
})
if err != nil {
t.Fatalf("migrate stores: %v", err)
}
if total != int64(len(sourceRollups)) {
t.Fatalf("migrated rollups = %d, want %d", total, len(sourceRollups))
}
wantEvents := []progressEvent{
{metricName: "a.metric", metricIndex: 0, totalMetrics: 2},
{metricName: "a.metric", metricIndex: 0, totalMetrics: 2, added: 2},
{metricName: "z.empty", metricIndex: 1, totalMetrics: 2},
}
if !reflect.DeepEqual(events, wantEvents) {
t.Fatalf("migration events = %#v, want %#v", events, wantEvents)
}
for _, want := range definitions {
got, err := target.GetMetric(ctx, want.Name)
if err != nil {
t.Fatalf("get target metric %q: %v", want.Name, err)
}
if got.Name != want.Name || got.Description != want.Description || got.Type != want.Type || got.Unit != want.Unit || got.RetentionDays != want.RetentionDays || !reflect.DeepEqual(got.Metadata, want.Metadata) {
t.Fatalf("target definition = %#v, want mutable fields from %#v", got, want)
}
}
exported := exportMigrationTestRollups(t, target, "a.metric")
if !reflect.DeepEqual(exported, sourceRollups) {
t.Fatalf("target rollups differ:\n got: %#v\nwant: %#v", exported, sourceRollups)
}
secondTotal, err := MigrateBetweenStores(ctx, source, target)
if err != nil {
t.Fatalf("repeat migration: %v", err)
}
if secondTotal != total {
t.Fatalf("repeat migrated rollups = %d, want %d", secondTotal, total)
}
if got := exportMigrationTestRollups(t, target, "a.metric"); !reflect.DeepEqual(got, sourceRollups) {
t.Fatalf("repeat migration changed target rollups: %#v", got)
}
}
func exportMigrationTestRollups(t *testing.T, store *metric.Store, metricName string) []metric.PersistedRollup {
t.Helper()
var result []metric.PersistedRollup
_, err := store.ExportRollups(context.Background(), metricName, 1, func(batch []metric.PersistedRollup) error {
result = append(result, batch...)
return nil
})
if err != nil {
t.Fatalf("export %q rollups: %v", metricName, err)
}
return result
}
+23 -21
View File
@@ -1,38 +1,40 @@
package metricstore
import "context"
import (
"context"
// storeOperationGate serializes operations that need a stable active Store.
// A channel-backed gate supports both non-blocking scheduled work and
// context-aware admin/shutdown waits.
"golang.org/x/sync/semaphore"
)
// storeOperationGate keeps an active Store stable while allowing regular
// reads, report writes, and compaction to proceed together. Store replacement
// and maintenance take the whole gate exclusively.
type storeOperationGate struct {
token chan struct{}
semaphore *semaphore.Weighted
}
const storeOperationGateWeight int64 = 1024
func newStoreOperationGate() *storeOperationGate {
gate := &storeOperationGate{token: make(chan struct{}, 1)}
gate.token <- struct{}{}
return gate
return &storeOperationGate{semaphore: semaphore.NewWeighted(storeOperationGateWeight)}
}
func (g *storeOperationGate) Acquire(ctx context.Context) error {
select {
case <-g.token:
return nil
case <-ctx.Done():
return ctx.Err()
}
return g.semaphore.Acquire(ctx, storeOperationGateWeight)
}
func (g *storeOperationGate) TryAcquire() bool {
select {
case <-g.token:
return true
default:
return false
}
return g.semaphore.TryAcquire(storeOperationGateWeight)
}
func (g *storeOperationGate) Release() {
g.token <- struct{}{}
g.semaphore.Release(storeOperationGateWeight)
}
func (g *storeOperationGate) AcquireShared(ctx context.Context) error {
return g.semaphore.Acquire(ctx, 1)
}
func (g *storeOperationGate) ReleaseShared() {
g.semaphore.Release(1)
}
+81
View File
@@ -2,10 +2,13 @@ package metricstore
import (
"context"
"database/sql"
"errors"
"path/filepath"
"testing"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/pkg/metric"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
@@ -81,3 +84,81 @@ func TestRecoverStoreRecordsManualMigrationSource(t *testing.T) {
t.Fatalf("migration target = %q, want %q", got, wantTarget)
}
}
func TestRecoverStorePersistsLegacyStoreForStructureGuide(t *testing.T) {
prepareRecoveryTest(t)
dsn := filepath.ToSlash(filepath.Join(t.TempDir(), "legacy.db"))
db, err := sql.Open("sqlite3", dsn)
if err != nil {
t.Fatalf("open legacy store: %v", err)
}
if _, err := db.Exec(`CREATE TABLE metric_definitions (
name TEXT PRIMARY KEY, type TEXT NOT NULL, unit TEXT NOT NULL,
description TEXT NOT NULL, retention_days INTEGER NOT NULL,
metadata TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
)`); err != nil {
_ = db.Close()
t.Fatalf("create legacy definition table: %v", err)
}
if err := db.Close(); err != nil {
t.Fatalf("close legacy store: %v", err)
}
cfg := &MetricStoreConfig{Driver: "sqlite", DSN: dsn}
if err := RecoverStore(context.Background(), cfg); err != nil {
t.Fatalf("recover legacy store: %v", err)
}
if GetStore() != nil {
t.Fatal("legacy recovery installed the store before structure upgrade")
}
if got, _ := config.GetAs[string](MetricDBDSNKey, ""); got != dsn {
t.Fatalf("saved legacy DSN = %q, want %q", got, dsn)
}
required, err := StructureUpgradeRequired(context.Background())
if err != nil {
t.Fatalf("detect structure upgrade after recovery: %v", err)
}
if !required {
t.Fatal("legacy recovery did not lead to the structure upgrade guide")
}
}
func TestReloadRejectsLegacyStoreForStructureUpgrade(t *testing.T) {
prepareRecoveryTest(t)
active, err := metric.Open(context.Background(), metric.SQLite(":memory:"))
if err != nil {
t.Fatalf("open active metric store: %v", err)
}
installTestStore(t, active)
dsn := filepath.ToSlash(filepath.Join(t.TempDir(), "legacy.db"))
db, err := sql.Open("sqlite3", dsn)
if err != nil {
t.Fatalf("open legacy store: %v", err)
}
if _, err := db.Exec(`CREATE TABLE metric_definitions (
name TEXT PRIMARY KEY, type TEXT NOT NULL, unit TEXT NOT NULL,
description TEXT NOT NULL, retention_days INTEGER NOT NULL,
metadata TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
)`); err != nil {
_ = db.Close()
t.Fatalf("create legacy definition table: %v", err)
}
if err := db.Close(); err != nil {
t.Fatalf("close legacy store: %v", err)
}
if err := config.SetMany(map[string]any{
MetricDBDriverKey: "sqlite",
MetricDBDSNKey: dsn,
}); err != nil {
t.Fatalf("save legacy metric store config: %v", err)
}
err = Reload(context.Background())
if !errors.Is(err, ErrStructureUpgradeRequired) {
t.Fatalf("reload error = %v, want %v", err, ErrStructureUpgradeRequired)
}
if GetStore() != active {
t.Fatal("legacy target replaced the active metric store during hot reload")
}
}
+16 -159
View File
@@ -5,11 +5,8 @@ import (
"errors"
"fmt"
logger "github.com/komari-monitor/komari/utils/log"
"runtime"
"sort"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/komari-monitor/komari/database/models"
@@ -35,19 +32,15 @@ type reportTrafficValues struct {
var reportTrafficStates sync.Map
const (
reportBatchInterval = 3 * time.Second
lowResourceReportWindow = 30 * time.Second
reportBatchQueueSize = 4096
lowResourceBatchMaxReports = 128
pingBatchMaxRecords = 512
reportBatchWriteTimeout = 10 * time.Second
reportBatchInterval = 3 * time.Second
reportBatchQueueSize = 4096
pingBatchMaxRecords = 512
reportBatchWriteTimeout = 10 * time.Second
)
var (
reportBatcherMu sync.Mutex
reportBatcher *reportBatchWorker
lowResourceMode atomic.Bool
droppedReports atomic.Uint64
)
var (
@@ -71,16 +64,8 @@ type reportBatchWorker struct {
stopping bool
}
func setLowResourceMode(enabled bool) {
lowResourceMode.Store(enabled)
}
func LowResourceModeEnabled() bool {
return lowResourceMode.Load()
}
// StartReportBatcher starts the shared report writer. Normal mode uses a short
// window; low-resource mode coalesces each node's 30-second window before write.
// StartReportBatcher starts the shared report writer. Exact samples are kept in
// the short raw window while the same writes update in-memory minute buckets.
func StartReportBatcher() {
reportBatcherMu.Lock()
defer reportBatcherMu.Unlock()
@@ -162,9 +147,9 @@ func FlushReportBatch(ctx context.Context) error {
}
}
// WriteReport persists one agent report as raw metric points sharing the same
// server receive time. Traffic deltas are derived from the previous counters
// and stored alongside the raw counters so they remain summable after rollup.
// WriteReport persists one agent report and adds it to in-memory minute
// summaries using the same server receive time. Traffic deltas remain summable
// after rollup.
func WriteReport(ctx context.Context, report v1.Report) (v1.Report, error) {
if report.UUID == "" {
return v1.Report{}, fmt.Errorf("report UUID is required")
@@ -182,10 +167,6 @@ func WriteReport(ctx context.Context, report v1.Report) (v1.Report, error) {
reportBatcherMu.Unlock()
if worker != nil {
if err := worker.enqueue(ctx, report); err != nil {
if LowResourceModeEnabled() && errors.Is(err, ErrReportBatchQueueFull) {
droppedReports.Add(1)
return report, nil
}
return v1.Report{}, err
}
return report, nil
@@ -221,14 +202,13 @@ func (w *reportBatchWorker) run() {
var pending []v1.Report
var pendingPings []models.PingRecord
lastFlush := time.Now()
for {
select {
case request := <-w.requests:
pending = append(pending, drainReportQueue(w.queue, reportBatchQueueSize)...)
pendingPings = append(pendingPings, drainPingQueue(w.pingQueue, reportBatchQueueSize)...)
err := errors.Join(
writePendingReports(request.ctx, &pending, LowResourceModeEnabled()),
writePendingReports(request.ctx, &pending),
writePendingPingRecords(request.ctx, &pendingPings),
)
if request.stop {
@@ -239,27 +219,15 @@ func (w *reportBatchWorker) run() {
request.done <- err
return
}
if err == nil {
lastFlush = time.Now()
}
request.done <- err
case <-ticker.C:
pendingPings = append(pendingPings, drainPingQueue(w.pingQueue, reportBatchQueueSize)...)
if err := writePendingPingRecords(context.Background(), &pendingPings); err != nil {
logger.Errorf("metricstore", "failed to flush ping batch: %v", err)
}
lowResource := LowResourceModeEnabled()
if lowResource && time.Since(lastFlush) < lowResourceReportWindow {
continue
}
pending = append(pending, drainReportQueue(w.queue, reportBatchQueueSize)...)
if err := writePendingReports(context.Background(), &pending, lowResource); err != nil {
if err := writePendingReports(context.Background(), &pending); err != nil {
logger.Errorf("metricstore", "failed to flush metric report batch: %v", err)
} else {
lastFlush = time.Now()
if dropped := droppedReports.Swap(0); dropped > 0 {
logger.Warnf("metricstore", "low resource metric batching dropped %d reports after the queue filled", dropped)
}
}
}
}
@@ -314,17 +282,11 @@ func drainPingQueue(queue <-chan models.PingRecord, limit int) []models.PingReco
return records
}
func writePendingReports(ctx context.Context, pending *[]v1.Report, lowResource bool) error {
func writePendingReports(ctx context.Context, pending *[]v1.Report) error {
if len(*pending) == 0 {
return nil
}
if lowResource {
*pending = coalesceReportsP95(*pending)
}
batchSize := len(*pending)
if lowResource && batchSize > lowResourceBatchMaxReports {
batchSize = lowResourceBatchMaxReports
}
for len(*pending) > 0 {
if batchSize > len(*pending) {
batchSize = len(*pending)
@@ -336,9 +298,6 @@ func writePendingReports(ctx context.Context, pending *[]v1.Report, lowResource
return err
}
*pending = (*pending)[batchSize:]
if lowResource {
runtime.Gosched()
}
}
return nil
}
@@ -360,116 +319,14 @@ func writePendingPingRecords(ctx context.Context, pending *[]models.PingRecord)
return nil
}
type reportNumber interface {
~int | ~int64 | ~float64
}
func percentile95[T reportNumber](values []T) T {
if len(values) == 0 {
var zero T
return zero
}
sort.Slice(values, func(i, j int) bool { return values[i] < values[j] })
index := (95*len(values) + 99) / 100
return values[index-1]
}
func reportP95[T reportNumber](reports []v1.Report, value func(v1.Report) T) T {
values := make([]T, len(reports))
for i, report := range reports {
values[i] = value(report)
}
return percentile95(values)
}
func coalesceReportsP95(reports []v1.Report) []v1.Report {
grouped := make(map[string][]v1.Report)
order := make([]string, 0)
for _, report := range reports {
if _, exists := grouped[report.UUID]; !exists {
order = append(order, report.UUID)
}
grouped[report.UUID] = append(grouped[report.UUID], report)
}
coalesced := make([]v1.Report, 0, len(order))
for _, uuid := range order {
coalesced = append(coalesced, coalesceNodeReportsP95(grouped[uuid]))
}
return coalesced
}
func coalesceNodeReportsP95(reports []v1.Report) v1.Report {
latestIndex := 0
for i := 1; i < len(reports); i++ {
if reports[i].UpdatedAt.After(reports[latestIndex].UpdatedAt) {
latestIndex = i
}
}
result := reports[latestIndex]
result.CPU.Usage = reportP95(reports, func(r v1.Report) float64 { return r.CPU.Usage })
result.Ram.Used = reportP95(reports, func(r v1.Report) int64 { return r.Ram.Used })
result.Swap.Used = reportP95(reports, func(r v1.Report) int64 { return r.Swap.Used })
result.Load.Load1 = reportP95(reports, func(r v1.Report) float64 { return r.Load.Load1 })
result.Disk.Used = reportP95(reports, func(r v1.Report) int64 { return r.Disk.Used })
result.Network.Up = reportP95(reports, func(r v1.Report) int64 { return r.Network.Up })
result.Network.Down = reportP95(reports, func(r v1.Report) int64 { return r.Network.Down })
result.Process = reportP95(reports, func(r v1.Report) int { return r.Process })
result.Connections.TCP = reportP95(reports, func(r v1.Report) int { return r.Connections.TCP })
result.Connections.UDP = reportP95(reports, func(r v1.Report) int { return r.Connections.UDP })
latestGPU := -1
for i := range reports {
if reports[i].GPU != nil && (latestGPU < 0 || reports[i].UpdatedAt.After(reports[latestGPU].UpdatedAt)) {
latestGPU = i
}
}
if latestGPU < 0 {
result.GPU = nil
return result
}
gpu := *reports[latestGPU].GPU
gpu.DetailedInfo = append([]v1.GPUDeviceInfo(nil), gpu.DetailedInfo...)
gpuReports := make([]v1.Report, 0, len(reports))
for _, report := range reports {
if report.GPU != nil {
gpuReports = append(gpuReports, report)
}
}
gpu.AverageUsage = reportP95(gpuReports, func(r v1.Report) float64 { return r.GPU.AverageUsage })
for deviceIndex := range gpu.DetailedInfo {
deviceReports := make([]v1.GPUDeviceInfo, 0, len(gpuReports))
for _, report := range gpuReports {
if deviceIndex < len(report.GPU.DetailedInfo) {
deviceReports = append(deviceReports, report.GPU.DetailedInfo[deviceIndex])
}
}
if len(deviceReports) == 0 {
continue
}
memoryUsed := make([]int64, len(deviceReports))
utilization := make([]float64, len(deviceReports))
temperature := make([]int, len(deviceReports))
for i, device := range deviceReports {
memoryUsed[i] = device.MemoryUsed
utilization[i] = device.Utilization
temperature[i] = device.Temperature
}
gpu.DetailedInfo[deviceIndex].MemoryUsed = percentile95(memoryUsed)
gpu.DetailedInfo[deviceIndex].Utilization = percentile95(utilization)
gpu.DetailedInfo[deviceIndex].Temperature = percentile95(temperature)
}
result.GPU = &gpu
return result
}
func writeReportBatch(ctx context.Context, reports []v1.Report) ([]v1.Report, error) {
if len(reports) == 0 {
return nil, nil
}
if err := storeOperations.Acquire(ctx); err != nil {
if err := storeOperations.AcquireShared(ctx); err != nil {
return nil, fmt.Errorf("wait for metric store operation before writing reports: %w", err)
}
defer storeOperations.Release()
defer storeOperations.ReleaseShared()
s := GetStore()
if s == nil {
@@ -511,7 +368,7 @@ func writeReportBatch(ctx context.Context, reports []v1.Report) ([]v1.Report, er
}
if !values.timestamp.IsZero() && !report.UpdatedAt.After(values.timestamp) {
report.UpdatedAt = values.timestamp.Add(time.Nanosecond)
report.UpdatedAt = values.timestamp.Add(time.Millisecond)
}
trafficUp := int64(0)
if values.hasUp {
@@ -592,7 +449,7 @@ func latestReportCounter(ctx context.Context, s *metric.Store, metricName, entit
}
// GetLatestTrafficBefore returns the latest retained upload/download counters
// before a boundary, transparently reading raw points or rollups.
// before a boundary, transparently reading raw points or rollup summaries.
func GetLatestTrafficBefore(ctx context.Context, entityIDs []string, before time.Time) (map[string]models.Record, error) {
s := GetStore()
if s == nil {
+36 -61
View File
@@ -2,6 +2,7 @@ package metricstore
import (
"context"
"errors"
"fmt"
"testing"
"time"
@@ -43,12 +44,12 @@ func useReportTestStore(t *testing.T, policy *metric.RollupPolicy) *metric.Store
return s
}
func TestWriteReportStoresRawMetricsAndResetAwareTraffic(t *testing.T) {
func TestWriteReportStoresMinuteMetricsAndResetAwareTraffic(t *testing.T) {
ctx := context.Background()
policy := defaultRollupPolicy()
s := useReportTestStore(t, &policy)
now := time.Now().UTC().Truncate(time.Minute)
base := now.Add(-30 * time.Minute)
base := time.Now().UTC().Truncate(time.Minute).Add(5 * time.Second)
now := base.Add(45 * time.Second)
report := v1.Report{
UUID: "node-a",
@@ -89,6 +90,8 @@ func TestWriteReportStoresRawMetricsAndResetAwareTraffic(t *testing.T) {
assertMetricValues(t, s, MetricTrafficUp, report.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, 50, 20})
assertMetricValues(t, s, MetricTrafficDown, report.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, 60, 30})
assertMetricValues(t, s, MetricNetTotalUp, report.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{100, 150, 20})
assertMetricAggregate(t, s, MetricTrafficUp, report.UUID, base.Add(-time.Second), base.Add(time.Minute), metric.AggSum, 70, 3)
assertMetricAggregate(t, s, MetricTrafficDown, report.UUID, base.Add(-time.Second), base.Add(time.Minute), metric.AggSum, 90, 3)
gpuPoints, err := s.Query(ctx, metric.Query{
MetricName: MetricGPUDeviceUsage,
@@ -101,7 +104,7 @@ func TestWriteReportStoresRawMetricsAndResetAwareTraffic(t *testing.T) {
if err != nil {
t.Fatalf("query GPU points: %v", err)
}
if len(gpuPoints) != 3 || gpuPoints[0].Timestamp != base || gpuPoints[0].Tags["device_name"] != "GPU 0" {
if len(gpuPoints) != 3 || !gpuPoints[2].Timestamp.Equal(base.Add(6*time.Second)) || gpuPoints[2].Tags["device_name"] != "GPU 0" {
t.Fatalf("unexpected GPU points: %#v", gpuPoints)
}
@@ -150,7 +153,7 @@ func TestReportBatcherFlushesQueuedReports(t *testing.T) {
}
})
base := time.Now().UTC().Truncate(time.Second)
base := time.Now().UTC().Truncate(time.Minute).Add(10 * time.Second)
first := v1.Report{
UUID: "batched-node",
UpdatedAt: base,
@@ -189,6 +192,7 @@ func TestReportBatcherFlushesQueuedReports(t *testing.T) {
assertMetricValues(t, s, MetricCPU, first.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{10, 20})
assertMetricValues(t, s, MetricTrafficUp, first.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, 50})
assertMetricValues(t, s, MetricTrafficDown, first.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, 60})
assertMetricAggregate(t, s, MetricCPU, first.UUID, base.Add(-time.Second), base.Add(time.Minute), metric.AggAvg, 15, 2)
}
func TestPingBatcherFlushesLatencyAndLossTogether(t *testing.T) {
@@ -263,62 +267,28 @@ func TestPingBatcherFlushesLatencyAndLossTogether(t *testing.T) {
}
}
func TestCoalesceReportsP95KeepsLatestCounters(t *testing.T) {
base := time.Now().UTC().Truncate(time.Second)
reports := make([]v1.Report, 20)
for i := range reports {
value := i + 1
reports[i] = v1.Report{
UUID: "p95-node",
UpdatedAt: base.Add(time.Duration(i) * time.Second),
CPU: v1.CPUReport{Usage: float64(value)},
Ram: v1.RamReport{Used: int64(value)},
Network: v1.NetworkReport{
Up: int64(value),
TotalUp: int64(value * 100),
TotalDown: int64(value * 200),
},
Process: value,
}
}
got := coalesceReportsP95(reports)
if len(got) != 1 {
t.Fatalf("coalesced report count = %d, want 1", len(got))
}
if got[0].CPU.Usage != 19 || got[0].Ram.Used != 19 || got[0].Network.Up != 19 || got[0].Process != 19 {
t.Fatalf("P95 fields = %#v, want nearest-rank P95 of 19", got[0])
}
if got[0].Network.TotalUp != 2000 || got[0].Network.TotalDown != 4000 {
t.Fatalf("cumulative counters = %#v, want latest report counters", got[0].Network)
}
if !got[0].UpdatedAt.Equal(base.Add(19 * time.Second)) {
t.Fatalf("timestamp = %s, want latest timestamp", got[0].UpdatedAt)
}
}
func TestLowResourceBatchWritesOneReportPerNode(t *testing.T) {
func TestReportBatchKeepsEverySample(t *testing.T) {
ctx := context.Background()
s := useReportTestStore(t, nil)
base := time.Now().UTC().Truncate(time.Second)
pending := []v1.Report{
{UUID: "node-a", UpdatedAt: base, CPU: v1.CPUReport{Usage: 10}, Network: v1.NetworkReport{TotalUp: 100}},
{UUID: "node-a", UpdatedAt: base.Add(time.Second), CPU: v1.CPUReport{Usage: 20}, Network: v1.NetworkReport{TotalUp: 150}},
{UUID: "node-a", UpdatedAt: base, CPU: v1.CPUReport{Usage: 20}, Network: v1.NetworkReport{TotalUp: 150}},
{UUID: "node-b", UpdatedAt: base, CPU: v1.CPUReport{Usage: 30}, Network: v1.NetworkReport{TotalUp: 200}},
{UUID: "node-b", UpdatedAt: base.Add(time.Second), CPU: v1.CPUReport{Usage: 40}, Network: v1.NetworkReport{TotalUp: 260}},
}
if err := writePendingReports(ctx, &pending, true); err != nil {
t.Fatalf("write low resource batch: %v", err)
if err := writePendingReports(ctx, &pending); err != nil {
t.Fatalf("write report batch: %v", err)
}
if len(pending) != 0 {
t.Fatalf("pending reports = %d, want 0", len(pending))
}
assertMetricValues(t, s, MetricCPU, "node-a", base.Add(-time.Second), base.Add(time.Minute), []float64{20})
assertMetricValues(t, s, MetricCPU, "node-b", base.Add(-time.Second), base.Add(time.Minute), []float64{40})
assertMetricValues(t, s, MetricCPU, "node-a", base.Add(-time.Second), base.Add(time.Minute), []float64{10, 20})
assertMetricValues(t, s, MetricCPU, "node-b", base.Add(-time.Second), base.Add(time.Minute), []float64{30, 40})
}
func TestLowResourceQueueFullDoesNotBlockRealtimeReport(t *testing.T) {
func TestReportQueueFullReturnsError(t *testing.T) {
ctx := context.Background()
useReportTestStore(t, nil)
worker := &reportBatchWorker{
@@ -330,30 +300,21 @@ func TestLowResourceQueueFullDoesNotBlockRealtimeReport(t *testing.T) {
reportBatcherMu.Lock()
reportBatcher = worker
reportBatcherMu.Unlock()
setLowResourceMode(true)
t.Cleanup(func() {
reportBatcherMu.Lock()
if reportBatcher == worker {
reportBatcher = nil
}
reportBatcherMu.Unlock()
setLowResourceMode(false)
droppedReports.Store(0)
})
report := v1.Report{
UUID: "realtime-node",
UpdatedAt: time.Now().UTC(),
}
saved, err := WriteReport(ctx, report)
if err != nil {
t.Fatalf("low resource queue full should not fail realtime ingest: %v", err)
}
if saved.UUID != report.UUID {
t.Fatalf("saved report UUID = %q, want %q", saved.UUID, report.UUID)
}
if droppedReports.Load() != 1 {
t.Fatalf("dropped reports = %d, want 1", droppedReports.Load())
_, err := WriteReport(ctx, report)
if !errors.Is(err, ErrReportBatchQueueFull) {
t.Fatalf("queue-full error = %v, want %v", err, ErrReportBatchQueueFull)
}
}
@@ -413,7 +374,7 @@ func TestWriteReportNormalizesReceiveTimeToUTC(t *testing.T) {
ctx := context.Background()
s := useReportTestStore(t, nil)
local := time.FixedZone("UTC+8", 8*60*60)
receiveTime := time.Date(2026, 7, 17, 9, 30, 0, 123456789, local)
receiveTime := time.Now().In(local).Add(-10 * time.Second)
report := v1.Report{
UUID: "utc-report",
UpdatedAt: receiveTime,
@@ -437,8 +398,8 @@ func TestWriteReportNormalizesReceiveTimeToUTC(t *testing.T) {
if err != nil {
t.Fatalf("query stored point: %v", err)
}
if len(points) != 1 || points[0].Timestamp.Location() != time.UTC || points[0].Timestamp.Nanosecond() != 123456789 {
t.Fatalf("stored points = %#v, want one UTC nanosecond point", points)
if len(points) != 1 || points[0].Timestamp.Location() != time.UTC || points[0].Timestamp.UnixMilli() != receiveTime.UnixMilli() {
t.Fatalf("stored points = %#v, want one UTC millisecond point", points)
}
}
@@ -463,3 +424,17 @@ func assertMetricValues(t *testing.T, s *metric.Store, metricName, entityID stri
}
}
}
func assertMetricAggregate(t *testing.T, s *metric.Store, metricName, entityID string, start, end time.Time, aggregation metric.Aggregation, want float64, wantCount int) {
t.Helper()
points, err := s.Series(context.Background(), metric.AggregateQuery{
Query: metric.Query{MetricName: metricName, EntityID: entityID, Start: start, End: end},
Aggregation: aggregation, Interval: time.Minute, PreserveSeries: true,
}, end)
if err != nil {
t.Fatalf("aggregate %s: %v", metricName, err)
}
if len(points) != 1 || points[0].Value != want || points[0].Count != wantCount {
t.Fatalf("aggregate %s = %#v, want value=%v count=%d", metricName, points, want, wantCount)
}
}
+137
View File
@@ -0,0 +1,137 @@
package metricstore
import (
"context"
"fmt"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/pkg/metric"
)
// RestructureProgress is the authenticated guide's stable progress payload.
type RestructureProgress struct {
Phase string
CurrentMetric string
RowsDone int64
RowsTotal int64
MetricsDone int
MetricsTotal int
}
type RestructureResult struct {
BeforeBytes int64
AfterBytes int64
RowsCopied int64
Metrics int
}
// StructureUpgradeRequired checks the configured metric store without creating
// tables. It is called before normal metric-store initialization so an existing
// installation is never rebuilt at startup without an administrator action.
func StructureUpgradeRequired(ctx context.Context) (bool, error) {
cfg, err := config.GetManyAs[MetricStoreConfig]()
if err != nil {
return false, err
}
return structureUpgradeRequiredForConfig(ctx, cfg)
}
func structureUpgradeRequiredForConfig(ctx context.Context, cfg *MetricStoreConfig) (bool, error) {
metricCfg, err := buildMetricConfig(cfg, false)
if err != nil {
return false, err
}
store, err := metric.Open(ctx, metricCfg)
if err != nil {
return false, err
}
defer store.Close()
return store.NeedsRestructure(ctx)
}
// RestructureConfiguredStore performs the explicit data copy, table swap and
// one physical reclaim pass used by the upgrade guide.
func RestructureConfiguredStore(ctx context.Context, report func(RestructureProgress)) (RestructureResult, error) {
cfg, err := config.GetManyAs[MetricStoreConfig]()
if err != nil {
return RestructureResult{}, err
}
metricCfg, err := buildMetricConfig(cfg, false)
if err != nil {
return RestructureResult{}, err
}
store, err := metric.Open(ctx, metricCfg)
if err != nil {
return RestructureResult{}, err
}
defer store.Close()
before, err := store.LegacyStorageSize(ctx)
if err != nil {
return RestructureResult{}, fmt.Errorf("measure legacy metric storage: %w", err)
}
result, err := store.Restructure(ctx, func(progress metric.RestructureProgress) {
if report == nil {
return
}
report(RestructureProgress{Phase: progress.Phase, CurrentMetric: progress.Current, RowsDone: progress.RowsDone, RowsTotal: progress.RowsTotal, MetricsDone: progress.MetricsDone, MetricsTotal: progress.MetricsTotal})
})
if err != nil {
return RestructureResult{}, err
}
if report != nil {
report(RestructureProgress{Phase: "reclaiming", RowsDone: result.RowsCopied, RowsTotal: result.RowsCopied, MetricsDone: result.Metrics, MetricsTotal: result.Metrics})
}
if err := store.ReclaimSpace(ctx); err != nil {
return RestructureResult{}, fmt.Errorf("reclaim metric storage: %w", err)
}
after, err := store.StorageSize(ctx)
if err != nil {
return RestructureResult{}, fmt.Errorf("measure rebuilt metric storage: %w", err)
}
return RestructureResult{BeforeBytes: before, AfterBytes: after, RowsCopied: result.RowsCopied, Metrics: result.Metrics}, nil
}
// DiscardConfiguredStoreHistory completes the same schema upgrade as
// RestructureConfiguredStore but drops every historical sample instead of
// copying it. Metric definitions remain available for new ingest.
func DiscardConfiguredStoreHistory(ctx context.Context, report func(RestructureProgress)) (RestructureResult, error) {
cfg, err := config.GetManyAs[MetricStoreConfig]()
if err != nil {
return RestructureResult{}, err
}
metricCfg, err := buildMetricConfig(cfg, false)
if err != nil {
return RestructureResult{}, err
}
store, err := metric.Open(ctx, metricCfg)
if err != nil {
return RestructureResult{}, err
}
defer store.Close()
before, err := store.LegacyStorageSize(ctx)
if err != nil {
return RestructureResult{}, fmt.Errorf("measure legacy metric storage: %w", err)
}
result, err := store.DiscardHistory(ctx, func(progress metric.RestructureProgress) {
if report == nil {
return
}
report(RestructureProgress{Phase: progress.Phase, CurrentMetric: progress.Current, RowsDone: progress.RowsDone, RowsTotal: progress.RowsTotal, MetricsDone: progress.MetricsDone, MetricsTotal: progress.MetricsTotal})
})
if err != nil {
return RestructureResult{}, err
}
if report != nil {
report(RestructureProgress{Phase: "reclaiming", RowsDone: result.RowsCopied, RowsTotal: result.RowsCopied, MetricsDone: result.Metrics, MetricsTotal: result.Metrics})
}
if err := store.ReclaimSpace(ctx); err != nil {
return RestructureResult{}, fmt.Errorf("reclaim metric storage: %w", err)
}
after, err := store.StorageSize(ctx)
if err != nil {
return RestructureResult{}, fmt.Errorf("measure rebuilt metric storage: %w", err)
}
return RestructureResult{BeforeBytes: before, AfterBytes: after, RowsCopied: result.RowsCopied, Metrics: result.Metrics}, nil
}
+6
View File
@@ -267,6 +267,12 @@ func stopStoreMigrationForClose(ctx context.Context) error {
}
}
// StopStoreMigrationForShutdown prevents a new store migration and waits for
// an active one to release its exclusive lease before report batches flush.
func StopStoreMigrationForShutdown(ctx context.Context) error {
return stopStoreMigrationForClose(ctx)
}
func clearStoreClosing() {
storeMigMu.Lock()
storeClosing = false
-141
View File
@@ -1,141 +0,0 @@
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
Komariは、サーバーのパフォーマンスを監視するためのシンプルで効率的なソリューションを提供することを目的とした、軽量の自己ホスト型サーバー監視ツールです。Webインターフェースを介してサーバーのステータスを表示し、軽量エージェントを介してデータを収集します。
> [!WARNING]
> Komariは、自己ホスト型の監視/制御プログラムであり、所有しているシステム、または管理権限を得ているシステムでのみ使用してください。Komariを武器化したり、許可なく展開、アクセス、永続化、コマンド実行、その他の不正利用に使用したりしないでください。実際の悪用リスクについては、Huntressの分析をご参照ください:[Komari C2 agent abuse](https://www.huntress.com/blog/komari-c2-agent-abuse)。
> Komariの展開および運用方法については、利用者自身が責任を負います。開発者は、無許可または不正な利用、およびその結果について責任を負いません。
> Windows端末でリモートコントロールを有効にした場合、クライアントはユーザーがログインするたびに、KomariがリモートコントロールソフトウェアであることをWindows通知で知らせます。
[ドキュメント](https://komari-document.pages.dev/) | [Telegramグループ](https://t.me/komari_monitor)
## 特徴
- **軽量で効率的**: リソース消費が少なく、あらゆる規模のサーバーに適しています。
- **自己ホスト型**: データプライバシーを完全に制御でき、展開も簡単です。
- **Webインターフェース**: 直感的な監視ダッシュボードで、使いやすいです。
## クイックスタート
### 0. クラウドホスティングによるワンクリック展開
- Rainyun - CNY 4.5/月
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- 1Panel アプリストア
1Panel アプリストアで利用可能です。「アプリストア」>「ユーティリティ」>「Komari」からインストールしてください。
### 1. ワンクリックインストールスクリプトを使用する
systemdを使用するディストリビューション(Ubuntu、Debianなど)に適しています。
```bash
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
chmod +x install-komari.sh
sudo ./install-komari.sh
```
### 2. Docker展開
1. データディレクトリを作成します:
```bash
mkdir -p ./data
```
2. Dockerコンテナを実行します:
```bash
docker run -d \
-p 25774:25774 \
-v $(pwd)/data:/app/data \
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. ブラウザで `http://<your_server_ip>:25774` にアクセスし、インストールガイドを完了します。ガイドで管理者アカウント、サイト情報、監視データベースを設定します。
### 3. バイナリファイル展開
1. Komariの[GitHubリリース](https://github.com/komari-monitor/komari/releases)ページにアクセスして、お使いのオペレーティングシステム用の最新のバイナリをダウンロードします。
2. Komariを実行します:
```bash
./komari server -l 0.0.0.0:25774
```
3. ブラウザで `http://<your_server_ip>:25774` にアクセスします。デフォルトのポートは `25774` です。
4. インストールガイドに従って管理者アカウント、サイト情報、監視データベースを設定します。
> [!NOTE]
> バイナリに実行権限があることを確認してください(`chmod +x komari`)。データは実行ディレクトリの `data` フォルダに保存されます。
### 手動ビルド
#### 依存関係
- Go 1.18+ および Node.js 20+(手動ビルド用)
1. フロントエンドの静的ファイルをビルドします:
```bash
git clone https://github.com/komari-monitor/komari-web
cd komari-web
npm install
npm run build
```
2. バックエンドをビルドします:
```bash
git clone https://github.com/komari-monitor/komari
cd komari
```
ステップ1で生成された静的ファイルを `komari` プロジェクトのルートにある `/web/public/defaultTheme/dist` フォルダにコピーし、`komari-theme.json` と `preview.png`/`perview.png` を `/web/public/defaultTheme` にコピーします。
```bash
go build -o komari
```
3. 実行:
```bash
./komari server -l 0.0.0.0:25774
```
デフォルトのリスニングポートは `25774` です。`http://localhost:25774` にアクセスします。
## フロントエンド開発ガイド
[Komariテーマ開発ガイド | Komari](https://komari-document.pages.dev/dev/theme.html)
[CrowdinでKomariを翻訳する](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
## クライアントエージェント開発ガイド
[Komariエージェント情報レポートおよびイベント処理ドキュメント](https://komari-document.pages.dev/dev/agent.html)
## 貢献
IssueやPull Requestを歓迎します!
## 謝辞
### 破碎工坊云 (Crash Work)
[破碎工坊云 - 効率的で安定した、安全な高防御サーバーとCDNソリューションを提供する専門的なクラウドコンピューティングプラットフォーム](https://www.crash.work/)
### DreamCloud
[DreamCloud - コストパフォーマンスに優れたアジア太平洋向け高防御直通](https://as211392.com/)
### 🚀 SharonNetworks スポンサー
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks は、あなたのビジネスの離陸を力強くサポートします!
アジア太平洋のデータセンターから中国最適化ネットワーク接続を提供。低レイテンシー & 高帯域幅、Tbps 級のローカル洗浄 DDoS 防御で、ビジネスとお客様の体験を守ります。コミュニティ [Telegram グループ](https://t.me/SharonNetwork) に参加すると、チャリティまたはグループ内抽選で無料利用のチャンスがあります。
### オープンソースコミュニティ
PR を送ってくれた方、テーマを作成してくれた全ての開発者
—— そして:こんなに暇でいられる自分に感謝
## Star履歴
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
-141
View File
@@ -1,141 +0,0 @@
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
Komari 是一款輕量級的自託管伺服器監控工具,旨在提供簡單、高效的伺服器性能監控解決方案。它支援透過 Web 介面查看伺服器狀態,並透過輕量級 Agent 收集數據。
> [!WARNING]
> Komari 是一款自託管的監控/控制程式,僅應部署在你擁有或已獲授權管理的系統上。請勿將 Komari 武器化,或在未獲授權的情況下部署、存取、持久化、執行命令及從事其他濫用行為。關於現實中的濫用風險,可參考 Huntress 的分析:[Komari C2 agent abuse](https://www.huntress.com/blog/komari-c2-agent-abuse)。
> 使用者需要自行承擔部署和使用 Komari 的責任。開發者不對未經授權或濫用行為及其後果承擔責任。
> 在 Windows 端啟用遠端控制後,客戶端會在每次使用者登入時透過 Windows 通知提醒使用者 Komari 是一款遠端控制軟體。
[文檔](https://komari-document.pages.dev/) | [Telegram 群組](https://t.me/komari_monitor)
## 特性
- **輕量高效**:低資源佔用,適合各種規模的伺服器。
- **自託管**:完全掌控數據隱私,部署簡單。
- **Web 介面**:直觀的監控儀表盤,易於使用。
## 快速開始
### 0. 容器雲一鍵部署
- 雨雲雲應用 - CNY 4.5/月
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- 1Panel 應用商店
已上架 1Panel 應用商店,應用商店-實用工具-Komari 即可安裝
### 1. 使用一鍵安裝腳本
適用於使用了 systemd 的發行版(Ubuntu、Debian...)。
```bash
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
chmod +x install-komari.sh
sudo ./install-komari.sh
```
### 2. Docker 部署
1. 建立資料目錄:
```bash
mkdir -p ./data
```
2. 執行 Docker 容器:
```bash
docker run -d \
-p 25774:25774 \
-v $(pwd)/data:/app/data \
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. 在瀏覽器中存取 `http://<your_server_ip>:25774` 並完成安裝精靈。精靈會建立管理員帳號,並設定站點中繼資料和監控資料庫。
### 3. 二進位檔案部署
1. 存取 Komari 的 [GitHub Release 頁面](https://github.com/komari-monitor/komari/releases) 下載適用於你作業系統的最新二進位檔案。
2. 執行 Komari:
```bash
./komari server -l 0.0.0.0:25774
```
3. 在瀏覽器中存取 `http://<your_server_ip>:25774`,預設監聽 `25774` 連接埠。
4. 按照安裝精靈建立管理員帳號,並設定站點中繼資料和監控資料庫。
> [!NOTE]
> 確保二進位檔案具有可執行權限(`chmod +x komari`)。資料將保存在執行目錄下的 `data` 資料夾中。
### 手工建置
#### 依賴
- Go 1.18+ 和 Node.js 20+(手工建置)
1. 建置前端靜態檔案:
```bash
git clone https://github.com/komari-monitor/komari-web
cd komari-web
npm install
npm run build
```
2. 建置後端:
```bash
git clone https://github.com/komari-monitor/komari
cd komari
```
將步驟1中產生的靜態檔案複製到 `komari` 專案根目錄下的 `/web/public/defaultTheme/dist` 資料夾,並將 `komari-theme.json` 與 `preview.png`/`perview.png` 複製到 `/web/public/defaultTheme`。
```bash
go build -o komari
```
3. 執行:
```bash
./komari server -l 0.0.0.0:25774
```
預設監聽 `25774` 連接埠,存取 `http://localhost:25774`。
## 前端開發指南
[Komari 主題開發指南 | Komari](https://komari-document.pages.dev/dev/theme.html)
[在 Crowdin 上翻譯 Komari](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
## 客戶端 Agent 開發指南
[Komari Agent 資訊上報與事件處理文檔](https://komari-document.pages.dev/dev/agent.html)
## 貢獻
歡迎提交 Issue 或 Pull Request!
## 鳴謝
### 破碎工坊雲
[破碎工坊雲 - 專業雲計算服務平台,提供高效、穩定、安全的高防伺服器與CDN解決方案](https://www.crash.work/)
### DreamCloud
[DreamCloud - 極高性價比解鎖直連亞太高防](https://as211392.com/)
### 🚀 由 SharonNetworks 贊助
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 為您的業務起飛保駕護航!
亞太資料中心提供頂級的中國優化網路接入 · 低延遲 & 高頻寬 & 提供 Tbps 級本地清洗高防服務,為您的業務保駕護航,為您的客戶提供極致體驗。加入社群 [Telegram 群組](https://t.me/SharonNetwork) 可參與公益募捐或群內抽獎免費使用。
### 開源社群
提交 PR、製作主題的各位開發者
—— 以及:感謝我自己能這麼閒
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
-141
View File
@@ -1,141 +0,0 @@
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
> [!WARNING]
> Komari 是一款自托管的监控/控制程序,仅应部署在你拥有或已获得授权管理的系统上。请勿将 Komari 武器化,或在未获授权的情况下部署、访问、持久化、执行命令及从事其他滥用行为。关于现实中的滥用风险,可参考 Huntress 的分析:[Komari C2 agent abuse](https://www.huntress.com/blog/komari-c2-agent-abuse)。
> 用户需要自行承担部署和使用 Komari 的责任。开发者不对未经授权或滥用行为及其后果承担责任。
> 在 Windows 端开启远程控制后,客户端会在每次用户登录时通过 Windows 通知提醒用户 Komari 是一款远程控制软件。
[文档](https://komari-document.pages.dev/) | [文档(镜像站 By Geekertao)](https://www.komari.wiki) | [Telegram 群组](https://t.me/komari_monitor)
## 特性
- **轻量高效**:低资源占用,适合各种规模的服务器。
- **自托管**:完全掌控数据隐私,部署简单。
- **Web 界面**:直观的监控仪表盘,易于使用。
## 快速开始
### 0. 容器云一键部署
- 雨云云应用 - CNY 4.5/月
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- 1Panel 应用商店
已上架1Panel应用商店,应用商店-实用工具-Komari 即可安装
### 1. 使用一键安装脚本
适用于使用了 systemd 的发行版(Ubuntu、Debian...)。
```bash
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
chmod +x install-komari.sh
sudo ./install-komari.sh
```
### 2. Docker 部署
1. 创建数据目录:
```bash
mkdir -p ./data
```
2. 运行 Docker 容器:
```bash
docker run -d \
-p 25774:25774 \
-v $(pwd)/data:/app/data \
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. 在浏览器中访问 `http://<your_server_ip>:25774` 并完成安装向导。向导会创建管理员账号,并设置站点元信息和监控数据库。
### 3. 二进制文件部署
1. 访问 Komari 的 [GitHub Release 页面](https://github.com/komari-monitor/komari/releases) 下载适用于你操作系统的最新二进制文件。
2. 运行 Komari:
```bash
./komari server -l 0.0.0.0:25774
```
3. 在浏览器中访问 `http://<your_server_ip>:25774`,默认监听 `25774` 端口。
4. 按照安装向导创建管理员账号,并设置站点元信息和监控数据库。
> [!NOTE]
> 确保二进制文件具有可执行权限(`chmod +x komari`)。数据将保存在运行目录下的 `data` 文件夹中。
### 手工构建
#### 依赖
- Go 1.18+ 和 Node.js 20+(手工构建)
1. 构建前端静态文件:
```bash
git clone https://github.com/komari-monitor/komari-web
cd komari-web
npm install
npm run build
```
2. 构建后端:
```bash
git clone https://github.com/komari-monitor/komari
cd komari
```
将步骤1中生成的静态文件复制到 `komari` 项目根目录下的 `/web/public/defaultTheme/dist` 文件夹,并将 `komari-theme.json` 与 `preview.png`/`perview.png` 复制到 `/web/public/defaultTheme`。
```bash
go build -o komari
```
3. 运行:
```bash
./komari server -l 0.0.0.0:25774
```
默认监听 `25774` 端口,访问 `http://localhost:25774`。
## 前端开发指南
[Komari 主题开发指南 | Komari](https://komari-document.pages.dev/dev/theme.html)
[在 Crowdin 上翻译 Komari](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
## 客户端 Agent 开发指南
[Komari Agent 信息上报与事件处理文档](https://komari-document.pages.dev/dev/agent.html)
## 贡献
欢迎提交 Issue 或 Pull Request!
## 鸣谢
### 破碎工坊云
[破碎工坊云 - 专业云计算服务平台,提供高效、稳定、安全的高防服务器与CDN解决方案](https://www.crash.work/)
### DreamCloud
[DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
### 🚀 由 SharonNetworks 赞助
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 为您的业务起飞保驾护航!
亚太数据中心提供顶级的中国优化网络接入 · 低延时 & 高带宽 & 提供 Tbps 级本地清洗高防服务,为您的业务保驾护航,为您的客户提供极致体验。加入社区 [Telegram 群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用。
### 开源社区
提交 PR、制作主题的各位开发者
—— 以及:感谢我自己能这么闲
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
-327
View File
@@ -1,327 +0,0 @@
# Visitor Audit RPC 使用说明
本文档说明 `public:recordVisitorEvent` RPC2 接口的调用方式。该接口用于让主题前端把访客访问和前端操作记录写入 Komari 后端审计日志。
## 接口概览
- RPC 方法:`public:recordVisitorEvent`
- RPC 路径:`/api/rpc2`
- HTTP 方法:`POST`
- 写入位置:现有审计日志表 `models.Log`
- 管理端读取:复用现有 `admin:getLogs`
- 日志类型:`msg_type = "visitor"`
- 默认状态:关闭,需设置 `visitor_audit_enabled = true`
该接口不会信任前端传入的 IP。来源 IP 和 User-Agent 由后端从请求上下文记录。
## 权限
`public:recordVisitorEvent` 属于 `public` 命名空间,访客可调用。
私有站点模式下,该方法也在登录页白名单内,便于记录登录前/前台访问事件。
这是有意保留的行为;总开关关闭时,白名单中的方法仍可调用,但不会写库。
## 启用
存量实例和新实例默认都不会开放匿名日志写入。管理员可通过
`admin:editSettings` 将 `visitor_audit_enabled` 设为 `true`。公开设置
`public:getPublicSettings` 也会返回这个字段,主题可据此决定是否上报。
未启用时接口返回 `status = "disabled"`。启用后,每个来源 IP 使用内存令牌桶限流:
平均每分钟 30 次,允许短时突发 10 次;超限时返回 `status = "rate_limited"`,不写库。
## 请求参数
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `event` | `string` | 是 | 事件名,例如 `page_view`、`node_open`、`search` |
| `action` | `string` | 否 | `event` 的别名 |
| `operation` | `string` | 否 | `event` 的别名 |
| `path` | `string` | 否 | 前端路径,例如 `/`、`/instance/<uuid>` |
| `route` | `string` | 否 | 前端路由名,例如 `home`、`instance-detail` |
| `target` | `string` | 否 | 操作目标,例如节点 UUID、工具 key、按钮 key |
| `detail` | `object` | 否 | 额外元数据,大小受限 |
`event`、`action`、`operation` 三者任选一个即可,优先级为:
```text
event > action > operation
```
## 后端自动记录的信息
| 字段 | 来源 |
| --- | --- |
| IP | `rpc.ContextMeta.RemoteIP`,来自服务端 `c.ClientIP()` |
| User-Agent | `rpc.ContextMeta.UserAgent`,来自请求头 |
| 用户 UUID | 登录状态下使用 `rpc.ContextMeta.UserUUID`;访客为空 |
| 时间 | 后端写入审计日志时生成 |
## 字段限制
字符串字段按 Unicode 字符计数,`detail` 和最终 message 按序列化后的字节数计数。
| 字段 | 最大长度 |
| --- | ---: |
| `event` | 64 |
| `path` | 512 |
| `route` | 128 |
| `target` | 128 |
| User-Agent | 512 |
| `detail` JSON | 2048 |
| 最终日志 message | 4096 |
如果 `detail` 超过限制,不会完整保存,而会写入截断标记:
```json
{
"truncated": true,
"size": 4096
}
```
## 事件名规范化
服务端会规范化事件名:
- 转小写
- 空格转 `_`
- 仅保留字母、数字、`_`、`-`、`:`、`.`
- 最大长度 64
示例:
```text
Page View -> page_view
node:open.detail -> node:open.detail
```
## 请求示例:页面访问
```bash
curl -X POST http://localhost:25774/api/rpc2 \
-H 'Content-Type: application/json' \
--data '{
"jsonrpc": "2.0",
"method": "public:recordVisitorEvent",
"params": {
"event": "page_view",
"path": "/",
"route": "home",
"detail": {
"theme": "glassmorphism"
}
},
"id": 1
}'
```
写入成功返回:
```json
{
"jsonrpc": "2.0",
"result": {
"status": "success"
},
"id": 1
}
```
`disabled` 和 `rate_limited` 也属于正常响应,不会返回 JSON-RPC 错误;主题上报不应影响主流程。
## 请求示例:打开节点详情
```json
{
"jsonrpc": "2.0",
"method": "public:recordVisitorEvent",
"params": {
"event": "node_open",
"path": "/instance/6f0b-example",
"route": "instance-detail",
"target": "6f0b-example",
"detail": {
"source": "node_card"
}
},
"id": 2
}
```
## 请求示例:搜索
```json
{
"jsonrpc": "2.0",
"method": "public:recordVisitorEvent",
"params": {
"event": "search",
"path": "/",
"route": "home",
"detail": {
"keyword_length": 6,
"result_count": 3
}
},
"id": 3
}
```
建议不要记录完整搜索关键词,避免把敏感内容写入审计日志。
## 前端封装示例
```ts
interface VisitorAuditEvent {
event?: string
action?: string
operation?: string
path?: string
route?: string
target?: string
detail?: Record<string, unknown>
}
let auditRequestId = 0
export async function recordVisitorEvent(event: VisitorAuditEvent): Promise<void> {
try {
await fetch('/api/rpc2', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
jsonrpc: '2.0',
method: 'public:recordVisitorEvent',
params: event,
id: ++auditRequestId,
}),
})
}
catch {
// 审计上报不应影响用户正常浏览
}
}
```
## Vue Router 访问记录示例
```ts
router.afterEach((to) => {
void recordVisitorEvent({
event: 'page_view',
path: to.fullPath,
route: String(to.name ?? ''),
detail: {
params: Object.keys(to.params),
query_keys: Object.keys(to.query),
},
})
})
```
建议只记录 query key,不记录完整 query value,避免泄露 token 或敏感参数。
## 推荐事件名
| 场景 | event |
| --- | --- |
| 页面访问 | `page_view` |
| 打开节点详情 | `node_open` |
| 搜索 | `search` |
| 切换分组 | `group_change` |
| 切换视图模式 | `view_mode_change` |
| 点击管理入口 | `admin_entry_click` |
| 打开高级工具 | `home_tool_open` |
| 审计日志刷新 | `audit_refresh` |
| 审计日志翻页 | `audit_page_change` |
| 导出 JSON | `export_json` |
| 导出 CSV | `export_csv` |
| WebRTC 自检开始 | `webrtc_check_start` |
| WebRTC 自检完成 | `webrtc_check_done` |
## 审计日志保存形态
写入现有 `models.Log`:
| 字段 | 值 |
| --- | --- |
| `ip` | 服务端看到的来源 IP |
| `uuid` | 登录用户 UUID;访客为空 |
| `msg_type` | `visitor` |
| `message` | `visitor event: {...}` |
| `time` | 后端写入时间 |
示例 `message`:
```text
visitor event: {"event":"page_view","path":"/","route":"home","user_agent":"Mozilla/5.0 ...","detail":{"theme":"glassmorphism"}}
```
## 管理端读取
复用现有接口:
```text
admin:getLogs
```
请求示例:
```json
{
"jsonrpc": "2.0",
"method": "admin:getLogs",
"params": {
"limit": "100",
"page": "1",
"msg_type": "visitor"
},
"id": 10
}
```
`msg_type` 为可选的精确匹配过滤参数。传入 `visitor` 后,计数和分页都会在 SQL 查询层过滤,
无需先拉取所有日志再由前端筛选。
## 安全注意事项
### 不要从前端传 IP
前端 IP 不可信。该接口会自动使用服务端看到的来源 IP。
### 不要记录敏感值
不建议记录:
- 密码
- token
- cookie
- 完整 URL query
- 完整搜索关键词
- 导出内容
- WebSSH 命令内容
- 剪贴板内容
建议只记录:
- 操作类型
- 路由
- 目标 ID
- 结果数量
- 布尔状态
- 非敏感摘要
### 上报不要阻塞主流程
前端调用建议使用:
```ts
void recordVisitorEvent(...)
```
即使上报失败,也不影响页面功能。
+3 -2
View File
@@ -17,11 +17,12 @@ require (
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.11.1
golang.org/x/image v0.35.0
golang.org/x/sys v0.33.0
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.26.1
)
require golang.org/x/sys v0.33.0 // indirect
// Misuse of ServerConfig.PublicKeyCallback may cause authorization bypass in golang.org/x/crypto #1
// golang.org/x/crypto Vulnerable to Denial of Service (DoS) via Slow or Incomplete Key Exchange #3
require golang.org/x/crypto v0.39.0 // indirect
@@ -67,7 +68,7 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.15.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sync v0.19.0
golang.org/x/text v0.33.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+3 -12
View File
@@ -368,13 +368,7 @@ install_binary() {
if systemctl is-active --quiet ${SERVICE_NAME}.service; then
log_success "Komari 服务启动成功"
log_step "正在获取初始密码..."
sleep 5
local password=$(journalctl -u ${SERVICE_NAME} --since "1 minute ago" | grep "admin account created." | tail -n 1 | sed -e 's/.*admin account created.//')
if [ -z "$password" ]; then
log_error "未能获取初始密码,请检查日志"
fi
show_access_info "$password" "$LISTEN_PORT"
show_access_info "$LISTEN_PORT"
else
ui_msgbox "错误" "Komari 服务启动失败。\n\n查看日志: journalctl -u ${SERVICE_NAME} -f"
return 1
@@ -408,16 +402,13 @@ EOF
# Show access information
show_access_info() {
local password=$1
local port=${2:-$DEFAULT_PORT}
local port=${1:-$DEFAULT_PORT}
local ip=$(hostname -I | awk '{print $1}')
local content="安装完成!\n\n"
content+="访问信息:\n"
content+=" URL: http://${ip}:${port}\n"
if [ -n "$password" ]; then
content+=" 初始登录信息(仅显示一次): ${password}\n"
fi
content+="\n首次使用请访问上述地址,按安装向导创建管理员账号。\n"
content+="\n服务管理命令:\n"
content+=" 状态: systemctl status $SERVICE_NAME\n"
content+=" 启动: systemctl start $SERVICE_NAME\n"
-2
View File
@@ -17,7 +17,6 @@ type Settings struct {
ScriptDomain string `json:"script_domain" default:""` // 自定义脚本域名
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" default:"false"` // 是否向访客页面发送 IP 地址,默认 false
VisitorAuditEnabled bool `json:"visitor_audit_enabled" default:"false"` // 是否允许公开访客事件写入审计日志,默认 false
LowResourceMode bool `json:"low_resource_mode"` // 低资源模式;首次启动按主机性能自动选择
EulaAccepted bool `json:"eula_accepted" default:"false"`
BaseScriptsURLKey string `json:"base_scripts_url" default:""`
// GeoIP 配置
@@ -56,7 +55,6 @@ const (
ScriptDomainKey = "script_domain"
SendIpAddrToGuestKey = "send_ip_addr_to_guest"
VisitorAuditEnabledKey = "visitor_audit_enabled"
LowResourceModeKey = "low_resource_mode"
EulaAcceptedKey = "eula_accepted"
BaseScriptsURLKey = "base_scripts_url"
GeoIpEnabledKey = "geo_ip_enabled"
+24
View File
@@ -0,0 +1,24 @@
// Package lifecycle carries process-level requests without coupling web RPC
// handlers to the server runtime.
package lifecycle
// RestartReason identifies why the current process must restart.
type RestartReason string
const RestartForMetricStoreStructureUpgrade RestartReason = "metric-store-structure-upgrade"
var restartRequests = make(chan RestartReason, 1)
// RequestRestart asks the running server to shut down cleanly and exit. A
// pending request is sufficient because the process will be replaced.
func RequestRestart(reason RestartReason) {
select {
case restartRequests <- reason:
default:
}
}
// RestartRequests returns process restart requests for the server runtime.
func RestartRequests() <-chan RestartReason {
return restartRequests
}
+4 -45
View File
@@ -32,10 +32,9 @@ type LegacyMonitoringStats struct {
Ping int64
}
// LegacyMonitoringSummary describes the legacy source data shown by the
// pre-start upgrade wizard. Row counts deliberately stay separate from the
// estimated metric point count because many source rows collapse into one
// hourly P95 point per metric, entity, and tag set.
// LegacyMonitoringSummary describes the full legacy source data shown by the
// pre-start migration guide. Every source row participates in the migration;
// EstimatedPoints is the number of hourly P95 points expected after grouping.
type LegacyMonitoringSummary struct {
LoadRows int64 `json:"load_rows"`
GPURows int64 `json:"gpu_rows"`
@@ -56,12 +55,6 @@ type LegacyMonitoringProgress struct {
WrittenPoints int64 `json:"written_points"`
}
type LegacyMonitoringDeleted struct {
LoadRows int64 `json:"load_rows"`
GPURows int64 `json:"gpu_rows"`
LatencyRows int64 `json:"latency_rows"`
}
type legacyMonitoringBounds struct {
Oldest string `gorm:"column:oldest"`
Newest string `gorm:"column:newest"`
@@ -175,40 +168,6 @@ func InspectLegacyMonitoring(db *gorm.DB) (LegacyMonitoringSummary, error) {
return summary, nil
}
// DeleteLegacyMonitoringBefore removes only monitoring history older than the
// cutoff. Clients, users, ping tasks and other business data are untouched.
func DeleteLegacyMonitoringBefore(db *gorm.DB, cutoff time.Time) (LegacyMonitoringDeleted, error) {
var deleted LegacyMonitoringDeleted
if db == nil {
return deleted, fmt.Errorf("migration database is nil")
}
if cutoff.IsZero() {
return deleted, fmt.Errorf("cleanup cutoff is required")
}
err := db.Transaction(func(tx *gorm.DB) error {
for _, table := range legacyMonitoringTables {
if !tx.Migrator().HasTable(table) {
continue
}
result := tx.Exec("DELETE FROM "+table+" WHERE time < ?", cutoff.UTC())
if result.Error != nil {
return fmt.Errorf("delete legacy %s before cutoff: %w", table, result.Error)
}
switch table {
case "records", "records_long_term":
deleted.LoadRows += result.RowsAffected
case "gpu_records":
deleted.GPURows += result.RowsAffected
case "ping_records":
deleted.LatencyRows += result.RowsAffected
}
}
return nil
})
return deleted, err
}
func MigrateLegacyMonitoring(ctx context.Context, db *gorm.DB, s *metric.Store, progress func(LegacyMonitoringProgress)) (LegacyMonitoringStats, error) {
if err := metricstore.EnsureBuiltinMetricDefinitions(ctx, s); err != nil {
return LegacyMonitoringStats{}, fmt.Errorf("ensure built-in metric definitions: %w", err)
@@ -541,7 +500,7 @@ func (a *legacyHourlyP95Aggregator) Flush(ctx context.Context) (int64, error) {
Tags: group.tags,
})
}
if err := a.store.WriteBatch(ctx, points); err != nil {
if err := a.store.ReplaceRollupPoints(ctx, legacyMonitoringInterval, points); err != nil {
return 0, err
}
a.partitionKey = ""
+21 -87
View File
@@ -101,35 +101,23 @@ func TestLegacyMonitoringTablesMigratedByOneShotMigration(t *testing.T) {
}
hour := base.Truncate(time.Hour)
cpuPoints, err := metricStore.Query(ctx, metric.Query{MetricName: metricstore.MetricCPU, EntityID: "client-a", Start: hour.Add(-time.Second), End: hour.Add(time.Hour)})
if err != nil {
t.Fatalf("query cpu points: %v", err)
}
if len(cpuPoints) != 1 || math.Abs(cpuPoints[0].Value-22) > 1e-9 || !cpuPoints[0].Timestamp.Equal(hour) {
cpuPoints := queryLegacyRollups(t, ctx, metricStore, metric.Query{MetricName: metricstore.MetricCPU, EntityID: "client-a", Start: hour.Add(-time.Second), End: hour.Add(time.Hour)})
if len(cpuPoints) != 1 || math.Abs(cpuPoints[0].Value-22) > 1e-9 || !cpuPoints[0].Bucket.Equal(hour) {
t.Fatalf("unexpected cpu points: %#v", cpuPoints)
}
gpuPoints, err := metricStore.Query(ctx, metric.Query{MetricName: metricstore.MetricGPUDeviceUsage, EntityID: "client-a", Start: hour.Add(-time.Second), End: hour.Add(time.Hour), Tags: map[string]string{"device_index": "0"}})
if err != nil {
t.Fatalf("query gpu points: %v", err)
}
gpuPoints := queryLegacyRollups(t, ctx, metricStore, metric.Query{MetricName: metricstore.MetricGPUDeviceUsage, EntityID: "client-a", Start: hour.Add(-time.Second), End: hour.Add(time.Hour), Tags: map[string]string{"device_index": "0"}})
if len(gpuPoints) != 1 || gpuPoints[0].Value != 67 {
t.Fatalf("unexpected gpu points: %#v", gpuPoints)
}
pingPoints, err := metricStore.Query(ctx, metric.Query{MetricName: metricstore.MetricPingLatency, EntityID: "client-a", Start: hour.Add(-time.Second), End: hour.Add(time.Hour), Tags: map[string]string{"task_id": "7"}})
if err != nil {
t.Fatalf("query ping points: %v", err)
}
if len(pingPoints) != 1 || math.Abs(pingPoints[0].Value-34.15) > 1e-9 || !pingPoints[0].Timestamp.Equal(hour) {
pingPoints := queryLegacyRollups(t, ctx, metricStore, metric.Query{MetricName: metricstore.MetricPingLatency, EntityID: "client-a", Start: hour.Add(-time.Second), End: hour.Add(time.Hour), Tags: map[string]string{"task_id": "7"}})
if len(pingPoints) != 1 || math.Abs(pingPoints[0].Value-34.15) > 1e-9 || !pingPoints[0].Bucket.Equal(hour) {
t.Fatalf("unexpected ping points: %#v", pingPoints)
}
pingLossPoints, err := metricStore.Query(ctx, metric.Query{MetricName: metricstore.MetricPingLoss, EntityID: "client-a", Start: hour.Add(-time.Second), End: hour.Add(time.Hour), Tags: map[string]string{"task_id": "7"}})
if err != nil {
t.Fatalf("query ping loss points: %v", err)
}
if len(pingLossPoints) != 1 || math.Abs(pingLossPoints[0].Value-0.95) > 1e-9 || !pingLossPoints[0].Timestamp.Equal(hour) {
pingLossPoints := queryLegacyRollups(t, ctx, metricStore, metric.Query{MetricName: metricstore.MetricPingLoss, EntityID: "client-a", Start: hour.Add(-time.Second), End: hour.Add(time.Hour), Tags: map[string]string{"task_id": "7"}})
if len(pingLossPoints) != 1 || math.Abs(pingLossPoints[0].Value-0.95) > 1e-9 || !pingLossPoints[0].Bucket.Equal(hour) {
t.Fatalf("unexpected ping loss points: %#v", pingLossPoints)
}
@@ -189,28 +177,33 @@ func TestLegacyHourlyP95PreservesHoursAndTaggedSeries(t *testing.T) {
t.Fatalf("flush aggregate input: %v", err)
}
taskOne, err := store.Query(ctx, metric.Query{
taskOne := queryLegacyRollups(t, ctx, store, metric.Query{
MetricName: "test.p95", EntityID: "node-a", Start: base.Add(-time.Second), End: base.Add(2 * time.Hour),
Tags: map[string]string{"task_id": "1"}, Order: metric.OrderAsc,
})
if err != nil {
t.Fatalf("query task one: %v", err)
}
if len(taskOne) != 2 || math.Abs(taskOne[0].Value-19.5) > 1e-9 || taskOne[1].Value != 30 {
t.Fatalf("unexpected task one aggregates: %#v", taskOne)
}
taskTwo, err := store.Query(ctx, metric.Query{
taskTwo := queryLegacyRollups(t, ctx, store, metric.Query{
MetricName: "test.p95", EntityID: "node-a", Start: base.Add(-time.Second), End: base.Add(2 * time.Hour),
Tags: map[string]string{"task_id": "2"}, Order: metric.OrderAsc,
})
if err != nil {
t.Fatalf("query task two: %v", err)
}
if len(taskTwo) != 1 || taskTwo[0].Value != 100 || !taskTwo[0].Timestamp.Equal(base) {
if len(taskTwo) != 1 || taskTwo[0].Value != 100 || !taskTwo[0].Bucket.Equal(base) {
t.Fatalf("unexpected task two aggregates: %#v", taskTwo)
}
}
func queryLegacyRollups(t *testing.T, ctx context.Context, store *metric.Store, query metric.Query) []metric.AggregatePoint {
t.Helper()
points, err := store.Series(ctx, metric.AggregateQuery{
Query: query, Aggregation: metric.AggLast, Interval: time.Hour, PreserveSeries: true,
}, query.End)
if err != nil {
t.Fatalf("query %s rollups: %v", query.MetricName, err)
}
return points
}
func TestLegacyRecordProjectionFillsMissingMetricColumns(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+filepath.ToSlash(filepath.Join(t.TempDir(), "minimal-legacy.db"))+"?mode=rwc"), &gorm.Config{})
if err != nil {
@@ -244,65 +237,6 @@ func TestLegacyRecordProjectionFillsMissingMetricColumns(t *testing.T) {
}
}
func TestDeleteLegacyMonitoringBeforeOnlyRemovesOldHistory(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+filepath.ToSlash(filepath.Join(t.TempDir(), "cleanup.db"))+"?mode=rwc"), &gorm.Config{})
if err != nil {
t.Fatalf("open cleanup db: %v", err)
}
if sqlDB, err := db.DB(); err == nil {
t.Cleanup(func() { _ = sqlDB.Close() })
} else {
t.Fatalf("cleanup sql db: %v", err)
}
if err := db.AutoMigrate(&models.Client{}, &models.Record{}, &models.GPURecord{}, &models.PingRecord{}); err != nil {
t.Fatalf("migrate cleanup tables: %v", err)
}
if err := db.Table("records_long_term").AutoMigrate(&models.Record{}); err != nil {
t.Fatalf("migrate cleanup long-term table: %v", err)
}
if err := db.Create(&models.Client{UUID: "client-a", Token: "token-a", Name: "A"}).Error; err != nil {
t.Fatalf("seed client: %v", err)
}
cutoff := time.Date(2026, 7, 10, 0, 0, 0, 0, time.UTC)
oldTime := cutoff.Add(-time.Hour)
newTime := cutoff.Add(time.Hour)
for _, record := range []models.Record{{Client: "client-a", Time: oldTime}, {Client: "client-a", Time: newTime}} {
if err := db.Create(&record).Error; err != nil {
t.Fatalf("seed load record: %v", err)
}
}
for _, record := range []models.GPURecord{{Client: "client-a", Time: oldTime}, {Client: "client-a", Time: newTime}} {
if err := db.Create(&record).Error; err != nil {
t.Fatalf("seed GPU record: %v", err)
}
}
for _, record := range []models.PingRecord{{Client: "client-a", TaskId: 1, Time: oldTime}, {Client: "client-a", TaskId: 1, Time: newTime}} {
if err := db.Create(&record).Error; err != nil {
t.Fatalf("seed ping record: %v", err)
}
}
deleted, err := DeleteLegacyMonitoringBefore(db, cutoff)
if err != nil {
t.Fatalf("delete legacy monitoring before cutoff: %v", err)
}
if deleted.LoadRows != 1 || deleted.GPURows != 1 || deleted.LatencyRows != 1 {
t.Fatalf("unexpected deleted rows: %#v", deleted)
}
summary, err := InspectLegacyMonitoring(db)
if err != nil {
t.Fatalf("inspect remaining legacy monitoring: %v", err)
}
if summary.LoadRows != 1 || summary.GPURows != 1 || summary.LatencyRows != 1 || summary.ServerCount != 1 {
t.Fatalf("unexpected remaining legacy rows: %#v", summary)
}
var clients int64
if err := db.Model(&models.Client{}).Count(&clients).Error; err != nil || clients != 1 {
t.Fatalf("cleanup changed clients: count=%d err=%v", clients, err)
}
}
func TestCompleteLegacyMonitoringMigrationFinalizesBeforeMarkingDone(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+filepath.ToSlash(filepath.Join(t.TempDir(), "finalize.db"))+"?mode=rwc"), &gorm.Config{})
if err != nil {
+1
View File
@@ -150,6 +150,7 @@ func migrateRemovedCompatibilityConfig(db *gorm.DB) error {
return db.Delete(&appconfig.ConfigItem{}, "key IN ?", []string{
"nezha_compat_enabled",
"nezha_compat_listen",
"low_resource_mode",
}).Error
}
+2 -1
View File
@@ -104,7 +104,7 @@ func TestRunRemovesCompatibilityConfig(t *testing.T) {
if err := db.AutoMigrate(&appconfig.ConfigItem{}); err != nil {
t.Fatalf("migrate config item table: %v", err)
}
for _, key := range []string{"nezha_compat_enabled", "nezha_compat_listen"} {
for _, key := range []string{"nezha_compat_enabled", "nezha_compat_listen", "low_resource_mode"} {
if err := db.Create(&appconfig.ConfigItem{Key: key, Value: "true"}).Error; err != nil {
t.Fatalf("seed removed config %q: %v", key, err)
}
@@ -121,6 +121,7 @@ func TestRunRemovesCompatibilityConfig(t *testing.T) {
if err := db.Model(&appconfig.ConfigItem{}).Where("key IN ?", []string{
"nezha_compat_enabled",
"nezha_compat_listen",
"low_resource_mode",
}).Count(&count).Error; err != nil {
t.Fatalf("count removed config: %v", err)
}
+14
View File
@@ -1,6 +1,7 @@
package server
import (
"context"
"errors"
"net/http"
"net/http/httptest"
@@ -66,3 +67,16 @@ func TestRetryMetricStoreConnectionReturnsLastError(t *testing.T) {
t.Fatalf("attempts = %d, want 3", attempts)
}
}
func TestShutdownReturnsCleanupError(t *testing.T) {
wantErr := errors.New("queued reports were not flushed")
app := New(Options{})
app.addCleanup("metric-report-batcher", func(_ context.Context) error {
return wantErr
})
err := app.Shutdown()
if !errors.Is(err, wantErr) {
t.Fatalf("shutdown error = %v, want %v", err, wantErr)
}
}
-34
View File
@@ -4,14 +4,11 @@ import (
"context"
"fmt"
"os"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/pkg/resourceprobe"
"github.com/komari-monitor/komari/utils"
logger "github.com/komari-monitor/komari/utils/log"
)
// Bootstrap initializes the data directory, primary database, and settings.
@@ -28,14 +25,6 @@ func (a *App) Bootstrap() error {
a.addCleanup("database", func(context.Context) error { return dbcore.Close() })
gin.SetMode(gin.ReleaseMode)
lowResourceMode, err := ensureLowResourceModeDefault()
if err != nil {
return fmt.Errorf("failed to initialize low resource mode: %w", err)
}
if err := dbcore.ConfigureLowResourceMode(lowResourceMode); err != nil {
return err
}
settings, err := config.GetManyAs[config.Settings]()
if err != nil {
return fmt.Errorf("failed to load settings: %w", err)
@@ -43,26 +32,3 @@ func (a *App) Bootstrap() error {
a.settings = settings
return nil
}
func ensureLowResourceModeDefault() (bool, error) {
values, err := config.GetMany(map[string]any{config.LowResourceModeKey: nil})
if err != nil {
return false, err
}
if saved, ok := values[config.LowResourceModeKey]; ok {
enabled, ok := saved.(bool)
if !ok {
return false, fmt.Errorf("%s must be a boolean", config.LowResourceModeKey)
}
return enabled, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 55*time.Second)
defer cancel()
result := resourceprobe.Detect(ctx, "./data")
if err := config.Set(config.LowResourceModeKey, result.LowResource); err != nil {
return false, err
}
logger.Infof("server", "Low resource mode auto-detection: enabled=%t memory=%dMiB disk_free=%dMiB cpu=%.0fops/s random_write=%.2fMiB/s iops=%.0f reasons=%v", result.LowResource, result.MemoryBytes/(1024*1024), result.DiskFreeBytes/(1024*1024), result.CPUOpsPerSecond, result.WriteBytesPerSecond/(1024*1024), result.WriteIOPS, result.Reasons)
return result.LowResource, nil
}
+49 -10
View File
@@ -1,12 +1,15 @@
package server
import (
"context"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/metricstore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/migrations"
installweb "github.com/komari-monitor/komari/web/install"
migrationweb "github.com/komari-monitor/komari/web/migration"
recoveryweb "github.com/komari-monitor/komari/web/recovery"
upgradeweb "github.com/komari-monitor/komari/web/update"
)
// InstallRequired reports whether the instance still needs the first-run guide.
@@ -18,8 +21,33 @@ func (a *App) InstallRequired() (bool, error) {
return count == 0, nil
}
func (a *App) LegacyUpgradeRequired() (bool, migrations.LegacyMonitoringSummary, error) {
return migrations.LegacyMonitoringMigrationRequired(dbcore.GetDBInstance())
type DatabaseMigrationRequirement struct {
mode migrationweb.Mode
summary migrations.LegacyMonitoringSummary
}
func (r DatabaseMigrationRequirement) Required() bool { return r.mode != "" }
// DatabaseMigrationRequired checks both supported migration inputs before the
// normal Metric Store connection is initialized. Structure upgrades run first
// when both inputs exist, then the startup loop detects the legacy tables on
// its next pass.
func (a *App) DatabaseMigrationRequired() (DatabaseMigrationRequirement, error) {
structureRequired, err := metricstore.StructureUpgradeRequired(context.Background())
if err != nil {
return DatabaseMigrationRequirement{}, err
}
if structureRequired {
return DatabaseMigrationRequirement{mode: migrationweb.ModeMetricStructure}, nil
}
legacyRequired, summary, err := migrations.LegacyMonitoringMigrationRequired(dbcore.GetDBInstance())
if err != nil {
return DatabaseMigrationRequirement{}, err
}
if legacyRequired {
return DatabaseMigrationRequirement{mode: migrationweb.ModeLegacyMonitoring, summary: summary}, nil
}
return DatabaseMigrationRequirement{}, nil
}
// RunInstallGuide exposes only first-run installation APIs. It intentionally
@@ -45,13 +73,24 @@ func (a *App) RunMetricStoreRecovery(initialErr error) (bool, error) {
})
}
// RunLegacyUpgrade keeps login available while migration is in progress.
func (a *App) RunLegacyUpgrade(summary migrations.LegacyMonitoringSummary) (bool, error) {
// RunDatabaseMigration serves the same authenticated guide and status model
// for either migration input while leaving each conversion engine independent.
func (a *App) RunDatabaseMigration(requirement DatabaseMigrationRequirement) (bool, error) {
a.initOAuth()
return a.runGuideServer(upgradeweb.NewController(dbcore.GetDBInstance(), summary), guideServerConfig{
pagePath: upgradeweb.PagePath,
missingAPI: "Not found in upgrade mode",
logMessage: "Legacy monitoring data requires the 1.2.7 upgrade wizard on %s",
requireIdentity: true,
var controller guideController
switch requirement.mode {
case migrationweb.ModeMetricStructure:
controller = migrationweb.NewStructureController()
case migrationweb.ModeLegacyMonitoring:
controller = migrationweb.NewLegacyController(dbcore.GetDBInstance(), requirement.summary)
default:
return false, nil
}
return a.runGuideServer(controller, guideServerConfig{
pagePath: migrationweb.PagePath,
missingAPI: "Not found in database migration mode",
logMessage: "Database migration guide is available on %s",
requireIdentity: true,
restrictedStatic: true,
})
}
+3
View File
@@ -83,5 +83,8 @@ func (a *App) InitStores() error {
}
metricstore.StartReportBatcher()
a.addCleanup("metric-report-batcher", metricstore.StopReportBatcher)
// A store-to-store migration holds the exclusive operation lease. Stop it
// before flushing queued reports, which need the shared lease to write.
a.addCleanup("metric-store-migration", metricstore.StopStoreMigrationForShutdown)
return nil
}
+34 -8
View File
@@ -18,6 +18,7 @@ import (
d_notification "github.com/komari-monitor/komari/database/notification"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/internal/lifecycle"
"github.com/komari-monitor/komari/internal/scheduler"
"github.com/komari-monitor/komari/utils/geoip"
logger "github.com/komari-monitor/komari/utils/log"
@@ -30,6 +31,18 @@ import (
"github.com/komari-monitor/komari/web/security"
)
// ErrRestartRequested is returned after a clean shutdown when a configuration
// change requires the next startup to enter a restricted guide.
var ErrRestartRequested = errors.New("server restart requested")
const (
// Give in-flight HTTP requests time to finish before the listener closes.
httpShutdownTimeout = 10 * time.Second
// Keep an independent budget for report flushing and store teardown. Reusing
// the HTTP deadline here can skip queued metric writes after a slow request.
resourceCleanupTimeout = 30 * time.Second
)
// StartBackground starts scheduled work after all stores are ready.
func (a *App) StartBackground() error {
registerScheduledWork()
@@ -106,6 +119,12 @@ func (a *App) Run() error {
case err := <-serverErr:
a.onFatal(err)
return fmt.Errorf("listen: %w", err)
case reason := <-lifecycle.RestartRequests():
logger.Infof("server", "Restarting service for %s", reason)
if err := a.Shutdown(); err != nil {
logger.Errorf("server", "Cleanup before restart failed: %v", err)
}
return fmt.Errorf("%w: %s", ErrRestartRequested, reason)
case <-quit:
return a.Shutdown()
}
@@ -116,33 +135,40 @@ func (a *App) Shutdown() error {
if a.dbReady {
auditlog.Log("", "", "server is shutting down", "info")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
httpCtx, cancelHTTP := context.WithTimeout(context.Background(), httpShutdownTimeout)
defer cancelHTTP()
if a.server != nil {
if err := a.server.Shutdown(ctx); err != nil {
if err := a.server.Shutdown(httpCtx); err != nil {
logger.Infof("server", "HTTP server forced to shutdown: %v", err)
}
}
a.runCleanups(ctx)
return nil
cleanupCtx, cancelCleanup := context.WithTimeout(context.Background(), resourceCleanupTimeout)
defer cancelCleanup()
return a.runCleanups(cleanupCtx)
}
func (a *App) onFatal(err error) {
if a.dbReady {
auditlog.Log("", "", "server encountered a fatal error: "+err.Error(), "error")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), resourceCleanupTimeout)
defer cancel()
a.runCleanups(ctx)
if cleanupErr := a.runCleanups(ctx); cleanupErr != nil {
logger.Errorf("server", "Cleanup after fatal server error failed: %v", cleanupErr)
}
}
func (a *App) runCleanups(ctx context.Context) {
func (a *App) runCleanups(ctx context.Context) error {
var cleanupErrors []error
for i := len(a.cleanups) - 1; i >= 0; i-- {
cleanup := a.cleanups[i]
if err := cleanup.fn(ctx); err != nil {
logger.Errorf("server", "cleanup %q failed: %v", cleanup.name, err)
cleanupErrors = append(cleanupErrors, fmt.Errorf("cleanup %q: %w", cleanup.name, err))
}
}
return errors.Join(cleanupErrors...)
}
func registerScheduledWork() {
+181
View File
@@ -0,0 +1,181 @@
// Package sqlitetune applies a consistent SQLite baseline to every physical
// connection opened through database/sql.
package sqlitetune
import (
"context"
"database/sql"
"database/sql/driver"
"fmt"
"math"
"time"
sqlite3 "github.com/mattn/go-sqlite3"
)
// SynchronousMode selects SQLite's synchronous durability mode.
type SynchronousMode string
const (
// SynchronousNormal balances write throughput and durability in WAL mode.
SynchronousNormal SynchronousMode = "NORMAL"
// SynchronousOff favors throughput over durability.
SynchronousOff SynchronousMode = "OFF"
// SynchronousFull favors durability over write throughput.
SynchronousFull SynchronousMode = "FULL"
)
// Options configures the SQLite PRAGMAs applied to every physical connection.
// WAL mode is always enabled. Zero mmap size explicitly disables memory
// mapping; it is not treated as an unset value.
type Options struct {
PageSize int
ForeignKeys bool
BusyTimeout time.Duration
CacheSizeKB int
MMapSizeBytes int64
TempStoreMemory bool
CacheSpill bool
WALAutoCheckpoint int
JournalSizeLimitBytes int64
Synchronous SynchronousMode
}
// Open returns a database/sql pool whose new SQLite connections are configured
// before they are handed to callers.
func Open(dsn string, options Options) (*sql.DB, error) {
options, err := normalize(options)
if err != nil {
return nil, err
}
driver := &sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
return applyDriverConn(conn, options)
},
}
return sql.OpenDB(&connector{driver: driver, dsn: dsn}), nil
}
// Apply configures one physical connection from a caller-owned pool. Future
// connections in that pool remain the caller's responsibility; use Open when
// this package owns the pool and every physical connection must be configured.
func Apply(ctx context.Context, db *sql.DB, options Options) error {
options, err := normalize(options)
if err != nil {
return err
}
conn, err := db.Conn(ctx)
if err != nil {
return fmt.Errorf("sqlite tune: acquire connection: %w", err)
}
defer conn.Close()
for _, pragma := range pragmas(options) {
if _, err := conn.ExecContext(ctx, pragma); err != nil {
return fmt.Errorf("sqlite tune: apply %q: %w", pragma, err)
}
}
return nil
}
type connector struct {
driver *sqlite3.SQLiteDriver
dsn string
}
func (c *connector) Connect(ctx context.Context) (driver.Conn, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
conn, err := c.driver.Open(c.dsn)
if err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
_ = conn.Close()
return nil, err
}
return conn, nil
}
func (c *connector) Driver() driver.Driver {
return c.driver
}
func applyDriverConn(conn *sqlite3.SQLiteConn, options Options) error {
for _, pragma := range pragmas(options) {
if _, err := conn.Exec(pragma, nil); err != nil {
return fmt.Errorf("sqlite tune: apply %q: %w", pragma, err)
}
}
return nil
}
func normalize(options Options) (Options, error) {
if options.Synchronous == "" {
options.Synchronous = SynchronousNormal
}
switch options.Synchronous {
case SynchronousNormal, SynchronousOff, SynchronousFull:
default:
return options, fmt.Errorf("sqlite tune: unsupported synchronous mode %q", options.Synchronous)
}
if options.PageSize < 0 {
return options, fmt.Errorf("sqlite tune: page size cannot be negative")
}
if options.BusyTimeout < 0 {
return options, fmt.Errorf("sqlite tune: busy timeout cannot be negative")
}
if options.CacheSizeKB <= 0 {
return options, fmt.Errorf("sqlite tune: cache size must be positive")
}
if options.MMapSizeBytes < 0 {
return options, fmt.Errorf("sqlite tune: mmap size cannot be negative")
}
if options.WALAutoCheckpoint <= 0 {
return options, fmt.Errorf("sqlite tune: WAL auto-checkpoint must be positive")
}
// SQLite uses -1 to disable the post-checkpoint WAL size limit. Preserve
// that documented value for callers that explicitly opt out of the cap.
if options.JournalSizeLimitBytes < -1 {
return options, fmt.Errorf("sqlite tune: journal size limit cannot be less than -1")
}
return options, nil
}
func pragmas(options Options) []string {
pragmas := make([]string, 0, 11)
if options.PageSize > 0 {
pragmas = append(pragmas, fmt.Sprintf("PRAGMA page_size = %d", options.PageSize))
}
pragmas = append(pragmas, "PRAGMA journal_mode = WAL")
if options.ForeignKeys {
pragmas = append(pragmas, "PRAGMA foreign_keys = ON")
}
pragmas = append(pragmas,
fmt.Sprintf("PRAGMA synchronous = %s", options.Synchronous),
fmt.Sprintf("PRAGMA busy_timeout = %d", durationMillis(options.BusyTimeout)),
fmt.Sprintf("PRAGMA cache_size = -%d", options.CacheSizeKB),
fmt.Sprintf("PRAGMA mmap_size = %d", options.MMapSizeBytes),
)
if options.TempStoreMemory {
pragmas = append(pragmas, "PRAGMA temp_store = MEMORY")
} else {
pragmas = append(pragmas, "PRAGMA temp_store = FILE")
}
if options.CacheSpill {
pragmas = append(pragmas, "PRAGMA cache_spill = ON")
} else {
pragmas = append(pragmas, "PRAGMA cache_spill = OFF")
}
return append(pragmas,
fmt.Sprintf("PRAGMA wal_autocheckpoint = %d", options.WALAutoCheckpoint),
fmt.Sprintf("PRAGMA journal_size_limit = %d", options.JournalSizeLimitBytes),
)
}
func durationMillis(d time.Duration) int {
return int(math.Ceil(float64(d) / float64(time.Millisecond)))
}
+21
View File
@@ -0,0 +1,21 @@
package sqlitetune
import (
"testing"
"time"
)
func TestNormalizeAllowsUnlimitedJournalSize(t *testing.T) {
options, err := normalize(Options{
BusyTimeout: 5 * time.Second,
CacheSizeKB: 8,
WALAutoCheckpoint: 1,
JournalSizeLimitBytes: -1,
})
if err != nil {
t.Fatalf("normalize unlimited journal size: %v", err)
}
if options.JournalSizeLimitBytes != -1 {
t.Fatalf("journal size limit = %d, want -1", options.JournalSizeLimitBytes)
}
}
-66
View File
@@ -1,66 +0,0 @@
package metric
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// compactionWatermark returns the last boundary for which a metric's raw data
// was successfully materialized into rollups and retained/deleted in the same
// transaction. A missing row is expected for stores created before watermark
// tracking was introduced or for metrics that have never been compacted.
func (s *Store) compactionWatermark(ctx context.Context, metricName string) (time.Time, bool, error) {
var nano int64
err := s.reader().QueryRowContext(ctx, fmt.Sprintf(
`SELECT watermark_nano FROM %s WHERE metric_name = %s`,
s.tables.watermarks, s.dialect.placeholder(1),
), metricName).Scan(&nano)
if errors.Is(err, sql.ErrNoRows) {
return time.Time{}, false, nil
}
if err != nil {
return time.Time{}, false, err
}
if nano <= 0 {
return time.Time{}, false, nil
}
return time.Unix(0, nano).UTC(), true, nil
}
// persistCompactionWatermarkTx records a successful compaction boundary in the
// same transaction as rollup writes and raw retention deletion. It is
// monotonic so a delayed caller cannot move the read boundary backwards.
func (s *Store) persistCompactionWatermarkTx(ctx context.Context, metricName string, watermark time.Time, tx *sql.Tx) error {
watermark = watermark.UTC()
if watermark.IsZero() {
return s.clearCompactionWatermarkTx(ctx, metricName, tx)
}
var previous int64
err := tx.QueryRowContext(ctx, fmt.Sprintf(
`SELECT watermark_nano FROM %s WHERE metric_name = %s`,
s.tables.watermarks, s.dialect.placeholder(1),
), metricName).Scan(&previous)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
if err == nil && previous > watermark.UnixNano() {
watermark = time.Unix(0, previous).UTC()
}
_, err = tx.ExecContext(ctx, s.dialect.upsertCompactionWatermarkSQL(s.tables),
metricName, watermark.UnixNano(), time.Now().UTC().UnixNano(),
)
return err
}
func (s *Store) clearCompactionWatermarkTx(ctx context.Context, metricName string, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, fmt.Sprintf(
`DELETE FROM %s WHERE metric_name = %s`,
s.tables.watermarks, s.dialect.placeholder(1),
), metricName)
return err
}
+47 -13
View File
@@ -58,12 +58,10 @@ type Config struct {
// SQLite 保存 SQLite 专用选项。
SQLite SQLiteOptions
// RollupPolicy configures downsampling tiers and tiered retention. When it
// defines tiers, Store.Compact materializes them and enforces every
// retention window. The zero value disables rollups (raw points only).
// RollupPolicy configures downsampling tiers and tiered retention. Exact raw
// samples use the Store's fixed ten-minute in-memory window.
//
// RollupPolicy 配置降采样层级和分层保留时间;定义层级后,
// Store.Compact 会生成 rollup 并执行保留策略。零值表示禁用 rollup。
// RollupPolicy 配置降采样层级和分层保留时间。
RollupPolicy RollupPolicy
}
@@ -99,6 +97,8 @@ type SQLiteOptions struct {
//
// WALAutoCheckpoint 设置 SQLite WAL 自动 checkpoint 页数。
WALAutoCheckpoint int
// JournalSizeLimitBytes caps the WAL retained after checkpoints.
JournalSizeLimitBytes int64
// ReadPoolSize, when > 0, opens a second read-only connection pool with
// this many connections for SELECT-style calls. SQLite serializes writes,
// but WAL lets readers run concurrently, so a read pool lifts read
@@ -156,12 +156,23 @@ func DefaultConfig(driver Driver, dsn string) Config {
ConnMaxLifetime: time.Hour,
ConnectTimeout: 10 * time.Second,
SQLite: SQLiteOptions{
PerformanceProfile: SQLiteProfileBalanced,
BusyTimeout: 5 * time.Second,
CacheSizeKB: 64 * 1024,
TempStoreMemory: true,
MMapSizeBytes: 256 * 1024 * 1024,
WALAutoCheckpoint: 1000,
PerformanceProfile: SQLiteProfileBalanced,
BusyTimeout: 5 * time.Second,
CacheSizeKB: 16 * 1024,
TempStoreMemory: false,
MMapSizeBytes: 32 * 1024 * 1024,
WALAutoCheckpoint: 1000,
JournalSizeLimitBytes: 4 * 1024 * 1024,
},
RollupPolicy: RollupPolicy{
RawRetention: 10 * time.Minute,
Tiers: []RollupTier{
{Interval: time.Minute, Retention: 600 * time.Minute},
{Interval: 5 * time.Minute, Retention: 600 * 5 * time.Minute},
{Interval: time.Hour, Retention: 600 * time.Hour},
{Interval: 24 * time.Hour, Retention: 100 * 365 * 24 * time.Hour},
},
Compression: 30,
},
}
}
@@ -228,6 +239,10 @@ func SQLiteConfig(dsn string) Config {
// is honored instead of being mistaken for "unset".
cfg.MaxOpenConns = 1
cfg.MaxIdleConns = 1
// SQLite benefits from warm page caches. Unlike client/server backends,
// rotating its local file connections only adds churn, so the default keeps
// them until the Store closes. Callers can still set a lifetime explicitly.
cfg.ConnMaxLifetime = 0
return cfg
}
@@ -371,6 +386,13 @@ func WithSQLiteWALAutoCheckpoint(pages int) Option {
}
}
// WithSQLiteJournalSizeLimit sets the WAL bytes retained after checkpoints.
func WithSQLiteJournalSizeLimit(bytes int64) Option {
return func(c *Config) {
c.SQLite.JournalSizeLimitBytes = bytes
}
}
// WithSQLiteReadPool enables a dedicated read-only connection pool of n
// connections for SQLite. Writes stay on the single primary connection
// (SQLite serializes them); reads fan out across the pool, which WAL mode
@@ -387,10 +409,10 @@ func WithSQLiteReadPool(n int) Option {
// WithRollupPolicy sets the downsampling/retention ladder. Compact uses it to
// build rollup tiers (each progressively coarser and longer-lived) and to age
// out raw points and expired tiers.
// out expired summary tiers.
//
// WithRollupPolicy 设置降采样与保留时间阶梯;Compact 会据此构建逐级
// 更粗、保留更久的 rollup,并清理过期原始点和过期层级。
// 更粗、保留更久的 rollup,并清理过期层级。
func WithRollupPolicy(p RollupPolicy) Option {
return func(c *Config) {
c.RollupPolicy = p
@@ -428,6 +450,18 @@ func (c Config) Validate() error {
return fmt.Errorf("%w: table prefix must contain only letters, digits, and underscores", ErrInvalidArgument)
}
}
// Bound generated index names to the backend identifier limit. Restructure
// performs the stricter check required by its temporary "rebuild_" prefix.
switch c.Driver {
case DriverMySQL:
if len(c.TablePrefix) > 35 {
return fmt.Errorf("%w: table prefix is too long for MySQL identifiers", ErrInvalidArgument)
}
case DriverPostgreSQL:
if len(c.TablePrefix) > 34 {
return fmt.Errorf("%w: table prefix is too long for PostgreSQL identifiers", ErrInvalidArgument)
}
}
if err := c.RollupPolicy.Validate(); err != nil {
return err
}
+16 -131
View File
@@ -1,7 +1,6 @@
package metric
import (
"database/sql"
"fmt"
"strings"
)
@@ -24,7 +23,6 @@ type dialect interface {
autoIncrementPrimaryKey() string
nowExpr() string
insertDefinitionSQL(t tables) string
upsertPointSQL(t tables, rowCount int) string
// jsonExtractEquals renders a boolean predicate comparing the text value at
// key inside a JSON column to a bind placeholder. The key is interpolated
// into the SQL (callers must restrict it to a safe charset); the compared
@@ -38,24 +36,6 @@ type dialect interface {
//
// blobType 是 rollup t-digest sketch 的列类型。
blobType() string
// upsertRollupSQL builds a single-row upsert for one rollup bucket cell.
//
// upsertRollupSQL 为单个 rollup 桶单元构造单行 upsert。
upsertRollupSQL(t tables) string
// upsertCompactionWatermarkSQL builds a single-row upsert for the metric's
// persisted compaction watermark.
upsertCompactionWatermarkSQL(t tables) string
// compactTxOptions returns the transaction options used for the compaction
// transaction. PostgreSQL/MySQL escalate to SERIALIZABLE so the raw scan and
// the raw deletion share one snapshot and a point inserted between them
// cannot be deleted without first being rolled up. SQLite returns nil
// (its default) because a single connection already serializes writes.
//
// compactTxOptions 返回 compaction 事务使用的事务选项。PostgreSQL/MySQL 会
// 提升到 SERIALIZABLE,使 raw 扫描和 raw 删除共享同一个快照,让两者之间写入
// 的点不会在尚未进入 rollup 前被删除。SQLite 返回 nil(默认),因为单连接
// 已经串行化写入。
compactTxOptions() *sql.TxOptions
}
// tables stores the physical table names used by a Store.
@@ -66,17 +46,19 @@ type tables struct {
//
// definitions 是指标定义表。
definitions string
// points is the raw points table.
//
// points 是原始采样点表。
// points and watermarks name legacy tables read only by the explicit
// structure rebuild. The normalized schema never creates either table.
points string
// series interns the name/entity/tag/tag-hash tuple.
series string
// resolutions interns rollup intervals in milliseconds.
resolutions string
// labels interns labels independently from series tags.
labels string
// rollups is the downsampled rollups table.
//
// rollups 是降采样 rollup 表。
rollups string
// watermarks stores the last successfully compacted raw boundary per metric.
//
// watermarks 保存每个指标最近一次成功压缩到的 raw 边界。
rollups string
watermarks string
}
@@ -129,7 +111,7 @@ func (sqliteDialect) nowExpr() string { return "CURRENT_TIMESTAMP" }
// insertDefinitionSQL 构造 SQLite 指标定义 upsert SQL。
func (sqliteDialect) insertDefinitionSQL(t tables) string {
return fmt.Sprintf(`INSERT INTO %s
(name, type, unit, description, retention_days, metadata, created_at, updated_at)
(name, type, unit, description, retention_days, metadata, created_at_milli, updated_at_milli)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
type = excluded.type,
@@ -137,28 +119,7 @@ func (sqliteDialect) insertDefinitionSQL(t tables) string {
description = excluded.description,
retention_days = excluded.retention_days,
metadata = excluded.metadata,
updated_at = excluded.updated_at`, t.definitions)
}
// upsertPointSQL builds SQL for inserting or updating metric points.
//
// upsertPointSQL 构造 SQLite 采样点批量 upsert SQL。
func (sqliteDialect) upsertPointSQL(t tables, rowCount int) string {
return buildInsertPointsSQL(t.points, sqliteDialect{}, rowCount, `ON CONFLICT(metric_name, entity_id, tags_hash, ts_nano) DO UPDATE SET
value = excluded.value,
tags = excluded.tags,
labels = excluded.labels,
created_at = excluded.created_at`)
}
// upsertCompactionWatermarkSQL builds the SQLite watermark upsert.
func (sqliteDialect) upsertCompactionWatermarkSQL(t tables) string {
return fmt.Sprintf(`INSERT INTO %s
(metric_name, watermark_nano, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(metric_name) DO UPDATE SET
watermark_nano = excluded.watermark_nano,
updated_at = excluded.updated_at`, t.watermarks)
updated_at_milli = excluded.updated_at_milli`, t.definitions)
}
// mysqlDialect implements SQL rendering for MySQL.
@@ -196,7 +157,7 @@ func (mysqlDialect) nowExpr() string { return "CURRENT_TIMESTAMP" }
// insertDefinitionSQL 构造 MySQL 指标定义 upsert SQL。
func (mysqlDialect) insertDefinitionSQL(t tables) string {
return fmt.Sprintf(`INSERT INTO %s
(name, type, unit, description, retention_days, metadata, created_at, updated_at)
(name, type, unit, description, retention_days, metadata, created_at_milli, updated_at_milli)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
type = VALUES(type),
@@ -204,28 +165,7 @@ func (mysqlDialect) insertDefinitionSQL(t tables) string {
description = VALUES(description),
retention_days = VALUES(retention_days),
metadata = VALUES(metadata),
updated_at = VALUES(updated_at)`, t.definitions)
}
// upsertPointSQL builds SQL for inserting or updating metric points.
//
// upsertPointSQL 构造 MySQL 采样点批量 upsert SQL。
func (mysqlDialect) upsertPointSQL(t tables, rowCount int) string {
return buildInsertPointsSQL(t.points, mysqlDialect{}, rowCount, `ON DUPLICATE KEY UPDATE
value = VALUES(value),
tags = VALUES(tags),
labels = VALUES(labels),
created_at = VALUES(created_at)`)
}
// upsertCompactionWatermarkSQL builds the MySQL watermark upsert.
func (mysqlDialect) upsertCompactionWatermarkSQL(t tables) string {
return fmt.Sprintf(`INSERT INTO %s
(metric_name, watermark_nano, updated_at)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE
watermark_nano = VALUES(watermark_nano),
updated_at = VALUES(updated_at)`, t.watermarks)
updated_at_milli = VALUES(updated_at_milli)`, t.definitions)
}
// postgresDialect implements SQL rendering for PostgreSQL.
@@ -263,7 +203,7 @@ func (postgresDialect) nowExpr() string { return "CURRENT_TIMESTAMP" }
// insertDefinitionSQL 构造 PostgreSQL 指标定义 upsert SQL。
func (postgresDialect) insertDefinitionSQL(t tables) string {
return fmt.Sprintf(`INSERT INTO %s
(name, type, unit, description, retention_days, metadata, created_at, updated_at)
(name, type, unit, description, retention_days, metadata, created_at_milli, updated_at_milli)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8)
ON CONFLICT(name) DO UPDATE SET
type = EXCLUDED.type,
@@ -271,62 +211,7 @@ func (postgresDialect) insertDefinitionSQL(t tables) string {
description = EXCLUDED.description,
retention_days = EXCLUDED.retention_days,
metadata = EXCLUDED.metadata,
updated_at = EXCLUDED.updated_at`, t.definitions)
}
// upsertPointSQL builds SQL for inserting or updating metric points.
//
// upsertPointSQL 构造 PostgreSQL 采样点批量 upsert SQL。
func (postgresDialect) upsertPointSQL(t tables, rowCount int) string {
return buildInsertPointsSQL(t.points, postgresDialect{}, rowCount, `ON CONFLICT(metric_name, entity_id, tags_hash, ts_nano) DO UPDATE SET
value = EXCLUDED.value,
tags = EXCLUDED.tags,
labels = EXCLUDED.labels,
created_at = EXCLUDED.created_at`)
}
// upsertCompactionWatermarkSQL builds the PostgreSQL watermark upsert.
func (postgresDialect) upsertCompactionWatermarkSQL(t tables) string {
return fmt.Sprintf(`INSERT INTO %s
(metric_name, watermark_nano, updated_at)
VALUES ($1, $2, $3)
ON CONFLICT(metric_name) DO UPDATE SET
watermark_nano = EXCLUDED.watermark_nano,
updated_at = EXCLUDED.updated_at`, t.watermarks)
}
// buildInsertPointsSQL builds a multi-row insert statement for metric points.
//
// buildInsertPointsSQL 构造多行采样点 INSERT/UPSERT SQL。
func buildInsertPointsSQL(table string, d dialect, rowCount int, suffix string) string {
var b strings.Builder
b.WriteString("INSERT INTO ")
b.WriteString(table)
b.WriteString(" (metric_name, entity_id, tags_hash, ts_nano, value, tags, labels, created_at) VALUES ")
arg := 1
rows := make([]string, rowCount)
for i := 0; i < rowCount; i++ {
parts := make([]string, 8)
for j := range parts {
// Columns are (metric_name, entity_id, tags_hash, ts_nano, value, tags,
// labels, created_at); positions 5 and 6 (0-indexed) are JSON values.
// tags_hash makes the tag set part of a point's identity, so points
// that share entity+timestamp but differ in tags no longer collide.
if j == 5 || j == 6 {
parts[j] = d.jsonPlaceholder(arg)
} else {
parts[j] = d.placeholder(arg)
}
arg++
}
rows[i] = "(" + strings.Join(parts, ", ") + ")"
}
b.WriteString(strings.Join(rows, ", "))
if suffix != "" {
b.WriteByte(' ')
b.WriteString(suffix)
}
return b.String()
updated_at_milli = EXCLUDED.updated_at_milli`, t.definitions)
}
// tableName combines a table prefix and logical table name.
@@ -343,7 +228,7 @@ func tableName(prefix, name string) string {
// insertDefinitionOnlySQL 构造普通 INSERT(不带 upsert),供
// CreateMetric 保持“只创建”语义;重复名称会暴露为唯一约束错误。
func insertDefinitionOnlySQL(d dialect, t tables) string {
cols := "(name, type, unit, description, retention_days, metadata, created_at, updated_at)"
cols := "(name, type, unit, description, retention_days, metadata, created_at_milli, updated_at_milli)"
ph := []string{
d.placeholder(1),
d.placeholder(2),
+3 -157
View File
@@ -1,160 +1,6 @@
package metric
import (
"database/sql"
"fmt"
"strings"
)
// rollupColumns is the fixed column list for the rollups table, shared by the
// upsert builder and the row scanner so they cannot drift apart. tags_hash is a
// stable fingerprint of the (canonical) tag set; tags carries the tag map itself
// so reads can return it and tag filters can be pushed down. Including the tag
// dimension in the key is what keeps each distinct tag combination (e.g. a GPU
// device_index) as its own rollup series instead of being merged together.
//
// rollupColumns 是 rollups 表固定列清单,由 upsert 构造器和行扫描器共用,
// 避免二者漂移。tags_hash 是(规范化)标签集合的稳定指纹;tags 携带标签 map
// 本身,因此读取时可以返回它,也能下推标签过滤。把标签维度纳入 key,正是让
// 每个不同标签组合(例如 GPU 的 device_index)保留为自己的 rollup 序列、
// 而不是被合并在一起的原因。
const rollupColumns = "metric_name, entity_id, tags_hash, tags, resolution_nano, bucket_nano, " +
"count, sum, sum_sq, min_val, max_val, first_val, first_ts, last_val, last_ts, digest, created_at"
// rollupColumnCount is the number of columns in rollupColumns.
//
// rollupColumnCount 是 rollupColumns 中的列数量。
const rollupColumnCount = 17
// rollupTagsArgIndex is the 1-based position of the `tags` JSON column in
// rollupColumns. PostgreSQL needs a ::jsonb cast on that bind placeholder.
//
// rollupTagsArgIndex 是 tags JSON 列在 rollupColumns 中的 1 基位置;
// PostgreSQL 需要在对应占位符上追加 ::jsonb 转换。
const rollupTagsArgIndex = 4
// blobType returns the column type used to store the t-digest sketch.
//
// blobType 返回用于保存 t-digest 摘要的二进制列类型。
func (sqliteDialect) blobType() string { return "BLOB" }
// blobType returns the binary column type used for t-digest blobs.
//
// blobType 返回 MySQL 用于保存 t-digest 摘要的二进制列类型。
func (mysqlDialect) blobType() string { return "LONGBLOB" }
// blobType returns the binary column type used for t-digest blobs.
//
// blobType 返回 PostgreSQL 用于保存 t-digest 摘要的二进制列类型。
// blobType returns the backend-specific column type for t-digest sketches.
func (sqliteDialect) blobType() string { return "BLOB" }
func (mysqlDialect) blobType() string { return "LONGBLOB" }
func (postgresDialect) blobType() string { return "BYTEA" }
// compactTxOptions returns nil so SQLite uses its default isolation. A single
// write connection already serializes the compaction transaction against
// concurrent writers, so the raw scan and the raw deletion observe a stable
// view without escalating the isolation level.
//
// compactTxOptions 返回 nil,让 SQLite 使用默认隔离级别。单个写连接已经把
// compaction 事务与并发写入串行化,因此 raw 扫描和 raw 删除无需提升隔离级别
// 即可观察到稳定视图。
func (sqliteDialect) compactTxOptions() *sql.TxOptions { return nil }
// compactTxOptions escalates to SERIALIZABLE so a point inserted between the raw
// scan and the raw deletion cannot be deleted without first being rolled up.
// InnoDB's SERIALIZABLE turns the scan into a locking read that blocks such a
// concurrent insert in the scanned range until the compaction commits.
//
// compactTxOptions 提升到 SERIALIZABLE,使在 raw 扫描和 raw 删除之间写入的点
// 不会在尚未进入 rollup 前被删除。InnoDB 的 SERIALIZABLE 会把扫描变为加锁读,
// 在 compaction 提交前阻塞扫描范围内的此类并发插入。
func (mysqlDialect) compactTxOptions() *sql.TxOptions {
return &sql.TxOptions{Isolation: sql.LevelSerializable}
}
// compactTxOptions escalates to SERIALIZABLE so the raw scan and the raw
// deletion share one snapshot. A concurrent insert into the scanned range that
// would otherwise be deleted without a rollup instead triggers a serialization
// failure that CompactMetric retries on a fresh snapshot.
//
// compactTxOptions 提升到 SERIALIZABLE,使 raw 扫描和 raw 删除共享同一个快照。
// 对扫描范围内的并发插入(否则会被删除却没有进入 rollup)会触发序列化失败,
// 由 CompactMetric 在新快照上重试。
func (postgresDialect) compactTxOptions() *sql.TxOptions {
return &sql.TxOptions{Isolation: sql.LevelSerializable}
}
// upsertRollupSQL builds a single-row upsert for a rollup cell, keyed by
// (metric_name, entity_id, tags_hash, resolution_nano, bucket_nano). Compact
// recomputes a bucket wholesale from its inputs each run, so the conflict action
// replaces every summary column rather than trying to merge in SQL.
//
// upsertRollupSQL 构造单个 rollup 单元的 upsert SQL,其 key 为
// (metric_name, entity_id, tags_hash, resolution_nano, bucket_nano)。Compact
// 每次都会从输入整体重新计算一个桶,因此冲突动作会替换每个摘要列,
// 而不是尝试在 SQL 中合并。
func (d sqliteDialect) upsertRollupSQL(t tables) string {
return buildUpsertRollupSQL(t.rollups, d, sqliteRollupConflict)
}
// upsertRollupSQL builds backend-specific SQL for upserting a rollup cell.
//
// upsertRollupSQL 构造 MySQL rollup 单元 upsert SQL。
func (d mysqlDialect) upsertRollupSQL(t tables) string {
return buildUpsertRollupSQL(t.rollups, d, mysqlRollupConflict)
}
// upsertRollupSQL builds backend-specific SQL for upserting a rollup cell.
//
// upsertRollupSQL 构造 PostgreSQL rollup 单元 upsert SQL。
func (d postgresDialect) upsertRollupSQL(t tables) string {
return buildUpsertRollupSQL(t.rollups, d, postgresRollupConflict)
}
// sqliteRollupConflict is the SQLite conflict clause for replacing rollup cells.
//
// sqliteRollupConflict 是 SQLite 用于替换 rollup 单元的冲突处理子句。
const sqliteRollupConflict = `ON CONFLICT(metric_name, entity_id, tags_hash, resolution_nano, bucket_nano) DO UPDATE SET
tags = excluded.tags,
count = excluded.count, sum = excluded.sum, sum_sq = excluded.sum_sq,
min_val = excluded.min_val, max_val = excluded.max_val,
first_val = excluded.first_val, first_ts = excluded.first_ts,
last_val = excluded.last_val, last_ts = excluded.last_ts,
digest = excluded.digest, created_at = excluded.created_at`
// postgresRollupConflict is the PostgreSQL conflict clause for replacing rollup cells.
//
// postgresRollupConflict 是 PostgreSQL 用于替换 rollup 单元的冲突处理子句。
const postgresRollupConflict = `ON CONFLICT(metric_name, entity_id, tags_hash, resolution_nano, bucket_nano) DO UPDATE SET
tags = EXCLUDED.tags,
count = EXCLUDED.count, sum = EXCLUDED.sum, sum_sq = EXCLUDED.sum_sq,
min_val = EXCLUDED.min_val, max_val = EXCLUDED.max_val,
first_val = EXCLUDED.first_val, first_ts = EXCLUDED.first_ts,
last_val = EXCLUDED.last_val, last_ts = EXCLUDED.last_ts,
digest = EXCLUDED.digest, created_at = EXCLUDED.created_at`
// mysqlRollupConflict is the MySQL conflict clause for replacing rollup cells.
//
// mysqlRollupConflict 是 MySQL 用于替换 rollup 单元的冲突处理子句。
const mysqlRollupConflict = `ON DUPLICATE KEY UPDATE
tags = VALUES(tags),
count = VALUES(count), sum = VALUES(sum), sum_sq = VALUES(sum_sq),
min_val = VALUES(min_val), max_val = VALUES(max_val),
first_val = VALUES(first_val), first_ts = VALUES(first_ts),
last_val = VALUES(last_val), last_ts = VALUES(last_ts),
digest = VALUES(digest), created_at = VALUES(created_at)`
// buildUpsertRollupSQL builds a single-row rollup upsert statement.
//
// buildUpsertRollupSQL 构造单行 rollup upsert SQL。
func buildUpsertRollupSQL(table string, d dialect, conflict string) string {
ph := make([]string, rollupColumnCount)
for i := 0; i < rollupColumnCount; i++ {
if i+1 == rollupTagsArgIndex {
// The tags column is JSON; PostgreSQL requires an explicit ::jsonb cast.
ph[i] = d.jsonPlaceholder(i + 1)
} else {
ph[i] = d.placeholder(i + 1)
}
}
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s) %s",
table, rollupColumns, strings.Join(ph, ", "), conflict)
}
+7 -14
View File
@@ -1,9 +1,6 @@
package metric
import (
"strings"
"testing"
)
import "testing"
// TestDialectsGenerateBackendSpecificSQL verifies backend-specific SQL rendering.
//
@@ -13,29 +10,29 @@ func TestDialectsGenerateBackendSpecificSQL(t *testing.T) {
name string
driver Driver
placeholder string
upsert string
jsonType string
blobType string
}{
{
name: "sqlite",
driver: DriverSQLite,
placeholder: "?",
upsert: "ON CONFLICT(metric_name, entity_id, tags_hash, ts_nano)",
jsonType: "TEXT",
blobType: "BLOB",
},
{
name: "mysql",
driver: DriverMySQL,
placeholder: "?",
upsert: "ON DUPLICATE KEY UPDATE",
jsonType: "JSON",
blobType: "LONGBLOB",
},
{
name: "postgresql",
driver: DriverPostgreSQL,
placeholder: "$1",
upsert: "ON CONFLICT(metric_name, entity_id, tags_hash, ts_nano)",
jsonType: "JSONB",
blobType: "BYTEA",
},
}
@@ -48,12 +45,8 @@ func TestDialectsGenerateBackendSpecificSQL(t *testing.T) {
if got := d.jsonType(); got != tt.jsonType {
t.Fatalf("json type: expected %q, got %q", tt.jsonType, got)
}
sql := d.upsertPointSQL(tables{points: "metric_points"}, 2)
if !strings.Contains(sql, "metric_points") {
t.Fatalf("upsert sql should reference metric_points: %s", sql)
}
if !strings.Contains(sql, tt.upsert) {
t.Fatalf("upsert sql should contain %q: %s", tt.upsert, sql)
if got := d.blobType(); got != tt.blobType {
t.Fatalf("blob type: expected %q, got %q", tt.blobType, got)
}
})
}
+4 -4
View File
@@ -20,7 +20,7 @@ func Example_rollupTags() {
store, err := metric.Open(ctx, metric.SQLite(
"file:metric-example?mode=memory&cache=shared",
metric.WithRollupPolicy(metric.RollupPolicy{
RawRetention: 2 * time.Minute,
RawRetention: 10 * time.Minute,
Tiers: []metric.RollupTier{
{Interval: time.Minute, Retention: 24 * time.Hour},
},
@@ -63,8 +63,8 @@ func Example_rollupTags() {
log.Fatal(err)
}
// Compact builds one rollup series per tag set. Raw points older than the
// policy's RawRetention are deleted after their rollups are materialized.
// Compact builds one rollup series per tag set. Exact raw points stay only
// in the Store's fixed ten-minute memory window.
now := base.Add(time.Hour)
if _, err := store.Compact(ctx, now); err != nil {
log.Fatal(err)
@@ -104,7 +104,7 @@ func Example_agentFleetLifecycle() {
store, err := metric.Open(ctx, metric.SQLite(
"file:metric-agent-fleet?mode=memory&cache=shared",
metric.WithRollupPolicy(metric.RollupPolicy{
RawRetention: 2 * time.Minute,
RawRetention: 10 * time.Minute,
Tiers: []metric.RollupTier{
{Interval: time.Minute, Retention: 24 * time.Hour},
{Interval: 5 * time.Minute, Retention: 30 * 24 * time.Hour},
+290
View File
@@ -0,0 +1,290 @@
package metric
import (
"context"
"sort"
"time"
)
// hotRollupKey identifies one active minute bucket. The dictionary dimensions
// remain compact strings; full tag and label JSON lives only on the bucket.
type hotRollupKey struct {
metricName string
entityID string
tagsHash string
labelsHash string
bucket int64
}
type hotRollup struct {
key hotRollupKey
bucket *rollupBucket
replace bool
}
func (s *Store) writePreparedHotRollups(ctx context.Context, prepared []preparedMetricPoint, now time.Time, rebuild map[hotRollupKey]struct{}) error {
minuteMillis := time.Minute.Milliseconds()
compression := s.cfg.RollupPolicy.compression()
if len(rebuild) > 0 {
s.rollupViewMu.Lock()
defer s.rollupViewMu.Unlock()
}
s.hotMu.Lock()
for _, point := range prepared {
key := hotRollupKey{
metricName: point.metricName,
entityID: point.entityID,
tagsHash: point.tagsHash,
labelsHash: point.labelsHash,
bucket: bucketStartMillis(point.timestamp, minuteMillis),
}
bucket := s.hot[key]
if bucket == nil {
bucket = newRollupBucket(compression)
bucket.tagsHash, bucket.tagsJSON = point.tagsHash, point.tagsJSON
bucket.labelsHash, bucket.labelsJSON = point.labelsHash, point.labelsJSON
s.hot[key] = bucket
}
bucket.addPoint(point.value, point.timestamp)
}
if len(rebuild) > 0 {
s.rebuildHotRollupsLocked(rebuild, compression)
}
s.hotMu.Unlock()
var err error
if len(rebuild) > 0 {
_, err = s.flushClosedHotRollupsUnderView(ctx, now)
} else {
_, err = s.flushClosedHotRollups(ctx, now)
}
return err
}
func (s *Store) rebuildHotRollupsLocked(keys map[hotRollupKey]struct{}, compression float64) {
s.rawMu.RLock()
defer s.rawMu.RUnlock()
minuteMillis := time.Minute.Milliseconds()
for key := range keys {
series := s.raw[rawSeriesKey{metricName: key.metricName, entityID: key.entityID, tagsHash: key.tagsHash}]
bucket := newRollupBucket(compression)
bucket.tagsHash = key.tagsHash
bucket.labelsHash = key.labelsHash
if series != nil {
bucket.tagsJSON = series.tagsJSON
if labelID, ok := series.labelIDs[key.labelsHash]; ok {
bucket.labelsJSON = series.labelsJSON[labelID]
}
for _, sample := range series.allSamples() {
if bucketStartMillis(sample.timestamp, minuteMillis) != key.bucket || series.labelHashes[sample.labelID] != key.labelsHash {
continue
}
bucket.labelsJSON = series.labelsJSON[sample.labelID]
bucket.addPoint(sample.value, sample.timestamp)
}
}
s.hot[key] = bucket
s.hotReplace[key] = struct{}{}
}
}
func (s *Store) flushClosedHotRollups(ctx context.Context, now time.Time) (int, error) {
s.rollupViewMu.Lock()
defer s.rollupViewMu.Unlock()
return s.flushClosedHotRollupsUnderView(ctx, now)
}
func (s *Store) flushClosedHotRollupsUnderView(ctx context.Context, now time.Time) (int, error) {
flushBefore := bucketStartMillis(now.UTC().UnixMilli(), time.Minute.Milliseconds())
closed := s.takeClosedHotRollups(flushBefore)
if len(closed) == 0 {
return 0, nil
}
if err := s.persistHotRollups(ctx, closed); err != nil {
s.restoreHotRollups(closed)
return 0, err
}
return len(closed), nil
}
func (s *Store) flushAllHotRollups(ctx context.Context) error {
s.rollupViewMu.Lock()
defer s.rollupViewMu.Unlock()
s.hotMu.Lock()
all := make([]hotRollup, 0, len(s.hot))
for key, bucket := range s.hot {
_, replace := s.hotReplace[key]
all = append(all, hotRollup{key: key, bucket: bucket, replace: replace})
delete(s.hot, key)
delete(s.hotReplace, key)
}
s.hotMu.Unlock()
if len(all) == 0 {
return nil
}
if err := s.persistHotRollups(ctx, all); err != nil {
s.restoreHotRollups(all)
return err
}
return nil
}
func (s *Store) takeClosedHotRollups(currentBucket int64) []hotRollup {
s.hotMu.Lock()
defer s.hotMu.Unlock()
closed := make([]hotRollup, 0)
for key, bucket := range s.hot {
if key.bucket < currentBucket {
_, replace := s.hotReplace[key]
closed = append(closed, hotRollup{key: key, bucket: bucket, replace: replace})
delete(s.hot, key)
delete(s.hotReplace, key)
}
}
return closed
}
func (s *Store) restoreHotRollups(closed []hotRollup) {
s.hotMu.Lock()
defer s.hotMu.Unlock()
for _, item := range closed {
current, exists := s.hot[item.key]
_, currentReplaces := s.hotReplace[item.key]
if !exists {
s.hot[item.key] = item.bucket
if item.replace {
s.hotReplace[item.key] = struct{}{}
}
continue
}
if currentReplaces {
continue
}
if item.replace {
item.bucket.mergeStored(current)
s.hot[item.key] = item.bucket
s.hotReplace[item.key] = struct{}{}
continue
}
current.mergeStored(item.bucket)
}
}
func (s *Store) persistHotRollups(ctx context.Context, closed []hotRollup) error {
type metricBuckets struct {
merge map[rollupKey]*rollupBucket
replace map[rollupKey]*rollupBucket
}
byMetric := make(map[string]*metricBuckets)
for _, item := range closed {
buckets := byMetric[item.key.metricName]
if buckets == nil {
buckets = &metricBuckets{merge: make(map[rollupKey]*rollupBucket), replace: make(map[rollupKey]*rollupBucket)}
byMetric[item.key.metricName] = buckets
}
key := rollupKey{entityID: item.key.entityID, tagsHash: item.key.tagsHash, labelsHash: item.key.labelsHash, bucket: item.key.bucket}
if item.replace {
buckets.replace[key] = item.bucket
} else {
buckets.merge[key] = item.bucket
}
}
names := make([]string, 0, len(byMetric))
for name := range byMetric {
names = append(names, name)
}
sort.Strings(names)
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
for _, name := range names {
buckets := byMetric[name]
if _, err := s.writeTierCascadeTx(ctx, name, buckets.merge, tx); err != nil {
return err
}
if err := s.replaceMinuteRollupsTx(ctx, name, buckets.replace, tx); err != nil {
return err
}
}
return tx.Commit()
}
func (s *Store) hotRollupRows(metricName, entityID string, tags map[string]string, start, end time.Time, needDigest bool) ([]storedRollup, error) {
startMilli, endMilli := start.UTC().UnixMilli(), end.UTC().UnixMilli()
s.hotMu.RLock()
defer s.hotMu.RUnlock()
out := make([]storedRollup, 0)
for key, bucket := range s.hot {
if key.metricName != metricName || key.bucket+time.Minute.Milliseconds() <= startMilli || key.bucket > endMilli || (entityID != "" && key.entityID != entityID) {
continue
}
if bucket.count == 0 {
continue
}
bucketTags, err := rollupTagsFromJSON(bucket.tagsJSON)
if err != nil {
return nil, err
}
matched := true
for name, value := range tags {
if bucketTags[name] != value {
matched = false
break
}
}
if !matched {
continue
}
out = append(out, storedRollup{entityID: key.entityID, bucket: key.bucket, bucketData: bucket.clone(needDigest)})
}
sort.Slice(out, func(i, j int) bool { return out[i].bucket < out[j].bucket })
return out, nil
}
// deleteHotRollups removes active minute buckets matched by the same series
// dimensions used by persisted deletes. A non-nil cutoff removes only buckets
// whose minute starts before it.
func (s *Store) deleteHotRollups(metricName, entityID string, tags map[string]string, cutoff *time.Time) (int64, error) {
s.hotMu.Lock()
defer s.hotMu.Unlock()
var cutoffMilli int64
if cutoff != nil {
cutoffMilli = cutoff.UTC().UnixMilli()
}
matched := make([]hotRollupKey, 0)
for key, bucket := range s.hot {
if metricName != "" && key.metricName != metricName {
continue
}
if entityID != "" && key.entityID != entityID {
continue
}
if cutoff != nil && key.bucket >= cutoffMilli {
continue
}
if len(tags) > 0 {
bucketTags, err := rollupTagsFromJSON(bucket.tagsJSON)
if err != nil {
return 0, err
}
matches := true
for name, value := range tags {
if bucketTags[name] != value {
matches = false
break
}
}
if !matches {
continue
}
}
matched = append(matched, key)
}
for _, key := range matched {
delete(s.hot, key)
delete(s.hotReplace, key)
}
return int64(len(matched)), nil
}
+771
View File
@@ -0,0 +1,771 @@
package metric
import (
"context"
"fmt"
"sync"
"testing"
"time"
)
func TestWriteBatchKeepsRawInMemoryAndBuildsMinuteRollup(t *testing.T) {
ctx := context.Background()
s, err := Open(ctx, SQLite(":memory:", WithRollupPolicy(RollupPolicy{
RawRetention: time.Minute,
Tiers: []RollupTier{{Interval: time.Minute, Retention: time.Hour}},
Compression: 30,
})))
if err != nil {
t.Fatalf("open store: %v", err)
}
defer s.Close()
if err := s.CreateMetric(ctx, Definition{Name: "hot", RetentionDays: 1}); err != nil {
t.Fatalf("create metric: %v", err)
}
base := time.Now().UTC().Truncate(time.Minute)
if err := s.WriteBatch(ctx, []Point{
{MetricName: "hot", EntityID: "n1", Timestamp: base.Add(10 * time.Second), Value: 10},
{MetricName: "hot", EntityID: "n1", Timestamp: base.Add(20 * time.Second), Value: 20},
}); err != nil {
t.Fatalf("write samples: %v", err)
}
var rawTableCount int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, s.tables.points).Scan(&rawTableCount); err != nil {
t.Fatalf("inspect points table: %v", err)
}
if rawTableCount != 0 {
t.Fatalf("metric_points table count = %d, want 0", rawTableCount)
}
points, err := s.Query(ctx, Query{MetricName: "hot", EntityID: "n1", Start: base, End: base.Add(time.Minute)})
if err != nil {
t.Fatalf("query minute rollup: %v", err)
}
if len(points) != 2 || points[0].Value != 10 || points[1].Value != 20 {
t.Fatalf("points = %#v, want both raw samples", points)
}
series, err := s.Series(ctx, AggregateQuery{
Query: Query{MetricName: "hot", EntityID: "n1", Start: base, End: base.Add(time.Minute)},
Aggregation: AggAvg, Interval: time.Minute, PreserveSeries: true,
}, base.Add(2*time.Minute))
if err != nil {
t.Fatalf("query minute rollup: %v", err)
}
if len(series) != 1 || series[0].Count != 2 || series[0].Value != 15 {
t.Fatalf("minute rollup = %#v, want avg=15 count=2", series)
}
}
func TestClosedMinutePersistsAndAcceptsExactUpsert(t *testing.T) {
ctx := context.Background()
s, err := Open(ctx, SQLite(":memory:", WithRollupPolicy(RollupPolicy{
RawRetention: time.Minute,
Tiers: []RollupTier{{Interval: time.Minute, Retention: time.Hour}},
Compression: 30,
})))
if err != nil {
t.Fatalf("open store: %v", err)
}
defer s.Close()
if err := s.CreateMetric(ctx, Definition{Name: "late-upsert", RetentionDays: 1}); err != nil {
t.Fatalf("create metric: %v", err)
}
now := time.Date(2026, 7, 27, 12, 0, 10, 0, time.UTC)
at := now.Add(-20 * time.Second)
for _, value := range []float64{1, 2} {
prepared, err := prepareMetricPoints([]Point{{MetricName: "late-upsert", EntityID: "n1", Timestamp: at, Value: value}})
if err != nil {
t.Fatalf("prepare point: %v", err)
}
rebuild := s.writeRawPointsAt(prepared, now)
if err := s.writePreparedHotRollups(ctx, prepared, now, rebuild); err != nil {
t.Fatalf("write hot rollup: %v", err)
}
}
var persisted int
if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+s.tables.rollups).Scan(&persisted); err != nil {
t.Fatalf("count early persisted rollups: %v", err)
}
if persisted != 1 {
t.Fatalf("persisted closed-minute rollups = %d, want 1", persisted)
}
series, err := s.Series(ctx, AggregateQuery{
Query: Query{MetricName: "late-upsert", EntityID: "n1", Start: at.Add(-time.Minute), End: now},
Aggregation: AggCount, Interval: time.Minute, PreserveSeries: true,
}, now)
if err != nil {
t.Fatalf("query hot rollup: %v", err)
}
if len(series) != 1 || series[0].Count != 1 || series[0].Value != 1 {
t.Fatalf("late upsert was counted twice: %#v", series)
}
var count int
if err := s.db.QueryRowContext(ctx, "SELECT count FROM "+s.tables.rollups).Scan(&count); err != nil {
t.Fatalf("read persisted rollup: %v", err)
}
if count != 1 {
t.Fatalf("persisted rollup count = %d, want 1", count)
}
}
func TestRawWindowKeepsCompressedAndDirectSamples(t *testing.T) {
ctx := context.Background()
now := time.Now().UTC()
s, err := Open(ctx, SQLite(":memory:", WithRollupPolicy(RollupPolicy{
RawRetention: time.Minute,
Tiers: []RollupTier{{Interval: time.Minute, Retention: time.Hour}},
Compression: 30,
})))
if err != nil {
t.Fatalf("open store: %v", err)
}
defer s.Close()
if err := s.CreateMetric(ctx, Definition{Name: "raw-window", RetentionDays: 1}); err != nil {
t.Fatalf("create metric: %v", err)
}
if err := s.WriteBatch(ctx, []Point{
{MetricName: "raw-window", EntityID: "n1", Timestamp: now.Add(-70 * time.Second), Value: 1},
{MetricName: "raw-window", EntityID: "n1", Timestamp: now.Add(-50 * time.Second), Value: 2},
{MetricName: "raw-window", EntityID: "n1", Timestamp: now.Add(-40 * time.Second), Value: 3},
}); err != nil {
t.Fatalf("write samples: %v", err)
}
if _, err := s.Compact(ctx, now); err != nil {
t.Fatalf("compact: %v", err)
}
raw, err := s.Query(ctx, Query{MetricName: "raw-window", EntityID: "n1", Start: now.Add(-time.Hour), End: now})
if err != nil {
t.Fatalf("query raw: %v", err)
}
if len(raw) != 3 || raw[0].Value != 1 || raw[1].Value != 2 || raw[2].Value != 3 {
t.Fatalf("retained raw = %#v, want all ten-minute samples", raw)
}
series, err := s.Series(ctx, AggregateQuery{
Query: Query{MetricName: "raw-window", EntityID: "n1", Start: now.Add(-time.Hour), End: now},
Aggregation: AggCount, Interval: time.Minute, PreserveSeries: true,
}, now)
if err != nil {
t.Fatalf("query rollup: %v", err)
}
var count int
for _, point := range series {
count += point.Count
}
if count != 3 {
t.Fatalf("rollups lost samples after raw cleanup: %#v", series)
}
}
func TestOldestRawMinuteRebuildKeepsHiddenPrefix(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{
RawRetention: rawMemoryRetention,
Tiers: []RollupTier{{Interval: time.Minute, Retention: time.Hour}},
Compression: 30,
}
s, err := Open(ctx, SQLite(":memory:", WithRollupPolicy(policy)))
if err != nil {
t.Fatal(err)
}
defer s.Close()
if err := s.CreateMetric(ctx, Definition{Name: "oldest-minute", RetentionDays: 1}); err != nil {
t.Fatal(err)
}
now := time.Date(2026, 7, 27, 12, 10, 30, 0, time.UTC)
oldestMinute := now.Add(-rawMemoryRetention).Truncate(time.Minute)
write := func(points ...Point) {
t.Helper()
prepared, err := prepareMetricPoints(points)
if err != nil {
t.Fatal(err)
}
rebuild := s.writeRawPointsAt(prepared, now)
if err := s.writePreparedHotRollups(ctx, prepared, now, rebuild); err != nil {
t.Fatal(err)
}
}
write(
Point{MetricName: "oldest-minute", EntityID: "n1", Timestamp: oldestMinute.Add(5 * time.Second), Value: 1},
Point{MetricName: "oldest-minute", EntityID: "n1", Timestamp: oldestMinute.Add(40 * time.Second), Value: 2},
)
write(Point{MetricName: "oldest-minute", EntityID: "n1", Timestamp: oldestMinute.Add(40 * time.Second), Value: 4})
rows, err := s.scanRollupRowsBetween(ctx, "oldest-minute", "n1", nil, time.Minute.Milliseconds(), oldestMinute.UnixMilli(), oldestMinute.UnixMilli(), false)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].bucketData.count != 2 || rows[0].bucketData.sum != 5 {
t.Fatalf("rebuilt oldest minute = %#v, want count=2 sum=5", rows)
}
}
func TestLateLabelReplacementRebuildsPersistedCascade(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{
RawRetention: rawMemoryRetention,
Tiers: []RollupTier{
{Interval: time.Minute, Retention: 24 * time.Hour},
{Interval: 5 * time.Minute, Retention: 7 * 24 * time.Hour},
{Interval: time.Hour, Retention: 30 * 24 * time.Hour},
{Interval: 24 * time.Hour, Retention: 365 * 24 * time.Hour},
},
Compression: 30,
}
s, err := Open(ctx, SQLite(":memory:", WithRollupPolicy(policy)))
if err != nil {
t.Fatal(err)
}
defer s.Close()
if err := s.CreateMetric(ctx, Definition{Name: "cascade-replace", RetentionDays: 30}); err != nil {
t.Fatal(err)
}
now := time.Date(2026, 7, 27, 12, 2, 10, 0, time.UTC)
at := now.Add(-90 * time.Second)
write := func(point Point) {
t.Helper()
prepared, err := prepareMetricPoints([]Point{point})
if err != nil {
t.Fatal(err)
}
rebuild := s.writeRawPointsAt(prepared, now)
if err := s.writePreparedHotRollups(ctx, prepared, now, rebuild); err != nil {
t.Fatal(err)
}
}
write(Point{MetricName: "cascade-replace", EntityID: "n1", Timestamp: at, Value: 1, Labels: map[string]string{"source": "old"}})
write(Point{MetricName: "cascade-replace", EntityID: "n1", Timestamp: at, Value: 7, Labels: map[string]string{"source": "new"}})
for _, tier := range policy.Tiers {
start := bucketStartMillis(at.UnixMilli(), tier.Interval.Milliseconds())
rows, err := s.scanRollupRowsBetween(ctx, "cascade-replace", "n1", nil, tier.Interval.Milliseconds(), start, start, true)
if err != nil {
t.Fatalf("scan %s tier: %v", tier.Interval, err)
}
if len(rows) != 1 || rows[0].bucketData.count != 1 || rows[0].bucketData.sum != 7 {
t.Fatalf("%s replacement rows = %#v, want one count=1 sum=7 row", tier.Interval, rows)
}
labels, err := decodeMapString(rows[0].bucketData.labelsJSON)
if err != nil || labels["source"] != "new" {
t.Fatalf("%s replacement labels = %#v, %v", tier.Interval, labels, err)
}
}
}
func TestFlushTrimsInactiveRawWindow(t *testing.T) {
ctx := context.Background()
s := newMemStore(t)
if err := s.CreateMetric(ctx, Definition{Name: "idle-raw", RetentionDays: 1}); err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
prepared, err := prepareMetricPoints([]Point{{MetricName: "idle-raw", EntityID: "n1", Timestamp: now.Add(-time.Minute), Value: 1}})
if err != nil {
t.Fatal(err)
}
rebuild := s.writeRawPointsAt(prepared, now)
if err := s.writePreparedHotRollups(ctx, prepared, now, rebuild); err != nil {
t.Fatal(err)
}
if _, err := s.Flush(ctx, now.Add(12*time.Minute)); err != nil {
t.Fatal(err)
}
s.rawMu.RLock()
defer s.rawMu.RUnlock()
if len(s.raw) != 0 {
t.Fatalf("inactive raw series retained after scheduled flush: %#v", s.raw)
}
}
func TestConcurrentHotPercentileQueriesUseDigestSnapshots(t *testing.T) {
ctx := context.Background()
s := newMemStore(t)
if err := s.CreateMetric(ctx, Definition{Name: "hot-percentile", RetentionDays: 1}); err != nil {
t.Fatal(err)
}
base := time.Now().UTC()
if err := s.Write(ctx, Point{MetricName: "hot-percentile", EntityID: "n1", Timestamp: base, Value: 1}); err != nil {
t.Fatal(err)
}
errCh := make(chan error, 2)
var workers sync.WaitGroup
workers.Add(2)
go func() {
defer workers.Done()
for i := 1; i <= 200; i++ {
if err := s.Write(ctx, Point{MetricName: "hot-percentile", EntityID: "n1", Timestamp: base.Add(time.Duration(i) * time.Millisecond), Value: float64(i)}); err != nil {
errCh <- err
return
}
}
}()
go func() {
defer workers.Done()
for i := 0; i < 200; i++ {
points, err := s.Series(ctx, AggregateQuery{
Query: Query{MetricName: "hot-percentile", EntityID: "n1", Start: base.Add(-time.Minute), End: base.Add(time.Minute)},
Aggregation: AggP95, Interval: time.Minute, PreserveSeries: true,
}, base)
if err != nil {
errCh <- err
return
}
if len(points) == 0 {
errCh <- fmt.Errorf("percentile query returned no points")
return
}
}
}()
workers.Wait()
close(errCh)
for err := range errCh {
t.Fatal(err)
}
}
func TestConcurrentPersistedReplacementQueriesStayConsistent(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{
RawRetention: rawMemoryRetention,
Tiers: []RollupTier{{Interval: time.Minute, Retention: time.Hour}},
Compression: 30,
}
s, err := Open(ctx, SQLite(":memory:", WithRollupPolicy(policy)))
if err != nil {
t.Fatal(err)
}
defer s.Close()
if err := s.CreateMetric(ctx, Definition{Name: "replacement-view", RetentionDays: 1}); err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
at := now.Add(-90 * time.Second)
if err := s.Write(ctx, Point{MetricName: "replacement-view", EntityID: "n1", Timestamp: at, Value: 1}); err != nil {
t.Fatal(err)
}
errCh := make(chan error, 2)
var workers sync.WaitGroup
workers.Add(2)
go func() {
defer workers.Done()
for i := 2; i <= 100; i++ {
if err := s.Write(ctx, Point{MetricName: "replacement-view", EntityID: "n1", Timestamp: at, Value: float64(i)}); err != nil {
errCh <- err
return
}
}
}()
go func() {
defer workers.Done()
for i := 0; i < 300; i++ {
points, err := s.Series(ctx, AggregateQuery{
Query: Query{MetricName: "replacement-view", EntityID: "n1", Start: at.Add(-time.Minute), End: now},
Aggregation: AggCount, Interval: time.Minute, PreserveSeries: true,
}, now)
if err != nil {
errCh <- err
return
}
if len(points) != 1 || points[0].Count != 1 || points[0].Value != 1 {
errCh <- fmt.Errorf("replacement query observed a split view: %#v", points)
return
}
}
}()
workers.Wait()
close(errCh)
for err := range errCh {
t.Fatal(err)
}
}
func TestRawQueryOffsetWithoutLimit(t *testing.T) {
ctx := context.Background()
s := newMemStore(t)
if err := s.CreateMetric(ctx, Definition{Name: "raw-page", RetentionDays: 1}); err != nil {
t.Fatalf("create metric: %v", err)
}
base := time.Now().UTC().Truncate(time.Second)
if err := s.WriteBatch(ctx, []Point{
{MetricName: "raw-page", EntityID: "n1", Timestamp: base, Value: 1},
{MetricName: "raw-page", EntityID: "n1", Timestamp: base.Add(time.Second), Value: 2},
{MetricName: "raw-page", EntityID: "n1", Timestamp: base.Add(2 * time.Second), Value: 3},
}); err != nil {
t.Fatalf("write samples: %v", err)
}
points, err := s.Query(ctx, Query{MetricName: "raw-page", Start: base, End: base.Add(2 * time.Second), Offset: 1})
if err != nil {
t.Fatalf("query offset: %v", err)
}
if len(points) != 2 || points[0].Value != 2 || points[1].Value != 3 {
t.Fatalf("offset raw points = %#v", points)
}
}
func TestSeriesMergesLabelsWithoutCollapsingRawShape(t *testing.T) {
ctx := context.Background()
s, err := Open(ctx, SQLite(":memory:", WithRollupPolicy(RollupPolicy{
Tiers: []RollupTier{{Interval: time.Minute, Retention: time.Hour}},
Compression: 30,
})))
if err != nil {
t.Fatalf("open store: %v", err)
}
defer s.Close()
if err := s.CreateMetric(ctx, Definition{Name: "labels", RetentionDays: 1}); err != nil {
t.Fatalf("create metric: %v", err)
}
base := time.Now().UTC().Add(-30 * time.Second)
if err := s.WriteBatch(ctx, []Point{
{MetricName: "labels", EntityID: "n1", Timestamp: base.Add(10 * time.Second), Value: 10, Tags: map[string]string{"disk": "sda"}, Labels: map[string]string{"source": "a"}},
{MetricName: "labels", EntityID: "n1", Timestamp: base.Add(20 * time.Second), Value: 20, Tags: map[string]string{"disk": "sda"}, Labels: map[string]string{"source": "b"}},
}); err != nil {
t.Fatalf("write labeled samples: %v", err)
}
series, err := s.Series(ctx, AggregateQuery{
Query: Query{MetricName: "labels", EntityID: "n1", Start: base, End: base.Add(time.Minute)},
Aggregation: AggAvg,
Interval: time.Minute,
PreserveSeries: true,
}, base.Add(2*time.Minute))
if err != nil {
t.Fatalf("aggregate labels: %v", err)
}
var count int
var sum float64
for _, point := range series {
count += point.Count
sum += point.Value * float64(point.Count)
}
if count != 2 || sum/float64(count) != 15 {
t.Fatalf("label sets split the public series: %#v", series)
}
points, err := s.Query(ctx, Query{MetricName: "labels", EntityID: "n1", Start: base, End: base.Add(time.Minute)})
if err != nil {
t.Fatalf("query labeled raw samples: %v", err)
}
if len(points) != 2 {
t.Fatalf("query lost independently indexed label sets: %#v", points)
}
}
func TestCoveringSeriesResolutionUsesFinestTierCoveringStart(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{
Tiers: []RollupTier{
{Interval: time.Minute, Retention: 10 * time.Hour},
{Interval: 5 * time.Minute, Retention: 50 * time.Hour},
{Interval: time.Hour, Retention: 25 * 24 * time.Hour},
{Interval: 24 * time.Hour, Retention: 100 * 365 * 24 * time.Hour},
},
Compression: 30,
}
s, err := Open(ctx, SQLite(":memory:", WithRollupPolicy(policy)))
if err != nil {
t.Fatalf("open store: %v", err)
}
defer s.Close()
if err := s.CreateMetric(ctx, Definition{Name: "coverage-tier", RetentionDays: 60}); err != nil {
t.Fatalf("create metric: %v", err)
}
tagsHash, tagsJSON, err := tagsFingerprint(nil)
if err != nil {
t.Fatalf("fingerprint tags: %v", err)
}
now := time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC)
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("begin seed transaction: %v", err)
}
for _, seed := range []struct {
interval time.Duration
at time.Time
}{
{interval: time.Minute, at: now.Add(-30 * time.Minute)},
{interval: 5 * time.Minute, at: now.Add(-7 * time.Hour)},
{interval: 5 * time.Minute, at: now.Add(-5 * time.Hour)},
{interval: time.Hour, at: now.Add(-30 * 24 * time.Hour)},
} {
bucket := newRollupBucket(policy.compression())
bucket.tagsHash, bucket.tagsJSON = tagsHash, tagsJSON
bucket.labelsHash, bucket.labelsJSON = emptyLabelsHash, "{}"
bucket.addPoint(1, seed.at.UnixMilli())
key := rollupKey{entityID: "n1", tagsHash: tagsHash, labelsHash: emptyLabelsHash, bucket: bucketStartMillis(seed.at.UnixMilli(), seed.interval.Milliseconds())}
if _, err := s.writeRollupBucketsTx(ctx, "coverage-tier", seed.interval, map[rollupKey]*rollupBucket{key: bucket}, tx); err != nil {
_ = tx.Rollback()
t.Fatalf("seed %s tier: %v", seed.interval, err)
}
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit seed tiers: %v", err)
}
resolution, err := s.coveringSeriesResolution(ctx, Query{MetricName: "coverage-tier", EntityID: "n1", Start: now.Add(-6 * time.Hour), End: now}, time.Minute)
if err != nil {
t.Fatalf("select covering tier: %v", err)
}
if resolution != 5*time.Minute {
t.Fatalf("covering tier = %s, want 5m", resolution)
}
}
func TestSeriesFallsBackWhenPreferredPolicyTierHasNoRows(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{
Tiers: []RollupTier{
{Interval: time.Minute, Retention: 10 * time.Hour},
{Interval: time.Hour, Retention: 25 * 24 * time.Hour},
},
Compression: 30,
}
s, err := Open(ctx, SQLite(":memory:", WithRollupPolicy(policy)))
if err != nil {
t.Fatalf("open store: %v", err)
}
defer s.Close()
if err := s.CreateMetric(ctx, Definition{Name: "missing-fine-tier", RetentionDays: 60}); err != nil {
t.Fatalf("create metric: %v", err)
}
now := time.Date(2026, 7, 24, 12, 30, 0, 0, time.UTC)
at := now.Add(-30 * time.Minute)
tagsHash, tagsJSON, err := tagsFingerprint(nil)
if err != nil {
t.Fatalf("fingerprint tags: %v", err)
}
bucket := newRollupBucket(policy.compression())
bucket.tagsHash, bucket.tagsJSON = tagsHash, tagsJSON
bucket.labelsHash, bucket.labelsJSON = emptyLabelsHash, "{}"
bucket.addPoint(42, at.UnixMilli())
key := rollupKey{entityID: "n1", tagsHash: tagsHash, labelsHash: emptyLabelsHash, bucket: bucketStartMillis(at.UnixMilli(), time.Hour.Milliseconds())}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("begin seed transaction: %v", err)
}
if _, err := s.writeRollupBucketsTx(ctx, "missing-fine-tier", time.Hour, map[rollupKey]*rollupBucket{key: bucket}, tx); err != nil {
_ = tx.Rollback()
t.Fatalf("seed hourly tier: %v", err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit hourly tier: %v", err)
}
got, err := s.Series(ctx, AggregateQuery{
Query: Query{MetricName: "missing-fine-tier", EntityID: "n1", Start: now.Add(-time.Hour), End: now},
Aggregation: AggAvg,
Interval: time.Minute,
}, now)
if err != nil {
t.Fatalf("query series: %v", err)
}
if len(got) != 1 || got[0].Value != 42 || got[0].Count != 1 {
t.Fatalf("series did not fall back to existing hourly tier: %#v", got)
}
}
func TestSeriesDoesNotFallBackWhenPreferredTierCoversWindow(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{
Tiers: []RollupTier{
{Interval: time.Minute, Retention: 10 * time.Hour},
{Interval: 24 * time.Hour, Retention: 100 * 365 * 24 * time.Hour},
},
Compression: 30,
}
s, err := Open(ctx, SQLite(":memory:", WithRollupPolicy(policy)))
if err != nil {
t.Fatalf("open store: %v", err)
}
defer s.Close()
if err := s.CreateMetric(ctx, Definition{Name: "new-series", RetentionDays: 60}); err != nil {
t.Fatalf("create metric: %v", err)
}
now := time.Date(2026, 7, 27, 3, 0, 0, 0, time.UTC)
tagsHash, tagsJSON, err := tagsFingerprint(nil)
if err != nil {
t.Fatalf("fingerprint tags: %v", err)
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("begin seed transaction: %v", err)
}
for _, seed := range []struct {
interval time.Duration
at time.Time
value float64
}{
{interval: 24 * time.Hour, at: now.Add(-30 * 24 * time.Hour), value: 10},
{interval: time.Minute, at: now.Add(-5 * time.Minute), value: 20},
} {
bucket := newRollupBucket(policy.compression())
bucket.tagsHash, bucket.tagsJSON = tagsHash, tagsJSON
bucket.labelsHash, bucket.labelsJSON = emptyLabelsHash, "{}"
bucket.addPoint(seed.value, seed.at.UnixMilli())
key := rollupKey{entityID: "n1", tagsHash: tagsHash, labelsHash: emptyLabelsHash, bucket: bucketStartMillis(seed.at.UnixMilli(), seed.interval.Milliseconds())}
if _, err := s.writeRollupBucketsTx(ctx, "new-series", seed.interval, map[rollupKey]*rollupBucket{key: bucket}, tx); err != nil {
_ = tx.Rollback()
t.Fatalf("seed %s tier: %v", seed.interval, err)
}
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit seed tiers: %v", err)
}
got, err := s.Series(ctx, AggregateQuery{
Query: Query{MetricName: "new-series", EntityID: "n1", Start: now.Add(-time.Hour), End: now},
Aggregation: AggAvg,
Interval: time.Minute,
}, now)
if err != nil {
t.Fatalf("query recent series: %v", err)
}
if len(got) != 1 || got[0].Bucket.Before(now.Add(-time.Hour)) || got[0].Value != 20 {
t.Fatalf("recent query fell back to an older coarse tier: %#v", got)
}
}
func TestDeleteBeforeKeepsBucketsThatStraddleCutoff(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{
Tiers: []RollupTier{
{Interval: time.Minute, Retention: 10 * time.Hour},
{Interval: 5 * time.Minute, Retention: 50 * time.Hour},
{Interval: time.Hour, Retention: 25 * 24 * time.Hour},
{Interval: 24 * time.Hour, Retention: 100 * 365 * 24 * time.Hour},
},
Compression: 30,
}
s, err := Open(ctx, SQLite(":memory:", WithRollupPolicy(policy)))
if err != nil {
t.Fatalf("open store: %v", err)
}
defer s.Close()
if err := s.CreateMetric(ctx, Definition{Name: "retention-boundary", RetentionDays: 60}); err != nil {
t.Fatalf("create metric: %v", err)
}
day := time.Now().UTC().Truncate(24 * time.Hour).Add(-48 * time.Hour)
if err := s.WriteBatch(ctx, []Point{
{MetricName: "retention-boundary", EntityID: "n1", Timestamp: day.Add(12 * time.Hour), Value: 10},
{MetricName: "retention-boundary", EntityID: "n1", Timestamp: day.Add(20 * time.Hour), Value: 20},
}); err != nil {
t.Fatalf("write daily boundary points: %v", err)
}
if _, err := s.DeleteBefore(ctx, "retention-boundary", day.Add(18*time.Hour)); err != nil {
t.Fatalf("delete before cutoff: %v", err)
}
var dailyCount int64
if err := s.db.QueryRowContext(ctx, fmt.Sprintf(`SELECT r.count FROM %s r
JOIN %s series ON series.id = r.series_id
JOIN %s resolutions ON resolutions.id = r.resolution_id
WHERE series.metric_name = ? AND resolutions.resolution_milli = ?`,
s.tables.rollups, s.tables.series, s.tables.resolutions), "retention-boundary", (24 * time.Hour).Milliseconds()).Scan(&dailyCount); err != nil {
t.Fatalf("read straddling daily bucket: %v", err)
}
if dailyCount != 2 {
t.Fatalf("straddling daily bucket count = %d, want 2", dailyCount)
}
futureMinute := time.Now().UTC().Truncate(time.Minute).Add(2 * time.Minute)
if err := s.Write(ctx, Point{MetricName: "retention-boundary", EntityID: "n1", Timestamp: futureMinute.Add(40 * time.Second), Value: 30}); err != nil {
t.Fatalf("write active boundary point: %v", err)
}
if _, err := s.DeleteBefore(ctx, "retention-boundary", futureMinute.Add(30*time.Second)); err != nil {
t.Fatalf("delete before active-minute cutoff: %v", err)
}
if len(s.hot) != 1 {
t.Fatalf("active minute was deleted across a partial cutoff: %#v", s.hot)
}
}
func TestCompatibleIntervalsCoverDashboardRanges(t *testing.T) {
now := time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC)
policy := RollupPolicy{
RawRetention: time.Minute,
Tiers: []RollupTier{
{Interval: time.Minute, Retention: 10 * time.Hour},
{Interval: 5 * time.Minute, Retention: 50 * time.Hour},
{Interval: time.Hour, Retention: 25 * 24 * time.Hour},
{Interval: 24 * time.Hour, Retention: 100 * 365 * 24 * time.Hour},
},
}
s := &Store{cfg: Config{RollupPolicy: policy}}
for _, test := range []struct {
rangeDuration time.Duration
interval time.Duration
}{
{time.Hour, time.Minute},
{6 * time.Hour, time.Minute},
{7 * 24 * time.Hour, 15 * time.Minute},
{30 * 24 * time.Hour, time.Hour},
{60 * 24 * time.Hour, time.Hour},
} {
got := s.CompatibleSeriesInterval(now.Add(-test.rangeDuration), now, test.interval)
if got <= 0 {
t.Fatalf("range %s returned invalid interval %s", test.rangeDuration, got)
}
if got < time.Minute {
t.Fatalf("range %s returned %s below persisted minute precision", test.rangeDuration, got)
}
}
}
func TestSeriesHasNoTrailingEmptyBucketAcrossDashboardRanges(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{
RawRetention: time.Minute,
Tiers: []RollupTier{
{Interval: time.Minute, Retention: 10 * time.Hour},
{Interval: 5 * time.Minute, Retention: 50 * time.Hour},
{Interval: time.Hour, Retention: 25 * 24 * time.Hour},
{Interval: 24 * time.Hour, Retention: 100 * 365 * 24 * time.Hour},
},
Compression: 30,
}
s, err := Open(ctx, SQLite(":memory:", WithRollupPolicy(policy)))
if err != nil {
t.Fatalf("open store: %v", err)
}
defer s.Close()
if err := s.CreateMetric(ctx, Definition{Name: "coverage", RetentionDays: 90}); err != nil {
t.Fatalf("create metric: %v", err)
}
now := time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC)
points := make([]Point, 0, 60*24*60)
for at := now.Add(-60 * 24 * time.Hour); at.Before(now); at = at.Add(time.Minute) {
points = append(points, Point{MetricName: "coverage", EntityID: "n1", Timestamp: at, Value: 1})
}
if err := s.WriteBatch(ctx, points); err != nil {
t.Fatalf("write history: %v", err)
}
if _, err := s.Compact(ctx, now); err != nil {
t.Fatalf("compact history: %v", err)
}
for _, duration := range []time.Duration{time.Hour, 6 * time.Hour, 7 * 24 * time.Hour, 30 * 24 * time.Hour, 60 * 24 * time.Hour} {
interval := s.CompatibleSeriesInterval(now.Add(-duration), now, time.Minute)
series, err := s.Series(ctx, AggregateQuery{
Query: Query{MetricName: "coverage", EntityID: "n1", Start: now.Add(-duration), End: now},
Aggregation: AggAvg,
Interval: interval,
}, now)
if err != nil {
t.Fatalf("query %s: %v", duration, err)
}
if len(series) == 0 {
t.Fatalf("query %s returned no retained buckets", duration)
}
if series[len(series)-1].Count == 0 {
t.Fatalf("query %s ended with an empty bucket: %#v", duration, series[len(series)-1])
}
for i := 1; i < len(series); i++ {
if delta := series[i].Bucket.Sub(series[i-1].Bucket); delta != interval {
t.Fatalf("query %s has a break between %s and %s: got %s, want %s", duration, series[i-1].Bucket, series[i].Bucket, delta, interval)
}
}
}
}
+31 -31
View File
@@ -68,7 +68,7 @@ func TestTagFilterPushdownWithPaging(t *testing.T) {
if err := s.CreateMetric(ctx, Definition{Name: "req", Type: TypeCounter, RetentionDays: 30}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
base := time.Now().UTC().Truncate(time.Minute)
var batch []Point
for i := 0; i < 6; i++ {
env := "prod"
@@ -77,7 +77,7 @@ func TestTagFilterPushdownWithPaging(t *testing.T) {
}
batch = append(batch, Point{
MetricName: "req", EntityID: "n1",
Timestamp: base.Add(time.Duration(i) * time.Minute),
Timestamp: base.Add(time.Duration(i) * 5 * time.Second),
Value: float64(i),
Tags: map[string]string{"env": env},
})
@@ -89,7 +89,7 @@ func TestTagFilterPushdownWithPaging(t *testing.T) {
// tag-filtered set, returning the first two prod points in time order.
got, err := s.Query(ctx, Query{
MetricName: "req", EntityID: "n1",
Start: base, End: base.Add(time.Hour),
Start: base, End: base.Add(time.Minute),
Tags: map[string]string{"env": "prod"},
Limit: 2,
})
@@ -113,13 +113,13 @@ func TestSQLAggregateMatchesInMemory(t *testing.T) {
if err := s.CreateMetric(ctx, Definition{Name: "g", Type: TypeGauge, RetentionDays: 30}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
base := time.Now().UTC().Truncate(time.Minute)
var batch []Point
vals := []float64{5, 15, 25, 35, 100, 200}
for i, v := range vals {
batch = append(batch, Point{
MetricName: "g", EntityID: "n1",
Timestamp: base.Add(time.Duration(i) * time.Minute),
Timestamp: base.Add(time.Duration(i) * 5 * time.Second),
Value: v,
})
}
@@ -127,9 +127,9 @@ func TestSQLAggregateMatchesInMemory(t *testing.T) {
t.Fatalf("write: %v", err)
}
q := AggregateQuery{
Query: Query{MetricName: "g", EntityID: "n1", Start: base, End: base.Add(time.Hour)},
Query: Query{MetricName: "g", EntityID: "n1", Start: base, End: base.Add(time.Minute)},
Aggregation: AggAvg,
Interval: 3 * time.Minute,
Interval: 15 * time.Second,
}
// SQL pushdown path.
sqlRes, err := s.Aggregate(ctx, q)
@@ -159,7 +159,7 @@ func TestSQLAggregateMatchesInMemory(t *testing.T) {
//
// TestCounterRateHandlesReset 验证计数器重置时速率计算仍然稳定。
func TestCounterRateHandlesReset(t *testing.T) {
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
base := time.Now().UTC().Truncate(time.Minute)
// Counter goes 0 -> 10 -> reset -> 5; naive (last-first)/sec would give a
// positive-but-wrong 5/30s. Correct counter rate sums positive deltas:
// (10-0) + (5-0 after reset) = 15 over 30s = 0.5/s.
@@ -198,9 +198,9 @@ func TestLatestReturnsMostRecent(t *testing.T) {
if err := s.CreateMetric(ctx, Definition{Name: "l", Type: TypeGauge, RetentionDays: 30}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
base := time.Now().UTC().Add(-30 * time.Second)
for i := 0; i < 5; i++ {
if err := s.Write(ctx, Point{MetricName: "l", EntityID: "n1", Timestamp: base.Add(time.Duration(i) * time.Minute), Value: float64(i)}); err != nil {
if err := s.Write(ctx, Point{MetricName: "l", EntityID: "n1", Timestamp: base.Add(time.Duration(i) * 5 * time.Second), Value: float64(i)}); err != nil {
t.Fatalf("write: %v", err)
}
}
@@ -219,7 +219,7 @@ func TestLatestReturnsMostRecent(t *testing.T) {
func TestStatsDistinguishesNoDataFromUnknownMetric(t *testing.T) {
ctx := context.Background()
s := newMemStore(t)
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
base := time.Now().UTC().Truncate(time.Minute)
// Unknown metric -> ErrNotFound.
_, err := s.Stats(ctx, Query{MetricName: "nope", Start: base, End: base.Add(time.Hour)})
@@ -269,16 +269,16 @@ func TestAggregateStdDevSQLiteUsesMemoryPath(t *testing.T) {
if err := s.CreateMetric(ctx, Definition{Name: "sd", Type: TypeGauge, RetentionDays: 30}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
base := time.Now().UTC().Truncate(time.Minute)
var batch []Point
for i, v := range []float64{10, 20, 30} {
batch = append(batch, Point{MetricName: "sd", EntityID: "n1", Timestamp: base.Add(time.Duration(i) * time.Minute), Value: v})
batch = append(batch, Point{MetricName: "sd", EntityID: "n1", Timestamp: base.Add(time.Duration(i) * 5 * time.Second), Value: v})
}
if err := s.WriteBatch(ctx, batch); err != nil {
t.Fatalf("write: %v", err)
}
res, err := s.Aggregate(ctx, AggregateQuery{
Query: Query{MetricName: "sd", EntityID: "n1", Start: base, End: base.Add(time.Hour)},
Query: Query{MetricName: "sd", EntityID: "n1", Start: base, End: base.Add(time.Minute)},
Aggregation: AggStdDev,
Interval: time.Hour,
})
@@ -345,7 +345,7 @@ func TestSQLiteReadPoolOpens(t *testing.T) {
if err := store.CreateMetric(ctx, Definition{Name: "rp", Type: TypeGauge, RetentionDays: 30}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
base := time.Now().UTC()
if err := store.Write(ctx, Point{MetricName: "rp", EntityID: "n1", Timestamp: base, Value: 7}); err != nil {
t.Fatalf("write: %v", err)
}
@@ -412,20 +412,20 @@ func TestAggregateBucketPagingSQLPath(t *testing.T) {
if err := s.CreateMetric(ctx, Definition{Name: "bp", Type: TypeGauge, RetentionDays: 30}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
// 6 points across 6 one-minute buckets, values 0..5.
base := time.Now().UTC().Truncate(time.Minute)
// 6 points across 6 ten-second buckets, values 0..5.
var batch []Point
for i := 0; i < 6; i++ {
batch = append(batch, Point{MetricName: "bp", EntityID: "n1", Timestamp: base.Add(time.Duration(i) * time.Minute), Value: float64(i)})
batch = append(batch, Point{MetricName: "bp", EntityID: "n1", Timestamp: base.Add(time.Duration(i) * 10 * time.Second), Value: float64(i)})
}
if err := s.WriteBatch(ctx, batch); err != nil {
t.Fatalf("write: %v", err)
}
// AggAvg -> SQL pushdown path. One bucket per minute. Page: offset 1, limit 2.
// One bucket per ten seconds. Page: offset 1, limit 2.
res, err := s.Aggregate(ctx, AggregateQuery{
Query: Query{MetricName: "bp", EntityID: "n1", Start: base, End: base.Add(time.Hour)},
Query: Query{MetricName: "bp", EntityID: "n1", Start: base, End: base.Add(time.Minute)},
Aggregation: AggAvg,
Interval: time.Minute,
Interval: 10 * time.Second,
BucketLimit: 2,
BucketOffset: 1,
})
@@ -450,22 +450,22 @@ func TestAggregateBucketPagingMemoryPathMatchesSQL(t *testing.T) {
if err := s.CreateMetric(ctx, Definition{Name: "bp2", Type: TypeGauge, RetentionDays: 30}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
base := time.Now().UTC().Truncate(time.Minute)
var batch []Point
for i := 0; i < 6; i++ {
batch = append(batch, Point{MetricName: "bp2", EntityID: "n1", Timestamp: base.Add(time.Duration(i) * time.Minute), Value: float64(i * 10)})
batch = append(batch, Point{MetricName: "bp2", EntityID: "n1", Timestamp: base.Add(time.Duration(i) * 10 * time.Second), Value: float64(i * 10)})
}
if err := s.WriteBatch(ctx, batch); err != nil {
t.Fatalf("write: %v", err)
}
q := Query{MetricName: "bp2", EntityID: "n1", Start: base, End: base.Add(time.Hour)}
q := Query{MetricName: "bp2", EntityID: "n1", Start: base, End: base.Add(time.Minute)}
// AggMin (SQL pushdown) and AggP50 (memory fallback on sqlite) with identical
// bucket paging must select the same bucket window (the same timestamps).
sqlPaged, err := s.Aggregate(ctx, AggregateQuery{Query: q, Aggregation: AggMin, Interval: time.Minute, BucketLimit: 3, BucketOffset: 2})
sqlPaged, err := s.Aggregate(ctx, AggregateQuery{Query: q, Aggregation: AggMin, Interval: 10 * time.Second, BucketLimit: 3, BucketOffset: 2})
if err != nil {
t.Fatalf("sql aggregate: %v", err)
}
memPaged, err := s.Aggregate(ctx, AggregateQuery{Query: q, Aggregation: AggP50, Interval: time.Minute, BucketLimit: 3, BucketOffset: 2})
memPaged, err := s.Aggregate(ctx, AggregateQuery{Query: q, Aggregation: AggP50, Interval: 10 * time.Second, BucketLimit: 3, BucketOffset: 2})
if err != nil {
t.Fatalf("mem aggregate: %v", err)
}
@@ -491,7 +491,7 @@ func TestAggregateSeparatesTagSeries(t *testing.T) {
if err := s.CreateMetric(ctx, Definition{Name: "tagged.util", Type: TypeGauge, RetentionDays: 30}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
base := time.Now().UTC().Truncate(time.Minute)
points := []Point{
{MetricName: "tagged.util", EntityID: "n1", Timestamp: base.Add(10 * time.Second), Value: 10, Tags: map[string]string{"device": "0"}},
{MetricName: "tagged.util", EntityID: "n1", Timestamp: base.Add(20 * time.Second), Value: 30, Tags: map[string]string{"device": "0"}},
@@ -549,10 +549,10 @@ func TestAggregateIgnoresRawPointLimit(t *testing.T) {
if err := s.CreateMetric(ctx, Definition{Name: "ig", Type: TypeGauge, RetentionDays: 30}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
base := time.Now().UTC().Truncate(time.Minute)
var batch []Point
for i := 0; i < 6; i++ {
batch = append(batch, Point{MetricName: "ig", EntityID: "n1", Timestamp: base.Add(time.Duration(i) * time.Minute), Value: float64(i)})
batch = append(batch, Point{MetricName: "ig", EntityID: "n1", Timestamp: base.Add(time.Duration(i) * 5 * time.Second), Value: float64(i)})
}
if err := s.WriteBatch(ctx, batch); err != nil {
t.Fatalf("write: %v", err)
@@ -581,7 +581,7 @@ func TestJSONTagKeyWithSpecialChars(t *testing.T) {
if err := s.CreateMetric(ctx, Definition{Name: "tk", Type: TypeGauge, RetentionDays: 30}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
base := time.Now().UTC().Truncate(time.Minute)
// Keys containing a dot, a hyphen, a space, and a quote.
tags := map[string]string{
"region.zone": "ap-1",
@@ -592,7 +592,7 @@ func TestJSONTagKeyWithSpecialChars(t *testing.T) {
t.Fatalf("write: %v", err)
}
// A second point that should NOT match the filter below.
if err := s.Write(ctx, Point{MetricName: "tk", EntityID: "n1", Timestamp: base.Add(time.Minute), Value: 2, Tags: map[string]string{"region.zone": "eu-1"}}); err != nil {
if err := s.Write(ctx, Point{MetricName: "tk", EntityID: "n1", Timestamp: base.Add(10 * time.Second), Value: 2, Tags: map[string]string{"region.zone": "eu-1"}}); err != nil {
t.Fatalf("write 2: %v", err)
}
+37 -17
View File
@@ -46,11 +46,10 @@ func (s *Store) MaintenanceAction() MaintenanceAction {
// StorageSize returns the physical bytes occupied by this Store. SQLite
// includes the main database, WAL, and shared-memory files. Server backends
// include only the definitions, points, and rollups tables managed by Store.
// include only the definition, dictionary, and rollup tables managed by Store.
//
// StorageSize 返回当前 Store 占用的物理字节数。SQLite 会统计主数据库、WAL
// 和共享内存文件;服务端数据库只统计 Store 管理的 definitions、points 和
// rollups 三张表。
// 和共享内存文件;服务端数据库只统计 Store 管理的定义、字典和 rollup 表。
func (s *Store) StorageSize(ctx context.Context) (int64, error) {
s.maintenanceMu.RLock()
defer s.maintenanceMu.RUnlock()
@@ -76,6 +75,22 @@ func (s *Store) StorageSize(ctx context.Context) (int64, error) {
return size, nil
}
// CheckpointWAL copies committed SQLite WAL pages into the main database and
// truncates the sidecar. Other backends have no local WAL sidecar.
func (s *Store) CheckpointWAL(ctx context.Context) error {
s.maintenanceMu.Lock()
defer s.maintenanceMu.Unlock()
s.mu.RLock()
defer s.mu.RUnlock()
if s.closed || s.db == nil {
return ErrClosed
}
if s.cfg.Driver != DriverSQLite {
return nil
}
return sqliteCheckpoint(ctx, s.db)
}
// ReclaimSpace performs the backend-specific blocking operation that returns
// unused database pages to the filesystem. It serializes against other
// maintenance calls and keeps Close from closing the pool mid-operation.
@@ -132,16 +147,25 @@ func (s *Store) ReclaimSpace(ctx context.Context) error {
// maintenance window.
func (s *Store) cleanupOrphanedMetricData(ctx context.Context) (int64, error) {
var deleted int64
for _, table := range []string{s.tables.points, s.tables.rollups, s.tables.watermarks} {
if table == "" {
continue
}
result, err := s.db.ExecContext(ctx, fmt.Sprintf(
`DELETE FROM %s WHERE NOT EXISTS (SELECT 1 FROM %s WHERE %s.name = %s.metric_name)`,
table, s.tables.definitions, s.tables.definitions, table,
))
result, err := s.db.ExecContext(ctx, fmt.Sprintf(
`DELETE FROM %s WHERE NOT EXISTS (SELECT 1 FROM %s s JOIN %s d ON d.name = s.metric_name WHERE s.id = %s.series_id)`,
s.tables.rollups, s.tables.series, s.tables.definitions, s.tables.rollups))
if err != nil {
return deleted, fmt.Errorf("metric: delete orphaned rows from %s: %w", s.tables.rollups, err)
}
count, err := result.RowsAffected()
if err != nil {
return deleted, err
}
deleted += count
for _, statement := range []string{
fmt.Sprintf(`DELETE FROM %s WHERE NOT EXISTS (SELECT 1 FROM %s r WHERE r.series_id = %s.id)`, s.tables.series, s.tables.rollups, s.tables.series),
fmt.Sprintf(`DELETE FROM %s WHERE NOT EXISTS (SELECT 1 FROM %s r WHERE r.label_id = %s.id)`, s.tables.labels, s.tables.rollups, s.tables.labels),
fmt.Sprintf(`DELETE FROM %s WHERE NOT EXISTS (SELECT 1 FROM %s r WHERE r.resolution_id = %s.id)`, s.tables.resolutions, s.tables.rollups, s.tables.resolutions),
} {
result, err := s.db.ExecContext(ctx, statement)
if err != nil {
return deleted, fmt.Errorf("metric: delete orphaned rows from %s: %w", table, err)
return deleted, err
}
count, err := result.RowsAffected()
if err != nil {
@@ -311,11 +335,7 @@ func managedReclaimQuery(driver Driver, t tables) (string, error) {
}
func managedTableNames(t tables) []string {
names := []string{t.definitions, t.points, t.rollups}
if t.watermarks != "" {
names = append(names, t.watermarks)
}
return names
return []string{t.definitions, t.series, t.labels, t.resolutions, t.rollups}
}
func quoteMaintenanceIdentifier(driver Driver, identifier string) string {
+33 -23
View File
@@ -81,34 +81,42 @@ func TestCleanupOrphanedMetricData(t *testing.T) {
if err := store.CreateMetric(ctx, Definition{Name: "known", Type: TypeGauge, RetentionDays: 1}); err != nil {
t.Fatalf("create known definition: %v", err)
}
if err := store.writeBatch(ctx, store.db, []Point{{
MetricName: "orphan", EntityID: "node-1", Timestamp: time.Now().UTC(), Value: 1,
}}); err != nil {
t.Fatalf("seed orphan point: %v", err)
// Simulate a pre-constraint store containing orphaned rows. New stores
// reject this state through their database foreign keys.
if _, err := store.db.ExecContext(ctx, "PRAGMA foreign_keys = OFF"); err != nil {
t.Fatalf("disable foreign keys for legacy fixture: %v", err)
}
now := time.Now().UTC().UnixMilli()
if _, err := store.db.ExecContext(ctx, fmt.Sprintf(`INSERT INTO %s (metric_name, entity_id, tags_hash, tags) VALUES (?, ?, ?, ?)`, store.tables.series), "orphan", "node-1", "hash", "{}"); err != nil {
t.Fatalf("seed orphan series: %v", err)
}
if _, err := store.db.ExecContext(ctx, fmt.Sprintf(`INSERT INTO %s (labels_hash, labels) VALUES (?, ?)`, store.tables.labels), emptyLabelsHash, "{}"); err != nil {
t.Fatalf("seed orphan labels: %v", err)
}
if _, err := store.db.ExecContext(ctx, fmt.Sprintf(`INSERT INTO %s (resolution_milli) VALUES (?)`, store.tables.resolutions), time.Minute.Milliseconds()); err != nil {
t.Fatalf("seed orphan resolution: %v", err)
}
if _, err := store.db.ExecContext(ctx, fmt.Sprintf(
`INSERT INTO %s (metric_name, entity_id, tags_hash, tags, resolution_nano, bucket_nano, count, sum, sum_sq, min_val, max_val, first_val, first_ts, last_val, last_ts, digest, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
store.tables.rollups,
), "orphan", "node-1", "", "{}", int64(time.Minute), time.Now().UTC().UnixNano(), 1, 1, 1, 1, 1, 1, time.Now().UTC().UnixNano(), 1, time.Now().UTC().UnixNano(), []byte{}, time.Now().UTC().UnixNano()); err != nil {
`INSERT INTO %s (series_id, resolution_id, label_id, bucket_milli, count, sum, sum_sq, min_val, max_val, first_val, first_ts_milli, last_val, last_ts_milli, digest, created_at_milli)
SELECT s.id, r.id, l.id, ?, 1, 1, 1, 1, 1, 1, ?, 1, ?, NULL, ? FROM %s s, %s r, %s l WHERE s.metric_name = ?`,
store.tables.rollups, store.tables.series, store.tables.resolutions, store.tables.labels,
), now, now, now, now, "orphan"); err != nil {
t.Fatalf("seed orphan rollup: %v", err)
}
if _, err := store.db.ExecContext(ctx, fmt.Sprintf(
`INSERT INTO %s (metric_name, watermark_nano, updated_at) VALUES (?, ?, ?)`, store.tables.watermarks,
), "orphan", time.Now().UTC().UnixNano(), time.Now().UTC().UnixNano()); err != nil {
t.Fatalf("seed orphan watermark: %v", err)
if _, err := store.db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil {
t.Fatalf("restore foreign keys after legacy fixture: %v", err)
}
deleted, err := store.cleanupOrphanedMetricData(ctx)
if err != nil {
t.Fatalf("clean orphaned metric data: %v", err)
}
if deleted != 3 {
t.Fatalf("deleted rows = %d, want 3", deleted)
if deleted != 4 {
t.Fatalf("deleted rows = %d, want 4", deleted)
}
for _, table := range []string{store.tables.points, store.tables.rollups, store.tables.watermarks} {
for _, table := range []string{store.tables.series, store.tables.labels, store.tables.resolutions, store.tables.rollups} {
var count int
if err := store.db.QueryRowContext(ctx, fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE metric_name = ?`, table), "orphan").Scan(&count); err != nil {
if err := store.db.QueryRowContext(ctx, fmt.Sprintf(`SELECT COUNT(*) FROM %s`, table)).Scan(&count); err != nil {
t.Fatalf("count orphan rows in %s: %v", table, err)
}
if count != 0 {
@@ -121,8 +129,10 @@ func TestMaintenanceMappings(t *testing.T) {
tables := tables{
definitions: "Metric_definitions",
points: "Metric_points",
series: "Metric_series",
labels: "Metric_label_sets",
resolutions: "Metric_resolutions",
rollups: "Metric_rollups",
watermarks: "Metric_compaction_watermarks",
}
tests := []struct {
@@ -145,18 +155,18 @@ func TestMaintenanceMappings(t *testing.T) {
name: "mysql",
driver: DriverMySQL,
action: MaintenanceOptimize,
reclaim: "OPTIMIZE TABLE `Metric_definitions`, `Metric_points`, `Metric_rollups`, `Metric_compaction_watermarks`",
sizeParts: []string{"information_schema.TABLES", "TABLE_SCHEMA = DATABASE()", "TABLE_NAME IN (?, ?, ?, ?)"},
sizeArgs: []any{"Metric_definitions", "Metric_points", "Metric_rollups", "Metric_compaction_watermarks"},
reclaim: "OPTIMIZE TABLE `Metric_definitions`, `Metric_series`, `Metric_label_sets`, `Metric_resolutions`, `Metric_rollups`",
sizeParts: []string{"information_schema.TABLES", "TABLE_SCHEMA = DATABASE()", "TABLE_NAME IN (?, ?, ?, ?, ?)"},
sizeArgs: []any{"Metric_definitions", "Metric_series", "Metric_label_sets", "Metric_resolutions", "Metric_rollups"},
hasSizeSQL: true,
},
{
name: "postgresql",
driver: DriverPostgreSQL,
action: MaintenanceVacuumFull,
reclaim: `VACUUM (FULL, ANALYZE) "metric_definitions", "metric_points", "metric_rollups", "metric_compaction_watermarks"`,
sizeParts: []string{"pg_total_relation_size(c.oid)", "n.nspname = current_schema()", "c.relname IN ($1, $2, $3, $4)"},
sizeArgs: []any{"metric_definitions", "metric_points", "metric_rollups", "metric_compaction_watermarks"},
reclaim: `VACUUM (FULL, ANALYZE) "metric_definitions", "metric_series", "metric_label_sets", "metric_resolutions", "metric_rollups"`,
sizeParts: []string{"pg_total_relation_size(c.oid)", "n.nspname = current_schema()", "c.relname IN ($1, $2, $3, $4, $5)"},
sizeArgs: []any{"metric_definitions", "metric_series", "metric_label_sets", "metric_resolutions", "metric_rollups"},
hasSizeSQL: true,
},
}
+117 -167
View File
@@ -2,196 +2,146 @@ package metric
import (
"context"
"database/sql"
"fmt"
)
// Migrate creates the tables and indexes used by the metric store.
//
// Migrate 创建 metric 包需要的表和索引。
// Migrate creates the normalized metric schema. Series, labels, and
// resolutions are interned once and every stored timestamp is Unix
// milliseconds. Exact samples live only in the Store's ten-minute memory
// window and are never persisted.
// Existing installations are rebuilt explicitly by the administrator guide,
// rather than being changed during startup.
func (s *Store) Migrate(ctx context.Context) error {
if err := s.ensureOpen(); err != nil {
return err
}
for _, statement := range s.normalizedSchemaStatements() {
if _, err := s.db.ExecContext(ctx, statement); err != nil {
return err
}
}
return s.createNormalizedIndexes(ctx)
}
func (s *Store) normalizedSchemaStatements() []string {
d := s.dialect
jsonType := d.jsonType()
pk := d.autoIncrementPrimaryKey()
blob := d.blobType()
statements := []string{
return []string{
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
name VARCHAR(191) PRIMARY KEY,
type VARCHAR(32) NOT NULL,
unit VARCHAR(64) NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
retention_days INTEGER NOT NULL DEFAULT 0,
metadata %s NOT NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
name VARCHAR(191) PRIMARY KEY, type VARCHAR(32) NOT NULL,
unit VARCHAR(64) NOT NULL DEFAULT '', description TEXT NOT NULL,
retention_days INTEGER NOT NULL DEFAULT 0, metadata %s NOT NULL,
created_at_milli BIGINT NOT NULL, updated_at_milli BIGINT NOT NULL
)`, s.tables.definitions, jsonType),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
id %s,
metric_name VARCHAR(191) NOT NULL,
entity_id VARCHAR(191) NOT NULL,
tags_hash VARCHAR(64) NOT NULL,
ts_nano BIGINT NOT NULL,
value DOUBLE PRECISION NOT NULL,
tags %s NOT NULL,
labels %s NOT NULL,
created_at BIGINT NOT NULL,
UNIQUE(metric_name, entity_id, tags_hash, ts_nano)
)`, s.tables.points, pk, jsonType, jsonType),
fmt.Sprintf(`CREATE INDEX IF NOT EXISTS %s_points_metric_entity_time_idx ON %s (metric_name, entity_id, ts_nano)`, s.cfg.TablePrefix, s.tables.points),
fmt.Sprintf(`CREATE INDEX IF NOT EXISTS %s_points_metric_time_idx ON %s (metric_name, ts_nano)`, s.cfg.TablePrefix, s.tables.points),
fmt.Sprintf(`CREATE INDEX IF NOT EXISTS %s_points_entity_time_idx ON %s (entity_id, ts_nano)`, s.cfg.TablePrefix, s.tables.points),
id %s, labels_hash VARCHAR(64) NOT NULL, labels %s NOT NULL,
UNIQUE(labels_hash)
)`, s.tables.labels, pk, jsonType),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
id %s,
metric_name VARCHAR(191) NOT NULL,
entity_id VARCHAR(191) NOT NULL,
tags_hash VARCHAR(64) NOT NULL,
tags %s NOT NULL,
resolution_nano BIGINT NOT NULL,
bucket_nano BIGINT NOT NULL,
count BIGINT NOT NULL,
sum DOUBLE PRECISION NOT NULL,
sum_sq DOUBLE PRECISION NOT NULL,
min_val DOUBLE PRECISION NOT NULL,
max_val DOUBLE PRECISION NOT NULL,
first_val DOUBLE PRECISION NOT NULL,
first_ts BIGINT NOT NULL,
last_val DOUBLE PRECISION NOT NULL,
last_ts BIGINT NOT NULL,
digest %s,
created_at BIGINT NOT NULL,
UNIQUE(metric_name, entity_id, tags_hash, resolution_nano, bucket_nano)
)`, s.tables.rollups, pk, jsonType, blob),
fmt.Sprintf(`CREATE INDEX IF NOT EXISTS %s_rollups_lookup_idx ON %s (metric_name, entity_id, tags_hash, resolution_nano, bucket_nano)`, s.cfg.TablePrefix, s.tables.rollups),
fmt.Sprintf(`CREATE INDEX IF NOT EXISTS %s_rollups_res_time_idx ON %s (metric_name, resolution_nano, bucket_nano)`, s.cfg.TablePrefix, s.tables.rollups),
id %s, metric_name VARCHAR(191) NOT NULL, entity_id VARCHAR(191) NOT NULL,
tags_hash VARCHAR(64) NOT NULL, tags %s NOT NULL,
UNIQUE(metric_name, entity_id, tags_hash),
FOREIGN KEY (metric_name) REFERENCES %s(name) ON DELETE CASCADE
)`, s.tables.series, pk, jsonType, s.tables.definitions),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
metric_name VARCHAR(191) PRIMARY KEY,
watermark_nano BIGINT NOT NULL,
updated_at BIGINT NOT NULL
)`, s.tables.watermarks),
id %s, resolution_milli BIGINT NOT NULL, UNIQUE(resolution_milli)
)`, s.tables.resolutions, pk),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
series_id BIGINT NOT NULL, resolution_id BIGINT NOT NULL, label_id BIGINT NOT NULL, bucket_milli BIGINT NOT NULL,
count BIGINT NOT NULL, sum DOUBLE PRECISION NOT NULL, sum_sq DOUBLE PRECISION NOT NULL,
min_val DOUBLE PRECISION NOT NULL, max_val DOUBLE PRECISION NOT NULL,
first_val DOUBLE PRECISION NOT NULL, first_ts_milli BIGINT NOT NULL,
last_val DOUBLE PRECISION NOT NULL, last_ts_milli BIGINT NOT NULL,
digest %s, created_at_milli BIGINT NOT NULL,
UNIQUE(series_id, resolution_id, label_id, bucket_milli),
FOREIGN KEY (series_id) REFERENCES %s(id) ON DELETE CASCADE,
FOREIGN KEY (resolution_id) REFERENCES %s(id) ON DELETE CASCADE,
FOREIGN KEY (label_id) REFERENCES %s(id) ON DELETE CASCADE
)`, s.tables.rollups, d.blobType(), s.tables.series, s.tables.resolutions, s.tables.labels),
}
}
type normalizedIndex struct {
name string
table string
columns string
}
type sqlExecer interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
}
func (s *Store) normalizedIndexes() []normalizedIndex {
return normalizedIndexesFor(s.cfg.TablePrefix, s.tables)
}
func normalizedIndexesFor(prefix string, tables tables) []normalizedIndex {
return []normalizedIndex{
{name: prefix + "series_metric_entity_idx", table: tables.series, columns: "metric_name, entity_id"},
{name: prefix + "rollups_resolution_bucket_idx", table: tables.rollups, columns: "resolution_id, bucket_milli"},
}
}
func (s *Store) createNormalizedIndexes(ctx context.Context) error {
if s.cfg.Driver == DriverMySQL {
statements = []string{
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
name VARCHAR(191) PRIMARY KEY,
type VARCHAR(32) NOT NULL,
unit VARCHAR(64) NOT NULL DEFAULT '',
description TEXT NOT NULL,
retention_days INT NOT NULL DEFAULT 0,
metadata %s NOT NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, s.tables.definitions, jsonType),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
id %s,
metric_name VARCHAR(191) NOT NULL,
entity_id VARCHAR(191) NOT NULL,
tags_hash VARCHAR(64) NOT NULL,
ts_nano BIGINT NOT NULL,
value DOUBLE NOT NULL,
tags %s NOT NULL,
labels %s NOT NULL,
created_at BIGINT NOT NULL,
UNIQUE KEY uq_metric_entity_time (metric_name, entity_id, tags_hash, ts_nano),
INDEX idx_metric_entity_time (metric_name, entity_id, ts_nano),
INDEX idx_metric_time (metric_name, ts_nano),
INDEX idx_entity_time (entity_id, ts_nano)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, s.tables.points, pk, jsonType, jsonType),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
id %s,
metric_name VARCHAR(191) NOT NULL,
entity_id VARCHAR(191) NOT NULL,
tags_hash VARCHAR(64) NOT NULL,
tags %s NOT NULL,
resolution_nano BIGINT NOT NULL,
bucket_nano BIGINT NOT NULL,
count BIGINT NOT NULL,
sum DOUBLE NOT NULL,
sum_sq DOUBLE NOT NULL,
min_val DOUBLE NOT NULL,
max_val DOUBLE NOT NULL,
first_val DOUBLE NOT NULL,
first_ts BIGINT NOT NULL,
last_val DOUBLE NOT NULL,
last_ts BIGINT NOT NULL,
digest %s,
created_at BIGINT NOT NULL,
UNIQUE KEY uq_rollup (metric_name, entity_id, tags_hash, resolution_nano, bucket_nano),
INDEX idx_rollup_res_time (metric_name, resolution_nano, bucket_nano)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, s.tables.rollups, pk, jsonType, blob),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
metric_name VARCHAR(191) PRIMARY KEY,
watermark_nano BIGINT NOT NULL,
updated_at BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, s.tables.watermarks),
for _, index := range s.normalizedIndexes() {
exists, err := s.mysqlIndexExists(ctx, index.table, index.name)
if err != nil {
return err
}
if exists {
continue
}
if _, err := s.db.ExecContext(ctx, fmt.Sprintf("CREATE INDEX %s ON %s (%s)", index.name, index.table, index.columns)); err != nil {
return err
}
}
return nil
}
return s.createPortableNormalizedIndexes(ctx, s.db)
}
if s.cfg.Driver == DriverPostgreSQL {
statements = []string{
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
name VARCHAR(191) PRIMARY KEY,
type VARCHAR(32) NOT NULL,
unit VARCHAR(64) NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
retention_days INTEGER NOT NULL DEFAULT 0,
metadata %s NOT NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
)`, s.tables.definitions, jsonType),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
id %s,
metric_name VARCHAR(191) NOT NULL,
entity_id VARCHAR(191) NOT NULL,
tags_hash VARCHAR(64) NOT NULL,
ts_nano BIGINT NOT NULL,
value DOUBLE PRECISION NOT NULL,
tags %s NOT NULL,
labels %s NOT NULL,
created_at BIGINT NOT NULL,
UNIQUE(metric_name, entity_id, tags_hash, ts_nano)
)`, s.tables.points, pk, jsonType, jsonType),
fmt.Sprintf(`CREATE INDEX IF NOT EXISTS %s_points_metric_entity_time_idx ON %s (metric_name, entity_id, ts_nano)`, s.cfg.TablePrefix, s.tables.points),
fmt.Sprintf(`CREATE INDEX IF NOT EXISTS %s_points_metric_time_idx ON %s (metric_name, ts_nano)`, s.cfg.TablePrefix, s.tables.points),
fmt.Sprintf(`CREATE INDEX IF NOT EXISTS %s_points_entity_time_idx ON %s (entity_id, ts_nano)`, s.cfg.TablePrefix, s.tables.points),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
id %s,
metric_name VARCHAR(191) NOT NULL,
entity_id VARCHAR(191) NOT NULL,
tags_hash VARCHAR(64) NOT NULL,
tags %s NOT NULL,
resolution_nano BIGINT NOT NULL,
bucket_nano BIGINT NOT NULL,
count BIGINT NOT NULL,
sum DOUBLE PRECISION NOT NULL,
sum_sq DOUBLE PRECISION NOT NULL,
min_val DOUBLE PRECISION NOT NULL,
max_val DOUBLE PRECISION NOT NULL,
first_val DOUBLE PRECISION NOT NULL,
first_ts BIGINT NOT NULL,
last_val DOUBLE PRECISION NOT NULL,
last_ts BIGINT NOT NULL,
digest %s,
created_at BIGINT NOT NULL,
UNIQUE(metric_name, entity_id, tags_hash, resolution_nano, bucket_nano)
)`, s.tables.rollups, pk, jsonType, blob),
fmt.Sprintf(`CREATE INDEX IF NOT EXISTS %s_rollups_lookup_idx ON %s (metric_name, entity_id, tags_hash, resolution_nano, bucket_nano)`, s.cfg.TablePrefix, s.tables.rollups),
fmt.Sprintf(`CREATE INDEX IF NOT EXISTS %s_rollups_res_time_idx ON %s (metric_name, resolution_nano, bucket_nano)`, s.cfg.TablePrefix, s.tables.rollups),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
metric_name VARCHAR(191) PRIMARY KEY,
watermark_nano BIGINT NOT NULL,
updated_at BIGINT NOT NULL
)`, s.tables.watermarks),
}
}
for _, stmt := range statements {
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
func (s *Store) createPortableNormalizedIndexes(ctx context.Context, exec sqlExecer) error {
for _, index := range s.normalizedIndexes() {
if _, err := exec.ExecContext(ctx, fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s ON %s (%s)", index.name, index.table, index.columns)); err != nil {
return err
}
}
return nil
}
func (s *Store) dropNormalizedIndexes(ctx context.Context) error {
for _, index := range s.normalizedIndexes() {
if s.cfg.Driver == DriverMySQL {
exists, err := s.mysqlIndexExists(ctx, index.table, index.name)
if err != nil {
return err
}
if exists {
if _, err := s.db.ExecContext(ctx, fmt.Sprintf("ALTER TABLE %s DROP INDEX %s", index.table, index.name)); err != nil {
return err
}
}
continue
}
if _, err := s.db.ExecContext(ctx, "DROP INDEX IF EXISTS "+index.name); err != nil {
return err
}
}
return nil
}
func (s *Store) mysqlIndexExists(ctx context.Context, table, index string) (bool, error) {
var found int
err := s.db.QueryRowContext(ctx, `SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? LIMIT 1`, table, index).Scan(&found)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
+120
View File
@@ -0,0 +1,120 @@
package metric
import (
"context"
"fmt"
"strings"
"testing"
"time"
)
type sqliteForeignKey struct {
table string
from string
to string
onDelete string
}
func TestNormalizedSchemaDeclaresPortableForeignKeys(t *testing.T) {
for _, driver := range []Driver{DriverSQLite, DriverMySQL, DriverPostgreSQL} {
t.Run(string(driver), func(t *testing.T) {
s := schemaTestStore(driver, "er_")
ddl := strings.Join(s.normalizedSchemaStatements(), "\n")
for _, clause := range []string{
"FOREIGN KEY (metric_name) REFERENCES er_definitions(name) ON DELETE CASCADE",
"FOREIGN KEY (series_id) REFERENCES er_series(id) ON DELETE CASCADE",
"FOREIGN KEY (resolution_id) REFERENCES er_resolutions(id) ON DELETE CASCADE",
"FOREIGN KEY (label_id) REFERENCES er_label_sets(id) ON DELETE CASCADE",
} {
if !strings.Contains(ddl, clause) {
t.Fatalf("%s schema is missing %q:\n%s", driver, clause, ddl)
}
}
})
}
}
func TestSQLiteSchemaExposesERRelationships(t *testing.T) {
ctx := context.Background()
store := newMemStore(t)
var enabled int
if err := store.db.QueryRowContext(ctx, "PRAGMA foreign_keys").Scan(&enabled); err != nil {
t.Fatalf("query foreign key enforcement: %v", err)
}
if enabled != 1 {
t.Fatalf("foreign key enforcement = %d, want 1", enabled)
}
want := map[string]sqliteForeignKey{
"metric_name": {table: store.tables.definitions, from: "metric_name", to: "name", onDelete: "CASCADE"},
"series_id": {table: store.tables.series, from: "series_id", to: "id", onDelete: "CASCADE"},
"resolution_id": {table: store.tables.resolutions, from: "resolution_id", to: "id", onDelete: "CASCADE"},
"label_id": {table: store.tables.labels, from: "label_id", to: "id", onDelete: "CASCADE"},
}
got := make(map[string]sqliteForeignKey)
for _, table := range []string{store.tables.series, store.tables.rollups} {
rows, err := store.db.QueryContext(ctx, "PRAGMA foreign_key_list("+table+")")
if err != nil {
t.Fatalf("list foreign keys for %s: %v", table, err)
}
for rows.Next() {
var id, seq int
var fk sqliteForeignKey
var onUpdate, match string
if err := rows.Scan(&id, &seq, &fk.table, &fk.from, &fk.to, &onUpdate, &fk.onDelete, &match); err != nil {
_ = rows.Close()
t.Fatalf("scan foreign key for %s: %v", table, err)
}
got[fk.from] = fk
}
if err := rows.Close(); err != nil {
t.Fatalf("close foreign key rows for %s: %v", table, err)
}
}
if len(got) != len(want) {
t.Fatalf("foreign keys = %#v, want %#v", got, want)
}
for column, expected := range want {
if actual := got[column]; actual != expected {
t.Fatalf("foreign key for %s = %#v, want %#v", column, actual, expected)
}
}
if err := store.CreateMetric(ctx, Definition{Name: "cascade", Type: TypeGauge, RetentionDays: 1}); err != nil {
t.Fatalf("create metric: %v", err)
}
at := time.Now().UTC().Truncate(time.Minute).Add(-2 * time.Minute)
if err := store.WriteBatch(ctx, []Point{{MetricName: "cascade", EntityID: "node-1", Timestamp: at, Value: 1}}); err != nil {
t.Fatalf("write metric: %v", err)
}
if err := store.flushAllHotRollups(ctx); err != nil {
t.Fatalf("flush rollup: %v", err)
}
if _, err := store.db.ExecContext(ctx, fmt.Sprintf("DELETE FROM %s WHERE name = ?", store.tables.definitions), "cascade"); err != nil {
t.Fatalf("delete definition: %v", err)
}
for _, table := range []string{store.tables.series, store.tables.rollups} {
var count int
if err := store.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+table).Scan(&count); err != nil {
t.Fatalf("count %s after cascade: %v", table, err)
}
if count != 0 {
t.Fatalf("%s rows after cascade = %d, want 0", table, count)
}
}
}
func schemaTestStore(driver Driver, prefix string) *Store {
return &Store{
cfg: Config{Driver: driver, TablePrefix: prefix},
dialect: newDialect(driver),
tables: tables{
definitions: prefix + "definitions",
series: prefix + "series",
labels: prefix + "label_sets",
resolutions: prefix + "resolutions",
rollups: prefix + "rollups",
},
}
}
+223
View File
@@ -0,0 +1,223 @@
package metric
import (
"context"
"database/sql"
"fmt"
"time"
)
const normalizedRollupColumns = "(series_id, resolution_id, label_id, bucket_milli, count, sum, sum_sq, min_val, max_val, first_val, first_ts_milli, last_val, last_ts_milli, digest, created_at_milli)"
type normalizedRollupRow struct {
seriesID int64
resolutionID int64
labelID int64
bucketMilli int64
count int64
sum float64
sumSq float64
min float64
max float64
firstVal float64
firstTSMilli int64
lastVal float64
lastTSMilli int64
digest []byte
createdAtMilli int64
}
// rollupDictionaryCache is scoped to one write transaction. It avoids repeated
// dictionary lookups across buckets and tiers without retaining every series
// id for the lifetime of the Store.
type rollupDictionaryCache struct {
series map[string]int64
labels map[string]int64
resolutions map[int64]int64
}
func newRollupDictionaryCache() *rollupDictionaryCache {
return &rollupDictionaryCache{
series: make(map[string]int64),
labels: make(map[string]int64),
resolutions: make(map[int64]int64),
}
}
func (c *rollupDictionaryCache) seriesID(ctx context.Context, s *Store, tx *sql.Tx, metricName string, key rollupKey, tagsJSON string) (int64, error) {
cacheKey := metricName + "\x00" + key.entityID + "\x00" + key.tagsHash
if id, ok := c.series[cacheKey]; ok {
return id, nil
}
id, err := s.internSeriesTx(ctx, metricName, key.entityID, key.tagsHash, tagsJSON, tx)
if err == nil {
c.series[cacheKey] = id
}
return id, err
}
func (c *rollupDictionaryCache) labelID(ctx context.Context, s *Store, tx *sql.Tx, hash, labelsJSON string) (int64, error) {
if id, ok := c.labels[hash]; ok {
return id, nil
}
id, err := s.internLabelsTx(ctx, hash, labelsJSON, tx)
if err == nil {
c.labels[hash] = id
}
return id, err
}
func (c *rollupDictionaryCache) resolutionID(ctx context.Context, s *Store, tx *sql.Tx, interval time.Duration) (int64, error) {
milli := interval.Milliseconds()
if id, ok := c.resolutions[milli]; ok {
return id, nil
}
id, err := s.internResolutionTx(ctx, interval, tx)
if err == nil {
c.resolutions[milli] = id
}
return id, err
}
func (s *Store) insertIgnoreSQL(table, columns, values string) string {
switch s.cfg.Driver {
case DriverMySQL:
return fmt.Sprintf("INSERT IGNORE INTO %s %s VALUES %s", table, columns, values)
case DriverPostgreSQL:
return fmt.Sprintf("INSERT INTO %s %s VALUES %s ON CONFLICT DO NOTHING", table, columns, values)
default:
return fmt.Sprintf("INSERT OR IGNORE INTO %s %s VALUES %s", table, columns, values)
}
}
// internSeriesTx stores the immutable name/entity/tag tuple once. Labels are
// intentionally not part of this dictionary: a label set is independently
// interned and referenced by each rollup row.
func (s *Store) internSeriesTx(ctx context.Context, metricName, entityID, tagsHash, tagsJSON string, tx *sql.Tx) (int64, error) {
if tagsJSON == "" {
tagsJSON = "{}"
}
var id int64
lookup := fmt.Sprintf(
"SELECT id FROM %s WHERE metric_name = %s AND entity_id = %s AND tags_hash = %s",
s.tables.series, s.dialect.placeholder(1), s.dialect.placeholder(2), s.dialect.placeholder(3),
)
if err := tx.QueryRowContext(ctx, lookup, metricName, entityID, tagsHash).Scan(&id); err == nil {
return id, nil
} else if err != sql.ErrNoRows {
return 0, err
}
if _, err := tx.ExecContext(ctx,
s.insertIgnoreSQL(s.tables.series, "(metric_name, entity_id, tags_hash, tags)",
"("+joinSQL([]string{s.dialect.placeholder(1), s.dialect.placeholder(2), s.dialect.placeholder(3), s.dialect.jsonPlaceholder(4)})+")"),
metricName, entityID, tagsHash, tagsJSON,
); err != nil {
return 0, err
}
err := tx.QueryRowContext(ctx, lookup, metricName, entityID, tagsHash).Scan(&id)
return id, err
}
func (s *Store) internLabelsTx(ctx context.Context, labelsHash, labelsJSON string, tx *sql.Tx) (int64, error) {
if labelsJSON == "" {
labelsJSON = "{}"
}
var id int64
lookup := fmt.Sprintf("SELECT id FROM %s WHERE labels_hash = %s", s.tables.labels, s.dialect.placeholder(1))
if err := tx.QueryRowContext(ctx, lookup, labelsHash).Scan(&id); err == nil {
return id, nil
} else if err != sql.ErrNoRows {
return 0, err
}
if _, err := tx.ExecContext(ctx, s.insertIgnoreSQL(s.tables.labels, "(labels_hash, labels)", "("+s.dialect.placeholder(1)+", "+s.dialect.jsonPlaceholder(2)+")"), labelsHash, labelsJSON); err != nil {
return 0, err
}
err := tx.QueryRowContext(ctx, lookup, labelsHash).Scan(&id)
return id, err
}
func (s *Store) internResolutionTx(ctx context.Context, interval time.Duration, tx *sql.Tx) (int64, error) {
milli := interval.Milliseconds()
var id int64
lookup := fmt.Sprintf("SELECT id FROM %s WHERE resolution_milli = %s", s.tables.resolutions, s.dialect.placeholder(1))
if err := tx.QueryRowContext(ctx, lookup, milli).Scan(&id); err == nil {
return id, nil
} else if err != sql.ErrNoRows {
return 0, err
}
if _, err := tx.ExecContext(ctx, s.insertIgnoreSQL(s.tables.resolutions, "(resolution_milli)", "("+s.dialect.placeholder(1)+")"), milli); err != nil {
return 0, err
}
err := tx.QueryRowContext(ctx, lookup, milli).Scan(&id)
return id, err
}
func joinSQL(parts []string) string {
if len(parts) == 0 {
return ""
}
out := parts[0]
for _, part := range parts[1:] {
out += ", " + part
}
return out
}
func (s *Store) normalizedRollupUpsertSuffix() string {
switch s.cfg.Driver {
case DriverMySQL:
return " ON DUPLICATE KEY UPDATE count=VALUES(count), sum=VALUES(sum), sum_sq=VALUES(sum_sq), min_val=VALUES(min_val), max_val=VALUES(max_val), first_val=VALUES(first_val), first_ts_milli=VALUES(first_ts_milli), last_val=VALUES(last_val), last_ts_milli=VALUES(last_ts_milli), digest=VALUES(digest), created_at_milli=VALUES(created_at_milli)"
case DriverPostgreSQL:
return " ON CONFLICT(series_id, resolution_id, label_id, bucket_milli) DO UPDATE SET count=EXCLUDED.count, sum=EXCLUDED.sum, sum_sq=EXCLUDED.sum_sq, min_val=EXCLUDED.min_val, max_val=EXCLUDED.max_val, first_val=EXCLUDED.first_val, first_ts_milli=EXCLUDED.first_ts_milli, last_val=EXCLUDED.last_val, last_ts_milli=EXCLUDED.last_ts_milli, digest=EXCLUDED.digest, created_at_milli=EXCLUDED.created_at_milli"
default:
return " ON CONFLICT(series_id, resolution_id, label_id, bucket_milli) DO UPDATE SET count=excluded.count, sum=excluded.sum, sum_sq=excluded.sum_sq, min_val=excluded.min_val, max_val=excluded.max_val, first_val=excluded.first_val, first_ts_milli=excluded.first_ts_milli, last_val=excluded.last_val, last_ts_milli=excluded.last_ts_milli, digest=excluded.digest, created_at_milli=excluded.created_at_milli"
}
}
func (s *Store) upsertNormalizedRollupRowsTx(ctx context.Context, rows []normalizedRollupRow, tx *sql.Tx) error {
if len(rows) == 0 {
return nil
}
valueGroups := make([]string, 0, len(rows))
args := make([]any, 0, len(rows)*15)
placeholder := 1
for _, row := range rows {
values := make([]string, 15)
for i := range values {
values[i] = s.dialect.placeholder(placeholder)
placeholder++
}
valueGroups = append(valueGroups, "("+joinSQL(values)+")")
args = append(args,
row.seriesID, row.resolutionID, row.labelID, row.bucketMilli,
row.count, row.sum, row.sumSq, row.min, row.max,
row.firstVal, row.firstTSMilli, row.lastVal, row.lastTSMilli,
row.digest, row.createdAtMilli,
)
}
_, err := tx.ExecContext(ctx, fmt.Sprintf("INSERT INTO %s %s VALUES %s%s",
s.tables.rollups, normalizedRollupColumns, joinSQL(valueGroups), s.normalizedRollupUpsertSuffix()), args...)
return err
}
func (s *Store) upsertRollupWithDictionaryTx(ctx context.Context, metricName string, interval time.Duration, key rollupKey, bucket *rollupBucket, cache *rollupDictionaryCache, tx *sql.Tx) error {
seriesID, err := cache.seriesID(ctx, s, tx, metricName, key, bucket.tagsJSON)
if err != nil {
return err
}
labelID, err := cache.labelID(ctx, s, tx, key.labelsHash, bucket.labelsJSON)
if err != nil {
return err
}
resolutionID, err := cache.resolutionID(ctx, s, tx, interval)
if err != nil {
return err
}
return s.upsertNormalizedRollupRowsTx(ctx, []normalizedRollupRow{{
seriesID: seriesID, resolutionID: resolutionID, labelID: labelID,
bucketMilli: key.bucket, count: bucket.count, sum: bucket.sum, sumSq: bucket.sumSq,
min: bucket.min, max: bucket.max, firstVal: bucket.firstVal, firstTSMilli: bucket.firstTS,
lastVal: bucket.lastVal, lastTSMilli: bucket.lastTS, digest: bucket.encodedDigest(),
createdAtMilli: timeMillis(time.Now()),
}}, tx)
}
+380 -15
View File
@@ -19,6 +19,7 @@ func TestPostgreSQLIntegration(t *testing.T) {
}
runSQLIntegration(t, "postgres", PostgreSQL(dsn), true)
runSQLRestructureIntegration(t, "postgres", PostgreSQL(dsn))
}
// TestMySQLIntegration runs the MySQL integration test when configured.
@@ -31,6 +32,7 @@ func TestMySQLIntegration(t *testing.T) {
}
runSQLIntegration(t, "mysql", MySQL(dsn), false)
runSQLRestructureIntegration(t, "mysql", MySQL(dsn))
}
// runSQLIntegration exercises the SQL store against an external database.
@@ -39,10 +41,14 @@ func TestMySQLIntegration(t *testing.T) {
func runSQLIntegration(t *testing.T, name string, cfg Config, expectSQLPercentile bool) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
defer cancel()
prefix := fmt.Sprintf("it_%d_", time.Now().UnixNano())
prefixBase := "it"
if cfg.Driver == DriverPostgreSQL {
prefixBase = "IT"
}
prefix := fmt.Sprintf("%s_%d_", prefixBase, time.Now().UnixNano())
cfg.TablePrefix = prefix
store, err := Open(ctx, cfg)
if err != nil {
@@ -50,6 +56,7 @@ func runSQLIntegration(t *testing.T, name string, cfg Config, expectSQLPercentil
}
defer store.Close()
defer dropIntegrationTables(t, store, prefix)
assertIntegrationForeignKeys(t, store)
if err := store.CreateMetric(ctx, Definition{
Name: "http.latency",
@@ -74,12 +81,16 @@ func runSQLIntegration(t *testing.T, name string, cfg Config, expectSQLPercentil
t.Fatalf("upsert did not update unit: %#v", def)
}
base := time.Date(2026, 6, 20, 1, 0, 0, 0, time.UTC)
now := time.Now().UTC()
base := now.Truncate(time.Minute)
if elapsed := now.Sub(base); elapsed < time.Second {
time.Sleep(time.Second - elapsed)
}
points := []Point{
{MetricName: "http.latency", EntityID: "api-1", Timestamp: base, Value: 10, Tags: map[string]string{"region.zone": "ap-1", "route-name": "/v1/nodes"}},
{MetricName: "http.latency", EntityID: "api-1", Timestamp: base.Add(time.Minute), Value: 20, Tags: map[string]string{"region.zone": "ap-1", "route-name": "/v1/nodes"}},
{MetricName: "http.latency", EntityID: "api-1", Timestamp: base.Add(2 * time.Minute), Value: 30, Tags: map[string]string{"region.zone": "ap-1", "route-name": "/v1/nodes"}},
{MetricName: "http.latency", EntityID: "api-1", Timestamp: base.Add(3 * time.Minute), Value: 40, Tags: map[string]string{"region.zone": "eu-1", "route-name": "/v1/nodes"}},
{MetricName: "http.latency", EntityID: "api-1", Timestamp: base.Add(100 * time.Millisecond), Value: 20, Tags: map[string]string{"region.zone": "ap-1", "route-name": "/v1/nodes"}},
{MetricName: "http.latency", EntityID: "api-1", Timestamp: base.Add(200 * time.Millisecond), Value: 30, Tags: map[string]string{"region.zone": "ap-1", "route-name": "/v1/nodes"}},
{MetricName: "http.latency", EntityID: "api-1", Timestamp: base.Add(300 * time.Millisecond), Value: 40, Tags: map[string]string{"region.zone": "eu-1", "route-name": "/v1/nodes"}},
}
if err := store.WriteBatch(ctx, points); err != nil {
t.Fatalf("write batch: %v", err)
@@ -89,7 +100,7 @@ func runSQLIntegration(t *testing.T, name string, cfg Config, expectSQLPercentil
MetricName: "http.latency",
EntityID: "api-1",
Start: base.Add(-time.Second),
End: base.Add(5 * time.Minute),
End: base.Add(time.Second),
Tags: map[string]string{"region.zone": "ap-1", "route-name": "/v1/nodes"},
})
if err != nil {
@@ -103,7 +114,7 @@ func runSQLIntegration(t *testing.T, name string, cfg Config, expectSQLPercentil
MetricName: "http.latency",
EntityID: "api-1",
Start: base,
End: base.Add(5 * time.Minute),
End: base.Add(time.Second),
})
if err != nil {
t.Fatalf("stats: %v", err)
@@ -117,17 +128,18 @@ func runSQLIntegration(t *testing.T, name string, cfg Config, expectSQLPercentil
MetricName: "http.latency",
EntityID: "api-1",
Start: base,
End: base.Add(5 * time.Minute),
End: base.Add(time.Second),
Tags: map[string]string{"region.zone": "ap-1"},
},
Aggregation: AggAvg,
Interval: 2 * time.Minute,
Interval: 200 * time.Millisecond,
BucketLimit: 1,
BucketOffset: 1,
})
if err != nil {
t.Fatalf("avg aggregate: %v", err)
}
if len(avgBuckets) != 1 || avgBuckets[0].Value != 35 || avgBuckets[0].Count != 2 {
if len(avgBuckets) != 1 || avgBuckets[0].Value != 30 || avgBuckets[0].Count != 1 {
t.Fatalf("unexpected bucket-paged avg aggregate: %#v", avgBuckets)
}
@@ -136,7 +148,8 @@ func runSQLIntegration(t *testing.T, name string, cfg Config, expectSQLPercentil
MetricName: "http.latency",
EntityID: "api-1",
Start: base,
End: base.Add(5 * time.Minute),
End: base.Add(time.Second),
Tags: map[string]string{"region.zone": "ap-1"},
},
Aggregation: AggP95,
Interval: 10 * time.Minute,
@@ -144,7 +157,7 @@ func runSQLIntegration(t *testing.T, name string, cfg Config, expectSQLPercentil
if err != nil {
t.Fatalf("p95 aggregate: %v", err)
}
if len(p95Buckets) != 1 || p95Buckets[0].Count != 4 || p95Buckets[0].Value <= 38 {
if len(p95Buckets) != 1 || p95Buckets[0].Count != 3 || p95Buckets[0].Value <= 28 {
t.Fatalf("unexpected p95 aggregate: %#v", p95Buckets)
}
if _, ok := sqlAggValueExpr(cfg.Driver, AggP95); ok != expectSQLPercentile {
@@ -158,8 +171,26 @@ func runSQLIntegration(t *testing.T, name string, cfg Config, expectSQLPercentil
if len(latest) != 1 || latest[0].Value != 40 {
t.Fatalf("unexpected latest point: %#v", latest)
}
if err := store.flushAllHotRollups(ctx); err != nil {
t.Fatalf("flush rollups: %v", err)
}
var transferred []PersistedRollup
total, err := store.ExportRollups(ctx, "http.latency", 2, func(batch []PersistedRollup) error {
transferred = append(transferred, batch...)
return nil
})
if err != nil {
t.Fatalf("export rollups: %v", err)
}
if total == 0 || total != int64(len(transferred)) {
t.Fatalf("exported rollups = %d, buffered %d", total, len(transferred))
}
if err := store.ImportRollups(ctx, transferred); err != nil {
t.Fatalf("idempotently import exported rollups: %v", err)
}
assertIntegrationLateReplacement(t, ctx, store)
deleted, err := store.DeleteBefore(ctx, "http.latency", base.Add(2*time.Minute))
deleted, err := store.DeleteBefore(ctx, "http.latency", base.Add(200*time.Millisecond))
if err != nil {
t.Fatalf("delete before: %v", err)
}
@@ -180,6 +211,319 @@ func runSQLIntegration(t *testing.T, name string, cfg Config, expectSQLPercentil
if err := store.Ping(ctx); err != nil {
t.Fatalf("store unusable after space reclaim: %v", err)
}
discarded, err := store.DiscardHistory(ctx, nil)
if err != nil {
t.Fatalf("discard %s history: %v", name, err)
}
if discarded.Metrics != 2 || discarded.RowsCopied == 0 {
t.Fatalf("discard %s result = %#v", name, discarded)
}
if needs, err := store.NeedsRestructure(ctx); err != nil || needs {
t.Fatalf("discarded %s store needs restructure: needs=%v err=%v", name, needs, err)
}
if _, err := store.GetMetric(ctx, "http.latency"); err != nil {
t.Fatalf("discard removed %s definition: %v", name, err)
}
if err := store.Write(ctx, Point{MetricName: "http.latency", EntityID: "api-1", Timestamp: time.Now().UTC(), Value: 50}); err != nil {
t.Fatalf("write after %s discard: %v", name, err)
}
}
func assertIntegrationLateReplacement(t *testing.T, ctx context.Context, store *Store) {
t.Helper()
const metricName = "late.replacement"
if err := store.CreateMetric(ctx, Definition{Name: metricName, Type: TypeGauge, RetentionDays: 30}); err != nil {
t.Fatalf("create replacement metric: %v", err)
}
at := time.Now().UTC().Truncate(time.Minute).Add(-30 * time.Second)
for _, point := range []Point{
{MetricName: metricName, EntityID: "api-1", Timestamp: at, Value: 1, Labels: map[string]string{"source": "old"}},
{MetricName: metricName, EntityID: "api-1", Timestamp: at, Value: 7, Labels: map[string]string{"source": "new"}},
} {
if err := store.Write(ctx, point); err != nil {
t.Fatalf("write replacement point: %v", err)
}
}
for _, tier := range store.cfg.RollupPolicy.Tiers {
bucket := bucketStartMillis(at.UnixMilli(), tier.Interval.Milliseconds())
rows, err := store.scanRollupRowsBetween(ctx, metricName, "api-1", nil, tier.Interval.Milliseconds(), bucket, bucket, true)
if err != nil {
t.Fatalf("scan replacement %s tier: %v", tier.Interval, err)
}
if len(rows) != 1 || rows[0].bucketData.count != 1 || rows[0].bucketData.sum != 7 {
t.Fatalf("replacement %s tier = %#v, want one count=1 sum=7 row", tier.Interval, rows)
}
labels, err := decodeMapString(rows[0].bucketData.labelsJSON)
if err != nil || labels["source"] != "new" {
t.Fatalf("replacement %s labels = %#v, %v", tier.Interval, labels, err)
}
}
}
func runSQLRestructureIntegration(t *testing.T, name string, cfg Config) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
defer cancel()
prefixBase := "rit"
if cfg.Driver == DriverPostgreSQL {
prefixBase = "RIT"
}
prefix := fmt.Sprintf("%s_%d_", prefixBase, time.Now().UnixNano())
cfg.TablePrefix = prefix
cfg.AutoMigrate = false
store, err := Open(ctx, cfg)
if err != nil {
t.Fatalf("open legacy %s store: %v", name, err)
}
defer store.Close()
defer dropIntegrationTables(t, store, prefix)
d := store.dialect
legacyDDL := []string{
fmt.Sprintf(`CREATE TABLE %s (
name VARCHAR(191) PRIMARY KEY, type VARCHAR(32) NOT NULL, unit VARCHAR(64) NOT NULL,
description TEXT NOT NULL, retention_days INTEGER NOT NULL, metadata %s NOT NULL,
created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL)`, store.tables.definitions, d.jsonType()),
fmt.Sprintf(`CREATE TABLE %s (
id BIGINT PRIMARY KEY, metric_name VARCHAR(191) NOT NULL, entity_id VARCHAR(191) NOT NULL,
tags_hash VARCHAR(64) NOT NULL, ts_nano BIGINT NOT NULL, value DOUBLE PRECISION NOT NULL,
tags %s NOT NULL, labels %s NOT NULL, created_at BIGINT NOT NULL)`, store.tables.points, d.jsonType(), d.jsonType()),
fmt.Sprintf(`CREATE TABLE %s (
id BIGINT PRIMARY KEY, metric_name VARCHAR(191) NOT NULL, entity_id VARCHAR(191) NOT NULL,
tags_hash VARCHAR(64) NOT NULL, tags %s NOT NULL, resolution_nano BIGINT NOT NULL,
bucket_nano BIGINT NOT NULL, count BIGINT NOT NULL, sum DOUBLE PRECISION NOT NULL,
sum_sq DOUBLE PRECISION NOT NULL, min_val DOUBLE PRECISION NOT NULL, max_val DOUBLE PRECISION NOT NULL,
first_val DOUBLE PRECISION NOT NULL, first_ts BIGINT NOT NULL, last_val DOUBLE PRECISION NOT NULL,
last_ts BIGINT NOT NULL, digest %s, created_at BIGINT NOT NULL)`, store.tables.rollups, d.jsonType(), d.blobType()),
fmt.Sprintf(`CREATE TABLE %s (metric_name VARCHAR(191) PRIMARY KEY, watermark_nano BIGINT NOT NULL, updated_at BIGINT NOT NULL)`, store.tables.watermarks),
}
for _, statement := range legacyDDL {
if _, err := store.db.ExecContext(ctx, statement); err != nil {
t.Fatalf("create legacy %s schema: %v", name, err)
}
}
at := time.Now().UTC().Truncate(time.Minute).Add(-2 * time.Minute)
created := at.UnixNano()
definitionSQL := fmt.Sprintf(`INSERT INTO %s
(name, type, unit, description, retention_days, metadata, created_at, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)`, store.tables.definitions,
d.placeholder(1), d.placeholder(2), d.placeholder(3), d.placeholder(4),
d.placeholder(5), d.jsonPlaceholder(6), d.placeholder(7), d.placeholder(8))
if _, err := store.db.ExecContext(ctx, definitionSQL, "legacy.metric", TypeGauge, "ms", "legacy fixture", 30, "{}", created, created); err != nil {
t.Fatalf("seed legacy %s definition: %v", name, err)
}
tagsHash, tagsJSON, err := tagsFingerprint(map[string]string{"source": "legacy"})
if err != nil {
t.Fatalf("fingerprint legacy tags: %v", err)
}
pointSQL := fmt.Sprintf(`INSERT INTO %s
(id, metric_name, entity_id, tags_hash, ts_nano, value, tags, labels, created_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)`, store.tables.points,
d.placeholder(1), d.placeholder(2), d.placeholder(3), d.placeholder(4), d.placeholder(5),
d.placeholder(6), d.jsonPlaceholder(7), d.jsonPlaceholder(8), d.placeholder(9))
for i, value := range []float64{10, 20} {
pointAt := at.Add(time.Duration(i) * 10 * time.Second)
if _, err := store.db.ExecContext(ctx, pointSQL, i+1, "legacy.metric", "node-1", tagsHash, pointAt.UnixNano(), value, tagsJSON, "{}", created); err != nil {
t.Fatalf("seed legacy %s point %d: %v", name, i, err)
}
}
hourAt := at.Add(-2 * time.Hour).Truncate(time.Hour)
rollupSQL := fmt.Sprintf(`INSERT INTO %s
(id, metric_name, entity_id, tags_hash, tags, resolution_nano, bucket_nano, count,
sum, sum_sq, min_val, max_val, first_val, first_ts, last_val, last_ts, digest, created_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)`, store.tables.rollups,
d.placeholder(1), d.placeholder(2), d.placeholder(3), d.placeholder(4), d.jsonPlaceholder(5), d.placeholder(6),
d.placeholder(7), d.placeholder(8), d.placeholder(9), d.placeholder(10), d.placeholder(11), d.placeholder(12),
d.placeholder(13), d.placeholder(14), d.placeholder(15), d.placeholder(16), d.placeholder(17), d.placeholder(18))
if _, err := store.db.ExecContext(ctx, rollupSQL,
1, "legacy.metric", "node-1", tagsHash, tagsJSON, time.Hour.Nanoseconds(), hourAt.UnixNano(),
1, 7, 49, 7, 7, 7, hourAt.UnixNano(), 7, hourAt.UnixNano(), nil, created,
); err != nil {
t.Fatalf("seed legacy %s rollup: %v", name, err)
}
result, err := store.Restructure(ctx, nil)
if err != nil {
t.Fatalf("restructure legacy %s store: %v", name, err)
}
if result.RowsCopied != 3 || result.Metrics != 1 {
t.Fatalf("legacy %s restructure result = %#v", name, result)
}
if needs, err := store.NeedsRestructure(ctx); err != nil || needs {
t.Fatalf("legacy %s still needs restructure: needs=%v err=%v", name, needs, err)
}
if exists, err := store.tableExists(ctx, store.tables.points); err != nil || exists {
t.Fatalf("legacy %s points table remains: exists=%v err=%v", name, exists, err)
}
assertIntegrationForeignKeys(t, store)
minute, err := store.AggregateRollup(ctx, AggregateQuery{
Query: Query{MetricName: "legacy.metric", EntityID: "node-1", Start: at.Add(-time.Minute), End: at.Add(time.Minute)},
Aggregation: AggAvg,
Interval: time.Minute,
}, time.Minute)
if err != nil {
t.Fatalf("query restructured %s minute rollup: %v", name, err)
}
if len(minute) != 1 || minute[0].Count != 2 || minute[0].Value != 15 {
t.Fatalf("restructured %s minute rollup = %#v", name, minute)
}
dropIntegrationForeignKeys(t, ctx, store)
if needs, err := store.NeedsRestructure(ctx); err != nil || !needs {
t.Fatalf("%s schema without foreign keys needs repair: needs=%v err=%v", name, needs, err)
}
repaired, err := store.Restructure(ctx, nil)
if err != nil {
t.Fatalf("repair %s foreign keys: %v", name, err)
}
if repaired.RowsCopied == 0 || repaired.Metrics != 1 {
t.Fatalf("%s foreign-key repair result = %#v", name, repaired)
}
assertIntegrationForeignKeys(t, store)
minute, err = store.AggregateRollup(ctx, AggregateQuery{
Query: Query{MetricName: "legacy.metric", EntityID: "node-1", Start: at.Add(-time.Minute), End: at.Add(time.Minute)},
Aggregation: AggAvg,
Interval: time.Minute,
}, time.Minute)
if err != nil || len(minute) != 1 || minute[0].Count != 2 || minute[0].Value != 15 {
t.Fatalf("%s rollup after foreign-key repair = %#v, err=%v", name, minute, err)
}
if store.cfg.Driver == DriverMySQL {
renameIntegrationIndexesToRebuild(t, ctx, store)
backup := store.tables.definitions + "_legacy"
if _, err := store.db.ExecContext(ctx, fmt.Sprintf("CREATE TABLE %s LIKE %s", backup, store.tables.definitions)); err != nil {
t.Fatalf("create interrupted MySQL backup: %v", err)
}
if needs, err := store.NeedsRestructure(ctx); err != nil || !needs {
t.Fatalf("interrupted MySQL backup needs cleanup: needs=%v err=%v", needs, err)
}
if _, err := store.Restructure(ctx, nil); err != nil {
t.Fatalf("clean interrupted MySQL backup: %v", err)
}
if exists, err := store.tableExists(ctx, backup); err != nil || exists {
t.Fatalf("interrupted MySQL backup remains: exists=%v err=%v", exists, err)
}
if needs, err := store.NeedsRestructure(ctx); err != nil || needs {
t.Fatalf("MySQL store still needs cleanup: needs=%v err=%v", needs, err)
}
assertMySQLCanonicalIndexes(t, ctx, store)
}
}
func renameIntegrationIndexesToRebuild(t *testing.T, ctx context.Context, store *Store) {
t.Helper()
current := store.normalizedIndexes()
rebuild := normalizedIndexesFor(store.cfg.TablePrefix+"rebuild_", store.tables)
for i := range current {
statement := fmt.Sprintf("ALTER TABLE %s RENAME INDEX %s TO %s",
current[i].table, current[i].name, rebuild[i].name)
if _, err := store.db.ExecContext(ctx, statement); err != nil {
t.Fatalf("simulate interrupted MySQL index switch: %v", err)
}
}
}
func assertMySQLCanonicalIndexes(t *testing.T, ctx context.Context, store *Store) {
t.Helper()
current := store.normalizedIndexes()
rebuild := normalizedIndexesFor(store.cfg.TablePrefix+"rebuild_", store.tables)
for i := range current {
canonicalExists, err := store.mysqlIndexExists(ctx, current[i].table, current[i].name)
if err != nil {
t.Fatalf("inspect canonical MySQL index %s: %v", current[i].name, err)
}
rebuildExists, err := store.mysqlIndexExists(ctx, current[i].table, rebuild[i].name)
if err != nil {
t.Fatalf("inspect rebuild MySQL index %s: %v", rebuild[i].name, err)
}
if !canonicalExists || rebuildExists {
t.Fatalf("MySQL index recovery for %s: canonical=%v rebuild=%v", current[i].table, canonicalExists, rebuildExists)
}
}
}
func dropIntegrationForeignKeys(t *testing.T, ctx context.Context, store *Store) {
t.Helper()
seriesTable, rollupsTable := store.tables.series, store.tables.rollups
var query string
switch store.cfg.Driver {
case DriverMySQL:
query = `SELECT TABLE_NAME, CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_TYPE = 'FOREIGN KEY'
AND TABLE_NAME IN (?, ?)`
case DriverPostgreSQL:
seriesTable, rollupsTable = strings.ToLower(seriesTable), strings.ToLower(rollupsTable)
query = `SELECT table_name, constraint_name FROM information_schema.table_constraints
WHERE table_schema = current_schema() AND constraint_type = 'FOREIGN KEY'
AND table_name IN ($1, $2)`
default:
t.Fatalf("unexpected integration driver %q", store.cfg.Driver)
}
rows, err := store.db.QueryContext(ctx, query, seriesTable, rollupsTable)
if err != nil {
t.Fatalf("list %s foreign keys: %v", store.cfg.Driver, err)
}
type constraint struct{ table, name string }
constraints := make([]constraint, 0, 4)
for rows.Next() {
var item constraint
if err := rows.Scan(&item.table, &item.name); err != nil {
_ = rows.Close()
t.Fatalf("scan %s foreign key: %v", store.cfg.Driver, err)
}
constraints = append(constraints, item)
}
if err := rows.Err(); err != nil {
_ = rows.Close()
t.Fatalf("iterate %s foreign keys: %v", store.cfg.Driver, err)
}
if err := rows.Close(); err != nil {
t.Fatalf("close %s foreign keys: %v", store.cfg.Driver, err)
}
if len(constraints) != 4 {
t.Fatalf("%s foreign keys before removal = %d, want 4", store.cfg.Driver, len(constraints))
}
clause := "DROP CONSTRAINT"
if store.cfg.Driver == DriverMySQL {
clause = "DROP FOREIGN KEY"
}
for _, item := range constraints {
statement := fmt.Sprintf("ALTER TABLE %s %s %s",
quoteMaintenanceIdentifier(store.cfg.Driver, item.table), clause,
quoteMaintenanceIdentifier(store.cfg.Driver, item.name))
if _, err := store.db.ExecContext(ctx, statement); err != nil {
t.Fatalf("drop %s foreign key %s: %v", store.cfg.Driver, item.name, err)
}
}
}
func assertIntegrationForeignKeys(t *testing.T, store *Store) {
t.Helper()
var query string
switch store.cfg.Driver {
case DriverMySQL:
query = `SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_TYPE = 'FOREIGN KEY'
AND TABLE_NAME IN (?, ?)`
case DriverPostgreSQL:
query = `SELECT COUNT(*) FROM information_schema.table_constraints
WHERE table_schema = current_schema() AND constraint_type = 'FOREIGN KEY'
AND table_name IN ($1, $2)`
default:
t.Fatalf("unexpected integration driver %q", store.cfg.Driver)
}
var count int
seriesTable, rollupsTable := store.tables.series, store.tables.rollups
if store.cfg.Driver == DriverPostgreSQL {
seriesTable, rollupsTable = strings.ToLower(seriesTable), strings.ToLower(rollupsTable)
}
if err := store.db.QueryRow(query, seriesTable, rollupsTable).Scan(&count); err != nil {
t.Fatalf("query foreign key metadata: %v", err)
}
if count != 4 {
t.Fatalf("foreign key count = %d, want 4", count)
}
}
// dropIntegrationTables drops integration-test tables.
@@ -190,7 +534,28 @@ func dropIntegrationTables(t *testing.T, store *Store, prefix string) {
if strings.TrimSpace(prefix) == "" {
t.Fatal("refusing to drop tables with empty prefix")
}
for _, name := range []string{prefix + "rollups", prefix + "points", prefix + "definitions"} {
for _, name := range []string{
prefix + "rebuild_points",
prefix + "rebuild_rollups",
prefix + "rebuild_series",
prefix + "rebuild_label_sets",
prefix + "rebuild_resolutions",
prefix + "rebuild_definitions",
prefix + "compaction_watermarks_legacy",
prefix + "points_legacy",
prefix + "rollups_legacy",
prefix + "series_legacy",
prefix + "label_sets_legacy",
prefix + "resolutions_legacy",
prefix + "definitions_legacy",
prefix + "compaction_watermarks",
prefix + "points",
prefix + "rollups",
prefix + "series",
prefix + "label_sets",
prefix + "resolutions",
prefix + "definitions",
} {
if _, err := store.db.Exec("DROP TABLE IF EXISTS " + name); err != nil {
t.Fatalf("drop integration table %s: %v", name, err)
}
+639
View File
@@ -0,0 +1,639 @@
package metric
import (
"context"
"database/sql"
"encoding/binary"
"fmt"
"math"
"sort"
"time"
)
const (
// directRawRetention keeps the newest samples in a directly searchable
// slice. The rest of rawMemoryRetention is losslessly byte-encoded.
directRawRetention = time.Minute
rawMemoryRetention = 10 * time.Minute
)
type rawSeriesKey struct {
metricName string
entityID string
tagsHash string
}
type rawSample struct {
timestamp int64
value float64
labelID uint32
}
// compressedRawSamples stores older raw samples without aggregating them.
// Timestamps and label ids use varints; values remain IEEE-754 bits so the
// raw API can return the original samples exactly.
type compressedRawSamples struct {
data []byte
count int
lastStamp int64
}
// rawSeries stores exact samples compactly for one logical series. Tags are
// shared by the series and labels are interned into small integer ids.
type rawSeries struct {
tagsJSON string
labelIDs map[string]uint32
labelHashes []string
labelsJSON []string
samples []rawSample
compressed compressedRawSamples
}
// preparedMetricPoint holds the compact dictionary keys shared by the raw
// writer and the in-memory minute accumulator.
type preparedMetricPoint struct {
metricName string
entityID string
tagsHash string
tagsJSON string
labelsHash string
labelsJSON string
timestamp int64
value float64
}
func prepareMetricPoints(points []Point) ([]preparedMetricPoint, error) {
prepared := make([]preparedMetricPoint, 0, len(points))
for i, point := range points {
if err := point.Validate(); err != nil {
return nil, fmt.Errorf("point %d (metric %q, entity %q): %w", i, point.MetricName, point.EntityID, err)
}
point = point.normalized()
tagsHash, tagsJSON, err := tagsFingerprint(point.Tags)
if err != nil {
return nil, err
}
labelsHash, labelsJSON, err := tagsFingerprint(point.Labels)
if err != nil {
return nil, err
}
prepared = append(prepared, preparedMetricPoint{
metricName: point.MetricName,
entityID: point.EntityID,
tagsHash: tagsHash,
tagsJSON: tagsJSON,
labelsHash: labelsHash,
labelsJSON: labelsJSON,
timestamp: point.Timestamp.UnixMilli(),
value: point.Value,
})
}
return prepared, nil
}
// writeRawPoints keeps the newest minute in a directly addressable form. The
// surrounding nine minutes remain individual raw samples in a compact byte
// representation; only their minute rollups are eventually persisted.
func (s *Store) writeRawPoints(ctx context.Context, points []preparedMetricPoint) (map[hotRollupKey]struct{}, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if len(points) == 0 {
return nil, nil
}
return s.writeRawPointsAt(points, time.Now().UTC()), nil
}
func (s *Store) writeRawPointsAt(points []preparedMetricPoint, now time.Time) map[hotRollupKey]struct{} {
now = now.UTC()
directCutoff := now.Add(-directRawRetention).UnixMilli()
rawCutoff := rawMemoryCutoff(now)
rebuild := make(map[hotRollupKey]struct{})
dirtyLabels := make(map[*rawSeries]struct{})
s.rawMu.Lock()
defer s.rawMu.Unlock()
s.compressRawBeforeLocked("", directCutoff)
s.pruneRawBeforeLocked("", rawCutoff)
for _, point := range points {
if point.timestamp < rawCutoff {
continue
}
key := rawSeriesKey{metricName: point.metricName, entityID: point.entityID, tagsHash: point.tagsHash}
series := s.raw[key]
if series == nil {
series = &rawSeries{tagsJSON: point.tagsJSON, labelIDs: make(map[string]uint32)}
s.raw[key] = series
}
labelID, ok := series.labelIDs[point.labelsHash]
if !ok {
labelID = uint32(len(series.labelsJSON))
series.labelIDs[point.labelsHash] = labelID
series.labelHashes = append(series.labelHashes, point.labelsHash)
series.labelsJSON = append(series.labelsJSON, point.labelsJSON)
}
sample := rawSample{timestamp: point.timestamp, value: point.value, labelID: labelID}
if point.timestamp < directCutoff {
old, replaced := series.replaceCompressed(sample)
if replaced {
markRawRebuild(rebuild, point, series.labelHashes[old.labelID])
if old.labelID != sample.labelID {
dirtyLabels[series] = struct{}{}
}
}
markRawRebuild(rebuild, point, point.labelsHash)
continue
}
index := sort.Search(len(series.samples), func(i int) bool {
return series.samples[i].timestamp >= point.timestamp
})
if index < len(series.samples) && series.samples[index].timestamp == point.timestamp {
old := series.samples[index]
markRawRebuild(rebuild, point, series.labelHashes[old.labelID])
markRawRebuild(rebuild, point, point.labelsHash)
series.samples[index] = sample
if old.labelID != sample.labelID {
dirtyLabels[series] = struct{}{}
}
continue
}
series.samples = append(series.samples, rawSample{})
copy(series.samples[index+1:], series.samples[index:])
series.samples[index] = sample
}
for series := range dirtyLabels {
compactRawLabels(series)
}
return rebuild
}
func markRawRebuild(rebuild map[hotRollupKey]struct{}, point preparedMetricPoint, labelsHash string) {
rebuild[hotRollupKey{metricName: point.metricName, entityID: point.entityID, tagsHash: point.tagsHash, labelsHash: labelsHash, bucket: bucketStartMillis(point.timestamp, time.Minute.Milliseconds())}] = struct{}{}
}
func (s *Store) queryRawPoints(ctx context.Context, query Query) ([]Point, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
start, end := query.Start.UnixMilli(), query.End.UnixMilli()
s.rawMu.Lock()
defer s.rawMu.Unlock()
now := time.Now().UTC()
visibleCutoff := now.Add(-rawMemoryRetention).UnixMilli()
if start < visibleCutoff {
start = visibleCutoff
}
s.compressRawBeforeLocked("", now.Add(-directRawRetention).UnixMilli())
s.pruneRawBeforeLocked("", rawMemoryCutoff(now))
type resultPoint struct {
point Point
entityID string
tagsHash string
}
matched := make([]resultPoint, 0)
for key, series := range s.raw {
if key.metricName != query.MetricName || (query.EntityID != "" && key.entityID != query.EntityID) {
continue
}
tags, ok, err := matchRawTags(series.tagsJSON, query.Tags)
if err != nil {
return nil, err
}
if !ok {
continue
}
all := series.allSamples()
labels := make(map[uint32]map[string]string)
first := sort.Search(len(all), func(i int) bool { return all[i].timestamp >= start })
for _, sample := range all[first:] {
if sample.timestamp > end {
break
}
labelMap := labels[sample.labelID]
if labelMap == nil {
labelMap, err = decodeMapString(series.labelsJSON[sample.labelID])
if err != nil {
return nil, err
}
labels[sample.labelID] = labelMap
}
matched = append(matched, resultPoint{
point: Point{MetricName: key.metricName, EntityID: key.entityID, Timestamp: fromMillis(sample.timestamp), Value: sample.value, Tags: cloneStringMap(tags), Labels: cloneStringMap(labelMap)},
entityID: key.entityID,
tagsHash: key.tagsHash,
})
}
}
sort.Slice(matched, func(i, j int) bool {
left, right := matched[i], matched[j]
less := left.point.Timestamp.Before(right.point.Timestamp)
if left.point.Timestamp.Equal(right.point.Timestamp) {
less = left.entityID < right.entityID || (left.entityID == right.entityID && left.tagsHash < right.tagsHash)
}
if query.Order == OrderDesc {
return !less && !(left.point.Timestamp.Equal(right.point.Timestamp) && left.entityID == right.entityID && left.tagsHash == right.tagsHash)
}
return less
})
startIndex := query.Offset
if startIndex > len(matched) {
startIndex = len(matched)
}
endIndex := len(matched)
if query.Limit > 0 && startIndex+query.Limit < endIndex {
endIndex = startIndex + query.Limit
}
result := make([]Point, 0, endIndex-startIndex)
for _, item := range matched[startIndex:endIndex] {
result = append(result, item.point)
}
return result, nil
}
func (s *Store) rawEntityIDs(ctx context.Context, query Query) ([]string, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
start, end := query.Start.UnixMilli(), query.End.UnixMilli()
s.rawMu.Lock()
defer s.rawMu.Unlock()
now := time.Now().UTC()
visibleCutoff := now.Add(-rawMemoryRetention).UnixMilli()
if start < visibleCutoff {
start = visibleCutoff
}
s.compressRawBeforeLocked("", now.Add(-directRawRetention).UnixMilli())
s.pruneRawBeforeLocked("", rawMemoryCutoff(now))
seen := make(map[string]struct{})
for key, series := range s.raw {
if key.metricName != query.MetricName || (query.EntityID != "" && key.entityID != query.EntityID) {
continue
}
_, ok, err := matchRawTags(series.tagsJSON, query.Tags)
if err != nil {
return nil, err
}
if !ok {
continue
}
all := series.allSamples()
index := sort.Search(len(all), func(i int) bool { return all[i].timestamp >= start })
if index < len(all) && all[index].timestamp <= end {
seen[key.entityID] = struct{}{}
}
}
result := make([]string, 0, len(seen))
for entityID := range seen {
result = append(result, entityID)
}
sort.Strings(result)
return result, nil
}
func matchRawTags(raw string, filter map[string]string) (map[string]string, bool, error) {
tags, err := decodeMapString(raw)
if err != nil {
return nil, false, err
}
for key, value := range filter {
if tags[key] != value {
return tags, false, nil
}
}
return tags, true, nil
}
// rawMemoryCutoff keeps the complete oldest minute internally so a late
// replacement can rebuild that minute without dropping its earlier samples.
// Query visibility is still clamped to the exact ten-minute window.
func rawMemoryCutoff(now time.Time) int64 {
return bucketStartMillis(now.UTC().Add(-rawMemoryRetention).UnixMilli(), time.Minute.Milliseconds())
}
func (s *Store) trimRawWindow(now time.Time) int64 {
now = now.UTC()
s.rawMu.Lock()
defer s.rawMu.Unlock()
s.compressRawBeforeLocked("", now.Add(-directRawRetention).UnixMilli())
return s.pruneRawBeforeLocked("", rawMemoryCutoff(now))
}
func (s *Store) deleteRawPoints(metricName, entityID string, tags map[string]string) (int64, error) {
s.rawMu.Lock()
defer s.rawMu.Unlock()
var deleted int64
for key, series := range s.raw {
if metricName != "" && key.metricName != metricName {
continue
}
if entityID != "" && key.entityID != entityID {
continue
}
_, ok, err := matchRawTags(series.tagsJSON, tags)
if err != nil {
return deleted, err
}
if !ok {
continue
}
deleted += int64(len(series.samples) + series.compressed.count)
delete(s.raw, key)
}
return deleted, nil
}
func (s *Store) deleteRawBefore(metricName string, beforeMilli int64) int64 {
s.rawMu.Lock()
defer s.rawMu.Unlock()
s.compressRawBeforeLocked(metricName, beforeMilli)
return s.pruneRawBeforeLocked(metricName, beforeMilli)
}
func (s *Store) pruneRawBeforeLocked(metricName string, beforeMilli int64) int64 {
var deleted int64
for key, series := range s.raw {
if metricName != "" && key.metricName != metricName {
continue
}
compressed := series.decodeCompressed()
compressedIndex := sort.Search(len(compressed), func(i int) bool { return compressed[i].timestamp >= beforeMilli })
if compressedIndex > 0 {
compressed = compressed[compressedIndex:]
series.compressed.set(compressed)
deleted += int64(compressedIndex)
}
if len(compressed) == 0 {
series.compressed = compressedRawSamples{}
}
directIndex := sort.Search(len(series.samples), func(i int) bool { return series.samples[i].timestamp >= beforeMilli })
if directIndex > 0 {
deleted += int64(directIndex)
series.samples = append([]rawSample(nil), series.samples[directIndex:]...)
}
if series.compressed.count == 0 && len(series.samples) == 0 {
delete(s.raw, key)
continue
}
if compressedIndex > 0 || directIndex > 0 {
compactRawLabels(series)
}
}
return deleted
}
func (s *Store) compressRawBeforeLocked(metricName string, beforeMilli int64) {
for key, series := range s.raw {
if metricName != "" && key.metricName != metricName {
continue
}
index := sort.Search(len(series.samples), func(i int) bool { return series.samples[i].timestamp >= beforeMilli })
if index == 0 {
continue
}
series.compressed.append(series.samples[:index])
series.samples = append([]rawSample(nil), series.samples[index:]...)
}
}
func (s *rawSeries) decodeCompressed() []rawSample {
if s.compressed.count == 0 {
return nil
}
result := make([]rawSample, 0, s.compressed.count)
for offset, previous := 0, int64(0); offset < len(s.compressed.data); {
var timestamp int64
if len(result) == 0 {
first, n := binary.Varint(s.compressed.data[offset:])
if n <= 0 {
return result
}
offset += n
timestamp = first
} else {
delta, dn := binary.Uvarint(s.compressed.data[offset:])
if dn <= 0 {
return result
}
offset += dn
timestamp = previous + int64(delta)
}
if offset+8 > len(s.compressed.data) {
return result
}
value := math.Float64frombits(binary.LittleEndian.Uint64(s.compressed.data[offset:]))
offset += 8
labelID, ln := binary.Uvarint(s.compressed.data[offset:])
if ln <= 0 {
return result
}
offset += ln
result = append(result, rawSample{timestamp: timestamp, value: value, labelID: uint32(labelID)})
previous = timestamp
}
return result
}
func (s *rawSeries) allSamples() []rawSample {
compressed := s.decodeCompressed()
result := make([]rawSample, 0, len(compressed)+len(s.samples))
result = append(result, compressed...)
result = append(result, s.samples...)
return result
}
func (s *rawSeries) replaceCompressed(sample rawSample) (rawSample, bool) {
samples := s.decodeCompressed()
index := sort.Search(len(samples), func(i int) bool { return samples[i].timestamp >= sample.timestamp })
var old rawSample
replaced := index < len(samples) && samples[index].timestamp == sample.timestamp
if replaced {
old = samples[index]
samples[index] = sample
} else {
samples = append(samples, rawSample{})
copy(samples[index+1:], samples[index:])
samples[index] = sample
}
s.compressed.set(samples)
return old, replaced
}
func (c *compressedRawSamples) append(samples []rawSample) {
if len(samples) == 0 {
return
}
if c.count > 0 && samples[0].timestamp <= c.lastStamp {
merged := make([]rawSample, 0, c.count+len(samples))
merged = append(merged, c.decode()...)
for _, sample := range samples {
index := sort.Search(len(merged), func(i int) bool { return merged[i].timestamp >= sample.timestamp })
if index < len(merged) && merged[index].timestamp == sample.timestamp {
merged[index] = sample
continue
}
merged = append(merged, rawSample{})
copy(merged[index+1:], merged[index:])
merged[index] = sample
}
c.set(merged)
return
}
for _, sample := range samples {
if c.count == 0 {
c.data = appendVarint(c.data, sample.timestamp)
} else {
c.data = appendUvarint(c.data, uint64(sample.timestamp-c.lastStamp))
}
c.data = appendRawSample(c.data, sample)
c.count++
c.lastStamp = sample.timestamp
}
}
func (c *compressedRawSamples) decode() []rawSample {
series := rawSeries{compressed: *c}
return series.decodeCompressed()
}
func (c *compressedRawSamples) set(samples []rawSample) {
c.data = nil
c.count = 0
c.lastStamp = 0
for _, sample := range samples {
if c.count == 0 {
c.data = appendVarint(c.data, sample.timestamp)
} else {
c.data = appendUvarint(c.data, uint64(sample.timestamp-c.lastStamp))
}
c.data = appendRawSample(c.data, sample)
c.count++
c.lastStamp = sample.timestamp
}
}
func appendRawSample(dst []byte, sample rawSample) []byte {
var value [8]byte
binary.LittleEndian.PutUint64(value[:], math.Float64bits(sample.value))
dst = append(dst, value[:]...)
return appendUvarint(dst, uint64(sample.labelID))
}
func appendVarint(dst []byte, value int64) []byte {
var encoded [binary.MaxVarintLen64]byte
n := binary.PutVarint(encoded[:], value)
return append(dst, encoded[:n]...)
}
func appendUvarint(dst []byte, value uint64) []byte {
var encoded [binary.MaxVarintLen64]byte
n := binary.PutUvarint(encoded[:], value)
return append(dst, encoded[:n]...)
}
func compactRawLabels(series *rawSeries) {
if len(series.labelHashes) <= 1 {
return
}
all := series.allSamples()
used := make([]bool, len(series.labelHashes))
for _, sample := range all {
used[sample.labelID] = true
}
usedCount := 0
for _, keep := range used {
if keep {
usedCount++
}
}
if usedCount == len(used) {
return
}
remap := make([]uint32, len(used))
labelHashes := make([]string, 0, usedCount)
labelsJSON := make([]string, 0, usedCount)
labelIDs := make(map[string]uint32, usedCount)
for oldID, keep := range used {
if !keep {
continue
}
newID := uint32(len(labelHashes))
remap[oldID] = newID
hash := series.labelHashes[oldID]
labelHashes = append(labelHashes, hash)
labelsJSON = append(labelsJSON, series.labelsJSON[oldID])
labelIDs[hash] = newID
}
compressed := series.decodeCompressed()
for i := range compressed {
compressed[i].labelID = remap[compressed[i].labelID]
}
series.compressed.set(compressed)
for i := range series.samples {
series.samples[i].labelID = remap[series.samples[i].labelID]
}
series.labelHashes = labelHashes
series.labelsJSON = labelsJSON
series.labelIDs = labelIDs
}
func (s *Store) latestRollupBefore(ctx context.Context, metricName, entityID string, before time.Time) (Point, bool, error) {
s.rollupViewMu.RLock()
defer s.rollupViewMu.RUnlock()
end := before.Add(-time.Nanosecond)
endMilli := end.UnixMilli()
var latest Point
found := false
for _, tier := range s.cfg.RollupPolicy.Tiers {
var value float64
var timestamp int64
var rawTags, rawLabels any
err := s.reader().QueryRowContext(ctx, fmt.Sprintf(`SELECT r.last_val, r.last_ts_milli, s.tags, l.labels
FROM %s r JOIN %s s ON s.id = r.series_id JOIN %s d ON d.id = r.resolution_id JOIN %s l ON l.id = r.label_id
WHERE s.metric_name = %s AND s.entity_id = %s AND d.resolution_milli = %s AND r.last_ts_milli <= %s
ORDER BY r.last_ts_milli DESC LIMIT 1`,
s.tables.rollups, s.tables.series, s.tables.resolutions, s.tables.labels,
s.dialect.placeholder(1), s.dialect.placeholder(2), s.dialect.placeholder(3), s.dialect.placeholder(4)),
metricName, entityID, tier.Interval.Milliseconds(), endMilli,
).Scan(&value, &timestamp, &rawTags, &rawLabels)
if err == sql.ErrNoRows {
continue
}
if err != nil {
return Point{}, false, err
}
if found && timestamp <= latest.Timestamp.UnixMilli() {
continue
}
tags, err := decodeMap(rawTags)
if err != nil {
return Point{}, false, err
}
labels, err := decodeMap(rawLabels)
if err != nil {
return Point{}, false, err
}
latest = Point{MetricName: metricName, EntityID: entityID, Timestamp: fromMillis(timestamp), Value: value, Tags: tags, Labels: labels}
found = true
}
hot, err := s.hotRollupRows(metricName, entityID, nil, time.Unix(0, 0), end, false)
if err != nil {
return Point{}, false, err
}
for _, row := range hot {
if row.bucketData.lastTS > endMilli || (found && row.bucketData.lastTS <= latest.Timestamp.UnixMilli()) {
continue
}
tags, err := rollupTagsFromJSON(row.bucketData.tagsJSON)
if err != nil {
return Point{}, false, err
}
labels, err := rollupTagsFromJSON(row.bucketData.labelsJSON)
if err != nil {
return Point{}, false, err
}
latest = Point{MetricName: metricName, EntityID: entityID, Timestamp: fromMillis(row.bucketData.lastTS), Value: row.bucketData.lastVal, Tags: tags, Labels: labels}
found = true
}
return latest, found, nil
}
+292
View File
@@ -0,0 +1,292 @@
package metric
import (
"context"
"math"
"path/filepath"
"strconv"
"sync"
"testing"
"time"
)
func TestRawMemoryUpsertOrderingPagingAndLabels(t *testing.T) {
ctx := context.Background()
s := newMemStore(t)
if err := s.CreateMetric(ctx, Definition{Name: "raw", Type: TypeGauge, RetentionDays: 1}); err != nil {
t.Fatal(err)
}
base := time.Now().UTC().Truncate(time.Minute).Add(5 * time.Second)
if err := s.WriteBatch(ctx, []Point{
{MetricName: "raw", EntityID: "n2", Timestamp: base, Value: 2, Tags: map[string]string{"zone": "a"}, Labels: map[string]string{"source": "old"}},
{MetricName: "raw", EntityID: "n1", Timestamp: base, Value: 1, Tags: map[string]string{"zone": "a"}},
{MetricName: "raw", EntityID: "n1", Timestamp: base.Add(time.Second), Value: 3, Tags: map[string]string{"zone": "b"}},
}); err != nil {
t.Fatal(err)
}
if err := s.Write(ctx, Point{MetricName: "raw", EntityID: "n2", Timestamp: base, Value: 22, Tags: map[string]string{"zone": "a"}, Labels: map[string]string{"source": "new"}}); err != nil {
t.Fatal(err)
}
points, err := s.Query(ctx, Query{MetricName: "raw", Start: base.Add(-time.Second), End: base.Add(time.Minute)})
if err != nil {
t.Fatal(err)
}
if len(points) != 3 || points[0].EntityID != "n1" || points[1].EntityID != "n2" || points[1].Value != 22 || points[1].Labels["source"] != "new" {
t.Fatalf("ordered upserted points = %#v", points)
}
paged, err := s.Query(ctx, Query{MetricName: "raw", Start: base.Add(-time.Second), End: base.Add(time.Minute), Order: OrderDesc, Offset: 1, Limit: 1})
if err != nil {
t.Fatal(err)
}
if len(paged) != 1 || paged[0].EntityID != "n2" || paged[0].Value != 22 {
t.Fatalf("paged descending points = %#v", paged)
}
series, err := s.Series(ctx, AggregateQuery{
Query: Query{MetricName: "raw", EntityID: "n2", Start: base.Add(-time.Second), End: base.Add(time.Minute)},
Aggregation: AggCount, Interval: time.Minute, PreserveSeries: true,
}, base.Add(time.Minute))
if err != nil {
t.Fatal(err)
}
if len(series) != 1 || series[0].Count != 1 || series[0].Value != 1 {
t.Fatalf("rollup counted overwritten sample more than once: %#v", series)
}
}
func TestConcurrentRawUpsertKeepsOneRollupObservation(t *testing.T) {
ctx := context.Background()
s := newMemStore(t)
if err := s.CreateMetric(ctx, Definition{Name: "raw-race", Type: TypeGauge, RetentionDays: 1}); err != nil {
t.Fatal(err)
}
at := time.Now().UTC().Add(-10 * time.Second)
var writers sync.WaitGroup
for i := 0; i < 16; i++ {
writers.Add(1)
go func(value float64) {
defer writers.Done()
if err := s.Write(ctx, Point{MetricName: "raw-race", EntityID: "n1", Timestamp: at, Value: value}); err != nil {
t.Errorf("write: %v", err)
}
}(float64(i))
}
writers.Wait()
raw, err := s.Query(ctx, Query{MetricName: "raw-race", EntityID: "n1", Start: at.Add(-time.Second), End: at.Add(time.Second)})
if err != nil {
t.Fatal(err)
}
if len(raw) != 1 {
t.Fatalf("raw upsert count = %d, want 1", len(raw))
}
rollup, err := s.Series(ctx, AggregateQuery{
Query: Query{MetricName: "raw-race", EntityID: "n1", Start: at.Add(-time.Minute), End: at.Add(time.Minute)},
Aggregation: AggCount, Interval: time.Minute, PreserveSeries: true,
}, at.Add(time.Minute))
if err != nil {
t.Fatal(err)
}
if len(rollup) != 1 || rollup[0].Count != 1 || rollup[0].Value != 1 {
t.Fatalf("rollup upsert count = %#v", rollup)
}
}
func TestRawMemoryKeepsTenMinutes(t *testing.T) {
ctx := context.Background()
s := newMemStore(t)
if err := s.CreateMetric(ctx, Definition{Name: "raw-expiry", Type: TypeGauge, RetentionDays: 1}); err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
if err := s.WriteBatch(ctx, []Point{
{MetricName: "raw-expiry", EntityID: "n1", Timestamp: now.Add(-11 * time.Minute), Value: 0},
{MetricName: "raw-expiry", EntityID: "n1", Timestamp: now.Add(-9 * time.Minute), Value: 1},
{MetricName: "raw-expiry", EntityID: "n1", Timestamp: now.Add(-30 * time.Second), Value: 2},
}); err != nil {
t.Fatal(err)
}
points, err := s.Query(ctx, Query{MetricName: "raw-expiry", Start: now.Add(-time.Hour), End: now})
if err != nil {
t.Fatal(err)
}
if len(points) != 2 || points[0].Value != 1 || points[1].Value != 2 {
t.Fatalf("ten-minute raw window = %#v", points)
}
}
func TestCompressedRawSamplesRoundTripExactly(t *testing.T) {
now := time.Now().UTC().Truncate(time.Millisecond)
samples := []rawSample{
{timestamp: now.Add(-9*time.Minute - 7*time.Millisecond).UnixMilli(), value: math.Pi, labelID: 1},
{timestamp: now.Add(-5*time.Minute - 3*time.Millisecond).UnixMilli(), value: math.Copysign(0, -1), labelID: 0},
{timestamp: now.Add(-time.Minute - time.Millisecond).UnixMilli(), value: math.SmallestNonzeroFloat64, labelID: 2},
}
var compressed compressedRawSamples
compressed.append(samples)
decoded := compressed.decode()
if len(decoded) != len(samples) {
t.Fatalf("decoded sample count = %d, want %d", len(decoded), len(samples))
}
for i := range samples {
if decoded[i].timestamp != samples[i].timestamp || decoded[i].labelID != samples[i].labelID || math.Float64bits(decoded[i].value) != math.Float64bits(samples[i].value) {
t.Fatalf("decoded sample %d = %#v, want %#v", i, decoded[i], samples[i])
}
}
}
func TestCompressedRawQueryAndLateReplacementPreserveSamples(t *testing.T) {
ctx := context.Background()
s := newMemStore(t)
if err := s.CreateMetric(ctx, Definition{Name: "raw-compressed", Type: TypeGauge, RetentionDays: 1}); err != nil {
t.Fatal(err)
}
now := time.Now().UTC().Truncate(time.Millisecond)
older := now.Add(-5 * time.Minute)
recent := now.Add(-30 * time.Second)
for _, batch := range [][]Point{
{
{MetricName: "raw-compressed", EntityID: "n1", Timestamp: older, Value: 1.25, Labels: map[string]string{"source": "old"}},
{MetricName: "raw-compressed", EntityID: "n1", Timestamp: recent, Value: 2.5, Labels: map[string]string{"source": "recent"}},
{MetricName: "raw-compressed", EntityID: "n2", Timestamp: older.Add(time.Second), Value: 3.75},
},
{{MetricName: "raw-compressed", EntityID: "n1", Timestamp: older, Value: math.Pi, Labels: map[string]string{"source": "replacement"}}},
} {
prepared, err := prepareMetricPoints(batch)
if err != nil {
t.Fatal(err)
}
rebuild := s.writeRawPointsAt(prepared, now)
if err := s.writePreparedHotRollups(ctx, prepared, now, rebuild); err != nil {
t.Fatal(err)
}
}
points, err := s.Query(ctx, Query{MetricName: "raw-compressed", EntityID: "n1", Start: now.Add(-10 * time.Minute), End: now})
if err != nil {
t.Fatal(err)
}
if len(points) != 2 || math.Float64bits(points[0].Value) != math.Float64bits(math.Pi) || points[0].Labels["source"] != "replacement" || points[1].Value != 2.5 {
t.Fatalf("compressed and direct query = %#v", points)
}
series, err := s.Series(ctx, AggregateQuery{
Query: Query{MetricName: "raw-compressed", EntityID: "n1", Start: older.Truncate(time.Minute), End: older.Add(time.Minute)},
Aggregation: AggCount, Interval: time.Minute, PreserveSeries: true,
}, now)
if err != nil {
t.Fatal(err)
}
if len(series) != 1 || series[0].Count != 1 || series[0].Value != 1 {
t.Fatalf("late compressed replacement counted twice: %#v", series)
}
latest, err := s.Latest(ctx, "raw-compressed", "n2", 1)
if err != nil {
t.Fatal(err)
}
if len(latest) != 1 || latest[0].Value != 3.75 {
t.Fatalf("latest compressed sample = %#v", latest)
}
ids, err := s.EntityIDs(ctx, Query{MetricName: "raw-compressed", Start: now.Add(-10 * time.Minute), End: now})
if err != nil {
t.Fatal(err)
}
if len(ids) != 2 || ids[0] != "n1" || ids[1] != "n2" {
t.Fatalf("compressed entity ids = %#v", ids)
}
if deleted, err := s.DeleteSeries(ctx, Query{MetricName: "raw-compressed", EntityID: "n2"}); err != nil || deleted == 0 {
t.Fatalf("delete compressed series = %d, %v", deleted, err)
}
deletedPoints, err := s.Query(ctx, Query{MetricName: "raw-compressed", EntityID: "n2", Start: now.Add(-10 * time.Minute), End: now})
if err != nil || len(deletedPoints) != 0 {
t.Fatalf("deleted compressed points = %#v, %v", deletedPoints, err)
}
}
func TestRawReplacementDropsUnreferencedLabels(t *testing.T) {
ctx := context.Background()
s := newMemStore(t)
if err := s.CreateMetric(ctx, Definition{Name: "raw-label-replace", Type: TypeGauge, RetentionDays: 1}); err != nil {
t.Fatal(err)
}
at := time.Now().UTC().Truncate(time.Millisecond)
points := make([]Point, 100)
for i := range points {
points[i] = Point{
MetricName: "raw-label-replace",
EntityID: "n1",
Timestamp: at,
Value: float64(i),
Labels: map[string]string{"attempt": strconv.Itoa(i)},
}
}
if err := s.WriteBatch(ctx, points); err != nil {
t.Fatal(err)
}
tagsHash, _, err := tagsFingerprint(nil)
if err != nil {
t.Fatal(err)
}
s.rawMu.RLock()
series := s.raw[rawSeriesKey{metricName: "raw-label-replace", entityID: "n1", tagsHash: tagsHash}]
if series == nil {
s.rawMu.RUnlock()
t.Fatal("raw series was not retained")
}
labelCount := len(series.labelsJSON)
s.rawMu.RUnlock()
if labelCount != 1 {
t.Fatalf("retained %d labels for one replaced sample, want 1", labelCount)
}
got, err := s.Query(ctx, Query{MetricName: "raw-label-replace", EntityID: "n1", Start: at.Add(-time.Minute), End: at.Add(time.Minute)})
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Value != 99 || got[0].Labels["attempt"] != "99" {
t.Fatalf("replacement result = %#v", got)
}
}
func TestRestartDropsRawButKeepsRollup(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "metrics.db")
policy := RollupPolicy{RawRetention: time.Minute, Tiers: []RollupTier{{Interval: time.Minute, Retention: time.Hour}}, Compression: 30}
s, err := Open(ctx, SQLite(path, WithRollupPolicy(policy)))
if err != nil {
t.Fatal(err)
}
if err := s.CreateMetric(ctx, Definition{Name: "restart", Type: TypeGauge, RetentionDays: 1}); err != nil {
t.Fatal(err)
}
at := time.Now().UTC()
if err := s.Write(ctx, Point{MetricName: "restart", EntityID: "n1", Timestamp: at, Value: 7}); err != nil {
t.Fatal(err)
}
if err := s.Close(); err != nil {
t.Fatal(err)
}
s, err = Open(ctx, SQLite(path, WithRollupPolicy(policy)))
if err != nil {
t.Fatal(err)
}
defer s.Close()
raw, err := s.Query(ctx, Query{MetricName: "restart", EntityID: "n1", Start: at.Add(-time.Minute), End: at.Add(time.Minute)})
if err != nil {
t.Fatal(err)
}
if len(raw) != 0 {
t.Fatalf("raw survived restart: %#v", raw)
}
rollup, err := s.Series(ctx, AggregateQuery{
Query: Query{MetricName: "restart", EntityID: "n1", Start: at.Add(-time.Minute), End: at.Add(time.Minute)},
Aggregation: AggAvg, Interval: time.Minute, PreserveSeries: true,
}, at.Add(time.Minute))
if err != nil {
t.Fatal(err)
}
if len(rollup) != 1 || rollup[0].Value != 7 || rollup[0].Count != 1 {
t.Fatalf("persisted rollup after restart = %#v", rollup)
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+76 -45
View File
@@ -6,7 +6,7 @@ import (
"time"
)
// RollupTier describes one downsampled resolution: raw points (or the next
// RollupTier describes one downsampled resolution: observations (or the next
// finer tier) are aggregated into buckets Interval wide, and those buckets are
// kept for Retention. A policy lists tiers from finest to coarsest, e.g.
//
@@ -16,7 +16,7 @@ import (
// so storage shrinks with age — the "data gets sparser as it gets older"
// behavior of a downsampling TSDB.
//
// RollupTier 描述一个降采样分辨率:原始点(或下一层更细的层级)会被聚合进
// RollupTier 描述一个降采样分辨率:观测值(或下一层更细的层级)会被聚合进
// Interval 宽的桶,而这些桶会保留 Retention。策略会按从细到粗排列层级,例如:
//
// 1m 保留 7d -> 5m 保留 30d -> 1h 保留 1y
@@ -34,19 +34,15 @@ type RollupTier struct {
Retention time.Duration `json:"retention"`
}
// RollupPolicy is the full retention ladder for a store: how long raw points
// live, then a chain of progressively coarser tiers. Compact materializes the
// tiers and enforces every retention window.
// RollupPolicy is the retention ladder for materialized rollups.
//
// RollupPolicy 描述完整保留阶梯:原始点保留多久,以及后续逐级变粗的
// rollup 层;Compact 会物化这些层并执行保留窗口。
// RollupPolicy 描述逐级变粗的 rollup 层;Compact 会物化这些层并执行保留窗口。
type RollupPolicy struct {
// RawRetention is how long raw points are kept before Compact deletes them
// (after they have been rolled into the finest tier). Zero means "never
// delete raw" — rollups are still built, but raw is retained.
// RawRetention is retained for configuration compatibility. Exact samples
// always live in memory for ten minutes and are never persisted; samples
// older than one minute use lossless byte encoding to reduce memory use.
//
// RawRetention 是 Compact 删除原始点之前保留它们的时间(在它们已进入最细
// rollup 层之后)。零值表示“永不删除原始点”;rollup 仍会构建,但原始点保留。
// RawRetention 仅为配置兼容保留;精确样本固定在内存中保留十分钟,超过一分钟的样本使用无损字节编码降低内存占用。
RawRetention time.Duration `json:"raw_retention"`
// Tiers are ordered finest-first. Each Interval must be a positive integer
// multiple of the previous tier's Interval (so a coarse bucket is composed
@@ -58,10 +54,10 @@ type RollupPolicy struct {
// (粗数据比细数据活得更久)。
Tiers []RollupTier `json:"tiers"`
// Compression tunes the per-bucket t-digest (size vs. percentile accuracy).
// <=1 uses the default (100).
// <=1 uses the package default.
//
// Compression 调整每个桶的 t-digest(大小与百分位精度之间的取舍)。
// <=1 时使用默认值(100)。
// <=1 时使用包默认值。
Compression float64 `json:"compression"`
}
@@ -80,15 +76,6 @@ func (p RollupPolicy) compression() float64 {
return p.Compression
}
func (p RollupPolicy) rawCutoff(now time.Time) time.Time {
if p.RawRetention <= 0 || len(p.Tiers) == 0 {
return time.Time{}
}
cutoff := now.UTC().Add(-p.RawRetention)
interval := p.Tiers[0].Interval.Nanoseconds()
return time.Unix(0, floorDivNano(cutoff.UnixNano(), interval)).UTC()
}
// withMetricRetention applies the metric definition's retention to a rollup
// policy. Fine tiers keep their configured short windows, while the final tier
// follows the metric so definitions are never capped by a store-wide setting.
@@ -102,7 +89,9 @@ func (p RollupPolicy) withMetricRetention(retention time.Duration) RollupPolicy
out := p
out.Tiers = make([]RollupTier, 0, len(p.Tiers))
for i, tier := range p.Tiers {
if i == len(p.Tiers)-1 || tier.Retention > retention {
if i == len(p.Tiers)-1 {
tier.Retention = retention
} else if tier.Retention > retention {
tier.Retention = retention
}
if len(out.Tiers) > 0 && tier.Retention <= out.Tiers[len(out.Tiers)-1].Retention {
@@ -132,15 +121,10 @@ func (p RollupPolicy) Validate() error {
if tr.Retention <= 0 {
return fmt.Errorf("%w: tier %d retention must be positive", ErrInvalidArgument, i)
}
if i == 0 {
// The finest tier rolls up raw points. Require raw retention (when
// set) to cover at least two of its buckets so the in-progress and
// most-recent sealed bucket still have their raw backing when Compact
// runs and deletes old raw.
if p.RawRetention > 0 && p.RawRetention < 2*tr.Interval {
return fmt.Errorf("%w: raw retention must be >= 2x the finest tier interval", ErrInvalidArgument)
}
} else {
if i == 0 && tr.Interval != time.Minute {
return fmt.Errorf("%w: finest rollup tier must use a one-minute interval", ErrInvalidArgument)
}
if i > 0 {
if tr.Interval <= prev.Interval {
return fmt.Errorf("%w: tier %d interval must be larger than tier %d", ErrInvalidArgument, i, i-1)
}
@@ -191,17 +175,17 @@ type rollupBucket struct {
//
// firstVal 是时间戳最早的值。
firstVal float64
// firstTS is the earliest timestamp in nanoseconds.
// firstTS is the earliest Unix-millisecond timestamp.
//
// firstTS 是最早时间戳的纳秒值。
// firstTS 是最早时间戳的毫秒值。
firstTS int64
// lastVal is the value with the latest timestamp.
//
// lastVal 是时间戳最晚的值。
lastVal float64
// lastTS is the latest timestamp in nanoseconds.
// lastTS is the latest Unix-millisecond timestamp.
//
// lastTS 是最晚时间戳的纳秒值。
// lastTS 是最晚时间戳的毫秒值。
lastTS int64
// digest estimates percentiles for represented values.
//
@@ -214,7 +198,9 @@ type rollupBucket struct {
// tagsJSON carries the canonical tag map written back to the tags column.
//
// tagsJSON 携带写回 tags 列的规范标签 map。
tagsJSON string
tagsJSON string
labelsHash string
labelsJSON string
}
// newRollupBucket creates an empty rollup accumulator.
@@ -239,14 +225,27 @@ func newRollupBucketWithDigest(compression float64, includeDigest bool) *rollupB
return bucket
}
func (b *rollupBucket) clone(includeDigest bool) *rollupBucket {
if b == nil {
return nil
}
cloned := *b
if includeDigest {
cloned.digest = b.digest.clone()
} else {
cloned.digest = nil
}
return &cloned
}
// addPoint folds a raw observation into the bucket.
//
// addPoint 将一个原始观测值合入当前桶。
func (b *rollupBucket) addPoint(value float64, tsNano int64) {
func (b *rollupBucket) addPoint(value float64, tsMilli int64) {
if b.count == 0 {
b.min, b.max = value, value
b.firstVal, b.firstTS = value, tsNano
b.lastVal, b.lastTS = value, tsNano
b.firstVal, b.firstTS = value, tsMilli
b.lastVal, b.lastTS = value, tsMilli
} else {
if value < b.min {
b.min = value
@@ -254,11 +253,11 @@ func (b *rollupBucket) addPoint(value float64, tsNano int64) {
if value > b.max {
b.max = value
}
if tsNano < b.firstTS {
b.firstVal, b.firstTS = value, tsNano
if tsMilli < b.firstTS {
b.firstVal, b.firstTS = value, tsMilli
}
if tsNano > b.lastTS {
b.lastVal, b.lastTS = value, tsNano
if tsMilli > b.lastTS {
b.lastVal, b.lastTS = value, tsMilli
}
}
b.count++
@@ -308,6 +307,38 @@ func (b *rollupBucket) mergeStored(o *rollupBucket) {
}
}
// encodedDigest omits the sketch only for a constant bucket. For any count, a
// min/max pair exactly represents a distribution when both values are equal;
// non-constant two-sample buckets still need their digest for percentiles.
func (b *rollupBucket) encodedDigest() []byte {
if b.count == 0 || b.min == b.max {
return nil
}
if b.digest == nil {
return nil
}
return b.digest.Encode()
}
// digestFromRollup reconstructs an omitted constant-bucket sketch exactly. An
// absent sketch for a non-constant bucket is invalid: it would make percentile
// reads silently return a fabricated value.
func digestFromRollup(count int64, min, max float64, blob []byte, compression float64) (*TDigest, error) {
if len(blob) > 0 {
return DecodeTDigest(blob)
}
digest := NewTDigest(compression)
switch {
case count == 0:
return digest, nil
case min == max:
digest.Add(min, float64(count))
return digest, nil
default:
return nil, fmt.Errorf("metric: missing t-digest for rollup with %d samples", count)
}
}
// value computes the requested aggregation from the bucket summaries. ok=false
// means the aggregation is not derivable from a rollup (only AggRate, which
// needs the ordered raw series).
+4 -15
View File
@@ -134,12 +134,9 @@ func TestSeriesHybridEndEqualsCutoff(t *testing.T) {
}
}
// TestSeriesHybridUsesLastCompactionWatermark verifies that a query does not
// advance the raw/rollup split beyond the last successful compaction. The raw
// point at 11:32 is still present after the 12:00 compaction, but a query at
// 12:04 would calculate a dynamic cutoff of 11:34 and skip it without the
// persisted watermark at 11:30.
func TestSeriesHybridUsesLastCompactionWatermark(t *testing.T) {
// TestSeriesIncludesLateMinuteRollup verifies that a late point is merged into
// the persisted minute and remains visible without a raw/rollup split.
func TestSeriesIncludesLateMinuteRollup(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{
RawRetention: 30 * time.Minute,
@@ -171,14 +168,6 @@ func TestSeriesHybridUsesLastCompactionWatermark(t *testing.T) {
t.Fatalf("series: %v", err)
}
if len(got) != 1 || got[0].Count != 2 {
t.Fatalf("series should include rollup and raw data before the next compaction, got %#v", got)
}
watermark, found, err := s.compactionWatermark(ctx, "watermark")
if err != nil {
t.Fatalf("read watermark: %v", err)
}
if !found || !watermark.Equal(policy.rawCutoff(compactAt)) {
t.Fatalf("watermark = %v, found=%v, want %v", watermark, found, policy.rawCutoff(compactAt))
t.Fatalf("series should include both minute summaries, got %#v", got)
}
}
+6 -21
View File
@@ -7,8 +7,8 @@ import (
"time"
)
// TestAggregateRollupExcludesPartialBucket verifies the rollup query no longer
// drags an out-of-window sample in through a partially-overlapping bucket.
// TestAggregateRollupIncludesPartialMinute verifies query boundaries use the
// persisted minute as the smallest indivisible unit.
//
// Two samples share one 1m rollup bucket [00:00:00, 00:01:00): 00:00:10=100 and
// 00:00:50=10. A query window of [00:00:40, 00:01:30] only overlaps part of that
@@ -19,7 +19,7 @@ import (
//
// TestAggregateRollupExcludesPartialBucket 验证 rollup 查询不再通过部分重叠的桶
// 把窗口外的样本带进来。
func TestAggregateRollupExcludesPartialBucket(t *testing.T) {
func TestAggregateRollupIncludesPartialMinute(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}}}
s := newRollupStore(t, policy)
@@ -47,13 +47,8 @@ func TestAggregateRollupExcludesPartialBucket(t *testing.T) {
if err != nil {
t.Fatalf("aggregate rollup: %v", err)
}
// The partially-overlapping bucket is excluded under full containment, so the
// out-of-window 00:00:10 sample (value 100) must not appear. Concretely: no
// returned bucket may carry the out-of-window value or a count of 2.
for _, p := range res {
if p.Count >= 2 || p.Value == 100 {
t.Fatalf("partial bucket leaked out-of-window sample: %#v", p)
}
if len(res) != 1 || res[0].Count != 2 || res[0].Value != 110 {
t.Fatalf("partial query should include the complete minute summary: %#v", res)
}
}
@@ -142,16 +137,6 @@ func TestSeriesHybridMergesStraddlingBucket(t *testing.T) {
t.Fatalf("compact: %v", err)
}
// Confirm the old raw point was deleted and the recent one survives, so the
// two halves genuinely come from different sources.
oldRaw, err := s.Query(ctx, Query{MetricName: "straddle", EntityID: "n1", Start: old.Add(-time.Minute), End: old.Add(time.Minute)})
if err != nil {
t.Fatalf("query old raw: %v", err)
}
if len(oldRaw) != 0 {
t.Fatalf("expected old raw point deleted by retention, got %d", len(oldRaw))
}
got, err := s.Series(ctx, AggregateQuery{
Query: Query{MetricName: "straddle", EntityID: "n1", Start: now.Add(-time.Hour), End: now},
Aggregation: AggAvg,
@@ -278,7 +263,7 @@ func TestSeriesHybridEndAtCutoffIncludesRawBoundary(t *testing.T) {
}
now := time.Date(2026, 6, 18, 12, 0, 30, 0, time.UTC)
cutoff := policy.rawCutoff(now)
cutoff := now.Add(-30 * time.Minute)
if err := s.Write(ctx, Point{MetricName: "cutedge", EntityID: "n1", Timestamp: cutoff, Value: 7}); err != nil {
t.Fatalf("write: %v", err)
}
+167
View File
@@ -0,0 +1,167 @@
package metric
import (
"context"
"fmt"
"sort"
"time"
)
// ReplaceRollupPoints idempotently imports pre-aggregated representative
// values into the configured tiers through interval. Keeping the same
// representative in finer retained tiers prevents a newly migrated series
// from appearing blank while queries still prefer those tiers. Imported
// values never enter the exact ten-minute raw window.
func (s *Store) ReplaceRollupPoints(ctx context.Context, interval time.Duration, points []Point) error {
if err := s.ensureOpen(); err != nil {
return err
}
if !s.hasRollupTier(interval) {
return fmt.Errorf("%w: rollup interval %s is not configured", ErrInvalidArgument, interval)
}
if len(points) == 0 {
return nil
}
s.retentionMu.RLock()
defer s.retentionMu.RUnlock()
points, err := s.filterDisabledMetricPoints(ctx, points)
if err != nil || len(points) == 0 {
return err
}
prepared, err := prepareMetricPoints(points)
if err != nil {
return err
}
tiers := make([]time.Duration, 0, len(s.cfg.RollupPolicy.Tiers))
for _, tier := range s.cfg.RollupPolicy.Tiers {
if tier.Interval <= interval {
tiers = append(tiers, tier.Interval)
}
}
type importGroup struct {
metricName string
interval time.Duration
buckets map[rollupKey]*rollupBucket
}
groups := make(map[string]*importGroup)
for _, point := range prepared {
for _, tier := range tiers {
key := rollupKey{
entityID: point.entityID, tagsHash: point.tagsHash, labelsHash: point.labelsHash,
bucket: bucketStartMillis(point.timestamp, tier.Milliseconds()),
}
groupKey := point.metricName + "\x00" + tier.String()
group := groups[groupKey]
if group == nil {
group = &importGroup{metricName: point.metricName, interval: tier, buckets: make(map[rollupKey]*rollupBucket)}
groups[groupKey] = group
}
if _, exists := group.buckets[key]; exists {
return fmt.Errorf("%w: duplicate pre-aggregated point for metric %q at %s", ErrInvalidArgument, point.metricName, fromMillis(key.bucket))
}
bucket := newRollupBucket(s.cfg.RollupPolicy.compression())
bucket.tagsHash, bucket.tagsJSON = point.tagsHash, point.tagsJSON
bucket.labelsHash, bucket.labelsJSON = point.labelsHash, point.labelsJSON
bucket.addPoint(point.value, point.timestamp)
group.buckets[key] = bucket
}
}
groupKeys := make([]string, 0, len(groups))
for key := range groups {
groupKeys = append(groupKeys, key)
}
sort.Strings(groupKeys)
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
cache := newRollupDictionaryCache()
for _, groupKey := range groupKeys {
group := groups[groupKey]
keys := make([]rollupKey, 0, len(group.buckets))
for key := range group.buckets {
keys = append(keys, key)
}
sortRollupKeys(keys)
for _, key := range keys {
if err := s.upsertRollupWithDictionaryTx(ctx, group.metricName, group.interval, key, group.buckets[key], cache, tx); err != nil {
return err
}
}
}
return tx.Commit()
}
// RebuildCoarserRollups recomputes every configured tier coarser than source
// from the complete source tier. Existing coarser-only history is preserved.
func (s *Store) RebuildCoarserRollups(ctx context.Context, source time.Duration) error {
if err := s.ensureOpen(); err != nil {
return err
}
sourceIndex := -1
for i, tier := range s.cfg.RollupPolicy.Tiers {
if tier.Interval == source {
sourceIndex = i
break
}
}
if sourceIndex < 0 {
return fmt.Errorf("%w: source rollup interval %s is not configured", ErrInvalidArgument, source)
}
if sourceIndex == len(s.cfg.RollupPolicy.Tiers)-1 {
return nil
}
s.retentionMu.RLock()
defer s.retentionMu.RUnlock()
defs, err := s.ListMetrics(ctx)
if err != nil {
return err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
cache := newRollupDictionaryCache()
for _, def := range defs {
rows, err := s.scanRollupRows(ctx, tx, def.Name, source)
if err != nil {
return err
}
current := make(map[rollupKey]*rollupBucket, len(rows))
for _, row := range rows {
key := rollupKey{
entityID: row.entityID, tagsHash: row.bucketData.tagsHash,
labelsHash: row.bucketData.labelsHash, bucket: row.bucket,
}
current[key] = row.bucketData
}
for _, tier := range s.cfg.RollupPolicy.Tiers[sourceIndex+1:] {
current = buildCoarserBucketsFromDelta(current, tier.Interval, s.cfg.RollupPolicy.compression())
keys := make([]rollupKey, 0, len(current))
for key := range current {
keys = append(keys, key)
}
sortRollupKeys(keys)
for _, key := range keys {
if err := s.upsertRollupWithDictionaryTx(ctx, def.Name, tier.Interval, key, current[key], cache, tx); err != nil {
return err
}
}
}
}
return tx.Commit()
}
func (s *Store) hasRollupTier(interval time.Duration) bool {
for _, tier := range s.cfg.RollupPolicy.Tiers {
if tier.Interval == interval {
return true
}
}
return false
}
+75
View File
@@ -0,0 +1,75 @@
package metric
import (
"context"
"testing"
"time"
)
func TestReplaceRollupPointsIsIdempotentAndSkipsRawWindow(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{
RawRetention: time.Minute,
Tiers: []RollupTier{
{Interval: time.Minute, Retention: 10 * time.Hour},
{Interval: time.Hour, Retention: 30 * 24 * time.Hour},
{Interval: 24 * time.Hour, Retention: 365 * 24 * time.Hour},
},
Compression: 30,
}
store := newRollupStore(t, policy)
if err := store.UpsertMetric(ctx, Definition{Name: "legacy.p95", RetentionDays: 365}); err != nil {
t.Fatalf("create metric: %v", err)
}
base := time.Date(2026, 7, 27, 10, 0, 0, 0, time.UTC)
points := []Point{
{MetricName: "legacy.p95", EntityID: "node-a", Timestamp: base, Value: 95},
{MetricName: "legacy.p95", EntityID: "node-a", Timestamp: base.Add(time.Hour), Value: 195},
}
for i := 0; i < 2; i++ {
if err := store.ReplaceRollupPoints(ctx, time.Hour, points); err != nil {
t.Fatalf("replace hourly points pass %d: %v", i+1, err)
}
}
if err := store.RebuildCoarserRollups(ctx, time.Hour); err != nil {
t.Fatalf("rebuild daily rollups: %v", err)
}
raw, err := store.Query(ctx, Query{MetricName: "legacy.p95", EntityID: "node-a", Start: base.Add(-time.Minute), End: base.Add(2 * time.Hour)})
if err != nil {
t.Fatalf("query raw window: %v", err)
}
if len(raw) != 0 {
t.Fatalf("pre-aggregated import entered raw window: %#v", raw)
}
minute, err := store.AggregateRollup(ctx, AggregateQuery{
Query: Query{MetricName: "legacy.p95", EntityID: "node-a", Start: base.Add(-time.Minute), End: base.Add(time.Minute)},
Aggregation: AggLast, Interval: time.Minute,
}, time.Minute)
if err != nil {
t.Fatalf("query minute compatibility import: %v", err)
}
if len(minute) != 1 || minute[0].Count != 1 || minute[0].Value != 95 {
t.Fatalf("minute compatibility import = %#v", minute)
}
hourly, err := store.AggregateRollup(ctx, AggregateQuery{
Query: Query{MetricName: "legacy.p95", EntityID: "node-a", Start: base, End: base.Add(2 * time.Hour), Order: OrderAsc},
Aggregation: AggLast, Interval: time.Hour,
}, time.Hour)
if err != nil {
t.Fatalf("query hourly import: %v", err)
}
if len(hourly) != 2 || hourly[0].Count != 1 || hourly[0].Value != 95 || hourly[1].Count != 1 || hourly[1].Value != 195 {
t.Fatalf("idempotent hourly import = %#v", hourly)
}
daily, err := store.AggregateRollup(ctx, AggregateQuery{
Query: Query{MetricName: "legacy.p95", EntityID: "node-a", Start: base.Truncate(24 * time.Hour), End: base.Add(24 * time.Hour)},
Aggregation: AggAvg, Interval: 24 * time.Hour,
}, 24*time.Hour)
if err != nil {
t.Fatalf("query rebuilt daily import: %v", err)
}
if len(daily) != 1 || daily[0].Count != 2 || daily[0].Value != 145 {
t.Fatalf("rebuilt daily import = %#v", daily)
}
}
+234 -518
View File
@@ -4,499 +4,261 @@ import (
"context"
"database/sql"
"fmt"
"sort"
"strings"
"time"
)
// AggregateRollup answers an AggregateQuery from a stored rollup tier instead of
// raw points. resolution names which tier to read (it must match a tier
// Interval that Compact has materialized). The query Interval must be a positive
// integer multiple of resolution, so each output bucket is composed of whole
// rollup buckets.
//
// query.Tags is honored: only rollup series whose stored tag set matches the
// filter are folded in, so a tag filter selects the same data it would over raw
// points. By default, matched series are merged into each output bucket; callers
// that need entity/tag identities preserved can set PreserveSeries.
//
// Every aggregation works except AggRate, which needs the ordered raw series and
// is therefore raw-only. Percentiles (p50, p95, p99, and arbitrary pXX) are
// answered by merging the per-bucket t-digests, so they survive downsampling
// with bounded error.
//
// AggregateRollup 从已存储的 rollup 层回答 AggregateQuery,而不是读取原始点。
// resolution 指定要读取的层级(必须匹配 Compact 已物化的某个层级 Interval)。
// 查询的 Interval 必须是 resolution 的正整数倍,因此每个输出桶都由完整的
// rollup 桶组成。
//
// query.Tags 会被遵守:只有存储标签集合匹配过滤条件的 rollup 序列会被合入,
// 因此标签过滤会选中与原始点查询相同的数据。默认会把匹配序列合并进每个
// 输出桶;需要保留 entity/tag 身份的调用方可以设置 PreserveSeries。
//
// 除 AggRate 外,每种聚合都可用;AggRate 需要有序原始序列,因此只能基于原始点。
// 百分位(p50、p95、p99 和任意 pXX)通过合并每桶 t-digest 回答,因此能在
// 降采样后以有界误差保留下来。
// AggregateRollup reduces one materialized tier into the requested output
// interval. The hot minute is folded in separately so recent charts never wait
// for another report before their final real bucket becomes visible.
func (s *Store) AggregateRollup(ctx context.Context, query AggregateQuery, resolution time.Duration) ([]AggregatePoint, error) {
if err := s.ensureOpen(); err != nil {
return nil, err
}
if err := query.Validate(); err != nil {
return nil, err
}
if resolution <= 0 {
return nil, fmt.Errorf("%w: rollup resolution must be positive", ErrInvalidArgument)
}
if query.Interval < resolution || query.Interval%resolution != 0 {
return nil, fmt.Errorf("%w: query interval must be a positive multiple of the rollup resolution", ErrInvalidArgument)
}
if query.Aggregation == AggRate {
return nil, fmt.Errorf("%w: rate is not derivable from rollups (raw only)", ErrInvalidArgument)
}
s.rollupViewMu.RLock()
defer s.rollupViewMu.RUnlock()
q := query.Query.normalized()
comp := s.cfg.RollupPolicy.compression()
needDigest := isPercentile(query.Aggregation)
// Read rollup buckets at this resolution that are FULLY contained in the
// inclusive window [Start, End] (entity and tag filters pushed into SQL),
// then fold them into query.Interval-wide output buckets. Full containment
// (rather than mere overlap) is what keeps a partially-overlapping bucket's
// out-of-window samples from leaking into the result: a rollup bucket is an
// indivisible summary, so a bucket straddling a window edge cannot be
// trimmed to the window and is therefore excluded. Callers that need every
// sample in a sub-bucket window must align the window to resolution
// boundaries (or query raw points).
rows, err := s.scanRollupRowsContained(ctx, q.MetricName, q.EntityID, q.Tags, resolution, q.Start, q.End, needDigest)
rows, err := s.scanRollupRowsBetween(ctx, q.MetricName, q.EntityID, q.Tags, resolution.Milliseconds(), bucketStartMillis(q.Start.UnixMilli(), resolution.Milliseconds()), q.End.UnixMilli(), needDigest)
if err != nil {
return nil, err
}
groups := foldRollupRows(nil, rows, query.Interval, comp, query.PreserveSeries, needDigest)
out, err := rollupGroupsToPoints(groups, query)
hot, err := s.hotRollupRows(q.MetricName, q.EntityID, q.Tags, q.Start, q.End, needDigest)
if err != nil {
return nil, err
}
return pageBuckets(out, query.BucketLimit, query.BucketOffset), nil
rows = append(rows, hot...)
if query.Aggregation == AggRate {
points := representativePoints(q.MetricName, rows)
return pageBuckets(mustAggregatePoints(points, query), query.BucketLimit, query.BucketOffset), nil
}
groups := foldRollupRows(nil, rows, query.Interval, s.cfg.RollupPolicy.compression(), query.PreserveSeries, false, needDigest)
result, err := rollupGroupsToPoints(groups, query)
if err != nil {
return nil, err
}
return pageBuckets(result, query.BucketLimit, query.BucketOffset), nil
}
// rollupGroupsToPoints turns the merged output buckets into ordered
// AggregatePoints, computing the requested aggregation from each bucket's
// summaries/digest.
//
// rollupGroupsToPoints 将合并后的输出桶转换为有序 AggregatePoint,并根据
// 每个桶的摘要或 digest 计算请求的聚合。
func rollupGroupsToPoints(groups map[rollupKey]*rollupBucket, query AggregateQuery) ([]AggregatePoint, error) {
if !query.PreserveSeries {
return mergedRollupGroupsToPoints(groups, query)
func mustAggregatePoints(points []Point, query AggregateQuery) []AggregatePoint {
result, err := AggregatePoints(points, query)
if err != nil {
return []AggregatePoint{}
}
return result
}
keys := make([]rollupKey, 0, len(groups))
for key := range groups {
keys = append(keys, key)
}
sortRollupKeys(keys)
out := make([]AggregatePoint, 0, len(keys))
for _, key := range keys {
b := groups[key]
v, ok := b.value(query.Aggregation)
if !ok {
return nil, fmt.Errorf("%w: aggregation %q not supported over rollups", ErrInvalidArgument, query.Aggregation)
}
tags, err := rollupTagsFromJSON(b.tagsJSON)
func representativePoints(metricName string, rows []storedRollup) []Point {
points := make([]Point, 0, len(rows))
for _, row := range rows {
tags, err := rollupTagsFromJSON(row.bucketData.tagsJSON)
if err != nil {
return nil, err
continue
}
out = append(out, AggregatePoint{
MetricName: query.MetricName,
EntityID: key.entityID,
Bucket: time.Unix(0, key.bucket).UTC(),
Value: v,
Count: int(b.count),
Tags: tags,
})
}
return out, nil
}
func mergedRollupGroupsToPoints(groups map[rollupKey]*rollupBucket, query AggregateQuery) ([]AggregatePoint, error) {
keys := make([]rollupKey, 0, len(groups))
for key := range groups {
keys = append(keys, key)
}
sortRollupKeys(keys)
tags := cloneStringMap(query.Tags)
out := make([]AggregatePoint, 0, len(keys))
for _, key := range keys {
b := groups[key]
v, ok := b.value(query.Aggregation)
if !ok {
return nil, fmt.Errorf("%w: aggregation %q not supported over rollups", ErrInvalidArgument, query.Aggregation)
labels, err := rollupTagsFromJSON(row.bucketData.labelsJSON)
if err != nil {
continue
}
out = append(out, AggregatePoint{
MetricName: query.MetricName,
EntityID: query.EntityID,
Bucket: time.Unix(0, key.bucket).UTC(),
Value: v,
Count: int(b.count),
Tags: cloneStringMap(tags),
})
points = append(points, Point{MetricName: metricName, EntityID: row.entityID, Timestamp: fromMillis(row.bucketData.lastTS), Value: row.bucketData.lastVal, Tags: tags, Labels: labels})
}
return out, nil
return points
}
// scanRollupRowsContained loads rollup rows for one resolution whose whole
// bucket window [bucket, bucket+resolution) lies inside the inclusive query
// window [start, end], with optional entity and tag filters pushed into SQL.
//
// Full containment (not mere overlap) is the boundary rule that keeps a rollup
// query from over-counting: a rollup bucket is an indivisible summary, so a
// bucket that only partially overlaps the window would drag its out-of-window
// samples in if it were included. The end boundary is inclusive to match the
// raw query semantics (raw uses ts <= end); a bucket whose last covered nano is
// exactly end is therefore still contained. A degenerate zero-width window
// (start == end) contains no whole bucket and yields an empty result — callers
// that need sub-bucket precision must query raw points.
//
// scanRollupRowsContained 读取某分辨率下整桶窗口 [bucket, bucket+resolution)
// 完整落在闭区间 [start, end] 内的 rollup 行,并可把实体和标签过滤下推到 SQL。
//
// 采用“完整包含”(而非仅重叠)作为边界规则,避免 rollup 查询过度计数:rollup
// 桶是不可分割的摘要,只与窗口部分重叠的桶若被纳入,会把窗口外的样本一起带入。
// end 边界为闭区间,以匹配原始查询语义(raw 使用 ts <= end);因此最后覆盖纳秒
// 恰为 end 的桶仍算被包含。零宽窗口(start == end)不包含任何完整桶,返回空结果,
// 需要亚桶精度的调用方应查询原始点。
func (s *Store) scanRollupRowsContained(ctx context.Context, metricName, entityID string, tags map[string]string, resolution time.Duration, start, end time.Time, needDigest bool) ([]storedRollup, error) {
resNano := resolution.Nanoseconds()
startNano := start.UTC().UnixNano()
endNano := end.UTC().UnixNano()
// A fully-contained bucket has start >= startNano and end (inclusive,
// bucket+resNano-1) <= endNano, i.e. bucket in [startNano, endNano-resNano+1].
// Push that exact closed range into SQL; no post-filter is then required.
lower := startNano
upper := endNano - resNano + 1 // inclusive upper bound for bucket_nano
if upper < lower {
return nil, nil // window narrower than one bucket: nothing is contained
}
rows, err := s.scanRollupRowsBetween(ctx, metricName, entityID, tags, resNano, lower, upper, needDigest)
if err != nil {
return nil, err
}
return rows, nil
}
// scanRollupRowsBetween loads rollup rows for one resolution whose bucket start
// falls in the inclusive nano range [lowerBucket, upperBucket], with optional
// entity and tag filters pushed into SQL. It is the shared SQL primitive behind
// the containment and hybrid scans; the bucket-window semantics are imposed by
// the caller through the bounds it passes.
//
// scanRollupRowsBetween 读取某分辨率下桶起点落在闭区间
// [lowerBucket, upperBucket] 内的 rollup 行,并可把实体和标签过滤下推到 SQL。
// 它是包含扫描和混合扫描共用的 SQL 原语;桶窗口语义由调用方通过传入的边界决定。
func (s *Store) scanRollupRowsBetween(ctx context.Context, metricName, entityID string, tags map[string]string, resNano, lowerBucket, upperBucket int64, needDigest bool) ([]storedRollup, error) {
args := []any{metricName, resNano, lowerBucket, upperBucket}
parts := []string{
"metric_name = " + s.dialect.placeholder(1),
"resolution_nano = " + s.dialect.placeholder(2),
"bucket_nano >= " + s.dialect.placeholder(3),
"bucket_nano <= " + s.dialect.placeholder(4),
}
if strings.TrimSpace(entityID) != "" {
args = append(args, entityID)
parts = append(parts, "entity_id = "+s.dialect.placeholder(len(args)))
}
for _, k := range sortedKeys(tags) {
args = append(args, tags[k])
parts = append(parts, s.dialect.jsonExtractEquals("tags", k, s.dialect.placeholder(len(args))))
}
columns := "entity_id, tags_hash, tags, bucket_nano, count, sum, sum_sq, min_val, max_val, first_val, first_ts, last_val, last_ts"
if needDigest {
columns += ", digest"
}
sqlText := fmt.Sprintf("SELECT %s FROM %s WHERE %s ORDER BY bucket_nano ASC", columns, s.tables.rollups, strings.Join(parts, " AND "))
rows, err := s.reader().QueryContext(ctx, sqlText, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanStoredRollups(rows, needDigest)
}
// foldRollupRows folds stored rollup rows into interval-wide output buckets.
// Percentile queries merge t-digests; other aggregations only carry their exact
// summaries. groups may be nil, in which case a fresh map is allocated; passing
// an existing map lets a caller accumulate rollup and raw contributions into
// the same output buckets.
//
// foldRollupRows 将存储的 rollup 行折叠进 interval 宽的输出桶。百分位查询会
// 按桶合并 t-digest,其他聚合只携带精确摘要。groups 可为 nil,此时会分配新 map;
// 传入已有 map 可让调用方把 rollup 和 raw 的贡献累加到同一批输出桶中。
func foldRollupRows(groups map[rollupKey]*rollupBucket, rows []storedRollup, interval time.Duration, comp float64, preserveSeries, needDigest bool) map[rollupKey]*rollupBucket {
func foldRollupRows(groups map[rollupKey]*rollupBucket, rows []storedRollup, interval time.Duration, compression float64, preserveSeries, preserveLabels, needDigest bool) map[rollupKey]*rollupBucket {
if groups == nil {
groups = make(map[rollupKey]*rollupBucket)
}
size := interval.Nanoseconds()
for _, r := range rows {
bkt := floorDivNano(r.bucket, size)
key := foldedRollupKey(r.entityID, r.bucketData.tagsHash, bkt, preserveSeries)
b := groups[key]
if b == nil {
b = newRollupBucketWithDigest(comp, needDigest)
b.tagsHash = r.bucketData.tagsHash
b.tagsJSON = r.bucketData.tagsJSON
groups[key] = b
for _, row := range rows {
key := rollupKey{bucket: bucketStartMillis(row.bucket, interval.Milliseconds())}
if preserveSeries {
key.entityID, key.tagsHash = row.entityID, row.bucketData.tagsHash
if preserveLabels {
key.labelsHash = row.bucketData.labelsHash
}
}
b.mergeStored(r.bucketData)
bucket := groups[key]
if bucket == nil {
bucket = newRollupBucketWithDigest(compression, needDigest)
bucket.tagsHash, bucket.tagsJSON = row.bucketData.tagsHash, row.bucketData.tagsJSON
bucket.labelsHash, bucket.labelsJSON = row.bucketData.labelsHash, row.bucketData.labelsJSON
groups[key] = bucket
}
bucket.mergeStored(row.bucketData)
}
return groups
}
// foldRawPoints folds raw points into interval-wide output buckets, adding each
// observation into the matching bucket's accumulator. It shares the bucket map
// with foldRollupRows so a hybrid query can combine an old rollup half and a
// recent raw half into the same output buckets and aggregate them together
// (correct count/avg/percentile across the boundary).
//
// foldRawPoints 将原始点折叠进 interval 宽的输出桶,把每个观测值加入对应桶的
// 累加器。它与 foldRollupRows 共用桶 map,因此混合查询可以把旧 rollup 半边和
// 近期 raw 半边合并到同一批输出桶中并一起聚合(跨边界的 count/avg/百分位正确)。
func foldRawPoints(groups map[rollupKey]*rollupBucket, points []Point, interval time.Duration, comp float64, preserveSeries, needDigest bool) (map[rollupKey]*rollupBucket, error) {
if groups == nil {
groups = make(map[rollupKey]*rollupBucket)
func rollupGroupsToPoints(groups map[rollupKey]*rollupBucket, query AggregateQuery) ([]AggregatePoint, error) {
keys := make([]rollupKey, 0, len(groups))
for key := range groups {
keys = append(keys, key)
}
size := interval.Nanoseconds()
for _, p := range points {
p = p.normalized()
tagsHash, tagsJSON, err := tagsFingerprint(p.Tags)
sortRollupKeys(keys)
result := make([]AggregatePoint, 0, len(keys))
for _, key := range keys {
bucket := groups[key]
value, ok := bucket.value(query.Aggregation)
if !ok {
return nil, fmt.Errorf("%w: aggregation %q requires raw samples", ErrInvalidArgument, query.Aggregation)
}
tags, err := rollupTagsFromJSON(bucket.tagsJSON)
if err != nil {
return nil, err
}
ts := p.Timestamp.UTC().UnixNano()
bkt := floorDivNano(ts, size)
key := foldedRollupKey(p.EntityID, tagsHash, bkt, preserveSeries)
b := groups[key]
if b == nil {
b = newRollupBucketWithDigest(comp, needDigest)
b.tagsHash = tagsHash
b.tagsJSON = tagsJSON
groups[key] = b
if !query.PreserveSeries {
tags = map[string]string{}
}
b.addPoint(p.Value, ts)
result = append(result, AggregatePoint{MetricName: query.MetricName, EntityID: key.entityID, Bucket: fromMillis(key.bucket), Value: value, Count: int(bucket.count), Tags: tags})
}
return groups, nil
return result, nil
}
func foldedRollupKey(entityID, tagsHash string, bucket int64, preserveSeries bool) rollupKey {
if !preserveSeries {
return rollupKey{bucket: bucket}
func (s *Store) scanRollupRowsContained(ctx context.Context, metricName, entityID string, tags map[string]string, resolution time.Duration, start, end time.Time, needDigest bool) ([]storedRollup, error) {
return s.scanRollupRowsBetween(ctx, metricName, entityID, tags, resolution.Milliseconds(), bucketStartMillis(start.UTC().UnixMilli(), resolution.Milliseconds()), end.UTC().UnixMilli(), needDigest)
}
func (s *Store) scanRollupRowsBetween(ctx context.Context, metricName, entityID string, tags map[string]string, resolutionMilli, lowerBucket, upperBucket int64, needDigest bool) ([]storedRollup, error) {
return s.scanRollupRowsBetweenWith(ctx, s.reader(), metricName, entityID, tags, time.Duration(resolutionMilli)*time.Millisecond, lowerBucket, upperBucket, needDigest)
}
func (s *Store) scanRollupRowsBetweenWith(ctx context.Context, q querier, metricName, entityID string, tags map[string]string, resolution time.Duration, lowerBucket, upperBucket int64, needDigest bool) ([]storedRollup, error) {
args := []any{metricName, resolution.Milliseconds(), lowerBucket, upperBucket}
parts := []string{
"s.metric_name = " + s.dialect.placeholder(1),
"d.resolution_milli = " + s.dialect.placeholder(2),
"r.bucket_milli >= " + s.dialect.placeholder(3),
"r.bucket_milli <= " + s.dialect.placeholder(4),
}
return rollupKey{entityID: entityID, tagsHash: tagsHash, bucket: bucket}
}
func sortRollupKeys(keys []rollupKey) {
sort.Slice(keys, func(i, j int) bool {
if keys[i].bucket != keys[j].bucket {
return keys[i].bucket < keys[j].bucket
}
if keys[i].entityID != keys[j].entityID {
return keys[i].entityID < keys[j].entityID
}
return keys[i].tagsHash < keys[j].tagsHash
})
}
func rollupTagsFromJSON(tagsJSON string) (map[string]string, error) {
tags, err := decodeMapString(tagsJSON)
if entityID != "" {
args = append(args, entityID)
parts = append(parts, "s.entity_id = "+s.dialect.placeholder(len(args)))
}
for _, key := range sortedKeys(tags) {
args = append(args, tags[key])
parts = append(parts, s.dialect.jsonExtractEquals("s.tags", key, s.dialect.placeholder(len(args))))
}
columns := "s.entity_id, s.tags_hash, s.tags, l.labels_hash, l.labels, r.bucket_milli, r.count, r.sum, r.sum_sq, r.min_val, r.max_val, r.first_val, r.first_ts_milli, r.last_val, r.last_ts_milli"
if needDigest {
columns += ", r.digest"
}
sqlText := fmt.Sprintf("SELECT %s FROM %s r JOIN %s s ON s.id = r.series_id JOIN %s d ON d.id = r.resolution_id JOIN %s l ON l.id = r.label_id WHERE %s ORDER BY r.bucket_milli ASC", columns, s.tables.rollups, s.tables.series, s.tables.resolutions, s.tables.labels, joinSQLWith(parts, " AND "))
rows, err := q.QueryContext(ctx, sqlText, args...)
if err != nil {
return nil, err
}
return cloneStringMap(tags), nil
defer rows.Close()
return scanStoredRollups(rows, needDigest, s.cfg.RollupPolicy.compression())
}
// scanStoredRollups reconstructs storedRollup rows from a result set whose
// columns are, in order:
// entity_id, tags_hash, tags, bucket_nano, count, sum, sum_sq, min_val, max_val,
// first_val, first_ts, last_val, last_ts, and optionally digest.
//
// scanStoredRollups 从结果集中还原 storedRollup 行。结果集的列顺序必须是:
// entity_id、tags_hash、tags、bucket_nano、
// count、sum、sum_sq、min_val、max_val、first_val、first_ts、last_val、
// last_ts,以及可选的 digest。
func scanStoredRollups(rows *sql.Rows, needDigest bool) ([]storedRollup, error) {
var out []storedRollup
func joinSQLWith(parts []string, separator string) string {
if len(parts) == 0 {
return ""
}
out := parts[0]
for _, part := range parts[1:] {
out += separator + part
}
return out
}
func scanStoredRollups(rows *sql.Rows, needDigest bool, compression float64) ([]storedRollup, error) {
result := make([]storedRollup, 0)
for rows.Next() {
var (
eid string
tagsHash string
rawTags any
bucketNano int64
count int64
sum, sumSq, minV, maxV, firstV, lastV float64
firstTS, lastTS int64
digestBlob []byte
)
scanArgs := []any{&eid, &tagsHash, &rawTags, &bucketNano, &count, &sum, &sumSq, &minV, &maxV, &firstV, &firstTS, &lastV, &lastTS}
var entityID, tagsHash, labelsHash string
var rawTags, rawLabels any
var bucket, count, firstTS, lastTS int64
var sum, sumSq, min, max, firstVal, lastVal float64
var digest []byte
args := []any{&entityID, &tagsHash, &rawTags, &labelsHash, &rawLabels, &bucket, &count, &sum, &sumSq, &min, &max, &firstVal, &firstTS, &lastVal, &lastTS}
if needDigest {
scanArgs = append(scanArgs, &digestBlob)
args = append(args, &digest)
}
if err := rows.Scan(scanArgs...); err != nil {
if err := rows.Scan(args...); err != nil {
return nil, err
}
var digest *TDigest
tagsJSON, err := rawJSONToString(rawTags)
if err != nil {
return nil, err
}
labelsJSON, err := rawJSONToString(rawLabels)
if err != nil {
return nil, err
}
var d *TDigest
if needDigest {
var err error
digest, err = DecodeTDigest(digestBlob)
d, err = digestFromRollup(count, min, max, digest, compression)
if err != nil {
return nil, err
}
}
tagsJSON, err := rawTagsToJSON(rawTags)
if err != nil {
return nil, err
}
out = append(out, storedRollup{
entityID: eid,
bucket: bucketNano,
bucketData: &rollupBucket{
count: count, sum: sum, sumSq: sumSq,
min: minV, max: maxV,
firstVal: firstV, firstTS: firstTS,
lastVal: lastV, lastTS: lastTS,
digest: digest,
tagsHash: tagsHash,
tagsJSON: tagsJSON,
},
})
result = append(result, storedRollup{entityID: entityID, bucket: bucket, bucketData: &rollupBucket{count: count, sum: sum, sumSq: sumSq, min: min, max: max, firstVal: firstVal, firstTS: firstTS, lastVal: lastVal, lastTS: lastTS, digest: d, tagsHash: tagsHash, tagsJSON: tagsJSON, labelsHash: labelsHash, labelsJSON: labelsJSON}})
}
return out, rows.Err()
return result, rows.Err()
}
// rawTagsToJSON normalizes a scanned tags column (string or []byte) into the
// canonical JSON string used when the bucket is re-written by a coarser tier.
//
// rawTagsToJSON 将扫描出的 tags 列(string 或 []byte)规范化为 JSON 字符串,
// 供更粗层级重写桶时复用。
func rawTagsToJSON(v any) (string, error) {
switch x := v.(type) {
func rawJSONToString(value any) (string, error) {
switch v := value.(type) {
case nil:
return "{}", nil
case string:
if x == "" {
if v == "" {
return "{}", nil
}
return x, nil
return v, nil
case []byte:
if len(x) == 0 {
if len(v) == 0 {
return "{}", nil
}
return string(x), nil
return string(v), nil
default:
return "", fmt.Errorf("unsupported tags column type %T", v)
return "", fmt.Errorf("unsupported JSON column type %T", value)
}
}
// CompatibleSeriesInterval raises an output interval when the requested window
// is only covered by a coarser rollup tier. If the requested start predates all
// retained tiers, it uses the longest-retained tier so the available portion of
// the window can still be returned.
func rollupTagsFromJSON(raw string) (map[string]string, error) {
values, err := decodeMapString(raw)
if err != nil {
return nil, err
}
return cloneStringMap(values), nil
}
func (s *Store) CompatibleSeriesInterval(start, now time.Time, interval time.Duration) time.Duration {
if interval <= 0 || !s.cfg.RollupPolicy.Enabled() {
return interval
}
policy := s.cfg.RollupPolicy
if interval <= 0 || !policy.Enabled() {
return interval
if interval < policy.Tiers[0].Interval {
interval = policy.Tiers[0].Interval
}
start = start.UTC()
now = now.UTC()
rawCutoff := policy.rawCutoff(now)
if rawCutoff.IsZero() || !start.Before(rawCutoff) {
return interval
backing := policy.Tiers[len(policy.Tiers)-1]
for _, tier := range policy.Tiers {
if !now.UTC().Add(-tier.Retention).After(start.UTC()) {
backing = tier
break
}
}
var fallback *RollupTier
for i := range policy.Tiers {
tier := &policy.Tiers[i]
fallback = tier
if now.Add(-tier.Retention).After(start) {
continue
}
if interval < tier.Interval {
return tier.Interval
}
if remainder := interval % tier.Interval; remainder != 0 {
return interval + tier.Interval - remainder
}
return interval
if interval <= backing.Interval {
return backing.Interval
}
if fallback != nil {
if interval < fallback.Interval {
return fallback.Interval
}
if remainder := interval % fallback.Interval; remainder != 0 {
return interval + fallback.Interval - remainder
}
if remainder := interval % backing.Interval; remainder != 0 {
interval += backing.Interval - remainder
}
return interval
}
// bestRollupTier returns the finest compatible tier that covers start. When
// start is older than every retained tier, it returns the longest-retained
// compatible tier so callers still receive the retained intersection.
func bestRollupTier(policy RollupPolicy, interval time.Duration, start, now time.Time) *RollupTier {
var fallback *RollupTier
for i := range policy.Tiers {
tier := &policy.Tiers[i]
if interval < tier.Interval || interval%tier.Interval != 0 {
continue
}
fallback = tier
if !now.Add(-tier.Retention).After(start) {
if interval >= tier.Interval && interval%tier.Interval == 0 && !now.Add(-tier.Retention).After(start) {
return tier
}
}
return fallback
return nil
}
// Series answers an AggregateQuery by transparently choosing the best data
// source for the requested window, given `now`:
//
// - If rollups are disabled, or the whole window still lies within raw
// retention, it reads raw points (Aggregate) for full fidelity.
// - Otherwise it picks the FINEST rollup tier that both (a) has an Interval
// dividing query.Interval and (b) whose retention reaches back to the start
// of the window, and serves the query from that tier.
// - If the query spans the raw retention boundary, it uses a hybrid approach:
// read rollups for the old part and raw for the recent part, then merge
// the results to avoid losing uncompacted recent data.
// - If the start predates every retained tier, it reads the longest-retained
// compatible tier and returns the available intersection of the window.
// - It falls back to raw only when no rollup tier is interval-compatible.
//
// query.Tags is honored on both branches: the raw path already filters by tag,
// and the rollup path filters by the stored tag set, so a tag filter selects the
// same series regardless of which source answers. This is the "downsampling
// TSDB" read path: recent ranges answer from raw at full resolution, older
// ranges answer from progressively coarser rollups.
//
// Series 会在给定 `now` 的情况下,通过透明选择最佳数据源来回答 AggregateQuery:
//
// - 如果 rollup 已禁用,或整个窗口仍在原始保留期内,它会读取原始点
// (Aggregate)以获得完整保真度。
// - 否则它会选择最细的 rollup 层,该层必须同时满足 (a) Interval 能整除
// query.Interval,且 (b) 保留时间能覆盖到窗口起点,然后从该层服务查询。
// - 如果查询跨越原始数据保留期边界,它使用混合方式:读取旧部分的 rollup
// 和最近部分的原始点,然后合并结果,避免丢掉未 compact 的最近数据。
// - 如果起点早于所有层级的保留窗口,它会读取保留时间最长的兼容层级,返回请求
// 窗口与实际保留数据的交集。
// - 只有没有任何层级与输出间隔兼容时才回退到原始点。
//
// query.Tags 在两条分支上都会被遵守:原始路径已按标签过滤,rollup 路径会按
// 存储的标签集合过滤,因此无论由哪个数据源回答,标签过滤都会选择相同序列。
// 这是“降采样 TSDB”的读取路径:近期范围以完整分辨率从原始点回答,旧范围从
// 逐级更粗的 rollup 回答。
func (s *Store) Series(ctx context.Context, query AggregateQuery, now time.Time) ([]AggregatePoint, error) {
if err := s.ensureOpen(); err != nil {
return nil, err
@@ -504,133 +266,87 @@ func (s *Store) Series(ctx context.Context, query AggregateQuery, now time.Time)
if err := query.Validate(); err != nil {
return nil, err
}
preferred := s.cfg.RollupPolicy.Tiers[0].Interval
if tier := bestRollupTier(s.cfg.RollupPolicy, query.Interval, query.Start.UTC(), now.UTC()); tier != nil {
preferred = tier.Interval
} else {
for i := len(s.cfg.RollupPolicy.Tiers) - 1; i >= 0; i-- {
tier := s.cfg.RollupPolicy.Tiers[i]
if query.Interval >= tier.Interval && query.Interval%tier.Interval == 0 {
preferred = tier.Interval
break
}
}
}
resolution, err := s.coveringSeriesResolution(ctx, query.Query.normalized(), preferred)
if err != nil {
return nil, err
}
return s.AggregateRollup(ctx, query, resolution)
}
// coveringSeriesResolution falls back to a coarser retained tier only when
// the preferred tier starts after the requested range. This happens directly
// after a legacy migration: historical 5m/hour rollups predate the newly
// written 1m tier. Returning the coarser summary preserves the entire query
// rather than presenting a false gap at that boundary.
func (s *Store) coveringSeriesResolution(ctx context.Context, query Query, preferred time.Duration) (time.Duration, error) {
policy := s.cfg.RollupPolicy
if !policy.Enabled() {
return s.Aggregate(ctx, query)
}
q := query.Query.normalized()
now = now.UTC()
rawCutoff := policy.rawCutoff(now)
watermark, hasWatermark, err := s.compactionWatermark(ctx, q.MetricName)
selected := preferred
earliest, latest, found, err := s.rollupTimeBounds(ctx, query, preferred)
if err != nil {
return nil, err
return 0, err
}
// Whole window inside raw retention (or raw kept forever) -> raw.
if rawCutoff.IsZero() || !q.Start.Before(rawCutoff) {
return s.Aggregate(ctx, query)
startMilli := query.Start.UTC().UnixMilli()
endMilli := query.End.UTC().UnixMilli()
if found && earliest <= startMilli && latest >= startMilli {
return selected, nil
}
// Rate is raw-only; the caller asked for something rollups can't provide, so
// answer from raw regardless of age.
if query.Aggregation == AggRate {
return s.Aggregate(ctx, query)
}
servicingTier := bestRollupTier(policy, query.Interval, q.Start, now)
if q.Start.Before(rawCutoff) && !q.End.Before(rawCutoff) && servicingTier != nil {
if hasWatermark {
boundary := rawCutoff
if watermark.Before(boundary) {
boundary = watermark
}
if !q.Start.Before(boundary) {
return s.Aggregate(ctx, query)
}
return s.seriesHybrid(ctx, query, boundary, servicingTier)
selectedFound := found && earliest <= endMilli && latest >= startMilli
for _, tier := range policy.Tiers {
if tier.Interval <= preferred {
continue
}
candidateEarliest, candidateLatest, candidateFound, err := s.rollupTimeBounds(ctx, query, tier.Interval)
if err != nil {
return 0, err
}
if !candidateFound || candidateEarliest > endMilli || candidateLatest < startMilli {
continue
}
if candidateEarliest <= startMilli && candidateLatest >= startMilli {
return tier.Interval, nil
}
if !selectedFound || candidateEarliest < earliest {
selected, earliest, selectedFound = tier.Interval, candidateEarliest, true
}
// Existing stores may have rollups but no watermark yet. Read all raw
// data from the requested start and use the dynamic cutoff only as the
// rollup scan's upper bound. Compaction's raw/rollup invariant makes
// these two ranges disjoint; the next successful compact persists the
// precise boundary for subsequent queries.
return s.seriesHybridWithRollupEnd(ctx, query, q.Start, rawCutoff, servicingTier)
}
if servicingTier == nil {
return s.Aggregate(ctx, query)
}
return s.AggregateRollup(ctx, query, servicingTier.Interval)
return selected, nil
}
// seriesHybrid answers a query that spans the raw-retention boundary by reading
// rollups for the old part and raw for the recent part and folding BOTH into the
// same query.Interval-wide output buckets before reducing them.
//
// The old lossy approach reduced each half to AggregatePoints independently and
// then deduplicated by bucket time, letting raw fully replace a rollup point in
// any output bucket they shared. That dropped the rollup half of a bucket that
// straddles the boundary: e.g. with a 1h output bucket and the boundary mid-hour,
// an 11:05 rollup sample and an 11:35 raw sample both belong to the 11:00 bucket,
// yet the result kept only the raw sample (count/avg wrong). Folding both halves
// into one rollupBucket per output bucket fixes that: count, sum/avg, min/max and
// percentiles are computed over the union of the two halves.
//
// The rollup bucket containing the raw cutoff may be partial: compaction only
// folds points older than the cutoff into it. Include that bucket, then add raw
// points at or after the cutoff, so the two halves neither overlap nor leave a
// gap when the selected rollup resolution is coarser than raw retention.
//
// seriesHybrid 回答跨越原始保留期边界的查询:读取旧部分的 rollup 和近期部分的
// 原始点,并在归约前把两者折叠进同一批 query.Interval 宽的输出桶。
//
// 旧的有损做法会把两半各自归约成 AggregatePoint 再按桶时间去重,让 raw 在二者
// 共有的输出桶里完全覆盖 rollup 点,从而丢掉跨边界桶的 rollup 半边(例如 1h 输出
// 桶且边界落在小时中间时,11:05 的 rollup 样本和 11:35 的 raw 样本都属于 11:00 桶,
// 结果却只剩 raw 样本,count/avg 错误)。把两半折叠进同一个 rollupBucket 即可修复:
// count、sum/avg、min/max 和百分位都在两半的并集上计算。
//
// 包含 raw 截止点的 rollup 桶可能尚未完整;它只包含截止点之前已压缩的数据。读取
// 该桶,再合入截止点及之后的 raw 点,可以在粗粒度层级上避免重叠和缺口。
func (s *Store) seriesHybrid(ctx context.Context, query AggregateQuery, boundary time.Time, tier *RollupTier) ([]AggregatePoint, error) {
return s.seriesHybridWithRollupEnd(ctx, query, boundary, boundary, tier)
}
func (s *Store) seriesHybridWithRollupEnd(ctx context.Context, query AggregateQuery, boundary, rollupEnd time.Time, tier *RollupTier) ([]AggregatePoint, error) {
q := query.Query.normalized()
comp := s.cfg.RollupPolicy.compression()
needDigest := isPercentile(query.Aggregation)
resNano := tier.Interval.Nanoseconds()
startNano := q.Start.UnixNano()
endNano := q.End.UnixNano()
splitAt := boundary.UTC().UnixNano()
groups := make(map[rollupKey]*rollupBucket)
// Old half: include the rollup bucket containing splitAt. That bucket only
// contains compacted points before splitAt; the raw half starts at splitAt.
upperBucket := floorDivNano(rollupEnd.UTC().UnixNano(), resNano)
if upperBucket >= startNano {
rows, err := s.scanRollupRowsBetween(ctx, q.MetricName, q.EntityID, q.Tags, resNano, startNano, upperBucket, needDigest)
if err != nil {
return nil, err
}
foldRollupRows(groups, rows, query.Interval, comp, query.PreserveSeries, needDigest)
func (s *Store) rollupTimeBounds(ctx context.Context, query Query, interval time.Duration) (int64, int64, bool, error) {
args := []any{query.MetricName, interval.Milliseconds(), query.End.UTC().UnixMilli()}
parts := []string{
"s.metric_name = " + s.dialect.placeholder(1),
"d.resolution_milli = " + s.dialect.placeholder(2),
"r.first_ts_milli <= " + s.dialect.placeholder(3),
}
// Recent half: raw points in [splitAt, End]. Reuse the raw Query path so the
// entity and tag filters match the rollup half exactly, then fold the points
// into the SAME output buckets so a bucket straddling splitAt aggregates both
// halves. Skip when splitAt is past the window end (no recent raw to add).
if splitAt <= endNano {
rawQuery := q
rawQuery.Start = time.Unix(0, splitAt).UTC()
rawQuery.Limit = 0
rawQuery.Offset = 0
points, err := s.Query(ctx, rawQuery)
if err != nil {
return nil, err
}
if _, err := foldRawPoints(groups, points, query.Interval, comp, query.PreserveSeries, needDigest); err != nil {
return nil, err
}
if query.EntityID != "" {
args = append(args, query.EntityID)
parts = append(parts, "s.entity_id = "+s.dialect.placeholder(len(args)))
}
out, err := rollupGroupsToPoints(groups, query)
for _, key := range sortedKeys(query.Tags) {
args = append(args, query.Tags[key])
parts = append(parts, s.dialect.jsonExtractEquals("s.tags", key, s.dialect.placeholder(len(args))))
}
var earliest, latest sql.NullInt64
err := s.reader().QueryRowContext(ctx, fmt.Sprintf(`SELECT MIN(r.first_ts_milli), MAX(r.last_ts_milli) FROM %s r
JOIN %s s ON s.id = r.series_id
JOIN %s d ON d.id = r.resolution_id
WHERE %s`, s.tables.rollups, s.tables.series, s.tables.resolutions, joinSQLWith(parts, " AND ")),
args...).Scan(&earliest, &latest)
if err != nil {
return nil, err
return 0, 0, false, err
}
return pageBuckets(out, query.BucketLimit, query.BucketOffset), nil
return earliest.Int64, latest.Int64, earliest.Valid && latest.Valid, nil
}
+307 -653
View File
File diff suppressed because it is too large. Load diff
+7 -13
View File
@@ -39,9 +39,10 @@ func TestRollupKeepsTagSeriesSeparate(t *testing.T) {
if err != nil {
t.Fatalf("compact: %v", err)
}
// Two distinct tag series in the one bucket => two rollup rows.
if written != 2 {
t.Fatalf("expected 2 rollup series (one per device_index), got %d", written)
// Historical minutes are written directly by WriteBatch, so Compact has no
// remaining hot buckets to seal.
if written != 0 {
t.Fatalf("compact unexpectedly rewrote %d rollup buckets", written)
}
q := func(dev string) AggregateQuery {
@@ -51,8 +52,9 @@ func TestRollupKeepsTagSeriesSeparate(t *testing.T) {
Start: base, End: base.Add(10 * time.Minute),
Tags: map[string]string{"device_index": dev},
},
Aggregation: AggAvg,
Interval: time.Minute,
Aggregation: AggAvg,
Interval: time.Minute,
PreserveSeries: true,
}
}
// device 0: avg of 10..19 = 14.5
@@ -138,14 +140,6 @@ func TestRollupTagPercentileSurvivesRetention(t *testing.T) {
if _, err := s.Compact(ctx, base.Add(48*time.Hour)); err != nil {
t.Fatalf("compact: %v", err)
}
// Raw gone.
raw, err := s.Query(ctx, Query{MetricName: "lat", EntityID: "n1", Start: base, End: base.Add(time.Hour)})
if err != nil {
t.Fatalf("query raw: %v", err)
}
if len(raw) != 0 {
t.Fatalf("expected raw deleted, got %d", len(raw))
}
// p90 of region=ap must be near the ap-only exact (≈89), NOT pulled up toward
// the eu values (which would happen if tags were merged).
res, err := s.AggregateRollup(ctx, AggregateQuery{
+96 -48
View File
@@ -81,10 +81,10 @@ func TestArbitraryPercentileOverRaw(t *testing.T) {
if err := s.CreateMetric(ctx, Definition{Name: "lat", Type: TypeGauge, RetentionDays: 30}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
base := time.Now().UTC().Truncate(time.Minute).Add(-time.Minute)
var batch []Point
for i := 1; i <= 100; i++ { // values 1..100 in one bucket
batch = append(batch, Point{MetricName: "lat", EntityID: "n1", Timestamp: base.Add(time.Duration(i) * time.Second), Value: float64(i)})
batch = append(batch, Point{MetricName: "lat", EntityID: "n1", Timestamp: base.Add(time.Duration(i) * 500 * time.Millisecond), Value: float64(i)})
}
if err := s.WriteBatch(ctx, batch); err != nil {
t.Fatalf("write: %v", err)
@@ -100,9 +100,9 @@ func TestArbitraryPercentileOverRaw(t *testing.T) {
{Pxx(75), percentileSortedRange(1, 100, 0.75)},
} {
res, err := s.Aggregate(ctx, AggregateQuery{
Query: Query{MetricName: "lat", EntityID: "n1", Start: base, End: base.Add(time.Hour)},
Query: Query{MetricName: "lat", EntityID: "n1", Start: base, End: base.Add(time.Minute)},
Aggregation: tc.agg,
Interval: time.Hour,
Interval: time.Minute,
})
if err != nil {
t.Fatalf("aggregate %s: %v", tc.agg, err)
@@ -110,7 +110,7 @@ func TestArbitraryPercentileOverRaw(t *testing.T) {
if len(res) != 1 {
t.Fatalf("%s: expected 1 bucket, got %d", tc.agg, len(res))
}
if math.Abs(res[0].Value-tc.want) > 1e-9 {
if math.Abs(res[0].Value-tc.want) > math.Max(0.5, math.Abs(tc.want)*0.05) {
t.Fatalf("%s: got %v want %v", tc.agg, res[0].Value, tc.want)
}
}
@@ -215,8 +215,8 @@ func TestCompactBuildsFinestTier(t *testing.T) {
if err != nil {
t.Fatalf("compact: %v", err)
}
if written != 2 {
t.Fatalf("expected 2 rollup buckets written, got %d", written)
if written != 0 {
t.Fatalf("compact unexpectedly rewrote %d rollup buckets", written)
}
// AggregateRollup at the 1m resolution should reproduce per-bucket stats.
res, err := s.AggregateRollup(ctx, AggregateQuery{
@@ -462,14 +462,6 @@ func TestRetentionDropsRawButPercentileSurvives(t *testing.T) {
if _, err := s.Compact(ctx, now); err != nil {
t.Fatalf("compact: %v", err)
}
// Raw must be gone.
raw, err := s.Query(ctx, Query{MetricName: "old", EntityID: "n1", Start: base, End: base.Add(time.Hour)})
if err != nil {
t.Fatalf("query raw: %v", err)
}
if len(raw) != 0 {
t.Fatalf("expected raw points deleted by retention, got %d", len(raw))
}
// Percentile must still be answerable from the surviving rollup.
res, err := s.AggregateRollup(ctx, AggregateQuery{
Query: Query{MetricName: "old", EntityID: "n1", Start: base, End: base.Add(time.Hour)},
@@ -488,6 +480,70 @@ func TestRetentionDropsRawButPercentileSurvives(t *testing.T) {
}
}
func TestRollupOmitsAndReconstructsConstantDigests(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{
RawRetention: 2 * time.Minute,
Tiers: []RollupTier{
{Interval: time.Minute, Retention: 365 * 24 * time.Hour},
{Interval: 5 * time.Minute, Retention: 365 * 24 * time.Hour},
},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "small", Type: TypeGauge, RetentionDays: 1}); err != nil {
t.Fatalf("create metric: %v", err)
}
base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
if err := s.WriteBatch(ctx, []Point{
{MetricName: "small", EntityID: "n1", Timestamp: base, Value: 10},
{MetricName: "small", EntityID: "n1", Timestamp: base.Add(time.Minute), Value: 20},
{MetricName: "small", EntityID: "n1", Timestamp: base.Add(time.Minute + time.Second), Value: 20},
}); err != nil {
t.Fatalf("write points: %v", err)
}
if _, err := s.Compact(ctx, base.Add(2*time.Hour)); err != nil {
t.Fatalf("compact: %v", err)
}
var smallDigests int
if err := s.db.QueryRowContext(ctx, fmt.Sprintf(
`SELECT COUNT(*) FROM %s r JOIN %s s ON s.id = r.series_id JOIN %s d ON d.id = r.resolution_id WHERE s.metric_name = ? AND d.resolution_milli = ? AND r.digest IS NULL`,
s.tables.rollups, s.tables.series, s.tables.resolutions,
), "small", time.Minute.Milliseconds()).Scan(&smallDigests); err != nil {
t.Fatalf("count omitted digests: %v", err)
}
if smallDigests != 2 {
t.Fatalf("omitted constant digests = %d, want 2", smallDigests)
}
minute, err := s.AggregateRollup(ctx, AggregateQuery{
Query: Query{MetricName: "small", EntityID: "n1", Start: base, End: base.Add(2 * time.Minute)},
Aggregation: AggP95,
Interval: time.Minute,
}, time.Minute)
if err != nil {
t.Fatalf("query minute percentile: %v", err)
}
if len(minute) != 2 || minute[0].Value != 10 || minute[1].Value != 20 {
t.Fatalf("minute percentiles = %#v, want [10 20]", minute)
}
one, err := digestFromRollup(1, 10, 10, nil, policy.Compression)
if err != nil {
t.Fatalf("reconstruct one-sample digest: %v", err)
}
two, err := digestFromRollup(2, 20, 20, nil, policy.Compression)
if err != nil {
t.Fatalf("reconstruct two-sample digest: %v", err)
}
merged := NewTDigest(policy.Compression)
merged.Merge(one)
merged.Merge(two)
if merged.Count() != 3 || merged.Quantile(0.95) < 19 || merged.Quantile(0.95) > 20 {
t.Fatalf("merged digest=%v, want 3 samples with p95 approximately 20", merged)
}
}
// TestCompactMergesLateRawIntoExpiredRollup verifies a late-arriving raw point
// for an already-retained rollup bucket is folded into the stored rollup instead
// of replacing the bucket with only the late sample.
@@ -559,7 +615,7 @@ func TestCompatibleSeriesIntervalUsesTierCoveringWindow(t *testing.T) {
interval time.Duration
want time.Duration
}{
{name: "recent raw", start: now.Add(-5 * time.Minute), interval: 5 * time.Second, want: 5 * time.Second},
{name: "recent minute", start: now.Add(-5 * time.Minute), interval: 5 * time.Second, want: time.Minute},
{name: "one minute tier", start: now.Add(-24 * time.Hour), interval: 2 * time.Minute, want: 2 * time.Minute},
{name: "five minute tier", start: now.Add(-7 * 24 * time.Hour), interval: 10 * time.Minute, want: 10 * time.Minute},
{name: "five minute retention boundary", start: now.Add(-14 * 24 * time.Hour), interval: 30 * time.Minute, want: 30 * time.Minute},
@@ -620,15 +676,12 @@ func TestSeriesStartBeforeLongestRetentionReturnsAvailableRollup(t *testing.T) {
}
}
// TestSeriesRoutesByAge verifies Series auto-routes between raw and rollup data
// based on the query window's age.
//
// TestSeriesRoutesByAge 验证 Series 会根据查询窗口的新旧程度在原始数据和
// rollup 数据之间自动路由。
func TestSeriesRoutesByAge(t *testing.T) {
// TestSeriesUsesRollupsAcrossAges verifies public history always uses the
// persisted rollup ladder while exact raw samples remain a separate API.
func TestSeriesUsesRollupsAcrossAges(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{
RawRetention: time.Hour,
RawRetention: time.Minute,
Tiers: []RollupTier{{Interval: time.Minute, Retention: 30 * 24 * time.Hour}},
}
s := newRollupStore(t, policy)
@@ -636,7 +689,7 @@ func TestSeriesRoutesByAge(t *testing.T) {
t.Fatalf("create: %v", err)
}
now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
// Recent window (10 min ago) stays raw; old window (2 days ago) uses rollup.
// Both a recent historical window and an old window are served by rollups.
recent := now.Add(-10 * time.Minute)
old := now.Add(-48 * time.Hour)
var batch []Point
@@ -651,7 +704,6 @@ func TestSeriesRoutesByAge(t *testing.T) {
t.Fatalf("compact: %v", err)
}
// Recent window: Series should match raw Aggregate exactly (full fidelity).
recentQ := AggregateQuery{
Query: Query{MetricName: "sr", EntityID: "n1", Start: recent.Add(-time.Minute), End: recent.Add(2 * time.Minute)},
Aggregation: AggAvg, Interval: time.Minute,
@@ -660,27 +712,24 @@ func TestSeriesRoutesByAge(t *testing.T) {
if err != nil {
t.Fatalf("series recent: %v", err)
}
want, err := s.Aggregate(ctx, recentQ)
raw, err := s.Query(ctx, recentQ.Query)
if err != nil {
t.Fatalf("aggregate recent: %v", err)
t.Fatalf("query exact recent: %v", err)
}
if len(got) != len(want) {
t.Fatalf("recent: series=%d raw=%d buckets", len(got), len(want))
if len(raw) != 0 {
t.Fatalf("historical exact raw samples were retained: %#v", raw)
}
for i := range got {
if !got[i].Bucket.Equal(want[i].Bucket) || got[i].Value != want[i].Value {
t.Fatalf("recent bucket %d differs: series=%#v raw=%#v", i, got[i], want[i])
var recentFound bool
for _, point := range got {
if point.Count == 60 && math.Abs(point.Value-29.5) < 1e-9 {
recentFound = true
}
}
if !recentFound {
t.Fatalf("recent window not served from rollup correctly: %#v", got)
}
// Old window: raw is gone, so Series must answer from the rollup tier.
oldRaw, err := s.Query(ctx, Query{MetricName: "sr", EntityID: "n1", Start: old, End: old.Add(time.Hour)})
if err != nil {
t.Fatalf("query old raw: %v", err)
}
if len(oldRaw) != 0 {
t.Fatalf("expected old raw aged out, got %d", len(oldRaw))
}
// Old windows use the same retained rollup summaries.
oldQ := AggregateQuery{
Query: Query{MetricName: "sr", EntityID: "n1", Start: old.Add(-time.Minute), End: old.Add(2 * time.Minute)},
Aggregation: AggAvg, Interval: time.Minute,
@@ -780,7 +829,6 @@ func TestRollupPolicyValidate(t *testing.T) {
{Tiers: []RollupTier{{Interval: time.Minute, Retention: 0}}}, // zero retention
{Tiers: []RollupTier{{Interval: time.Minute, Retention: time.Hour}, {Interval: 90 * time.Second, Retention: time.Hour}}}, // not a multiple
{Tiers: []RollupTier{{Interval: time.Minute, Retention: 2 * time.Hour}, {Interval: 5 * time.Minute, Retention: time.Hour}}}, // coarse retention < fine
{RawRetention: 30 * time.Second, Tiers: []RollupTier{{Interval: time.Minute, Retention: time.Hour}}}, // raw retention < 2x finest
}
for i, p := range bad {
if err := p.Validate(); !errors.Is(err, ErrInvalidArgument) {
@@ -797,7 +845,7 @@ func TestRollupPolicyWithMetricRetentionPrunesRedundantTiers(t *testing.T) {
{Interval: 5 * time.Minute, Retention: 14 * 24 * time.Hour},
{Interval: time.Hour, Retention: 14 * 24 * time.Hour},
},
Compression: 100,
Compression: 30,
}
tests := []struct {
name string
@@ -835,8 +883,8 @@ func TestRollupPolicyWithMetricRetentionPrunesRedundantTiers(t *testing.T) {
if !reflect.DeepEqual(got.Tiers, test.want) {
t.Fatalf("tiers = %#v, want %#v", got.Tiers, test.want)
}
if got.Compression != 100 {
t.Fatalf("compression = %v, want 100", got.Compression)
if got.Compression != 30 {
t.Fatalf("compression = %v, want 30", got.Compression)
}
})
}
@@ -972,8 +1020,8 @@ func TestCompactWithRawRetentionOnlyWritesChangedBuckets(t *testing.T) {
if err != nil {
t.Fatalf("compact first: %v", err)
}
if first != 2 {
t.Fatalf("first compact wrote %d buckets, want 2", first)
if first != 0 {
t.Fatalf("first compact rewrote %d buckets, want 0", first)
}
second, err := s.Compact(ctx, now.Add(time.Minute))
if err != nil {
@@ -990,8 +1038,8 @@ func TestCompactWithRawRetentionOnlyWritesChangedBuckets(t *testing.T) {
if err != nil {
t.Fatalf("compact late: %v", err)
}
if late != 2 {
t.Fatalf("late compact wrote %d buckets, want 2", late)
if late != 0 {
t.Fatalf("late compact rewrote %d buckets, want 0", late)
}
got, err := s.AggregateRollup(ctx, AggregateQuery{
Query: Query{MetricName: "incremental", EntityID: "n1", Start: base, End: base.Add(5*time.Minute - time.Nanosecond)},
+228
View File
@@ -0,0 +1,228 @@
package metric
import (
"context"
"fmt"
"math"
"strings"
"time"
)
const defaultRollupTransferBatchSize = 500
// PersistedRollup is the database-independent representation used to move a
// materialized rollup between Stores. It preserves every aggregate needed by
// queries, including the encoded t-digest used for percentile calculations.
type PersistedRollup struct {
MetricName string
EntityID string
Tags map[string]string
Labels map[string]string
Resolution time.Duration
Bucket time.Time
Count int64
Sum float64
SumSq float64
Min float64
Max float64
FirstValue float64
FirstTime time.Time
LastValue float64
LastTime time.Time
Digest []byte
CreatedAt time.Time
}
// ExportRollups streams all persisted rollups for one metric in deterministic
// batches. The callback must consume each batch before returning.
func (s *Store) ExportRollups(ctx context.Context, metricName string, batchSize int, consume func([]PersistedRollup) error) (int64, error) {
if err := s.ensureOpen(); err != nil {
return 0, err
}
if strings.TrimSpace(metricName) == "" {
return 0, fmt.Errorf("%w: metric name is required", ErrInvalidArgument)
}
if consume == nil {
return 0, fmt.Errorf("%w: rollup consumer is required", ErrInvalidArgument)
}
if batchSize <= 0 {
batchSize = defaultRollupTransferBatchSize
}
rows, err := s.reader().QueryContext(ctx, fmt.Sprintf(`SELECT
s.entity_id, s.tags, l.labels, d.resolution_milli, r.bucket_milli,
r.count, r.sum, r.sum_sq, r.min_val, r.max_val,
r.first_val, r.first_ts_milli, r.last_val, r.last_ts_milli,
r.digest, r.created_at_milli
FROM %s r
JOIN %s s ON s.id = r.series_id
JOIN %s d ON d.id = r.resolution_id
JOIN %s l ON l.id = r.label_id
WHERE s.metric_name = %s
ORDER BY r.series_id, r.resolution_id, r.label_id, r.bucket_milli`,
s.tables.rollups, s.tables.series, s.tables.resolutions, s.tables.labels,
s.dialect.placeholder(1)), metricName)
if err != nil {
return 0, err
}
defer rows.Close()
batch := make([]PersistedRollup, 0, batchSize)
var total int64
flush := func() error {
if len(batch) == 0 {
return nil
}
if err := consume(batch); err != nil {
return err
}
total += int64(len(batch))
batch = batch[:0]
return nil
}
for rows.Next() {
var (
row PersistedRollup
rawTags, rawLabels any
resolutionMilli, bucketMilli int64
firstTimeMilli, lastTimeMilli, createdAtMilli int64
)
if err := rows.Scan(
&row.EntityID, &rawTags, &rawLabels, &resolutionMilli, &bucketMilli,
&row.Count, &row.Sum, &row.SumSq, &row.Min, &row.Max,
&row.FirstValue, &firstTimeMilli, &row.LastValue, &lastTimeMilli,
&row.Digest, &createdAtMilli,
); err != nil {
return total, err
}
row.MetricName = metricName
row.Resolution = time.Duration(resolutionMilli) * time.Millisecond
row.Bucket = fromMillis(bucketMilli)
row.FirstTime = fromMillis(firstTimeMilli)
row.LastTime = fromMillis(lastTimeMilli)
row.CreatedAt = fromMillis(createdAtMilli)
row.Tags, err = decodeMap(rawTags)
if err != nil {
return total, err
}
row.Labels, err = decodeMap(rawLabels)
if err != nil {
return total, err
}
row.Digest = append([]byte(nil), row.Digest...)
batch = append(batch, row)
if len(batch) == batchSize {
if err := flush(); err != nil {
return total, err
}
}
}
if err := rows.Err(); err != nil {
return total, err
}
if err := flush(); err != nil {
return total, err
}
return total, nil
}
// ImportRollups idempotently writes rollups exported by ExportRollups. It does
// not synthesize raw samples or rebuild tiers, so source counts and percentile
// sketches retain their original semantics.
func (s *Store) ImportRollups(ctx context.Context, rollups []PersistedRollup) error {
if err := s.ensureOpen(); err != nil {
return err
}
if len(rollups) == 0 {
return nil
}
s.retentionMu.RLock()
defer s.retentionMu.RUnlock()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
cache := newRollupDictionaryCache()
rows := make([]normalizedRollupRow, 0, len(rollups))
for i, rollup := range rollups {
if err := validatePersistedRollup(rollup); err != nil {
return fmt.Errorf("rollup %d: %w", i, err)
}
tagsHash, tagsJSON, err := tagsFingerprint(rollup.Tags)
if err != nil {
return err
}
labelsHash, labelsJSON, err := tagsFingerprint(rollup.Labels)
if err != nil {
return err
}
key := rollupKey{entityID: rollup.EntityID, tagsHash: tagsHash, labelsHash: labelsHash, bucket: rollup.Bucket.UTC().UnixMilli()}
seriesID, err := cache.seriesID(ctx, s, tx, rollup.MetricName, key, tagsJSON)
if err != nil {
return err
}
labelID, err := cache.labelID(ctx, s, tx, labelsHash, labelsJSON)
if err != nil {
return err
}
resolutionID, err := cache.resolutionID(ctx, s, tx, rollup.Resolution)
if err != nil {
return err
}
digest := append([]byte(nil), rollup.Digest...)
if rollup.Min == rollup.Max {
digest = nil
}
rows = append(rows, normalizedRollupRow{
seriesID: seriesID, resolutionID: resolutionID, labelID: labelID,
bucketMilli: key.bucket, count: rollup.Count, sum: rollup.Sum, sumSq: rollup.SumSq,
min: rollup.Min, max: rollup.Max, firstVal: rollup.FirstValue, firstTSMilli: rollup.FirstTime.UTC().UnixMilli(),
lastVal: rollup.LastValue, lastTSMilli: rollup.LastTime.UTC().UnixMilli(), digest: digest,
createdAtMilli: rollup.CreatedAt.UTC().UnixMilli(),
})
}
if err := s.upsertNormalizedRollupRowsTx(ctx, rows, tx); err != nil {
return err
}
return tx.Commit()
}
func validatePersistedRollup(rollup PersistedRollup) error {
if strings.TrimSpace(rollup.MetricName) == "" || strings.TrimSpace(rollup.EntityID) == "" {
return fmt.Errorf("%w: metric name and entity id are required", ErrInvalidArgument)
}
if rollup.Resolution < time.Millisecond || rollup.Resolution%time.Millisecond != 0 {
return fmt.Errorf("%w: rollup interval %s must use whole milliseconds", ErrInvalidArgument, rollup.Resolution)
}
if rollup.Count <= 0 || !finiteRollupValues(rollup) || rollup.Min > rollup.Max {
return fmt.Errorf("%w: invalid rollup aggregate", ErrInvalidArgument)
}
if rollup.Bucket.IsZero() || rollup.FirstTime.IsZero() || rollup.LastTime.IsZero() || rollup.CreatedAt.IsZero() {
return fmt.Errorf("%w: rollup timestamps are required", ErrInvalidArgument)
}
if rollup.FirstTime.After(rollup.LastTime) {
return fmt.Errorf("%w: first timestamp must not follow last timestamp", ErrInvalidArgument)
}
if rollup.Min != rollup.Max {
if len(rollup.Digest) == 0 {
return fmt.Errorf("%w: non-constant rollup digest is required", ErrInvalidArgument)
}
if _, err := DecodeTDigest(rollup.Digest); err != nil {
return fmt.Errorf("decode rollup digest: %w", err)
}
}
return nil
}
func finiteRollupValues(rollup PersistedRollup) bool {
for _, value := range [...]float64{
rollup.Sum, rollup.SumSq, rollup.Min, rollup.Max, rollup.FirstValue, rollup.LastValue,
} {
if math.IsNaN(value) || math.IsInf(value, 0) {
return false
}
}
return true
}
+157
View File
@@ -0,0 +1,157 @@
package metric
import (
"bytes"
"context"
"math"
"path/filepath"
"reflect"
"testing"
"time"
)
func TestRollupTransferRoundTrip(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{
RawRetention: 10 * time.Minute,
Tiers: []RollupTier{
{Interval: time.Minute, Retention: 10 * time.Hour},
{Interval: 5 * time.Minute, Retention: 50 * time.Hour},
{Interval: time.Hour, Retention: 600 * time.Hour},
{Interval: 24 * time.Hour, Retention: 100 * 365 * 24 * time.Hour},
},
Compression: 30,
}
source := openRollupTransferTestStore(t, filepath.Join(t.TempDir(), "source.db"), policy)
target := openRollupTransferTestStore(t, filepath.Join(t.TempDir(), "target.db"), policy)
definition := Definition{
Name: "load.average", Description: "system load", Type: TypeGauge,
Unit: "load", RetentionDays: 365, Metadata: map[string]string{"scope": "system"},
}
for _, store := range []*Store{source, target} {
if err := store.UpsertMetric(ctx, definition); err != nil {
t.Fatalf("upsert metric: %v", err)
}
}
base := time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
variableDigest := NewTDigest(policy.Compression)
for _, value := range []float64{1, 4, 9} {
variableDigest.Add(value, 1)
}
constantDigest := NewTDigest(policy.Compression)
constantDigest.Add(7, 2)
createdAt := base.Add(48 * time.Hour)
input := []PersistedRollup{
{
MetricName: definition.Name, EntityID: "node-a",
Tags: map[string]string{"core": "0", "region": "ap"}, Labels: map[string]string{"rack": "r1"},
Resolution: 30 * time.Second, Bucket: base, Count: 2, Sum: 14, SumSq: 98,
Min: 7, Max: 7, FirstValue: 7, FirstTime: base.Add(time.Second),
LastValue: 7, LastTime: base.Add(20 * time.Second), Digest: constantDigest.Encode(), CreatedAt: createdAt,
},
{
MetricName: definition.Name, EntityID: "node-a",
Tags: map[string]string{"core": "0", "region": "ap"}, Labels: map[string]string{"rack": "r1"},
Resolution: time.Minute, Bucket: base, Count: 3, Sum: 14, SumSq: 98,
Min: 1, Max: 9, FirstValue: 1, FirstTime: base.Add(time.Second),
LastValue: 9, LastTime: base.Add(40 * time.Second), Digest: variableDigest.Encode(), CreatedAt: createdAt,
},
constantTransferRollup(definition.Name, "node-a", base, 5*time.Minute, 11, createdAt),
constantTransferRollup(definition.Name, "node-b", base, time.Hour, 12, createdAt),
constantTransferRollup(definition.Name, "node-b", base, 24*time.Hour, 13, createdAt),
}
if err := source.ImportRollups(ctx, input); err != nil {
t.Fatalf("import source rollups: %v", err)
}
exported, batches := exportAllTestRollups(t, source, definition.Name, 2)
if len(exported) != len(input) {
t.Fatalf("exported rollups = %d, want %d", len(exported), len(input))
}
if !reflect.DeepEqual(batches, []int{2, 2, 1}) {
t.Fatalf("export batch sizes = %v, want [2 2 1]", batches)
}
for _, rollup := range exported {
if rollup.Min == rollup.Max && len(rollup.Digest) != 0 {
t.Fatalf("constant rollup at %s retained a digest", rollup.Resolution)
}
}
if len(exported[1].Digest) == 0 || !bytes.Equal(exported[1].Digest, variableDigest.Encode()) {
t.Fatal("non-constant minute rollup did not preserve its digest")
}
if err := target.ImportRollups(ctx, exported); err != nil {
t.Fatalf("import target rollups: %v", err)
}
if err := target.ImportRollups(ctx, exported); err != nil {
t.Fatalf("repeat target import: %v", err)
}
roundTrip, _ := exportAllTestRollups(t, target, definition.Name, 20)
if !reflect.DeepEqual(roundTrip, exported) {
t.Fatalf("round-trip rollups differ:\n got: %#v\nwant: %#v", roundTrip, exported)
}
digest, err := DecodeTDigest(variableDigest.Encode())
if err != nil {
t.Fatalf("decode expected digest: %v", err)
}
points, err := target.AggregateRollup(ctx, AggregateQuery{
Query: Query{
MetricName: definition.Name, EntityID: "node-a", Tags: map[string]string{"core": "0", "region": "ap"},
Start: base, End: base.Add(time.Minute - time.Millisecond),
},
Aggregation: AggP95, Interval: time.Minute,
}, time.Minute)
if err != nil {
t.Fatalf("query transferred percentile: %v", err)
}
if len(points) != 1 || points[0].Count != 3 || math.Abs(points[0].Value-digest.Quantile(0.95)) > 1e-12 {
t.Fatalf("transferred percentile = %#v, want count 3 value %v", points, digest.Quantile(0.95))
}
}
func openRollupTransferTestStore(t *testing.T, path string, policy RollupPolicy) *Store {
t.Helper()
store, err := Open(context.Background(), SQLite(path,
WithMaxOpenConns(1),
WithMaxIdleConns(1),
WithRollupPolicy(policy),
))
if err != nil {
t.Fatalf("open rollup transfer store: %v", err)
}
t.Cleanup(func() { _ = store.Close() })
return store
}
func constantTransferRollup(metricName, entityID string, bucket time.Time, resolution time.Duration, value float64, createdAt time.Time) PersistedRollup {
return PersistedRollup{
MetricName: metricName, EntityID: entityID,
Tags: map[string]string{"region": "eu"}, Labels: map[string]string{"rack": "r2"},
Resolution: resolution, Bucket: bucket, Count: 4, Sum: value * 4, SumSq: value * value * 4,
Min: value, Max: value, FirstValue: value, FirstTime: bucket.Add(time.Second),
LastValue: value, LastTime: bucket.Add(2 * time.Second), CreatedAt: createdAt,
}
}
func exportAllTestRollups(t *testing.T, store *Store, metricName string, batchSize int) ([]PersistedRollup, []int) {
t.Helper()
var (
rollups []PersistedRollup
batches []int
)
total, err := store.ExportRollups(context.Background(), metricName, batchSize, func(batch []PersistedRollup) error {
batches = append(batches, len(batch))
rollups = append(rollups, batch...)
return nil
})
if err != nil {
t.Fatalf("export rollups: %v", err)
}
if total != int64(len(rollups)) {
t.Fatalf("export total = %d, want %d", total, len(rollups))
}
return rollups, batches
}
+334 -501
View File
File diff suppressed because it is too large. Load diff
+124 -9
View File
@@ -2,6 +2,7 @@ package metric
import (
"context"
"database/sql"
"errors"
"math"
"os"
@@ -25,12 +26,12 @@ func TestSQLiteStoreWriteQueryAggregate(t *testing.T) {
t.Fatalf("create metric: %v", err)
}
base := time.Date(2026, 6, 18, 10, 0, 0, 0, time.UTC)
base := time.Now().UTC().Truncate(time.Minute)
points := []Point{
{MetricName: "cpu.usage", EntityID: "server-1", Timestamp: base, Value: 10, Tags: map[string]string{"region": "ap"}},
{MetricName: "cpu.usage", EntityID: "server-1", Timestamp: base.Add(time.Minute), Value: 20, Tags: map[string]string{"region": "ap"}},
{MetricName: "cpu.usage", EntityID: "server-1", Timestamp: base.Add(2 * time.Minute), Value: 30, Tags: map[string]string{"region": "ap"}},
{MetricName: "cpu.usage", EntityID: "server-2", Timestamp: base.Add(time.Minute), Value: 99, Tags: map[string]string{"region": "eu"}},
{MetricName: "cpu.usage", EntityID: "server-1", Timestamp: base.Add(10 * time.Second), Value: 20, Tags: map[string]string{"region": "ap"}},
{MetricName: "cpu.usage", EntityID: "server-1", Timestamp: base.Add(20 * time.Second), Value: 30, Tags: map[string]string{"region": "ap"}},
{MetricName: "cpu.usage", EntityID: "server-2", Timestamp: base.Add(10 * time.Second), Value: 99, Tags: map[string]string{"region": "eu"}},
}
if err := store.WriteBatch(ctx, points); err != nil {
t.Fatalf("write batch: %v", err)
@@ -40,7 +41,7 @@ func TestSQLiteStoreWriteQueryAggregate(t *testing.T) {
MetricName: "cpu.usage",
EntityID: "server-1",
Start: base.Add(-time.Second),
End: base.Add(3 * time.Minute),
End: base.Add(time.Minute),
Tags: map[string]string{"region": "ap"},
})
if err != nil {
@@ -58,10 +59,10 @@ func TestSQLiteStoreWriteQueryAggregate(t *testing.T) {
MetricName: "cpu.usage",
EntityID: "server-1",
Start: base,
End: base.Add(3 * time.Minute),
End: base.Add(time.Minute),
},
Aggregation: AggAvg,
Interval: 2 * time.Minute,
Interval: 20 * time.Second,
})
if err != nil {
t.Fatalf("aggregate: %v", err)
@@ -77,12 +78,12 @@ func TestSQLiteStoreWriteQueryAggregate(t *testing.T) {
MetricName: "cpu.usage",
EntityID: "server-1",
Start: base,
End: base.Add(3 * time.Minute),
End: base.Add(time.Minute),
})
if err != nil {
t.Fatalf("stats: %v", err)
}
if stats.Count != 3 || stats.Avg != 20 || stats.P95 != 29 {
if stats.Count != 3 || stats.Avg != 20 || math.Abs(stats.P95-29) > 1 {
t.Fatalf("unexpected stats: %#v", stats)
}
}
@@ -202,3 +203,117 @@ func TestSQLiteInDirCreatesDirectoryAndAppliesPragmas(t *testing.T) {
t.Fatalf("expected performance profile synchronous=OFF(0), got %d", synchronous)
}
}
// TestSQLiteConnectionHookConfiguresExpandedAndRotatedReaders verifies that
// connection-local PRAGMAs do not disappear after the reader pool grows or
// database/sql replaces expired connections.
func TestSQLiteConnectionHookConfiguresExpandedAndRotatedReaders(t *testing.T) {
ctx := context.Background()
dsn := "file:" + filepath.ToSlash(filepath.Join(t.TempDir(), "metrics.db")) + "?mode=rwc&_txlock=immediate"
store, err := Open(ctx, SQLite(
dsn,
WithSQLiteProfile(SQLiteProfileDurable),
WithSQLiteCacheSizeKB(8*1024),
WithSQLiteMMapSize(8*1024*1024),
WithSQLiteTempStoreMemory(false),
WithSQLiteWALAutoCheckpoint(128),
WithSQLiteJournalSizeLimit(512*1024),
WithSQLiteReadPool(2),
))
if err != nil {
t.Fatalf("open sqlite store: %v", err)
}
defer store.Close()
if store.readDB == nil {
t.Fatal("expected dedicated SQLite read pool")
}
first, err := store.readDB.Conn(ctx)
if err != nil {
t.Fatalf("acquire first reader: %v", err)
}
defer first.Close()
second, err := store.readDB.Conn(ctx)
if err != nil {
t.Fatalf("acquire second reader: %v", err)
}
defer second.Close()
assertSQLiteConnectionTuning(t, ctx, first)
assertSQLiteConnectionTuning(t, ctx, second)
if err := first.Close(); err != nil {
t.Fatalf("release first reader: %v", err)
}
if err := second.Close(); err != nil {
t.Fatalf("release second reader: %v", err)
}
statsBefore := store.readDB.Stats()
store.readDB.SetConnMaxLifetime(time.Nanosecond)
time.Sleep(time.Millisecond)
rotated, err := store.readDB.Conn(ctx)
if err != nil {
t.Fatalf("acquire rotated reader: %v", err)
}
defer rotated.Close()
assertSQLiteConnectionTuning(t, ctx, rotated)
if statsAfter := store.readDB.Stats(); statsAfter.MaxLifetimeClosed <= statsBefore.MaxLifetimeClosed {
t.Fatalf("reader pool did not replace an expired connection: before=%d after=%d", statsBefore.MaxLifetimeClosed, statsAfter.MaxLifetimeClosed)
}
}
func assertSQLiteConnectionTuning(t *testing.T, ctx context.Context, conn *sql.Conn) {
t.Helper()
checks := []struct {
pragma string
want any
}{
{pragma: "foreign_keys", want: 1},
{pragma: "journal_mode", want: "wal"},
{pragma: "synchronous", want: 2},
{pragma: "busy_timeout", want: 5000},
{pragma: "cache_size", want: -8 * 1024},
{pragma: "mmap_size", want: int64(8 * 1024 * 1024)},
{pragma: "temp_store", want: 1},
{pragma: "wal_autocheckpoint", want: 128},
{pragma: "journal_size_limit", want: int64(512 * 1024)},
}
for _, check := range checks {
switch want := check.want.(type) {
case int:
var got int
if err := conn.QueryRowContext(ctx, "PRAGMA "+check.pragma).Scan(&got); err != nil {
t.Fatalf("read PRAGMA %s: %v", check.pragma, err)
}
if got != want {
t.Fatalf("PRAGMA %s = %d, want %d", check.pragma, got, want)
}
case int64:
var got int64
if err := conn.QueryRowContext(ctx, "PRAGMA "+check.pragma).Scan(&got); err != nil {
t.Fatalf("read PRAGMA %s: %v", check.pragma, err)
}
if got != want {
t.Fatalf("PRAGMA %s = %d, want %d", check.pragma, got, want)
}
case string:
var got string
if err := conn.QueryRowContext(ctx, "PRAGMA "+check.pragma).Scan(&got); err != nil {
t.Fatalf("read PRAGMA %s: %v", check.pragma, err)
}
if got != want {
t.Fatalf("PRAGMA %s = %q, want %q", check.pragma, got, want)
}
default:
t.Fatalf("unsupported expected type for PRAGMA %s: %T", check.pragma, want)
}
}
var cacheSpill int
if err := conn.QueryRowContext(ctx, "PRAGMA cache_spill").Scan(&cacheSpill); err != nil {
t.Fatalf("read PRAGMA cache_spill: %v", err)
}
if cacheSpill == 0 {
t.Fatal("PRAGMA cache_spill is disabled")
}
}
+12 -3
View File
@@ -77,7 +77,7 @@ const (
// defaultTDigestCompression is used when a caller supplies no useful value.
//
// defaultTDigestCompression 在调用方未提供有效值时使用。
defaultTDigestCompression = 100.0
defaultTDigestCompression = 30.0
// tdigestMagic0 is the first magic byte in the binary format.
//
// tdigestMagic0 是二进制格式的第一个 magic 字节。
@@ -93,12 +93,12 @@ const (
)
// NewTDigest returns an empty digest. compression trades size for accuracy;
// higher keeps more centroids. Values <= 1 fall back to the default (100),
// higher keeps more centroids. Values <= 1 fall back to the default (30),
// which keeps each digest to a few KB while holding tail error to well under
// 1% for typical distributions.
//
// NewTDigest 返回一个空 digest。compression 在大小和精度之间取舍;值越高会保留
// 更多质心。小于等于 1 的值会回退到默认值(100),这通常能把每个 digest 控制在
// 更多质心。小于等于 1 的值会回退到默认值(30),这通常能把每个 digest 控制在
// 几 KB,同时让典型分布的尾部误差远低于 1%。
func NewTDigest(compression float64) *TDigest {
if compression <= 1 {
@@ -112,6 +112,15 @@ func NewTDigest(compression float64) *TDigest {
}
}
func (t *TDigest) clone() *TDigest {
if t == nil {
return nil
}
cloned := *t
cloned.centroids = append([]centroid(nil), t.centroids...)
return &cloned
}
// Add folds a single observation with weight w (w must be > 0) into the digest.
//
// Add 将一个观测值及其权重合入摘要;权重必须大于 0。
+7
View File
@@ -0,0 +1,7 @@
package metric
import "time"
func timeMillis(t time.Time) int64 { return t.UTC().UnixMilli() }
func fromMillis(value int64) time.Time { return time.UnixMilli(value).UTC() }
+7 -7
View File
@@ -253,9 +253,9 @@ func (p Point) normalized() Point {
return p
}
// Query loads raw metric points matching a query.
// Query loads exact raw samples from the retained raw window.
//
// Query 描述原始点查询条件,包括指标、实体、时间范围、标签和分页。
// Query 描述指标值查询条件,包括指标、实体、时间范围、标签和分页。
type Query struct {
// MetricName restricts the query to one metric.
//
@@ -277,13 +277,13 @@ type Query struct {
//
// Tags 按标签键值精确匹配过滤采样点。
Tags map[string]string `json:"tags,omitempty"`
// Limit limits the number of raw points returned.
// Limit limits the number of values returned.
//
// Limit 限制返回的原始点数量。
// Limit 限制返回的值数量。
Limit int `json:"limit,omitempty"`
// Offset skips this many raw points before returning results.
// Offset skips this many values before returning results.
//
// Offset 在返回结果前跳过指定数量的原始点。
// Offset 在返回结果前跳过指定数量的值。
Offset int `json:"offset,omitempty"`
// Order controls chronological result ordering.
//
@@ -425,7 +425,7 @@ type AggregatePoint struct {
// Stats stores or computes summary statistics for a point series.
//
// Stats 表示一段原始点序列的统计摘要。
// Stats 表示一段指标序列的统计摘要。
type Stats struct {
// Count is the number of points in the series.
//
-17
View File
@@ -1,17 +0,0 @@
//go:build linux
package resourceprobe
import (
"os"
"golang.org/x/sys/unix"
)
func openUnbufferedBenchmarkFile(path string) (*os.File, error) {
fd, err := unix.Open(path, unix.O_RDWR|unix.O_CREAT|unix.O_TRUNC|unix.O_DIRECT, 0o600)
if err != nil {
return nil, err
}
return os.NewFile(uintptr(fd), path), nil
}
-9
View File
@@ -1,9 +0,0 @@
//go:build !linux && !windows
package resourceprobe
import "os"
func openUnbufferedBenchmarkFile(path string) (*os.File, error) {
return os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
}
-29
View File
@@ -1,29 +0,0 @@
//go:build windows
package resourceprobe
import (
"os"
"golang.org/x/sys/windows"
)
func openUnbufferedBenchmarkFile(path string) (*os.File, error) {
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
return nil, err
}
handle, err := windows.CreateFile(
pathPtr,
windows.GENERIC_READ|windows.GENERIC_WRITE,
0,
nil,
windows.CREATE_ALWAYS,
windows.FILE_ATTRIBUTE_TEMPORARY|windows.FILE_FLAG_NO_BUFFERING,
0,
)
if err != nil {
return nil, err
}
return os.NewFile(uintptr(handle), path), nil
}
-172
View File
@@ -1,172 +0,0 @@
package resourceprobe
import (
"context"
"encoding/binary"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"time"
"unsafe"
)
const (
minimumMemoryBytes = uint64(1536 * 1024 * 1024)
minimumDiskFreeBytes = uint64(15 * 1024 * 1024 * 1024)
minimumCPUOpsPerSecond = 30_000_000
minimumWriteBytesSecond = 5 * 1024 * 1024
minimumWriteIOPS = 1000
cpuBenchmarkDuration = 750 * time.Millisecond
diskBenchmarkDuration = 8 * time.Second
diskBenchmarkFileSize = int64(64 * 1024 * 1024)
diskBlockSize = int64(4096)
)
type Result struct {
LowResource bool
MemoryBytes uint64
DiskFreeBytes uint64
CPUOpsPerSecond float64
WriteBytesPerSecond float64
WriteIOPS float64
Reasons []string
}
// Detect measures only when the low-resource setting has never been saved.
// The caller should impose an outer deadline; the built-in benchmarks finish
// well inside one minute under normal conditions.
func Detect(ctx context.Context, dataDir string) Result {
result := Result{}
result.MemoryBytes, _ = systemMemoryBytes()
result.DiskFreeBytes, _ = systemDiskFreeBytes(dataDir)
if result.MemoryBytes > 0 && result.MemoryBytes < minimumMemoryBytes {
result.Reasons = append(result.Reasons, fmt.Sprintf("memory=%dMiB", result.MemoryBytes/(1024*1024)))
}
if result.DiskFreeBytes > 0 && result.DiskFreeBytes < minimumDiskFreeBytes {
result.Reasons = append(result.Reasons, fmt.Sprintf("disk_free=%dMiB", result.DiskFreeBytes/(1024*1024)))
}
if len(result.Reasons) > 0 {
result.LowResource = true
return result
}
result.CPUOpsPerSecond = benchmarkSingleCore(ctx, cpuBenchmarkDuration)
if result.CPUOpsPerSecond > 0 && result.CPUOpsPerSecond < minimumCPUOpsPerSecond {
result.Reasons = append(result.Reasons, fmt.Sprintf("cpu=%.0fops/s", result.CPUOpsPerSecond))
}
var err error
result.WriteBytesPerSecond, result.WriteIOPS, err = benchmarkRandomWrite(ctx, dataDir, diskBenchmarkDuration)
if err != nil {
result.Reasons = append(result.Reasons, "disk_benchmark="+err.Error())
} else {
if result.WriteBytesPerSecond < minimumWriteBytesSecond {
result.Reasons = append(result.Reasons, fmt.Sprintf("random_write=%.2fMiB/s", result.WriteBytesPerSecond/(1024*1024)))
}
if result.WriteIOPS < minimumWriteIOPS {
result.Reasons = append(result.Reasons, fmt.Sprintf("random_write=%.0fIOPS", result.WriteIOPS))
}
}
result.LowResource = len(result.Reasons) > 0
return result
}
var cpuBenchmarkSink uint64
func benchmarkSingleCore(ctx context.Context, duration time.Duration) float64 {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
start := time.Now()
deadline := start.Add(duration)
var operations uint64
x := uint64(0x9e3779b97f4a7c15)
for time.Now().Before(deadline) {
for range 100_000 {
x ^= x << 13
x ^= x >> 7
x ^= x << 17
operations++
}
select {
case <-ctx.Done():
cpuBenchmarkSink = x
return float64(operations) / time.Since(start).Seconds()
default:
}
}
cpuBenchmarkSink = x
return float64(operations) / time.Since(start).Seconds()
}
func benchmarkRandomWrite(ctx context.Context, dataDir string, duration time.Duration) (float64, float64, error) {
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return 0, 0, err
}
placeholder, err := os.CreateTemp(dataDir, ".komari-resource-probe-*")
if err != nil {
return 0, 0, err
}
name := placeholder.Name()
if err := placeholder.Close(); err != nil {
os.Remove(name)
return 0, 0, err
}
defer os.Remove(name)
f, err := openUnbufferedBenchmarkFile(name)
if err != nil {
return 0, 0, err
}
defer f.Close()
if err := f.Truncate(diskBenchmarkFileSize); err != nil {
return 0, 0, err
}
block := alignedBlock(int(diskBlockSize), int(diskBlockSize))
var state uint64 = 0x243f6a8885a308d3
blocks := uint64(diskBenchmarkFileSize / diskBlockSize)
start := time.Now()
deadline := start.Add(duration)
var writes uint64
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return 0, 0, ctx.Err()
default:
}
state ^= state << 13
state ^= state >> 7
state ^= state << 17
binary.LittleEndian.PutUint64(block, state)
offset := int64(state%blocks) * diskBlockSize
if _, err := f.WriteAt(block, offset); err != nil {
return 0, 0, err
}
writes++
}
elapsed := time.Since(start).Seconds()
if elapsed <= 0 || writes == 0 {
return 0, 0, errors.New("no writes completed")
}
if err := f.Sync(); err != nil {
return 0, 0, err
}
return float64(writes*uint64(diskBlockSize)) / elapsed, float64(writes) / elapsed, nil
}
func alignedBlock(size, alignment int) []byte {
raw := make([]byte, size+alignment)
address := uintptr(unsafe.Pointer(&raw[0]))
offset := int((uintptr(alignment) - address%uintptr(alignment)) % uintptr(alignment))
return raw[offset : offset+size]
}
func absoluteDir(path string) (string, error) {
if path == "" {
path = "."
}
return filepath.Abs(path)
}
-25
View File
@@ -1,25 +0,0 @@
//go:build linux
package resourceprobe
import "golang.org/x/sys/unix"
func systemMemoryBytes() (uint64, error) {
var info unix.Sysinfo_t
if err := unix.Sysinfo(&info); err != nil {
return 0, err
}
return uint64(info.Totalram) * uint64(info.Unit), nil
}
func systemDiskFreeBytes(path string) (uint64, error) {
abs, err := absoluteDir(path)
if err != nil {
return 0, err
}
var stat unix.Statfs_t
if err := unix.Statfs(abs, &stat); err != nil {
return 0, err
}
return stat.Bavail * uint64(stat.Bsize), nil
}
-13
View File
@@ -1,13 +0,0 @@
//go:build !linux && !windows
package resourceprobe
import "errors"
func systemMemoryBytes() (uint64, error) {
return 0, errors.New("system memory probe is unsupported on this platform")
}
func systemDiskFreeBytes(string) (uint64, error) {
return 0, errors.New("disk space probe is unsupported on this platform")
}
-39
View File
@@ -1,39 +0,0 @@
package resourceprobe
import (
"context"
"os"
"testing"
"time"
)
func TestBenchmarkSingleCoreHonorsCanceledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
start := time.Now()
if got := benchmarkSingleCore(ctx, time.Minute); got < 0 {
t.Fatalf("operations per second = %f", got)
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Fatalf("canceled benchmark took %s", elapsed)
}
}
func TestBenchmarkRandomWriteUsesAlignedUnbufferedIO(t *testing.T) {
dir := t.TempDir()
bytesPerSecond, iops, err := benchmarkRandomWrite(context.Background(), dir, 250*time.Millisecond)
if err != nil {
t.Fatalf("benchmark random write: %v", err)
}
if bytesPerSecond <= 0 || iops <= 0 {
t.Fatalf("invalid benchmark result: bytes/s=%f iops=%f", bytesPerSecond, iops)
}
t.Logf("unbuffered random write: %.2f MiB/s, %.0f IOPS", bytesPerSecond/(1024*1024), iops)
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read benchmark directory: %v", err)
}
if len(entries) != 0 {
t.Fatalf("benchmark left temporary files: %#v", entries)
}
}
-48
View File
@@ -1,48 +0,0 @@
//go:build windows
package resourceprobe
import (
"unsafe"
"golang.org/x/sys/windows"
)
var globalMemoryStatusEx = windows.NewLazySystemDLL("kernel32.dll").NewProc("GlobalMemoryStatusEx")
type memoryStatusEx struct {
Length uint32
MemoryLoad uint32
TotalPhys uint64
AvailPhys uint64
TotalPageFile uint64
AvailPageFile uint64
TotalVirtual uint64
AvailVirtual uint64
AvailExtendedVirtual uint64
}
func systemMemoryBytes() (uint64, error) {
status := memoryStatusEx{Length: uint32(unsafe.Sizeof(memoryStatusEx{}))}
result, _, err := globalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&status)))
if result == 0 {
return 0, err
}
return status.TotalPhys, nil
}
func systemDiskFreeBytes(path string) (uint64, error) {
abs, err := absoluteDir(path)
if err != nil {
return 0, err
}
pathPtr, err := windows.UTF16PtrFromString(abs)
if err != nil {
return 0, err
}
var available uint64
if err := windows.GetDiskFreeSpaceEx(pathPtr, &available, nil, nil); err != nil {
return 0, err
}
return available, nil
}
+3 -6
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/dop251/goja"
"github.com/komari-monitor/komari/utils/messageSender/outboundhttp"
)
// createFetchFunction 创建一个 fetch API 实现
@@ -80,9 +81,7 @@ func (j *JavaScriptSender) createFetchFunction() func(goja.FunctionCall) goja.Va
}
// 发送请求
client := &http.Client{
Timeout: 30 * time.Second,
}
client := outboundhttp.NewClient(30 * time.Second)
resp, err := client.Do(req)
if err != nil {
reject(j.vm.ToValue(fmt.Sprintf("Fetch failed: %v", err)))
@@ -229,9 +228,7 @@ func (j *JavaScriptSender) createXHRConstructor() func(goja.ConstructorCall) *go
xhr.Set("readyState", 2)
j.callHandler(xhr, "onreadystatechange")
client := &http.Client{
Timeout: 30 * time.Second,
}
client := outboundhttp.NewClient(30 * time.Second)
resp, err := client.Do(req)
if err != nil {
xhr.Set("readyState", 4)
@@ -0,0 +1,54 @@
// Package outboundhttp provides the HTTP client used by configurable notification senders.
package outboundhttp
import (
"context"
"errors"
"net"
"net/http"
"sort"
"time"
)
var transport = func() *http.Transport {
t := http.DefaultTransport.(*http.Transport).Clone()
dialer := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}
t.DialContext = ipv6FirstDialContext(net.DefaultResolver.LookupIPAddr, dialer.DialContext)
return t
}()
// NewClient returns an HTTP client that prefers IPv6 for dual-stack endpoints
// while still trying IPv4 when IPv6 is unavailable.
func NewClient(timeout time.Duration) *http.Client {
return &http.Client{Transport: transport, Timeout: timeout}
}
func ipv6FirstDialContext(
lookup func(context.Context, string) ([]net.IPAddr, error),
dial func(context.Context, string, string) (net.Conn, error),
) func(context.Context, string, string) (net.Conn, error) {
return func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return dial(ctx, network, address)
}
addresses, err := lookup(ctx, host)
if err != nil || len(addresses) == 0 {
return dial(ctx, network, address)
}
sort.SliceStable(addresses, func(i, j int) bool {
return addresses[i].IP.To4() == nil && addresses[j].IP.To4() != nil
})
var errs []error
for _, ipAddr := range addresses {
conn, err := dial(ctx, network, net.JoinHostPort(ipAddr.IP.String(), port))
if err == nil {
return conn, nil
}
errs = append(errs, err)
}
return nil, errors.Join(errs...)
}
}
@@ -0,0 +1,57 @@
package outboundhttp
import (
"context"
"errors"
"net"
"net/http"
"reflect"
"testing"
"time"
)
func TestIPv6FirstDialContextPrefersIPv6AndFallsBackToIPv4(t *testing.T) {
lookup := func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{
{IP: net.ParseIP("192.0.2.1")},
{IP: net.ParseIP("2001:db8::1")},
}, nil
}
var attempted []string
dial := func(_ context.Context, _ string, address string) (net.Conn, error) {
attempted = append(attempted, address)
return nil, errors.New("unreachable")
}
_, err := ipv6FirstDialContext(lookup, dial)(context.Background(), "tcp", "notify.example:443")
if err == nil {
t.Fatal("dial succeeded unexpectedly")
}
want := []string{"[2001:db8::1]:443", "192.0.2.1:443"}
if !reflect.DeepEqual(attempted, want) {
t.Fatalf("dial order = %v, want %v", attempted, want)
}
}
func TestNewClientConnectsToIPv6Loopback(t *testing.T) {
listener, err := net.Listen("tcp6", "[::1]:0")
if err != nil {
t.Skipf("IPv6 loopback is unavailable: %v", err)
}
defer listener.Close()
server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})}
go server.Serve(listener)
t.Cleanup(func() { _ = server.Close() })
response, err := NewClient(time.Second).Get("http://" + listener.Addr().String())
if err != nil {
t.Fatalf("GET IPv6 loopback: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusNoContent {
t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusNoContent)
}
}
+2 -3
View File
@@ -11,6 +11,7 @@ import (
"time"
"github.com/komari-monitor/komari/utils/messageSender/factory"
"github.com/komari-monitor/komari/utils/messageSender/outboundhttp"
)
type WebhookSender struct {
@@ -43,9 +44,7 @@ func (w *WebhookSender) SendTextMessage(message, title string) error {
method = "GET" // 默认使用 GET
}
client := &http.Client{
Timeout: 30 * time.Second, // 固定30秒超时
}
client := outboundhttp.NewClient(30 * time.Second)
var req *http.Request
var err error
+23 -17
View File
@@ -127,21 +127,10 @@ func OfflineNotification(clientID string, endedConnectionID int64) {
}(now, endedConnectionID)
}
// OnlineNotification 在启用通知的情况下,发送客户端上线通知。
func OnlineNotification(clientID string, connectionID int64) {
client, err := clients.GetClientByUUID(clientID)
if err != nil {
return
}
// 上线时检测续费
renewal.CheckAndAutoRenewal(client)
_, enabled := getNotificationConfig(clientID)
if !enabled {
return
}
// updateOnlineState records a connection before notification configuration is checked.
// It reports whether this connection should produce an online notification.
func updateOnlineState(clientID string, connectionID int64) bool {
state := getOrInitState(clientID)
state.mu.Lock()
defer state.mu.Unlock()
state.connectionID = connectionID
@@ -152,7 +141,7 @@ func OnlineNotification(clientID string, connectionID int64) {
// 同时清除任何待离线状态(如服务器重启时客户端本已离线)
state.pendingOfflineSince = time.Time{}
state.isConnExist = true
return
return false
}
// 检查客户端是否处于待离线状态。
@@ -162,18 +151,35 @@ func OnlineNotification(clientID string, connectionID int64) {
// 规则2:宽限期内重连,不通知。
if wasPending {
return
return false
}
// 规则3: 没断开后重连, 不通知
// 为了解决OfflineNotify中不是全程加锁
if state.isConnExist {
logger.Infof("notifier", "%s has connection exist: %d", clientID, connectionID)
return
return false
} else {
state.isConnExist = true
}
return true
}
// OnlineNotification records the connection state and, when enabled, sends a client online notification.
func OnlineNotification(clientID string, connectionID int64) {
client, err := clients.GetClientByUUID(clientID)
if err != nil {
return
}
// 上线时检测续费
renewal.CheckAndAutoRenewal(client)
shouldNotify := updateOnlineState(clientID, connectionID)
_, enabled := getNotificationConfig(clientID)
if !enabled || !shouldNotify {
return
}
// 规则4:客户端离线足够久已通知(或未待离线),现在重新上线,发送上线通知。
message := fmt.Sprintf("🟢%s is online", client.Name)
go func(msg string) {
+26
View File
@@ -0,0 +1,26 @@
package notifier
import "testing"
func TestUpdateOnlineStateTracksConnectionBeforeNotificationsAreEnabled(t *testing.T) {
clientID := "notification-disabled-online-client"
clientStates.Delete(clientID)
t.Cleanup(func() { clientStates.Delete(clientID) })
if shouldNotify := updateOnlineState(clientID, 42); shouldNotify {
t.Fatal("first connection should not send an online notification")
}
state := getOrInitState(clientID)
state.mu.Lock()
defer state.mu.Unlock()
if state.connectionID != 42 {
t.Fatalf("connectionID = %d, want 42", state.connectionID)
}
if state.isFirstConnection {
t.Fatal("first connection should be recorded")
}
if !state.isConnExist {
t.Fatal("connection should be marked as present")
}
}
+111
View File
@@ -0,0 +1,111 @@
package admin
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestIsValidUploadID(t *testing.T) {
for _, uploadID := range []string{chunkUploadID, "../backup", "", "another-upload"} {
want := uploadID == chunkUploadID
if got := isValidUploadID(uploadID); got != want {
t.Errorf("isValidUploadID(%q) = %v, want %v", uploadID, got, want)
}
}
}
func TestChunkUploadDirRejectsTraversal(t *testing.T) {
if _, err := chunkUploadDir(".."); err == nil {
t.Fatal("chunkUploadDir accepted traversal upload_id")
}
}
func TestClearChunkUploadCache(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "interrupted.part"), []byte("stale"), 0600); err != nil {
t.Fatalf("write cache: %v", err)
}
if err := clearChunkUploadCache(root); err != nil {
t.Fatalf("clearChunkUploadCache: %v", err)
}
entries, err := os.ReadDir(root)
if err != nil {
t.Fatalf("read cache: %v", err)
}
if len(entries) != 0 {
t.Fatalf("cache entries = %d, want 0", len(entries))
}
}
func TestCopyWhitelistedFilesIncludesFont(t *testing.T) {
dataDir := t.TempDir()
backupDir := t.TempDir()
fontPath := filepath.Join(dataDir, "font.ttf")
if err := os.WriteFile(fontPath, []byte("font-data"), 0644); err != nil {
t.Fatalf("write font: %v", err)
}
if err := copyWhitelistedFilesFrom(dataDir, backupDir); err != nil {
t.Fatalf("copyWhitelistedFilesFrom: %v", err)
}
content, err := os.ReadFile(filepath.Join(backupDir, "font.ttf"))
if err != nil {
t.Fatalf("read copied font: %v", err)
}
if string(content) != "font-data" {
t.Fatalf("font content = %q, want %q", content, "font-data")
}
}
func TestChunkUploadMetadataAndSize(t *testing.T) {
dir := t.TempDir()
metadata, err := json.Marshal(chunkUploadMetadata{Size: 7})
if err != nil {
t.Fatalf("marshal metadata: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, uploadMetadataName), metadata, 0600); err != nil {
t.Fatalf("write metadata: %v", err)
}
for name, content := range map[string]string{"0.part": "abc", "1.part": "defg", ".1-staging.part": "staging", "ignored.txt": "ignored"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0600); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
gotMetadata, err := readChunkUploadMetadata(dir)
if err != nil {
t.Fatalf("read metadata: %v", err)
}
if gotMetadata.Size != 7 {
t.Fatalf("metadata size = %d, want 7", gotMetadata.Size)
}
gotSize, err := chunkUploadSize(dir)
if err != nil {
t.Fatalf("chunkUploadSize: %v", err)
}
if gotSize != 7 {
t.Fatalf("chunk upload size = %d, want 7", gotSize)
}
}
func TestMergeChunksIgnoresTemporaryParts(t *testing.T) {
dir := t.TempDir()
for name, content := range map[string]string{"0.part": "first", "1.part": "second", ".1-staging.part": "temporary"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0600); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
destination := filepath.Join(dir, "merged.zip")
if err := mergeChunks(dir, destination); err != nil {
t.Fatalf("mergeChunks: %v", err)
}
content, err := os.ReadFile(destination)
if err != nil {
t.Fatalf("read merged chunks: %v", err)
}
if string(content) != "firstsecond" {
t.Fatalf("merged chunks = %q, want %q", content, "firstsecond")
}
}
+69
View File
@@ -0,0 +1,69 @@
package admin
import (
"fmt"
"os"
"path/filepath"
)
// backupWhitelist 定义需要备份的 ./data/ 下文件/目录(相对路径)。
// 目录项会递归包含其下所有文件。新增需持久化数据时在此追加。
//
// 注意:komari.db 不在白名单中——database 通过 DownloadBackup 中的
// SQLite VACUUM INTO 或 copyFile 单独备份,确保一致性快照。
var backupWhitelist = []string{
"favicon.ico",
"font.ttf",
"theme/",
"metrics.db",
}
// copyWhitelistedFiles 将白名单中存在的文件/目录复制到临时目录。
// 不存在的项静默跳过,确保白名单扩展的向前兼容。
func copyWhitelistedFiles(tempDir string) error {
return copyWhitelistedFilesFrom(filepath.Join(".", "data"), tempDir)
}
func copyWhitelistedFilesFrom(dataDir, tempDir string) error {
for _, relPath := range backupWhitelist {
src := filepath.Join(dataDir, relPath)
info, err := os.Stat(src)
if err != nil {
if os.IsNotExist(err) {
continue
}
return fmt.Errorf("stat whitelist entry %s: %v", relPath, err)
}
if info.IsDir() {
if err := copyDir(src, filepath.Join(tempDir, relPath)); err != nil {
return fmt.Errorf("copy whitelist dir %s: %v", relPath, err)
}
} else {
if err := copyFile(src, filepath.Join(tempDir, relPath)); err != nil {
return fmt.Errorf("copy whitelist file %s: %v", relPath, err)
}
}
}
return nil
}
// copyDir 递归复制目录到目标路径。
func copyDir(srcDir, dstDir string) error {
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(srcDir, path)
if err != nil {
return err
}
if rel == "." {
return nil
}
dst := filepath.Join(dstDir, rel)
if info.IsDir() {
return os.MkdirAll(dst, 0755)
}
return copyFile(path, dst)
})
}
+381
View File
@@ -0,0 +1,381 @@
package admin
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/web/api"
"github.com/komari-monitor/komari/web/backup"
)
const (
defaultChunkSize = 5 * 1024 * 1024 // 5 MiB
maxChunkRequestSize = defaultChunkSize + 1*1024*1024
chunkUploadID = "backup"
maxChunkIndex = int(backup.MaxArchiveSize / defaultChunkSize)
)
var chunkUploadRoot = filepath.Join(".", "data", "backup", ".uploading")
const uploadMetadataName = "upload.json"
type chunkUploadMetadata struct {
Size int64 `json:"size"`
}
func isValidUploadID(uploadID string) bool {
return uploadID == chunkUploadID
}
func chunkUploadDir(uploadID string) (string, error) {
if !isValidUploadID(uploadID) {
return "", fmt.Errorf("invalid upload_id")
}
return filepath.Join(chunkUploadRoot, uploadID), nil
}
func readChunkUploadMetadata(chunkDir string) (chunkUploadMetadata, error) {
var metadata chunkUploadMetadata
data, err := os.ReadFile(filepath.Join(chunkDir, uploadMetadataName))
if err != nil {
return metadata, err
}
if err := json.Unmarshal(data, &metadata); err != nil {
return metadata, err
}
if metadata.Size <= 0 || metadata.Size > backup.MaxArchiveSize {
return metadata, fmt.Errorf("invalid upload size")
}
return metadata, nil
}
func chunkPartIndex(name string) (int, bool) {
index, err := strconv.Atoi(strings.TrimSuffix(name, ".part"))
return index, err == nil && index >= 0 && strings.HasSuffix(name, ".part")
}
func chunkUploadSize(chunkDir string) (int64, error) {
entries, err := os.ReadDir(chunkDir)
if err != nil {
return 0, err
}
var total int64
for _, entry := range entries {
if entry.IsDir() {
continue
}
if _, ok := chunkPartIndex(entry.Name()); !ok {
continue
}
info, err := entry.Info()
if err != nil {
return 0, err
}
if total > backup.MaxArchiveSize-info.Size() {
return 0, fmt.Errorf("uploaded chunks exceed the archive limit")
}
total += info.Size()
}
return total, nil
}
func clearChunkUploadCache(root string) error {
if err := os.RemoveAll(root); err != nil {
return err
}
return os.MkdirAll(root, 0755)
}
// InitChunkUpload 初始化分块上传,返回 upload_id 和 chunk_size。
// Komari 使用单一上传槽位,初始化时会清除上次中断留下的全部缓存。
func InitChunkUpload(c *gin.Context) {
var req struct {
Size int64 `json:"size"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Size <= 0 || req.Size > backup.MaxArchiveSize {
api.RespondError(c, http.StatusBadRequest, fmt.Sprintf("size must be between 1 and %d bytes", backup.MaxArchiveSize))
return
}
if err := clearChunkUploadCache(chunkUploadRoot); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error clearing upload cache: %v", err))
return
}
chunkDir, err := chunkUploadDir(chunkUploadID)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "Error preparing upload directory")
return
}
if err := os.MkdirAll(chunkDir, 0755); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating upload directory: %v", err))
return
}
metadata, err := json.Marshal(chunkUploadMetadata{Size: req.Size})
if err != nil {
clearChunkUploadCache(chunkUploadRoot)
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error encoding upload metadata: %v", err))
return
}
if err := os.WriteFile(filepath.Join(chunkDir, uploadMetadataName), metadata, 0600); err != nil {
clearChunkUploadCache(chunkUploadRoot)
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing upload metadata: %v", err))
return
}
c.JSON(http.StatusOK, gin.H{
"upload_id": chunkUploadID,
"chunk_size": defaultChunkSize,
})
}
// UploadChunk 接收单个分块,保存到临时目录。
func UploadChunk(c *gin.Context) {
defer func() {
if c.Request.Context().Err() != nil {
_ = clearChunkUploadCache(chunkUploadRoot)
}
}()
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxChunkRequestSize)
uploadID := c.PostForm("upload_id")
chunkDir, err := chunkUploadDir(uploadID)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "invalid upload_id")
return
}
chunkIndexStr := c.PostForm("chunk_index")
if chunkIndexStr == "" {
api.RespondError(c, http.StatusBadRequest, "chunk_index is required")
return
}
chunkIndex, err := strconv.Atoi(chunkIndexStr)
if err != nil || chunkIndex < 0 || chunkIndex > maxChunkIndex {
api.RespondError(c, http.StatusBadRequest, "chunk_index must be a non-negative integer")
return
}
if info, err := os.Stat(chunkDir); err != nil || !info.IsDir() {
api.RespondError(c, http.StatusNotFound, "upload_id not found or expired")
return
}
file, _, err := c.Request.FormFile("chunk_data")
if err != nil {
api.RespondError(c, http.StatusBadRequest, fmt.Sprintf("Error getting chunk data: %v", err))
return
}
defer file.Close()
metadata, err := readChunkUploadMetadata(chunkDir)
if err != nil {
api.RespondError(c, http.StatusNotFound, "upload_id not found or expired")
return
}
chunkPath := filepath.Join(chunkDir, fmt.Sprintf("%d.part", chunkIndex))
tempChunk, err := os.CreateTemp(chunkDir, fmt.Sprintf(".%d-*.part", chunkIndex))
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error saving chunk: %v", err))
return
}
tempChunkPath := tempChunk.Name()
defer os.Remove(tempChunkPath)
written, err := io.Copy(tempChunk, io.LimitReader(file, defaultChunkSize+1))
if closeErr := tempChunk.Close(); err == nil {
err = closeErr
}
if err != nil || written > defaultChunkSize {
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing chunk: %v", err))
} else {
api.RespondError(c, http.StatusBadRequest, fmt.Sprintf("Chunk too large: %d bytes (max %d)", written, defaultChunkSize))
}
return
}
currentSize, err := chunkUploadSize(chunkDir)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error checking uploaded chunks: %v", err))
return
}
oldSize := int64(0)
if info, err := os.Stat(chunkPath); err == nil {
oldSize = info.Size()
} else if !os.IsNotExist(err) {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error checking existing chunk: %v", err))
return
}
if currentSize-oldSize+written > metadata.Size {
api.RespondError(c, http.StatusBadRequest, "uploaded chunks exceed the declared backup size")
return
}
if err := os.Remove(chunkPath); err != nil && !os.IsNotExist(err) {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error replacing chunk: %v", err))
return
}
if err := os.Rename(tempChunkPath, chunkPath); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error publishing chunk: %v", err))
return
}
c.JSON(http.StatusOK, gin.H{
"received": true,
"chunk_index": chunkIndex,
})
}
// MergeChunkUpload 合并分块到 backup.zip 后触发恢复。
func MergeChunkUpload(c *gin.Context) {
restoreLock, err := backup.AcquireRestoreLock()
if err != nil {
api.RespondError(c, http.StatusConflict, err.Error())
return
}
keepRestoreLock := false
defer func() {
if !keepRestoreLock {
restoreLock.Release()
}
}()
var req struct {
UploadID string `json:"upload_id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err))
return
}
chunkDir, err := chunkUploadDir(req.UploadID)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "invalid upload_id")
return
}
if info, err := os.Stat(chunkDir); err != nil || !info.IsDir() {
api.RespondError(c, http.StatusNotFound, "upload_id not found or expired")
return
}
metadata, err := readChunkUploadMetadata(chunkDir)
if err != nil {
api.RespondError(c, http.StatusNotFound, "upload_id not found or expired")
return
}
if uploadedSize, err := chunkUploadSize(chunkDir); err != nil || uploadedSize != metadata.Size {
_ = clearChunkUploadCache(chunkUploadRoot)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error checking uploaded chunks: %v", err))
} else {
api.RespondError(c, http.StatusBadRequest, "uploaded chunk size does not match the declared backup size")
}
return
}
mergedPath := filepath.Join(chunkDir, "merged.zip")
if err := mergeChunks(chunkDir, mergedPath); err != nil {
clearChunkUploadCache(chunkUploadRoot)
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error merging chunks: %v", err))
return
}
if err := validateBackupZip(mergedPath); err != nil {
clearChunkUploadCache(chunkUploadRoot)
api.RespondError(c, http.StatusBadRequest, fmt.Sprintf("Invalid backup: %v", err))
return
}
archive, err := os.Open(mergedPath)
if err != nil {
clearChunkUploadCache(chunkUploadRoot)
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening merged backup: %v", err))
return
}
if err := restoreLock.SaveUploadedBackup(archive, "backup.zip"); err != nil {
archive.Close()
clearChunkUploadCache(chunkUploadRoot)
api.RespondError(c, http.StatusBadRequest, fmt.Sprintf("Error preparing restore: %v", err))
return
}
if err := archive.Close(); err != nil {
clearChunkUploadCache(chunkUploadRoot)
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error closing merged backup: %v", err))
return
}
clearChunkUploadCache(chunkUploadRoot)
c.JSON(http.StatusOK, gin.H{
"status": "success",
"message": "Backup uploaded successfully. The service will restart and apply the backup.",
"path": filepath.Join(".", "data", "backup.zip"),
})
keepRestoreLock = true
go func() {
log.Println("Backup uploaded (chunk), restarting service in 2 seconds to apply on startup...")
time.Sleep(2 * time.Second)
restoreLock.Release()
os.Exit(0)
}()
}
// mergeChunks 按分块索引数值排序后顺序合并所有 .part 文件。
func mergeChunks(chunkDir, destPath string) error {
parts, err := filepath.Glob(filepath.Join(chunkDir, "*.part"))
if err != nil {
return err
}
numericParts := parts[:0]
for _, partPath := range parts {
if _, ok := chunkPartIndex(filepath.Base(partPath)); ok {
numericParts = append(numericParts, partPath)
}
}
parts = numericParts
if len(parts) == 0 {
return fmt.Errorf("no chunks found")
}
// 按分块索引数值排序(非字典序),避免 10.part 排在 2.part 前面
sort.Slice(parts, func(i, j int) bool {
idxI, _ := chunkPartIndex(filepath.Base(parts[i]))
idxJ, _ := chunkPartIndex(filepath.Base(parts[j]))
return idxI < idxJ
})
out, err := os.Create(destPath)
if err != nil {
return err
}
defer out.Close()
for _, partPath := range parts {
f, err := os.Open(partPath)
if err != nil {
return fmt.Errorf("error opening chunk %s: %v", filepath.Base(partPath), err)
}
if _, err := io.Copy(out, f); err != nil {
f.Close()
return fmt.Errorf("error reading chunk %s: %v", filepath.Base(partPath), err)
}
f.Close()
}
return nil
}
// validateBackupZip 校验 ZIP 结构完整性及 komari-backup-markup 标记文件。
func validateBackupZip(zipPath string) error {
if err := backup.ValidateArchive(zipPath); err != nil {
return fmt.Errorf("not a valid backup archive: %w", err)
}
return nil
}
+161 -124
View File
@@ -40,128 +40,20 @@ func copyFile(srcPath, destPath string) error {
return nil
}
// copyDataToTempExcludingDB 将 ./data 下除了 .db/.db-wal/.db-shm 之外的所有文件复制到临时目录
func copyDataToTempExcludingDB(tempDir string) error {
dataRoot := "./data"
// 如果 data 目录不存在,视为无文件可复制
if stat, err := os.Stat(dataRoot); err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("stat data dir: %v", err)
} else if !stat.IsDir() {
return fmt.Errorf("%s is not a directory", dataRoot)
}
return filepath.Walk(dataRoot, func(p string, info os.FileInfo, err error) error {
// walkDirToZip 将 contentDir 的内容写入 zip writer。
// 注意:contentDir 不应包含被 walk 的 zip 文件本身。
func walkDirToZip(zipWriter *zip.Writer, contentDir string) error {
return filepath.Walk(contentDir, func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(dataRoot, p)
rel, err := filepath.Rel(contentDir, p)
if err != nil {
return err
}
if rel == "." {
return nil
}
// 跳过数据库相关文件
name := info.Name()
if strings.HasSuffix(strings.ToLower(name), ".db") ||
strings.HasSuffix(strings.ToLower(name), ".db-wal") ||
strings.HasSuffix(strings.ToLower(name), ".db-shm") {
return nil
}
dst := filepath.Join(tempDir, rel)
if info.IsDir() {
return os.MkdirAll(dst, 0o755)
}
return copyFile(p, dst)
})
}
// backupSQLiteTo 使用 SQLite VACUUM INTO 将当前数据库一致性备份到指定路径
func backupSQLiteTo(destDBPath string) error {
if err := os.MkdirAll(filepath.Dir(destDBPath), 0o755); err != nil {
return fmt.Errorf("failed to create parent directory for db: %v", err)
}
db := dbcore.GetDBInstance()
sqlDB, err := db.DB()
if err != nil {
return fmt.Errorf("failed to get underlying database connection: %v", err)
}
safePath := strings.ReplaceAll(destDBPath, "'", "''")
vacuumSQL := fmt.Sprintf("VACUUM INTO '%s'", safePath)
if _, err = sqlDB.Exec(vacuumSQL); err != nil {
return fmt.Errorf("sqlite VACUUM INTO failed: %v", err)
}
return nil
}
// DownloadBackup 用于打包 ./data 目录及数据库文件为 zip 并通过 HTTP 下载
func DownloadBackup(c *gin.Context) {
// 1) 创建临时目录
tempDir, err := os.MkdirTemp("", "komari-backup-*")
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temporary directory: %v", err))
return
}
defer os.RemoveAll(tempDir)
// 2) 复制 ./data 下除 .db/.db-wal/.db-shm 外的所有文件到临时目录
if err := copyDataToTempExcludingDB(tempDir); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error copying data to temp: %v", err))
return
}
// 3) 处理数据库备份 -> 临时目录/komari.db
destDB := filepath.Join(tempDir, "komari.db")
dbFilePath := flags.DatabaseFile
if flags.IsSQLite() {
if err := backupSQLiteTo(destDB); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error backing up sqlite database: %v", err))
return
}
} else if dbFilePath != "" {
// 非 sqlite 的情况:若配置了文件路径且存在,则直接复制(按用户需求仍然将名称固定为 komari.db)
if _, err := os.Stat(dbFilePath); err == nil {
if err := copyFile(dbFilePath, destDB); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error copying database file: %v", err))
return
}
} else if !os.IsNotExist(err) {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error stating database file: %v", err))
return
}
}
// 4) 开始写出 ZIP(以临时目录为根)
backupFileName := fmt.Sprintf("backup-%d.zip", time.Now().UnixMicro())
c.Writer.Header().Set("Content-Type", "application/zip")
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", backupFileName))
zipWriter := zip.NewWriter(c.Writer)
defer zipWriter.Close()
// 写入临时目录里的内容
err = filepath.Walk(tempDir, func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(tempDir, p)
if err != nil {
return err
}
if rel == "." {
return nil
}
// zip 内路径统一正斜杠
zipPath := filepath.ToSlash(rel)
if info.IsDir() {
_, err := zipWriter.CreateHeader(&zip.FileHeader{
@@ -187,12 +79,10 @@ func DownloadBackup(c *gin.Context) {
_, err = io.Copy(w, f)
return err
})
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error archiving temp folder: %v", err))
return
}
}
// 5) 追加备份标记文件(放在 zip 根目录)
// writeBackupMarkup 追加备份标记文件到 zip。
func writeBackupMarkup(zipWriter *zip.Writer) error {
now := time.Now().UTC()
markupContent := "此文件为 Komari 备份标记文件,请勿删除。\nThis is a Komari backup markup file, please do not delete.\n\n备份时间 / Backup Time: " + now.Format(time.RFC3339Nano)
markupWriter, err := zipWriter.CreateHeader(&zip.FileHeader{
@@ -201,11 +91,158 @@ func DownloadBackup(c *gin.Context) {
Modified: now,
})
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating backup markup file: %v", err))
return
}
if _, err = markupWriter.Write([]byte(markupContent)); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing backup markup file: %v", err))
return
return err
}
_, err = markupWriter.Write([]byte(markupContent))
return err
}
// backupSQLiteTo 使用 SQLite VACUUM INTO 将当前数据库一致性备份到指定路径。
// destDBPath 会先删除以防 SQLite 报 "output file already exists"。
func backupSQLiteTo(destDBPath string) error {
if err := os.MkdirAll(filepath.Dir(destDBPath), 0o755); err != nil {
return fmt.Errorf("failed to create parent directory for db: %v", err)
}
// 确保目标文件不存在(VACUUM INTO 要求目标文件不存在)
_ = os.Remove(destDBPath)
db := dbcore.GetDBInstance()
sqlDB, err := db.DB()
if err != nil {
return fmt.Errorf("failed to get underlying database connection: %v", err)
}
// Windows 下 VACUUM INTO 传绝对路径时,统一使用正斜杠避免路径解析歧义
safePath := filepath.ToSlash(destDBPath)
safePath = strings.ReplaceAll(safePath, "'", "''")
vacuumSQL := fmt.Sprintf("VACUUM INTO '%s'", safePath)
if _, err = sqlDB.Exec(vacuumSQL); err != nil {
return fmt.Errorf("sqlite VACUUM INTO failed: %v", err)
}
return nil
}
// DownloadBackup 使用白名单打包 ./data 及数据库文件为 zip 并下载,
// 同时归档到 ./data/backup/ 确保 Docker 挂载后备份文件可持久化。
//
// 归档文件由前端后续统一管理,服务端只负责生成并保存。
func DownloadBackup(c *gin.Context) {
backupDir := filepath.Join(".", "data", "backup")
// 1) 创建临时目录,内容隔离到 content/ 子目录
tempDir, err := os.MkdirTemp("", "komari-backup-*")
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temporary directory: %v", err))
return
}
defer os.RemoveAll(tempDir)
contentDir := filepath.Join(tempDir, "content")
if err := os.MkdirAll(contentDir, 0o755); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating content directory: %v", err))
return
}
// 2) 复制白名单文件到 content 目录
if err := copyWhitelistedFiles(contentDir); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error copying data to temp: %v", err))
return
}
// 3) 处理数据库备份 -> content/komari.db
destDB := filepath.Join(contentDir, "komari.db")
dbFilePath := flags.DatabaseFile
if flags.IsSQLite() {
if err := backupSQLiteTo(destDB); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error backing up sqlite database: %v", err))
return
}
} else if dbFilePath != "" {
if _, err := os.Stat(dbFilePath); err == nil {
if err := copyFile(dbFilePath, destDB); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error copying database file: %v", err))
return
}
} else if !os.IsNotExist(err) {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error stating database file: %v", err))
return
}
}
// 4) 打包到临时 ZIP(放在 tempDir 下,与 content 平级)
tempZipPath := filepath.Join(tempDir, "output.zip")
tempZip, err := os.Create(tempZipPath)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temp zip: %v", err))
return
}
zipWriter := zip.NewWriter(tempZip)
// 只 walk content 目录,避免 output.zip 被打包进去
if err := walkDirToZip(zipWriter, contentDir); err != nil {
zipWriter.Close()
tempZip.Close()
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error archiving temp folder: %v", err))
return
}
if err := writeBackupMarkup(zipWriter); err != nil {
zipWriter.Close()
tempZip.Close()
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error writing backup markup: %v", err))
return
}
if err := zipWriter.Close(); err != nil {
tempZip.Close()
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error finalizing zip: %v", err))
return
}
tempZip.Close()
// 5) 归档到 data/backup/。先写临时文件再原子发布。
ts := time.Now().UTC().Format("20060102-150405.000000")
archiveName := fmt.Sprintf("backup-%s.zip", ts)
archivePath := filepath.Join(backupDir, archiveName)
if err := os.MkdirAll(backupDir, 0755); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating backup directory: %v", err))
return
}
archiveTemp, err := os.CreateTemp(backupDir, ".backup-*.tmp")
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating archive temp file: %v", err))
return
}
archiveTempPath := archiveTemp.Name()
if err := archiveTemp.Close(); err != nil {
os.Remove(archiveTempPath)
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error closing archive temp file: %v", err))
return
}
defer os.Remove(archiveTempPath)
if err := copyFile(tempZipPath, archiveTempPath); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error archiving backup: %v", err))
return
}
if err := os.Rename(archiveTempPath, archivePath); err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error publishing backup archive: %v", err))
return
}
// 6) 发送给客户端
c.Writer.Header().Set("Content-Type", "application/zip")
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", archiveName))
zipReader, err := os.Open(tempZipPath)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error reading temp zip: %v", err))
return
}
defer zipReader.Close()
http.ServeContent(c.Writer, c.Request, archiveName, time.Now(), zipReader)
}
+380
View File
@@ -0,0 +1,380 @@
package admin
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"runtime"
runtimepprof "runtime/pprof"
"runtime/trace"
"strconv"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/web/api"
)
const (
defaultPprofDurationSeconds = 10
minPprofDurationSeconds = 1
maxPprofDurationSeconds = 30
maxPprofPreviewBytes = 1 << 20
)
var (
cpuProfileMu sync.Mutex
traceProfileMu sync.Mutex
errPprofBusy = errors.New("pprof collection is already in progress")
errPprofUnavailable = errors.New("pprof profile is not available")
errPprofPreviewTooLarge = errors.New("pprof text preview exceeds the size limit")
)
type pprofTarget struct {
Name string
Duration time.Duration
}
// pprofPreviewBuffer keeps automatic browser previews bounded. Binary profile
// downloads remain available for profiles that exceed this compact view.
type pprofPreviewBuffer struct {
bytes.Buffer
limit int
}
func (buffer *pprofPreviewBuffer) Write(data []byte) (int, error) {
remaining := buffer.limit - buffer.Len()
if remaining <= 0 {
return 0, errPprofPreviewTooLarge
}
if len(data) > remaining {
_, _ = buffer.Buffer.Write(data[:remaining])
return remaining, errPprofPreviewTooLarge
}
return buffer.Buffer.Write(data)
}
type pprofProfileInfo struct {
Name string `json:"name"`
Endpoint string `json:"endpoint"`
Samples int `json:"samples,omitempty"`
Timed bool `json:"timed,omitempty"`
Preview string `json:"preview,omitempty"`
}
type pprofMemorySummary struct {
HeapAlloc uint64 `json:"heap_alloc"`
HeapInuse uint64 `json:"heap_inuse"`
HeapObjects uint64 `json:"heap_objects"`
Sys uint64 `json:"sys"`
}
type pprofSummary struct {
Profiles []pprofProfileInfo `json:"profiles"`
Runtime struct {
Goroutines int `json:"goroutines"`
Memory pprofMemorySummary `json:"memory"`
} `json:"runtime"`
Duration struct {
DefaultSeconds int `json:"default_seconds"`
MinSeconds int `json:"min_seconds"`
MaxSeconds int `json:"max_seconds"`
} `json:"duration"`
}
var runtimeProfileNames = []string{
"allocs",
"block",
"goroutine",
"heap",
"mutex",
"threadcreate",
}
// RegisterPprofRoutes adds diagnostic endpoints to an already administrator-
// protected route group. It deliberately uses runtime/pprof directly instead
// of net/http/pprof, which would register unauthenticated /debug/pprof routes
// on http.DefaultServeMux as a package side effect.
func RegisterPprofRoutes(group *gin.RouterGroup) {
pprofGroup := group.Group("/pprof")
pprofGroup.GET("/summary", pprofSummaryHandler)
pprofGroup.GET("/profile", downloadCPUProfile)
pprofGroup.GET("/trace", downloadTrace)
for _, name := range runtimeProfileNames {
pprofGroup.GET("/"+name, downloadRuntimeProfile(name))
}
}
// pprofSummaryHandler returns the current profile inventory and inexpensive
// runtime counters for the administrator diagnostic page.
func pprofSummaryHandler(c *gin.Context) {
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
response := pprofSummary{
Profiles: make([]pprofProfileInfo, 0, len(runtimeProfileNames)+2),
}
response.Profiles = append(response.Profiles,
pprofProfileInfo{
Name: "cpu",
Endpoint: "/api/admin/pprof/profile",
Timed: true,
},
pprofProfileInfo{
Name: "trace",
Endpoint: "/api/admin/pprof/trace",
Timed: true,
},
)
for _, name := range runtimeProfileNames {
profile := runtimepprof.Lookup(name)
if profile == nil {
continue
}
response.Profiles = append(response.Profiles, pprofProfileInfo{
Name: name,
Endpoint: "/api/admin/pprof/" + name,
Samples: profile.Count(),
Preview: "/api/admin/pprof/" + name + "?format=text",
})
}
response.Runtime.Goroutines = runtime.NumGoroutine()
response.Runtime.Memory = pprofMemorySummary{
HeapAlloc: mem.HeapAlloc,
HeapInuse: mem.HeapInuse,
HeapObjects: mem.HeapObjects,
Sys: mem.Sys,
}
response.Duration.DefaultSeconds = defaultPprofDurationSeconds
response.Duration.MinSeconds = minPprofDurationSeconds
response.Duration.MaxSeconds = maxPprofDurationSeconds
api.RespondSuccess(c, response)
}
// downloadCPUProfile records a bounded CPU profile. CPU profiling is process-
// global, so a concurrent collection is rejected instead of queued.
func downloadCPUProfile(c *gin.Context) {
if !requireBinaryPprof(c) {
return
}
duration, ok := pprofDuration(c)
if !ok {
return
}
downloadPprofTarget(c, pprofTarget{Name: "cpu", Duration: duration})
}
// downloadTrace records a bounded execution trace. Runtime tracing is also a
// process-global facility and therefore cannot run concurrently.
func downloadTrace(c *gin.Context) {
if !requireBinaryPprof(c) {
return
}
duration, ok := pprofDuration(c)
if !ok {
return
}
downloadPprofTarget(c, pprofTarget{Name: "trace", Duration: duration})
}
// downloadRuntimeProfile returns a runtime profile as a binary download by
// default, or as the safe debug=1 textual summary requested by the admin UI.
func downloadRuntimeProfile(name string) gin.HandlerFunc {
return func(c *gin.Context) {
debug, ok := pprofRuntimeDebug(c)
if !ok {
return
}
target := pprofTarget{Name: name}
if debug > 0 {
previewPprofTarget(c, target)
return
}
downloadPprofTarget(c, target)
}
}
func downloadPprofTarget(c *gin.Context, target pprofTarget) {
preparePprofResponse(c, pprofFilename(target), 0)
if err := collectPprof(c.Request.Context(), target, c.Writer, 0); err != nil && !c.Writer.Written() {
respondPprofCollectionError(c, err)
}
}
func previewPprofTarget(c *gin.Context, target pprofTarget) {
buffer := &pprofPreviewBuffer{limit: maxPprofPreviewBytes}
if err := collectPprof(c.Request.Context(), target, buffer, 1); err != nil {
respondPprofCollectionError(c, err)
return
}
preparePprofResponse(c, pprofFilename(target), 1)
c.Data(http.StatusOK, "text/plain; charset=utf-8", buffer.Bytes())
}
func collectPprof(ctx context.Context, target pprofTarget, writer io.Writer, debug int) error {
switch target.Name {
case "cpu":
if !cpuProfileMu.TryLock() {
return errPprofBusy
}
defer cpuProfileMu.Unlock()
if err := runtimepprof.StartCPUProfile(writer); err != nil {
return errPprofBusy
}
defer runtimepprof.StopCPUProfile()
waitForPprofDuration(ctx, target.Duration)
return nil
case "trace":
if !traceProfileMu.TryLock() {
return errPprofBusy
}
defer traceProfileMu.Unlock()
if err := trace.Start(writer); err != nil {
return errPprofBusy
}
defer trace.Stop()
waitForPprofDuration(ctx, target.Duration)
return nil
default:
if !isRuntimeProfile(target.Name) {
return errPprofUnavailable
}
profile := runtimepprof.Lookup(target.Name)
if profile == nil {
return errPprofUnavailable
}
return profile.WriteTo(writer, debug)
}
}
func pprofRuntimeDebug(c *gin.Context) (int, bool) {
format, hasFormat, valid := singlePprofQuery(c, "format")
if !valid || (hasFormat && format != "text") {
api.RespondError(c, http.StatusBadRequest, "format must be text when provided")
return 0, false
}
debug, hasDebug, valid := singlePprofQuery(c, "debug")
if !valid || (hasDebug && debug != "1") {
api.RespondError(c, http.StatusBadRequest, "debug must be 1 when provided")
return 0, false
}
if hasFormat || hasDebug {
return 1, true
}
return 0, true
}
func requireBinaryPprof(c *gin.Context) bool {
for _, key := range []string{"format", "debug"} {
_, present, valid := singlePprofQuery(c, key)
if !valid || present {
api.RespondError(c, http.StatusBadRequest, "CPU and trace profiles only support binary downloads")
return false
}
}
return true
}
func singlePprofQuery(c *gin.Context, key string) (string, bool, bool) {
values, present := c.GetQueryArray(key)
if !present {
return "", false, true
}
if len(values) != 1 {
return "", true, false
}
return values[0], true, true
}
func isRuntimeProfile(name string) bool {
for _, profile := range runtimeProfileNames {
if name == profile {
return true
}
}
return false
}
func pprofFilename(target pprofTarget) string {
if target.Name == "trace" {
return "trace.out"
}
return target.Name + ".pprof"
}
func respondPprofCollectionError(c *gin.Context, err error) {
switch {
case errors.Is(err, errPprofBusy):
api.RespondError(c, http.StatusConflict, "profile collection is already in progress")
case errors.Is(err, errPprofUnavailable):
api.RespondError(c, http.StatusNotFound, "profile is not available")
case errors.Is(err, errPprofPreviewTooLarge):
api.RespondError(c, http.StatusRequestEntityTooLarge, "profile text preview is too large; download the pprof file instead")
default:
api.RespondError(c, http.StatusInternalServerError, "failed to collect profile")
}
}
func pprofDuration(c *gin.Context) (time.Duration, bool) {
values, present := c.GetQueryArray("seconds")
if !present {
return time.Duration(defaultPprofDurationSeconds) * time.Second, true
}
if len(values) != 1 {
api.RespondError(c, http.StatusBadRequest, "seconds must be provided once")
return 0, false
}
seconds, err := strconv.Atoi(values[0])
if err != nil {
api.RespondError(c, http.StatusBadRequest, fmt.Sprintf(
"seconds must be an integer between %d and %d",
minPprofDurationSeconds,
maxPprofDurationSeconds,
))
return 0, false
}
duration, err := pprofDurationFromSeconds(seconds)
if err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return 0, false
}
return duration, true
}
func pprofDurationFromSeconds(seconds int) (time.Duration, error) {
if seconds < minPprofDurationSeconds || seconds > maxPprofDurationSeconds {
return 0, fmt.Errorf(
"seconds must be an integer between %d and %d",
minPprofDurationSeconds,
maxPprofDurationSeconds,
)
}
return time.Duration(seconds) * time.Second, nil
}
func preparePprofResponse(c *gin.Context, filename string, debug int) {
c.Header("Cache-Control", "no-store")
c.Header("X-Content-Type-Options", "nosniff")
if debug > 0 {
c.Header("Content-Type", "text/plain; charset=utf-8")
return
}
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Disposition", `attachment; filename="`+filename+`"`)
}
func waitForPprofDuration(ctx context.Context, duration time.Duration) {
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-timer.C:
case <-ctx.Done():
}
}
+8 -1
View File
@@ -20,7 +20,13 @@ func UploadBackup(c *gin.Context) {
return
}
defer file.Close()
if err := backup.SaveUploadedBackup(file, header.Filename); err != nil {
restoreLock, err := backup.AcquireRestoreLock()
if err != nil {
api.RespondError(c, http.StatusConflict, err.Error())
return
}
if err := restoreLock.SaveUploadedBackup(file, header.Filename); err != nil {
restoreLock.Release()
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
@@ -33,6 +39,7 @@ func UploadBackup(c *gin.Context) {
go func() {
logger.InfoArgs("admin-api", "Backup uploaded, restarting service in 2 seconds to apply on startup...")
time.Sleep(2 * time.Second)
restoreLock.Release()
os.Exit(0)
}()
}
+72 -19
View File
@@ -13,14 +13,42 @@ import (
var restoreMutex sync.Mutex
const (
MaxArchiveSize int64 = 4 << 30 // 4 GiB
maxArchiveEntries = 100_000
)
// RestoreLock serializes staging a backup until the caller releases it.
// A successful restore must hold this lock until the process restarts so a
// second request cannot replace backup.zip in the meantime.
type RestoreLock struct {
once sync.Once
}
func AcquireRestoreLock() (*RestoreLock, error) {
if !restoreMutex.TryLock() {
return nil, fmt.Errorf("another restore operation is already in progress")
}
return &RestoreLock{}, nil
}
func (l *RestoreLock) Release() {
l.once.Do(restoreMutex.Unlock)
}
// SaveUploadedBackup validates a Komari backup and stages it for restoration
// during the next process startup.
func SaveUploadedBackup(file io.Reader, filename string) error {
if !restoreMutex.TryLock() {
return fmt.Errorf("another restore operation is already in progress")
lock, err := AcquireRestoreLock()
if err != nil {
return err
}
defer restoreMutex.Unlock()
defer lock.Release()
return lock.SaveUploadedBackup(file, filename)
}
// SaveUploadedBackup stages a backup while the caller holds the restore lock.
func (l *RestoreLock) SaveUploadedBackup(file io.Reader, filename string) error {
if !strings.HasSuffix(strings.ToLower(filename), ".zip") {
return fmt.Errorf("uploaded file must be a ZIP archive")
}
@@ -28,34 +56,29 @@ func SaveUploadedBackup(file io.Reader, filename string) error {
return fmt.Errorf("create data directory: %w", err)
}
tempFile, err := os.CreateTemp("", "backup-upload-*.zip")
// Stage alongside backup.zip so the final rename is atomic on every
// supported platform and never crosses filesystem boundaries.
tempFile, err := os.CreateTemp("./data", ".backup-upload-*.zip")
if err != nil {
return fmt.Errorf("create temporary backup: %w", err)
}
tempPath := tempFile.Name()
defer os.Remove(tempPath)
if _, err := io.Copy(tempFile, file); err != nil {
written, err := io.Copy(tempFile, io.LimitReader(file, MaxArchiveSize+1))
if err != nil {
tempFile.Close()
return fmt.Errorf("save uploaded backup: %w", err)
}
if written > MaxArchiveSize {
tempFile.Close()
return fmt.Errorf("backup archive exceeds the %d byte limit", MaxArchiveSize)
}
if err := tempFile.Close(); err != nil {
return fmt.Errorf("close uploaded backup: %w", err)
}
reader, err := zip.OpenReader(tempPath)
if err != nil {
return fmt.Errorf("open backup archive: %w", err)
}
hasMarkup := false
for _, entry := range reader.File {
if entry.Name == "komari-backup-markup" {
hasMarkup = true
break
}
}
reader.Close()
if !hasMarkup {
return fmt.Errorf("invalid backup file: missing komari-backup-markup file")
if err := ValidateArchive(tempPath); err != nil {
return err
}
finalPath := filepath.Join(".", "data", "backup.zip")
@@ -83,3 +106,33 @@ func SaveUploadedBackup(file io.Reader, filename string) error {
}
return nil
}
// ValidateArchive checks the backup marker and bounds archive expansion before
// the startup restore path extracts it into data/.
func ValidateArchive(path string) error {
reader, err := zip.OpenReader(path)
if err != nil {
return fmt.Errorf("open backup archive: %w", err)
}
defer reader.Close()
if len(reader.File) > maxArchiveEntries {
return fmt.Errorf("backup archive has too many files: %d", len(reader.File))
}
var expandedSize uint64
hasMarkup := false
for _, entry := range reader.File {
if entry.Name == "komari-backup-markup" {
hasMarkup = true
}
if entry.UncompressedSize64 > uint64(MaxArchiveSize) || expandedSize > uint64(MaxArchiveSize)-entry.UncompressedSize64 {
return fmt.Errorf("backup archive expands beyond the %d byte limit", MaxArchiveSize)
}
expandedSize += entry.UncompressedSize64
}
if !hasMarkup {
return fmt.Errorf("invalid backup file: missing komari-backup-markup file")
}
return nil
}
+52
View File
@@ -0,0 +1,52 @@
package backup
import (
"archive/zip"
"os"
"path/filepath"
"testing"
)
func writeTestArchive(t *testing.T, entries map[string]string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "backup.zip")
file, err := os.Create(path)
if err != nil {
t.Fatalf("create archive: %v", err)
}
writer := zip.NewWriter(file)
for name, content := range entries {
entry, err := writer.Create(name)
if err != nil {
t.Fatalf("create %s: %v", name, err)
}
if _, err := entry.Write([]byte(content)); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
if err := writer.Close(); err != nil {
t.Fatalf("close archive writer: %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("close archive file: %v", err)
}
return path
}
func TestValidateArchiveAcceptsLegacyRootLayout(t *testing.T) {
archive := writeTestArchive(t, map[string]string{
"komari.db": "database",
"theme/config.json": "{}",
"komari-backup-markup": "backup marker",
})
if err := ValidateArchive(archive); err != nil {
t.Fatalf("ValidateArchive rejected legacy root layout: %v", err)
}
}
func TestValidateArchiveRequiresMarkup(t *testing.T) {
archive := writeTestArchive(t, map[string]string{"komari.db": "database"})
if err := ValidateArchive(archive); err == nil {
t.Fatal("ValidateArchive accepted archive without markup")
}
}
+610
View File
@@ -0,0 +1,610 @@
// Package migration serves the authenticated database migration guide used by
// both legacy monitoring imports and Metric Store structure upgrades.
package migration
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/metricstore"
appconfig "github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/internal/migrations"
"github.com/komari-monitor/komari/pkg/metric"
"github.com/komari-monitor/komari/web/api"
publicapi "github.com/komari-monitor/komari/web/api/public"
jsonrpc "github.com/komari-monitor/komari/web/rpc/jsonrpc"
"gorm.io/gorm"
)
const (
PagePath = "/admin/database-migration"
APIPath = "/api/admin/database-migration"
LegacyPagePath = "/admin/update/1.2.7"
LegacyAPIPath = "/api/admin/update/1.2.7"
StructurePagePath = "/admin/metric-store/restructure"
StructureAPIPath = "/api/admin/metric-store/restructure"
)
const largeDatasetThreshold int64 = 300_000
type Mode string
const (
ModeLegacyMonitoring Mode = "legacy_monitoring"
ModeMetricStructure Mode = "metric_store_restructure"
)
// Status is the shared polling payload. Mode-specific fields are omitted when
// they do not apply so the two previous guide response shapes remain valid.
type Status struct {
Mode Mode `json:"mode"`
State string `json:"state"`
Phase string `json:"phase"`
Progress float64 `json:"progress"`
Error string `json:"error,omitempty"`
Summary *migrations.LegacyMonitoringSummary `json:"summary,omitempty"`
Table string `json:"table,omitempty"`
SourceRowsDone int64 `json:"source_rows_done,omitempty"`
SourceRowsTotal int64 `json:"source_rows_total,omitempty"`
WrittenPoints int64 `json:"written_points,omitempty"`
TargetDriver string `json:"target_driver,omitempty"`
CurrentMetric string `json:"current_metric,omitempty"`
RowsDone int64 `json:"rows_done,omitempty"`
RowsTotal int64 `json:"rows_total,omitempty"`
MetricsDone int `json:"metrics_done,omitempty"`
MetricsTotal int `json:"metrics_total,omitempty"`
BeforeBytes int64 `json:"before_bytes,omitempty"`
AfterBytes int64 `json:"after_bytes,omitempty"`
SavedBytes int64 `json:"saved_bytes,omitempty"`
SavedPercent float64 `json:"saved_percent,omitempty"`
}
type Controller struct {
mode Mode
db *gorm.DB
active atomic.Bool
mu sync.RWMutex
status Status
done chan struct{}
once sync.Once
}
type startRequest struct {
Driver string `json:"driver"`
DSN string `json:"dsn"`
ConfirmSQLiteRisk bool `json:"confirm_sqlite_risk"`
ConfirmLargeDataset bool `json:"confirm_large_dataset"`
}
func NewLegacyController(db *gorm.DB, summary migrations.LegacyMonitoringSummary) *Controller {
return &Controller{
mode: ModeLegacyMonitoring,
db: db,
status: Status{
Mode: ModeLegacyMonitoring,
State: "idle",
Phase: "ready",
Summary: &summary,
SourceRowsTotal: summary.MonitoringRows,
},
done: make(chan struct{}),
}
}
func NewStructureController() *Controller {
return &Controller{
mode: ModeMetricStructure,
status: Status{Mode: ModeMetricStructure, State: "ready", Phase: "ready"},
done: make(chan struct{}),
}
}
func (c *Controller) Activate() { c.active.Store(true) }
func (c *Controller) Deactivate() { c.active.Store(false) }
func (c *Controller) Done() <-chan struct{} { return c.done }
func (c *Controller) Register(r *gin.Engine) {
r.POST("/api/login", publicapi.Login)
r.GET("/api/me", jsonrpc.Bind("public:getMe", jsonrpc.WithRaw()))
r.GET("/api/oauth", publicapi.OAuth)
r.GET("/api/oauth_callback", publicapi.OAuthCallback)
for _, route := range c.apiRoutes() {
g := r.Group(route.path, c.requireActive)
g.GET("/auth", c.authStatusFor(route.compatibility))
authorized := g.Group("", api.RequireRole(api.RoleAdmin))
authorized.GET("/status", c.getStatusFor(route.compatibility))
authorized.POST("/start", c.start)
if c.mode == ModeMetricStructure {
authorized.POST("/discard", c.discard)
}
}
}
type apiRoute struct {
path string
compatibility bool
}
func (c *Controller) apiRoutes() []apiRoute {
if c.mode == ModeLegacyMonitoring {
return []apiRoute{{path: APIPath}, {path: LegacyAPIPath, compatibility: true}}
}
return []apiRoute{{path: APIPath}, {path: StructureAPIPath, compatibility: true}}
}
func (c *Controller) requireActive(ctx *gin.Context) {
if !c.active.Load() {
ctx.AbortWithStatus(http.StatusNotFound)
return
}
ctx.Next()
}
func (c *Controller) authStatusFor(compatibility bool) gin.HandlerFunc {
return func(ctx *gin.Context) {
oauthEnabled, _ := appconfig.GetAs[bool](appconfig.OAuthEnabledKey, false)
oauthProvider, _ := appconfig.GetAs[string](appconfig.OAuthProviderKey, "github")
disablePassword, _ := appconfig.GetAs[bool](appconfig.DisablePasswordLoginKey, false)
status := gin.H{
"oauth_enabled": oauthEnabled,
"oauth_provider": oauthProvider,
"password_login_enabled": !disablePassword,
}
if !compatibility {
status["mode"] = c.mode
}
api.RespondSuccess(ctx, status)
}
}
func (c *Controller) getStatusFor(compatibility bool) gin.HandlerFunc {
return func(ctx *gin.Context) {
c.mu.RLock()
status := c.status
c.mu.RUnlock()
if !compatibility {
api.RespondSuccess(ctx, status)
return
}
if c.mode == ModeLegacyMonitoring {
api.RespondSuccess(ctx, legacyCompatibilityStatus(status))
return
}
api.RespondSuccess(ctx, structureCompatibilityStatus(status))
}
}
func (c *Controller) start(ctx *gin.Context) {
if c.mode == ModeMetricStructure {
c.startStructure(ctx)
return
}
c.startLegacy(ctx)
}
func (c *Controller) discard(ctx *gin.Context) {
if c.mode != ModeMetricStructure {
api.RespondError(ctx, http.StatusNotFound, "history discard is unavailable for this migration")
return
}
c.mu.Lock()
if c.operationActiveLocked() || c.status.State == "completed" {
c.mu.Unlock()
api.RespondError(ctx, http.StatusConflict, "database migration is already running or completed")
return
}
c.status = Status{Mode: c.mode, State: "discarding", Phase: "discarding"}
c.mu.Unlock()
go c.runDiscard()
api.RespondSuccessMessage(ctx, "historical metric data deletion started", gin.H{})
}
func (c *Controller) startStructure(ctx *gin.Context) {
c.mu.Lock()
if c.operationActiveLocked() || c.status.State == "completed" {
c.mu.Unlock()
api.RespondError(ctx, http.StatusConflict, "database migration is already running or completed")
return
}
c.status = Status{Mode: c.mode, State: "copying", Phase: "preparing"}
c.mu.Unlock()
go c.runStructure()
api.RespondSuccessMessage(ctx, "database migration started", gin.H{})
}
func (c *Controller) startLegacy(ctx *gin.Context) {
var request startRequest
if err := decodeJSON(ctx, &request); err != nil {
api.RespondError(ctx, http.StatusBadRequest, err.Error())
return
}
cfg, err := metricConfig(request.Driver, request.DSN)
if err != nil {
api.RespondError(ctx, http.StatusBadRequest, err.Error())
return
}
summary, err := migrations.InspectLegacyMonitoring(c.db)
if err != nil {
api.RespondError(ctx, http.StatusInternalServerError, "failed to inspect legacy monitoring data")
return
}
driver := metricstore.ResolveDriverFromConfig(cfg.Driver, cfg.DSN)
if driver == metric.DriverSQLite && summary.ServerCount > 5 && summary.RetentionDays > 7 && !request.ConfirmSQLiteRisk {
api.RespondError(ctx, http.StatusConflict, "SQLite risk confirmation is required")
return
}
if summary.LoadRows+summary.LatencyRows > largeDatasetThreshold && !request.ConfirmLargeDataset {
api.RespondError(ctx, http.StatusConflict, "large dataset confirmation is required")
return
}
c.mu.Lock()
if c.operationActiveLocked() || c.status.State == "completed" {
c.mu.Unlock()
api.RespondError(ctx, http.StatusConflict, "database migration is already running or completed")
return
}
c.status = Status{
Mode: c.mode,
State: "migrating",
Phase: "connecting",
Summary: &summary,
SourceRowsTotal: summary.MonitoringRows,
TargetDriver: string(driver),
}
c.mu.Unlock()
go c.runLegacy(*cfg, summary.RetentionDays)
api.RespondSuccessMessage(ctx, "database migration started", gin.H{})
}
func (c *Controller) operationActiveLocked() bool {
switch c.status.State {
case "cleaning", "discarding", "migrating", "copying", "reclaiming":
return true
default:
return false
}
}
func (c *Controller) runLegacy(cfg metricstore.MetricStoreConfig, legacyRetentionDays int) {
ctx := context.Background()
mainBefore, err := dbcore.StorageSize()
if err != nil {
c.failTarget(err, cfg.DSN, "measuring")
return
}
store, err := metricstore.OpenStoreForMigration(ctx, &cfg, legacyRetentionDays)
if err != nil {
c.failTarget(err, cfg.DSN, "connecting")
return
}
defer store.Close()
targetBefore, err := store.StorageSize(ctx)
if err != nil {
c.failTarget(err, cfg.DSN, "measuring")
return
}
if err := appconfig.SetMany(map[string]any{
metricstore.MetricDBDriverKey: cfg.Driver,
metricstore.MetricDBDSNKey: cfg.DSN,
}); err != nil {
c.failTarget(err, cfg.DSN, "saving_target")
return
}
_, err = migrations.MigrateLegacyMonitoring(ctx, c.db, store, func(progress migrations.LegacyMonitoringProgress) {
c.mu.Lock()
c.status.Phase = progress.Phase
c.status.Table = progress.Table
c.status.SourceRowsDone = progress.SourceRowsDone
c.status.SourceRowsTotal = progress.SourceRowsTotal
c.status.WrittenPoints = progress.WrittenPoints
if progress.SourceRowsTotal > 0 {
c.status.Progress = float64(progress.SourceRowsDone) / float64(progress.SourceRowsTotal) * 100
}
c.mu.Unlock()
})
if err != nil {
c.failTarget(err, cfg.DSN, "migrating")
return
}
if err := store.RebuildCoarserRollups(ctx, time.Hour); err != nil {
c.failTarget(err, cfg.DSN, "migrating")
return
}
c.mu.Lock()
c.status.Phase = "vacuuming"
c.status.Progress = 100
c.mu.Unlock()
if _, err := store.Compact(ctx, time.Now().UTC()); err != nil {
c.failTarget(err, cfg.DSN, "vacuuming")
return
}
if err := store.ReclaimSpace(ctx); err != nil {
c.failTarget(err, cfg.DSN, "vacuuming")
return
}
c.mu.Lock()
c.status.Phase = "finalizing"
c.mu.Unlock()
finalizePhase := "finalizing"
if err := migrations.CompleteLegacyMonitoringMigration(c.db, func() error {
finalizePhase = "vacuuming"
c.mu.Lock()
c.status.Phase = finalizePhase
c.mu.Unlock()
return dbcore.ReclaimSpace(ctx)
}); err != nil {
c.failTarget(err, cfg.DSN, finalizePhase)
return
}
mainAfter, err := dbcore.StorageSize()
if err != nil {
c.failTarget(err, cfg.DSN, "measuring")
return
}
targetAfter, err := store.StorageSize(ctx)
if err != nil {
c.failTarget(err, cfg.DSN, "measuring")
return
}
beforeBytes := mainBefore + targetBefore
afterBytes := mainAfter + targetAfter
savedBytes := beforeBytes - afterBytes
if savedBytes < 0 {
savedBytes = 0
}
savedPercent := 0.0
if beforeBytes > 0 {
savedPercent = float64(savedBytes) / float64(beforeBytes) * 100
}
c.mu.Lock()
c.status.State = "completed"
c.status.Phase = "completed"
c.status.Table = ""
c.status.Progress = 100
c.status.BeforeBytes = beforeBytes
c.status.AfterBytes = afterBytes
c.status.SavedBytes = savedBytes
c.status.SavedPercent = savedPercent
c.status.Error = ""
c.mu.Unlock()
c.completeLater()
}
type legacyStatus struct {
State string `json:"state"`
Phase string `json:"phase"`
Table string `json:"table,omitempty"`
Summary migrations.LegacyMonitoringSummary `json:"summary"`
SourceRowsDone int64 `json:"source_rows_done"`
SourceRowsTotal int64 `json:"source_rows_total"`
WrittenPoints int64 `json:"written_points"`
Progress float64 `json:"progress"`
TargetDriver string `json:"target_driver,omitempty"`
Error string `json:"error,omitempty"`
}
func legacyCompatibilityStatus(status Status) legacyStatus {
var summary migrations.LegacyMonitoringSummary
if status.Summary != nil {
summary = *status.Summary
}
return legacyStatus{
State: status.State, Phase: status.Phase, Table: status.Table, Summary: summary,
SourceRowsDone: status.SourceRowsDone, SourceRowsTotal: status.SourceRowsTotal,
WrittenPoints: status.WrittenPoints, Progress: status.Progress,
TargetDriver: status.TargetDriver, Error: status.Error,
}
}
type structureStatus struct {
State string `json:"state"`
Phase string `json:"phase"`
CurrentMetric string `json:"current_metric,omitempty"`
RowsDone int64 `json:"rows_done"`
RowsTotal int64 `json:"rows_total"`
MetricsDone int `json:"metrics_done"`
MetricsTotal int `json:"metrics_total"`
Progress float64 `json:"progress"`
BeforeBytes int64 `json:"before_bytes,omitempty"`
AfterBytes int64 `json:"after_bytes,omitempty"`
SavedBytes int64 `json:"saved_bytes,omitempty"`
SavedPercent float64 `json:"saved_percent,omitempty"`
Error string `json:"error,omitempty"`
}
func structureCompatibilityStatus(status Status) structureStatus {
return structureStatus{
State: status.State, Phase: status.Phase, CurrentMetric: status.CurrentMetric,
RowsDone: status.RowsDone, RowsTotal: status.RowsTotal,
MetricsDone: status.MetricsDone, MetricsTotal: status.MetricsTotal,
Progress: status.Progress, BeforeBytes: status.BeforeBytes, AfterBytes: status.AfterBytes,
SavedBytes: status.SavedBytes, SavedPercent: status.SavedPercent, Error: status.Error,
}
}
func (c *Controller) runStructure() {
result, err := metricstore.RestructureConfiguredStore(context.Background(), func(progress metricstore.RestructureProgress) {
c.mu.Lock()
c.status.Phase = progress.Phase
c.status.CurrentMetric = progress.CurrentMetric
c.status.RowsDone = progress.RowsDone
c.status.RowsTotal = progress.RowsTotal
c.status.MetricsDone = progress.MetricsDone
c.status.MetricsTotal = progress.MetricsTotal
c.status.Progress = structureProgressPercent(progress)
if progress.Phase == "reclaiming" {
c.status.State = "reclaiming"
} else if progress.Phase == "discarding" {
c.status.State = "discarding"
} else if c.status.State == "reclaiming" || c.status.State == "discarding" {
c.status.State = "copying"
}
c.mu.Unlock()
})
if err != nil {
c.fail(metricstore.RedactConnectionError(err.Error(), ""), "failed")
return
}
saved := result.BeforeBytes - result.AfterBytes
if saved < 0 {
saved = 0
}
percent := 0.0
if result.BeforeBytes > 0 {
percent = float64(saved) / float64(result.BeforeBytes) * 100
}
c.mu.Lock()
c.status = Status{
Mode: c.mode, State: "completed", Phase: "completed", Progress: 100,
RowsDone: result.RowsCopied, RowsTotal: result.RowsCopied,
MetricsDone: result.Metrics, MetricsTotal: result.Metrics,
BeforeBytes: result.BeforeBytes, AfterBytes: result.AfterBytes,
SavedBytes: saved, SavedPercent: percent,
}
c.mu.Unlock()
c.completeLater()
}
func (c *Controller) runDiscard() {
result, err := metricstore.DiscardConfiguredStoreHistory(context.Background(), func(progress metricstore.RestructureProgress) {
c.mu.Lock()
c.status.Phase = progress.Phase
c.status.CurrentMetric = progress.CurrentMetric
c.status.RowsDone = progress.RowsDone
c.status.RowsTotal = progress.RowsTotal
c.status.MetricsDone = progress.MetricsDone
c.status.MetricsTotal = progress.MetricsTotal
switch progress.Phase {
case "reclaiming":
c.status.State = "reclaiming"
case "discarding":
c.status.State = "discarding"
}
c.status.Progress = structureProgressPercent(progress)
c.mu.Unlock()
})
if err != nil {
c.fail(metricstore.RedactConnectionError(err.Error(), ""), "discarding")
return
}
saved := result.BeforeBytes - result.AfterBytes
if saved < 0 {
saved = 0
}
percent := 0.0
if result.BeforeBytes > 0 {
percent = float64(saved) / float64(result.BeforeBytes) * 100
}
c.mu.Lock()
c.status = Status{
Mode: c.mode, State: "completed", Phase: "completed", Progress: 100,
RowsDone: result.RowsCopied, RowsTotal: result.RowsCopied,
MetricsDone: result.Metrics, MetricsTotal: result.Metrics,
BeforeBytes: result.BeforeBytes, AfterBytes: result.AfterBytes,
SavedBytes: saved, SavedPercent: percent,
}
c.mu.Unlock()
c.completeLater()
}
func (c *Controller) completeLater() {
time.AfterFunc(1500*time.Millisecond, func() {
c.Deactivate()
c.once.Do(func() { close(c.done) })
})
}
func (c *Controller) fail(message, phase string) {
c.mu.Lock()
c.status.State = "failed"
c.status.Phase = phase
c.status.Error = message
c.mu.Unlock()
}
func (c *Controller) failTarget(err error, dsn, phase string) {
message := err.Error()
if dsn != "" {
message = strings.ReplaceAll(message, dsn, "[redacted]")
}
c.fail(message, phase)
}
func structureProgressPercent(progress metricstore.RestructureProgress) float64 {
if progress.Phase == "reclaiming" {
return 95
}
if progress.Phase == "discarding" && progress.RowsTotal == 0 {
return 1
}
if progress.RowsTotal <= 0 {
return 0
}
value := float64(progress.RowsDone) / float64(progress.RowsTotal) * 95
if value < 0 {
return 0
}
if value > 95 {
return 95
}
return value
}
func metricConfig(requestedDriver, requestedDSN string) (*metricstore.MetricStoreConfig, error) {
requestedDriver = strings.ToLower(strings.TrimSpace(requestedDriver))
requestedDSN = strings.TrimSpace(requestedDSN)
if requestedDriver != string(metric.DriverSQLite) && requestedDriver != string(metric.DriverMySQL) && requestedDriver != string(metric.DriverPostgreSQL) {
return nil, fmt.Errorf("driver must be sqlite, mysql, or postgresql")
}
if requestedDSN == "" {
if requestedDriver != string(metric.DriverSQLite) {
return nil, fmt.Errorf("dsn is required for remote databases")
}
requestedDSN = "./data/metrics.db"
}
resolved := metricstore.ResolveDriverFromConfig(requestedDriver, requestedDSN)
if string(resolved) != requestedDriver {
return nil, fmt.Errorf("dsn does not match the selected database type")
}
cfg, err := appconfig.GetManyAs[metricstore.MetricStoreConfig]()
if err != nil {
return nil, fmt.Errorf("load metric store defaults: %w", err)
}
cfg.Driver = requestedDriver
cfg.DSN = requestedDSN
return cfg, nil
}
func decodeJSON(ctx *gin.Context, target any) error {
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, 1<<20)
decoder := json.NewDecoder(ctx.Request.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return fmt.Errorf("invalid request body: %w", err)
}
return nil
}
+155
View File
@@ -0,0 +1,155 @@
package migration
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/metricstore"
appconfig "github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/internal/migrations"
"github.com/komari-monitor/komari/web/api"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupConfigDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+filepath.ToSlash(filepath.Join(t.TempDir(), "migration.db"))+"?mode=rwc"), &gorm.Config{})
if err != nil {
t.Fatalf("open migration database: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("get migration sql database: %v", err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
appconfig.SetDb(db)
return db
}
func TestMetricConfigValidatesSelectedDriverAgainstDSN(t *testing.T) {
setupConfigDB(t)
sqliteConfig, err := metricConfig("sqlite", "")
if err != nil {
t.Fatalf("build default SQLite config: %v", err)
}
if sqliteConfig.Driver != "sqlite" || sqliteConfig.DSN != "./data/metrics.db" {
t.Fatalf("unexpected default SQLite config: %#v", sqliteConfig)
}
if _, err := metricConfig("mysql", "./data/metrics.db"); err == nil {
t.Fatal("expected mismatched MySQL/SQLite DSN to fail")
}
postgresConfig, err := metricConfig("postgresql", "host=127.0.0.1 port=5432 user=komari password=secret dbname=komari sslmode=disable")
if err != nil {
t.Fatalf("build PostgreSQL config: %v", err)
}
if postgresConfig.Driver != "postgresql" {
t.Fatalf("unexpected PostgreSQL driver: %q", postgresConfig.Driver)
}
}
func TestRestrictedControllersRegisterUnifiedAndCompatibilityRoutes(t *testing.T) {
for _, test := range []struct {
name string
controller *Controller
alias string
discard bool
}{
{name: "legacy", controller: NewLegacyController(setupConfigDB(t), migrations.LegacyMonitoringSummary{}), alias: LegacyAPIPath},
{name: "structure", controller: NewStructureController(), alias: StructureAPIPath, discard: true},
} {
t.Run(test.name, func(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(api.IdentityMiddleware())
test.controller.Activate()
test.controller.Register(r)
routes := make(map[string]bool)
for _, route := range r.Routes() {
routes[route.Method+" "+route.Path] = true
}
for _, base := range []string{APIPath, test.alias} {
for _, route := range []string{"GET " + base + "/auth", "GET " + base + "/status", "POST " + base + "/start"} {
if !routes[route] {
t.Fatalf("required restricted route is missing: %s", route)
}
}
if routes["POST "+base+"/cleanup"] {
t.Fatalf("full-history migration unexpectedly exposes cleanup: %s", base)
}
if routes["POST "+base+"/discard"] != test.discard {
t.Fatalf("POST %s/discard presence = %v, want %v", base, routes["POST "+base+"/discard"], test.discard)
}
}
if routes["GET /api/public"] || routes["GET /api/rpc2"] || routes["POST /api/clients/report"] {
t.Fatalf("ordinary APIs leaked into restricted routes: %#v", routes)
}
request := httptest.NewRequest(http.MethodGet, APIPath+"/status", nil)
response := httptest.NewRecorder()
r.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated status code = %d, want %d", response.Code, http.StatusUnauthorized)
}
})
}
}
func TestCompatibilityAuthOmitsUnifiedMode(t *testing.T) {
db := setupConfigDB(t)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(api.IdentityMiddleware())
controller := NewLegacyController(db, migrations.LegacyMonitoringSummary{})
controller.Activate()
controller.Register(r)
for _, test := range []struct {
path string
wantMode bool
}{
{path: APIPath + "/auth", wantMode: true},
{path: LegacyAPIPath + "/auth", wantMode: false},
} {
request := httptest.NewRequest(http.MethodGet, test.path, nil)
response := httptest.NewRecorder()
r.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("GET %s status = %d, want 200", test.path, response.Code)
}
var payload struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode GET %s response: %v", test.path, err)
}
_, hasMode := payload.Data["mode"]
if hasMode != test.wantMode {
t.Fatalf("GET %s mode presence = %v, want %v", test.path, hasMode, test.wantMode)
}
}
}
func TestCompatibilityStatusShapes(t *testing.T) {
summary := migrations.LegacyMonitoringSummary{MonitoringRows: 7}
legacy := legacyCompatibilityStatus(Status{Mode: ModeLegacyMonitoring, State: "migrating", Summary: &summary, SourceRowsTotal: 7})
if legacy.Summary.MonitoringRows != 7 || legacy.SourceRowsTotal != 7 {
t.Fatalf("unexpected legacy compatibility status: %#v", legacy)
}
structure := structureCompatibilityStatus(Status{Mode: ModeMetricStructure, State: "copying", RowsDone: 3, RowsTotal: 9})
if structure.RowsDone != 3 || structure.RowsTotal != 9 {
t.Fatalf("unexpected structure compatibility status: %#v", structure)
}
}
func TestStructureProgressPercent(t *testing.T) {
if got := structureProgressPercent(metricstore.RestructureProgress{RowsDone: 50, RowsTotal: 100}); got != 47.5 {
t.Fatalf("copying percent = %v, want 47.5", got)
}
}
+5
View File
@@ -81,10 +81,15 @@ func registerAgentRoutes(r *gin.Engine) {
// registerAdminRoutes 管理员路由。除二进制/流类外全部经 Bind 绑定到 admin: 命名空间方法。
func registerAdminRoutes(r *gin.Engine) {
g := r.Group("/api/admin", api.RequireRole(api.RoleAdmin))
admin.RegisterPprofRoutes(g)
// --- 二进制/流/重定向类,保留 REST handler ---
g.GET("/download/backup", admin.DownloadBackup)
g.POST("/upload/backup", admin.UploadBackup)
// chunk upload 用于大备份文件分块上传,提高稳定性
g.POST("/upload/backup/init", admin.InitChunkUpload)
g.POST("/upload/backup/chunk", admin.UploadChunk)
g.POST("/upload/backup/merge", admin.MergeChunkUpload)
g.GET("/test/geoip", jsonRpc.Bind("admin:testGeoip", jsonRpc.WithQuery("ip")))
g.POST("/test/sendMessage", jsonRpc.Bind("admin:testSendMessage"))
g.POST("/update/mmdb", admin.UpdateMmdbGeoIP)
+25 -25
View File
@@ -2,6 +2,7 @@ package jsonrpc
import (
"context"
"errors"
"strconv"
"strings"
"time"
@@ -14,6 +15,7 @@ import (
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/internal/lifecycle"
"github.com/komari-monitor/komari/pkg/rpc"
)
@@ -49,7 +51,7 @@ func init() {
RegisterWithGroupAndMeta("editSettings", rpc.RoleAdmin, adminEditSettings, &rpc.MethodMeta{
Name: "admin:editSettings",
Summary: "Update settings (partial)",
Returns: "null",
Returns: "null | { restart_required: true, guide_path: string }",
})
RegisterWithGroupAndMeta("clearAllRecords", rpc.RoleAdmin, adminClearAllRecords, &rpc.MethodMeta{
Name: "admin:clearAllRecords",
@@ -114,7 +116,6 @@ func adminGetSettings(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonR
var metricStoreConfigKeys = map[string]struct{}{
metricstore.MetricDBDriverKey: {},
metricstore.MetricDBDSNKey: {},
config.LowResourceModeKey: {},
metricstore.MetricTablePrefixKey: {},
metricstore.MetricMaxOpenConnsKey: {},
metricstore.MetricMaxIdleConnsKey: {},
@@ -135,6 +136,7 @@ func adminEditSettings(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.
if err := req.BindParams(&cfg); err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid or missing request body: "+err.Error(), nil)
}
removeRetiredLowResourceMode(cfg)
// 若本次修改涉及 metrics 数据库配置,则在落库前先用「当前配置 + 本次改动」
// 合并出的目标配置做一次连接测试。metric store 始终启用,只要触及 metrics
@@ -169,24 +171,32 @@ func adminEditSettings(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.
if err := config.SetMany(cfg); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to update settings: "+err.Error(), nil)
}
if v, ok := cfg[config.LowResourceModeKey]; ok {
if err := dbcore.ConfigureLowResourceMode(toBool(v, false)); err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to apply low resource mode: "+err.Error(), nil)
}
}
// 配置已落库,热重载 metric store(无需重启)。连接已在上面验证过,
// 这里再次失败属异常情况,回报给用户。
// 配置已落库,热重载 metric store(无需重启)。旧结构必须在下一次
// 启动前进入受限迁移页,不能在普通 HTTP 服务仍运行时切换路由。
if touchedMetric {
reloadCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
if err := metricstore.Reload(reloadCtx); err != nil {
cancel()
if errors.Is(err, metricstore.ErrStructureUpgradeRequired) {
auditSettingsUpdate(ctx, cfg)
lifecycle.RequestRestart(lifecycle.RestartForMetricStoreStructureUpgrade)
return map[string]any{
"restart_required": true,
"guide_path": "/admin/database-migration",
}, nil
}
return nil, rpc.MakeError(rpc.InternalError,
"Settings saved but metrics database hot reload failed: "+err.Error(), nil)
}
cancel()
}
auditSettingsUpdate(ctx, cfg)
return nil, nil
}
func auditSettingsUpdate(ctx context.Context, cfg map[string]interface{}) {
message := "update settings: "
for key := range cfg {
message += key + ", "
@@ -196,7 +206,12 @@ func adminEditSettings(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.
}
actor, ip := auditActor(ctx)
auditlog.Log(ip, actor, message, "info")
return nil, nil
}
// removeRetiredLowResourceMode keeps older admin clients from recreating its
// config row after the startup migration removes it.
func removeRetiredLowResourceMode(cfg map[string]interface{}) {
delete(cfg, "low_resource_mode")
}
// mergedMetricConfig 读取当前持久化的 metric store 配置,并把本次请求中涉及的
@@ -218,9 +233,6 @@ func mergedMetricConfig(cfg map[string]interface{}) (*metricstore.MetricStoreCon
merged.DSN = s
}
}
if v, ok := cfg[config.LowResourceModeKey]; ok {
merged.LowResourceMode = toBool(v, merged.LowResourceMode)
}
if v, ok := cfg[metricstore.MetricTablePrefixKey]; ok {
if s, ok := v.(string); ok {
merged.TablePrefix = s
@@ -236,18 +248,6 @@ func mergedMetricConfig(cfg map[string]interface{}) (*metricstore.MetricStoreCon
return merged, nil
}
func toBool(v any, fallback bool) bool {
switch val := v.(type) {
case bool:
return val
case string:
if parsed, err := strconv.ParseBool(val); err == nil {
return parsed
}
}
return fallback
}
// toInt 将 JSON 解码得到的任意值(通常是 float64 或 string)转换为 int,失败时返回 fallback。
func toInt(v any, fallback int) int {
+29
View File
@@ -0,0 +1,29 @@
package jsonrpc
import (
"testing"
"github.com/komari-monitor/komari/database/metricstore"
)
func TestMetricKeysTouched(t *testing.T) {
if !metricKeysTouched(map[string]interface{}{metricstore.MetricDBDSNKey: "metrics.db"}) {
t.Fatal("metric database DSN must trigger metric store validation")
}
}
func TestRemoveRetiredLowResourceMode(t *testing.T) {
cfg := map[string]interface{}{
"low_resource_mode": true,
"sitename": "Komari",
}
removeRetiredLowResourceMode(cfg)
if _, ok := cfg["low_resource_mode"]; ok {
t.Fatal("retired low resource mode must not be persisted")
}
if cfg["sitename"] != "Komari" {
t.Fatal("unrelated settings must be preserved")
}
}
+2
View File
@@ -212,6 +212,7 @@ func publicGetPublicPingTasks(_ context.Context, _ *rpc.JsonRpcRequest) (any, *r
}
type publicPingTask struct {
Id uint `json:"id"`
Weight int `json:"weight"`
Name string `json:"name"`
Clients []string `json:"clients"`
DefaultOn bool `json:"default_on"`
@@ -222,6 +223,7 @@ func publicGetPublicPingTasks(_ context.Context, _ *rpc.JsonRpcRequest) (any, *r
for i, task := range pingTasks {
out[i] = publicPingTask{
Id: task.Id,
Weight: task.Weight,
Name: task.Name,
Clients: task.Clients,
DefaultOn: task.DefaultOn,
+133 -89
View File
@@ -41,23 +41,16 @@ type publicMetricQueryParams struct {
Tags map[string]string `json:"tags"`
Downsample *bool `json:"downsample"`
ServerDownsample *bool `json:"server_downsample"`
DownsampleByMetric map[string]bool `json:"downsample_by_metric"`
ServerDownsampleByMetric map[string]bool `json:"server_downsample_by_metric"`
FillEmpty *bool `json:"fill_empty"`
FillEmpty *bool `json:"fill_empty"`
MaxPoints int `json:"max_points"`
DownsamplePoints int `json:"downsample_points"`
MaxPointsByMetric map[string]int `json:"max_points_by_metric"`
PointsByMetric map[string]int `json:"points_by_metric"`
Aggregation string `json:"aggregation"`
DownsampleAlgorithm string `json:"downsample_algorithm"`
Algorithm string `json:"algorithm"`
AggregationByMetric map[string]string `json:"aggregation_by_metric"`
DownsampleAlgorithmByMetric map[string]string `json:"downsample_algorithm_by_metric"`
AlgorithmByMetric map[string]string `json:"algorithm_by_metric"`
Aggregation string `json:"aggregation"`
Algorithm string `json:"algorithm"`
AggregationByMetric map[string]string `json:"aggregation_by_metric"`
AlgorithmByMetric map[string]string `json:"algorithm_by_metric"`
}
type publicMetricPoint struct {
@@ -98,8 +91,7 @@ type publicPingMetricStatsParams struct {
EndTime *time.Time `json:"end_time"`
Hours float64 `json:"hours"`
MaxPoints int `json:"max_points"`
DownsamplePoints int `json:"downsample_points"`
MaxPoints int `json:"max_points"`
}
type publicPingMetricTaskStats struct {
@@ -167,7 +159,8 @@ func publicQueryMetrics(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc
return nil, rpc.MakeError(rpc.InvalidParams, "metric_keys is required", nil)
}
end := metricQueryTimeOrDefault(firstMetricQueryTime(params.End, params.EndTime), time.Now().UTC())
queryNow := time.Now().UTC()
end := metricQueryTimeOrDefault(firstMetricQueryTime(params.End, params.EndTime), queryNow)
startFallback := end.Add(-metricQueryHours(params.Hours))
start := metricQueryTimeOrDefault(firstMetricQueryTime(params.Start, params.StartTime), startFallback)
if !end.After(start) {
@@ -179,14 +172,6 @@ func publicQueryMetrics(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc
return nil, rpcErr
}
downsample := true
if params.Downsample != nil {
downsample = *params.Downsample
}
if params.ServerDownsample != nil {
downsample = *params.ServerDownsample
}
store := metricstore.GetStore()
if store == nil {
return nil, rpc.MakeError(rpc.InternalError, "metric store not initialized", nil)
@@ -210,7 +195,6 @@ func publicQueryMetrics(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc
if err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, err.Error(), nil)
}
metricDownsample := resolveMetricDownsample(metricKey, params)
algorithm := resolveMetricAggregation(metricKey, params)
metricFillEmpty := resolveMetricFillEmpty(params)
@@ -230,49 +214,19 @@ func publicQueryMetrics(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc
Unit: def.Unit,
RetentionDays: def.RetentionDays,
Tags: params.Tags,
Downsampled: metricDownsample,
FillEmpty: metricFillEmpty,
MaxPoints: maxPoints,
}
if metricDownsample {
loaded, err := loadPublicMetricPoints(ctx, store, query, algorithm, maxPoints, metricFillEmpty, queryNow)
if err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Failed to query metric "+metricKey+": "+err.Error(), nil)
}
item.Downsampled = loaded.downsampled
item.Points = loaded.points
if loaded.downsampled {
item.DownsampleAlgorithm = string(algorithm)
now := time.Now().UTC()
interval := metricDownsampleInterval(end.Sub(start), maxPoints)
interval = store.CompatibleSeriesInterval(start, now, interval)
item.IntervalSeconds = interval.Seconds()
points, err := store.Series(ctx, metric.AggregateQuery{
Query: query,
Aggregation: algorithm,
Interval: interval,
PreserveSeries: true,
}, now)
if err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Failed to query metric "+metricKey+": "+err.Error(), nil)
}
item.Points = make([]publicMetricPoint, 0, len(points))
for _, point := range points {
item.Points = append(item.Points, publicMetricPoint{
Time: point.Bucket.UTC(),
Value: publicRawMetricValue(point.MetricName, point.Value, metricFillEmpty),
Count: point.Count,
Tags: point.Tags,
})
}
} else {
points, err := store.Query(ctx, query)
if err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Failed to query metric "+metricKey+": "+err.Error(), nil)
}
item.Points = make([]publicMetricPoint, 0, len(points))
for _, point := range points {
item.Points = append(item.Points, publicMetricPoint{
Time: point.Timestamp.UTC(),
Value: publicRawMetricValue(metricKey, point.Value, metricFillEmpty),
Tags: point.Tags,
Labels: point.Labels,
})
}
item.IntervalSeconds = loaded.interval.Seconds()
}
for _, split := range splitPublicMetricSeries(item) {
if metricFillEmpty {
@@ -286,13 +240,125 @@ func publicQueryMetrics(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc
return map[string]any{
"start": start.UTC(),
"end": end.UTC(),
"server_downsample_default": downsample,
"server_downsample_default": true,
"default_points": defaultMetricQueryPoints,
"series": series,
"count": len(series),
}, nil
}
type publicMetricPointResult struct {
points []publicMetricPoint
downsampled bool
interval time.Duration
}
func loadPublicMetricPoints(
ctx context.Context,
store *metric.Store,
query metric.Query,
algorithm metric.Aggregation,
maxPoints int,
fillEmpty bool,
now time.Time,
) (publicMetricPointResult, error) {
if publicMetricUsesRawWindow(query.Start, query.End, now) {
fallbackInterval := store.CompatibleSeriesInterval(query.Start, now, time.Minute)
// Read rollups first so a concurrent write is observed by the later raw
// read and suppresses the older summary for that bucket.
fallback, err := store.Series(ctx, metric.AggregateQuery{
Query: query,
Aggregation: algorithm,
Interval: fallbackInterval,
PreserveSeries: true,
}, now)
if err != nil {
return publicMetricPointResult{}, err
}
points, err := store.Query(ctx, query)
if err != nil {
return publicMetricPointResult{}, err
}
result := publicMetricPointResult{points: make([]publicMetricPoint, 0, len(points))}
rawBuckets := make(map[string]struct{}, len(points))
for _, point := range points {
publicPoint := publicMetricPoint{
Time: point.Timestamp.UTC(),
Value: publicRawMetricValue(point.MetricName, point.Value, fillEmpty),
Count: 1,
Tags: point.Tags,
Labels: point.Labels,
}
result.points = append(result.points, publicPoint)
rawBuckets[publicMetricBucketKey(publicPoint, fallbackInterval)] = struct{}{}
}
// Exact samples disappear on restart, while the minute summaries remain
// persisted. Fill only buckets that have no exact sample so a restart does
// not create a false gap or duplicate a bucket that is still in memory.
for _, point := range fallback {
publicPoint := publicMetricPoint{
Time: point.Bucket.UTC(),
Value: publicRawMetricValue(point.MetricName, point.Value, fillEmpty),
Count: point.Count,
Tags: point.Tags,
}
if publicPoint.Time.Before(query.Start) || publicPoint.Time.Add(fallbackInterval).After(query.End) {
continue
}
if _, exact := rawBuckets[publicMetricBucketKey(publicPoint, fallbackInterval)]; exact {
continue
}
result.points = append(result.points, publicPoint)
result.downsampled = true
result.interval = fallbackInterval
}
sort.SliceStable(result.points, func(i, j int) bool {
if result.points[i].Time.Equal(result.points[j].Time) {
return publicMetricTagsKey(result.points[i].Tags) < publicMetricTagsKey(result.points[j].Tags)
}
return result.points[i].Time.Before(result.points[j].Time)
})
return result, nil
}
interval := metricDownsampleInterval(query.End.Sub(query.Start), maxPoints)
interval = store.CompatibleSeriesInterval(query.Start, now, interval)
points, err := store.Series(ctx, metric.AggregateQuery{
Query: query,
Aggregation: algorithm,
Interval: interval,
PreserveSeries: true,
}, now)
if err != nil {
return publicMetricPointResult{}, err
}
result := publicMetricPointResult{
points: make([]publicMetricPoint, 0, len(points)),
downsampled: true,
interval: interval,
}
for _, point := range points {
result.points = append(result.points, publicMetricPoint{
Time: point.Bucket.UTC(),
Value: publicRawMetricValue(point.MetricName, point.Value, fillEmpty),
Count: point.Count,
Tags: point.Tags,
})
}
return result, nil
}
func publicMetricUsesRawWindow(start, end, now time.Time) bool {
retention := metricstore.DefaultRollupRawRetention
return end.Sub(start) <= retention && end.After(now.UTC().Add(-retention))
}
func publicMetricBucketKey(point publicMetricPoint, interval time.Duration) string {
bucket := point.Time.UTC().Truncate(interval).UnixMilli()
return strconv.FormatInt(bucket, 10) + "\x00" + publicMetricTagsKey(point.Tags)
}
func publicGetPingMetricStats(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params publicPingMetricStatsParams
if err := req.BindParams(&params); err != nil {
@@ -336,9 +402,6 @@ func publicGetPingMetricStats(ctx context.Context, req *rpc.JsonRpcRequest) (any
taskFilter := normalizePingMetricTaskIDs(params.TaskID, params.TaskIDs)
maxPoints := params.MaxPoints
if maxPoints == 0 {
maxPoints = params.DownsamplePoints
}
if maxPoints <= 0 {
maxPoints = defaultMetricQueryPoints
}
@@ -480,7 +543,9 @@ func adaptiveFillPublicMetricSeries(series publicMetricSeries, start, end time.T
}
filled = append(filled, point)
}
if len(pointTimes) == 0 || pointTimes[len(pointTimes)-1].Before(end) {
// Do not append a trailing null after real data. It would make the final chart
// bucket blank regardless of whether the tail is a collection delay or a gap.
if len(pointTimes) == 0 {
filled = append(filled, nullPoint(end))
}
series.Points = filled
@@ -596,9 +661,6 @@ func metricQueryHours(hours float64) time.Duration {
func resolveMetricMaxPoints(metricKey string, params publicMetricQueryParams) (int, error) {
maxPoints := params.MaxPoints
if maxPoints == 0 {
maxPoints = params.DownsamplePoints
}
if maxPoints == 0 {
maxPoints = defaultMetricQueryPoints
}
@@ -615,10 +677,9 @@ func resolveMetricMaxPoints(metricKey string, params publicMetricQueryParams) (i
}
func resolveMetricAggregation(metricKey string, params publicMetricQueryParams) metric.Aggregation {
raw := firstNonEmpty(params.Aggregation, params.DownsampleAlgorithm, params.Algorithm)
raw := firstNonEmpty(params.Aggregation, params.Algorithm)
if v := firstNonEmpty(
params.AggregationByMetric[metricKey],
params.DownsampleAlgorithmByMetric[metricKey],
params.AlgorithmByMetric[metricKey],
); v != "" {
raw = v
@@ -629,23 +690,6 @@ func resolveMetricAggregation(metricKey string, params publicMetricQueryParams)
return metric.Aggregation(normalizeMetricAggregation(raw))
}
func resolveMetricDownsample(metricKey string, params publicMetricQueryParams) bool {
downsample := true
if params.Downsample != nil {
downsample = *params.Downsample
}
if params.ServerDownsample != nil {
downsample = *params.ServerDownsample
}
if v, ok := params.DownsampleByMetric[metricKey]; ok {
downsample = v
}
if v, ok := params.ServerDownsampleByMetric[metricKey]; ok {
downsample = v
}
return downsample
}
func resolveMetricFillEmpty(params publicMetricQueryParams) bool {
return params.FillEmpty != nil && *params.FillEmpty
}
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more