mirror of
https://github.com/komari-monitor/komari.git
synced 2026-11-12 17:31:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d69effee7a | ||
|
|
ebe5278183 | ||
|
|
16d6f2670d | ||
|
|
abc53b3cc0 | ||
|
|
a1ae82169c | ||
|
|
8c849aa423 | ||
|
|
0d699951b7 | ||
|
|
1ffe413eb7 | ||
|
|
c3c3ed8ecf | ||
|
|
0103b99651 | ||
|
|
b8c100823f | ||
|
|
c7819cd96e | ||
|
|
17aa37c64f | ||
|
|
045f24ef85 |
No files matched your search
@@ -5,7 +5,7 @@
|
||||
|
||||

|
||||
|
||||
[简体中文](./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
|
||||
|
||||
[](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
|
||||
|
||||
[](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. |
|
||||
@@ -0,0 +1,49 @@
|
||||
# Komari
|
||||
|
||||

|
||||
[](https://deepwiki.com/komari-monitor/komari)
|
||||
|
||||

|
||||
|
||||
[简体中文](./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://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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
# Komari
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
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://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 スポンサー
|
||||
|
||||
[](https://sharon.io)
|
||||
|
||||
SharonNetworks は、あなたのビジネスの離陸を力強くサポートします!
|
||||
|
||||
アジア太平洋のデータセンターから中国最適化ネットワーク接続を提供。低レイテンシー & 高帯域幅、Tbps 級のローカル洗浄 DDoS 防御で、ビジネスとお客様の体験を守ります。コミュニティ [Telegram グループ](https://t.me/SharonNetwork) に参加すると、チャリティまたはグループ内抽選で無料利用のチャンスがあります。
|
||||
|
||||
### オープンソースコミュニティ
|
||||
|
||||
PR を送ってくれた方、テーマを作成してくれた全ての開発者
|
||||
|
||||
—— そして:こんなに暇でいられる自分に感謝
|
||||
|
||||
## Star履歴
|
||||
|
||||
[](https://www.star-history.com/#komari-monitor/komari&Date)
|
||||
@@ -1,141 +0,0 @@
|
||||
# Komari
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
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://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 贊助
|
||||
|
||||
[](https://sharon.io)
|
||||
|
||||
SharonNetworks 為您的業務起飛保駕護航!
|
||||
|
||||
亞太資料中心提供頂級的中國優化網路接入 · 低延遲 & 高頻寬 & 提供 Tbps 級本地清洗高防服務,為您的業務保駕護航,為您的客戶提供極致體驗。加入社群 [Telegram 群組](https://t.me/SharonNetwork) 可參與公益募捐或群內抽獎免費使用。
|
||||
|
||||
### 開源社群
|
||||
|
||||
提交 PR、製作主題的各位開發者
|
||||
|
||||
—— 以及:感謝我自己能這麼閒
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#komari-monitor/komari&Date)
|
||||
@@ -1,141 +0,0 @@
|
||||
# Komari
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
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://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 赞助
|
||||
|
||||
[](https://sharon.io)
|
||||
|
||||
SharonNetworks 为您的业务起飞保驾护航!
|
||||
|
||||
亚太数据中心提供顶级的中国优化网络接入 · 低延时 & 高带宽 & 提供 Tbps 级本地清洗高防服务,为您的业务保驾护航,为您的客户提供极致体验。加入社区 [Telegram 群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用。
|
||||
|
||||
### 开源社区
|
||||
|
||||
提交 PR、制作主题的各位开发者
|
||||
|
||||
—— 以及:感谢我自己能这么闲
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#komari-monitor/komari&Date)
|
||||
@@ -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(...)
|
||||
```
|
||||
|
||||
即使上报失败,也不影响页面功能。
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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, ×tamp, &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
|
||||
}
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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)},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+124
-9
@@ -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
@@ -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。
|
||||
|
||||
@@ -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
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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():
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(¶ms); 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
Reference in new issue
Block a user