Compare commits

..
13 Commits
Author SHA1 Message Date
Akizon77 2f70b44040 fix(rpc): common:getNodes returns uuid-keyed map instead of array
Each value still contains its own uuid field. Single-uuid query keeps returning a single object.
2026-07-20 20:27:24 +08:00
Akizon 5d26f1b18a Update generate-release-notes.yml 2026-07-20 09:53:15 +08:00
Akizon77 80cc39b02d Include commits in release notes 2026-07-20 09:53:15 +08:00
Akizon77 11c65ddbd0 Use stable release base for snapshot notes 2026-07-20 09:53:15 +08:00
Akizon77 ad74b57ecc Read release note workflow inputs 2026-07-20 09:53:15 +08:00
Akizon77 e13f79577d Improve release notes generation 2026-07-20 09:53:14 +08:00
Akizon77 832b80054f ci: generate AI-assisted release notes from stable tags 2026-07-20 09:53:14 +08:00
Akizon77 09d70996d3 ci: reuse frontend and toolchain actions in workflows
Extract repeated frontend build, frontend restore, and Zig setup steps into shared GitHub Actions so CI workflows use the same artifact and toolchain setup path. Update binary and Docker builds to depend on the frontend artifact, and require an explicit Docker dispatch version for controlled image tagging.
2026-07-20 09:53:14 +08:00
Akizon77 303f2bbcf5 Use UTC+8 for snapshot versions 2026-07-20 09:53:14 +08:00
airium 28ce225aa0 ci: align snapshot workflow behavior 2026-07-20 09:53:14 +08:00
Akizon77 683b7f652b fix: avoid sqlite database is locked failures for record writes 2026-07-20 09:53:05 +08:00
guocn a181b22071 fix(theme): prevent path traversal in theme operations
DeleteTheme/UpdateTheme/SetTheme used the user-supplied theme name
(short) directly in filepath.Join("./data/theme", short) before
filesystem operations (os.RemoveAll / os.Stat). Without validation,
a crafted short value like "../../" normalizes outside the theme
directory, letting an authenticated admin delete or access arbitrary
directories within the process working directory.

Reuse the existing isValidThemeShort validator (allowlist of
[a-zA-Z0-9_-]) in all three handlers before the path is built.
Add a regression test covering traversal payloads and absolute paths.
2026-07-20 09:53:05 +08:00
Torther d2f9261bc2 fix(telegram): include API error description in non-200 responses
SendTextMessage checked resp.StatusCode before decoding the JSON body, so non-200 responses returned only the status code without the error reason.

Move JSON decode before the status check. When the status is not 200, use the description field from the API response as the error message. Fall back to the original status code message if decode fails or description is empty.
2026-07-20 09:53:04 +08:00
208 changed files with 7730 additions and 16149 deletions

No files matched your search

+1 -8
View File
@@ -8,9 +8,6 @@ inputs:
repository:
description: komari-web repository URL.
default: https://github.com/komari-monitor/komari-web
ref:
description: Optional komari-web ref to build. Uses the repository default branch when empty.
default: ""
artifact-name:
description: Optional artifact name for the packed frontend theme.
default: ""
@@ -28,11 +25,7 @@ runs:
run: |
set -euxo pipefail
rm -rf komari-web
if [ -n "${{ inputs.ref }}" ]; then
git clone --depth=1 --branch "${{ inputs.ref }}" "${{ inputs.repository }}" komari-web
else
git clone --depth=1 "${{ inputs.repository }}" komari-web
fi
git clone --depth=1 "${{ inputs.repository }}" komari-web
cd komari-web
npm install
npm run build
-4
View File
@@ -59,10 +59,6 @@ jobs:
goarch: riscv64
zig_target_triple: riscv64-linux-musl
binary_extension: ""
- goos: linux
goarch: loong64
zig_target_triple: loongarch64-linux-musl
binary_extension: ""
exclude:
- goos: windows
-159
View File
@@ -1,159 +0,0 @@
name: Rebuild Existing Release
on:
workflow_dispatch:
inputs:
release_tag:
description: Existing GitHub Release tag whose assets and version image will be replaced.
required: true
type: string
server_ref:
description: Server commit, branch, or tag to build.
required: true
type: string
frontend_ref:
description: komari-web commit, branch, or tag to build.
required: true
type: string
permissions:
contents: write
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout workflow source
uses: actions/checkout@v4
- name: Build selected frontend artifact
uses: ./.github/actions/build-frontend
with:
artifact-name: komari-frontend
ref: ${{ inputs.frontend_ref }}
build-and-attach-binaries:
needs: build-frontend
runs-on: ubuntu-latest
strategy:
matrix:
include:
- goos: windows
goarch: amd64
zig_target_triple: x86_64-windows-gnu
binary_extension: .exe
- goos: windows
goarch: arm64
zig_target_triple: aarch64-windows-gnu
binary_extension: .exe
- goos: windows
goarch: 386
zig_target_triple: x86-windows-gnu
binary_extension: .exe
- goos: linux
goarch: amd64
zig_target_triple: x86_64-linux-musl
binary_extension: ""
- goos: linux
goarch: arm64
zig_target_triple: aarch64-linux-musl
binary_extension: ""
- goos: linux
goarch: 386
zig_target_triple: x86-linux-musl
binary_extension: ""
- goos: linux
goarch: riscv64
zig_target_triple: riscv64-linux-musl
binary_extension: ""
steps:
- name: Checkout server source
uses: actions/checkout@v4
with:
ref: ${{ inputs.server_ref }}
- name: Restore frontend artifact
uses: komari-monitor/komari/.github/actions/restore-frontend@main
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
- name: Install Zig cross-compiler toolchain
uses: komari-monitor/komari/.github/actions/setup-zig@main
- name: Build binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 1
CC: zig cc -target ${{ matrix.zig_target_triple }}
GIN_MODE: release
RELEASE_TAG: ${{ inputs.release_tag }}
run: |
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.binary_extension }}
VERSION_HASH="$(git rev-parse HEAD)"
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${RELEASE_TAG} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o "$BINARY_NAME"
- name: Upload binary to release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.release_tag }}
run: |
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.binary_extension }}
gh release upload "$RELEASE_TAG" "$BINARY_NAME" --clobber --repo "$GITHUB_REPOSITORY"
- name: Upload binary as artifact
uses: actions/upload-artifact@v4
with:
name: komari-${{ matrix.goos }}-${{ matrix.goarch }}
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.binary_extension }}
build-and-push-docker:
needs: build-and-attach-binaries
runs-on: ubuntu-latest
steps:
- name: Checkout server source
uses: actions/checkout@v4
with:
ref: ${{ inputs.server_ref }}
- name: Download Docker binaries
uses: actions/download-artifact@v4
with:
pattern: komari-linux-*
path: .
merge-multiple: true
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Build and push version Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ inputs.release_tag }}
no-cache: true
provenance: false
+1
View File
@@ -107,6 +107,7 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=${{ github.event.inputs.version }}
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v5
+1 -42
View File
@@ -13,24 +13,7 @@ env:
IMAGE_NAME: ${{ github.repository }}
jobs:
resolve-release:
runs-on: ubuntu-latest
outputs:
frontend_ref: ${{ steps.release.outputs.frontend_ref }}
steps:
- id: release
env:
TAG: ${{ github.event.release.tag_name }}
run: |
frontend_ref=""
if [[ "$TAG" =~ ^(.+)-fix[0-9]+$ ]]; then
frontend_ref="${BASH_REMATCH[1]}"
fi
echo "frontend_ref=$frontend_ref" >> "$GITHUB_OUTPUT"
build-frontend:
needs: resolve-release
runs-on: ubuntu-latest
steps:
@@ -41,7 +24,6 @@ jobs:
uses: ./.github/actions/build-frontend
with:
artifact-name: komari-frontend
ref: ${{ needs.resolve-release.outputs.frontend_ref }}
build-and-attach-binaries:
needs: build-frontend
@@ -83,10 +65,6 @@ jobs:
goarch: riscv64
zig_target_triple: riscv64-linux-musl
binary_extension: ""
- goos: linux
goarch: loong64
zig_target_triple: loongarch64-linux-musl
binary_extension: ""
exclude:
- goos: windows
@@ -173,20 +151,6 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Check whether this is the latest GitHub release
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.event.release.tag_name }}
run: |
is_latest="$(gh api graphql \
-f 'query=query($owner: String!, $name: String!, $tag: String!) { repository(owner: $owner, name: $name) { release(tagName: $tag) { isLatest } } }' \
-F owner="${{ github.repository_owner }}" \
-F name="${{ github.event.repository.name }}" \
-F tag="$TAG" \
--jq '.data.repository.release.isLatest')"
echo "is_latest=$is_latest" >> "$GITHUB_OUTPUT"
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
@@ -194,6 +158,7 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=${{ github.event.release.tag_name }}
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v5
@@ -205,9 +170,3 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
no-cache: true
provenance: false
- name: Promote Docker latest tag
if: steps.release.outputs.is_latest == 'true'
env:
TAG: ${{ github.event.release.tag_name }}
run: docker buildx imagetools create --tag "${REGISTRY}/${IMAGE_NAME}:latest" "${REGISTRY}/${IMAGE_NAME}:${TAG}"
-4
View File
@@ -112,10 +112,6 @@ jobs:
goarch: riscv64
zig_target_triple: riscv64-linux-musl
binary_extension: ""
- goos: linux
goarch: loong64
zig_target_triple: loongarch64-linux-musl
binary_extension: ""
exclude:
- goos: windows
+18 -1
View File
@@ -8,12 +8,29 @@ ARG TARGETARCH
RUN apk add --no-cache ca-certificates curl tzdata
COPY komari-${TARGETOS}-${TARGETARCH} /app/komari
RUN set -eux; \
case "${TARGETARCH}" in \
amd64) cloudflared_arch="amd64" ;; \
386) cloudflared_arch="386" ;; \
arm64) cloudflared_arch="arm64" ;; \
arm) cloudflared_arch="arm" ;; \
*) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
esac; \
curl -fsSL "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${cloudflared_arch}" -o /usr/local/bin/cloudflared; \
chmod +x /usr/local/bin/cloudflared
COPY komari-${TARGETOS}-${TARGETARCH} /app/komari
RUN chmod +x /app/komari
ENV GIN_MODE=release
ENV KOMARI_DB_TYPE=sqlite
ENV KOMARI_DB_FILE=/app/data/komari.db
ENV KOMARI_DB_HOST=localhost
ENV KOMARI_DB_PORT=3306
ENV KOMARI_DB_USER=root
ENV KOMARI_DB_PASS=
ENV KOMARI_DB_NAME=komari
ENV KOMARI_LISTEN=0.0.0.0:25774
EXPOSE 25774
+9 -2
View File
@@ -58,7 +58,14 @@ sudo ./install-komari.sh
--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. View the default username and password:
```bash
docker logs komari
```
4. Access `http://<your_server_ip>:25774` in your browser.
> [!NOTE]
> You can also customize the initial username and password through the environment variables `ADMIN_USERNAME` and `ADMIN_PASSWORD`.
### 3. Binary File Deployment
@@ -68,7 +75,7 @@ sudo ./install-komari.sh
./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.
4. The default username and password can be found in the startup logs or set via the environment variables `ADMIN_USERNAME` and `ADMIN_PASSWORD`.
> [!NOTE]
> Ensure the binary has execute permissions (`chmod +x komari`). Data will be saved in the `data` folder in the running directory.
+5
View File
@@ -10,6 +10,11 @@ var (
// 数据库配置
DatabaseType string // 数据库类型:sqlite
DatabaseFile string // SQLite数据库文件路径
DatabaseHost string // 保留的兼容参数,当前未使用
DatabasePort string // 保留的兼容参数,当前未使用
DatabaseUser string // 保留的兼容参数,当前未使用
DatabasePass string // 保留的兼容参数,当前未使用
DatabaseName string // 保留的兼容参数,当前未使用
Listen string
)
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"os"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/pkg/config"
"github.com/spf13/cobra"
)
+22 -2
View File
@@ -17,6 +17,17 @@ func GetEnv(key, defaultValue string) string {
return value
}
// 从环境变量获取默认值
var (
dbTypeEnv = GetEnv("KOMARI_DB_TYPE", "sqlite")
dbFileEnv = GetEnv("KOMARI_DB_FILE", "./data/komari.db")
dbHostEnv = GetEnv("KOMARI_DB_HOST", "localhost")
dbPortEnv = GetEnv("KOMARI_DB_PORT", "3306")
dbUserEnv = GetEnv("KOMARI_DB_USER", "root")
dbPassEnv = GetEnv("KOMARI_DB_PASS", "")
dbNameEnv = GetEnv("KOMARI_DB_NAME", "komari")
)
var RootCmd = &cobra.Command{
Use: "Komari",
Short: "Komari is a simple server monitoring tool",
@@ -36,6 +47,15 @@ func Execute() {
}
func init() {
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseType, "db-type", "t", "sqlite", "Database type (sqlite)")
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseFile, "database", "d", "./data/komari.db", "SQLite database file path")
// 设置命令行参数,提供环境变量作为默认值
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseType, "db-type", "t", dbTypeEnv, "Database type (sqlite) [env: KOMARI_DB_TYPE]")
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseFile, "database", "d", dbFileEnv, "SQLite database file path [env: KOMARI_DB_FILE]")
RootCmd.PersistentFlags().StringVar(&flags.DatabaseHost, "db-host", dbHostEnv, "Reserved database host parameter [env: KOMARI_DB_HOST]")
RootCmd.PersistentFlags().StringVar(&flags.DatabasePort, "db-port", dbPortEnv, "Reserved database port parameter [env: KOMARI_DB_PORT]")
RootCmd.PersistentFlags().StringVar(&flags.DatabaseUser, "db-user", dbUserEnv, "Reserved database username parameter [env: KOMARI_DB_USER]")
RootCmd.PersistentFlags().StringVar(&flags.DatabasePass, "db-pass", dbPassEnv, "Reserved database password parameter [env: KOMARI_DB_PASS]")
RootCmd.PersistentFlags().StringVar(&flags.DatabaseName, "db-name", dbNameEnv, "Reserved database name parameter [env: KOMARI_DB_NAME]")
for _, name := range []string{"db-host", "db-port", "db-user", "db-pass", "db-name"} {
_ = RootCmd.PersistentFlags().MarkHidden(name)
}
}
+205 -73
View File
@@ -1,10 +1,40 @@
package cmd
import (
appserver "github.com/komari-monitor/komari/internal/server"
logger "github.com/komari-monitor/komari/utils/log"
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/pkg/corn"
"github.com/komari-monitor/komari/web/api"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
d_notification "github.com/komari-monitor/komari/database/notification"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/cloudflared"
"github.com/komari-monitor/komari/utils/geoip"
logutil "github.com/komari-monitor/komari/utils/log"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/notifier"
"github.com/komari-monitor/komari/web/nezha"
"github.com/komari-monitor/komari/web/oauth"
report_cache "github.com/komari-monitor/komari/web/report"
"github.com/komari-monitor/komari/web/router"
"github.com/komari-monitor/komari/web/security"
"github.com/spf13/cobra"
)
@@ -24,82 +54,184 @@ func init() {
RootCmd.AddCommand(ServerCmd)
}
// RunServer 按显式的生命周期阶段启动服务端。
//
// 具体各阶段的职责与顺序见 internal/server.App。这里只负责串联:
// 任一初始化阶段失败即中止启动,避免在半初始化状态下对外提供服务。
func RunServer() {
app := appserver.New(appserver.Options{ListenAddr: flags.Listen})
if err := app.Bootstrap(); err != nil {
_ = app.Shutdown()
logger.Fatalf("server", "server startup failed at %q: %v", "bootstrap", err)
// #region 初始化
if err := os.MkdirAll("./data/theme", os.ModePerm); err != nil {
log.Fatalf("Failed to create theme directory: %v", err)
}
installRequired, err := app.InstallRequired()
InitDatabase()
if utils.VersionHash != "unknown" {
gin.SetMode(gin.ReleaseMode)
}
conf, err := config.GetManyAs[config.Settings]()
if err != nil {
_ = app.Shutdown()
logger.Fatalf("server", "server startup failed at %q: %v", "detect-first-run-install", err)
log.Fatal(err)
}
if installRequired {
completed, err := app.RunInstallGuide()
go geoip.InitGeoIp()
go DoScheduledWork()
go messageSender.Initialize()
// oidcInit
go oauth.Initialize()
if conf.NezhaCompatEnabled {
go func() {
if err := nezha.StartNezhaCompat(conf.NezhaCompatListen); err != nil {
log.Printf("Nezha compat server error: %v", err)
auditlog.EventLog("error", fmt.Sprintf("Nezha compat server error: %v", err))
}
}()
}
config.Subscribe(func(event config.ConfigEvent) {
if ok, t := config.IsChangedT[string](event, config.OAuthProviderKey); ok {
if t == "" || t == "none" {
t = "github"
}
oidcProvider, err := database.GetOidcConfigByName(t)
if err != nil {
log.Printf("Failed to get OIDC provider config: %v", err)
} else {
log.Printf("Using %s as OIDC provider", oidcProvider.Name)
}
err = oauth.LoadProvider(oidcProvider.Name, oidcProvider.Addition)
if err != nil {
auditlog.EventLog("error", fmt.Sprintf("Failed to load OIDC provider: %v", err))
}
}
if ok, t := config.IsChangedT[bool](event, config.NezhaCompatEnabledKey); ok {
if t {
l, _ := config.GetAs[string](config.NezhaCompatListenKey)
if err := nezha.StartNezhaCompat(l); err != nil {
log.Printf("start Nezha compat server error: %v", err)
auditlog.EventLog("error", fmt.Sprintf("start Nezha compat server error: %v", err))
}
} else {
if err := nezha.StopNezhaCompat(); err != nil {
log.Printf("stop Nezha compat server error: %v", err)
auditlog.EventLog("error", fmt.Sprintf("stop Nezha compat server error: %v", err))
}
}
}
})
// 初始化 cloudflared
if err := cloudflared.AutoStart(GetEnv("KOMARI_CLOUDFLARED_TOKEN", "")); err != nil {
log.Printf("failed to auto start cloudflared: %v", err)
}
r := gin.New()
r.Use(logutil.GinLogger())
r.Use(logutil.GinRecovery())
config.Subscribe(func(event config.ConfigEvent) {
if event.IsChanged(config.GeoIpProviderKey) {
go geoip.InitGeoIp()
}
if event.IsChanged(config.NotificationMethodKey) {
go messageSender.Initialize()
}
})
r.Use(security.CorsMiddleware(conf.CorsOriginCheckEnabled, conf.CorsAllowedOrigins))
r.Use(api.IdentityMiddleware())
r.Use(api.PrivateSiteMiddleware())
r.Use(func(c *gin.Context) {
if len(c.Request.URL.Path) >= 4 && c.Request.URL.Path[:4] == "/api" {
c.Header("Cache-Control", "no-store")
}
c.Next()
})
router.Register(r)
srv := &http.Server{
Addr: flags.Listen,
Handler: r,
}
log.Printf("Starting server on %s ...", flags.Listen)
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
OnFatal(err)
log.Fatalf("listen: %s\n", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
OnShutdown()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
}
func InitDatabase() {
var count int64 = 0
if dbcore.GetDBInstance().Model(&models.User{}).Count(&count); count == 0 {
user, passwd, err := accounts.CreateDefaultAdminAccount()
if err != nil {
_ = app.Shutdown()
logger.Fatalf("server", "server startup failed at %q: %v", "run-first-run-install", err)
panic(err)
}
if !completed {
return
}
}
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)
if err != nil {
_ = app.Shutdown()
logger.Fatalf("server", "server startup failed at %q: %v", "run-1.2.7-upgrade", err)
}
if !completed {
return
}
}
// Metric store 是唯一允许进入恢复向导的启动阶段:主库已经在
// Bootstrap 中就绪,因此可以保留登录能力并让管理员修正 DSN。
if err := app.ConnectMetricStoreWithRetry(); err != nil {
completed, recoveryErr := app.RunMetricStoreRecovery(err)
if recoveryErr != nil {
_ = app.Shutdown()
logger.Fatalf("server", "server startup failed at %q: %v", "metric-store-recovery", recoveryErr)
}
if !completed {
return
}
}
// 其余初始化阶段:任一步失败都不应继续对外服务。
type stage struct {
name string
fn func() error
}
stages := []stage{
{"init-stores", app.InitStores},
{"init-providers", app.InitProviders},
{"start-background", app.StartBackground},
{"build-router", app.BuildRouter},
}
for _, s := range stages {
if err := s.fn(); err != nil {
// 已登记的资源尽力回收,再退出。
_ = app.Shutdown()
logger.Fatalf("server", "server startup failed at %q: %v", s.name, err)
}
}
if err := app.Run(); err != nil {
logger.Fatalf("server", "server exited with error: %v", err)
log.Println("Default admin account created. Username:", user, ", Password:", passwd)
}
}
// #region 定时任务
func DoScheduledWork() {
if err := tasks.ReloadPingSchedule(); err != nil {
log.Println("Failed to reload ping schedule:", err)
}
if err := d_notification.ReloadLoadNotificationSchedule(); err != nil {
log.Println("Failed to reload load notification schedule:", err)
}
records.CompactRecord()
if err := corn.AddFunc("records:cleanup", "@every 30m", cleanupScheduledData); err != nil {
log.Println("Failed to add cleanup scheduled task:", err)
}
if err := corn.AddFunc("records:minute", "@every 1m", minuteScheduledWork); err != nil {
log.Println("Failed to add minute scheduled task:", err)
}
if err := corn.AddFunc("notifier:expire", "0 0 9 * * *", notifier.CheckExpireScheduledWork); err != nil {
log.Println("Failed to add expire notification scheduled task:", err)
}
notifier.InitTrafficReportSchedule()
}
func cleanupScheduledData() {
cfg, _ := config.GetManyAs[config.Settings]()
records.DeleteRecordBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
records.CompactRecord()
tasks.ClearTaskResultsByTimeBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
tasks.DeletePingRecordsBefore(time.Now().Add(-time.Hour * time.Duration(cfg.PingRecordPreserveTime)))
auditlog.RemoveOldLogs()
accounts.RemoveExpiredSessions()
}
func minuteScheduledWork() {
cfg, _ := config.GetManyAs[config.Settings]()
report_cache.SaveClientReportToDB()
if !cfg.RecordEnabled {
records.DeleteAll()
tasks.DeleteAllPingRecords()
}
// 每分钟检查一次流量提醒
notifier.CheckTraffic()
}
func OnShutdown() {
auditlog.Log("", "", "server is shutting down", "info")
corn.StopAll()
cloudflared.Shutdown()
}
func OnFatal(err error) {
auditlog.Log("", "", "server encountered a fatal error: "+err.Error(), "error")
cloudflared.Shutdown()
}
+40 -11
View File
@@ -4,12 +4,14 @@ import (
"crypto/sha256"
"encoding/base64"
"fmt"
"os"
"time"
"github.com/google/uuid"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"gorm.io/gorm"
"github.com/komari-monitor/komari/utils"
"github.com/google/uuid"
)
const constantSalt = "06Wm4Jv1Hkxx"
@@ -54,10 +56,7 @@ func hashPasswd(passwd string) string {
}
func CreateAccount(username, passwd string) (user models.User, err error) {
return CreateAccountWithDB(dbcore.GetDBInstance(), username, passwd)
}
func CreateAccountWithDB(db *gorm.DB, username, passwd string) (user models.User, err error) {
db := dbcore.GetDBInstance()
hashedPassword := hashPasswd(passwd)
user = models.User{
UUID: uuid.New().String(),
@@ -72,10 +71,7 @@ func CreateAccountWithDB(db *gorm.DB, username, passwd string) (user models.User
}
func DeleteAccountByUsername(username string) (err error) {
return DeleteAccountByUsernameWithDB(dbcore.GetDBInstance(), username)
}
func DeleteAccountByUsernameWithDB(db *gorm.DB, username string) (err error) {
db := dbcore.GetDBInstance()
err = db.Where("username = ?", username).Delete(&models.User{}).Error
if err != nil {
return err
@@ -83,6 +79,39 @@ func DeleteAccountByUsernameWithDB(db *gorm.DB, username string) (err error) {
return nil
}
// 创建默认管理员账户,使用环境变量 ADMIN_USERNAME 作为用户名,环境变量 ADMIN_PASSWORD 作为密码
func CreateDefaultAdminAccount() (username, passwd string, err error) {
db := dbcore.GetDBInstance()
username = os.Getenv("ADMIN_USERNAME")
if username == "" {
username = "admin"
}
passwd = os.Getenv("ADMIN_PASSWORD")
if passwd == "" {
passwd = utils.GeneratePassword()
}
hashedPassword := hashPasswd(passwd)
user := models.User{
UUID: uuid.New().String(),
Username: username,
Passwd: hashedPassword,
SSOID: "",
CreatedAt: models.FromTime(time.Now()),
UpdatedAt: models.FromTime(time.Now()),
}
err = db.Create(&user).Error
if err != nil {
return "", "", err
}
return username, passwd, nil
}
func GetUserByUUID(uuid string) (user models.User, err error) {
db := dbcore.GetDBInstance()
err = db.Where("uuid = ?", uuid).First(&user).Error
@@ -142,7 +171,7 @@ func UpdateUser(uuid string, name, password, sso_type *string) error {
if sso_type != nil {
updates["sso_type"] = *sso_type
}
updates["updated_at"] = time.Now().UTC()
updates["updated_at"] = time.Now()
err := db.Model(&models.User{}).Where("uuid = ?", uuid).Updates(updates).Error
if err != nil {
return err
+21 -7
View File
@@ -9,7 +9,7 @@ import (
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/messageSender"
@@ -33,11 +33,11 @@ func CreateSession(uuid string, expires int, userAgent, ip, login_method string)
sessionRecord := models.Session{
UUID: uuid,
Session: session,
Expires: time.Now().UTC().Add(time.Duration(expires) * time.Second),
Expires: models.FromTime(time.Now().Add(time.Duration(expires) * time.Second)),
UserAgent: userAgent,
Ip: ip,
LoginMethod: login_method,
LatestOnline: time.Now().UTC(),
LatestOnline: models.FromTime(time.Now()),
}
go func() {
LoginNotification, _ := config.GetAs[bool](config.LoginNotificationKey, false)
@@ -50,7 +50,7 @@ func CreateSession(uuid string, expires int, userAgent, ip, login_method string)
}
messageSender.SendEvent(models.EventMessage{
Event: messageevent.Login,
Time: time.Now().UTC(),
Time: time.Now(),
Message: fmt.Sprintf("%s: %s (%s)\n%s", login_method, ip, loc, userAgent),
Emoji: "🔑",
})
@@ -73,7 +73,7 @@ func GetSession(session string) (uuid string, err error) {
return "", err
}
if time.Now().UTC().After(sessionRecord.Expires) {
if time.Now().After(sessionRecord.Expires.ToTime()) {
// 会话已过期,删除它
_ = DeleteSession(session)
return "", errors.New("session expired")
@@ -111,10 +111,24 @@ func DeleteAllSessions() error {
return nil
}
func UpdateLatestOnline(session string) error {
db := dbcore.GetDBInstance()
return db.Model(&models.Session{}).Where("session = ?", session).Update("latest_online", time.Now()).Error
}
func UpdateLatestUserAgent(session, userAgent string) error {
db := dbcore.GetDBInstance()
return db.Model(&models.Session{}).Where("session = ?", session).Update("latest_user_agent", userAgent).Error
}
func UpdateLatestIp(session, ip string) error {
db := dbcore.GetDBInstance()
return db.Model(&models.Session{}).Where("session = ?", session).Update("latest_ip", ip).Error
}
func UpdateLatest(session, useragent, ip string) error {
db := dbcore.GetDBInstance()
return db.Model(&models.Session{}).Where("session = ?", session).Updates(map[string]interface{}{
"latest_online": time.Now().UTC(),
"latest_online": time.Now(),
"latest_user_agent": useragent,
"latest_ip": ip,
}).Error
@@ -122,7 +136,7 @@ func UpdateLatest(session, useragent, ip string) error {
func RemoveExpiredSessions() error {
db := dbcore.GetDBInstance()
result := db.Where("expires < ?", time.Now().UTC()).Delete(&models.Session{})
result := db.Where("expires < ?", time.Now()).Delete(&models.Session{})
if result.Error != nil {
return result.Error
}
+6 -7
View File
@@ -1,7 +1,7 @@
package auditlog
import (
logger "github.com/komari-monitor/komari/utils/log"
"log"
"time"
"github.com/komari-monitor/komari/database/dbcore"
@@ -9,17 +9,16 @@ import (
)
func Log(ip, uuid, message, msgType string) {
now := time.Now()
db := dbcore.GetDBInstance()
logEntry := &models.Log{
IP: ip,
UUID: uuid,
Message: message,
MsgType: msgType,
Time: time.Now().UTC(),
}
if err := db.Create(logEntry).Error; err != nil {
logger.Error("audit", "failed to persist audit event", "error", err, "type", msgType)
Time: models.FromTime(now),
}
db.Create(logEntry)
}
func EventLog(eventType, message string) {
@@ -29,8 +28,8 @@ func EventLog(eventType, message string) {
// Delete logs older than 30 days
func RemoveOldLogs() {
db := dbcore.GetDBInstance()
threshold := time.Now().UTC().AddDate(0, 0, -30)
threshold := time.Now().AddDate(0, 0, -30)
if err := db.Where("time < ?", threshold).Delete(&models.Log{}).Error; err != nil {
logger.ErrorArgs("audit", "Failed to remove old logs:", err)
log.Println("Failed to remove old logs:", err)
}
}
+41 -31
View File
@@ -3,7 +3,7 @@ package clients
import (
"encoding/json"
"fmt"
logger "github.com/komari-monitor/komari/utils/log"
"log"
"math"
"time"
@@ -11,6 +11,7 @@ import (
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/utils"
"gorm.io/gorm"
"github.com/google/uuid"
)
@@ -36,7 +37,7 @@ func SaveClientInfo(update map[string]interface{}) error {
return fmt.Errorf("no fields to update")
}
update["updated_at"] = time.Now().UTC()
update["updated_at"] = time.Now()
toFloat64 := func(value interface{}) (float64, bool) {
switch typed := value.(type) {
@@ -121,6 +122,24 @@ func SaveClientInfo(update map[string]interface{}) error {
return nil
}
func EditClientName(clientUUID, clientName string) error {
db := dbcore.GetDBInstance()
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Update("name", clientName).Error
if err != nil {
return err
}
return nil
}
func EditClientToken(clientUUID, token string) error {
db := dbcore.GetDBInstance()
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Update("token", token).Error
if err != nil {
return err
}
return nil
}
// CreateClient 创建新客户端
func CreateClient() (clientUUID, token string, err error) {
db := dbcore.GetDBInstance()
@@ -131,8 +150,8 @@ func CreateClient() (clientUUID, token string, err error) {
UUID: clientUUID,
Token: token,
Name: "client_" + clientUUID[0:8],
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
CreatedAt: models.FromTime(time.Now()),
UpdatedAt: models.FromTime(time.Now()),
}
err = db.Create(&client).Error
@@ -140,7 +159,7 @@ func CreateClient() (clientUUID, token string, err error) {
return "", "", err
}
if err := tasks.AddDefaultOnClientUUID(clientUUID); err != nil {
logger.ErrorArgs("clients", "Failed to apply default-on ping tasks to new client:", err)
log.Println("Failed to apply default-on ping tasks to new client:", err)
}
return clientUUID, token, nil
}
@@ -156,8 +175,8 @@ func CreateClientWithName(name string) (clientUUID, token string, err error) {
UUID: clientUUID,
Token: token,
Name: name,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
CreatedAt: models.FromTime(time.Now()),
UpdatedAt: models.FromTime(time.Now()),
}
err = db.Create(&client).Error
@@ -165,7 +184,7 @@ func CreateClientWithName(name string) (clientUUID, token string, err error) {
return "", "", err
}
if err := tasks.AddDefaultOnClientUUID(clientUUID); err != nil {
logger.ErrorArgs("clients", "Failed to apply default-on ping tasks to new client:", err)
log.Println("Failed to apply default-on ping tasks to new client:", err)
}
return clientUUID, token, nil
}
@@ -191,6 +210,19 @@ func GetClientByUUID(uuid string) (client models.Client, err error) {
return client, nil
}
// GetClientBasicInfo 获取指定 UUID 的客户端基本信息
func GetClientBasicInfo(uuid string) (client models.Client, err error) {
db := dbcore.GetDBInstance()
err = db.Where("uuid = ?", uuid).First(&client).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return models.Client{}, fmt.Errorf("客户端不存在: %s", uuid)
}
return models.Client{}, err
}
return client, nil
}
func GetClientTokenByUUID(uuid string) (token string, err error) {
db := dbcore.GetDBInstance()
var client models.Client
@@ -229,30 +261,8 @@ func SaveClient(updates map[string]interface{}) error {
}
}
}
if value, exists := updates["expired_at"]; exists {
switch typed := value.(type) {
case nil:
updates["expired_at"] = nil
case time.Time:
updates["expired_at"] = typed.UTC()
case *time.Time:
if typed == nil {
updates["expired_at"] = nil
} else {
updates["expired_at"] = typed.UTC()
}
case string:
stamp, err := time.Parse(time.RFC3339Nano, typed)
if err != nil {
return fmt.Errorf("expired_at must be an RFC3339 timestamp with a timezone: %w", err)
}
updates["expired_at"] = stamp.UTC()
default:
return fmt.Errorf("expired_at must be an RFC3339 timestamp with a timezone")
}
}
updates["updated_at"] = time.Now().UTC()
updates["updated_at"] = time.Now()
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(updates).Error
if err != nil {
+193
View File
@@ -1,14 +1,66 @@
package clients
import (
"encoding/json"
"errors"
"fmt"
"math"
"sync"
"time"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
v1 "github.com/komari-monitor/komari/protocol/v1"
"github.com/komari-monitor/komari/utils"
"gorm.io/gorm"
)
var reportSaveLocks sync.Map
func getReportSaveLock(clientUUID string) *sync.Mutex {
lock, _ := reportSaveLocks.LoadOrStore(clientUUID, &sync.Mutex{})
return lock.(*sync.Mutex)
}
func getLatestTrafficRecord(tx *gorm.DB, clientUUID string) (*models.Record, error) {
var record models.Record
err := tx.Where("client = ?", clientUUID).Order("time DESC").First(&record).Error
if err == nil {
return &record, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
err = tx.Table("records_long_term").Where("client = ?", clientUUID).Order("time DESC").First(&record).Error
if err == nil {
return &record, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
return nil, nil
}
// Report 表示客户端报告数据
// SaveReport 保存报告数据
func SaveReport(uuid string, data map[string]interface{}) (err error) {
report, err := ParseReport(data)
if err != nil {
return err
}
err = SaveClientReport(uuid, report)
if err != nil {
return err
}
return nil
}
func GetClientUUIDByToken(token string) (clientUUID string, err error) {
db := dbcore.GetDBInstance()
var client models.Client
@@ -19,6 +71,18 @@ func GetClientUUIDByToken(token string) (clientUUID string, err error) {
return client.UUID, nil
}
func ParseReport(data map[string]interface{}) (report v1.Report, err error) {
jsonData, err := json.Marshal(data)
if err != nil {
return v1.Report{}, err
}
err = json.Unmarshal(jsonData, &report)
if err != nil {
return v1.Report{}, err
}
return report, nil
}
// 检查数据防止异常数据导致数据库损坏
func ReportVerify(report v1.Report) error {
// 防止输入不合理范围
@@ -102,3 +166,132 @@ func ReportVerify(report v1.Report) error {
}
return nil
}
// SaveClientReport 保存客户端报告到 Record 表
func SaveClientReport(clientUUID string, report v1.Report) (err error) {
db := dbcore.GetDBInstance()
lock := getReportSaveLock(clientUUID)
lock.Lock()
defer lock.Unlock()
if err := ReportVerify(report); err != nil {
return fmt.Errorf("failed to save Record: %w", err)
}
// 保存GPU详细记录到独立表
currentTime := time.Now()
if report.GPU != nil && len(report.GPU.DetailedInfo) > 0 {
for idx, gpu := range report.GPU.DetailedInfo {
gpuRecord := models.GPURecord{
Client: clientUUID,
Time: models.FromTime(currentTime),
DeviceIndex: idx,
DeviceName: gpu.Name,
MemTotal: gpu.MemoryTotal,
MemUsed: gpu.MemoryUsed,
Utilization: float32(gpu.Utilization),
Temperature: gpu.Temperature,
}
if err := db.Create(&gpuRecord).Error; err != nil {
return fmt.Errorf("failed to save GPU record: %w", err)
}
}
}
// 计算平均GPU使用率,用于向后兼容
averageGPUUsage := float32(0)
if report.GPU != nil && len(report.GPU.DetailedInfo) > 0 {
averageGPUUsage = float32(report.GPU.AverageUsage)
}
Record := models.Record{
Client: clientUUID,
Time: models.FromTime(currentTime),
Cpu: float32(report.CPU.Usage),
Gpu: averageGPUUsage, // 使用平均GPU使用率
Ram: report.Ram.Used,
RamTotal: report.Ram.Total,
Swap: report.Swap.Used,
SwapTotal: report.Swap.Total,
Load: float32(report.Load.Load1), // 使用 Load1 作为主要负载指标
Temp: 0, // Report 未提供 TEMP,设为 0(与原 nil 行为类似)
Disk: report.Disk.Used,
DiskTotal: report.Disk.Total,
NetIn: report.Network.Down,
NetOut: report.Network.Up,
NetTotalUp: report.Network.TotalUp,
NetTotalDown: report.Network.TotalDown,
TrafficUp: 0,
TrafficDown: 0,
Process: report.Process,
Connections: report.Connections.TCP,
ConnectionsUdp: report.Connections.UDP,
//Uptime: report.Uptime,
}
// 使用事务确保 Record 和 ClientsInfo 一致性
err = db.Transaction(func(tx *gorm.DB) error {
previous, err := getLatestTrafficRecord(tx, clientUUID)
if err != nil {
return fmt.Errorf("failed to load previous Record: %w", err)
}
if previous != nil {
Record.TrafficUp = utils.ComputeTrafficDelta(report.Network.TotalUp, previous.NetTotalUp)
Record.TrafficDown = utils.ComputeTrafficDelta(report.Network.TotalDown, previous.NetTotalDown)
}
// 保存 Record
if err := tx.Create(&Record).Error; err != nil {
return fmt.Errorf("failed to save Record: %w", err)
}
return nil
})
if err != nil {
return err
}
return nil
}
/*
// getString 从 map 中获取字符串
func getString(data map[string]interface{}, key string) string {
if val, ok := data[key]; ok {
if str, ok := val.(string); ok {
return str
}
}
return ""
}
// getInt 从 map 中获取整数
func getInt(data map[string]interface{}, key string) int {
if val, ok := data[key]; ok {
if num, ok := val.(float64); ok {
return int(num)
}
}
return 0
}
// getInt64 从 map 中获取 int64
func getInt64(data map[string]interface{}, key string) int64 {
if val, ok := data[key]; ok {
if num, ok := val.(float64); ok {
return int64(num)
}
}
return 0
}
// getFloat 从 map 中获取 float64
func getFloat(data map[string]interface{}, key string) float64 {
if val, ok := data[key]; ok {
if num, ok := val.(float64); ok {
return num
}
}
return 0.0
}
*/
+83
View File
@@ -0,0 +1,83 @@
package clients
import (
"sync"
"testing"
"time"
"github.com/komari-monitor/komari/utils"
"github.com/stretchr/testify/assert"
)
func TestComputeTrafficDeltaHandlesZeroAndReset(t *testing.T) {
tests := []struct {
name string
current int64
previous int64
want int64
}{
{name: "previous zero counts current delta", current: 120, previous: 0, want: 120},
{name: "monotonic counter uses difference", current: 250, previous: 200, want: 50},
{name: "counter reset uses current", current: 15, previous: 250, want: 15},
{name: "negative previous remains guarded", current: 15, previous: -1, want: 0},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assert.Equal(t, test.want, utils.ComputeTrafficDelta(test.current, test.previous))
})
}
}
func TestReportSaveLockSerializesSameClient(t *testing.T) {
clientUUID := "client-lock-same"
lock := getReportSaveLock(clientUUID)
lock.Lock()
defer lock.Unlock()
acquired := make(chan struct{})
go func() {
secondLock := getReportSaveLock(clientUUID)
secondLock.Lock()
defer secondLock.Unlock()
close(acquired)
}()
select {
case <-acquired:
t.Fatal("same-client report save lock was acquired while already held")
case <-time.After(20 * time.Millisecond):
}
lock.Unlock()
select {
case <-acquired:
case <-time.After(time.Second):
t.Fatal("same-client report save lock was not released")
}
lock.Lock()
}
func TestReportSaveLockAllowsDifferentClients(t *testing.T) {
firstLock := getReportSaveLock("client-lock-a")
firstLock.Lock()
defer firstLock.Unlock()
var waitGroup sync.WaitGroup
waitGroup.Add(1)
acquired := make(chan struct{})
go func() {
defer waitGroup.Done()
secondLock := getReportSaveLock("client-lock-b")
secondLock.Lock()
defer secondLock.Unlock()
close(acquired)
}()
select {
case <-acquired:
case <-time.After(time.Second):
t.Fatal("different-client report save lock should not block")
}
waitGroup.Wait()
}
-6
View File
@@ -1,8 +1,6 @@
package clipboard
import (
"time"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
)
@@ -10,8 +8,6 @@ import (
// CreateClipboard 创建剪贴板记录
func CreateClipboard(cb *models.Clipboard) error {
db := dbcore.GetDBInstance()
cb.CreatedAt = time.Now().UTC()
cb.UpdatedAt = cb.CreatedAt
return db.Create(cb).Error
}
@@ -28,8 +24,6 @@ func GetClipboardByID(id int) (*models.Clipboard, error) {
// UpdateClipboardFields 更新剪贴板记录
func UpdateClipboardFields(id int, fields map[string]interface{}) error {
db := dbcore.GetDBInstance()
delete(fields, "created_at")
fields["updated_at"] = time.Now().UTC()
return db.Model(&models.Clipboard{}).Where("id = ?", id).Updates(fields).Error
}
+147 -291
View File
@@ -4,6 +4,7 @@ import (
"archive/zip"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
@@ -12,9 +13,9 @@ import (
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/internal/migrations"
logger "github.com/komari-monitor/komari/utils/log"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/pkg/migrations"
logutil "github.com/komari-monitor/komari/utils/log"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
@@ -167,101 +168,8 @@ func unzipToDir(zipPath, dstDir string) error {
var (
instance *gorm.DB
once sync.Once
initErr error
)
// SystemVersionKey 是记录“上次启动所用版本标识”的配置键(存于 configs 表)。
// 取代旧的 ./data/.komari-version 文件:版本标识随配置库一起备份/恢复,
// 也避免额外的裸文件依赖。
const SystemVersionKey = "system_version"
// versionID 是当前构建的版本标识,由 SetVersionID 在 Initialize 前注入。
var versionID string
// dbFileExistedAtStartup 记录本次进程启动、打开数据库之前 komari.db 是否已存在,
// 用于区分“全新安装”与“从旧版升级(无版本标记)”。在 doInitialize 打开数据库
// 之前采集。
var dbFileExistedAtStartup bool
// SetVersionID 设置当前构建的版本标识(通常为 CurrentVersion+"-"+VersionHash),
// 用于版本升级检测与自动备份。应在 Initialize() 之前调用;为空则跳过升级备份。
func SetVersionID(id string) {
versionID = id
}
// resolveDatabaseFile 返回当前使用的 SQLite 数据库文件路径。
func resolveDatabaseFile() string {
dbFile := flags.DatabaseFile
if dbFile == "" {
dbFile = "./data/komari.db"
}
return dbFile
}
// backupOnVersionUpgrade 在检测到版本升级时,把当前 ./data 打包到
// ./backup/upgrade-{time}.zip,便于升级(含 metrics 迁移)异常时回滚。
//
// 版本标识存放于配置库(configs 表,键 system_version),因此本函数必须在
// config.SetDb 之后、一次性 metrics 迁移(InitStores)之前调用。
//
// 触发规则:
// - versionID 为空:跳过(未注入版本,如部分测试场景)。
// - 配置中无版本且启动前无数据库文件:全新安装,仅写版本,不备份。
// - 配置中无版本但启动前已有数据库文件:从无版本标记的旧稳定版升级,备份。
// - 配置中版本与当前不同:版本升级,备份。
// - 配置中版本与当前一致:无需备份。
//
// 备份失败不阻止启动,但打印明确错误;备份成功(或无需备份)后写入/更新版本。
func backupOnVersionUpgrade() {
if versionID == "" {
return
}
prevVersion, readErr := config.GetAs[string](SystemVersionKey)
prevVersion = strings.TrimSpace(prevVersion)
versionRecorded := readErr == nil && prevVersion != ""
// 版本未变化,无需备份。
if versionRecorded && prevVersion == versionID {
return
}
// 全新安装:配置中无版本且启动前无数据库文件,直接写版本不备份。
if !versionRecorded && !dbFileExistedAtStartup {
writeVersionMarker()
return
}
// 需要备份(升级或从旧稳定版首次带版本标记启动)。
// 先做一次 WAL checkpoint,确保 komari.db 主文件包含最新数据,
// 避免备份出的库缺少仍留在 -wal 中的写入。
if instance != nil {
instance.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
}
if err := os.MkdirAll("./backup", 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))
backupZipPath := filepath.Join(".", "data", "backup.zip")
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}}); zipErr != nil {
logger.Errorf("dbcore", "[upgrade-backup] failed to backup ./data before upgrade (from %q to %q): %v", prevVersion, versionID, zipErr)
return
}
logger.Infof("dbcore", "[upgrade-backup] ./data backed up to %s before upgrade (from %q to %q)", bakPath, prevVersion, versionID)
writeVersionMarker()
}
// writeVersionMarker 将当前 versionID 写入配置库。
func writeVersionMarker() {
if err := config.Set(SystemVersionKey, versionID); err != nil {
logger.Errorf("dbcore", "[upgrade-backup] failed to persist version marker: %v", err)
}
}
func buildSQLiteDSN(databaseFile string) string {
if databaseFile == "" {
databaseFile = "./data/komari.db"
@@ -284,212 +192,160 @@ func buildSQLiteDSN(databaseFile string) string {
return "file:" + filepath.ToSlash(databaseFile) + separator + params
}
// Initialize 显式初始化数据库连接与表结构,仅执行一次。
// 与 GetDBInstance 不同,Initialize 返回错误而非直接退出进程,
// 便于启动生命周期统一处理错误、以及在测试/CLI 命令中做隔离。
func Initialize() error {
once.Do(func() {
initErr = doInitialize()
})
return initErr
}
// GetDBInstance 返回全局数据库实例。
// 为兼容既有大量调用点,这里保留“出错即退出”的语义;
// 需要错误处理的启动流程应优先调用 Initialize()。
func GetDBInstance() *gorm.DB {
if err := Initialize(); err != nil {
logger.Fatalf("dbcore", "Failed to initialize database: %v", err)
}
return instance
}
once.Do(func() {
// Close 关闭底层数据库连接,供关闭流程调用。
func Close() error {
if instance == nil {
return nil
}
sqlDB, err := instance.DB()
if err != nil {
return err
}
return sqlDB.Close()
}
var err error
func doInitialize() error {
var err error
// 在数据库初始化前执行:如果存在 ./data/backup.zip,则进行恢复逻辑
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 {
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 {
logger.Errorf("dbcore", "[restore] failed to zip current data: %v", zipErr)
// 在数据库初始化前执行:如果存在 ./data/backup.zip,则进行恢复逻辑
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 {
log.Printf("[restore] failed to create backup dir: %v", err)
} else {
logger.Infof("dbcore", "[restore] current data zipped to %s", bakPath)
tsName := time.Now().Format("20060102-150405")
bakPath := filepath.Join("./backup", fmt.Sprintf("%s.zip", tsName))
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}}); zipErr != nil {
log.Printf("[restore] failed to zip current data: %v", zipErr)
} else {
log.Printf("[restore] current data zipped to %s", bakPath)
}
}
// 5. 删除除了 ./data/backup.zip 之外的所有文件
if delErr := removeAllInDirExcept("./data", map[string]struct{}{backupZipPath: {}}); delErr != nil {
log.Printf("[restore] failed to cleanup data dir: %v", delErr)
}
// 6. 解压 ./data/backup.zip 到 ./data
if unzipErr := unzipToDir(backupZipPath, "./data"); unzipErr != nil {
log.Printf("[restore] failed to unzip backup into data: %v", unzipErr)
} else {
log.Printf("[restore] backup.zip extracted to ./data")
}
// 7. 删除 ./data/backup.zip
if rmErr := os.Remove(backupZipPath); rmErr != nil {
log.Printf("[restore] failed to remove backup.zip: %v", rmErr)
} else {
log.Printf("[restore] backup.zip removed")
}
// 8. 删除标记
if rmErr := os.Remove("./data/komari-backup-markup"); rmErr != nil {
log.Printf("[restore] failed to remove komari-backup-markup: %v", rmErr)
} else {
log.Printf("[restore] komari-backup-markup removed")
}
}
}()
// 5. 删除除了 ./data/backup.zip 之外的所有文件
if delErr := removeAllInDirExcept("./data", map[string]struct{}{backupZipPath: {}}); delErr != nil {
logger.Errorf("dbcore", "[restore] failed to cleanup data dir: %v", delErr)
}
// 6. 解压 ./data/backup.zip 到 ./data
if unzipErr := unzipToDir(backupZipPath, "./data"); unzipErr != nil {
logger.Errorf("dbcore", "[restore] failed to unzip backup into data: %v", unzipErr)
} else {
logger.Infof("dbcore", "[restore] backup.zip extracted to ./data")
}
// 7. 删除 ./data/backup.zip
if rmErr := os.Remove(backupZipPath); rmErr != nil {
logger.Errorf("dbcore", "[restore] failed to remove backup.zip: %v", rmErr)
} else {
logger.Infof("dbcore", "[restore] backup.zip removed")
}
// 8. 删除标记
if rmErr := os.Remove("./data/komari-backup-markup"); rmErr != nil {
logger.Errorf("dbcore", "[restore] failed to remove komari-backup-markup: %v", rmErr)
} else {
logger.Infof("dbcore", "[restore] komari-backup-markup removed")
}
logConfig := &gorm.Config{
Logger: logutil.NewGormLogger(),
}
}()
// 记录“打开数据库之前”komari.db 是否已存在,用于区分全新安装与旧版升级。
// 必须在(可能的)恢复逻辑之后、gorm.Open 之前采集:恢复会解压出旧库,
// gorm.Open 会创建空库。
if _, statErr := os.Stat(resolveDatabaseFile()); statErr == nil {
dbFileExistedAtStartup = true
}
logConfig := &gorm.Config{
Logger: logger.NewGormLogger(),
NowFunc: func() time.Time { return time.Now().UTC() },
}
// 根据数据库类型选择不同的连接方式
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 层为所有连接预设。
dsn := buildSQLiteDSN(flags.DatabaseFile)
instance, err = gorm.Open(sqlite.Open(dsn), logConfig)
// 根据数据库类型选择不同的连接方式
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 层为所有连接预设。
dsn := buildSQLiteDSN(flags.DatabaseFile)
instance, err = gorm.Open(sqlite.Open(dsn), logConfig)
if err != nil {
log.Fatalf("Failed to connect to SQLite3 database: %v", err)
}
log.Printf("Using SQLite database file: %s", flags.DatabaseFile)
if sqlDB, dbErr := instance.DB(); dbErr == nil {
// SQLite 同一时刻只允许一个写者;限制连接数可避免连接池层面的写写竞争。
// 负载历史每分钟会执行包含读和写的事务,若连接池允许多个连接,容易与
// ping 结果等短写入撞锁并导致整批负载记录回滚。
sqlDB.SetMaxOpenConns(1)
sqlDB.SetMaxIdleConns(1)
sqlDB.SetConnMaxLifetime(0)
} else {
log.Printf("Failed to access underlying sql.DB for SQLite tuning: %v", dbErr)
}
instance.Exec("PRAGMA wal = ON;")
if err := instance.Exec("PRAGMA journal_mode = WAL;").Error; err != nil {
log.Printf("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:
log.Fatalf("Unsupported database type: %s (supported: %s)", flags.DatabaseType, flags.SupportedDatabaseTypes())
}
if err := migrations.Run(migrations.Context{DB: instance}); err != nil {
log.Fatalf("Failed to run startup migrations: %v", err)
}
config.SetDb(instance)
// 自动迁移模型
err = instance.AutoMigrate(
&models.User{},
&models.Client{},
&models.Record{},
&models.GPURecord{},
&models.Log{},
&models.Clipboard{},
&models.LoadNotification{},
&models.OfflineNotification{},
&models.TrafficReportNotification{},
&models.PingRecord{},
&models.PingTask{},
&models.OidcProvider{},
&models.MessageSenderProvider{},
&models.ThemeConfiguration{},
)
if err != nil {
return fmt.Errorf("failed to connect to SQLite3 database: %w", err)
log.Fatalf("Failed to create tables: %v", 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)
}
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())
}
if err := migrations.Run(migrations.Context{DB: instance}); err != nil {
return fmt.Errorf("failed to run startup migrations: %w", err)
}
config.SetDb(instance)
// 配置库就绪后、执行后续 AutoMigrate 之前:
// 基于配置中的版本标记检测升级并自动备份 ./data,便于回滚。
backupOnVersionUpgrade()
// 自动迁移模型
//
// 注意:负载/GPU/ping 历史监控数据运行期全部走 metric store(默认 SQLite
// ./data/metrics.db,或配置的 MySQL/PostgreSQL)。旧的 records /
// records_long_term / gpu_records / ping_records 表不再建表、不再写入。
// 若升级时旧表仍存在,管理员可通过升级向导显式导入并清理。
// models.Record / models.PingRecord / models.GPURecord 结构体仍作为
// metric store 的读写 DTO 和旧表导入 DTO 保留在 models 包中。
err = instance.AutoMigrate(
&models.User{},
&models.Client{},
&models.Log{},
&models.Clipboard{},
&models.LoadNotification{},
&models.OfflineNotification{},
&models.TrafficReportNotification{},
&models.PingTask{},
&models.OidcProvider{},
&models.MessageSenderProvider{},
&models.ThemeConfiguration{},
)
if err != nil {
return fmt.Errorf("failed to create tables: %w", err)
}
if err := instance.AutoMigrate(
&models.Session{},
); err != nil {
logger.Errorf("dbcore", "Failed to create Session table, it may already exist: %v", err)
}
if err := instance.AutoMigrate(
&models.Task{},
&models.TaskResult{},
); err != nil {
logger.Errorf("dbcore", "Failed to create Task and TaskResult table, it may already exist: %v", err)
}
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;",
err = instance.Table("records_long_term").AutoMigrate(
&models.Record{},
)
} 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)
if err != nil {
log.Printf("Failed to create records_long_term table, it may already exist: %v", err)
}
}
return nil
err = instance.Table("gpu_records_long_term").AutoMigrate(
&models.GPURecord{},
)
if err != nil {
log.Printf("Failed to create gpu_records_long_term table, it may already exist: %v", err)
}
err = instance.AutoMigrate(
&models.Session{},
)
if err != nil {
log.Printf("Failed to create Session table, it may already exist: %v", err)
}
err = instance.AutoMigrate(
&models.Task{},
&models.TaskResult{},
)
if err != nil {
log.Printf("Failed to create Task and TaskResult table, it may already exist: %v", err)
}
// Manually create composite indexes
if flags.IsSQLite() {
instance.Exec("CREATE INDEX IF NOT EXISTS idx_record_client_time ON records(client, time)")
instance.Exec("CREATE INDEX IF NOT EXISTS idx_record_lt_client_time ON records_long_term(client, time)")
instance.Exec("CREATE INDEX IF NOT EXISTS idx_gpu_record_client_time ON gpu_records(client, time)")
instance.Exec("CREATE INDEX IF NOT EXISTS idx_gpu_record_lt_client_time ON gpu_records_long_term(client, time)")
instance.Exec("CREATE INDEX IF NOT EXISTS idx_ping_record_client_time ON ping_records(client, time)")
}
})
return instance
}
-92
View File
@@ -1,92 +0,0 @@
package dbcore
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"strings"
"sync"
"github.com/komari-monitor/komari/cmd/flags"
)
var maintenanceMu sync.Mutex
// StorageSize returns the bytes occupied by the main SQLite database and its
// WAL/SHM sidecar files.
func StorageSize() (int64, error) {
if !flags.IsSQLite() {
return 0, errors.New("main database size is only available for SQLite")
}
return sqliteFileSetSize(resolveDatabaseFile())
}
// ReclaimSpace checkpoints the main database WAL and rewrites the SQLite file.
func ReclaimSpace(ctx context.Context) error {
if !flags.IsSQLite() {
return errors.New("main database maintenance is only supported for SQLite")
}
maintenanceMu.Lock()
defer maintenanceMu.Unlock()
db, err := GetDBInstance().DB()
if err != nil {
return fmt.Errorf("get main database connection: %w", err)
}
if err := checkpointSQLiteWAL(ctx, db); err != nil {
return err
}
if _, err := db.ExecContext(ctx, "VACUUM"); err != nil {
return fmt.Errorf("vacuum main database: %w", err)
}
return checkpointSQLiteWAL(ctx, db)
}
func checkpointSQLiteWAL(ctx context.Context, db *sql.DB) error {
var busy, logFrames, checkpointedFrames int
if err := db.QueryRowContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)").Scan(&busy, &logFrames, &checkpointedFrames); err != nil {
return fmt.Errorf("checkpoint main database WAL: %w", err)
}
if busy != 0 {
return fmt.Errorf(
"checkpoint main database WAL: database is busy (%d log frames, %d checkpointed)",
logFrames,
checkpointedFrames,
)
}
return nil
}
func sqliteFileSetSize(dsn string) (int64, error) {
path := strings.TrimPrefix(strings.TrimSpace(dsn), "file:")
if index := strings.IndexByte(path, '?'); index >= 0 {
path = path[:index]
}
if path == "" || path == ":memory:" || strings.Contains(strings.ToLower(dsn), "mode=memory") {
return 0, errors.New("database is not backed by a local file")
}
var total int64
foundDatabase := false
for _, suffix := range []string{"", "-wal", "-shm"} {
info, err := os.Stat(path + suffix)
switch {
case err == nil:
total += info.Size()
if suffix == "" {
foundDatabase = true
}
case errors.Is(err, os.ErrNotExist):
continue
default:
return 0, fmt.Errorf("stat database file %q: %w", path+suffix, err)
}
}
if !foundDatabase {
return 0, fmt.Errorf("database file %q does not exist", path)
}
return total, nil
}
-37
View File
@@ -1,37 +0,0 @@
package dbcore
import (
"os"
"path/filepath"
"testing"
)
func TestSQLiteFileSetSizeIncludesWALAndSHM(t *testing.T) {
databasePath := filepath.Join(t.TempDir(), "komari.db")
files := map[string]int{
databasePath: 11,
databasePath + "-wal": 7,
databasePath + "-shm": 5,
}
for path, size := range files {
if err := os.WriteFile(path, make([]byte, size), 0o600); err != nil {
t.Fatalf("write fixture %s: %v", path, err)
}
}
got, err := sqliteFileSetSize("file:" + filepath.ToSlash(databasePath) + "?mode=rwc")
if err != nil {
t.Fatalf("sqliteFileSetSize: %v", err)
}
if got != 23 {
t.Fatalf("size = %d, want 23", got)
}
}
func TestSQLiteFileSetSizeRejectsNonFileDatabase(t *testing.T) {
for _, dsn := range []string{":memory:", "file:metrics?MODE=MEMORY&cache=shared"} {
if _, err := sqliteFileSetSize(dsn); err == nil {
t.Fatalf("sqliteFileSetSize(%q) should fail", dsn)
}
}
}
-95
View File
@@ -1,95 +0,0 @@
package metricstore
import (
"context"
"errors"
"fmt"
"github.com/komari-monitor/komari/pkg/metric"
)
var (
ErrStoreNotInitialized = errors.New("metric store not initialized")
ErrStoreBusy = errors.New("metric store is busy")
)
// StorageInfo describes the physical storage owned by the active metric store.
// Size remains useful even when the store points at an external database because
// pkg/metric limits its query to the three tables managed by this Store.
type StorageInfo struct {
Driver metric.Driver
Action metric.MaintenanceAction
Size int64
}
// MaintenanceResult keeps measurement failures separate from the maintenance
// error. A database may allow table maintenance while denying catalog queries,
// and callers should still be able to report that the operation succeeded.
type MaintenanceResult struct {
Driver metric.Driver
Action metric.MaintenanceAction
Before int64
After int64
BeforeSizeError error
AfterSizeError error
}
// 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 {
return StorageInfo{}, fmt.Errorf("wait for metric store operations before inspection: %w", err)
}
defer storeOperations.Release()
storeMu.RLock()
activeStore := store
storeMu.RUnlock()
if activeStore == nil {
return StorageInfo{}, ErrStoreNotInitialized
}
info := StorageInfo{
Driver: activeStore.Driver(),
Action: activeStore.MaintenanceAction(),
}
size, err := activeStore.StorageSize(ctx)
info.Size = size
return info, err
}
// 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.
func ReclaimSpace(ctx context.Context) (MaintenanceResult, error) {
if !storeOperations.TryAcquire() {
storeMu.RLock()
defer storeMu.RUnlock()
if store == nil {
return MaintenanceResult{}, ErrStoreNotInitialized
}
return MaintenanceResult{
Driver: store.Driver(),
Action: store.MaintenanceAction(),
BeforeSizeError: ErrStoreBusy,
AfterSizeError: ErrStoreBusy,
}, ErrStoreBusy
}
defer storeOperations.Release()
storeMu.RLock()
activeStore := store
storeMu.RUnlock()
if activeStore == nil {
return MaintenanceResult{}, ErrStoreNotInitialized
}
result := MaintenanceResult{
Driver: activeStore.Driver(),
Action: activeStore.MaintenanceAction(),
}
result.Before, result.BeforeSizeError = activeStore.StorageSize(ctx)
maintenanceErr := activeStore.ReclaimSpace(ctx)
result.After, result.AfterSizeError = activeStore.StorageSize(ctx)
return result, maintenanceErr
}
-176
View File
@@ -1,176 +0,0 @@
package metricstore
import (
"context"
"errors"
"testing"
"time"
"github.com/komari-monitor/komari/pkg/metric"
)
func TestInspectAndReclaimStorage(t *testing.T) {
ctx := context.Background()
s, err := metric.Open(ctx, metric.SQLite(":memory:"))
if err != nil {
t.Fatalf("open metric store: %v", err)
}
installTestStore(t, s)
info, err := InspectStorage(ctx)
if err != nil {
t.Fatalf("inspect storage: %v", err)
}
if info.Driver != metric.DriverSQLite || info.Action != metric.MaintenanceVacuum {
t.Fatalf("unexpected storage info: %#v", info)
}
if info.Size != 0 {
t.Fatalf("in-memory storage size = %d, want 0", info.Size)
}
result, err := ReclaimSpace(ctx)
if err != nil {
t.Fatalf("reclaim space: %v", err)
}
if result.Driver != metric.DriverSQLite || result.Action != metric.MaintenanceVacuum {
t.Fatalf("unexpected maintenance result: %#v", result)
}
if result.BeforeSizeError != nil || result.AfterSizeError != nil {
t.Fatalf("unexpected size errors: before=%v after=%v", result.BeforeSizeError, result.AfterSizeError)
}
}
func TestReclaimSpaceReportsBusyStore(t *testing.T) {
s, err := metric.Open(context.Background(), metric.SQLite(":memory:"))
if err != nil {
t.Fatalf("open metric store: %v", err)
}
installTestStore(t, s)
if !storeOperations.TryAcquire() {
t.Fatal("acquire store operation gate")
}
defer storeOperations.Release()
result, err := ReclaimSpace(context.Background())
if !errors.Is(err, ErrStoreBusy) {
t.Fatalf("reclaim error = %v, want %v", err, ErrStoreBusy)
}
if result.Driver != metric.DriverSQLite || result.Action != metric.MaintenanceVacuum {
t.Fatalf("busy result lost store metadata: %#v", result)
}
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)
}
if !compactOperations.TryAcquire() {
t.Fatal("acquire compact operation gate")
}
defer compactOperations.Release()
if _, err := Compact(context.Background(), time.Now()); !errors.Is(err, ErrCompactInProgress) {
t.Fatalf("overlapping compact error = %v, want %v", err, ErrCompactInProgress)
}
}
func TestInspectStorageRequiresInitializedStore(t *testing.T) {
storeMu.Lock()
previous := store
store = nil
storeMu.Unlock()
t.Cleanup(func() {
storeMu.Lock()
store = previous
storeMu.Unlock()
})
if _, err := InspectStorage(context.Background()); !errors.Is(err, ErrStoreNotInitialized) {
t.Fatalf("inspect error = %v, want %v", err, ErrStoreNotInitialized)
}
}
func TestCloseStoreContextCancelsMigrationBeforeTakingStoreLock(t *testing.T) {
s, err := metric.Open(context.Background(), metric.SQLite(":memory:"))
if err != nil {
t.Fatalf("open metric store: %v", err)
}
installTestStore(t, s)
migrationCtx, cancelMigration := context.WithCancel(context.Background())
done := make(chan struct{})
if !storeOperations.TryAcquire() {
t.Fatal("acquire store operation gate")
}
storeMigMu.Lock()
storeMigCancel = cancelMigration
storeMigDone = done
storeMigMu.Unlock()
go func() {
<-migrationCtx.Done()
storeOperations.Release()
storeMigMu.Lock()
storeMigCancel = nil
storeMigDone = nil
close(done)
storeMigMu.Unlock()
}()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := CloseStoreContext(ctx); err != nil {
t.Fatalf("close store: %v", err)
}
if migrationCtx.Err() == nil {
t.Fatal("store migration was not canceled before close")
}
if err := Reload(context.Background()); !errors.Is(err, ErrStoreBusy) {
t.Fatalf("reload after close error = %v, want %v", err, ErrStoreBusy)
}
}
func TestStoreOperationWaitsRespectContext(t *testing.T) {
if !storeOperations.TryAcquire() {
t.Fatal("acquire store operation gate")
}
defer storeOperations.Release()
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := InspectStorage(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("inspect error = %v, want context canceled", err)
}
if err := Reload(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("reload error = %v, want context canceled", err)
}
if err := CloseStoreContext(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("close error = %v, want context canceled", err)
}
}
func installTestStore(t *testing.T, s *metric.Store) {
t.Helper()
storeMigMu.Lock()
previousClosing := storeClosing
storeClosing = false
storeMigMu.Unlock()
storeMu.Lock()
previous := store
previousFingerprint := storeFingerprint
store = s
storeFingerprint = "test|memory"
storeMu.Unlock()
t.Cleanup(func() {
storeMigMu.Lock()
storeClosing = previousClosing
storeMigMu.Unlock()
storeMu.Lock()
store = previous
storeFingerprint = previousFingerprint
storeMu.Unlock()
_ = s.Close()
})
}
-63
View File
@@ -1,63 +0,0 @@
package metricstore
const (
MetricCPU = "cpu.usage"
MetricGPU = "gpu.usage"
MetricGPUDeviceUsage = "gpu.device.usage"
MetricGPUMem = "gpu.memory.used"
MetricGPUMemTotal = "gpu.memory.total"
MetricGPUTemp = "gpu.temperature"
MetricRAM = "memory.used"
MetricSwap = "swap.used"
MetricLoad = "load.average"
MetricDisk = "disk.used"
MetricNetIn = "net.in.rate"
MetricNetOut = "net.out.rate"
MetricNetTotalUp = "net.total.up"
MetricNetTotalDown = "net.total.down"
MetricTrafficUp = "traffic.up"
MetricTrafficDown = "traffic.down"
MetricProcess = "process.count"
MetricConnections = "connections.tcp"
MetricConnectionsUDP = "connections.udp"
MetricPingLatency = "ping.latency_ms"
MetricPingLoss = "ping.loss"
)
// loadRecordMetricNames are the entity-level metrics used to reconstruct the
// legacy Record response shape.
var loadRecordMetricNames = []string{
MetricCPU, MetricGPU, MetricRAM, MetricSwap, MetricLoad, MetricDisk, MetricNetIn, MetricNetOut,
MetricNetTotalUp, MetricNetTotalDown, MetricTrafficUp, MetricTrafficDown,
MetricProcess, MetricConnections, MetricConnectionsUDP,
}
var obsoleteBuiltinMetricNames = []string{
"memory.total",
"swap.total",
"temperature",
"disk.total",
}
// gpuDeviceRecordMetricNames are stored separately from the entity-level GPU
// average and are included when deleting all system records.
var gpuDeviceRecordMetricNames = []string{
MetricGPUDeviceUsage, MetricGPUMem, MetricGPUMemTotal, MetricGPUTemp,
}
var recordMetricNames = joinMetricNames(loadRecordMetricNames, gpuDeviceRecordMetricNames)
// Ping has an independent retention and cleanup boundary.
var pingMetricNames = []string{MetricPingLatency, MetricPingLoss}
func joinMetricNames(groups ...[]string) []string {
total := 0
for _, group := range groups {
total += len(group)
}
names := make([]string, 0, total)
for _, group := range groups {
names = append(names, group...)
}
return names
}
File diff suppressed because it is too large. Load diff
-603
View File
@@ -1,603 +0,0 @@
package metricstore
import (
"context"
"database/sql"
"errors"
"fmt"
"path/filepath"
"testing"
"time"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/metric"
v1 "github.com/komari-monitor/komari/protocol/v1"
)
func TestDefaultRollupPolicy(t *testing.T) {
policy := defaultRollupPolicy()
if err := policy.Validate(); err != nil {
t.Fatalf("default rollup policy should validate: %v", err)
}
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))
}
wantIntervals := []time.Duration{time.Minute, 5 * time.Minute, time.Hour}
wantRetentions := []time.Duration{48 * time.Hour, 14 * 24 * time.Hour, 14 * 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])
}
if policy.Tiers[i].Retention != wantRetentions[i] {
t.Fatalf("tier %d retention = %s, want %s", i, policy.Tiers[i].Retention, wantRetentions[i])
}
}
}
func TestBuildMetricConfigEnablesDefaultRollupPolicy(t *testing.T) {
cfg, err := buildMetricConfig(&MetricStoreConfig{
Driver: "sqlite",
DSN: ":memory:",
TablePrefix: "metric_",
}, false)
if err != nil {
t.Fatalf("build metric config: %v", err)
}
if !cfg.RollupPolicy.Enabled() {
t.Fatal("expected default rollup policy to be enabled")
}
if cfg.RollupPolicy.RawRetention != DefaultRollupRawRetention {
t.Fatalf("raw retention = %s, want %s", cfg.RollupPolicy.RawRetention, DefaultRollupRawRetention)
}
}
func TestBuildMetricConfigLeavesFinalRetentionToMetricDefinition(t *testing.T) {
cfg, err := buildMetricConfig(&MetricStoreConfig{
Driver: "sqlite",
DSN: ":memory:",
}, false)
if err != nil {
t.Fatalf("build metric config: %v", err)
}
wantRollupRetention := 14 * 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)
}
}
func TestBuildMetricConfigAlwaysEnablesDownsampling(t *testing.T) {
cfg, err := buildMetricConfig(&MetricStoreConfig{
Driver: "sqlite",
DSN: ":memory:",
}, false)
if err != nil {
t.Fatalf("build metric config: %v", err)
}
if !cfg.RollupPolicy.Enabled() {
t.Fatal("expected rollup policy to be enabled")
}
}
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)
s, err := metric.Open(ctx, metric.SQLite(":memory:",
metric.WithMaxOpenConns(1),
metric.WithRollupPolicy(defaultRollupPolicy()),
))
if err != nil {
t.Fatalf("open metric store: %v", err)
}
defer s.Close()
if err := s.UpsertMetric(ctx, metric.Definition{
Name: MetricPingLatency,
Type: metric.TypeGauge,
RetentionDays: 30,
}); err != nil {
t.Fatalf("create ping metric: %v", err)
}
if err := s.WriteBatch(ctx, []metric.Point{
{MetricName: MetricPingLatency, EntityID: "node-a", Timestamp: now.Add(-20 * time.Minute), Value: 20, Tags: map[string]string{"task_id": "7"}},
{MetricName: MetricPingLatency, EntityID: "node-a", Timestamp: now.Add(-10 * time.Minute), Value: 10, Tags: map[string]string{"task_id": "7"}},
{MetricName: MetricPingLatency, EntityID: "node-a", Timestamp: now.Add(-5 * time.Minute), Value: 5, Tags: map[string]string{"task_id": "7"}},
}); err != nil {
t.Fatalf("write ping points: %v", err)
}
if _, err := s.Compact(ctx, now); err != nil {
t.Fatalf("compact ping points: %v", err)
}
storeMu.Lock()
oldStore := store
store = s
storeMu.Unlock()
defer func() {
storeMu.Lock()
store = oldStore
storeMu.Unlock()
}()
records, err := GetPingRecords(ctx, "node-a", 7, now.Add(-30*time.Minute), now)
if err != nil {
t.Fatalf("get ping records: %v", err)
}
if len(records) != 3 {
t.Fatalf("expected 3 ping records across raw and rollup data, got %d: %#v", len(records), records)
}
if records[0].Value != 5 || records[1].Value != 10 || records[2].Value != 20 {
t.Fatalf("unexpected ping values in descending order: %#v", records)
}
}
func TestCreateMetricDefinitionsUsesExplicitRetentionAndPreservesOverrides(t *testing.T) {
if defaultBuiltinMetricRetentionDays != 1 {
t.Fatalf("default built-in metric retention = %d, want 1 day", defaultBuiltinMetricRetentionDays)
}
ctx := context.Background()
s, err := metric.Open(ctx, metric.SQLite(":memory:", metric.WithMaxOpenConns(1)))
if err != nil {
t.Fatalf("open metric store: %v", err)
}
defer s.Close()
if err := createMetricDefinitions(ctx, s); err != nil {
t.Fatalf("create definitions: %v", err)
}
defs, err := s.ListMetrics(ctx)
if err != nil {
t.Fatalf("list definitions: %v", err)
}
if len(defs) != 21 {
t.Fatalf("definition count = %d, want 21", len(defs))
}
for _, def := range defs {
if def.RetentionDays != defaultBuiltinMetricRetentionDays {
t.Fatalf("%s retention = %d, want %d", def.Name, def.RetentionDays, defaultBuiltinMetricRetentionDays)
}
}
cpu, err := s.GetMetric(ctx, MetricCPU)
if err != nil {
t.Fatalf("get cpu definition: %v", err)
}
cpu.RetentionDays = 60
if err := s.UpsertMetric(ctx, cpu); err != nil {
t.Fatalf("override cpu retention: %v", err)
}
if err := createMetricDefinitions(ctx, s); err != nil {
t.Fatalf("recreate definitions: %v", err)
}
cpu, err = s.GetMetric(ctx, MetricCPU)
if err != nil {
t.Fatalf("reload cpu definition: %v", err)
}
if cpu.RetentionDays != 60 {
t.Fatalf("cpu retention = %d, want preserved override 60", cpu.RetentionDays)
}
if _, err := s.SetMetricRetention(ctx, MetricCPU, 0); err != nil {
t.Fatalf("disable cpu retention: %v", err)
}
if err := createMetricDefinitions(ctx, s); err != nil {
t.Fatalf("refresh disabled definition: %v", err)
}
cpu, err = s.GetMetric(ctx, MetricCPU)
if err != nil {
t.Fatalf("reload disabled cpu definition: %v", err)
}
if cpu.RetentionDays != 0 {
t.Fatalf("cpu retention = %d, want preserved disabled state", cpu.RetentionDays)
}
}
func TestCreateMetricDefinitionsRemovesObsoleteMetrics(t *testing.T) {
ctx := context.Background()
s, err := metric.Open(ctx, metric.SQLite(":memory:", metric.WithMaxOpenConns(1)))
if err != nil {
t.Fatalf("open metric store: %v", err)
}
defer s.Close()
if err := s.CreateMetric(ctx, metric.Definition{Name: "memory.total", Type: metric.TypeGauge, RetentionDays: 1}); err != nil {
t.Fatalf("create obsolete definition: %v", err)
}
if err := s.Write(ctx, metric.Point{MetricName: "memory.total", EntityID: "node-a", Timestamp: time.Now().UTC(), Value: 1024}); err != nil {
t.Fatalf("write obsolete point: %v", err)
}
if err := createMetricDefinitions(ctx, s); err != nil {
t.Fatalf("refresh built-in definitions: %v", err)
}
if _, err := s.GetMetric(ctx, "memory.total"); !errors.Is(err, metric.ErrNotFound) {
t.Fatalf("obsolete definition error = %v, want ErrNotFound", err)
}
points, err := s.Query(ctx, metric.Query{MetricName: "memory.total", EntityID: "node-a", Start: time.Now().UTC().Add(-time.Hour), End: time.Now().UTC().Add(time.Hour)})
if err != nil {
t.Fatalf("query obsolete points: %v", err)
}
if len(points) != 0 {
t.Fatalf("obsolete points remain: %#v", points)
}
}
func TestCreateMetricDefinitionsUsesLegacySpanOnlyForNewDefinitions(t *testing.T) {
ctx := context.Background()
s, err := metric.Open(ctx, metric.SQLite(":memory:", metric.WithMaxOpenConns(1)))
if err != nil {
t.Fatalf("open metric store: %v", err)
}
defer s.Close()
if err := createMetricDefinitionsWithDefaultRetention(ctx, s, 10); err != nil {
t.Fatalf("create migration definitions: %v", err)
}
defs, err := s.ListMetrics(ctx)
if err != nil {
t.Fatalf("list migration definitions: %v", err)
}
for _, def := range defs {
if def.RetentionDays != 10 {
t.Fatalf("%s retention = %d, want legacy span 10", def.Name, def.RetentionDays)
}
}
cpu, err := s.GetMetric(ctx, MetricCPU)
if err != nil {
t.Fatalf("get CPU definition: %v", err)
}
cpu.RetentionDays = 3
if err := s.UpsertMetric(ctx, cpu); err != nil {
t.Fatalf("override CPU retention: %v", err)
}
if err := createMetricDefinitionsWithDefaultRetention(ctx, s, 20); err != nil {
t.Fatalf("refresh migration definitions: %v", err)
}
cpu, err = s.GetMetric(ctx, MetricCPU)
if err != nil {
t.Fatalf("reload CPU definition: %v", err)
}
if cpu.RetentionDays != 3 {
t.Fatalf("existing CPU retention = %d, want preserved 3", cpu.RetentionDays)
}
}
func TestGetRetentionSummaryUsesAllMetricDefinitions(t *testing.T) {
ctx := context.Background()
s, err := metric.Open(ctx, metric.SQLite(":memory:", metric.WithMaxOpenConns(1)))
if err != nil {
t.Fatalf("open metric store: %v", err)
}
defer s.Close()
storeMu.Lock()
oldStore := store
store = s
storeMu.Unlock()
defer func() {
storeMu.Lock()
store = oldStore
storeMu.Unlock()
}()
empty, err := GetRetentionSummary(ctx)
if err != nil {
t.Fatalf("summarize empty store: %v", err)
}
if empty.AllPositive || empty.MaxDays != 0 {
t.Fatalf("unexpected empty summary: %#v", empty)
}
for _, def := range []metric.Definition{
{Name: "short", Type: metric.TypeGauge, RetentionDays: 7},
{Name: "long", Type: metric.TypeGauge, RetentionDays: 60},
} {
if err := s.UpsertMetric(ctx, def); err != nil {
t.Fatalf("upsert %s: %v", def.Name, err)
}
}
summary, err := GetRetentionSummary(ctx)
if err != nil {
t.Fatalf("summarize definitions: %v", err)
}
if !summary.AllPositive || summary.MaxDays != 60 {
t.Fatalf("unexpected summary: %#v", summary)
}
if _, err := s.SetMetricRetention(ctx, "short", 0); err != nil {
t.Fatalf("disable short metric: %v", err)
}
summary, err = GetRetentionSummary(ctx)
if err != nil {
t.Fatalf("summarize disabled metric: %v", err)
}
if summary.AllPositive || summary.MaxDays != 60 {
t.Fatalf("unexpected disabled summary: %#v", summary)
}
}
func TestSummarizeRetentionDefinitionsRequiresEveryMetricToBePositive(t *testing.T) {
summary := summarizeRetentionDefinitions([]metric.Definition{
{Name: "enabled", RetentionDays: 30},
{Name: "disabled", RetentionDays: 0},
{Name: "long", RetentionDays: 60},
})
if summary.AllPositive || summary.MaxDays != 60 {
t.Fatalf("unexpected summary: %#v", summary)
}
}
func TestCompactCleansExpiredRawPointsWhenDownsamplingDisabled(t *testing.T) {
ctx := context.Background()
s, err := metric.Open(ctx, metric.SQLite(":memory:", metric.WithMaxOpenConns(1)))
if err != nil {
t.Fatalf("open metric store: %v", err)
}
if err := s.UpsertMetric(ctx, metric.Definition{
Name: "raw.metric",
Type: metric.TypeGauge,
RetentionDays: 1,
}); err != nil {
t.Fatalf("upsert metric: %v", err)
}
now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.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},
}); err != nil {
t.Fatalf("write points: %v", err)
}
storeMu.Lock()
oldStore := store
oldCompactAt := compactAt
store = s
compactAt = 0
storeMu.Unlock()
defer func() {
storeMu.Lock()
store = oldStore
compactAt = oldCompactAt
storeMu.Unlock()
_ = s.Close()
}()
if _, err := Compact(ctx, now); err != nil {
t.Fatalf("compact: %v", err)
}
points, err := s.Query(ctx, metric.Query{
MetricName: "raw.metric",
EntityID: "node",
Start: now.Add(-72 * time.Hour),
End: now,
})
if err != nil {
t.Fatalf("query points: %v", err)
}
if len(points) != 1 || points[0].Value != 2 {
t.Fatalf("expected only the retained raw point, got %#v", points)
}
}
func TestCompactContinuesAfterOneMetricFails(t *testing.T) {
ctx := context.Background()
dsn := filepath.Join(t.TempDir(), "compact.db")
s, err := metric.Open(ctx, metric.SQLite(dsn,
metric.WithMaxOpenConns(1),
metric.WithRollupPolicy(defaultRollupPolicy()),
))
if err != nil {
t.Fatalf("open metric store: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
for _, name := range []string{"a.invalid", "b.healthy"} {
if err := s.CreateMetric(ctx, metric.Definition{Name: name, Type: metric.TypeGauge, RetentionDays: 1}); err != nil {
t.Fatalf("create metric %s: %v", name, err)
}
}
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)
}
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(),
)
_ = rawDB.Close()
if err != nil {
t.Fatalf("insert malformed point: %v", err)
}
storeMu.Lock()
previousStore := store
previousCompactAt := compactAt
store = s
compactAt = 0
storeMu.Unlock()
t.Cleanup(func() {
storeMu.Lock()
store = previousStore
compactAt = previousCompactAt
storeMu.Unlock()
})
if _, err := Compact(ctx, now); err == nil {
t.Fatal("expected malformed metric to fail compaction")
}
points, err := s.Query(ctx, metric.Query{
MetricName: "b.healthy",
EntityID: "node",
Start: old.Add(-time.Minute),
End: now,
})
if err != nil {
t.Fatalf("query healthy raw points: %v", err)
}
if len(points) != 0 {
t.Fatalf("healthy metric was blocked by another metric failure: %s", fmt.Sprint(points))
}
}
func TestCompactKeepsRotatingCursorAfterFullCycle(t *testing.T) {
ctx := context.Background()
s, err := metric.Open(ctx, metric.SQLite(":memory:",
metric.WithMaxOpenConns(1),
metric.WithRollupPolicy(defaultRollupPolicy()),
))
if err != nil {
t.Fatalf("open metric store: %v", err)
}
for _, name := range []string{"a.metric", "b.metric", "c.metric"} {
if err := s.UpsertMetric(ctx, metric.Definition{Name: name, Type: metric.TypeGauge}); err != nil {
t.Fatalf("upsert metric %s: %v", name, err)
}
}
storeMu.Lock()
oldStore := store
oldCompactAt := compactAt
store = s
compactAt = 1
storeMu.Unlock()
defer func() {
storeMu.Lock()
store = oldStore
compactAt = oldCompactAt
storeMu.Unlock()
_ = s.Close()
}()
if _, err := Compact(ctx, time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)); err != nil {
t.Fatalf("compact: %v", err)
}
if compactAt != 1 {
t.Fatalf("compact cursor = %d, want 1 after a complete rotated cycle", compactAt)
}
}
func TestGetRecordsByClientAndTimeReadsRollupsAfterRawCompaction(t *testing.T) {
ctx := context.Background()
s, err := metric.Open(ctx, metric.SQLite(":memory:",
metric.WithMaxOpenConns(1),
metric.WithRollupPolicy(defaultRollupPolicy()),
))
if err != nil {
t.Fatalf("open metric store: %v", err)
}
if err := createMetricDefinitions(ctx, s); err != nil {
t.Fatalf("create metric definitions: %v", err)
}
storeMu.Lock()
oldStore := store
oldCompactAt := compactAt
store = s
compactAt = 0
storeMu.Unlock()
defer func() {
storeMu.Lock()
store = oldStore
compactAt = oldCompactAt
storeMu.Unlock()
_ = s.Close()
}()
now := time.Now().UTC().Truncate(time.Minute)
ts := now.Add(-time.Hour)
rec := models.Record{
Client: "node-a",
Time: ts,
Cpu: 42.5,
Ram: 123456,
RamTotal: 999999,
Disk: 456789,
DiskTotal: 777777,
Load: 0.75,
Connections: 321,
ConnectionsUdp: 12,
}
if _, err := WriteReport(ctx, v1.Report{
UUID: rec.Client,
UpdatedAt: ts,
CPU: v1.CPUReport{Usage: float64(rec.Cpu)},
Ram: v1.RamReport{Used: rec.Ram, Total: rec.RamTotal},
Load: v1.LoadReport{Load1: float64(rec.Load)},
Disk: v1.DiskReport{Used: rec.Disk, Total: rec.DiskTotal},
Process: rec.Process,
Connections: v1.ConnectionsReport{
TCP: rec.Connections,
UDP: rec.ConnectionsUdp,
},
}); err != nil {
t.Fatalf("write record: %v", err)
}
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)
}
if len(got) != 1 {
t.Fatalf("expected 1 reconstructed record from rollup, got %d: %#v", len(got), got)
}
if got[0].Cpu == 0 || got[0].Ram == 0 || got[0].Disk == 0 || got[0].Connections == 0 {
t.Fatalf("record was not reconstructed from rollup: %#v", got[0])
}
all, err := GetRecordsByTime(ctx, ts.Add(-time.Minute), now)
if err != nil {
t.Fatalf("get all records: %v", err)
}
if len(all) != 1 || all[0].Client != rec.Client || all[0].Cpu == 0 {
t.Fatalf("all-client records were not reconstructed from rollup: %#v", all)
}
}
-210
View File
@@ -1,210 +0,0 @@
package metricstore
import (
"context"
"fmt"
logger "github.com/komari-monitor/komari/utils/log"
"strings"
"time"
"github.com/komari-monitor/komari/pkg/metric"
)
// storeMigrationWindow 是 store-to-store 迁移时按时间分窗查询的窗口大小。
// 采样点按时间窗口分批拉取,避免一次性把整段序列读入内存。
const storeMigrationWindow = 6 * time.Hour
// configFromFingerprint 从目标指纹(driver|dsn)重建一个 MetricStoreConfig,
// 用于以只读方式打开“上一次的 metrics 目标库”。表前缀、保留天数、连接数等
// 沿用当前配置(切换后端时这些通常不变)。
func configFromFingerprint(fingerprint string, base *MetricStoreConfig) (*MetricStoreConfig, error) {
idx := strings.Index(fingerprint, "|")
if idx < 0 {
return nil, fmt.Errorf("invalid target fingerprint: %q", fingerprint)
}
driver := fingerprint[:idx]
dsn := fingerprint[idx+1:]
if strings.TrimSpace(driver) == "" {
return nil, fmt.Errorf("empty driver in target fingerprint: %q", fingerprint)
}
return &MetricStoreConfig{
Driver: driver,
DSN: dsn,
TablePrefix: base.TablePrefix,
MaxOpenConns: base.MaxOpenConns,
MaxIdleConns: base.MaxIdleConns,
}, nil
}
// openSourceStore 打开一个已存在的 metrics 目标库作为数据搬运的源库读取。
//
// 使用 autoMigrate=true:GORM 的 AutoMigrate 只新增表/列/索引、从不删除数据,
// 因此对真实旧库幂等无害;而当源库文件/表不存在(例如老快照记录了 completed
// 但 metrics.db 缺失)时,可创建空表让后续 ListMetrics 返回空集而非报
// "no such table",从而把“无历史可迁移”识别为正常情况而非错误。
func openSourceStore(ctx context.Context, cfg *MetricStoreConfig) (*metric.Store, error) {
metricCfg, err := buildMetricConfig(cfg, true)
if err != nil {
return nil, err
}
return metric.Open(ctx, metricCfg)
}
// defaultSQLiteFingerprint 返回默认 SQLite metrics 库(./data/metrics.db)的目标指纹。
// 老快照的 metrics 数据固定落在该 SQLite 文件,用于在缺失指纹时推断上一个源库。
func defaultSQLiteFingerprint() string {
return targetFingerprint(&MetricStoreConfig{Driver: "sqlite", DSN: "./data/metrics.db"})
}
// migrateFromPreviousStore 打开由 prevFingerprint 指定的上一个 metrics 目标库作为源,
// 把其中的全部指标搬运到当前目标 dst(例如 SQLite metrics.db → MySQL/PostgreSQL)。
//
// 该过程幂等:dst 以 (metric, entity, tags, ts) upsert 写入,中断后重启可安全重跑。
func migrateFromPreviousStore(prevFingerprint string, cfg *MetricStoreConfig, dst *metric.Store) error {
prevCfg, err := configFromFingerprint(prevFingerprint, cfg)
if err != nil {
return fmt.Errorf("parse previous metrics target %q: %w", prevFingerprint, err)
}
ctx := context.Background()
src, err := openSourceStore(ctx, prevCfg)
if err != nil {
return fmt.Errorf("open previous metrics store (%s): %w", prevFingerprint, err)
}
defer src.Close()
logger.Infof("metricstore", "Migrating metrics from previous store %s to current target...", prevFingerprint)
total, err := MigrateBetweenStores(ctx, src, dst)
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)
return nil
}
// storeMigrationObserver 在 store-to-store 迁移过程中接收进度回调。
// - currentMetric:当前正在搬运的指标名。
// - metricIndex:该指标在全部指标中的序号(0 起),即已完成的指标数。
// - totalMetrics:指标定义总数。
// - addedPoints:本次新写入目标库的采样点数(用于外部累计)。
type storeMigrationObserver func(currentMetric string, metricIndex, totalMetrics int, addedPoints int64)
// MigrateBetweenStores 把 src metric store 的全部指标定义与采样点搬运到 dst。
//
// 用于 metrics 后端切换(例如默认 SQLite metrics.db → MySQL/PostgreSQL):
// - 先在 dst 建立/更新全部指标定义;
// - 再按指标、按时间窗口分批读取源库采样点并写入 dst。
//
// 采样点在 dst 以 (metric_name, entity_id, tags, ts) upsert 写入,窗口边界重叠也
// 幂等,因此整个过程可安全重试。返回搬运的采样点总数。
func MigrateBetweenStores(ctx context.Context, src, dst *metric.Store) (int64, error) {
return migrateBetweenStores(ctx, src, dst, nil)
}
// migrateBetweenStores 是 MigrateBetweenStores 的内部实现,额外接受一个可选的进度
// 观察者 observe(为 nil 时行为与旧版本完全一致)。WebUI/API 手动触发的迁移传入
// 回调以实时更新进度。
func migrateBetweenStores(ctx context.Context, src, dst *metric.Store, observe storeMigrationObserver) (int64, error) {
if src == nil || dst == nil {
return 0, fmt.Errorf("source or destination metric store is nil")
}
defs, err := src.ListMetrics(ctx)
if err != nil {
return 0, fmt.Errorf("list source metrics: %w", err)
}
var total int64
for i, def := range defs {
if observe != nil {
// 进入下一个指标:已完成 i 个指标。
observe(def.Name, i, len(defs), 0)
}
// 目标库先建立指标定义,保证后续写入的指标存在。
if err := dst.UpsertMetric(ctx, def); err != nil {
return total, fmt.Errorf("upsert metric %q on target: %w", def.Name, err)
}
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:
}
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))
if observe != nil {
observe(def.Name, i, len(defs), int64(len(points)))
}
}
if migrated > 0 {
logger.Infof("metricstore", "[store-migration] metric %q: migrated %d points", 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
}
-38
View File
@@ -1,38 +0,0 @@
package metricstore
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.
type storeOperationGate struct {
token chan struct{}
}
func newStoreOperationGate() *storeOperationGate {
gate := &storeOperationGate{token: make(chan struct{}, 1)}
gate.token <- struct{}{}
return gate
}
func (g *storeOperationGate) Acquire(ctx context.Context) error {
select {
case <-g.token:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (g *storeOperationGate) TryAcquire() bool {
select {
case <-g.token:
return true
default:
return false
}
}
func (g *storeOperationGate) Release() {
g.token <- struct{}{}
}
-83
View File
@@ -1,83 +0,0 @@
package metricstore
import (
"context"
"path/filepath"
"testing"
"github.com/komari-monitor/komari/internal/config"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func prepareRecoveryTest(t *testing.T) {
t.Helper()
configDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open config db: %v", err)
}
config.SetDb(configDB)
storeInitMu.Lock()
storeMigMu.Lock()
previousClosing := storeClosing
storeClosing = false
storeMigMu.Unlock()
storeMu.Lock()
previousStore := store
previousFingerprint := storeFingerprint
store = nil
storeFingerprint = ""
storeMu.Unlock()
storeInitMu.Unlock()
t.Cleanup(func() {
_ = CloseStoreContext(context.Background())
storeMigMu.Lock()
storeClosing = previousClosing
storeMigMu.Unlock()
storeMu.Lock()
store = previousStore
storeFingerprint = previousFingerprint
storeMu.Unlock()
})
}
func TestRecoverStoreFailureKeepsCurrentConfig(t *testing.T) {
prepareRecoveryTest(t)
if err := config.SetMany(map[string]any{
MetricDBDriverKey: "sqlite",
MetricDBDSNKey: "old.db",
}); err != nil {
t.Fatalf("save original config: %v", err)
}
missing := "file:" + filepath.ToSlash(filepath.Join(t.TempDir(), "missing.db")) + "?mode=ro"
err := RecoverStore(context.Background(), &MetricStoreConfig{Driver: "sqlite", DSN: missing})
if err == nil {
t.Fatal("recovery unexpectedly opened a missing read-only database")
}
if got, _ := config.GetAs[string](MetricDBDSNKey, ""); got != "old.db" {
t.Fatalf("DSN changed after failed recovery: %q", got)
}
if GetStore() != nil {
t.Fatal("failed recovery installed a store")
}
}
func TestRecoverStoreRecordsManualMigrationSource(t *testing.T) {
prepareRecoveryTest(t)
dsn := filepath.ToSlash(filepath.Join(t.TempDir(), "recovered.db"))
t.Cleanup(func() { _ = CloseStoreContext(context.Background()) })
cfg := &MetricStoreConfig{Driver: "sqlite", DSN: dsn}
if err := RecoverStore(context.Background(), cfg); err != nil {
t.Fatalf("recover store: %v", err)
}
if GetStore() == nil {
t.Fatal("successful recovery did not install a store")
}
wantTarget := targetFingerprint(cfg)
if got, _ := config.GetAs[string](MigrationTargetKey, ""); got != wantTarget {
t.Fatalf("migration target = %q, want %q", got, wantTarget)
}
}
-22
View File
@@ -1,22 +0,0 @@
package metricstore
import (
"regexp"
"strings"
)
var (
metricCredentialPattern = regexp.MustCompile(`(?i)\b(password|passwd)\s*=\s*('[^']*'|"[^"]*"|[^\s,;)&]+)`)
metricURLCredentialPattern = regexp.MustCompile(`(?i)([a-z0-9_.-]+):([^@\s]+)@`)
)
// RedactConnectionError removes the supplied DSN and common password fragments
// before a connection error is shown to an administrator or written to a log.
func RedactConnectionError(message, dsn string) string {
message = strings.TrimSpace(message)
if dsn != "" {
message = strings.ReplaceAll(message, dsn, "[redacted]")
}
message = metricCredentialPattern.ReplaceAllString(message, "$1=[redacted]")
return metricURLCredentialPattern.ReplaceAllString(message, "$1:[redacted]@")
}
-648
View File
@@ -1,648 +0,0 @@
package metricstore
import (
"context"
"errors"
"fmt"
logger "github.com/komari-monitor/komari/utils/log"
"runtime"
"sort"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/metric"
v1 "github.com/komari-monitor/komari/protocol/v1"
)
type reportTrafficState struct {
mu sync.Mutex
reportTrafficValues
}
type reportTrafficValues struct {
initialized bool
timestamp time.Time
hasUp bool
totalUp int64
hasDown bool
totalDown int64
}
var reportTrafficStates sync.Map
const (
reportBatchInterval = 3 * time.Second
lowResourceReportWindow = 30 * time.Second
reportBatchQueueSize = 4096
lowResourceBatchMaxReports = 128
pingBatchMaxRecords = 512
reportBatchWriteTimeout = 10 * time.Second
)
var (
reportBatcherMu sync.Mutex
reportBatcher *reportBatchWorker
lowResourceMode atomic.Bool
droppedReports atomic.Uint64
)
var (
ErrReportBatchQueueFull = errors.New("metric report batch queue is full")
ErrReportBatchStopped = errors.New("metric report batcher is stopped")
ErrPingBatchQueueFull = errors.New("ping batch queue is full")
)
type reportBatchRequest struct {
ctx context.Context
done chan error
stop bool
}
type reportBatchWorker struct {
mu sync.Mutex
queue chan v1.Report
pingQueue chan models.PingRecord
requests chan reportBatchRequest
done chan 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.
func StartReportBatcher() {
reportBatcherMu.Lock()
defer reportBatcherMu.Unlock()
if reportBatcher != nil {
return
}
worker := &reportBatchWorker{
queue: make(chan v1.Report, reportBatchQueueSize),
pingQueue: make(chan models.PingRecord, reportBatchQueueSize),
requests: make(chan reportBatchRequest, 1),
done: make(chan struct{}),
}
reportBatcher = worker
go worker.run()
}
// StopReportBatcher stops the report writer after flushing all queued reports.
func StopReportBatcher(ctx context.Context) error {
reportBatcherMu.Lock()
worker := reportBatcher
if worker == nil {
reportBatcherMu.Unlock()
return nil
}
worker.mu.Lock()
worker.stopping = true
worker.mu.Unlock()
reportBatcherMu.Unlock()
request := reportBatchRequest{ctx: ctx, done: make(chan error, 1), stop: true}
select {
case worker.requests <- request:
case <-ctx.Done():
return ctx.Err()
}
select {
case err := <-request.done:
<-worker.done
reportBatcherMu.Lock()
if reportBatcher == worker {
reportBatcher = nil
}
reportBatcherMu.Unlock()
return err
case <-ctx.Done():
return ctx.Err()
}
}
// FlushReportBatch synchronously flushes the current queue. It is useful for
// controlled handoff points and deterministic tests; normal operation uses the
// worker ticker and the active mode's flush interval.
func FlushReportBatch(ctx context.Context) error {
reportBatcherMu.Lock()
worker := reportBatcher
reportBatcherMu.Unlock()
if worker == nil {
return nil
}
request := reportBatchRequest{ctx: ctx, done: make(chan error, 1)}
worker.mu.Lock()
stopping := worker.stopping
worker.mu.Unlock()
if stopping {
return ErrReportBatchStopped
}
select {
case worker.requests <- request:
case <-worker.done:
return ErrReportBatchStopped
case <-ctx.Done():
return ctx.Err()
}
select {
case err := <-request.done:
return err
case <-ctx.Done():
return ctx.Err()
}
}
// 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.
func WriteReport(ctx context.Context, report v1.Report) (v1.Report, error) {
if report.UUID == "" {
return v1.Report{}, fmt.Errorf("report UUID is required")
}
if report.UpdatedAt.IsZero() {
return v1.Report{}, fmt.Errorf("report receive time is required")
}
report.UpdatedAt = report.UpdatedAt.UTC()
if GetStore() == nil {
return v1.Report{}, fmt.Errorf("metric store not enabled")
}
reportBatcherMu.Lock()
worker := reportBatcher
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
}
saved, err := writeReportBatch(ctx, []v1.Report{report})
if err != nil {
return v1.Report{}, err
}
return saved[0], nil
}
func (w *reportBatchWorker) enqueue(ctx context.Context, report v1.Report) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.stopping {
return ErrReportBatchStopped
}
if err := ctx.Err(); err != nil {
return err
}
select {
case w.queue <- report:
return nil
default:
return ErrReportBatchQueueFull
}
}
func (w *reportBatchWorker) run() {
ticker := time.NewTicker(reportBatchInterval)
defer ticker.Stop()
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()),
writePendingPingRecords(request.ctx, &pendingPings),
)
if request.stop {
if err != nil {
logger.Errorf("metricstore", "failed to flush metric report batch during shutdown: %v", err)
}
close(w.done)
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 {
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)
}
}
}
}
}
func (w *reportBatchWorker) enqueuePing(ctx context.Context, record models.PingRecord) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.stopping {
return ErrReportBatchStopped
}
if err := ctx.Err(); err != nil {
return err
}
select {
case w.pingQueue <- record:
return nil
default:
return ErrPingBatchQueueFull
}
}
func drainReportQueue(queue <-chan v1.Report, limit int) []v1.Report {
if limit <= 0 {
return nil
}
reports := make([]v1.Report, 0, limit)
for len(reports) < limit {
select {
case report := <-queue:
reports = append(reports, report)
default:
return reports
}
}
return reports
}
func drainPingQueue(queue <-chan models.PingRecord, limit int) []models.PingRecord {
if limit <= 0 {
return nil
}
records := make([]models.PingRecord, 0, limit)
for len(records) < limit {
select {
case record := <-queue:
records = append(records, record)
default:
return records
}
}
return records
}
func writePendingReports(ctx context.Context, pending *[]v1.Report, lowResource bool) 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)
}
writeCtx, cancel := context.WithTimeout(ctx, reportBatchWriteTimeout)
_, err := writeReportBatch(writeCtx, (*pending)[:batchSize])
cancel()
if err != nil {
return err
}
*pending = (*pending)[batchSize:]
if lowResource {
runtime.Gosched()
}
}
return nil
}
func writePendingPingRecords(ctx context.Context, pending *[]models.PingRecord) error {
for len(*pending) > 0 {
batchSize := len(*pending)
if batchSize > pingBatchMaxRecords {
batchSize = pingBatchMaxRecords
}
writeCtx, cancel := context.WithTimeout(ctx, reportBatchWriteTimeout)
err := writePingRecords(writeCtx, (*pending)[:batchSize])
cancel()
if err != nil {
return err
}
*pending = (*pending)[batchSize:]
}
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 {
return nil, fmt.Errorf("wait for metric store operation before writing reports: %w", err)
}
defer storeOperations.Release()
s := GetStore()
if s == nil {
return nil, fmt.Errorf("metric store not enabled")
}
prepared := make([]v1.Report, len(reports))
copy(prepared, reports)
points := make([]metric.Point, 0, len(reports)*20)
pendingStates := make(map[*reportTrafficState]reportTrafficValues)
for i, report := range prepared {
stateValue, _ := reportTrafficStates.LoadOrStore(report.UUID, &reportTrafficState{})
state := stateValue.(*reportTrafficState)
values, ok := pendingStates[state]
if !ok {
state.mu.Lock()
values = state.reportTrafficValues
state.mu.Unlock()
}
if !values.initialized {
totalUp, hasUp, err := latestReportCounter(ctx, s, MetricNetTotalUp, report.UUID, report.UpdatedAt)
if err == nil {
values.totalUp = totalUp
values.hasUp = hasUp
} else if ctx.Err() == nil {
logger.Errorf("metricstore", "failed to restore previous upload counter for %s: %v", report.UUID, err)
}
totalDown, hasDown, err := latestReportCounter(ctx, s, MetricNetTotalDown, report.UUID, report.UpdatedAt)
if err == nil {
values.totalDown = totalDown
values.hasDown = hasDown
} else if ctx.Err() == nil {
logger.Errorf("metricstore", "failed to restore previous download counter for %s: %v", report.UUID, err)
}
if err := ctx.Err(); err != nil {
return nil, err
}
values.initialized = true
}
if !values.timestamp.IsZero() && !report.UpdatedAt.After(values.timestamp) {
report.UpdatedAt = values.timestamp.Add(time.Nanosecond)
}
trafficUp := int64(0)
if values.hasUp {
trafficUp = TrafficCounterDelta(report.Network.TotalUp, values.totalUp)
}
trafficDown := int64(0)
if values.hasDown {
trafficDown = TrafficCounterDelta(report.Network.TotalDown, values.totalDown)
}
points = append(points, reportMetricPoints(report, trafficUp, trafficDown)...)
values.timestamp = report.UpdatedAt
values.hasUp = true
values.totalUp = report.Network.TotalUp
values.hasDown = true
values.totalDown = report.Network.TotalDown
pendingStates[state] = values
prepared[i] = report
}
if err := s.WriteBatch(ctx, points); err != nil {
return nil, err
}
for state, values := range pendingStates {
state.mu.Lock()
state.reportTrafficValues = values
state.mu.Unlock()
}
return prepared, nil
}
func reportMetricPoints(report v1.Report, trafficUp, trafficDown int64) []metric.Point {
entityID := report.UUID
ts := report.UpdatedAt
points := []metric.Point{
{MetricName: MetricCPU, EntityID: entityID, Timestamp: ts, Value: report.CPU.Usage},
{MetricName: MetricRAM, EntityID: entityID, Timestamp: ts, Value: float64(report.Ram.Used)},
{MetricName: MetricSwap, EntityID: entityID, Timestamp: ts, Value: float64(report.Swap.Used)},
{MetricName: MetricLoad, EntityID: entityID, Timestamp: ts, Value: report.Load.Load1},
{MetricName: MetricDisk, EntityID: entityID, Timestamp: ts, Value: float64(report.Disk.Used)},
{MetricName: MetricNetIn, EntityID: entityID, Timestamp: ts, Value: float64(report.Network.Down)},
{MetricName: MetricNetOut, EntityID: entityID, Timestamp: ts, Value: float64(report.Network.Up)},
{MetricName: MetricNetTotalUp, EntityID: entityID, Timestamp: ts, Value: float64(report.Network.TotalUp)},
{MetricName: MetricNetTotalDown, EntityID: entityID, Timestamp: ts, Value: float64(report.Network.TotalDown)},
{MetricName: MetricTrafficUp, EntityID: entityID, Timestamp: ts, Value: float64(trafficUp)},
{MetricName: MetricTrafficDown, EntityID: entityID, Timestamp: ts, Value: float64(trafficDown)},
{MetricName: MetricProcess, EntityID: entityID, Timestamp: ts, Value: float64(report.Process)},
{MetricName: MetricConnections, EntityID: entityID, Timestamp: ts, Value: float64(report.Connections.TCP)},
{MetricName: MetricConnectionsUDP, EntityID: entityID, Timestamp: ts, Value: float64(report.Connections.UDP)},
}
if report.GPU == nil {
return points
}
points = append(points, metric.Point{MetricName: MetricGPU, EntityID: entityID, Timestamp: ts, Value: report.GPU.AverageUsage})
for deviceIndex, gpu := range report.GPU.DetailedInfo {
tags := map[string]string{
"device_index": strconv.Itoa(deviceIndex),
"device_name": gpu.Name,
}
points = append(points,
metric.Point{MetricName: MetricGPUMem, EntityID: entityID, Timestamp: ts, Value: float64(gpu.MemoryUsed), Tags: tags},
metric.Point{MetricName: MetricGPUMemTotal, EntityID: entityID, Timestamp: ts, Value: float64(gpu.MemoryTotal), Tags: tags},
metric.Point{MetricName: MetricGPUDeviceUsage, EntityID: entityID, Timestamp: ts, Value: gpu.Utilization, Tags: tags},
metric.Point{MetricName: MetricGPUTemp, EntityID: entityID, Timestamp: ts, Value: float64(gpu.Temperature), Tags: tags},
)
}
return points
}
func latestReportCounter(ctx context.Context, s *metric.Store, metricName, entityID string, before time.Time) (int64, bool, error) {
point, ok, err := s.LatestBefore(ctx, metricName, entityID, before)
if err != nil {
return 0, false, err
}
if !ok {
return 0, false, nil
}
return int64(point.Value), true, nil
}
// GetLatestTrafficBefore returns the latest retained upload/download counters
// before a boundary, transparently reading raw points or rollups.
func GetLatestTrafficBefore(ctx context.Context, entityIDs []string, before time.Time) (map[string]models.Record, error) {
s := GetStore()
if s == nil {
return nil, fmt.Errorf("metric store not enabled")
}
result := make(map[string]models.Record, len(entityIDs))
for _, entityID := range entityIDs {
if entityID == "" {
continue
}
up, hasUp, err := latestReportCounter(ctx, s, MetricNetTotalUp, entityID, before)
if err != nil {
return nil, err
}
down, hasDown, err := latestReportCounter(ctx, s, MetricNetTotalDown, entityID, before)
if err != nil {
return nil, err
}
if !hasUp && !hasDown {
continue
}
result[entityID] = models.Record{
Client: entityID,
Time: before.UTC().Add(-time.Nanosecond),
NetTotalUp: up,
NetTotalDown: down,
}
}
return result, nil
}
// TrafficCounterDelta returns a reset-aware increase between two cumulative
// traffic counters. After a reset, the current counter is the new increase.
func TrafficCounterDelta(current, previous int64) int64 {
if current < 0 || previous < 0 {
return 0
}
if current >= previous {
return current - previous
}
return current
}
func deleteReportTrafficState(entityID string) {
reportTrafficStates.Delete(entityID)
}
func clearReportTrafficStates() {
reportTrafficStates.Range(func(key, _ any) bool {
reportTrafficStates.Delete(key)
return true
})
}
-465
View File
@@ -1,465 +0,0 @@
package metricstore
import (
"context"
"fmt"
"testing"
"time"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/metric"
v1 "github.com/komari-monitor/komari/protocol/v1"
)
func useReportTestStore(t *testing.T, policy *metric.RollupPolicy) *metric.Store {
t.Helper()
ctx := context.Background()
opts := []metric.Option{metric.WithMaxOpenConns(1)}
if policy != nil {
opts = append(opts, metric.WithRollupPolicy(*policy))
}
dsn := fmt.Sprintf("file:report-%d?mode=memory&cache=shared", time.Now().UnixNano())
s, err := metric.Open(ctx, metric.SQLite(dsn, opts...))
if err != nil {
t.Fatalf("open metric store: %v", err)
}
if err := createMetricDefinitions(ctx, s); err != nil {
_ = s.Close()
t.Fatalf("create metric definitions: %v", err)
}
storeMu.Lock()
previous := store
store = s
storeMu.Unlock()
clearReportTrafficStates()
t.Cleanup(func() {
clearReportTrafficStates()
storeMu.Lock()
store = previous
storeMu.Unlock()
_ = s.Close()
})
return s
}
func TestWriteReportStoresRawMetricsAndResetAwareTraffic(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)
report := v1.Report{
UUID: "node-a",
UpdatedAt: base,
CPU: v1.CPUReport{Usage: 12.5},
Ram: v1.RamReport{Used: 100, Total: 1000},
Swap: v1.RamReport{Used: 20, Total: 200},
Load: v1.LoadReport{Load1: 0.5},
Disk: v1.DiskReport{Used: 300, Total: 3000},
Network: v1.NetworkReport{Up: 3, Down: 4, TotalUp: 100, TotalDown: 200},
Process: 7,
Connections: v1.ConnectionsReport{TCP: 8, UDP: 9},
GPU: &v1.GPUDetailReport{
AverageUsage: 25,
DetailedInfo: []v1.GPUDeviceInfo{{
Name: "GPU 0", MemoryUsed: 400, MemoryTotal: 800, Utilization: 30, Temperature: 55,
}},
},
}
if _, err := WriteReport(ctx, report); err != nil {
t.Fatalf("write first report: %v", err)
}
report.UpdatedAt = base.Add(3 * time.Second)
report.Network.TotalUp = 150
report.Network.TotalDown = 260
if _, err := WriteReport(ctx, report); err != nil {
t.Fatalf("write second report: %v", err)
}
report.UpdatedAt = base.Add(6 * time.Second)
report.Network.TotalUp = 20
report.Network.TotalDown = 30
if _, err := WriteReport(ctx, report); err != nil {
t.Fatalf("write reset report: %v", err)
}
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})
gpuPoints, err := s.Query(ctx, metric.Query{
MetricName: MetricGPUDeviceUsage,
EntityID: report.UUID,
Start: base.Add(-time.Second),
End: base.Add(time.Minute),
Tags: map[string]string{"device_index": "0"},
Order: metric.OrderAsc,
})
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" {
t.Fatalf("unexpected GPU points: %#v", gpuPoints)
}
if _, err := s.Compact(ctx, now); err != nil {
t.Fatalf("compact reports: %v", err)
}
deleteReportTrafficState(report.UUID)
report.UpdatedAt = now
report.Network.TotalUp = 35
report.Network.TotalDown = 50
if _, err := WriteReport(ctx, report); err != nil {
t.Fatalf("write after restoring rollup baseline: %v", err)
}
assertMetricValues(t, s, MetricTrafficUp, report.UUID, now.Add(-time.Second), now.Add(time.Second), []float64{15})
assertMetricValues(t, s, MetricTrafficDown, report.UUID, now.Add(-time.Second), now.Add(time.Second), []float64{20})
}
func TestWriteReportSkipsMetricsWithoutAgentData(t *testing.T) {
ctx := context.Background()
s := useReportTestStore(t, nil)
timestamp := time.Now().UTC()
if _, err := WriteReport(ctx, v1.Report{
UUID: "node-without-gpu", UpdatedAt: timestamp,
}); err != nil {
t.Fatalf("write report: %v", err)
}
points, err := s.Query(ctx, metric.Query{
MetricName: MetricGPU, EntityID: "node-without-gpu",
Start: timestamp.Add(-time.Second), End: timestamp.Add(time.Second),
})
if err != nil {
t.Fatalf("query GPU metric: %v", err)
}
if len(points) != 0 {
t.Fatalf("GPU metric was written without GPU data: %#v", points)
}
}
func TestReportBatcherFlushesQueuedReports(t *testing.T) {
ctx := context.Background()
s := useReportTestStore(t, nil)
StartReportBatcher()
t.Cleanup(func() {
if err := StopReportBatcher(ctx); err != nil {
t.Errorf("stop report batcher: %v", err)
}
})
base := time.Now().UTC().Truncate(time.Second)
first := v1.Report{
UUID: "batched-node",
UpdatedAt: base,
CPU: v1.CPUReport{Usage: 10},
Network: v1.NetworkReport{TotalUp: 100, TotalDown: 200},
}
second := first
second.UpdatedAt = base.Add(3 * time.Second)
second.CPU.Usage = 20
second.Network.TotalUp = 150
second.Network.TotalDown = 260
if _, err := WriteReport(ctx, first); err != nil {
t.Fatalf("queue first report: %v", err)
}
if _, err := WriteReport(ctx, second); err != nil {
t.Fatalf("queue second report: %v", err)
}
points, err := s.Query(ctx, metric.Query{
MetricName: MetricCPU,
EntityID: first.UUID,
Start: base.Add(-time.Second),
End: base.Add(time.Minute),
Order: metric.OrderAsc,
})
if err != nil {
t.Fatalf("query before flush: %v", err)
}
if len(points) != 0 {
t.Fatalf("queued reports were written before flush: %#v", points)
}
if err := FlushReportBatch(ctx); err != nil {
t.Fatalf("flush report batch: %v", err)
}
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})
}
func TestPingBatcherFlushesLatencyAndLossTogether(t *testing.T) {
ctx := context.Background()
s := useReportTestStore(t, nil)
StartReportBatcher()
t.Cleanup(func() {
if err := StopReportBatcher(ctx); err != nil {
t.Errorf("stop report batcher: %v", err)
}
})
base := time.Now().UTC().Truncate(time.Second)
records := []models.PingRecord{
{Client: "ping-node", TaskId: 7, Time: base, Value: 24},
{Client: "ping-node", TaskId: 7, Time: base.Add(time.Minute), Value: -1},
{Client: "ping-node", TaskId: 8, Time: base, Value: 31},
}
for _, record := range records {
if err := WritePingRecord(ctx, record); err != nil {
t.Fatalf("queue ping record: %v", err)
}
}
for _, name := range []string{MetricPingLatency, MetricPingLoss} {
points, err := s.Query(ctx, metric.Query{
MetricName: name,
EntityID: "ping-node",
Start: base.Add(-time.Second),
End: base.Add(2 * time.Minute),
})
if err != nil {
t.Fatalf("query queued %s points: %v", name, err)
}
if len(points) != 0 {
t.Fatalf("queued %s points were written before flush: %#v", name, points)
}
}
if err := FlushReportBatch(ctx); err != nil {
t.Fatalf("flush ping batch: %v", err)
}
latency, err := s.Query(ctx, metric.Query{
MetricName: MetricPingLatency,
EntityID: "ping-node",
Tags: map[string]string{"task_id": "7"},
Start: base.Add(-time.Second),
End: base.Add(2 * time.Minute),
Order: metric.OrderAsc,
})
if err != nil {
t.Fatalf("query flushed latency points: %v", err)
}
if len(latency) != 2 || latency[0].Value != 24 || latency[1].Value != -1 {
t.Fatalf("latency points = %#v, want both original samples", latency)
}
loss, err := s.Query(ctx, metric.Query{
MetricName: MetricPingLoss,
EntityID: "ping-node",
Tags: map[string]string{"task_id": "7"},
Start: base.Add(-time.Second),
End: base.Add(2 * time.Minute),
Order: metric.OrderAsc,
})
if err != nil {
t.Fatalf("query flushed loss points: %v", err)
}
if len(loss) != 2 || loss[0].Value != 0 || loss[1].Value != 1 {
t.Fatalf("loss points = %#v, want success and loss samples", loss)
}
}
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) {
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-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 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})
}
func TestLowResourceQueueFullDoesNotBlockRealtimeReport(t *testing.T) {
ctx := context.Background()
useReportTestStore(t, nil)
worker := &reportBatchWorker{
queue: make(chan v1.Report, 1),
requests: make(chan reportBatchRequest, 1),
done: make(chan struct{}),
}
worker.queue <- v1.Report{UUID: "already-queued"}
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())
}
}
func TestRecordReconstructionUsesMetricSpecificAggregation(t *testing.T) {
ctx := context.Background()
s := useReportTestStore(t, nil)
base := time.Now().UTC().Truncate(time.Minute)
entityID := "node-aggregation"
points := []metric.Point{
{MetricName: MetricCPU, EntityID: entityID, Timestamp: base.Add(time.Second), Value: 10},
{MetricName: MetricCPU, EntityID: entityID, Timestamp: base.Add(2 * time.Second), Value: 30},
{MetricName: MetricNetTotalUp, EntityID: entityID, Timestamp: base.Add(time.Second), Value: 100},
{MetricName: MetricNetTotalUp, EntityID: entityID, Timestamp: base.Add(2 * time.Second), Value: 200},
{MetricName: MetricTrafficUp, EntityID: entityID, Timestamp: base.Add(time.Second), Value: 10},
{MetricName: MetricTrafficUp, EntityID: entityID, Timestamp: base.Add(2 * time.Second), Value: 20},
}
if err := s.WriteBatch(ctx, points); err != nil {
t.Fatalf("write points: %v", err)
}
records, err := GetRecordsByClientAndTime(ctx, entityID, base, base.Add(time.Hour))
if err != nil {
t.Fatalf("reconstruct records: %v", err)
}
if len(records) != 1 {
t.Fatalf("records = %#v, want one bucket", records)
}
if records[0].Cpu != 20 || records[0].NetTotalUp != 200 || records[0].TrafficUp != 30 {
t.Fatalf("unexpected aggregation result: %#v", records[0])
}
}
func TestTrafficCounterDelta(t *testing.T) {
tests := []struct {
name string
current int64
previous int64
want int64
}{
{name: "previous zero", current: 120, previous: 0, want: 120},
{name: "monotonic counter", current: 250, previous: 200, want: 50},
{name: "unchanged counter", current: 100, previous: 100, want: 0},
{name: "counter reset", current: 15, previous: 250, want: 15},
{name: "negative current", current: -1, previous: 100, want: 0},
{name: "negative previous", current: 15, previous: -1, want: 0},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := TrafficCounterDelta(test.current, test.previous); got != test.want {
t.Fatalf("TrafficCounterDelta(%d, %d) = %d, want %d", test.current, test.previous, got, test.want)
}
})
}
}
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)
report := v1.Report{
UUID: "utc-report",
UpdatedAt: receiveTime,
CPU: v1.CPUReport{Usage: 10},
Network: v1.NetworkReport{TotalUp: 1, TotalDown: 2},
}
saved, err := WriteReport(ctx, report)
if err != nil {
t.Fatalf("write report: %v", err)
}
if !saved.UpdatedAt.Equal(receiveTime) || saved.UpdatedAt.Location() != time.UTC {
t.Fatalf("saved receive time = %s (%s), want UTC", saved.UpdatedAt, saved.UpdatedAt.Location())
}
points, err := s.Query(ctx, metric.Query{
MetricName: MetricCPU,
EntityID: report.UUID,
Start: receiveTime.Add(-time.Nanosecond),
End: receiveTime.Add(time.Nanosecond),
})
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)
}
}
func assertMetricValues(t *testing.T, s *metric.Store, metricName, entityID string, start, end time.Time, want []float64) {
t.Helper()
points, err := s.Query(context.Background(), metric.Query{
MetricName: metricName,
EntityID: entityID,
Start: start,
End: end,
Order: metric.OrderAsc,
})
if err != nil {
t.Fatalf("query %s: %v", metricName, err)
}
if len(points) != len(want) {
t.Fatalf("%s point count = %d, want %d: %#v", metricName, len(points), len(want), points)
}
for i := range want {
if points[i].Value != want[i] {
t.Fatalf("%s point %d = %v, want %v", metricName, i, points[i].Value, want[i])
}
}
}
-309
View File
@@ -1,309 +0,0 @@
package metricstore
import (
"context"
"fmt"
logger "github.com/komari-monitor/komari/utils/log"
"strings"
"sync"
"time"
"github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/pkg/metric"
)
// store_migration.go
//
// 面向 WebUI/API 的“存储后端迁移”支撑:把一个 metrics 源库(默认本地 SQLite
// ./data/metrics.db)中的全部指标数据搬运到当前正在运行的 metrics 目标库
// (通常是用户新配置并热重载后的 MySQL/PostgreSQL)。
//
// 此迁移仅能由管理员在 WebUI 中「切换数据库后」手动触发,异步执行并可查询进度 /
// 取消。服务启动和指标库热重载均不会复制历史数据。数据以 upsert 写入,可安全重复执行。
//
// 典型流程:
// 1. 管理员在设置中把 metric_db_dsn 改为 MySQL/PostgreSQL,保存 → 后端连接测试
// 通过后热重载,当前 store 切到远端(此时远端为空)。
// 2. 管理员调用 startMetricMigration(不带 source 参数),系统以上一个 metrics
// 目标(默认 SQLite metrics.db)为源,把历史数据搬运到远端。
// 3. 完成后登记目标指纹,供下一次手动迁移默认选择源库。
// StoreMigrationProgress 描述一次 store-to-store 迁移的实时进度。
type StoreMigrationProgress struct {
Status string `json:"status"` // idle, running, completed, failed, canceled
SourceDriver string `json:"source_driver"` // 源库驱动
SourceDSN string `json:"source_dsn"` // 源库 DSN(脱敏后)
TargetDriver string `json:"target_driver"` // 目标库驱动
TargetDSN string `json:"target_dsn"` // 目标库 DSN(脱敏后)
TotalMetrics int `json:"total_metrics"` // 指标定义总数
CurrentMetric string `json:"current_metric"` // 当前正在搬运的指标名
MetricsDone int `json:"metrics_done"` // 已完成的指标数
MigratedPoints int64 `json:"migrated_points"` // 已搬运的采样点数
StartTime time.Time `json:"start_time"` // 开始时间
EndTime time.Time `json:"end_time"` // 结束时间
Error string `json:"error,omitempty"` // 错误信息
}
var (
storeMigMu sync.Mutex
storeMigProgress StoreMigrationProgress
storeMigCancel context.CancelFunc
storeMigDone chan struct{}
storeClosing bool
)
// IsStoreMigrationRunning 报告是否有 store-to-store 迁移正在运行。
func IsStoreMigrationRunning() bool {
storeMigMu.Lock()
defer storeMigMu.Unlock()
return storeMigCancel != nil
}
// GetStoreMigrationProgress 返回当前 store-to-store 迁移进度快照。
func GetStoreMigrationProgress() StoreMigrationProgress {
storeMigMu.Lock()
defer storeMigMu.Unlock()
return storeMigProgress
}
// ResolveStoreMigrationSourceFingerprint 推断本次 store 迁移的源库指纹。
//
// 优先级:
// 1. 显式传入的 driver+dsn(driver 可留空,交由 DSN 推断)。
// 2. 已保存的上一个 metrics 目标指纹(MigrationTargetKey)。
// 3. 默认 SQLite(./data/metrics.db)。
func ResolveStoreMigrationSourceFingerprint(driver, dsn string) string {
dsn = strings.TrimSpace(dsn)
if dsn != "" {
d := ResolveDriverFromConfig(driver, dsn)
return fmt.Sprintf("%s|%s", d, dsn)
}
if saved, _ := config.GetAs[string](MigrationTargetKey, ""); saved != "" {
return saved
}
return defaultSQLiteFingerprint()
}
// StartStoreMigration 异步启动一次 store-to-store 迁移:把源库(由 sourceDriver +
// sourceDSN 指定,留空则自动推断)的全部指标数据搬运到当前运行中的 metrics 目标库。
//
// 返回错误表示“未能启动”(例如已有迁移在跑、源与目标相同、目标未初始化等);
// 迁移过程中的错误通过 GetStoreMigrationProgress().Status == "failed" 与 Error 暴露。
func StartStoreMigration(sourceDriver, sourceDSN string) error {
cfg, err := config.GetManyAs[MetricStoreConfig]()
if err != nil {
return fmt.Errorf("failed to load metric store config: %w", err)
}
targetFP := targetFingerprint(cfg)
sourceFP := ResolveStoreMigrationSourceFingerprint(sourceDriver, sourceDSN)
if sourceFP == targetFP {
return fmt.Errorf("source and target metrics database are the same; nothing to migrate")
}
srcCfg, err := configFromFingerprint(sourceFP, cfg)
if err != nil {
return fmt.Errorf("resolve source metrics store: %w", err)
}
if !storeOperations.TryAcquire() {
return ErrStoreBusy
}
exclusiveLeaseHandedOff := false
defer func() {
if !exclusiveLeaseHandedOff {
storeOperations.Release()
}
}()
storeMu.RLock()
dst := store
activeFingerprint := storeFingerprint
storeMu.RUnlock()
if dst == nil {
return ErrStoreNotInitialized
}
if activeFingerprint != targetFP {
return fmt.Errorf("active metric store does not match the configured migration target; wait for hot reload and retry")
}
storeMigMu.Lock()
if storeClosing {
storeMigMu.Unlock()
return ErrStoreBusy
}
if storeMigCancel != nil {
storeMigMu.Unlock()
return fmt.Errorf("a store migration is already in progress")
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
storeMigCancel = cancel
storeMigDone = done
storeMigProgress = StoreMigrationProgress{
Status: "running",
SourceDriver: string(ResolveDriverFromConfig(srcCfg.Driver, srcCfg.DSN)),
SourceDSN: maskDSN(srcCfg.DSN),
TargetDriver: string(ResolveDriverFromConfig(cfg.Driver, cfg.DSN)),
TargetDSN: maskDSN(cfg.DSN),
StartTime: time.Now().UTC(),
}
storeMigMu.Unlock()
exclusiveLeaseHandedOff = true
go runStoreMigration(ctx, cancel, done, srcCfg, cfg, dst, targetFP)
return nil
}
// runStoreMigration 执行实际的搬运逻辑(在独立 goroutine 中)。
func runStoreMigration(ctx context.Context, cancel context.CancelFunc, done chan struct{}, srcCfg *MetricStoreConfig, cfg *MetricStoreConfig, dst *metric.Store, targetFP string) {
defer func() {
cancel()
storeOperations.Release()
storeMigMu.Lock()
storeMigCancel = nil
storeMigDone = nil
close(done)
storeMigMu.Unlock()
}()
src, err := openSourceStore(ctx, srcCfg)
if err != nil {
finishStoreMigration("failed", fmt.Errorf("open source metrics store: %w", err))
return
}
defer src.Close()
observe := func(currentMetric string, metricIndex, totalMetrics int, addedPoints int64) {
storeMigMu.Lock()
storeMigProgress.CurrentMetric = currentMetric
storeMigProgress.MetricsDone = metricIndex
storeMigProgress.TotalMetrics = totalMetrics
storeMigProgress.MigratedPoints += addedPoints
storeMigMu.Unlock()
}
total, err := migrateBetweenStores(ctx, src, dst, observe)
if err != nil {
if ctx.Err() != nil {
finishStoreMigration("canceled", nil)
logger.Warnf("metricstore", "[store-migration] canceled after %d points", total)
return
}
finishStoreMigration("failed", err)
return
}
// 搬运成功:登记当前目标指纹,供下一次手动迁移推断源库。
if err := config.Set(MigrationTargetKey, targetFP); err != nil {
logger.Errorf("metricstore", "[store-migration] failed to persist migration target fingerprint: %v", err)
}
finishStoreMigration("completed", nil)
logger.Infof("metricstore", "[store-migration] completed via API (%d points) target_driver=%s", total, ResolveDriverFromConfig(cfg.Driver, cfg.DSN))
}
// finishStoreMigration 统一收尾:设置终态状态、结束时间与错误信息。
func finishStoreMigration(status string, err error) {
storeMigMu.Lock()
defer storeMigMu.Unlock()
storeMigProgress.Status = status
storeMigProgress.EndTime = time.Now().UTC()
if err != nil {
storeMigProgress.Error = err.Error()
}
// 完成/取消时把当前指标标记为已全部处理,便于前端进度条归位。
if status == "completed" && storeMigProgress.TotalMetrics > 0 {
storeMigProgress.MetricsDone = storeMigProgress.TotalMetrics
}
}
// CancelStoreMigration 请求取消正在运行的 store-to-store 迁移,并等待其退出。
// 由于写入是幂等 upsert,取消后可安全重新发起,不会产生重复数据。
func CancelStoreMigration() error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
return stopStoreMigration(ctx, true)
}
func stopStoreMigration(ctx context.Context, requireRunning bool) error {
storeMigMu.Lock()
cancel := storeMigCancel
done := storeMigDone
storeMigMu.Unlock()
if cancel == nil {
if requireRunning {
return fmt.Errorf("no store migration is currently running")
}
return nil
}
cancel()
select {
case <-done:
return nil
case <-ctx.Done():
return fmt.Errorf("wait for store migration to cancel: %w", ctx.Err())
}
}
func stopStoreMigrationForClose(ctx context.Context) error {
storeMigMu.Lock()
storeClosing = true
cancel := storeMigCancel
done := storeMigDone
storeMigMu.Unlock()
if cancel == nil {
return nil
}
cancel()
select {
case <-done:
return nil
case <-ctx.Done():
return fmt.Errorf("wait for store migration to cancel: %w", ctx.Err())
}
}
func clearStoreClosing() {
storeMigMu.Lock()
storeClosing = false
storeMigMu.Unlock()
}
func isStoreClosing() bool {
storeMigMu.Lock()
defer storeMigMu.Unlock()
return storeClosing
}
// maskDSN 对 DSN 做粗粒度脱敏,避免在 API 响应/日志中泄露密码。
// 处理两类常见格式:URL(scheme://user:pass@host/...)与 key=value(password=...)。
func maskDSN(dsn string) string {
dsn = strings.TrimSpace(dsn)
if dsn == "" {
return ""
}
// key=value 形式:屏蔽 password=... 段。
if strings.Contains(dsn, "password=") {
parts := strings.Fields(dsn)
for i, p := range parts {
if strings.HasPrefix(strings.ToLower(p), "password=") {
parts[i] = "password=***"
}
}
return strings.Join(parts, " ")
}
// URL / user:pass@host 形式:屏蔽 user:pass@ 中的密码。
if at := strings.LastIndex(dsn, "@"); at > 0 {
head := dsn[:at]
tail := dsn[at:]
if colon := strings.LastIndex(head, ":"); colon >= 0 {
// 保留 scheme://user,屏蔽密码。
return head[:colon] + ":***" + tail
}
}
return dsn
}
+121
View File
@@ -0,0 +1,121 @@
package models
import (
"database/sql/driver"
"fmt"
"log"
"os"
"strings"
"sync"
"time"
)
// LocalTime is a custom time type for GORM to handle time.Time correctly.
// It ensures that all times are stored and retrieved based on the application's configured timezone.
type LocalTime time.Time
// Value implements the driver.Valuer interface.
// It converts UTCTime to a TEXT format that the database understands.
// The time is formatted as a string in the application's local timezone, without timezone information.
func (t LocalTime) Value() (driver.Value, error) {
if time.Time(t).IsZero() {
return nil, nil
}
return time.Time(t).In(GetAppLocation()).Format("2006-01-02 15:04:05.0000000"), nil
}
// Scan implements the sql.Scanner interface.
// It scans a value from the database into a UTCTime object.
func (t *LocalTime) Scan(v interface{}) error {
if v == nil {
*t = LocalTime(time.Time{})
return nil
}
loc := GetAppLocation()
switch val := v.(type) {
case time.Time:
// CRITICAL FIX: When the driver reads a timezone-less string (e.g., "16:00:00"),
// it often incorrectly assumes it's UTC. We must correct this by re-interpreting
// the date and clock values in the application's actual timezone.
year, month, day := val.Date()
hour, min, sec := val.Clock()
nanosec := val.Nanosecond()
*t = LocalTime(time.Date(year, month, day, hour, min, sec, nanosec, loc))
return nil
case []byte:
return t.parseTime(string(val), loc)
case string:
return t.parseTime(val, loc)
default:
return fmt.Errorf("UTCTime scan source was not string, []byte or time.Time: %T (%v)", v, v)
}
}
// parseTime handles parsing a string into UTCTime.
func (t *LocalTime) parseTime(timeStr string, loc *time.Location) error {
timeStr = strings.TrimSpace(timeStr)
if timeStr == "" {
*t = LocalTime(time.Time{})
return nil
}
layouts := []string{
time.RFC3339Nano, time.RFC3339,
"2006-01-02 15:04:05.0000000-07:00", "2006-01-02 15:04:05-07:00",
"2006-01-02 15:04:05.0000000", "2006-01-02 15:04:05", "2006-01-02",
}
for _, layout := range layouts {
if parsedTime, err := time.ParseInLocation(layout, timeStr, loc); err == nil {
*t = LocalTime(parsedTime)
return nil
}
}
return fmt.Errorf("unable to parse time string '%s' into UTCTime", timeStr)
}
// MarshalJSON implements the json.Marshaler interface.
// Serializes UTCTime to JSON in RFC3339 format with the correct timezone offset.
func (t LocalTime) MarshalJSON() ([]byte, error) {
if time.Time(t).IsZero() {
return []byte("null"), nil
}
formattedTime := time.Time(t).In(GetAppLocation()).Format(time.RFC3339)
return []byte(fmt.Sprintf(`"%s"`, formattedTime)), nil
}
// ToTime converts UTCTime to Go's native time.Time type.
func (t LocalTime) ToTime() time.Time { return time.Time(t) }
// FromTime converts Go's native time.Time type to UTCTime.
func FromTime(t time.Time) LocalTime { return LocalTime(t) }
// Now returns the current time in the application's configured location.
func Now() LocalTime { return LocalTime(time.Now().In(GetAppLocation())) }
var (
appLocation *time.Location
locationOnce sync.Once
)
// GetAppLocation retrieves the application's global timezone from the "TZ" environment variable.
func GetAppLocation() *time.Location {
locationOnce.Do(func() {
tz := os.Getenv("TZ")
if tz == "" {
tz = "UTC"
}
loc, err := time.LoadLocation(tz)
if err != nil {
log.Printf("Warning: Failed to load timezone '%s', falling back to UTC. Error: %v", tz, err)
appLocation = time.UTC
} else {
appLocation = loc
}
time.Local = appLocation
log.Printf("Application timezone is set to '%s'.", appLocation.String())
})
return appLocation
}
+2 -4
View File
@@ -1,13 +1,11 @@
package models
import "time"
type Clipboard struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement;unique"`
Text string `json:"text" gorm:"type:longtext"`
Name string `json:"name" gorm:"type:varchar(255)"`
Weight int `json:"weight" gorm:"type:int"`
Remark string `json:"remark" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedAt LocalTime `json:"created_at"`
UpdatedAt LocalTime `json:"updated_at"`
}
+2 -4
View File
@@ -1,12 +1,10 @@
package models
import "time"
type Log struct {
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
IP string `json:"ip" gorm:"type:varchar(45);"` // IPv4 or IPv6
UUID string `json:"uuid" gorm:"type:varchar(36);"`
Message string `json:"message" gorm:"type:text;not null"`
MsgType string `json:"msg_type" gorm:"type:varchar(20);not null;index:idx_logs_msg_type_time,priority:1"`
Time time.Time `json:"time" gorm:"autoCreateTime;not null;index:idx_logs_time;index:idx_logs_msg_type_time,priority:2"`
MsgType string `json:"msg_type" gorm:"type:varchar(20);not null"`
Time LocalTime `json:"time" gorm:"autoCreateTime;not null"`
}
+40 -41
View File
@@ -4,44 +4,43 @@ import (
"database/sql/driver"
"encoding/json"
"fmt"
"time"
)
// Client represents a registered client device
type Client struct {
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
Token string `json:"token,omitempty" gorm:"type:varchar(255);unique;not null"`
Name string `json:"name" gorm:"type:varchar(100)"`
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
Arch string `json:"arch" gorm:"type:varchar(50)"`
CpuCores int `json:"cpu_cores" gorm:"type:int"`
CpuPhysicalCores int `json:"cpu_physical_cores" gorm:"type:int"`
OS string `json:"os" gorm:"type:varchar(100)"`
KernelVersion string `json:"kernel_version" gorm:"type:varchar(100)"`
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
Region string `json:"region" gorm:"type:varchar(100)"`
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
Weight int `json:"weight" gorm:"type:int"`
Price float64 `json:"price"`
BillingCycle int `json:"billing_cycle"`
AutoRenewal bool `json:"auto_renewal" gorm:"default:false"` // 是否自动续费
Currency string `json:"currency" gorm:"type:varchar(20);default:'$'"`
ExpiredAt *time.Time `json:"expired_at" gorm:"type:timestamp"`
Group string `json:"group" gorm:"type:varchar(100)"`
Tags string `json:"tags" gorm:"type:text"` // split by ';'
Hidden bool `json:"hidden" gorm:"default:false"`
TrafficLimit int64 `json:"traffic_limit" gorm:"type:bigint"`
TrafficLimitType string `json:"traffic_limit_type" gorm:"type:varchar(10);default:'max'"` // 流量阈值类型:sum max min up down
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
Token string `json:"token,omitempty" gorm:"type:varchar(255);unique;not null"`
Name string `json:"name" gorm:"type:varchar(100)"`
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
Arch string `json:"arch" gorm:"type:varchar(50)"`
CpuCores int `json:"cpu_cores" gorm:"type:int"`
CpuPhysicalCores int `json:"cpu_physical_cores" gorm:"type:int"`
OS string `json:"os" gorm:"type:varchar(100)"`
KernelVersion string `json:"kernel_version" gorm:"type:varchar(100)"`
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
Region string `json:"region" gorm:"type:varchar(100)"`
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
Weight int `json:"weight" gorm:"type:int"`
Price float64 `json:"price"`
BillingCycle int `json:"billing_cycle"`
AutoRenewal bool `json:"auto_renewal" gorm:"default:false"` // 是否自动续费
Currency string `json:"currency" gorm:"type:varchar(20);default:'$'"`
ExpiredAt LocalTime `json:"expired_at" gorm:"type:timestamp"`
Group string `json:"group" gorm:"type:varchar(100)"`
Tags string `json:"tags" gorm:"type:text"` // split by ';'
Hidden bool `json:"hidden" gorm:"default:false"`
TrafficLimit int64 `json:"traffic_limit" gorm:"type:bigint"`
TrafficLimitType string `json:"traffic_limit_type" gorm:"type:varchar(10);default:'max'"` // 流量阈值类型:sum max min up down
CreatedAt LocalTime `json:"created_at"`
UpdatedAt LocalTime `json:"updated_at"`
}
// User represents an authenticated user
@@ -53,8 +52,8 @@ type User struct {
SSOID string `json:"sso_id" gorm:"type:varchar(100)"` // OAuth provider's user ID
TwoFactor string `json:"two_factor,omitempty" gorm:"type:varchar(255)"` // 2FA secret
Sessions []Session `json:"sessions,omitempty" gorm:"foreignKey:UUID;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedAt LocalTime `json:"created_at"`
UpdatedAt LocalTime `json:"updated_at"`
}
// Session manages user sessions
@@ -64,17 +63,17 @@ type Session struct {
UserAgent string `json:"user_agent" gorm:"type:text"`
Ip string `json:"ip" gorm:"type:varchar(100)"`
LoginMethod string `json:"login_method" gorm:"type:varchar(50)"`
LatestOnline time.Time `json:"latest_online" gorm:"type:timestamp"`
LatestOnline LocalTime `json:"latest_online" gorm:"type:timestamp"`
LatestUserAgent string `json:"latest_user_agent" gorm:"type:text"`
LatestIp string `json:"latest_ip" gorm:"type:varchar(100)"`
Expires time.Time `json:"expires" gorm:"not null"`
CreatedAt time.Time `json:"created_at"`
Expires LocalTime `json:"expires" gorm:"not null"`
CreatedAt LocalTime `json:"created_at"`
}
// Record logs client metrics over time
type Record struct {
Client string `json:"client" gorm:"type:varchar(36);index"`
Time time.Time `json:"time" gorm:"index"`
Time LocalTime `json:"time" gorm:"index"`
Cpu float32 `json:"cpu" gorm:"type:decimal(5,2)"` // e.g., 75.50%
Gpu float32 `json:"gpu" gorm:"type:decimal(5,2)"`
Ram int64 `json:"ram" gorm:"type:bigint"`
@@ -100,7 +99,7 @@ type Record struct {
// GPURecord logs individual GPU metrics over time
type GPURecord struct {
Client string `json:"client" gorm:"type:varchar(36);index"` // 客户端UUID
Time time.Time `json:"time" gorm:"index"` // 记录时间
Time LocalTime `json:"time" gorm:"index"` // 记录时间
DeviceIndex int `json:"device_index" gorm:"index"` // GPU设备索引 (0,1,2...)
DeviceName string `json:"device_name" gorm:"type:varchar(100)"` // GPU型号
MemTotal int64 `json:"mem_total" gorm:"type:bigint"` // 显存总量(字节)
+5 -7
View File
@@ -1,15 +1,13 @@
package models
import "time"
// Notification 定义了通知相关的数据库模型
type OfflineNotification struct {
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
ClientInfo Client `json:"client_info,omitempty" gorm:"foreignKey:Client;references:UUID"`
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
//Cooldown int `json:"cooldown" gorm:"type:int;not null;default:1800"` // 冷却时间(秒),默认 30 分钟
GracePeriod int `json:"grace_period" gorm:"type:int;not null;default:180"` // 宽限期(秒),默认 3 分钟
LastNotified *time.Time `json:"last_notified"` // 上次通知时间
GracePeriod int `json:"grace_period" gorm:"type:int;not null;default:180"` // 宽限期(秒),默认 3 分钟
LastNotified LocalTime `json:"last_notified"` // 上次通知时间
}
// LoadNotification 定义了基于资源占用达标时间比的负载通知规则
@@ -21,7 +19,7 @@ type LoadNotification struct {
Threshold float32 `json:"threshold" gorm:"type:decimal(5,2);not null;default:80.00"` // 阈值百分比
Ratio float32 `json:"ratio" gorm:"type:decimal(5,2);not null;default:0.80"` // 达标时间比
Interval int `json:"interval" gorm:"type:int;not null;default:15"` // 监测间隔(分钟)
LastNotified *time.Time `json:"last_notified"` // 上次通知时间
LastNotified LocalTime `json:"last_notified"` // 上次通知时间
}
// TrafficReportNotification 定义了流量定时报告的数据库模型
@@ -29,7 +27,7 @@ type TrafficReportNotification struct {
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
ClientInfo Client `json:"client_info,omitempty" gorm:"foreignKey:Client;references:UUID"`
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
Daily bool `json:"daily" gorm:"type:boolean;default:false"` // 日报
Weekly bool `json:"weekly" gorm:"type:boolean;default:false"` // 周报
Daily bool `json:"daily" gorm:"type:boolean;default:false"` // 日报
Weekly bool `json:"weekly" gorm:"type:boolean;default:false"` // 周报
Monthly bool `json:"monthly" gorm:"type:boolean;default:false"` // 月报
}
+1 -3
View File
@@ -1,13 +1,11 @@
package models
import "time"
type PingRecord struct {
Client string `json:"client" gorm:"type:varchar(36);not null;index"`
ClientInfo Client `json:"client_info" gorm:"foreignKey:Client;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
TaskId uint `json:"task_id" gorm:"not null;index"`
Task PingTask `json:"task" gorm:"foreignKey:TaskId;references:Id;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;"`
Time time.Time `json:"time" gorm:"index;not null"`
Time LocalTime `json:"time" gorm:"index;not null"`
Value int `json:"value" gorm:"type:int;not null"` // Ping 值,单位毫秒
}
+2 -4
View File
@@ -1,7 +1,5 @@
package models
import "time"
type Task struct {
TaskId string `json:"task_id" gorm:"type:varchar(36);primaryKey;unique"`
Clients StringArray `json:"clients" gorm:"type:longtext"`
@@ -15,6 +13,6 @@ type TaskResult struct {
ClientInfo Client `json:"client_info" gorm:"foreignKey:Client;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
Result string `json:"result" gorm:"type:longtext"`
ExitCode *int `json:"exit_code" gorm:"type:int"`
FinishedAt *time.Time `json:"finished_at" gorm:"type:timestamp"`
CreatedAt time.Time `json:"created_at" gorm:"type:timestamp"`
FinishedAt *LocalTime `json:"finished_at" gorm:"type:timestamp"`
CreatedAt LocalTime `json:"created_at" gorm:"type:timestamp"`
}
-77
View File
@@ -1,77 +0,0 @@
package models
import (
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestTimeFieldsStoreAndSerializeAsUTCWithNanoseconds(t *testing.T) {
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
NowFunc: func() time.Time { return time.Now().UTC() },
})
if err != nil {
t.Fatalf("open database: %v", err)
}
if err := db.AutoMigrate(&Log{}); err != nil {
t.Fatalf("migrate log: %v", err)
}
stamp := time.Date(2026, 7, 17, 1, 2, 3, 123456789, time.UTC)
entry := Log{IP: "127.0.0.1", UUID: "test", Message: "test", MsgType: "info", Time: stamp}
if err := db.Create(&entry).Error; err != nil {
t.Fatalf("create log: %v", err)
}
var loaded Log
if err := db.First(&loaded, entry.ID).Error; err != nil {
t.Fatalf("load log: %v", err)
}
if !loaded.Time.Equal(stamp) || loaded.Time.Nanosecond() != stamp.Nanosecond() {
t.Fatalf("loaded time = %s, want %s", loaded.Time, stamp)
}
encoded, err := json.Marshal(loaded)
if err != nil {
t.Fatalf("marshal log: %v", err)
}
if !strings.Contains(string(encoded), `"time":"2026-07-17T01:02:03.123456789Z"`) {
t.Fatalf("unexpected JSON: %s", encoded)
}
}
func TestNullableTimeFieldReadsAndSerializesNull(t *testing.T) {
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open database: %v", err)
}
if err := db.AutoMigrate(&Client{}); err != nil {
t.Fatalf("migrate client: %v", err)
}
if err := db.Exec(`INSERT INTO clients (uuid, token, expired_at) VALUES (?, ?, NULL)`, "nullable", "token").Error; err != nil {
t.Fatalf("insert client: %v", err)
}
var client Client
if err := db.First(&client, "uuid = ?", "nullable").Error; err != nil {
t.Fatalf("load client: %v", err)
}
if client.ExpiredAt != nil {
t.Fatalf("expired_at = %s, want nil", client.ExpiredAt)
}
encoded, err := json.Marshal(client)
if err != nil {
t.Fatalf("marshal client: %v", err)
}
if !strings.Contains(string(encoded), `"expired_at":null`) {
t.Fatalf("unexpected JSON: %s", encoded)
}
}
-5
View File
@@ -23,11 +23,6 @@ func GetOidcConfigByName(name string) (*models.OidcProvider, error) {
return &config, nil
}
func DeleteOidcConfigByName(name string) error {
db := dbcore.GetDBInstance()
return db.Delete(&models.OidcProvider{}, "name = ?", name).Error
}
func SaveOidcConfig(config *models.OidcProvider) error {
db := dbcore.GetDBInstance()
if err := db.Save(config).Error; err != nil {
+592 -17
View File
@@ -1,34 +1,609 @@
package records
import (
"context"
"errors"
"log"
"sort"
"strings"
"time"
"github.com/komari-monitor/komari/database/metricstore"
"gorm.io/gorm"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils"
)
// 历史监控数据已完全迁移到 metric store(默认 SQLite ./data/metrics.db,或配置的
// MySQL/PostgreSQL)。旧的 records / records_long_term / gpu_records / ping_records
// 表仅在启动一次性迁移时作为数据源读取,运行期的读写全部走 metric store。
//
// 因此本文件不再保留对旧表的任何读写、压缩(rollup)与流量增量修复逻辑:
// metric store 自行管理 rollup 与保留策略,旧表压缩已无意义。
func DeleteAll() error {
return metricstore.DeleteAllRecords(context.Background())
func RecordOne(rec models.Record) error {
db := dbcore.GetDBInstance()
return db.Create(&rec).Error
}
// GetGPURecordsByClientAndTime 获取 GPU 记录数据。
func RecordGPU(rec models.GPURecord) error {
db := dbcore.GetDBInstance()
return db.Create(&rec).Error
}
func DeleteAll() error {
db := dbcore.GetDBInstance()
if err := db.Exec("DELETE FROM records_long_term").Error; err != nil {
return err
}
if err := db.Exec("DELETE FROM gpu_records_long_term").Error; err != nil {
return err
}
if err := db.Exec("DELETE FROM gpu_records").Error; err != nil {
return err
}
return db.Exec("DELETE FROM records").Error
}
// GetGPURecordsByClientAndTime 获取GPU记录数据
func GetGPURecordsByClientAndTime(uuid string, start, end time.Time) ([]models.GPURecord, error) {
return metricstore.GetGPURecordsByClientAndTime(context.Background(), uuid, start, end)
db := dbcore.GetDBInstance()
var records []models.GPURecord
fourHoursAgo := time.Now().Add(-4*time.Hour - time.Minute)
var recentRecords []models.GPURecord
recentStart := start
if end.After(fourHoursAgo) {
if recentStart.Before(fourHoursAgo) {
recentStart = fourHoursAgo
}
err := db.Where("client = ? AND time >= ? AND time <= ?", uuid, recentStart, end).
Order("time ASC, device_index ASC").Find(&recentRecords).Error
if err != nil {
log.Printf("Error fetching recent GPU records for client %s between %s and %s: %v", uuid, recentStart, end, err)
return nil, err
}
}
var longTermRecords []models.GPURecord
err := db.Table("gpu_records_long_term").Where("client = ? AND time >= ? AND time <= ?", uuid, start, end).
Order("time ASC, device_index ASC").Find(&longTermRecords).Error
if err != nil {
log.Printf("Error fetching long-term GPU records for client %s between %s and %s: %v", uuid, start, end, err)
return recentRecords, nil
}
// 合并结果 - 不再需要类型转换
records = append(records, recentRecords...)
records = append(records, longTermRecords...)
return records, nil
}
func GetLatestRecord(uuid string) (Record []models.Record, err error) {
db := dbcore.GetDBInstance()
err = db.Where("client = ?", uuid).Order("time DESC").Limit(1).Find(&Record).Error
return
}
func DeleteRecordBefore(before time.Time) error {
db := dbcore.GetDBInstance()
db.Table("records_long_term").Where("time < ?", before).Delete(&models.Record{})
db.Table("gpu_records_long_term").Where("time < ?", before).Delete(&models.GPURecord{})
db.Where("time < ?", before).Delete(&models.GPURecord{})
return db.Where("time < ?", before).Delete(&models.Record{}).Error
}
func GetRecordsByClientAndTime(uuid string, start, end time.Time) ([]models.Record, error) {
return metricstore.GetRecordsByClientAndTime(context.Background(), uuid, start, end)
db := dbcore.GetDBInstance()
var records []models.Record
fourHoursAgo := time.Now().Add(-4*time.Hour - time.Minute)
var recentRecords []models.Record
recentStart := start
if end.After(fourHoursAgo) {
if recentStart.Before(fourHoursAgo) {
recentStart = fourHoursAgo
}
err := db.Where("client = ? AND time >= ? AND time <= ?", uuid, recentStart, end).Order("time ASC").Find(&recentRecords).Error
if err != nil {
log.Printf("Error fetching recent records for client %s between %s and %s: %v", uuid, recentStart, end, err)
return nil, err
}
}
var long_term []models.Record
err := db.Table("records_long_term").Where("client = ? AND time >= ? AND time <= ?", uuid, start, end).Order("time ASC").Find(&long_term).Error
if err != nil {
log.Printf("Error fetching long-term records for client %s between %s and %s: %v", uuid, start, end, err)
return recentRecords, nil
}
if len(long_term) == 0 {
// 没有查到long_term,返回全部recentRecords
records = append(records, recentRecords...)
return records, nil
}
// 查到了long_term,recentRecords按15分钟分组,每组只保留一条(取最新一条)
grouped := make(map[string]models.Record)
for _, rec := range recentRecords {
key := rec.Time.ToTime().Truncate(15 * time.Minute).Format(time.RFC3339)
if old, ok := grouped[key]; !ok || rec.Time.ToTime().After(old.Time.ToTime()) {
grouped[key] = rec
}
}
var groupedList []models.Record
for _, rec := range grouped {
groupedList = append(groupedList, rec)
}
sort.Slice(groupedList, func(i, j int) bool {
return groupedList[i].Time.ToTime().Before(groupedList[j].Time.ToTime())
})
records = append(records, groupedList...)
records = append(records, long_term...)
return records, nil
}
// GetRecordsByTime 获取所有客户端在时间范围内的记录。
func GetRecordsByTime(start, end time.Time) ([]models.Record, error) {
return metricstore.GetRecordsByTime(context.Background(), start, end)
func GetAllRecords() ([]models.Record, error) {
db := dbcore.GetDBInstance()
var records []models.Record
var long_term []models.Record
err := db.Table("records").Order("time ASC").Find(&records).Error
if err != nil {
log.Printf("Error fetching all records: %v", err)
return nil, err
}
err = db.Table("records_long_term").Order("time ASC").Find(&long_term).Error
if err != nil {
log.Printf("Error fetching long-term records: %v", err)
return records, nil
}
records = append(records, long_term...)
return records, nil
}
// 压缩数据库
func CompactRecord() error {
db := dbcore.GetDBInstance()
err := migrateOldRecords(db)
if err != nil {
log.Printf("Error migrating old records: %v", err)
return err
}
err = migrateGPURecords(db)
if err != nil {
log.Printf("Error migrating GPU records: %v", err)
return err
}
if flags.IsSQLite() {
db.Exec("PRAGMA wal_checkpoint(PASSIVE);")
}
//log.Printf("Record compaction completed")
return nil
}
func migrateOldRecords(db *gorm.DB) error {
return migrateOldRecordsAt(db, time.Now())
}
func compactRecordCutoff(now time.Time) time.Time {
return now.Add(-4 * time.Hour).Truncate(15 * time.Minute)
}
func migrateOldRecordsAt(db *gorm.DB, now time.Time) error {
cutoff := compactRecordCutoff(now)
// 查询 records 表中超过 4 小时的记录
var records []models.Record
if err := db.Table("records").Where("time < ?", cutoff).Find(&records).Error; err != nil {
return err
}
if len(records) == 0 {
return nil
}
previousByClient, err := getPreviousTrafficRecordsBefore(db, records)
if err != nil {
return err
}
repairZeroTrafficDeltas(records, previousByClient)
// 按 Client 和 15 分钟时间段分组,并存储所有记录以计算分位数
type groupData struct {
Cpu []float32
Gpu []float32
Load []float32
Temp []float32
Ram []int64
RamTotal []int64
Swap []int64
SwapTotal []int64
Disk []int64
DiskTotal []int64
NetIn []int64
NetOut []int64
NetTotalUp []int64
NetTotalDown []int64
TrafficUp int64
TrafficDown int64
LatestTime time.Time
LatestTotalUp int64
LatestTotalDown int64
Process []int
Connections []int
ConnectionsUdp []int
Uptime []int64
}
groupedRecords := make(map[string]*groupData)
for _, record := range records {
key := record.Client + "_" + record.Time.ToTime().Truncate(15*time.Minute).Format(time.RFC3339)
if _, ok := groupedRecords[key]; !ok {
groupedRecords[key] = &groupData{}
}
data := groupedRecords[key]
data.Cpu = append(data.Cpu, record.Cpu)
data.Gpu = append(data.Gpu, record.Gpu)
data.Load = append(data.Load, record.Load)
data.Temp = append(data.Temp, record.Temp)
data.Ram = append(data.Ram, record.Ram)
data.RamTotal = append(data.RamTotal, record.RamTotal)
data.Swap = append(data.Swap, record.Swap)
data.SwapTotal = append(data.SwapTotal, record.SwapTotal)
data.Disk = append(data.Disk, record.Disk)
data.DiskTotal = append(data.DiskTotal, record.DiskTotal)
data.NetIn = append(data.NetIn, record.NetIn)
data.NetOut = append(data.NetOut, record.NetOut)
data.NetTotalUp = append(data.NetTotalUp, record.NetTotalUp)
data.NetTotalDown = append(data.NetTotalDown, record.NetTotalDown)
data.TrafficUp += record.TrafficUp
data.TrafficDown += record.TrafficDown
if data.LatestTime.IsZero() || record.Time.ToTime().After(data.LatestTime) {
data.LatestTime = record.Time.ToTime()
data.LatestTotalUp = record.NetTotalUp
data.LatestTotalDown = record.NetTotalDown
}
data.Process = append(data.Process, record.Process)
data.Connections = append(data.Connections, record.Connections)
data.ConnectionsUdp = append(data.ConnectionsUdp, record.ConnectionsUdp)
//data.Uptime = append(data.Uptime, record.Uptime)
}
getPercentile := func(values []float64, percentile float64) float64 {
if len(values) == 0 {
return 0
}
sortedValues := make([]float64, len(values))
copy(sortedValues, values)
sort.Float64s(sortedValues)
index := float64(len(sortedValues)-1) * percentile
lowerIndex := int(index)
if lowerIndex >= len(sortedValues)-1 {
return sortedValues[len(sortedValues)-1]
}
frac := index - float64(lowerIndex)
return sortedValues[lowerIndex] + frac*(sortedValues[lowerIndex+1]-sortedValues[lowerIndex])
}
getIntPercentile := func(values []int64, percentile float64) int64 {
if len(values) == 0 {
return 0
}
floats := make([]float64, len(values))
for i, v := range values {
floats[i] = float64(v)
}
return int64(getPercentile(floats, percentile))
}
getInt32Percentile := func(values []int, percentile float64) int {
if len(values) == 0 {
return 0
}
floats := make([]float64, len(values))
for i, v := range values {
floats[i] = float64(v)
}
return int(getPercentile(floats, percentile))
}
return db.Transaction(func(tx *gorm.DB) error {
for key, data := range groupedRecords {
// 解析 Client 和时间
parts := strings.Split(key, "_")
clientUUID := parts[0]
timeSlot, err := time.Parse(time.RFC3339, strings.Join(parts[1:], "_"))
if err != nil {
return err
}
cpuFloats := make([]float64, len(data.Cpu))
for i, v := range data.Cpu {
cpuFloats[i] = float64(v)
}
gpuFloats := make([]float64, len(data.Gpu))
for i, v := range data.Gpu {
gpuFloats[i] = float64(v)
}
loadFloats := make([]float64, len(data.Load))
for i, v := range data.Load {
loadFloats[i] = float64(v)
}
tempFloats := make([]float64, len(data.Temp))
for i, v := range data.Temp {
tempFloats[i] = float64(v)
}
// 取高位
high_percentile := 0.7
// 检查 records_long_term 表中是否已存在相同的记录
var existingCount int64
if err := tx.Table("records_long_term").Where("client = ? AND time = ?", clientUUID, timeSlot).Count(&existingCount).Error; err != nil {
return err
}
newRec := models.Record{
Client: clientUUID,
Time: models.FromTime(timeSlot),
Cpu: float32(getPercentile(cpuFloats, high_percentile)),
Gpu: float32(getPercentile(gpuFloats, high_percentile)),
Load: float32(getPercentile(loadFloats, high_percentile)),
Temp: float32(getPercentile(tempFloats, high_percentile)),
Ram: getIntPercentile(data.Ram, high_percentile),
RamTotal: getIntPercentile(data.RamTotal, high_percentile),
Swap: getIntPercentile(data.Swap, high_percentile),
SwapTotal: getIntPercentile(data.SwapTotal, high_percentile),
Disk: getIntPercentile(data.Disk, high_percentile),
DiskTotal: getIntPercentile(data.DiskTotal, high_percentile),
NetIn: getIntPercentile(data.NetIn, 0.2),
NetOut: getIntPercentile(data.NetOut, 0.2),
NetTotalUp: data.LatestTotalUp,
NetTotalDown: data.LatestTotalDown,
TrafficUp: data.TrafficUp,
TrafficDown: data.TrafficDown,
Process: getInt32Percentile(data.Process, high_percentile),
Connections: getInt32Percentile(data.Connections, high_percentile),
ConnectionsUdp: getInt32Percentile(data.ConnectionsUdp, high_percentile),
//Uptime: getIntPercentile(data.Uptime, high_percentile),
}
// 如果记录已存在则更新,否则创建新记录
if existingCount > 0 {
if err := tx.Table("records_long_term").Where("client = ? AND time = ?", clientUUID, timeSlot).Updates(&newRec).Error; err != nil {
return err
}
} else {
if err := tx.Table("records_long_term").Create(&newRec).Error; err != nil {
return err
}
}
}
// 删除 records 表中的旧数据
if err := tx.Table("records").Where("time < ?", cutoff.Add(-1*time.Hour)).Delete(&models.Record{}).Error; err != nil {
return err
}
return nil
})
}
func getPreviousTrafficRecordsBefore(db *gorm.DB, records []models.Record) (map[string]*models.Record, error) {
firstTimeByClient := make(map[string]time.Time)
for _, record := range records {
recordTime := record.Time.ToTime()
firstTime, ok := firstTimeByClient[record.Client]
if !ok || recordTime.Before(firstTime) {
firstTimeByClient[record.Client] = recordTime
}
}
previousByClient := make(map[string]*models.Record, len(firstTimeByClient))
for clientUUID, firstTime := range firstTimeByClient {
previous, err := getPreviousTrafficRecordBefore(db, clientUUID, firstTime)
if err != nil {
return nil, err
}
if previous != nil {
previousByClient[clientUUID] = previous
}
}
return previousByClient, nil
}
func getPreviousTrafficRecordBefore(db *gorm.DB, clientUUID string, before time.Time) (*models.Record, error) {
var latest *models.Record
for _, table := range []string{"records", "records_long_term"} {
var record models.Record
queryBefore := before
if table == "records_long_term" {
queryBefore = before.Truncate(15 * time.Minute)
}
err := db.Table(table).
Where("client = ? AND time < ?", clientUUID, models.FromTime(queryBefore)).
Order("time DESC").
First(&record).Error
if err == nil {
if latest == nil || record.Time.ToTime().After(latest.Time.ToTime()) {
latest = &record
}
continue
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
}
return latest, nil
}
func repairZeroTrafficDeltas(records []models.Record, previousByClient map[string]*models.Record) {
recordsByClient := make(map[string][]*models.Record)
for i := range records {
recordsByClient[records[i].Client] = append(recordsByClient[records[i].Client], &records[i])
}
for _, clientRecords := range recordsByClient {
sort.Slice(clientRecords, func(i, j int) bool {
return clientRecords[i].Time.ToTime().Before(clientRecords[j].Time.ToTime())
})
var previous *models.Record
if previousByClient != nil {
previous = previousByClient[clientRecords[0].Client]
}
for _, current := range clientRecords {
if previous == nil {
previous = current
continue
}
if current.TrafficUp == 0 {
current.TrafficUp = utils.ComputeTrafficDelta(current.NetTotalUp, previous.NetTotalUp)
}
if current.TrafficDown == 0 {
current.TrafficDown = utils.ComputeTrafficDelta(current.NetTotalDown, previous.NetTotalDown)
}
previous = current
}
}
}
// migrateGPURecords 压缩GPU记录数据
func migrateGPURecords(db *gorm.DB) error {
cutoff := compactRecordCutoff(time.Now())
// 查询超过4小时的GPU记录
var gpuRecords []models.GPURecord
if err := db.Where("time < ?", cutoff).Find(&gpuRecords).Error; err != nil {
return err
}
if len(gpuRecords) == 0 {
return nil
}
// 按Client + DeviceIndex + 15分钟时间窗口分组
type gpuGroupKey struct {
Client string
DeviceIndex int
TimeSlot time.Time
DeviceName string
}
type gpuGroupData struct {
MemTotal []int64
MemUsed []int64
Utilization []float32
Temperature []int
}
groupedGPUs := make(map[gpuGroupKey]*gpuGroupData)
for _, record := range gpuRecords {
key := gpuGroupKey{
Client: record.Client,
DeviceIndex: record.DeviceIndex,
TimeSlot: record.Time.ToTime().Truncate(15 * time.Minute),
DeviceName: record.DeviceName,
}
if _, ok := groupedGPUs[key]; !ok {
groupedGPUs[key] = &gpuGroupData{}
}
data := groupedGPUs[key]
data.MemTotal = append(data.MemTotal, record.MemTotal)
data.MemUsed = append(data.MemUsed, record.MemUsed)
data.Utilization = append(data.Utilization, record.Utilization)
data.Temperature = append(data.Temperature, record.Temperature)
}
// 百分位数计算函数 (复用传统Record压缩逻辑)
getPercentile := func(values []float64, percentile float64) float64 {
if len(values) == 0 {
return 0
}
sortedValues := make([]float64, len(values))
copy(sortedValues, values)
sort.Float64s(sortedValues)
index := float64(len(sortedValues)-1) * percentile
lowerIndex := int(index)
if lowerIndex >= len(sortedValues)-1 {
return sortedValues[len(sortedValues)-1]
}
frac := index - float64(lowerIndex)
return sortedValues[lowerIndex] + frac*(sortedValues[lowerIndex+1]-sortedValues[lowerIndex])
}
getIntPercentile := func(values []int64, percentile float64) int64 {
if len(values) == 0 {
return 0
}
floats := make([]float64, len(values))
for i, v := range values {
floats[i] = float64(v)
}
return int64(getPercentile(floats, percentile))
}
// 温度数据转换辅助函数
convertIntToInt64 := func(values []int) []int64 {
result := make([]int64, len(values))
for i, v := range values {
result[i] = int64(v)
}
return result
}
getFloat32Percentile := func(values []float32, percentile float64) float32 {
if len(values) == 0 {
return 0
}
floats := make([]float64, len(values))
for i, v := range values {
floats[i] = float64(v)
}
return float32(getPercentile(floats, percentile))
}
// 保持与传统Record压缩的一致性
high_percentile := 0.7
return db.Transaction(func(tx *gorm.DB) error {
for key, data := range groupedGPUs {
// 检查是否已存在记录
var existingCount int64
if err := tx.Table("gpu_records_long_term").Where("client = ? AND device_index = ? AND time = ?",
key.Client, key.DeviceIndex, key.TimeSlot).Count(&existingCount).Error; err != nil {
return err
}
compressedGPU := models.GPURecord{
Client: key.Client,
DeviceIndex: key.DeviceIndex,
Time: models.FromTime(key.TimeSlot),
DeviceName: key.DeviceName,
MemTotal: getIntPercentile(data.MemTotal, high_percentile),
MemUsed: getIntPercentile(data.MemUsed, high_percentile),
Utilization: getFloat32Percentile(data.Utilization, high_percentile),
Temperature: int(getIntPercentile(convertIntToInt64(data.Temperature), high_percentile)),
}
if existingCount > 0 {
// 更新已存在记录
if err := tx.Table("gpu_records_long_term").Where("client = ? AND device_index = ? AND time = ?",
key.Client, key.DeviceIndex, key.TimeSlot).Updates(&compressedGPU).Error; err != nil {
return err
}
} else {
// 创建新记录
if err := tx.Table("gpu_records_long_term").Create(&compressedGPU).Error; err != nil {
return err
}
}
}
// 删除已压缩的原始GPU数据
if err := tx.Where("time < ?", cutoff.Add(-1*time.Hour)).Delete(&models.GPURecord{}).Error; err != nil {
return err
}
return nil
})
}
+321
View File
@@ -0,0 +1,321 @@
package records
import (
"encoding/csv"
"os"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/komari-monitor/komari/database/models"
)
var uuid = "7901508c-304f-49aa-b84f-957c33ae6f8a"
var _ = func() bool {
// 确保 Test 环境中使用 sqlite 内存数据库
return true
}()
// TestCompactRecord tests the database compaction logic by inserting 4h30m of data (one record per minute),
// then running migrateOldRecords and verifying the aggregation and cleanup.
func TestCompactRecord(t *testing.T) {
const totalMinutes = 12*60 + 30
loc := models.GetAppLocation()
now := time.Date(2026, 6, 15, 14, 30, 0, 0, loc)
threshold := compactRecordCutoff(now)
overlapCutoff := threshold.Add(-1 * time.Hour)
// 使用 sqlite 内存数据库并迁移表结构
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
assert.NoError(t, db.AutoMigrate(&models.Record{}))
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
expectedGroups := make(map[time.Time]struct{})
expectedRawRemain := 0
// 插入数据
for i := 0; i < totalMinutes; i++ {
recTime := now.Add(-time.Duration(i) * time.Minute)
rec := models.Record{Client: uuid, Time: models.FromTime(recTime), Cpu: float32(i), Gpu: float32(i), Load: float32(i), Temp: float32(i), Ram: int64(i)}
err := db.Create(&rec).Error
assert.NoError(t, err)
if recTime.Before(threshold) {
slot := recTime.Truncate(15 * time.Minute)
expectedGroups[slot] = struct{}{}
}
if !recTime.Before(overlapCutoff) {
expectedRawRemain++
}
}
// 导出原始数据到 CSV
os.MkdirAll("../../data", 0755)
var origRecs []models.Record
db.Order("time desc").Find(&origRecs)
fOrig, err := os.Create("../../data/original.csv")
assert.NoError(t, err)
defer fOrig.Close()
wOrig := csv.NewWriter(fOrig)
defer wOrig.Flush()
wOrig.Write([]string{"Client", "Time", "Cpu", "Gpu", "Load", "Temp", "Ram"})
for _, r := range origRecs {
wOrig.Write([]string{
r.Client,
r.Time.ToTime().Format(time.RFC3339),
strconv.FormatFloat(float64(r.Cpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Gpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Load), 'f', -1, 32),
strconv.FormatFloat(float64(r.Temp), 'f', -1, 32),
strconv.FormatInt(r.Ram, 10),
})
}
// 运行压缩(迁移)逻辑
err = migrateOldRecordsAt(db, now)
assert.NoError(t, err)
// 验证 long-term 表中的聚合记录数
var longCount int64
assert.NoError(t, db.Table("records_long_term").Count(&longCount).Error)
assert.Equal(t, int64(len(expectedGroups)), longCount)
// 验证原始表中剩余记录数
var remainCount int64
assert.NoError(t, db.Table("records").Count(&remainCount).Error)
assert.Equal(t, int64(expectedRawRemain), remainCount)
// 导出压缩后的数据到 CSV
var compRecs []models.Record
db.Table("records_long_term").Order("time desc").Find(&compRecs)
fComp, err := os.Create("../../data/compressed.csv")
assert.NoError(t, err)
defer fComp.Close()
wComp := csv.NewWriter(fComp)
defer wComp.Flush()
wComp.Write([]string{"Client", "Time", "Cpu", "Gpu", "Load", "Temp", "Ram"})
for _, r := range compRecs {
wComp.Write([]string{
r.Client,
r.Time.ToTime().Format(time.RFC3339),
strconv.FormatFloat(float64(r.Cpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Gpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Load), 'f', -1, 32),
strconv.FormatFloat(float64(r.Temp), 'f', -1, 32),
strconv.FormatInt(r.Ram, 10),
})
}
db.Table("records").Order("time desc").Find(&compRecs)
fComp, err = os.Create("../../data/compressed_records.csv")
assert.NoError(t, err)
defer fComp.Close()
wComp = csv.NewWriter(fComp)
defer wComp.Flush()
wComp.Write([]string{"Client", "Time", "Cpu", "Gpu", "Load", "Temp", "Ram"})
for _, r := range compRecs {
wComp.Write([]string{
r.Client,
r.Time.ToTime().Format(time.RFC3339),
strconv.FormatFloat(float64(r.Cpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Gpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Load), 'f', -1, 32),
strconv.FormatFloat(float64(r.Temp), 'f', -1, 32),
strconv.FormatInt(r.Ram, 10),
})
}
}
func TestCompactRecordPreservesExactTrafficDelta(t *testing.T) {
loc := models.GetAppLocation()
currentTime := time.Date(2026, 6, 15, 14, 30, 0, 0, loc)
now := currentTime.Truncate(15 * time.Minute).Add(-5*time.Hour + time.Minute)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
assert.NoError(t, db.AutoMigrate(&models.Record{}))
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
records := []models.Record{
{
Client: uuid,
Time: models.FromTime(now),
NetTotalUp: 100,
NetTotalDown: 200,
TrafficUp: 0,
TrafficDown: 0,
},
{
Client: uuid,
Time: models.FromTime(now.Add(5 * time.Minute)),
NetTotalUp: 150,
NetTotalDown: 260,
TrafficUp: 50,
TrafficDown: 60,
},
{
Client: uuid,
Time: models.FromTime(now.Add(10 * time.Minute)),
NetTotalUp: 10,
NetTotalDown: 30,
TrafficUp: 10,
TrafficDown: 30,
},
}
for _, rec := range records {
assert.NoError(t, db.Create(&rec).Error)
}
assert.NoError(t, migrateOldRecordsAt(db, currentTime))
var compacted []models.Record
assert.NoError(t, db.Table("records_long_term").Find(&compacted).Error)
require.Len(t, compacted, 1)
assert.Equal(t, int64(60), compacted[0].TrafficUp)
assert.Equal(t, int64(90), compacted[0].TrafficDown)
assert.Equal(t, int64(10), compacted[0].NetTotalUp)
assert.Equal(t, int64(30), compacted[0].NetTotalDown)
assert.True(t, compacted[0].Time.ToTime().Equal(records[2].Time.ToTime().Truncate(15*time.Minute)))
}
func TestRepairZeroTrafficDeltasPreservesRawResetDetailBeforeCompaction(t *testing.T) {
loc := models.GetAppLocation()
start := time.Date(2026, 6, 6, 0, 0, 0, 0, loc)
records := []models.Record{
{Client: uuid, Time: models.FromTime(start), NetTotalUp: 100, NetTotalDown: 200},
{Client: uuid, Time: models.FromTime(start.Add(5 * time.Minute)), NetTotalUp: 140, NetTotalDown: 260},
{Client: uuid, Time: models.FromTime(start.Add(10 * time.Minute)), NetTotalUp: 10, NetTotalDown: 20},
{Client: uuid, Time: models.FromTime(start.Add(15 * time.Minute)), NetTotalUp: 25, NetTotalDown: 35},
}
repairZeroTrafficDeltas(records, nil)
assert.Equal(t, int64(0), records[0].TrafficUp)
assert.Equal(t, int64(0), records[0].TrafficDown)
assert.Equal(t, int64(40), records[1].TrafficUp)
assert.Equal(t, int64(60), records[1].TrafficDown)
assert.Equal(t, int64(10), records[2].TrafficUp)
assert.Equal(t, int64(20), records[2].TrafficDown)
assert.Equal(t, int64(15), records[3].TrafficUp)
assert.Equal(t, int64(15), records[3].TrafficDown)
}
func TestRepairZeroTrafficDeltasUsesPreviousPersistedBaseline(t *testing.T) {
loc := models.GetAppLocation()
now := time.Date(2026, 6, 7, 12, 0, 0, 0, loc)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
assert.NoError(t, db.AutoMigrate(&models.Record{}))
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
assert.NoError(t, db.Table("records_long_term").Create(&models.Record{
Client: uuid,
Time: models.FromTime(now.Add(-10 * time.Minute)),
NetTotalUp: 100,
NetTotalDown: 200,
}).Error)
rawRecords := []models.Record{
{Client: uuid, Time: models.FromTime(now), NetTotalUp: 150, NetTotalDown: 260},
{Client: uuid, Time: models.FromTime(now.Add(5 * time.Minute)), NetTotalUp: 175, NetTotalDown: 300},
}
previousByClient, err := getPreviousTrafficRecordsBefore(db, rawRecords)
assert.NoError(t, err)
repairZeroTrafficDeltas(rawRecords, previousByClient)
assert.Equal(t, int64(50), rawRecords[0].TrafficUp)
assert.Equal(t, int64(60), rawRecords[0].TrafficDown)
assert.Equal(t, int64(25), rawRecords[1].TrafficUp)
assert.Equal(t, int64(40), rawRecords[1].TrafficDown)
}
func TestRepairZeroTrafficDeltasIgnoresSameSlotLongTermBaseline(t *testing.T) {
loc := models.GetAppLocation()
now := time.Date(2026, 6, 7, 12, 0, 30, 0, loc)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
assert.NoError(t, db.AutoMigrate(&models.Record{}))
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
assert.NoError(t, db.Table("records_long_term").Create(&models.Record{
Client: uuid,
Time: models.FromTime(now.Truncate(15 * time.Minute)),
NetTotalUp: 900,
NetTotalDown: 900,
}).Error)
rawRecords := []models.Record{
{Client: uuid, Time: models.FromTime(now), NetTotalUp: 150, NetTotalDown: 260},
{Client: uuid, Time: models.FromTime(now.Add(5 * time.Minute)), NetTotalUp: 175, NetTotalDown: 300},
}
previousByClient, err := getPreviousTrafficRecordsBefore(db, rawRecords)
assert.NoError(t, err)
repairZeroTrafficDeltas(rawRecords, previousByClient)
assert.Equal(t, int64(0), rawRecords[0].TrafficUp)
assert.Equal(t, int64(0), rawRecords[0].TrafficDown)
assert.Equal(t, int64(25), rawRecords[1].TrafficUp)
assert.Equal(t, int64(40), rawRecords[1].TrafficDown)
}
func TestCompactRecordRetainsOneHourOverlapWindow(t *testing.T) {
loc := models.GetAppLocation()
now := time.Date(2026, 6, 7, 12, 7, 0, 0, loc)
cutoff := compactRecordCutoff(now)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
assert.NoError(t, db.AutoMigrate(&models.Record{}))
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
records := []models.Record{
{Client: uuid, Time: models.FromTime(cutoff.Add(-time.Hour - time.Minute)), TrafficUp: 5},
{Client: uuid, Time: models.FromTime(cutoff.Add(-30 * time.Minute)), TrafficUp: 7},
{Client: uuid, Time: models.FromTime(now.Add(-3 * time.Hour)), TrafficUp: 11},
}
for _, rec := range records {
assert.NoError(t, db.Create(&rec).Error)
}
assert.NoError(t, migrateOldRecordsAt(db, now))
var remainTimes []models.Record
assert.NoError(t, db.Table("records").Order("time ASC").Find(&remainTimes).Error)
require.Len(t, remainTimes, 2)
assert.True(t, remainTimes[0].Time.ToTime().Equal(records[1].Time.ToTime()))
assert.True(t, remainTimes[1].Time.ToTime().Equal(records[2].Time.ToTime()))
}
func TestCompactRecordOnlyMigratesCompleteFifteenMinuteBuckets(t *testing.T) {
loc := models.GetAppLocation()
now := time.Date(2026, 6, 7, 12, 7, 0, 0, loc)
cutoff := compactRecordCutoff(now)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
assert.NoError(t, db.AutoMigrate(&models.Record{}))
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
compactable := models.Record{Client: uuid, Time: models.FromTime(cutoff.Add(-time.Minute)), TrafficUp: 5}
partialSlot := models.Record{Client: uuid, Time: models.FromTime(cutoff.Add(time.Minute)), TrafficUp: 7}
assert.NoError(t, db.Create(&compactable).Error)
assert.NoError(t, db.Create(&partialSlot).Error)
assert.NoError(t, migrateOldRecordsAt(db, now))
var compacted []models.Record
assert.NoError(t, db.Table("records_long_term").Order("time ASC").Find(&compacted).Error)
require.Len(t, compacted, 1)
assert.True(t, compacted[0].Time.ToTime().Equal(compactable.Time.ToTime().Truncate(15*time.Minute)))
var rawCount int64
assert.NoError(t, db.Table("records").Where("time = ?", partialSlot.Time).Count(&rawCount).Error)
assert.Equal(t, int64(1), rawCount)
}
+36 -18
View File
@@ -1,11 +1,9 @@
package tasks
import (
"context"
"time"
"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/utils"
"gorm.io/gorm"
@@ -47,12 +45,6 @@ func AddPingTask(clients []string, defaultOn bool, name string, target, task_typ
}
func DeletePingTask(id []uint) error {
// The metric store is independent from the main database, so clean it first
// to avoid leaving history that can no longer be addressed through the task.
if err := DeletePingRecords(id); err != nil {
return err
}
db := dbcore.GetDBInstance()
result := db.Where("id IN ?", id).Delete(&models.PingTask{})
if result.RowsAffected == 0 {
@@ -133,24 +125,38 @@ func UpdatePingTaskOrder(order map[uint]int) error {
return nil
}
// ping 记录已完全迁移到 metric store(指标 ping.latency_ms),运行期读写全部走
// metric store,旧 ping_records 表不再参与。
func SavePingRecord(record models.PingRecord) error {
return metricstore.WritePingRecord(context.Background(), record)
db := dbcore.GetDBInstance()
return db.Create(&record).Error
}
func DeletePingRecordsBefore(time time.Time) error {
db := dbcore.GetDBInstance()
err := db.Where("time < ?", time).Delete(&models.PingRecord{}).Error
return err
}
func DeletePingRecords(id []uint) error {
return metricstore.DeletePingRecordsByTask(context.Background(), id)
db := dbcore.GetDBInstance()
result := db.Where("task_id IN ?", id).Delete(&models.PingRecord{})
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return result.Error
}
func DeleteAllPingRecords() error {
return metricstore.DeleteAllPingRecords(context.Background())
db := dbcore.GetDBInstance()
result := db.Exec("DELETE FROM ping_records")
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return result.Error
}
func ReloadPingSchedule() error {
pingTasks, err := GetAllPingTasks()
if err != nil {
db := dbcore.GetDBInstance()
var pingTasks []models.PingTask
if err := db.Find(&pingTasks).Error; err != nil {
return err
}
return utils.ReloadPingSchedule(pingTasks)
@@ -195,5 +201,17 @@ func AddDefaultOnClientUUID(uuid string) error {
}
func GetPingRecords(uuid string, taskId int, start, end time.Time) ([]models.PingRecord, error) {
return metricstore.GetPingRecords(context.Background(), uuid, taskId, start, end)
db := dbcore.GetDBInstance()
var records []models.PingRecord
dbQuery := db.Model(&models.PingRecord{})
if uuid != "" {
dbQuery = dbQuery.Where("client = ?", uuid)
}
if taskId >= 0 {
dbQuery = dbQuery.Where("task_id = ?", uint(taskId))
}
if err := dbQuery.Where("time >= ? AND time <= ?", start, end).Order("time DESC").Find(&records).Error; err != nil {
return nil, err
}
return records, nil
}
+15 -4
View File
@@ -26,7 +26,7 @@ func CreateTask(taskId string, clients []string, command string) error {
Result: "",
ExitCode: nil,
FinishedAt: nil,
CreatedAt: time.Now().UTC(),
CreatedAt: models.FromTime(time.Now()),
})
}
if len(taskResults) > 0 {
@@ -57,6 +57,13 @@ func GetSpecificTaskResult(taskId, clientId string) (*models.TaskResult, error)
return &result, nil
}
func GetAllTasksResultByUUID(uuid string) ([]models.TaskResult, error) {
var results []models.TaskResult
if err := dbcore.GetDBInstance().Where("client = ?", uuid).Find(&results).Error; err != nil {
return nil, err
}
return results, nil
}
func GetAllTasks() ([]models.Task, error) {
var tasks []models.Task
if err := dbcore.GetDBInstance().Find(&tasks).Error; err != nil {
@@ -72,17 +79,21 @@ func GetTaskResultsByTaskId(taskId string) ([]models.TaskResult, error) {
}
return results, nil
}
func SaveTaskResult(taskId, clientId, result string, exitCode int, timestamp time.Time) error {
func DeleteTaskByTaskId(taskId string) error {
return dbcore.GetDBInstance().Where("task_id = ?", taskId).Delete(&models.Task{}).Error
}
func SaveTaskResult(taskId, clientId, result string, exitCode int, timestamp models.LocalTime) error {
return dbcore.GetDBInstance().
Model(&models.TaskResult{}).
Where("task_id = ? AND client = ?", taskId, clientId).
Updates(map[string]interface{}{
"result": result,
"exit_code": exitCode,
"finished_at": timestamp.UTC(),
"finished_at": timestamp,
}).Error
}
func ClearTaskResultsByTimeBefore(before time.Time) error {
return dbcore.GetDBInstance().Where("created_at < ?", before.UTC()).Delete(&models.TaskResult{}).Error
return dbcore.GetDBInstance().Where("created_at < ?", before.Format(time.RFC3339)).Delete(&models.TaskResult{}).Error
}
-68
View File
@@ -1,68 +0,0 @@
package tasks
import (
"testing"
"time"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
)
func TestClearTaskResultsByTimeBeforeUsesUTCTimeValue(t *testing.T) {
flags.DatabaseType = flags.DatabaseTypeSQLite
flags.DatabaseFile = "file:task_cleanup_time?mode=memory&cache=shared"
db := dbcore.GetDBInstance()
taskID := "task-cleanup-time"
if err := db.Create(&models.Task{TaskId: taskID, Command: "true"}).Error; err != nil {
t.Fatalf("create task: %v", err)
}
cutoff := time.Date(2026, 7, 17, 12, 0, 0, 123456789, time.UTC)
results := []models.TaskResult{
{TaskId: taskID, Client: "old", CreatedAt: cutoff.Add(-time.Nanosecond)},
{TaskId: taskID, Client: "boundary", CreatedAt: cutoff},
{TaskId: taskID, Client: "new", CreatedAt: cutoff.Add(time.Nanosecond)},
}
if err := db.Create(&results).Error; err != nil {
t.Fatalf("create task results: %v", err)
}
localCutoff := cutoff.In(time.FixedZone("UTC+8", 8*60*60))
if err := ClearTaskResultsByTimeBefore(localCutoff); err != nil {
t.Fatalf("clear task results: %v", err)
}
var remaining []models.TaskResult
if err := db.Where("task_id = ?", taskID).Order("created_at").Find(&remaining).Error; err != nil {
t.Fatalf("load remaining results: %v", err)
}
if len(remaining) != 2 || remaining[0].Client != "boundary" || remaining[1].Client != "new" {
t.Fatalf("remaining results = %#v, want boundary and new", remaining)
}
}
func TestGetAllPingTasksOrdersByWeightThenID(t *testing.T) {
flags.DatabaseType = flags.DatabaseTypeSQLite
flags.DatabaseFile = "file:ping_task_order?mode=memory&cache=shared"
db := dbcore.GetDBInstance()
tasks := []models.PingTask{
{Name: "third", Weight: 2, Type: "icmp", Target: "third.example", Interval: 60},
{Name: "first", Weight: 0, Type: "icmp", Target: "first.example", Interval: 60},
{Name: "second", Weight: 0, Type: "icmp", Target: "second.example", Interval: 60},
}
if err := db.Create(&tasks).Error; err != nil {
t.Fatalf("create ping tasks: %v", err)
}
ordered, err := GetAllPingTasks()
if err != nil {
t.Fatalf("get ordered ping tasks: %v", err)
}
if len(ordered) != len(tasks) {
t.Fatalf("ordered ping task count = %d, want %d", len(ordered), len(tasks))
}
if ordered[0].Id != tasks[1].Id || ordered[1].Id != tasks[2].Id || ordered[2].Id != tasks[0].Id {
t.Fatalf("ping task order = %#v, want ids [%d %d %d]", ordered, tasks[1].Id, tasks[2].Id, tasks[0].Id)
}
}
+16 -13
View File
@@ -1,18 +1,16 @@
package database
import (
"context"
"encoding/json"
logger "github.com/komari-monitor/komari/utils/log"
"log"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"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/config"
"github.com/komari-monitor/komari/pkg/config"
)
func GetPublicInfo() (map[string]interface{}, error) {
@@ -44,6 +42,15 @@ func GetPublicInfo() (map[string]interface{}, error) {
if !hasKey("o_auth_provider") {
cst.OAuthProvider = "github"
}
if !hasKey("record_enabled") {
cst.RecordEnabled = true
}
if !hasKey("record_preserve_time") {
cst.RecordPreserveTime = 720
}
if !hasKey("ping_record_preserve_time") {
cst.PingRecordPreserveTime = 24
}
// Fallback defaults if we couldn't enumerate keys.
if allErr != nil {
@@ -54,10 +61,7 @@ func GetPublicInfo() (map[string]interface{}, error) {
cst.Description = "Komari Monitor, a simple server monitoring tool."
}
}
retention, err := metricstore.GetRetentionSummary(context.Background())
if err != nil {
return nil, err
}
db := dbcore.GetDBInstance()
tc := models.ThemeConfiguration{}
err = db.Model(&models.ThemeConfiguration{}).Where("short = ?", cst.Theme).First(&tc).Error
@@ -67,7 +71,7 @@ func GetPublicInfo() (map[string]interface{}, error) {
tc_data := gin.H{}
err = json.Unmarshal([]byte(tc.Data), &tc_data)
if err != nil {
logger.Infof("database", "%v", err)
log.Printf("%v", err)
}
// Try to load theme declaration file and merge defaults for managed configuration
// Theme declarations live in ./data/theme/<short>/komari-theme.json
@@ -131,11 +135,10 @@ func GetPublicInfo() (map[string]interface{}, error) {
"oauth_provider": cst.OAuthProvider,
"disable_password_login": cst.DisablePasswordLogin,
"cors_origin_check_enabled": cst.CorsOriginCheckEnabled,
"record_enabled": retention.AllPositive, // 兼容旧版本主题
"record_preserve_time": retention.MaxDays * 24,
"ping_record_preserve_time": retention.MaxDays * 24,
"record_enabled": cst.RecordEnabled,
"record_preserve_time": cst.RecordPreserveTime,
"ping_record_preserve_time": cst.PingRecordPreserveTime,
"private_site": cst.PrivateSite,
"visitor_audit_enabled": cst.VisitorAuditEnabled,
"theme": cst.Theme,
"theme_settings": tc_data,
}, nil
+9 -2
View File
@@ -55,7 +55,14 @@ sudo ./install-komari.sh
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. ブラウザで `http://<your_server_ip>:25774` にアクセスし、インストールガイドを完了します。ガイドで管理者アカウント、サイト情報、監視データベースを設定します。
3. デフォルトのユーザー名とパスワードを表示します:
```bash
docker logs komari
```
4. ブラウザで `http://<your_server_ip>:25774` にアクセスします。
> [!NOTE]
> 環境変数 `ADMIN_USERNAME` と `ADMIN_PASSWORD` を使用して、初期のユーザー名とパスワードをカスタマイズすることもできます。
### 3. バイナリファイル展開
@@ -65,7 +72,7 @@ sudo ./install-komari.sh
./komari server -l 0.0.0.0:25774
```
3. ブラウザで `http://<your_server_ip>:25774` にアクセスします。デフォルトのポートは `25774` です。
4. インストールガイドに従って管理者アカウント、サイト情報、監視データベースを設定します。
4. デフォルトのユーザー名とパスワードは、起動ログで確認するか、環境変数 `ADMIN_USERNAME` と `ADMIN_PASSWORD` を介して設定できます。
> [!NOTE]
> バイナリに実行権限があることを確認してください(`chmod +x komari`)。データは実行ディレクトリの `data` フォルダに保存されます。
+9 -2
View File
@@ -55,7 +55,14 @@ sudo ./install-komari.sh
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. 在瀏覽器中存取 `http://<your_server_ip>:25774` 並完成安裝精靈。精靈會建立管理員帳號,並設定站點中繼資料和監控資料庫。
3. 查看預設帳號和密碼:
```bash
docker logs komari
```
4. 在瀏覽器中存取 `http://<your_server_ip>:25774`。
> [!NOTE]
> 你也可以透過環境變數 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自訂初始使用者名稱和密碼。
### 3. 二進位檔案部署
@@ -65,7 +72,7 @@ sudo ./install-komari.sh
./komari server -l 0.0.0.0:25774
```
3. 在瀏覽器中存取 `http://<your_server_ip>:25774`,預設監聽 `25774` 連接埠。
4. 按照安裝精靈建立管理員帳號,並設定站點中繼資料和監控資料庫。
4. 預設帳號和密碼可在啟動日誌中查看,或透過環境變數 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 設定。
> [!NOTE]
> 確保二進位檔案具有可執行權限(`chmod +x komari`)。資料將保存在執行目錄下的 `data` 資料夾中。
+9 -2
View File
@@ -55,7 +55,14 @@ sudo ./install-komari.sh
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. 在浏览器中访问 `http://<your_server_ip>:25774` 并完成安装向导。向导会创建管理员账号,并设置站点元信息和监控数据库。
3. 查看默认账号和密码:
```bash
docker logs komari
```
4. 在浏览器中访问 `http://<your_server_ip>:25774`。
> [!NOTE]
> 你也可以通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自定义初始用户名和密码。
### 3. 二进制文件部署
@@ -65,7 +72,7 @@ sudo ./install-komari.sh
./komari server -l 0.0.0.0:25774
```
3. 在浏览器中访问 `http://<your_server_ip>:25774`,默认监听 `25774` 端口。
4. 按照安装向导创建管理员账号,并设置站点元信息和监控数据库。
4. 默认账号和密码可在启动日志中查看,或通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 设置。
> [!NOTE]
> 确保二进制文件具有可执行权限(`chmod +x komari`)。数据将保存在运行目录下的 `data` 文件夹中。
-327
View File
@@ -1,327 +0,0 @@
# Visitor Audit RPC 使用说明
本文档说明 `public:recordVisitorEvent` RPC2 接口的调用方式。该接口用于让主题前端把访客访问和前端操作记录写入 Komari 后端审计日志。
## 接口概览
- RPC 方法:`public:recordVisitorEvent`
- RPC 路径:`/api/rpc2`
- HTTP 方法:`POST`
- 写入位置:现有审计日志表 `models.Log`
- 管理端读取:复用现有 `admin:getLogs`
- 日志类型:`msg_type = "visitor"`
- 默认状态:关闭,需设置 `visitor_audit_enabled = true`
该接口不会信任前端传入的 IP。来源 IP 和 User-Agent 由后端从请求上下文记录。
## 权限
`public:recordVisitorEvent` 属于 `public` 命名空间,访客可调用。
私有站点模式下,该方法也在登录页白名单内,便于记录登录前/前台访问事件。
这是有意保留的行为;总开关关闭时,白名单中的方法仍可调用,但不会写库。
## 启用
存量实例和新实例默认都不会开放匿名日志写入。管理员可通过
`admin:editSettings` 将 `visitor_audit_enabled` 设为 `true`。公开设置
`public:getPublicSettings` 也会返回这个字段,主题可据此决定是否上报。
未启用时接口返回 `status = "disabled"`。启用后,每个来源 IP 使用内存令牌桶限流:
平均每分钟 30 次,允许短时突发 10 次;超限时返回 `status = "rate_limited"`,不写库。
## 请求参数
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `event` | `string` | 是 | 事件名,例如 `page_view`、`node_open`、`search` |
| `action` | `string` | 否 | `event` 的别名 |
| `operation` | `string` | 否 | `event` 的别名 |
| `path` | `string` | 否 | 前端路径,例如 `/`、`/instance/<uuid>` |
| `route` | `string` | 否 | 前端路由名,例如 `home`、`instance-detail` |
| `target` | `string` | 否 | 操作目标,例如节点 UUID、工具 key、按钮 key |
| `detail` | `object` | 否 | 额外元数据,大小受限 |
`event`、`action`、`operation` 三者任选一个即可,优先级为:
```text
event > action > operation
```
## 后端自动记录的信息
| 字段 | 来源 |
| --- | --- |
| IP | `rpc.ContextMeta.RemoteIP`,来自服务端 `c.ClientIP()` |
| User-Agent | `rpc.ContextMeta.UserAgent`,来自请求头 |
| 用户 UUID | 登录状态下使用 `rpc.ContextMeta.UserUUID`;访客为空 |
| 时间 | 后端写入审计日志时生成 |
## 字段限制
字符串字段按 Unicode 字符计数,`detail` 和最终 message 按序列化后的字节数计数。
| 字段 | 最大长度 |
| --- | ---: |
| `event` | 64 |
| `path` | 512 |
| `route` | 128 |
| `target` | 128 |
| User-Agent | 512 |
| `detail` JSON | 2048 |
| 最终日志 message | 4096 |
如果 `detail` 超过限制,不会完整保存,而会写入截断标记:
```json
{
"truncated": true,
"size": 4096
}
```
## 事件名规范化
服务端会规范化事件名:
- 转小写
- 空格转 `_`
- 仅保留字母、数字、`_`、`-`、`:`、`.`
- 最大长度 64
示例:
```text
Page View -> page_view
node:open.detail -> node:open.detail
```
## 请求示例:页面访问
```bash
curl -X POST http://localhost:25774/api/rpc2 \
-H 'Content-Type: application/json' \
--data '{
"jsonrpc": "2.0",
"method": "public:recordVisitorEvent",
"params": {
"event": "page_view",
"path": "/",
"route": "home",
"detail": {
"theme": "glassmorphism"
}
},
"id": 1
}'
```
写入成功返回:
```json
{
"jsonrpc": "2.0",
"result": {
"status": "success"
},
"id": 1
}
```
`disabled` 和 `rate_limited` 也属于正常响应,不会返回 JSON-RPC 错误;主题上报不应影响主流程。
## 请求示例:打开节点详情
```json
{
"jsonrpc": "2.0",
"method": "public:recordVisitorEvent",
"params": {
"event": "node_open",
"path": "/instance/6f0b-example",
"route": "instance-detail",
"target": "6f0b-example",
"detail": {
"source": "node_card"
}
},
"id": 2
}
```
## 请求示例:搜索
```json
{
"jsonrpc": "2.0",
"method": "public:recordVisitorEvent",
"params": {
"event": "search",
"path": "/",
"route": "home",
"detail": {
"keyword_length": 6,
"result_count": 3
}
},
"id": 3
}
```
建议不要记录完整搜索关键词,避免把敏感内容写入审计日志。
## 前端封装示例
```ts
interface VisitorAuditEvent {
event?: string
action?: string
operation?: string
path?: string
route?: string
target?: string
detail?: Record<string, unknown>
}
let auditRequestId = 0
export async function recordVisitorEvent(event: VisitorAuditEvent): Promise<void> {
try {
await fetch('/api/rpc2', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
jsonrpc: '2.0',
method: 'public:recordVisitorEvent',
params: event,
id: ++auditRequestId,
}),
})
}
catch {
// 审计上报不应影响用户正常浏览
}
}
```
## Vue Router 访问记录示例
```ts
router.afterEach((to) => {
void recordVisitorEvent({
event: 'page_view',
path: to.fullPath,
route: String(to.name ?? ''),
detail: {
params: Object.keys(to.params),
query_keys: Object.keys(to.query),
},
})
})
```
建议只记录 query key,不记录完整 query value,避免泄露 token 或敏感参数。
## 推荐事件名
| 场景 | event |
| --- | --- |
| 页面访问 | `page_view` |
| 打开节点详情 | `node_open` |
| 搜索 | `search` |
| 切换分组 | `group_change` |
| 切换视图模式 | `view_mode_change` |
| 点击管理入口 | `admin_entry_click` |
| 打开高级工具 | `home_tool_open` |
| 审计日志刷新 | `audit_refresh` |
| 审计日志翻页 | `audit_page_change` |
| 导出 JSON | `export_json` |
| 导出 CSV | `export_csv` |
| WebRTC 自检开始 | `webrtc_check_start` |
| WebRTC 自检完成 | `webrtc_check_done` |
## 审计日志保存形态
写入现有 `models.Log`:
| 字段 | 值 |
| --- | --- |
| `ip` | 服务端看到的来源 IP |
| `uuid` | 登录用户 UUID;访客为空 |
| `msg_type` | `visitor` |
| `message` | `visitor event: {...}` |
| `time` | 后端写入时间 |
示例 `message`:
```text
visitor event: {"event":"page_view","path":"/","route":"home","user_agent":"Mozilla/5.0 ...","detail":{"theme":"glassmorphism"}}
```
## 管理端读取
复用现有接口:
```text
admin:getLogs
```
请求示例:
```json
{
"jsonrpc": "2.0",
"method": "admin:getLogs",
"params": {
"limit": "100",
"page": "1",
"msg_type": "visitor"
},
"id": 10
}
```
`msg_type` 为可选的精确匹配过滤参数。传入 `visitor` 后,计数和分页都会在 SQL 查询层过滤,
无需先拉取所有日志再由前端筛选。
## 安全注意事项
### 不要从前端传 IP
前端 IP 不可信。该接口会自动使用服务端看到的来源 IP。
### 不要记录敏感值
不建议记录:
- 密码
- token
- cookie
- 完整 URL query
- 完整搜索关键词
- 导出内容
- WebSSH 命令内容
- 剪贴板内容
建议只记录:
- 操作类型
- 路由
- 目标 ID
- 结果数量
- 布尔状态
- 非敏感摘要
### 上报不要阻塞主流程
前端调用建议使用:
```ts
void recordVisitorEvent(...)
```
即使上报失败,也不影响页面功能。
+7 -3
View File
@@ -3,6 +3,7 @@ module github.com/komari-monitor/komari
go 1.25.0
require (
github.com/coreos/go-oidc/v3 v3.15.0
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf
github.com/gin-gonic/gin v1.10.0
@@ -17,7 +18,8 @@ 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
google.golang.org/grpc v1.75.0
google.golang.org/protobuf v1.36.6
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.26.1
)
@@ -40,12 +42,12 @@ require (
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -67,8 +69,10 @@ 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/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+30
View File
@@ -12,6 +12,8 @@ github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFos
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -29,6 +31,12 @@ github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI=
github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -43,6 +51,8 @@ github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxo
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -125,6 +135,18 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
@@ -133,6 +155,8 @@ golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -140,6 +164,12 @@ golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-3
View File
@@ -226,9 +226,6 @@ detect_arch() {
riscv64)
echo "riscv64"
;;
loongarch64|loong64)
echo "loong64"
;;
*)
log_error "不支持的架构: $arch"
exit 1
-30
View File
@@ -1,30 +0,0 @@
package migrations
import "time"
// ClientInfo is the legacy table shape for migrating pre-Client model data.
type ClientInfo struct {
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
Name string `json:"name" gorm:"type:varchar(100);not null"`
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
Arch string `json:"arch" gorm:"type:varchar(50)"`
CpuCores int `json:"cpu_cores" gorm:"type:int"`
OS string `json:"os" gorm:"type:varchar(100)"`
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
Region string `json:"region" gorm:"type:varchar(100)"`
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
Weight int `json:"weight" gorm:"type:int"`
Price float64 `json:"price"`
BillingCycle int `json:"billing_cycle"`
ExpiredAt *time.Time `json:"expired_at" gorm:"type:timestamp"`
CreatedAt time.Time
UpdatedAt time.Time
}
-718
View File
@@ -1,718 +0,0 @@
package migrations
import (
"context"
"database/sql"
"encoding/json"
"fmt"
logger "github.com/komari-monitor/komari/utils/log"
"math"
"sort"
"strings"
"time"
"github.com/komari-monitor/komari/database/metricstore"
"github.com/komari-monitor/komari/database/models"
appconfig "github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/pkg/metric"
"gorm.io/gorm"
)
const (
legacyMonitoringMigrationDoneKey = "migration_legacy_monitoring_to_metric_store_done"
legacyMonitoringBatchSize = 500
legacyMonitoringInterval = time.Hour
)
var legacyMonitoringTables = []string{"records", "records_long_term", "gpu_records", "ping_records"}
type LegacyMonitoringStats struct {
Records int64
GPU int64
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.
type LegacyMonitoringSummary struct {
LoadRows int64 `json:"load_rows"`
GPURows int64 `json:"gpu_rows"`
LatencyRows int64 `json:"latency_rows"`
MonitoringRows int64 `json:"monitoring_rows"`
EstimatedPoints int64 `json:"estimated_points"`
ServerCount int64 `json:"server_count"`
RetentionDays int `json:"retention_days"`
OldestAt *time.Time `json:"oldest_at,omitempty"`
NewestAt *time.Time `json:"newest_at,omitempty"`
}
type LegacyMonitoringProgress struct {
Phase string `json:"phase"`
Table string `json:"table,omitempty"`
SourceRowsDone int64 `json:"source_rows_done"`
SourceRowsTotal int64 `json:"source_rows_total"`
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"`
}
type legacyBatchProgress func(table string, rows, points int64)
type legacyP95Group struct {
metricName string
entityID string
tags map[string]string
values []float64
}
type legacyHourlyP95Aggregator struct {
store *metric.Store
partitionKey string
bucket time.Time
groups map[string]*legacyP95Group
}
// LegacyMonitoringMigrationRequired reports whether startup must enter the
// restricted 1.2.7 upgrade flow. The migration itself runs only after an
// administrator explicitly starts it from that guide.
func LegacyMonitoringMigrationRequired(db *gorm.DB) (bool, LegacyMonitoringSummary, error) {
done, err := appconfig.GetAs[bool](legacyMonitoringMigrationDoneKey, false)
if err != nil {
return false, LegacyMonitoringSummary{}, fmt.Errorf("read legacy monitoring migration marker: %w", err)
}
summary, err := InspectLegacyMonitoring(db)
if err != nil {
return false, LegacyMonitoringSummary{}, err
}
return !done && summary.MonitoringRows > 0, summary, nil
}
func InspectLegacyMonitoring(db *gorm.DB) (LegacyMonitoringSummary, error) {
var summary LegacyMonitoringSummary
if db == nil {
return summary, fmt.Errorf("migration database is nil")
}
if db.Migrator().HasTable(&models.Client{}) {
if err := db.Model(&models.Client{}).Count(&summary.ServerCount).Error; err != nil {
return summary, fmt.Errorf("count monitored servers: %w", err)
}
}
counts := map[string]*int64{
"records": &summary.LoadRows,
"records_long_term": &summary.LoadRows,
"gpu_records": &summary.GPURows,
"ping_records": &summary.LatencyRows,
}
var oldest, newest time.Time
for _, table := range legacyMonitoringTables {
if !db.Migrator().HasTable(table) {
continue
}
var count int64
if err := db.Table(table).Count(&count).Error; err != nil {
return summary, fmt.Errorf("count legacy %s: %w", table, err)
}
*counts[table] += count
if count == 0 {
continue
}
var bounds legacyMonitoringBounds
if err := db.Table(table).Select("MIN(time) AS oldest, MAX(time) AS newest").Scan(&bounds).Error; err != nil {
return summary, fmt.Errorf("inspect legacy %s time range: %w", table, err)
}
tableOldest, err := parseLegacyTimestamp(bounds.Oldest, time.UTC)
if err != nil {
return summary, fmt.Errorf("parse legacy %s oldest time: %w", table, err)
}
tableNewest, err := parseLegacyTimestamp(bounds.Newest, time.UTC)
if err != nil {
return summary, fmt.Errorf("parse legacy %s newest time: %w", table, err)
}
if oldest.IsZero() || (!tableOldest.IsZero() && tableOldest.Before(oldest)) {
oldest = tableOldest
}
if newest.IsZero() || tableNewest.After(newest) {
newest = tableNewest
}
}
summary.MonitoringRows = summary.LoadRows + summary.GPURows + summary.LatencyRows
if !oldest.IsZero() {
oldestCopy := oldest
summary.OldestAt = &oldestCopy
}
if !newest.IsZero() {
newestCopy := newest
summary.NewestAt = &newestCopy
}
if !oldest.IsZero() && !newest.IsZero() {
estimatedPoints, err := estimateLegacyHourlyP95Points(db, summary, oldest, newest)
if err != nil {
return summary, err
}
summary.EstimatedPoints = estimatedPoints
retentionEnd := time.Now().UTC()
if newest.After(retentionEnd) {
retentionEnd = newest
}
summary.RetentionDays = int(math.Ceil(retentionEnd.Sub(oldest).Hours() / 24))
if summary.RetentionDays == 0 && summary.MonitoringRows > 0 {
summary.RetentionDays = 1
}
}
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)
}
return migrateLegacyMonitoringTables(ctx, db, s, progress)
}
// CompleteLegacyMonitoringMigration removes the legacy tables, runs any
// caller-owned database maintenance, and only then persists the completion
// marker. Keeping the marker last prevents a failed finalization from being
// reported as a completed migration.
func CompleteLegacyMonitoringMigration(db *gorm.DB, finalize func() error) error {
if err := dropLegacyMonitoringTables(db); err != nil {
return err
}
if finalize != nil {
if err := finalize(); err != nil {
return fmt.Errorf("finalize legacy monitoring migration: %w", err)
}
}
if err := appconfig.Set(legacyMonitoringMigrationDoneKey, true); err != nil {
return fmt.Errorf("mark legacy monitoring migration done: %w", err)
}
return nil
}
func runLegacyMonitoringMigration(ctx context.Context, db *gorm.DB, s *metric.Store, done bool, markDone func() error) (LegacyMonitoringStats, error) {
var stats LegacyMonitoringStats
if done {
return stats, nil
}
if db == nil {
return stats, fmt.Errorf("migration database is nil")
}
if s == nil {
return stats, fmt.Errorf("metric store is nil")
}
if markDone == nil {
return stats, fmt.Errorf("migration marker writer is nil")
}
stats, err := migrateLegacyMonitoringTables(ctx, db, s, nil)
if err != nil {
return stats, err
}
if err := dropLegacyMonitoringTables(db); err != nil {
return stats, err
}
if err := markDone(); err != nil {
return stats, fmt.Errorf("mark legacy monitoring migration done: %w", err)
}
return stats, nil
}
func migrateLegacyMonitoringTables(ctx context.Context, db *gorm.DB, s *metric.Store, progress func(LegacyMonitoringProgress)) (LegacyMonitoringStats, error) {
var stats LegacyMonitoringStats
summary, err := InspectLegacyMonitoring(db)
if err != nil {
return stats, err
}
var rowsDone, writtenPoints int64
emit := func(table string, rows, points int64) {
rowsDone += rows
writtenPoints += points
if progress != nil {
progress(LegacyMonitoringProgress{
Phase: "migrating",
Table: table,
SourceRowsDone: rowsDone,
SourceRowsTotal: summary.MonitoringRows,
WrittenPoints: writtenPoints,
})
}
}
if progress != nil {
emit("", 0, 0)
}
n, err := migrateLegacyRecordTables(ctx, s, db, []string{"records", "records_long_term"}, emit)
if err != nil {
return stats, err
}
stats.Records += n
n, err = migrateLegacyGPURecordTable(ctx, s, db, "gpu_records", emit)
if err != nil {
return stats, err
}
stats.GPU += n
n, err = migrateLegacyPingRecordTable(ctx, s, db, "ping_records", emit)
if err != nil {
return stats, err
}
stats.Ping += n
return stats, nil
}
func migrateLegacyRecordTables(ctx context.Context, s *metric.Store, db *gorm.DB, tables []string, progress legacyBatchProgress) (int64, error) {
var existing []string
var total int64
for _, table := range tables {
if !db.Migrator().HasTable(table) {
continue
}
var count int64
if err := db.Table(table).Count(&count).Error; err != nil {
return 0, fmt.Errorf("count legacy %s: %w", table, err)
}
if count > 0 {
existing = append(existing, table)
total += count
}
}
if len(existing) == 0 {
return 0, nil
}
parts := make([]string, 0, len(existing))
for _, table := range existing {
projection, err := legacyRecordProjection(db, table)
if err != nil {
return 0, err
}
parts = append(parts, projection)
}
query := strings.Join(parts, " UNION ALL ") + " ORDER BY client ASC, time ASC"
rows, err := db.Raw(query).Rows()
if err != nil {
return 0, fmt.Errorf("stream legacy record tables: %w", err)
}
defer rows.Close()
logger.Infof("migration", "[legacy-migration] aggregating %d rows from %s into 1h P95 points", total, strings.Join(existing, ","))
migrated, err := migrateLegacyStream(ctx, db, s, rows, "records", func() *models.Record { return &models.Record{} }, func(value models.Record) []metric.Point {
return recordToPoints(value)
}, progress)
if err != nil {
return migrated, fmt.Errorf("aggregate legacy record tables: %w", err)
}
logger.Infof("migration", "[legacy-migration] aggregated %d rows from %s", migrated, strings.Join(existing, ","))
return migrated, nil
}
func migrateLegacyGPURecordTable(ctx context.Context, s *metric.Store, db *gorm.DB, table string, progress legacyBatchProgress) (int64, error) {
if !db.Migrator().HasTable(table) {
return 0, nil
}
var total int64
if err := db.Table(table).Count(&total).Error; err != nil {
return 0, fmt.Errorf("count legacy %s: %w", table, err)
}
if total == 0 {
return 0, nil
}
rows, err := db.Table(table).Select("*").Order("client ASC, device_index ASC, device_name ASC, time ASC").Rows()
if err != nil {
return 0, fmt.Errorf("stream legacy %s: %w", table, err)
}
defer rows.Close()
logger.Infof("migration", "[legacy-migration] aggregating %d rows from %s into 1h P95 points", total, table)
migrated, err := migrateLegacyStream(ctx, db, s, rows, table, func() *models.GPURecord { return &models.GPURecord{} }, func(value models.GPURecord) []metric.Point {
return gpuRecordToPoints(value)
}, progress)
if err != nil {
return migrated, fmt.Errorf("aggregate legacy %s: %w", table, err)
}
logger.Infof("migration", "[legacy-migration] aggregated %d rows from %s", migrated, table)
return migrated, nil
}
func migrateLegacyPingRecordTable(ctx context.Context, s *metric.Store, db *gorm.DB, table string, progress legacyBatchProgress) (int64, error) {
if !db.Migrator().HasTable(table) {
return 0, nil
}
var total int64
if err := db.Table(table).Count(&total).Error; err != nil {
return 0, fmt.Errorf("count legacy %s: %w", table, err)
}
if total == 0 {
return 0, nil
}
rows, err := db.Table(table).Select("*").Order("client ASC, task_id ASC, time ASC").Rows()
if err != nil {
return 0, fmt.Errorf("stream legacy %s: %w", table, err)
}
defer rows.Close()
logger.Infof("migration", "[legacy-migration] aggregating %d rows from %s into 1h P95 points", total, table)
migrated, err := migrateLegacyStream(ctx, db, s, rows, table, func() *models.PingRecord { return &models.PingRecord{} }, func(value models.PingRecord) []metric.Point {
return pingRecordToPoints(value)
}, progress)
if err != nil {
return migrated, fmt.Errorf("aggregate legacy %s: %w", table, err)
}
logger.Infof("migration", "[legacy-migration] aggregated %d rows from %s", migrated, table)
return migrated, nil
}
func migrateLegacyStream[T any](ctx context.Context, db *gorm.DB, s *metric.Store, rows *sql.Rows, table string, newValue func() *T, toPoints func(T) []metric.Point, progress legacyBatchProgress) (int64, error) {
aggregator := &legacyHourlyP95Aggregator{store: s}
var migrated, batchRows, batchPoints int64
emit := func(force bool) {
if progress != nil && (batchRows > 0 || force && batchPoints > 0) {
progress(table, batchRows, batchPoints)
}
batchRows = 0
batchPoints = 0
}
for rows.Next() {
select {
case <-ctx.Done():
return migrated, ctx.Err()
default:
}
value := newValue()
if err := db.ScanRows(rows, value); err != nil {
return migrated, err
}
written, err := aggregator.Add(ctx, toPoints(*value))
if err != nil {
return migrated, err
}
migrated++
batchRows++
batchPoints += written
if batchRows >= legacyMonitoringBatchSize {
emit(false)
}
}
if err := rows.Err(); err != nil {
return migrated, err
}
written, err := aggregator.Flush(ctx)
if err != nil {
return migrated, err
}
batchPoints += written
emit(true)
return migrated, nil
}
func legacyRecordProjection(db *gorm.DB, table string) (string, error) {
columnTypes, err := db.Migrator().ColumnTypes(table)
if err != nil {
return "", fmt.Errorf("inspect legacy %s columns: %w", table, err)
}
available := make(map[string]struct{}, len(columnTypes))
for _, column := range columnTypes {
available[strings.ToLower(column.Name())] = struct{}{}
}
for _, required := range []string{"client", "time"} {
if _, ok := available[required]; !ok {
return "", fmt.Errorf("legacy %s is missing required column %s", table, required)
}
}
columns := []string{
"client", "time", "cpu", "gpu", "ram", "swap", "load", "disk",
"net_in", "net_out", "net_total_up", "net_total_down", "traffic_up",
"traffic_down", "process", "connections", "connections_udp",
}
projection := make([]string, 0, len(columns))
for _, column := range columns {
if _, ok := available[column]; ok {
projection = append(projection, column)
} else {
projection = append(projection, "0 AS "+column)
}
}
return "SELECT " + strings.Join(projection, ", ") + " FROM " + table, nil
}
func (a *legacyHourlyP95Aggregator) Add(ctx context.Context, points []metric.Point) (int64, error) {
var written int64
for _, point := range points {
bucket := point.Timestamp.UTC().Truncate(legacyMonitoringInterval)
partition, err := legacyPointPartition(point)
if err != nil {
return written, err
}
if a.groups != nil && (partition != a.partitionKey || !bucket.Equal(a.bucket)) {
if partition == a.partitionKey && bucket.Before(a.bucket) {
return written, fmt.Errorf("legacy points are not ordered within series: %s before %s", bucket, a.bucket)
}
n, err := a.Flush(ctx)
written += n
if err != nil {
return written, err
}
}
if a.groups == nil {
a.partitionKey = partition
a.bucket = bucket
a.groups = make(map[string]*legacyP95Group)
}
group := a.groups[point.MetricName]
if group == nil {
group = &legacyP95Group{
metricName: point.MetricName,
entityID: point.EntityID,
tags: cloneLegacyTags(point.Tags),
}
a.groups[point.MetricName] = group
}
group.values = append(group.values, point.Value)
}
return written, nil
}
func (a *legacyHourlyP95Aggregator) Flush(ctx context.Context) (int64, error) {
if len(a.groups) == 0 {
return 0, nil
}
names := make([]string, 0, len(a.groups))
for name := range a.groups {
names = append(names, name)
}
sort.Strings(names)
points := make([]metric.Point, 0, len(names))
for _, name := range names {
group := a.groups[name]
points = append(points, metric.Point{
MetricName: group.metricName,
EntityID: group.entityID,
Timestamp: a.bucket,
Value: legacyP95(group.values),
Tags: group.tags,
})
}
if err := a.store.WriteBatch(ctx, points); err != nil {
return 0, err
}
a.partitionKey = ""
a.bucket = time.Time{}
a.groups = nil
return int64(len(points)), nil
}
func legacyP95(values []float64) float64 {
if len(values) == 0 {
return 0
}
sort.Float64s(values)
if len(values) == 1 {
return values[0]
}
position := 0.95 * float64(len(values)-1)
lower := int(math.Floor(position))
upper := int(math.Ceil(position))
if lower == upper {
return values[lower]
}
weight := position - float64(lower)
return values[lower]*(1-weight) + values[upper]*weight
}
func legacyPointPartition(point metric.Point) (string, error) {
tags, err := json.Marshal(point.Tags)
if err != nil {
return "", fmt.Errorf("encode legacy point tags: %w", err)
}
return point.EntityID + "\x00" + string(tags), nil
}
func cloneLegacyTags(tags map[string]string) map[string]string {
if len(tags) == 0 {
return nil
}
cloned := make(map[string]string, len(tags))
for key, value := range tags {
cloned[key] = value
}
return cloned
}
func estimateLegacyHourlyP95Points(db *gorm.DB, summary LegacyMonitoringSummary, oldest, newest time.Time) (int64, error) {
type estimate struct {
Count int64 `gorm:"column:count"`
}
countSeries := func(query string) (int64, error) {
var result estimate
if err := db.Raw("SELECT COUNT(*) AS count FROM (" + query + ")").Scan(&result).Error; err != nil {
return 0, err
}
return result.Count, nil
}
firstHour := oldest.UTC().Truncate(legacyMonitoringInterval)
lastHour := newest.UTC().Truncate(legacyMonitoringInterval)
hours := int64(lastHour.Sub(firstHour)/legacyMonitoringInterval) + 1
if hours < 1 {
hours = 1
}
var recordParts []string
for _, table := range []string{"records", "records_long_term"} {
if db.Migrator().HasTable(table) {
recordParts = append(recordParts, "SELECT client FROM "+table)
}
}
var total int64
if len(recordParts) > 0 {
series, err := countSeries(strings.Join(recordParts, " UNION "))
if err != nil {
return 0, fmt.Errorf("estimate load series: %w", err)
}
total += minLegacyBuckets(summary.LoadRows, series*hours) * 15
}
if db.Migrator().HasTable("gpu_records") {
series, err := countSeries("SELECT client, device_index, device_name FROM gpu_records GROUP BY client, device_index, device_name")
if err != nil {
return 0, fmt.Errorf("estimate GPU series: %w", err)
}
total += minLegacyBuckets(summary.GPURows, series*hours) * 4
}
if db.Migrator().HasTable("ping_records") {
series, err := countSeries("SELECT client, task_id FROM ping_records GROUP BY client, task_id")
if err != nil {
return 0, fmt.Errorf("estimate ping series: %w", err)
}
total += minLegacyBuckets(summary.LatencyRows, series*hours) * 2
}
return total, nil
}
func minLegacyBuckets(sourceRows, estimatedBuckets int64) int64 {
if sourceRows < estimatedBuckets {
return sourceRows
}
return estimatedBuckets
}
func recordToPoints(rec models.Record) []metric.Point {
ts := rec.Time
entityID := rec.Client
return []metric.Point{
{MetricName: metricstore.MetricCPU, EntityID: entityID, Timestamp: ts, Value: float64(rec.Cpu)},
{MetricName: metricstore.MetricGPU, EntityID: entityID, Timestamp: ts, Value: float64(rec.Gpu)},
{MetricName: metricstore.MetricRAM, EntityID: entityID, Timestamp: ts, Value: float64(rec.Ram)},
{MetricName: metricstore.MetricSwap, EntityID: entityID, Timestamp: ts, Value: float64(rec.Swap)},
{MetricName: metricstore.MetricLoad, EntityID: entityID, Timestamp: ts, Value: float64(rec.Load)},
{MetricName: metricstore.MetricDisk, EntityID: entityID, Timestamp: ts, Value: float64(rec.Disk)},
{MetricName: metricstore.MetricNetIn, EntityID: entityID, Timestamp: ts, Value: float64(rec.NetIn)},
{MetricName: metricstore.MetricNetOut, EntityID: entityID, Timestamp: ts, Value: float64(rec.NetOut)},
{MetricName: metricstore.MetricNetTotalUp, EntityID: entityID, Timestamp: ts, Value: float64(rec.NetTotalUp)},
{MetricName: metricstore.MetricNetTotalDown, EntityID: entityID, Timestamp: ts, Value: float64(rec.NetTotalDown)},
{MetricName: metricstore.MetricTrafficUp, EntityID: entityID, Timestamp: ts, Value: float64(rec.TrafficUp)},
{MetricName: metricstore.MetricTrafficDown, EntityID: entityID, Timestamp: ts, Value: float64(rec.TrafficDown)},
{MetricName: metricstore.MetricProcess, EntityID: entityID, Timestamp: ts, Value: float64(rec.Process)},
{MetricName: metricstore.MetricConnections, EntityID: entityID, Timestamp: ts, Value: float64(rec.Connections)},
{MetricName: metricstore.MetricConnectionsUDP, EntityID: entityID, Timestamp: ts, Value: float64(rec.ConnectionsUdp)},
}
}
func gpuRecordToPoints(rec models.GPURecord) []metric.Point {
ts := rec.Time
tags := map[string]string{
"device_index": fmt.Sprintf("%d", rec.DeviceIndex),
"device_name": rec.DeviceName,
}
return []metric.Point{
{MetricName: metricstore.MetricGPUMem, EntityID: rec.Client, Timestamp: ts, Value: float64(rec.MemUsed), Tags: tags},
{MetricName: metricstore.MetricGPUMemTotal, EntityID: rec.Client, Timestamp: ts, Value: float64(rec.MemTotal), Tags: tags},
{MetricName: metricstore.MetricGPUDeviceUsage, EntityID: rec.Client, Timestamp: ts, Value: float64(rec.Utilization), Tags: tags},
{MetricName: metricstore.MetricGPUTemp, EntityID: rec.Client, Timestamp: ts, Value: float64(rec.Temperature), Tags: tags},
}
}
func pingRecordToPoints(rec models.PingRecord) []metric.Point {
ts := rec.Time
tags := map[string]string{"task_id": fmt.Sprintf("%d", rec.TaskId)}
loss := 0.0
if rec.Value < 0 {
loss = 1
}
return []metric.Point{
{
MetricName: metricstore.MetricPingLatency,
EntityID: rec.Client,
Timestamp: ts,
Value: float64(rec.Value),
Tags: tags,
},
{
MetricName: metricstore.MetricPingLoss,
EntityID: rec.Client,
Timestamp: ts,
Value: loss,
Tags: tags,
},
}
}
func dropLegacyMonitoringTables(db *gorm.DB) error {
for _, table := range legacyMonitoringTables {
if !db.Migrator().HasTable(table) {
continue
}
logger.Infof("migration", "[legacy-migration] dropping legacy table %s", table)
if err := db.Migrator().DropTable(table); err != nil {
return fmt.Errorf("drop legacy table %s: %w", table, err)
}
}
return nil
}
@@ -1,369 +0,0 @@
package migrations
import (
"context"
"database/sql"
"errors"
"math"
"path/filepath"
"testing"
"time"
"github.com/komari-monitor/komari/database/metricstore"
"github.com/komari-monitor/komari/database/models"
appconfig "github.com/komari-monitor/komari/internal/config"
"github.com/komari-monitor/komari/pkg/metric"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestLegacyMonitoringTablesMigratedByOneShotMigration(t *testing.T) {
ctx := context.Background()
mainDB, err := gorm.Open(sqlite.Open("file:"+filepath.ToSlash(filepath.Join(t.TempDir(), "komari.db"))+"?mode=rwc"), &gorm.Config{})
if err != nil {
t.Fatalf("open legacy db: %v", err)
}
if sqlDB, err := mainDB.DB(); err == nil {
t.Cleanup(func() { _ = sqlDB.Close() })
} else {
t.Fatalf("legacy sql db: %v", err)
}
if err := mainDB.AutoMigrate(&models.Record{}, &models.GPURecord{}, &models.PingRecord{}); err != nil {
t.Fatalf("migrate legacy tables: %v", err)
}
if err := mainDB.Table("records_long_term").AutoMigrate(&models.Record{}); err != nil {
t.Fatalf("migrate legacy long-term table: %v", err)
}
base := time.Date(2026, 7, 8, 23, 42, 0, 0, time.UTC)
if err := mainDB.Create(&models.Record{Client: "client-a", Time: base, Cpu: 12.5, Ram: 2048}).Error; err != nil {
t.Fatalf("seed records: %v", err)
}
if err := mainDB.Table("records_long_term").Create(&models.Record{Client: "client-a", Time: base.Add(time.Minute), Cpu: 22.5, Ram: 4096}).Error; err != nil {
t.Fatalf("seed records_long_term: %v", err)
}
if err := mainDB.Create(&models.GPURecord{Client: "client-a", Time: base, DeviceIndex: 0, DeviceName: "GPU 0", MemUsed: 1024, MemTotal: 2048, Utilization: 67, Temperature: 55}).Error; err != nil {
t.Fatalf("seed gpu_records: %v", err)
}
if err := mainDB.Create(&models.PingRecord{Client: "client-a", TaskId: 7, Time: base, Value: 36}).Error; err != nil {
t.Fatalf("seed ping_records: %v", err)
}
if err := mainDB.Create(&models.PingRecord{Client: "client-a", TaskId: 7, Time: base.Add(30 * time.Second), Value: -1}).Error; err != nil {
t.Fatalf("seed loss ping_records: %v", err)
}
summary, err := InspectLegacyMonitoring(mainDB)
if err != nil {
t.Fatalf("inspect legacy monitoring summary: %v", err)
}
if summary.LoadRows != 2 || summary.GPURows != 1 || summary.LatencyRows != 2 || summary.MonitoringRows != 5 {
t.Fatalf("unexpected legacy monitoring summary: %#v", summary)
}
if summary.EstimatedPoints != 21 || summary.RetentionDays < 1 {
t.Fatalf("unexpected legacy point estimate or retention: %#v", summary)
}
metricDB, err := sql.Open("sqlite3", "file:"+filepath.ToSlash(filepath.Join(t.TempDir(), "metrics.db"))+"?mode=rwc")
if err != nil {
t.Fatalf("open metric db: %v", err)
}
metricStore, err := metric.Open(ctx, metric.SQLite("", metric.WithDB(metricDB)))
if err != nil {
t.Fatalf("open metric store: %v", err)
}
t.Cleanup(func() {
_ = metricStore.Close()
_ = metricDB.Close()
})
markDoneCalls := 0
var lastProgress LegacyMonitoringProgress
if _, err := MigrateLegacyMonitoring(ctx, mainDB, metricStore, func(progress LegacyMonitoringProgress) {
lastProgress = progress
}); err != nil {
t.Fatalf("migrate legacy monitoring with progress: %v", err)
}
if lastProgress.SourceRowsDone != 5 || lastProgress.SourceRowsTotal != 5 || lastProgress.WrittenPoints != 21 {
t.Fatalf("unexpected final migration progress: %#v", lastProgress)
}
stats, err := runLegacyMonitoringMigration(ctx, mainDB, metricStore, false, func() error {
markDoneCalls++
return nil
})
if err != nil {
t.Fatalf("run legacy monitoring migration: %v", err)
}
if markDoneCalls != 1 {
t.Fatalf("expected migration marker to be written once, got %d", markDoneCalls)
}
if stats.Records != 2 || stats.GPU != 1 || stats.Ping != 2 {
t.Fatalf("unexpected stats: %#v", stats)
}
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) {
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)
}
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) {
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) {
t.Fatalf("unexpected ping loss points: %#v", pingLossPoints)
}
for _, table := range legacyMonitoringTables {
if mainDB.Migrator().HasTable(table) {
t.Fatalf("legacy table %s still exists", table)
}
}
stats, err = runLegacyMonitoringMigration(ctx, mainDB, metricStore, true, func() error {
markDoneCalls++
return nil
})
if err != nil {
t.Fatalf("rerun completed legacy monitoring migration: %v", err)
}
if stats != (LegacyMonitoringStats{}) {
t.Fatalf("completed migration should not scan legacy tables, got %#v", stats)
}
if markDoneCalls != 1 {
t.Fatalf("completed migration rewrote marker, calls=%d", markDoneCalls)
}
}
func TestLegacyHourlyP95PreservesHoursAndTaggedSeries(t *testing.T) {
ctx := context.Background()
metricDB, err := sql.Open("sqlite3", "file:"+filepath.ToSlash(filepath.Join(t.TempDir(), "hourly-p95.db"))+"?mode=rwc")
if err != nil {
t.Fatalf("open metric db: %v", err)
}
store, err := metric.Open(ctx, metric.SQLite("", metric.WithDB(metricDB)))
if err != nil {
t.Fatalf("open metric store: %v", err)
}
t.Cleanup(func() {
_ = store.Close()
_ = metricDB.Close()
})
if err := store.UpsertMetric(ctx, metric.Definition{Name: "test.p95", Type: metric.TypeGauge, RetentionDays: 1}); err != nil {
t.Fatalf("create test metric: %v", err)
}
base := time.Date(2026, 7, 8, 10, 0, 0, 0, time.UTC)
aggregator := &legacyHourlyP95Aggregator{store: store}
inputs := [][]metric.Point{
{{MetricName: "test.p95", EntityID: "node-a", Timestamp: base.Add(time.Minute), Value: 10, Tags: map[string]string{"task_id": "1"}}},
{{MetricName: "test.p95", EntityID: "node-a", Timestamp: base.Add(2 * time.Minute), Value: 20, Tags: map[string]string{"task_id": "1"}}},
{{MetricName: "test.p95", EntityID: "node-a", Timestamp: base.Add(time.Hour), Value: 30, Tags: map[string]string{"task_id": "1"}}},
{{MetricName: "test.p95", EntityID: "node-a", Timestamp: base.Add(time.Minute), Value: 100, Tags: map[string]string{"task_id": "2"}}},
}
for _, points := range inputs {
if _, err := aggregator.Add(ctx, points); err != nil {
t.Fatalf("add aggregate input: %v", err)
}
}
if _, err := aggregator.Flush(ctx); err != nil {
t.Fatalf("flush aggregate input: %v", err)
}
taskOne, err := store.Query(ctx, 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{
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) {
t.Fatalf("unexpected task two aggregates: %#v", taskTwo)
}
}
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 {
t.Fatalf("open minimal legacy db: %v", err)
}
if sqlDB, err := db.DB(); err == nil {
t.Cleanup(func() { _ = sqlDB.Close() })
} else {
t.Fatalf("minimal legacy sql db: %v", err)
}
if err := db.Exec("CREATE TABLE records_minimal (client TEXT NOT NULL, time DATETIME NOT NULL, cpu REAL NOT NULL)").Error; err != nil {
t.Fatalf("create minimal legacy table: %v", err)
}
base := time.Date(2026, 7, 8, 10, 0, 0, 0, time.UTC)
if err := db.Exec("INSERT INTO records_minimal (client, time, cpu) VALUES (?, ?, ?)", "node-a", base, 42.5).Error; err != nil {
t.Fatalf("insert minimal legacy row: %v", err)
}
projection, err := legacyRecordProjection(db, "records_minimal")
if err != nil {
t.Fatalf("build legacy projection: %v", err)
}
var record models.Record
if err := db.Raw(projection).Scan(&record).Error; err != nil {
t.Fatalf("scan projected legacy row: %v", err)
}
if record.Client != "node-a" || !record.Time.Equal(base) || record.Cpu != 42.5 {
t.Fatalf("unexpected projected record: %#v", record)
}
if record.ConnectionsUdp != 0 || record.NetTotalDown != 0 {
t.Fatalf("missing metric columns were not zero-filled: %#v", record)
}
}
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 {
t.Fatalf("open finalization database: %v", err)
}
if sqlDB, err := db.DB(); err == nil {
t.Cleanup(func() { _ = sqlDB.Close() })
} else {
t.Fatalf("finalization sql db: %v", err)
}
appconfig.SetDb(db)
if err := db.AutoMigrate(&models.Record{}); err != nil {
t.Fatalf("migrate legacy table: %v", err)
}
if err := appconfig.Set(legacyMonitoringMigrationDoneKey, false); err != nil {
t.Fatalf("reset completion marker: %v", err)
}
finalized := false
if err := CompleteLegacyMonitoringMigration(db, func() error {
finalized = true
if db.Migrator().HasTable(&models.Record{}) {
t.Fatal("legacy table still exists during finalization")
}
done, err := appconfig.GetAs[bool](legacyMonitoringMigrationDoneKey, false)
if err != nil {
return err
}
if done {
t.Fatal("completion marker was written before finalization")
}
return nil
}); err != nil {
t.Fatalf("complete legacy migration: %v", err)
}
if !finalized {
t.Fatal("finalizer was not called")
}
done, err := appconfig.GetAs[bool](legacyMonitoringMigrationDoneKey, false)
if err != nil {
t.Fatalf("read completion marker: %v", err)
}
if !done {
t.Fatal("completion marker was not written after finalization")
}
if err := db.AutoMigrate(&models.Record{}); err != nil {
t.Fatalf("restore legacy table: %v", err)
}
if err := appconfig.Set(legacyMonitoringMigrationDoneKey, false); err != nil {
t.Fatalf("reset completion marker after success: %v", err)
}
finalizeErr := errors.New("vacuum failed")
if err := CompleteLegacyMonitoringMigration(db, func() error { return finalizeErr }); !errors.Is(err, finalizeErr) {
t.Fatalf("finalization error = %v, want %v", err, finalizeErr)
}
done, err = appconfig.GetAs[bool](legacyMonitoringMigrationDoneKey, false)
if err != nil {
t.Fatalf("read completion marker after failure: %v", err)
}
if done {
t.Fatal("failed finalization wrote the completion marker")
}
}
-242
View File
@@ -1,242 +0,0 @@
package migrations
import (
"database/sql"
"fmt"
logger "github.com/komari-monitor/komari/utils/log"
"math"
"os"
"strconv"
"strings"
"time"
appconfig "github.com/komari-monitor/komari/internal/config"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const timestampUTCMigrationKey = "internal_timestamp_utc_migrated"
type timestampColumn struct {
table string
column string
}
var legacyTimestampColumns = [...]timestampColumn{
{table: "clients", column: "expired_at"},
{table: "clients", column: "created_at"},
{table: "clients", column: "updated_at"},
{table: "client_infos", column: "expired_at"},
{table: "client_infos", column: "created_at"},
{table: "client_infos", column: "updated_at"},
{table: "users", column: "created_at"},
{table: "users", column: "updated_at"},
{table: "sessions", column: "latest_online"},
{table: "sessions", column: "expires"},
{table: "sessions", column: "created_at"},
{table: "logs", column: "time"},
{table: "clipboards", column: "created_at"},
{table: "clipboards", column: "updated_at"},
{table: "offline_notifications", column: "last_notified"},
{table: "load_notifications", column: "last_notified"},
{table: "task_results", column: "finished_at"},
{table: "task_results", column: "created_at"},
{table: "records", column: "time"},
{table: "records_long_term", column: "time"},
{table: "gpu_records", column: "time"},
{table: "ping_records", column: "time"},
{table: "configs", column: "created_at"},
{table: "configs", column: "updated_at"},
}
var legacyTimestampLayouts = [...]string{
"2006-01-02 15:04:05.999999999-07:00",
"2006-01-02 15:04:05-07:00",
"2006-01-02 15:04:05.999999999",
"2006-01-02 15:04:05",
"2006-01-02T15:04:05.999999999",
"2006-01-02T15:04:05",
"2006-01-02",
}
type timestampRow struct {
rowID int64
value sql.NullString
}
// migrateLegacyTimestampColumns makes old offset-free SQLite values
// unambiguous before any current model scans them as time.Time.
func migrateLegacyTimestampColumns(db *gorm.DB) error {
if timestampMigrationDone(db) {
return nil
}
location := legacyTimestampLocation()
var converted int64
err := db.Transaction(func(tx *gorm.DB) error {
for _, target := range legacyTimestampColumns {
if !tx.Migrator().HasTable(target.table) || !tx.Migrator().HasColumn(target.table, target.column) {
continue
}
count, err := migrateTimestampColumn(tx, target, location)
if err != nil {
return err
}
converted += count
}
return nil
})
if err != nil {
return err
}
if converted > 0 {
logger.Infof("migration", "Converted %d legacy timestamp values to explicit UTC", converted)
}
return nil
}
func migrateTimestampColumn(db *gorm.DB, target timestampColumn, location *time.Location) (int64, error) {
table := quoteSQLiteIdentifier(target.table)
column := quoteSQLiteIdentifier(target.column)
var converted int64
var lastRowID int64
for {
query := fmt.Sprintf(
"SELECT rowid, CAST(%s AS TEXT) FROM %s WHERE rowid > ? AND %s IS NOT NULL ORDER BY rowid LIMIT 1000",
column, table, column,
)
rows, err := db.Raw(query, lastRowID).Rows()
if err != nil {
return converted, fmt.Errorf("read legacy timestamp %s.%s: %w", target.table, target.column, err)
}
batch := make([]timestampRow, 0, 1000)
for rows.Next() {
var row timestampRow
if err := rows.Scan(&row.rowID, &row.value); err != nil {
rows.Close()
return converted, fmt.Errorf("scan legacy timestamp %s.%s: %w", target.table, target.column, err)
}
batch = append(batch, row)
lastRowID = row.rowID
}
if err := rows.Err(); err != nil {
rows.Close()
return converted, fmt.Errorf("iterate legacy timestamp %s.%s: %w", target.table, target.column, err)
}
rows.Close()
if len(batch) == 0 {
break
}
for _, row := range batch {
raw := strings.TrimSpace(row.value.String)
if !row.value.Valid || raw == "" {
continue
}
stamp, err := parseLegacyTimestamp(raw, location)
if err != nil {
return converted, fmt.Errorf("convert legacy timestamp %s.%s rowid=%d: %w", target.table, target.column, row.rowID, err)
}
update := fmt.Sprintf("UPDATE %s SET %s = ? WHERE rowid = ?", table, column)
if err := db.Exec(update, stamp.UTC(), row.rowID).Error; err != nil {
return converted, fmt.Errorf("write UTC timestamp %s.%s rowid=%d: %w", target.table, target.column, row.rowID, err)
}
converted++
}
if len(batch) < 1000 {
break
}
}
return converted, nil
}
func parseLegacyTimestamp(value string, location *time.Location) (time.Time, error) {
raw := strings.TrimSpace(value)
if raw == "" {
return time.Time{}, fmt.Errorf("timestamp is empty")
}
if location == nil {
location = time.UTC
}
if stamp, err := time.Parse(time.RFC3339Nano, raw); err == nil {
return stamp.UTC(), nil
}
for _, layout := range legacyTimestampLayouts {
if stamp, err := time.ParseInLocation(layout, raw, location); err == nil {
return stamp.UTC(), nil
}
}
epoch, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return time.Time{}, fmt.Errorf("unsupported legacy timestamp %q", value)
}
return legacyEpochTime(epoch), nil
}
func legacyEpochTime(value int64) time.Time {
abs := value
if abs < 0 {
if abs == math.MinInt64 {
return time.Unix(0, value).UTC()
}
abs = -abs
}
switch {
case abs >= 1e17:
return time.Unix(0, value).UTC()
case abs >= 1e14:
return time.Unix(0, value*int64(time.Microsecond)).UTC()
case abs >= 1e11:
return time.UnixMilli(value).UTC()
default:
return time.Unix(value, 0).UTC()
}
}
// legacyTimestampLocation is intentionally migration-only. The old custom
// type used TZ and defaulted to UTC; current runtime code uses system time.Local.
func legacyTimestampLocation() *time.Location {
name := strings.TrimSpace(os.Getenv("TZ"))
if name == "" {
return time.UTC
}
location, err := time.LoadLocation(name)
if err != nil {
logger.Infof("migration", "Legacy timezone %q cannot be loaded; interpreting old timestamps as UTC: %v", name, err)
return time.UTC
}
return location
}
func timestampMigrationDone(db *gorm.DB) bool {
if !db.Migrator().HasTable(&appconfig.ConfigItem{}) || hasLegacyConfigTable(db) {
return false
}
var item appconfig.ConfigItem
if err := db.Where("key = ?", timestampUTCMigrationKey).First(&item).Error; err != nil {
return false
}
return item.Value == "true"
}
func markTimestampMigrationDone(db *gorm.DB) error {
if hasLegacyConfigTable(db) {
return fmt.Errorf("new config item table is unavailable")
}
if !db.Migrator().HasTable(&appconfig.ConfigItem{}) {
if err := db.AutoMigrate(&appconfig.ConfigItem{}); err != nil {
return fmt.Errorf("create config item table: %w", err)
}
}
item := appconfig.ConfigItem{Key: timestampUTCMigrationKey, Value: "true"}
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&item).Error
}
func quoteSQLiteIdentifier(value string) string {
return `"` + strings.ReplaceAll(value, `"`, `""`) + `"`
}
-99
View File
@@ -1,99 +0,0 @@
package migrations
import (
"fmt"
"strings"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestLegacyTimestampMigrationDefaultsToUTC(t *testing.T) {
t.Setenv("TZ", "")
db := openTimestampMigrationDB(t)
insertLegacyLogTime(t, db, "2026-07-17 09:30:00.1234567")
if err := migrateLegacyTimestampColumns(db); err != nil {
t.Fatalf("migrate timestamps: %v", err)
}
want := time.Date(2026, 7, 17, 9, 30, 0, 123456700, time.UTC)
assertMigratedLogTime(t, db, want)
}
func TestLegacyTimestampMigrationUsesLegacyTZ(t *testing.T) {
t.Setenv("TZ", "Asia/Shanghai")
db := openTimestampMigrationDB(t)
insertLegacyLogTime(t, db, "2026-07-17 09:30:00.123456789")
if err := migrateLegacyTimestampColumns(db); err != nil {
t.Fatalf("migrate timestamps: %v", err)
}
want := time.Date(2026, 7, 17, 1, 30, 0, 123456789, time.UTC)
assertMigratedLogTime(t, db, want)
}
func TestParseLegacyTimestampAcceptsHistoricalEpochUnits(t *testing.T) {
stamp := time.Date(2026, 7, 17, 1, 30, 0, 123456789, time.UTC)
tests := map[string]struct {
value string
want time.Time
}{
"seconds": {value: fmt.Sprint(stamp.Unix()), want: stamp.Truncate(time.Second)},
"milliseconds": {value: fmt.Sprint(stamp.UnixMilli()), want: stamp.Truncate(time.Millisecond)},
"microseconds": {value: fmt.Sprint(stamp.UnixMicro()), want: stamp.Truncate(time.Microsecond)},
"nanoseconds": {value: fmt.Sprint(stamp.UnixNano()), want: stamp},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
got, err := parseLegacyTimestamp(test.value, time.UTC)
if err != nil {
t.Fatalf("parse legacy timestamp: %v", err)
}
if !got.Equal(test.want) {
t.Fatalf("parsed timestamp = %s, want %s", got, test.want)
}
})
}
}
func openTimestampMigrationDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open database: %v", err)
}
if err := db.Exec(`CREATE TABLE logs (id INTEGER PRIMARY KEY, time TIMESTAMP)`).Error; err != nil {
t.Fatalf("create logs table: %v", err)
}
return db
}
func insertLegacyLogTime(t *testing.T, db *gorm.DB, value string) {
t.Helper()
if err := db.Exec(`INSERT INTO logs (time) VALUES (?)`, value).Error; err != nil {
t.Fatalf("insert legacy timestamp: %v", err)
}
}
func assertMigratedLogTime(t *testing.T, db *gorm.DB, want time.Time) {
t.Helper()
var got time.Time
if err := db.Raw(`SELECT time FROM logs LIMIT 1`).Scan(&got).Error; err != nil {
t.Fatalf("read migrated timestamp: %v", err)
}
if !got.Equal(want) || got.Nanosecond() != want.Nanosecond() {
t.Fatalf("migrated time = %s, want %s", got, want)
}
var raw string
if err := db.Raw(`SELECT CAST(time AS TEXT) FROM logs LIMIT 1`).Scan(&raw).Error; err != nil {
t.Fatalf("read raw migrated timestamp: %v", err)
}
if !strings.HasSuffix(raw, "+00:00") {
t.Fatalf("stored timestamp %q has no explicit UTC offset", raw)
}
}
-35
View File
@@ -1,35 +0,0 @@
package scheduler
import (
"testing"
"time"
)
func TestCronScheduleUsesSystemLocalWallClock(t *testing.T) {
originalLocal := time.Local
time.Local = time.FixedZone("UTC+8", 8*60*60)
t.Cleanup(func() { time.Local = originalLocal })
schedule, err := Parse("0 0 9 * * *")
if err != nil {
t.Fatalf("parse schedule: %v", err)
}
after := time.Date(2026, 7, 17, 0, 30, 0, 0, time.UTC)
want := time.Date(2026, 7, 17, 1, 0, 0, 0, time.UTC)
if got := schedule.Next(after); !got.Equal(want) {
t.Fatalf("next run = %s, want %s", got, want)
} else if got.Location() != time.UTC {
t.Fatalf("next run location = %s, want UTC", got.Location())
}
}
func TestEverySchedulePreservesElapsedDuration(t *testing.T) {
schedule, err := Parse("@every 90s")
if err != nil {
t.Fatalf("parse schedule: %v", err)
}
after := time.Now()
if got := schedule.Next(after); got.Sub(after) != 90*time.Second {
t.Fatalf("interval = %s, want 90s", got.Sub(after))
}
}
-47
View File
@@ -1,47 +0,0 @@
package server
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/internal/config"
)
// cleanupFunc is a cleanup action run during shutdown.
type cleanupFunc struct {
name string
fn func(ctx context.Context) error
}
// App owns the server lifecycle. Individual phases live in focused files:
// bootstrap, metric store, providers, guides, router, and runtime.
type App struct {
listenAddr string
settings *config.Settings
engine *gin.Engine
server *http.Server
reload *ReloadManager
dbReady bool
oauthReady bool
metricStoreCleanupAdded bool
cleanups []cleanupFunc
}
// Options configures the process-wide application runtime.
type Options struct {
ListenAddr string
}
// New constructs an empty application. Initialization happens in the
// explicit lifecycle phases called by the command entrypoint.
func New(options Options) *App {
return &App{listenAddr: options.ListenAddr, reload: NewReloadManager()}
}
// addCleanup registers a cleanup action. Shutdown executes actions in LIFO
// order, mirroring resource initialization order.
func (a *App) addCleanup(name string, fn func(ctx context.Context) error) {
a.cleanups = append(a.cleanups, cleanupFunc{name: name, fn: fn})
}
-68
View File
@@ -1,68 +0,0 @@
package server
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestRetryMetricStoreConnectionStopsAfterRecovery(t *testing.T) {
wantErr := errors.New("temporary connection failure")
attempts := 0
err := retryMetricStoreConnection(3, 0, func() error {
attempts++
if attempts < 3 {
return wantErr
}
return nil
})
if err != nil {
t.Fatalf("retry returned error after recovery: %v", err)
}
if attempts != 3 {
t.Fatalf("attempts = %d, want 3", attempts)
}
}
func TestGuideNoRouteRejectsUnknownAPIAndRedirectsPages(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(noStoreAPIResponses())
router.NoRoute(guideNoRoute("/install", "Not found in install mode", nil))
apiResponse := httptest.NewRecorder()
router.ServeHTTP(apiResponse, httptest.NewRequest(http.MethodGet, "/api/unknown", nil))
if apiResponse.Code != http.StatusNotFound {
t.Fatalf("API status = %d, want %d", apiResponse.Code, http.StatusNotFound)
}
if got := apiResponse.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("API cache control = %q, want no-store", got)
}
pageResponse := httptest.NewRecorder()
router.ServeHTTP(pageResponse, httptest.NewRequest(http.MethodGet, "/stale-guide", nil))
if pageResponse.Code != http.StatusTemporaryRedirect {
t.Fatalf("page status = %d, want %d", pageResponse.Code, http.StatusTemporaryRedirect)
}
if got := pageResponse.Header().Get("Location"); got != "/install" {
t.Fatalf("redirect location = %q, want /install", got)
}
}
func TestRetryMetricStoreConnectionReturnsLastError(t *testing.T) {
wantErr := errors.New("connection unavailable")
attempts := 0
err := retryMetricStoreConnection(3, 0, func() error {
attempts++
return wantErr
})
if !errors.Is(err, wantErr) {
t.Fatalf("error = %v, want %v", err, wantErr)
}
if attempts != 3 {
t.Fatalf("attempts = %d, want 3", attempts)
}
}
-68
View File
@@ -1,68 +0,0 @@
package server
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.
func (a *App) Bootstrap() error {
if err := os.MkdirAll("./data/theme", os.ModePerm); err != nil {
return fmt.Errorf("failed to create theme directory: %w", err)
}
dbcore.SetVersionID(utils.CurrentVersion + "-" + utils.VersionHash)
if err := dbcore.Initialize(); err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
a.dbReady = true
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)
}
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
}
-118
View File
@@ -1,118 +0,0 @@
package server
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/gin-gonic/gin"
logger "github.com/komari-monitor/komari/utils/log"
"github.com/komari-monitor/komari/web/api"
frontendpublic "github.com/komari-monitor/komari/web/public"
"github.com/komari-monitor/komari/web/security"
)
type guideController interface {
Activate()
Deactivate()
Register(*gin.Engine)
Done() <-chan struct{}
}
type guideServerConfig struct {
pagePath string
missingAPI string
logMessage string
requireIdentity bool
restrictedStatic bool
}
// runGuideServer hosts one temporary, narrowly scoped guide. It deliberately
// constructs a new Gin engine for each mode so normal routes never leak into
// installation, upgrade, or recovery flows.
func (a *App) runGuideServer(controller guideController, cfg guideServerConfig) (bool, error) {
controller.Activate()
defer controller.Deactivate()
r := gin.New()
r.Use(logger.GinLogger(), logger.GinRecovery(), noStoreAPIResponses())
if cfg.requireIdentity {
cors := security.NewCorsController(a.settings.CorsOriginCheckEnabled, a.settings.CorsAllowedOrigins)
r.Use(cors.Middleware(), api.IdentityMiddleware())
}
controller.Register(r)
serveStatic := frontendpublic.Static
if cfg.restrictedStatic {
serveStatic = frontendpublic.StaticRestricted
}
serveStatic(r.Group("/"), func(handlers ...gin.HandlerFunc) {
r.NoRoute(guideNoRoute(cfg.pagePath, cfg.missingAPI, handlers))
})
server := &http.Server{Addr: a.listenAddr, Handler: r}
a.engine = r
a.server = server
serverErr := make(chan error, 1)
logger.Infof("server", cfg.logMessage, a.listenAddr)
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
serverErr <- err
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(quit)
select {
case err := <-serverErr:
return false, fmt.Errorf("listen in guide mode: %w", err)
case <-quit:
return false, a.Shutdown()
case <-controller.Done():
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
return false, fmt.Errorf("stop guide server: %w", err)
}
a.server = nil
a.engine = nil
return true, nil
}
}
func noStoreAPIResponses() gin.HandlerFunc {
return func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api") {
c.Header("Cache-Control", "no-store")
}
c.Next()
}
}
func guideNoRoute(pagePath, missingAPI string, handlers []gin.HandlerFunc) gin.HandlerFunc {
return func(c *gin.Context) {
requestPath := c.Request.URL.Path
if strings.HasPrefix(requestPath, "/api") {
api.RespondError(c, http.StatusNotFound, missingAPI)
return
}
if c.Request.Method == http.MethodGet && requestPath != pagePath && filepath.Ext(requestPath) == "" {
c.Redirect(http.StatusTemporaryRedirect, pagePath)
return
}
for _, handler := range handlers {
handler(c)
if c.IsAborted() {
return
}
}
}
}
-57
View File
@@ -1,57 +0,0 @@
package server
import (
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/internal/migrations"
installweb "github.com/komari-monitor/komari/web/install"
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.
func (a *App) InstallRequired() (bool, error) {
var count int64
if err := dbcore.GetDBInstance().Model(&models.User{}).Count(&count).Error; err != nil {
return false, err
}
return count == 0, nil
}
func (a *App) LegacyUpgradeRequired() (bool, migrations.LegacyMonitoringSummary, error) {
return migrations.LegacyMonitoringMigrationRequired(dbcore.GetDBInstance())
}
// RunInstallGuide exposes only first-run installation APIs. It intentionally
// does not mount authentication or normal application routes.
func (a *App) RunInstallGuide() (bool, error) {
return a.runGuideServer(installweb.NewController(dbcore.GetDBInstance()), guideServerConfig{
pagePath: installweb.PagePath,
missingAPI: "Not found in install mode",
logMessage: "First-run installation guide is available on %s",
})
}
// RunMetricStoreRecovery keeps login available while exposing only the
// administrator-protected metric-store recovery API.
func (a *App) RunMetricStoreRecovery(initialErr error) (bool, error) {
a.initOAuth()
return a.runGuideServer(recoveryweb.NewController(initialErr, metricStoreReconnectAttempts), guideServerConfig{
pagePath: recoveryweb.PagePath,
missingAPI: "Not found in database recovery mode",
logMessage: "Metric store recovery is available on %s",
requireIdentity: true,
restrictedStatic: true,
})
}
// RunLegacyUpgrade keeps login available while migration is in progress.
func (a *App) RunLegacyUpgrade(summary migrations.LegacyMonitoringSummary) (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,
})
}
-87
View File
@@ -1,87 +0,0 @@
package server
import (
"fmt"
"time"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/metricstore"
"github.com/komari-monitor/komari/internal/config"
logger "github.com/komari-monitor/komari/utils/log"
)
const (
metricStoreReconnectAttempts = 3
metricStoreReconnectInterval = 5 * time.Second
)
// ConnectMetricStore performs one connection attempt and registers cleanup
// only after a store has actually opened.
func (a *App) ConnectMetricStore() error {
if err := metricstore.InitializeStore(); err != nil {
return fmt.Errorf("failed to initialize metric store: %s", redactMetricStoreError(err))
}
if !a.metricStoreCleanupAdded {
a.addCleanup("metric-store", metricstore.CloseStoreContext)
a.metricStoreCleanupAdded = true
}
return nil
}
func redactMetricStoreError(err error) string {
if err == nil {
return ""
}
dsn := ""
if cfg, cfgErr := config.GetManyAs[metricstore.MetricStoreConfig](); cfgErr == nil {
dsn = cfg.DSN
}
return metricstore.RedactConnectionError(err.Error(), dsn)
}
// ConnectMetricStoreWithRetry retries the monitoring database connection.
func (a *App) ConnectMetricStoreWithRetry() error {
attempt := 0
err := retryMetricStoreConnection(metricStoreReconnectAttempts, metricStoreReconnectInterval, func() error {
attempt++
err := a.ConnectMetricStore()
if err != nil {
logger.Warn("server", "Metric store connection attempt failed", "attempt", attempt, "max_attempts", metricStoreReconnectAttempts, "error", err)
}
return err
})
if err == nil && attempt > 1 {
logger.Infof("server", "Metric store connection recovered on attempt %d/%d", attempt, metricStoreReconnectAttempts)
}
return err
}
func retryMetricStoreConnection(attempts int, interval time.Duration, connect func() error) error {
if attempts < 1 {
return fmt.Errorf("metric store retry attempts must be positive")
}
var lastErr error
for attempt := 1; attempt <= attempts; attempt++ {
if attempt > 1 {
time.Sleep(interval)
}
if err := connect(); err == nil {
return nil
} else {
lastErr = err
}
}
return lastErr
}
// InitStores opens the metric store and starts its report batcher. Historical
// data migrations are started only through an explicit administrator action.
func (a *App) InitStores() error {
if err := a.ConnectMetricStore(); err != nil {
auditlog.EventLog("error", fmt.Sprintf("Failed to initialize metric store: %v", err))
return err
}
metricstore.StartReportBatcher()
a.addCleanup("metric-report-batcher", metricstore.StopReportBatcher)
return nil
}
-38
View File
@@ -1,38 +0,0 @@
package server
import (
"context"
"fmt"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/utils/geoip"
logger "github.com/komari-monitor/komari/utils/log"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/web/oauth"
)
// InitProviders initializes providers needed by the normal application.
func (a *App) InitProviders() error {
a.initOAuth()
go geoip.InitGeoIp()
a.addCleanup("geoip", func(context.Context) error { return geoip.Shutdown() })
messageSender.Initialize()
a.addCleanup("message-sender", func(context.Context) error { return messageSender.Shutdown() })
return nil
}
// initOAuth initializes OAuth once. Restricted authenticated guides need it
// before the normal provider initialization phase.
func (a *App) initOAuth() {
if a.oauthReady {
return
}
if err := oauth.Initialize(); err != nil {
logger.Errorf("server", "Failed to initialize OAuth provider: %v", err)
auditlog.EventLog("error", fmt.Sprintf("Failed to initialize OAuth provider: %v", err))
}
a.oauthReady = true
a.addCleanup("oauth", func(context.Context) error { return oauth.Shutdown() })
}
-67
View File
@@ -1,67 +0,0 @@
package server
import (
logger "github.com/komari-monitor/komari/utils/log"
"github.com/komari-monitor/komari/internal/config"
)
// reloadHandler 是单个配置热重载处理器。
//
// name 仅用于日志定位,handler 接收配置变更事件,自行判断关心的 key 是否变化。
// 每个 handler 相互独立:一个 panic 不应影响其它 handler。
type reloadHandler struct {
name string
handler func(config.ConfigEvent)
}
// ReloadManager 统一收敛所有配置热重载逻辑。
//
// 过去 config.Subscribe 分散在 cmd/server.go、cors.go 等多处,导致:
// - 谁监听哪些 key 不清晰;
// - 某个 handler panic 会连带影响同一订阅回调里的其它逻辑;
// - 关闭时无法统一停止。
//
// ReloadManager 把这些 handler 注册在一处,用单个 config.Subscribe 分发,
// 并对每个 handler 做 panic 隔离。
type ReloadManager struct {
handlers []reloadHandler
started bool
}
// NewReloadManager 创建一个空的热重载管理器。
func NewReloadManager() *ReloadManager {
return &ReloadManager{}
}
// Register 注册一个命名的热重载处理器。必须在 Start 之前调用。
func (m *ReloadManager) Register(name string, handler func(config.ConfigEvent)) {
if handler == nil {
return
}
m.handlers = append(m.handlers, reloadHandler{name: name, handler: handler})
}
// Start 向 config 订阅一次,之后每个事件会分发给全部已注册 handler。
// 重复调用无副作用。
func (m *ReloadManager) Start() {
if m.started {
return
}
m.started = true
config.Subscribe(func(event config.ConfigEvent) {
for _, h := range m.handlers {
m.dispatch(h, event)
}
})
}
// dispatch 执行单个 handler,并隔离 panic,避免一个 handler 影响其它。
func (m *ReloadManager) dispatch(h reloadHandler, event config.ConfigEvent) {
defer func() {
if r := recover(); r != nil {
logger.Errorf("reload", "config reload handler %q panicked: %v", h.name, r)
}
}()
h.handler(event)
}
-195
View File
@@ -1,195 +0,0 @@
package server
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/metricstore"
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/scheduler"
"github.com/komari-monitor/komari/utils/geoip"
logger "github.com/komari-monitor/komari/utils/log"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/notifier"
"github.com/komari-monitor/komari/web/api"
"github.com/komari-monitor/komari/web/oauth"
recoveryweb "github.com/komari-monitor/komari/web/recovery"
"github.com/komari-monitor/komari/web/router"
"github.com/komari-monitor/komari/web/security"
)
// StartBackground starts scheduled work after all stores are ready.
func (a *App) StartBackground() error {
registerScheduledWork()
a.addCleanup("scheduler", func(context.Context) error {
scheduler.StopAll()
return nil
})
return nil
}
func (a *App) registerReloadHandlers(cors *security.CorsController) {
a.reload.Register("oauth-provider", func(event config.ConfigEvent) {
if ok, providerName := config.IsChangedT[string](event, config.OAuthProviderKey); ok {
if providerName == "" || providerName == "none" {
providerName = "github"
}
oidcProvider, err := database.GetOidcConfigByName(providerName)
if err != nil {
logger.Errorf("server", "Failed to get OIDC provider config: %v", err)
return
}
logger.Infof("server", "Using %s as OIDC provider", oidcProvider.Name)
if err := oauth.LoadProvider(oidcProvider.Name, oidcProvider.Addition); err != nil {
auditlog.EventLog("error", fmt.Sprintf("Failed to load OIDC provider: %v", err))
}
}
})
a.reload.Register("geoip-provider", func(event config.ConfigEvent) {
if event.IsChanged(config.GeoIpProviderKey) {
go geoip.InitGeoIp()
}
})
a.reload.Register("message-sender", func(event config.ConfigEvent) {
if event.IsChanged(config.NotificationMethodKey) {
go messageSender.Initialize()
}
})
a.reload.Register("cors", func(event config.ConfigEvent) { cors.Update(event) })
}
// BuildRouter constructs the normal application router and starts reloads.
func (a *App) BuildRouter() error {
r := gin.New()
r.Use(logger.GinLogger(), logger.GinRecovery())
cors := security.NewCorsController(a.settings.CorsOriginCheckEnabled, a.settings.CorsAllowedOrigins)
r.Use(cors.Middleware(), api.IdentityMiddleware(), api.PrivateSiteMiddleware(), noStoreAPIResponses())
// The recovery UI belongs only to its temporary restricted listener.
r.GET(recoveryweb.PagePath, func(c *gin.Context) {
c.Redirect(http.StatusTemporaryRedirect, "/")
})
router.Register(r)
a.registerReloadHandlers(cors)
a.reload.Start()
a.engine = r
return nil
}
// Run starts the normal HTTP server and blocks until shutdown or fatal error.
func (a *App) Run() error {
a.server = &http.Server{Addr: a.listenAddr, Handler: a.engine}
serverErr := make(chan error, 1)
logger.Infof("server", "Starting server on %s ...", a.listenAddr)
go func() {
if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
serverErr <- err
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(quit)
select {
case err := <-serverErr:
a.onFatal(err)
return fmt.Errorf("listen: %w", err)
case <-quit:
return a.Shutdown()
}
}
// Shutdown stops HTTP first, then releases registered resources in LIFO order.
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()
if a.server != nil {
if err := a.server.Shutdown(ctx); err != nil {
logger.Infof("server", "HTTP server forced to shutdown: %v", err)
}
}
a.runCleanups(ctx)
return nil
}
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)
defer cancel()
a.runCleanups(ctx)
}
func (a *App) runCleanups(ctx context.Context) {
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)
}
}
}
func registerScheduledWork() {
if err := tasks.ReloadPingSchedule(); err != nil {
logger.ErrorArgs("server", "Failed to reload ping schedule:", err)
}
if err := d_notification.ReloadLoadNotificationSchedule(); err != nil {
logger.ErrorArgs("server", "Failed to reload load notification schedule:", err)
}
if err := scheduler.AddFunc("records:cleanup", "@every 30m", cleanupScheduledData); err != nil {
logger.ErrorArgs("server", "Failed to add cleanup scheduled task:", err)
}
if err := scheduler.AddContextFunc("metrics:compact", "@every 5m", true, compactMetricStore); err != nil {
logger.ErrorArgs("server", "Failed to add metric compact scheduled task:", err)
}
if err := scheduler.AddFunc("notifier:traffic", "@every 1m", notifier.CheckTraffic); err != nil {
logger.ErrorArgs("server", "Failed to add traffic notification task:", err)
}
if err := scheduler.AddFunc("notifier:expire", "0 0 9 * * *", notifier.CheckExpireScheduledWork); err != nil {
logger.ErrorArgs("server", "Failed to add expire notification task:", err)
}
notifier.InitTrafficReportSchedule()
}
const taskResultRetentionDays = 30
func cleanupScheduledData() {
before := time.Now().UTC().Add(-24 * time.Hour * taskResultRetentionDays)
if err := tasks.ClearTaskResultsByTimeBefore(before); err != nil {
logger.Errorf("server", "Failed to clean expired task results: %v", err)
}
auditlog.RemoveOldLogs()
accounts.RemoveExpiredSessions()
}
func compactMetricStore(ctx context.Context) {
compactCtx, cancel := context.WithTimeout(ctx, 15*time.Minute)
defer cancel()
written, err := metricstore.Compact(compactCtx, time.Now().UTC())
if errors.Is(err, metricstore.ErrCompactInProgress) {
return
}
if err != nil {
logger.Errorf("server", "Failed to compact metric store after writing %d rollup buckets: %v", written, err)
return
}
if written > 0 {
logger.Infof("server", "Metric store compacted %d rollup buckets", written)
}
}
+5 -4
View File
@@ -1,21 +1,22 @@
package main
import (
"log"
"log/slog"
"github.com/komari-monitor/komari/cmd"
"github.com/komari-monitor/komari/utils"
logger "github.com/komari-monitor/komari/utils/log"
logutil "github.com/komari-monitor/komari/utils/log"
)
func main() {
if utils.VersionHash == "unknown" {
logger.Setup(slog.LevelDebug)
logutil.SetupGlobalLogger(slog.LevelDebug)
} else {
logger.Setup(slog.LevelInfo)
logutil.SetupGlobalLogger(slog.LevelInfo)
}
logger.Infof("server", "Komari Monitor %s (hash: %s)", utils.CurrentVersion, utils.VersionHash)
log.Printf("Komari Monitor %s (hash: %s)", utils.CurrentVersion, utils.VersionHash)
cmd.Execute()
}
@@ -3,11 +3,11 @@ package config
import (
"encoding/json"
"fmt"
"log/slog"
"reflect"
"strings"
"sync"
logger "github.com/komari-monitor/komari/utils/log"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -31,6 +31,26 @@ var (
}
)
// Get 获取原始值 (反序列化为 interface{})
func Get(key string, defaul ...any) (any, error) {
var item ConfigItem
err := db.First(&item, "key = ?", key).Error
if err != nil {
if len(defaul) > 0 {
v := defaul[0]
err = Set(key, v)
return v, err
}
return nil, err
}
var val any
if err := json.Unmarshal([]byte(item.Value), &val); err != nil {
return nil, err
}
return val, nil
}
// GetAs 获取并转换为指定类型 (泛型),支持数值类型自动转换
func GetAs[T any](key string, defaul ...any) (T, error) {
var t T
@@ -105,7 +125,7 @@ func GetMany(keys map[string]any) (map[string]any, error) {
// 序列化后加入待写入列表
jsonBytes, err := json.Marshal(def)
if err != nil {
logger.Warn("config", "marshal default value failed", "key", k, "error", err)
slog.Warn("marshal default value failed", "key", k, "error", err)
continue
}
toInsert = append(toInsert, ConfigItem{
@@ -122,7 +142,7 @@ func GetMany(keys map[string]any) (map[string]any, error) {
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&toInsert).Error; err != nil {
logger.Warn("config", "batch insert default config failed", "error", err)
slog.Warn("batch insert default config failed", "error", err)
}
}
@@ -160,8 +180,9 @@ func GetManyAs[T any]() (*T, error) {
}
defaultTag := field.Tag.Get("default")
hasDefault := defaultTag != "" || field.Tag.Get("default") == ""
// 检查是否显式定义了 default tag (即使值为空)
_, hasDefault := field.Tag.Lookup("default")
_, hasDefault = field.Tag.Lookup("default")
fields = append(fields, fieldInfo{
index: i,
@@ -199,18 +220,18 @@ func GetManyAs[T any]() (*T, error) {
if dbValue, found := foundItems[fi.key]; found {
// 数据库中存在,使用数据库值
if err := unmarshalToField(dbValue, fieldVal); err != nil {
logger.Warn("config", "unmarshal config failed", "key", fi.key, "error", err)
slog.Warn("unmarshal config failed", "key", fi.key, "error", err)
}
} else if fi.hasDefault {
// 数据库中不存在,但有 default tag,解析默认值并写入数据库
if err := parseDefaultToField(fi.defaultVal, fieldVal); err != nil {
logger.Warn("config", "parse default value failed", "key", fi.key, "error", err)
slog.Warn("parse default value failed", "key", fi.key, "error", err)
continue
}
// 序列化后写入数据库
jsonBytes, err := json.Marshal(fieldVal.Interface())
if err != nil {
logger.Warn("config", "marshal default value failed", "key", fi.key, "error", err)
slog.Warn("marshal default value failed", "key", fi.key, "error", err)
continue
}
toInsert = append(toInsert, ConfigItem{
@@ -227,7 +248,7 @@ func GetManyAs[T any]() (*T, error) {
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&toInsert).Error; err != nil {
logger.Warn("config", "batch insert default config failed", "error", err)
slog.Warn("batch insert default config failed", "error", err)
}
}
@@ -406,6 +427,76 @@ func Set(key string, value any) error {
return nil
}
// SetMany 将结构体保存为多个配置项
func SetManyAs[T any](config T) error {
val := reflect.ValueOf(config)
if val.Kind() == reflect.Ptr {
val = val.Elem()
}
typ := val.Type()
var items []ConfigItem
for i := 0; i < val.NumField(); i++ {
fieldType := typ.Field(i)
tag := fieldType.Tag.Get("json")
if tag == "" || tag == "-" {
continue
}
fieldValue := val.Field(i).Interface()
bytes, err := json.Marshal(fieldValue)
if err != nil {
return fmt.Errorf("marshal field %s failed: %w", fieldType.Name, err)
}
items = append(items, ConfigItem{
Key: tag,
Value: string(bytes),
})
}
if len(items) == 0 {
return nil
}
keys := make([]string, 0, len(items))
newVal := make(map[string]any, len(items))
for _, it := range items {
keys = append(keys, it.Key)
var parsed any
if err := json.Unmarshal([]byte(it.Value), &parsed); err == nil {
newVal[it.Key] = parsed
}
}
oldVal := map[string]any{}
if len(keys) > 0 {
var oldItems []ConfigItem
if err := db.Where("key IN ?", keys).Find(&oldItems).Error; err == nil {
for _, oi := range oldItems {
var parsed any
if err := json.Unmarshal([]byte(oi.Value), &parsed); err == nil {
oldVal[oi.Key] = parsed
}
}
}
}
err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&items).Error
if err != nil {
return err
}
publishEvent(oldVal, newVal)
return nil
}
func SetMany(cst map[string]any) error {
var items []ConfigItem
for k, v := range cst {
@@ -16,21 +16,22 @@ type Settings struct {
AutoDiscoveryKey string `json:"auto_discovery_key" default:""` // 自动发现密钥
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 配置
GeoIpEnabled bool `json:"geo_ip_enabled" default:"true"`
GeoIpProvider string `json:"geo_ip_provider" default:"ipinfo"` // empty, mmdb, ip-api, geojs
// Nezha 兼容(Agent gRPC)
NezhaCompatEnabled bool `json:"nezha_compat_enabled" default:"false"`
NezhaCompatListen string `json:"nezha_compat_listen" default:""` // 例如 0.0.0.0:5555
// OAuth 配置
OAuthEnabled bool `json:"o_auth_enabled" default:"false"`
OAuthProvider string `json:"o_auth_provider" default:"github"`
DisablePasswordLogin bool `json:"disable_password_login" default:"false"`
OAuthEnabled bool `json:"o_auth_enabled" default:"false"`
OAuthProvider string `json:"o_auth_provider" default:"github"`
DisablePasswordLogin bool `json:"disable_password_login" default:"false"`
CloudflareTunnelToken string `json:"cloudflare_tunnel_token" default:""`
// 自定义美化
CustomHead string `json:"custom_head" default:""`
CustomBody string `json:"custom_body" default:""`
// 通知
NotificationEnabled bool `json:"notification_enabled" default:"true"` // 通知总开关
NotificationMethod string `json:"notification_method" default:"none"`
@@ -39,34 +40,38 @@ type Settings struct {
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" default:"7"` // 过期前多少天通知,默认7天
LoginNotification bool `json:"login_notification" default:"true"` // 登录通知
TrafficLimitPercentage float64 `json:"traffic_limit_percentage" default:"80.00"` // 流量限制百分比,默认80.00%
UpdatedAt time.Time
// Record
RecordEnabled bool `json:"record_enabled" default:"true"` // 是否启用记录功能
RecordPreserveTime int `json:"record_preserve_time" default:"720"` // 记录保留时间,单位小时,默认30天
PingRecordPreserveTime int `json:"ping_record_preserve_time" default:"24"` // Ping 记录保留时间,单位小时,默认1天
UpdatedAt time.Time
}
const (
SitenameKey = "sitename"
DescriptionKey = "description"
CorsOriginCheckEnabledKey = "cors_origin_check_enabled"
CorsAllowedOriginsKey = "cors_allowed_origins"
WsOriginCheckEnabledKey = "ws_origin_check_enabled"
WsAllowedOriginsKey = "ws_allowed_origins"
ThemeKey = "theme"
PrivateSiteKey = "private_site"
ApiKeyKey = "api_key"
AutoDiscoveryKeyKey = "auto_discovery_key"
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"
GeoIpProviderKey = "geo_ip_provider"
OAuthEnabledKey = "o_auth_enabled"
OAuthProviderKey = "o_auth_provider"
DisablePasswordLoginKey = "disable_password_login"
CustomHeadKey = "custom_head"
CustomBodyKey = "custom_body"
SitenameKey = "sitename"
DescriptionKey = "description"
CorsOriginCheckEnabledKey = "cors_origin_check_enabled"
CorsAllowedOriginsKey = "cors_allowed_origins"
WsOriginCheckEnabledKey = "ws_origin_check_enabled"
WsAllowedOriginsKey = "ws_allowed_origins"
ThemeKey = "theme"
PrivateSiteKey = "private_site"
ApiKeyKey = "api_key"
AutoDiscoveryKeyKey = "auto_discovery_key"
ScriptDomainKey = "script_domain"
SendIpAddrToGuestKey = "send_ip_addr_to_guest"
EulaAcceptedKey = "eula_accepted"
BaseScriptsURLKey = "base_scripts_url"
GeoIpEnabledKey = "geo_ip_enabled"
GeoIpProviderKey = "geo_ip_provider"
NezhaCompatEnabledKey = "nezha_compat_enabled"
NezhaCompatListenKey = "nezha_compat_listen"
OAuthEnabledKey = "o_auth_enabled"
OAuthProviderKey = "o_auth_provider"
DisablePasswordLoginKey = "disable_password_login"
CloudflareTunnelTokenKey = "cloudflare_tunnel_token"
CustomHeadKey = "custom_head"
CustomBodyKey = "custom_body"
NotificationEnabledKey = "notification_enabled"
NotificationMethodKey = "notification_method"
NotificationTemplateKey = "notification_template"
@@ -74,7 +79,9 @@ const (
ExpireNotificationLeadDaysKey = "expire_notification_lead_days"
LoginNotificationKey = "login_notification"
TrafficLimitPercentageKey = "traffic_limit_percentage"
RecordEnabledKey = "record_enabled"
RecordPreserveTimeKey = "record_preserve_time"
PingRecordPreserveTimeKey = "ping_record_preserve_time"
UpdatedAtKey = "updated_at"
XtermjsSettingsKey = "xtermjs_settings"
ThemeMarketSourcesKey = "theme_market_sources"
)
@@ -1,9 +1,9 @@
package scheduler
package corn
import (
"context"
"fmt"
logger "github.com/komari-monitor/komari/utils/log"
"log"
"strconv"
"strings"
"sync"
@@ -40,7 +40,7 @@ type cronSchedule struct {
}
func (s cronSchedule) Next(t time.Time) time.Time {
next := t.UTC().Truncate(time.Second).Add(time.Second)
next := t.Truncate(time.Second).Add(time.Second)
limit := next.Add(366 * 24 * time.Hour)
for next.Before(limit) {
if s.match(next) {
@@ -52,7 +52,6 @@ func (s cronSchedule) Next(t time.Time) time.Time {
}
func (s cronSchedule) match(t time.Time) bool {
t = t.In(time.Local)
_, okSecond := s.seconds[t.Second()]
_, okMinute := s.minutes[t.Minute()]
_, okHour := s.hours[t.Hour()]
@@ -172,7 +171,7 @@ func (m *Manager) run(ctx context.Context, name string, s schedule, runImmediate
nextTick := s.Next(time.Now())
if nextTick.IsZero() {
logger.Warnf("scheduler", "corn job %s has no next run time", name)
log.Printf("corn job %s has no next run time", name)
return
}
timer := time.NewTimer(time.Until(nextTick))
@@ -203,7 +202,7 @@ func resetTimer(timer *time.Timer, duration time.Duration) {
func safeRun(ctx context.Context, name string, fn Func) {
defer func() {
if r := recover(); r != nil {
logger.Errorf("scheduler", "corn job %s panic: %v", name, r)
log.Printf("corn job %s panic: %v", name, r)
}
}()
+36 -71
View File
@@ -7,17 +7,6 @@ import (
"time"
)
type aggregateSeriesKey struct {
metricName string
entityID string
tagsHash string
}
type aggregateGroupKey struct {
aggregateSeriesKey
bucket int64
}
// AggregatePoints groups raw points into time buckets and computes aggregate values.
//
// AggregatePoints 在内存中按查询配置将原始点分桶并聚合。
@@ -26,88 +15,49 @@ func AggregatePoints(points []Point, query AggregateQuery) ([]AggregatePoint, er
return nil, err
}
if len(points) == 0 {
if query.FillEmpty {
return emptyBuckets(query), nil
}
return []AggregatePoint{}, nil
}
points = sortedPoints(points)
groups := make(map[aggregateGroupKey][]Point)
seriesTags := make(map[aggregateSeriesKey]map[string]string)
groups := make(map[int64][]Point)
for _, point := range points {
point = point.normalized()
if point.MetricName == "" {
point.MetricName = query.MetricName
}
if point.EntityID == "" {
point.EntityID = query.EntityID
}
tagsHash, _, err := tagsFingerprint(point.Tags)
if err != nil {
return nil, err
}
bucket := alignTime(point.Timestamp, query.Interval).UnixNano()
series := aggregateSeriesKey{
metricName: point.MetricName,
entityID: point.EntityID,
tagsHash: tagsHash,
}
key := aggregateGroupKey{aggregateSeriesKey: series, bucket: bucket}
groups[key] = append(groups[key], point)
if _, ok := seriesTags[series]; !ok {
seriesTags[series] = cloneStringMap(point.Tags)
}
groups[bucket] = append(groups[bucket], point)
}
keys := make([]aggregateGroupKey, 0, len(groups))
for key := range groups {
keys = append(keys, key)
var buckets []int64
if query.FillEmpty {
for t := alignTime(query.Start, query.Interval); !t.After(query.End); t = t.Add(query.Interval) {
buckets = append(buckets, t.UnixNano())
}
} else {
for bucket := range groups {
buckets = append(buckets, bucket)
}
sort.Slice(buckets, func(i, j int) bool { return buckets[i] < buckets[j] })
}
sortAggregateGroupKeys(keys)
out := make([]AggregatePoint, 0, len(keys))
for _, key := range keys {
group := groups[key]
out := make([]AggregatePoint, 0, len(buckets))
for _, bucket := range buckets {
group := groups[bucket]
value, err := aggregateValue(group, query.Aggregation)
if err != nil {
return nil, err
}
out = append(out, AggregatePoint{
MetricName: key.metricName,
EntityID: key.entityID,
Bucket: time.Unix(0, key.bucket).UTC(),
MetricName: query.MetricName,
EntityID: query.EntityID,
Bucket: time.Unix(0, bucket).UTC(),
Value: value,
Count: len(group),
Tags: cloneStringMap(seriesTags[key.aggregateSeriesKey]),
})
}
return out, nil
}
func sortAggregateGroupKeys(keys []aggregateGroupKey) {
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].metricName != keys[j].metricName {
return keys[i].metricName < keys[j].metricName
}
if keys[i].entityID != keys[j].entityID {
return keys[i].entityID < keys[j].entityID
}
return keys[i].tagsHash < keys[j].tagsHash
})
}
func cloneStringMap(in map[string]string) map[string]string {
if len(in) == 0 {
return map[string]string{}
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
// CalculateStats computes summary statistics for a point series.
//
// CalculateStats 基于一组点计算统计摘要,包括均值、百分位、首尾值和标准差。
@@ -303,3 +253,18 @@ func alignTime(t time.Time, interval time.Duration) time.Time {
rem := ((nano % size) + size) % size
return time.Unix(0, nano-rem).UTC()
}
// emptyBuckets builds zero-count aggregate buckets for an empty range.
//
// emptyBuckets 根据查询范围生成空聚合桶,用于 FillEmpty 场景。
func emptyBuckets(query AggregateQuery) []AggregatePoint {
var out []AggregatePoint
for t := alignTime(query.Start, query.Interval); !t.After(query.End); t = t.Add(query.Interval) {
out = append(out, AggregatePoint{
MetricName: query.MetricName,
EntityID: query.EntityID,
Bucket: t,
})
}
return out
}
-66
View File
@@ -1,66 +0,0 @@
package metric
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// compactionWatermark returns the last boundary for which a metric's raw data
// was successfully materialized into rollups and retained/deleted in the same
// transaction. A missing row is expected for stores created before watermark
// tracking was introduced or for metrics that have never been compacted.
func (s *Store) compactionWatermark(ctx context.Context, metricName string) (time.Time, bool, error) {
var nano int64
err := s.reader().QueryRowContext(ctx, fmt.Sprintf(
`SELECT watermark_nano FROM %s WHERE metric_name = %s`,
s.tables.watermarks, s.dialect.placeholder(1),
), metricName).Scan(&nano)
if errors.Is(err, sql.ErrNoRows) {
return time.Time{}, false, nil
}
if err != nil {
return time.Time{}, false, err
}
if nano <= 0 {
return time.Time{}, false, nil
}
return time.Unix(0, nano).UTC(), true, nil
}
// persistCompactionWatermarkTx records a successful compaction boundary in the
// same transaction as rollup writes and raw retention deletion. It is
// monotonic so a delayed caller cannot move the read boundary backwards.
func (s *Store) persistCompactionWatermarkTx(ctx context.Context, metricName string, watermark time.Time, tx *sql.Tx) error {
watermark = watermark.UTC()
if watermark.IsZero() {
return s.clearCompactionWatermarkTx(ctx, metricName, tx)
}
var previous int64
err := tx.QueryRowContext(ctx, fmt.Sprintf(
`SELECT watermark_nano FROM %s WHERE metric_name = %s`,
s.tables.watermarks, s.dialect.placeholder(1),
), metricName).Scan(&previous)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
if err == nil && previous > watermark.UnixNano() {
watermark = time.Unix(0, previous).UTC()
}
_, err = tx.ExecContext(ctx, s.dialect.upsertCompactionWatermarkSQL(s.tables),
metricName, watermark.UnixNano(), time.Now().UTC().UnixNano(),
)
return err
}
func (s *Store) clearCompactionWatermarkTx(ctx context.Context, metricName string, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, fmt.Sprintf(
`DELETE FROM %s WHERE metric_name = %s`,
s.tables.watermarks, s.dialect.placeholder(1),
), metricName)
return err
}
+25 -8
View File
@@ -33,6 +33,10 @@ type Config struct {
//
// TablePrefix 是 Store 管理的所有表名前缀。
TablePrefix string
// DefaultRetentionDays is used when a metric definition omits retention.
//
// DefaultRetentionDays 在指标定义未设置保留时间时使用。
DefaultRetentionDays int
// AutoMigrate controls whether Open creates or updates the schema.
//
// AutoMigrate 控制 Open 是否创建或更新表结构。
@@ -147,14 +151,15 @@ type Option func(*Config)
// DefaultConfig 返回指定后端的默认配置。
func DefaultConfig(driver Driver, dsn string) Config {
return Config{
Driver: driver,
DSN: dsn,
TablePrefix: "metric_",
AutoMigrate: true,
MaxOpenConns: 25,
MaxIdleConns: 5,
ConnMaxLifetime: time.Hour,
ConnectTimeout: 10 * time.Second,
Driver: driver,
DSN: dsn,
TablePrefix: "metric_",
DefaultRetentionDays: 90,
AutoMigrate: true,
MaxOpenConns: 25,
MaxIdleConns: 5,
ConnMaxLifetime: time.Hour,
ConnectTimeout: 10 * time.Second,
SQLite: SQLiteOptions{
PerformanceProfile: SQLiteProfileBalanced,
BusyTimeout: 5 * time.Second,
@@ -263,6 +268,15 @@ func WithTablePrefix(prefix string) Option {
}
}
// WithDefaultRetention sets the default metric retention in days.
//
// WithDefaultRetention 设置指标默认保留天数。
func WithDefaultRetention(days int) Option {
return func(c *Config) {
c.DefaultRetentionDays = days
}
}
// WithAutoMigrate controls whether Open creates the schema automatically.
//
// WithAutoMigrate 控制 Open 时是否自动创建表结构。
@@ -428,6 +442,9 @@ func (c Config) Validate() error {
return fmt.Errorf("%w: table prefix must contain only letters, digits, and underscores", ErrInvalidArgument)
}
}
if c.DefaultRetentionDays <= 0 {
return fmt.Errorf("%w: default retention days must be positive", ErrInvalidArgument)
}
if err := c.RollupPolicy.Validate(); err != nil {
return err
}
+4
View File
@@ -12,6 +12,7 @@ func TestBackendBuildersApplyOptions(t *testing.T) {
cfg := SQLite(
"file:metrics.db?cache=shared",
WithTablePrefix("x_metric_"),
WithDefaultRetention(180),
WithAutoMigrate(false),
WithMaxOpenConns(10),
WithMaxIdleConns(2),
@@ -25,6 +26,9 @@ func TestBackendBuildersApplyOptions(t *testing.T) {
if cfg.TablePrefix != "x_metric_" {
t.Fatalf("table prefix option was not applied: %q", cfg.TablePrefix)
}
if cfg.DefaultRetentionDays != 180 {
t.Fatalf("default retention option was not applied: %d", cfg.DefaultRetentionDays)
}
if cfg.AutoMigrate {
t.Fatalf("auto migrate option was not applied")
}
-37
View File
@@ -42,9 +42,6 @@ type dialect interface {
//
// 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
@@ -74,10 +71,6 @@ type tables struct {
//
// rollups 是降采样 rollup 表。
rollups string
// watermarks stores the last successfully compacted raw boundary per metric.
//
// watermarks 保存每个指标最近一次成功压缩到的 raw 边界。
watermarks string
}
// newDialect returns the SQL dialect implementation for a backend.
@@ -151,16 +144,6 @@ func (sqliteDialect) upsertPointSQL(t tables, rowCount int) string {
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)
}
// mysqlDialect implements SQL rendering for MySQL.
//
// mysqlDialect 实现 MySQL 方言。
@@ -218,16 +201,6 @@ func (mysqlDialect) upsertPointSQL(t tables, rowCount int) string {
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)
}
// postgresDialect implements SQL rendering for PostgreSQL.
//
// postgresDialect 实现 PostgreSQL 方言。
@@ -285,16 +258,6 @@ func (postgresDialect) upsertPointSQL(t tables, rowCount int) string {
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。
+14 -14
View File
@@ -32,10 +32,10 @@ func Example_rollupTags() {
defer store.Close()
if err := store.CreateMetric(ctx, metric.Definition{
Name: "gpu.usage",
Type: metric.TypeGauge,
Unit: "%",
RetentionDays: 30}); err != nil {
Name: "gpu.usage",
Type: metric.TypeGauge,
Unit: "%",
}); err != nil {
log.Fatal(err)
}
@@ -117,15 +117,15 @@ func Example_agentFleetLifecycle() {
defer store.Close()
for _, def := range []metric.Definition{
{Name: "cpu.usage", Type: metric.TypeGauge, Unit: "%", RetentionDays: 30},
{Name: "mem.usage", Type: metric.TypeGauge, Unit: "%", RetentionDays: 30},
{Name: "disk.usage", Type: metric.TypeGauge, Unit: "%", RetentionDays: 30},
{Name: "swap.usage", Type: metric.TypeGauge, Unit: "%", RetentionDays: 30},
{Name: "net.in.bytes", Type: metric.TypeCounter, Unit: "bytes", RetentionDays: 30},
{Name: "net.out.bytes", Type: metric.TypeCounter, Unit: "bytes", RetentionDays: 30},
{Name: "process.count", Type: metric.TypeGauge, Unit: "count", RetentionDays: 30},
{Name: "gpu.usage", Type: metric.TypeGauge, Unit: "%", RetentionDays: 30},
{Name: "ping.latency_ms", Type: metric.TypeGauge, Unit: "ms", RetentionDays: 30},
{Name: "cpu.usage", Type: metric.TypeGauge, Unit: "%"},
{Name: "mem.usage", Type: metric.TypeGauge, Unit: "%"},
{Name: "disk.usage", Type: metric.TypeGauge, Unit: "%"},
{Name: "swap.usage", Type: metric.TypeGauge, Unit: "%"},
{Name: "net.in.bytes", Type: metric.TypeCounter, Unit: "bytes"},
{Name: "net.out.bytes", Type: metric.TypeCounter, Unit: "bytes"},
{Name: "process.count", Type: metric.TypeGauge, Unit: "count"},
{Name: "gpu.usage", Type: metric.TypeGauge, Unit: "%"},
{Name: "ping.latency_ms", Type: metric.TypeGauge, Unit: "ms"},
} {
if err := store.UpsertMetric(ctx, def); err != nil {
log.Fatal(err)
@@ -158,7 +158,7 @@ func Example_agentFleetLifecycle() {
}
if err := store.UpsertMetric(ctx, metric.Definition{
Name: "ping.latency_ms", Type: metric.TypeGauge, Unit: "ms",
RetentionDays: 30}); err != nil {
}); err != nil {
log.Fatal(err)
}
case 85:
+14 -69
View File
@@ -28,7 +28,7 @@ func newMemStore(t *testing.T) *Store {
func TestCreateMetricRejectsDuplicate(t *testing.T) {
ctx := context.Background()
s := newMemStore(t)
def := Definition{Name: "dup.metric", Type: TypeGauge, RetentionDays: 30}
def := Definition{Name: "dup.metric", Type: TypeGauge}
if err := s.CreateMetric(ctx, def); err != nil {
t.Fatalf("first create: %v", err)
}
@@ -44,10 +44,10 @@ func TestCreateMetricRejectsDuplicate(t *testing.T) {
func TestUpsertMetricOverwrites(t *testing.T) {
ctx := context.Background()
s := newMemStore(t)
if err := s.CreateMetric(ctx, Definition{Name: "m", Type: TypeGauge, Unit: "ms", RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "m", Type: TypeGauge, Unit: "ms"}); err != nil {
t.Fatalf("create: %v", err)
}
if err := s.UpsertMetric(ctx, Definition{Name: "m", Type: TypeCounter, Unit: "count", RetentionDays: 30}); err != nil {
if err := s.UpsertMetric(ctx, Definition{Name: "m", Type: TypeCounter, Unit: "count"}); err != nil {
t.Fatalf("upsert: %v", err)
}
got, err := s.GetMetric(ctx, "m")
@@ -65,7 +65,7 @@ func TestUpsertMetricOverwrites(t *testing.T) {
func TestTagFilterPushdownWithPaging(t *testing.T) {
ctx := context.Background()
s := newMemStore(t)
if err := s.CreateMetric(ctx, Definition{Name: "req", Type: TypeCounter, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "req", Type: TypeCounter}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
@@ -110,7 +110,7 @@ func TestTagFilterPushdownWithPaging(t *testing.T) {
func TestSQLAggregateMatchesInMemory(t *testing.T) {
ctx := context.Background()
s := newMemStore(t)
if err := s.CreateMetric(ctx, Definition{Name: "g", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "g", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
@@ -195,7 +195,7 @@ func TestAlignTimeNegativeTimestamp(t *testing.T) {
func TestLatestReturnsMostRecent(t *testing.T) {
ctx := context.Background()
s := newMemStore(t)
if err := s.CreateMetric(ctx, Definition{Name: "l", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "l", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
@@ -228,7 +228,7 @@ func TestStatsDistinguishesNoDataFromUnknownMetric(t *testing.T) {
}
// Known metric but empty window -> ErrNoData.
if err := s.CreateMetric(ctx, Definition{Name: "known", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "known", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
_, err = s.Stats(ctx, Query{MetricName: "known", Start: base, End: base.Add(time.Hour)})
@@ -266,7 +266,7 @@ func TestStdDevPopMatchesCalculateStats(t *testing.T) {
func TestAggregateStdDevSQLiteUsesMemoryPath(t *testing.T) {
ctx := context.Background()
s := newMemStore(t)
if err := s.CreateMetric(ctx, Definition{Name: "sd", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "sd", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
@@ -342,7 +342,7 @@ func TestSQLiteReadPoolOpens(t *testing.T) {
t.Fatalf("reader() should return the dedicated read pool")
}
// Round-trip a write (primary) and a read (read pool).
if err := store.CreateMetric(ctx, Definition{Name: "rp", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := store.CreateMetric(ctx, Definition{Name: "rp", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
@@ -379,7 +379,7 @@ func TestMemoryDSNSkipsReadPool(t *testing.T) {
func TestWriteBatchAtomicAcrossChunks(t *testing.T) {
ctx := context.Background()
s := newMemStore(t)
if err := s.CreateMetric(ctx, Definition{Name: "atom", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "atom", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
@@ -409,7 +409,7 @@ func TestWriteBatchAtomicAcrossChunks(t *testing.T) {
func TestAggregateBucketPagingSQLPath(t *testing.T) {
ctx := context.Background()
s := newMemStore(t)
if err := s.CreateMetric(ctx, Definition{Name: "bp", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "bp", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
@@ -447,7 +447,7 @@ func TestAggregateBucketPagingSQLPath(t *testing.T) {
func TestAggregateBucketPagingMemoryPathMatchesSQL(t *testing.T) {
ctx := context.Background()
s := newMemStore(t)
if err := s.CreateMetric(ctx, Definition{Name: "bp2", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "bp2", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
@@ -485,68 +485,13 @@ func TestAggregateBucketPagingMemoryPathMatchesSQL(t *testing.T) {
}
}
func TestAggregateSeparatesTagSeries(t *testing.T) {
ctx := context.Background()
s := newMemStore(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)
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"}},
{MetricName: "tagged.util", EntityID: "n1", Timestamp: base.Add(10 * time.Second), Value: 100, Tags: map[string]string{"device": "1"}},
{MetricName: "tagged.util", EntityID: "n1", Timestamp: base.Add(20 * time.Second), Value: 200, Tags: map[string]string{"device": "1"}},
}
if err := s.WriteBatch(ctx, points); err != nil {
t.Fatalf("write: %v", err)
}
query := Query{MetricName: "tagged.util", EntityID: "n1", Start: base, End: base.Add(time.Minute)}
sqlRes, err := s.Aggregate(ctx, AggregateQuery{
Query: query,
Aggregation: AggAvg,
Interval: time.Minute,
})
if err != nil {
t.Fatalf("sql aggregate: %v", err)
}
assertTaggedAggregate(t, sqlRes, "0", 20, 2)
assertTaggedAggregate(t, sqlRes, "1", 150, 2)
memRes, err := s.Aggregate(ctx, AggregateQuery{
Query: query,
Aggregation: AggP50,
Interval: time.Minute,
})
if err != nil {
t.Fatalf("memory aggregate: %v", err)
}
assertTaggedAggregate(t, memRes, "0", 20, 2)
assertTaggedAggregate(t, memRes, "1", 150, 2)
}
func assertTaggedAggregate(t *testing.T, points []AggregatePoint, device string, wantValue float64, wantCount int) {
t.Helper()
for _, point := range points {
if point.Tags["device"] != device {
continue
}
if point.Value != wantValue || point.Count != wantCount {
t.Fatalf("device %s aggregate = value %v count %d, want value %v count %d; all=%#v", device, point.Value, point.Count, wantValue, wantCount, points)
}
return
}
t.Fatalf("missing aggregate for device %s; all=%#v", device, points)
}
// TestAggregateIgnoresRawPointLimit verifies raw limits do not affect aggregation.
//
// TestAggregateIgnoresRawPointLimit 验证原始点分页参数不会影响聚合输入。
func TestAggregateIgnoresRawPointLimit(t *testing.T) {
ctx := context.Background()
s := newMemStore(t)
if err := s.CreateMetric(ctx, Definition{Name: "ig", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "ig", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
@@ -578,7 +523,7 @@ func TestAggregateIgnoresRawPointLimit(t *testing.T) {
func TestJSONTagKeyWithSpecialChars(t *testing.T) {
ctx := context.Background()
s := newMemStore(t)
if err := s.CreateMetric(ctx, Definition{Name: "tk", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "tk", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
-48
View File
@@ -1,48 +0,0 @@
package metric
import "time"
var standardQueryIntervals = [...]time.Duration{
time.Second,
5 * time.Second,
10 * time.Second,
15 * time.Second,
30 * time.Second,
time.Minute,
2 * time.Minute,
5 * time.Minute,
10 * time.Minute,
15 * time.Minute,
30 * time.Minute,
time.Hour,
2 * time.Hour,
3 * time.Hour,
6 * time.Hour,
12 * time.Hour,
24 * time.Hour,
}
// FloorStandardInterval returns the largest standard query interval that does
// not exceed interval. Values below one second use the minimum interval.
func FloorStandardInterval(interval time.Duration) time.Duration {
selected := standardQueryIntervals[0]
for _, candidate := range standardQueryIntervals {
if candidate > interval {
break
}
selected = candidate
}
return selected
}
// CeilStandardInterval returns the smallest standard query interval that is
// at least interval. Values above one day are rounded up to whole days.
func CeilStandardInterval(interval time.Duration) time.Duration {
for _, candidate := range standardQueryIntervals {
if candidate >= interval {
return candidate
}
}
day := standardQueryIntervals[len(standardQueryIntervals)-1]
return ((interval-1)/day + 1) * day
}
-31
View File
@@ -1,31 +0,0 @@
package metric
import (
"testing"
"time"
)
func TestStandardIntervals(t *testing.T) {
tests := []struct {
name string
interval time.Duration
floor time.Duration
ceil time.Duration
}{
{name: "below minimum", interval: 500 * time.Millisecond, floor: time.Second, ceil: time.Second},
{name: "exact", interval: 5 * time.Minute, floor: 5 * time.Minute, ceil: 5 * time.Minute},
{name: "between standards", interval: 7 * time.Minute, floor: 5 * time.Minute, ceil: 10 * time.Minute},
{name: "above one day", interval: 25 * time.Hour, floor: 24 * time.Hour, ceil: 48 * time.Hour},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := FloorStandardInterval(test.interval); got != test.floor {
t.Fatalf("FloorStandardInterval(%s) = %s, want %s", test.interval, got, test.floor)
}
if got := CeilStandardInterval(test.interval); got != test.ceil {
t.Fatalf("CeilStandardInterval(%s) = %s, want %s", test.interval, got, test.ceil)
}
})
}
}
-334
View File
@@ -1,334 +0,0 @@
package metric
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"strings"
)
// MaintenanceAction identifies the backend-specific operation used to reclaim
// physical database space.
//
// MaintenanceAction 表示回收数据库物理空间时使用的后端专用操作。
type MaintenanceAction string
const (
// MaintenanceVacuum is SQLite's VACUUM operation.
MaintenanceVacuum MaintenanceAction = "vacuum"
// MaintenanceOptimize is MySQL's OPTIMIZE TABLE operation.
MaintenanceOptimize MaintenanceAction = "optimize"
// MaintenanceVacuumFull is PostgreSQL's blocking VACUUM FULL operation.
MaintenanceVacuumFull MaintenanceAction = "vacuum_full"
)
const (
sqliteCheckpointSQL = "PRAGMA wal_checkpoint(TRUNCATE)"
sqliteVacuumSQL = "VACUUM"
)
// Driver returns the Store's configured database backend.
//
// Driver 返回 Store 配置的数据库后端。
func (s *Store) Driver() Driver {
return s.cfg.Driver
}
// MaintenanceAction returns the physical-space reclamation operation used by
// the Store's backend.
//
// MaintenanceAction 返回当前后端用于回收物理空间的操作。
func (s *Store) MaintenanceAction() MaintenanceAction {
return maintenanceActionFor(s.cfg.Driver)
}
// 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.
//
// StorageSize 返回当前 Store 占用的物理字节数。SQLite 会统计主数据库、WAL
// 和共享内存文件;服务端数据库只统计 Store 管理的 definitions、points 和
// rollups 三张表。
func (s *Store) StorageSize(ctx context.Context) (int64, error) {
s.maintenanceMu.RLock()
defer s.maintenanceMu.RUnlock()
s.mu.RLock()
defer s.mu.RUnlock()
if s.closed || s.db == nil {
return 0, ErrClosed
}
if s.cfg.Driver == DriverSQLite {
return s.sqliteStorageSize(ctx)
}
query, args, err := managedStorageSizeQuery(s.cfg.Driver, s.tables)
if err != nil {
return 0, err
}
var size int64
if err := s.db.QueryRowContext(ctx, query, args...).Scan(&size); err != nil {
return 0, fmt.Errorf("metric: query %s storage size: %w", s.cfg.Driver, err)
}
return size, nil
}
// 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.
//
// ReclaimSpace 执行后端专用的阻塞式空间回收操作。该方法会与其他维护调用
// 串行执行,并阻止 Close 在操作过程中关闭连接池。
func (s *Store) ReclaimSpace(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 _, err := s.cleanupOrphanedMetricData(ctx); err != nil {
return err
}
switch s.cfg.Driver {
case DriverSQLite:
if err := sqliteCheckpoint(ctx, s.db); err != nil {
return err
}
if _, err := s.db.ExecContext(ctx, sqliteVacuumSQL); err != nil {
return fmt.Errorf("metric: vacuum sqlite database: %w", err)
}
// VACUUM itself can populate the WAL; truncate it again so the reported
// physical size reflects the completed reclamation.
return sqliteCheckpoint(ctx, s.db)
case DriverMySQL:
query, err := managedReclaimQuery(s.cfg.Driver, s.tables)
if err != nil {
return err
}
return s.optimizeMySQLTables(ctx, query)
case DriverPostgreSQL:
query, err := managedReclaimQuery(s.cfg.Driver, s.tables)
if err != nil {
return err
}
if _, err := s.db.ExecContext(ctx, query); err != nil {
return fmt.Errorf("metric: vacuum PostgreSQL metric tables: %w", err)
}
return nil
default:
return fmt.Errorf("%w: unsupported driver %q", ErrInvalidArgument, s.cfg.Driver)
}
}
// cleanupOrphanedMetricData removes rows whose metric definition no longer
// exists. It runs immediately before an explicit space-reclaim operation so
// the physical rewrite returns the freed pages to the filesystem in the same
// 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,
))
if err != nil {
return deleted, fmt.Errorf("metric: delete orphaned rows from %s: %w", table, err)
}
count, err := result.RowsAffected()
if err != nil {
return deleted, err
}
deleted += count
}
return deleted, nil
}
func (s *Store) sqliteStorageSize(ctx context.Context) (int64, error) {
rows, err := s.db.QueryContext(ctx, "PRAGMA database_list")
if err != nil {
return 0, fmt.Errorf("metric: list sqlite databases: %w", err)
}
var path string
for rows.Next() {
var sequence int
var name, file string
if err := rows.Scan(&sequence, &name, &file); err != nil {
_ = rows.Close()
return 0, fmt.Errorf("metric: scan sqlite database path: %w", err)
}
if name == "main" {
path = file
}
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return 0, fmt.Errorf("metric: list sqlite databases: %w", err)
}
if err := rows.Close(); err != nil {
return 0, fmt.Errorf("metric: close sqlite database list: %w", err)
}
if path == "" {
// SQLite reports an empty path for in-memory databases.
return 0, nil
}
var size int64
for _, name := range []string{path, path + "-wal", path + "-shm"} {
info, err := os.Stat(name)
switch {
case err == nil:
size += info.Size()
case errors.Is(err, os.ErrNotExist):
default:
return 0, fmt.Errorf("metric: stat sqlite storage file %q: %w", name, err)
}
}
return size, nil
}
func sqliteCheckpoint(ctx context.Context, db *sql.DB) error {
var busy, logFrames, checkpointedFrames int
if err := db.QueryRowContext(ctx, sqliteCheckpointSQL).Scan(&busy, &logFrames, &checkpointedFrames); err != nil {
return fmt.Errorf("metric: checkpoint sqlite WAL: %w", err)
}
if busy != 0 {
return fmt.Errorf("metric: checkpoint sqlite WAL: database is busy (%d log frames, %d checkpointed)", logFrames, checkpointedFrames)
}
return nil
}
func (s *Store) optimizeMySQLTables(ctx context.Context, query string) error {
rows, err := s.db.QueryContext(ctx, query)
if err != nil {
return fmt.Errorf("metric: optimize MySQL metric tables: %w", err)
}
var resultErrors []error
for rows.Next() {
var table, operation, messageType, message string
if err := rows.Scan(&table, &operation, &messageType, &message); err != nil {
_ = rows.Close()
return fmt.Errorf("metric: scan MySQL optimize result: %w", err)
}
if err := mysqlOptimizeResultError(table, messageType, message); err != nil {
resultErrors = append(resultErrors, err)
}
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return fmt.Errorf("metric: read MySQL optimize results: %w", err)
}
if err := rows.Close(); err != nil {
return fmt.Errorf("metric: close MySQL optimize results: %w", err)
}
return errors.Join(resultErrors...)
}
func mysqlOptimizeResultError(table, messageType, message string) error {
if !strings.EqualFold(strings.TrimSpace(messageType), "error") {
return nil
}
table = strings.TrimSpace(table)
if table == "" {
table = "unknown table"
}
message = strings.TrimSpace(message)
if message == "" {
message = "unknown error"
}
return fmt.Errorf("metric: optimize MySQL table %s: %s", table, message)
}
func maintenanceActionFor(driver Driver) MaintenanceAction {
switch driver {
case DriverSQLite:
return MaintenanceVacuum
case DriverMySQL:
return MaintenanceOptimize
case DriverPostgreSQL:
return MaintenanceVacuumFull
default:
return ""
}
}
func managedStorageSizeQuery(driver Driver, t tables) (string, []any, error) {
names := managedTableNames(t)
d := newDialect(driver)
placeholders := make([]string, len(names))
for i := range names {
placeholders[i] = d.placeholder(i + 1)
}
inClause := strings.Join(placeholders, ", ")
switch driver {
case DriverMySQL:
return fmt.Sprintf(`SELECT COALESCE(SUM(DATA_LENGTH + INDEX_LENGTH), 0)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN (%s)`, inClause), stringsToAny(names), nil
case DriverPostgreSQL:
for i := range names {
names[i] = strings.ToLower(names[i])
}
return fmt.Sprintf(`SELECT COALESCE(SUM(pg_total_relation_size(c.oid)), 0)
FROM pg_catalog.pg_class AS c
JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace
WHERE n.nspname = current_schema()
AND c.relname IN (%s)`, inClause), stringsToAny(names), nil
default:
return "", nil, fmt.Errorf("%w: storage-size query is unavailable for driver %q", ErrInvalidArgument, driver)
}
}
func managedReclaimQuery(driver Driver, t tables) (string, error) {
names := managedTableNames(t)
switch driver {
case DriverSQLite:
return sqliteVacuumSQL, nil
case DriverMySQL:
for i := range names {
names[i] = quoteMaintenanceIdentifier(driver, names[i])
}
return "OPTIMIZE TABLE " + strings.Join(names, ", "), nil
case DriverPostgreSQL:
for i := range names {
names[i] = quoteMaintenanceIdentifier(driver, strings.ToLower(names[i]))
}
return "VACUUM (FULL, ANALYZE) " + strings.Join(names, ", "), nil
default:
return "", fmt.Errorf("%w: reclaim query is unavailable for driver %q", ErrInvalidArgument, driver)
}
}
func managedTableNames(t tables) []string {
names := []string{t.definitions, t.points, t.rollups}
if t.watermarks != "" {
names = append(names, t.watermarks)
}
return names
}
func quoteMaintenanceIdentifier(driver Driver, identifier string) string {
if driver == DriverMySQL {
return "`" + strings.ReplaceAll(identifier, "`", "``") + "`"
}
return `"` + strings.ReplaceAll(identifier, `"`, `""`) + `"`
}
func stringsToAny(values []string) []any {
args := make([]any, len(values))
for i := range values {
args[i] = values[i]
}
return args
}
-226
View File
@@ -1,226 +0,0 @@
package metric
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
func TestSQLiteStorageSizeAndReclaimSpace(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
store, err := Open(ctx, SQLiteInDir(dir, WithSQLiteWALAutoCheckpoint(1_000_000)))
if err != nil {
t.Fatalf("open sqlite store: %v", err)
}
t.Cleanup(func() { _ = store.Close() })
if got := store.Driver(); got != DriverSQLite {
t.Fatalf("Driver() = %q, want %q", got, DriverSQLite)
}
if got := store.MaintenanceAction(); got != MaintenanceVacuum {
t.Fatalf("MaintenanceAction() = %q, want %q", got, MaintenanceVacuum)
}
if _, err := store.db.ExecContext(ctx, `CREATE TABLE reclaim_fixture (payload BLOB NOT NULL)`); err != nil {
t.Fatalf("create reclaim fixture: %v", err)
}
if _, err := store.db.ExecContext(ctx, `INSERT INTO reclaim_fixture (payload) VALUES (zeroblob(4194304))`); err != nil {
t.Fatalf("populate reclaim fixture: %v", err)
}
if _, err := store.db.ExecContext(ctx, `DROP TABLE reclaim_fixture`); err != nil {
t.Fatalf("drop reclaim fixture: %v", err)
}
before, err := store.StorageSize(ctx)
if err != nil {
t.Fatalf("storage size before reclaim: %v", err)
}
path := filepath.Join(dir, "metrics.db")
if want := sqliteFileSetSize(t, path); before != want {
t.Fatalf("StorageSize() = %d, file sum = %d", before, want)
}
if err := store.ReclaimSpace(ctx); err != nil {
t.Fatalf("reclaim sqlite space: %v", err)
}
after, err := store.StorageSize(ctx)
if err != nil {
t.Fatalf("storage size after reclaim: %v", err)
}
if want := sqliteFileSetSize(t, path); after != want {
t.Fatalf("StorageSize() after reclaim = %d, file sum = %d", after, want)
}
if after >= before {
t.Fatalf("reclaim did not reduce physical storage: before=%d after=%d", before, after)
}
if err := store.Ping(ctx); err != nil {
t.Fatalf("store unusable after reclaim: %v", err)
}
if err := store.Close(); err != nil {
t.Fatalf("close store: %v", err)
}
if _, err := store.StorageSize(ctx); !errors.Is(err, ErrClosed) {
t.Fatalf("StorageSize() after Close error = %v, want ErrClosed", err)
}
if err := store.ReclaimSpace(ctx); !errors.Is(err, ErrClosed) {
t.Fatalf("ReclaimSpace() after Close error = %v, want ErrClosed", err)
}
}
func TestCleanupOrphanedMetricData(t *testing.T) {
ctx := context.Background()
store := newMemStore(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)
}
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 {
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)
}
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)
}
for _, table := range []string{store.tables.points, store.tables.rollups, store.tables.watermarks} {
var count int
if err := store.db.QueryRowContext(ctx, fmt.Sprintf(`SELECT COUNT(*) FROM %s WHERE metric_name = ?`, table), "orphan").Scan(&count); err != nil {
t.Fatalf("count orphan rows in %s: %v", table, err)
}
if count != 0 {
t.Fatalf("orphan rows remain in %s: %d", table, count)
}
}
}
func TestMaintenanceMappings(t *testing.T) {
tables := tables{
definitions: "Metric_definitions",
points: "Metric_points",
rollups: "Metric_rollups",
watermarks: "Metric_compaction_watermarks",
}
tests := []struct {
name string
driver Driver
action MaintenanceAction
reclaim string
sizeParts []string
sizeArgs []any
hasSizeSQL bool
}{
{
name: "sqlite",
driver: DriverSQLite,
action: MaintenanceVacuum,
reclaim: "VACUUM",
hasSizeSQL: false,
},
{
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"},
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"},
hasSizeSQL: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := maintenanceActionFor(tt.driver); got != tt.action {
t.Fatalf("maintenanceActionFor(%q) = %q, want %q", tt.driver, got, tt.action)
}
gotReclaim, err := managedReclaimQuery(tt.driver, tables)
if err != nil {
t.Fatalf("managedReclaimQuery(%q): %v", tt.driver, err)
}
if gotReclaim != tt.reclaim {
t.Fatalf("managedReclaimQuery(%q) = %q, want %q", tt.driver, gotReclaim, tt.reclaim)
}
gotSize, gotArgs, err := managedStorageSizeQuery(tt.driver, tables)
if !tt.hasSizeSQL {
if err == nil {
t.Fatalf("managedStorageSizeQuery(%q) unexpectedly succeeded: %q", tt.driver, gotSize)
}
return
}
if err != nil {
t.Fatalf("managedStorageSizeQuery(%q): %v", tt.driver, err)
}
for _, part := range tt.sizeParts {
if !strings.Contains(gotSize, part) {
t.Errorf("size query for %q does not contain %q: %s", tt.driver, part, gotSize)
}
}
if !reflect.DeepEqual(gotArgs, tt.sizeArgs) {
t.Fatalf("size args for %q = %#v, want %#v", tt.driver, gotArgs, tt.sizeArgs)
}
})
}
}
func TestMySQLOptimizeResultError(t *testing.T) {
if err := mysqlOptimizeResultError("metric_points", "status", "OK"); err != nil {
t.Fatalf("status result returned an error: %v", err)
}
if err := mysqlOptimizeResultError("metric_points", "note", "recreate and analyze instead"); err != nil {
t.Fatalf("note result returned an error: %v", err)
}
err := mysqlOptimizeResultError("komari.metric_points", " Error ", "operation failed")
if err == nil || !strings.Contains(err.Error(), "komari.metric_points") || !strings.Contains(err.Error(), "operation failed") {
t.Fatalf("error result was not preserved: %v", err)
}
}
func sqliteFileSetSize(t *testing.T, path string) int64 {
t.Helper()
var size int64
for _, name := range []string{path, path + "-wal", path + "-shm"} {
info, err := os.Stat(name)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
t.Fatalf("stat %q: %v", name, err)
}
size += info.Size()
}
return size
}
-15
View File
@@ -66,11 +66,6 @@ func (s *Store) Migrate(ctx context.Context) error {
)`, 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),
}
if s.cfg.Driver == DriverMySQL {
@@ -122,11 +117,6 @@ func (s *Store) Migrate(ctx context.Context) error {
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),
}
}
@@ -180,11 +170,6 @@ func (s *Store) Migrate(ctx context.Context) error {
)`, 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),
}
}
+8 -22
View File
@@ -52,18 +52,18 @@ func runSQLIntegration(t *testing.T, name string, cfg Config, expectSQLPercentil
defer dropIntegrationTables(t, store, prefix)
if err := store.CreateMetric(ctx, Definition{
Name: "http.latency",
Type: TypeGauge,
Unit: "ms",
Description: "HTTP latency",
Metadata: map[string]string{"source": "integration"},
RetentionDays: 30}); err != nil {
Name: "http.latency",
Type: TypeGauge,
Unit: "ms",
Description: "HTTP latency",
Metadata: map[string]string{"source": "integration"},
}); err != nil {
t.Fatalf("create metric: %v", err)
}
if err := store.CreateMetric(ctx, Definition{Name: "http.latency", Type: TypeGauge, RetentionDays: 30}); err == nil {
if err := store.CreateMetric(ctx, Definition{Name: "http.latency", Type: TypeGauge}); err == nil {
t.Fatalf("duplicate CreateMetric should fail")
}
if err := store.UpsertMetric(ctx, Definition{Name: "http.latency", Type: TypeGauge, Unit: "milliseconds", RetentionDays: 30}); err != nil {
if err := store.UpsertMetric(ctx, Definition{Name: "http.latency", Type: TypeGauge, Unit: "milliseconds"}); err != nil {
t.Fatalf("upsert metric: %v", err)
}
def, err := store.GetMetric(ctx, "http.latency")
@@ -166,20 +166,6 @@ func runSQLIntegration(t *testing.T, name string, cfg Config, expectSQLPercentil
if deleted != 2 {
t.Fatalf("expected 2 deleted points, got %d", deleted)
}
storageSize, err := store.StorageSize(ctx)
if err != nil {
t.Fatalf("storage size: %v", err)
}
if storageSize <= 0 {
t.Fatalf("storage size = %d, want a positive value", storageSize)
}
if err := store.ReclaimSpace(ctx); err != nil {
t.Fatalf("reclaim storage space: %v", err)
}
if err := store.Ping(ctx); err != nil {
t.Fatalf("store unusable after space reclaim: %v", err)
}
}
// dropIntegrationTables drops integration-test tables.
+6 -43
View File
@@ -89,30 +89,6 @@ func (p RollupPolicy) rawCutoff(now time.Time) time.Time {
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.
// Coarser tiers that do not extend retention are omitted because their buckets
// can be derived from the retained finer tier on demand.
func (p RollupPolicy) withMetricRetention(retention time.Duration) RollupPolicy {
if retention <= 0 || len(p.Tiers) == 0 {
return p
}
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 {
tier.Retention = retention
}
if len(out.Tiers) > 0 && tier.Retention <= out.Tiers[len(out.Tiers)-1].Retention {
continue
}
out.Tiers = append(out.Tiers, tier)
}
return out
}
// Validate enforces the structural rules that make cascading composition and
// retention well-defined.
//
@@ -221,22 +197,13 @@ type rollupBucket struct {
//
// newRollupBucket 创建一个空的 rollup 累加器。
func newRollupBucket(compression float64) *rollupBucket {
return newRollupBucketWithDigest(compression, true)
}
// newRollupBucketWithDigest creates a query or compaction accumulator. Query
// paths only need the digest for percentile aggregations.
func newRollupBucketWithDigest(compression float64, includeDigest bool) *rollupBucket {
bucket := &rollupBucket{
return &rollupBucket{
min: 0,
max: 0,
firstTS: 0,
lastTS: 0,
digest: NewTDigest(compression),
}
if includeDigest {
bucket.digest = NewTDigest(compression)
}
return bucket
}
// addPoint folds a raw observation into the bucket.
@@ -264,9 +231,7 @@ func (b *rollupBucket) addPoint(value float64, tsNano int64) {
b.count++
b.sum += value
b.sumSq += value * value
if b.digest != nil {
b.digest.Add(value, 1)
}
b.digest.Add(value, 1)
}
// mergeStored folds a finer rollup row (already-summarized) into this coarser
@@ -300,12 +265,10 @@ func (b *rollupBucket) mergeStored(o *rollupBucket) {
b.count += o.count
b.sum += o.sum
b.sumSq += o.sumSq
if o.digest != nil {
if b.digest == nil {
b.digest = NewTDigest(defaultTDigestCompression)
}
b.digest.Merge(o.digest)
if b.digest == nil {
b.digest = NewTDigest(defaultTDigestCompression)
}
b.digest.Merge(o.digest)
}
// value computes the requested aggregation from the bucket summaries. ok=false
+2 -51
View File
@@ -29,7 +29,7 @@ func TestSeriesHybridKeepsUncompactedRawNearCutoff(t *testing.T) {
Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "nearcut", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "nearcut", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
now := time.Date(2026, 6, 18, 12, 0, 30, 0, time.UTC)
@@ -94,7 +94,7 @@ func TestSeriesHybridEndEqualsCutoff(t *testing.T) {
Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "endcut", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "endcut", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
@@ -133,52 +133,3 @@ func TestSeriesHybridEndEqualsCutoff(t *testing.T) {
t.Fatalf("expected 11:00 bucket including the cutoff raw point: %#v", got)
}
}
// 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) {
ctx := context.Background()
policy := RollupPolicy{
RawRetention: 30 * time.Minute,
Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "watermark", Type: TypeGauge, RetentionDays: 30}); err != nil {
t.Fatalf("create: %v", err)
}
compactAt := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
if err := s.WriteBatch(ctx, []Point{
{MetricName: "watermark", EntityID: "n1", Timestamp: time.Date(2026, 6, 18, 11, 20, 0, 0, time.UTC), Value: 10},
{MetricName: "watermark", EntityID: "n1", Timestamp: time.Date(2026, 6, 18, 11, 32, 0, 0, time.UTC), Value: 100},
}); err != nil {
t.Fatalf("write: %v", err)
}
if _, err := s.Compact(ctx, compactAt); err != nil {
t.Fatalf("compact: %v", err)
}
queryAt := compactAt.Add(4 * time.Minute)
got, err := s.Series(ctx, AggregateQuery{
Query: Query{MetricName: "watermark", EntityID: "n1", Start: time.Date(2026, 6, 18, 11, 0, 0, 0, time.UTC), End: queryAt},
Aggregation: AggCount,
Interval: time.Hour,
}, queryAt)
if err != nil {
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))
}
}
+7 -47
View File
@@ -23,7 +23,7 @@ func TestAggregateRollupExcludesPartialBucket(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}}}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "win", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "win", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
@@ -72,7 +72,7 @@ func TestAggregateRollupEndBoundaryInclusive(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}}}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "edge", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "edge", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
@@ -123,7 +123,7 @@ func TestSeriesHybridMergesStraddlingBucket(t *testing.T) {
Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "straddle", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "straddle", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
@@ -192,7 +192,7 @@ func TestSeriesHybridSumAcrossBoundary(t *testing.T) {
Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "sumstraddle", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "sumstraddle", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
@@ -236,7 +236,7 @@ func TestSeriesHybridIncludesRawBetweenCutoffAndNextBucket(t *testing.T) {
Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "cutgap", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "cutgap", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
@@ -273,7 +273,7 @@ func TestSeriesHybridEndAtCutoffIncludesRawBoundary(t *testing.T) {
Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "cutedge", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "cutedge", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
@@ -310,7 +310,7 @@ func TestCompactDoesNotDoubleCountRawBucketAfterCutoffAdvances(t *testing.T) {
Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "nodup", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "nodup", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
@@ -345,43 +345,3 @@ func TestCompactDoesNotDoubleCountRawBucketAfterCutoffAdvances(t *testing.T) {
t.Fatalf("compaction should not double-count a bucket after cutoff advances, got %#v", got)
}
}
func TestSeriesHybridIncludesPartialCoarseBucketAtRawCutoff(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{
RawRetention: 10 * time.Minute,
Tiers: []RollupTier{
{Interval: time.Minute, Retention: 20 * time.Minute},
{Interval: time.Hour, Retention: 24 * time.Hour},
},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "coarse-cutoff", Type: TypeGauge, RetentionDays: 30}); err != nil {
t.Fatalf("create: %v", err)
}
now := time.Date(2026, 6, 18, 12, 50, 0, 0, time.UTC)
old := now.Add(-40 * time.Minute)
recent := now.Add(-5 * time.Minute)
if err := s.WriteBatch(ctx, []Point{
{MetricName: "coarse-cutoff", EntityID: "n1", Timestamp: old, Value: 10},
{MetricName: "coarse-cutoff", EntityID: "n1", Timestamp: recent, Value: 100},
}); err != nil {
t.Fatalf("write: %v", err)
}
if _, err := s.Compact(ctx, now); err != nil {
t.Fatalf("compact: %v", err)
}
got, err := s.Series(ctx, AggregateQuery{
Query: Query{MetricName: "coarse-cutoff", EntityID: "n1", Start: now.Add(-2 * time.Hour), End: now},
Aggregation: AggAvg,
Interval: time.Hour,
}, now)
if err != nil {
t.Fatalf("series: %v", err)
}
if len(got) != 1 || got[0].Count != 2 || math.Abs(got[0].Value-55) > 1e-9 {
t.Fatalf("expected coarse rollup and recent raw point, got %#v", got)
}
}
+112 -245
View File
@@ -17,8 +17,9 @@ import (
//
// 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.
// points (each distinct tag combination is its own rollup series). With no tag
// filter, all tag series within a bucket merge together, matching the raw path's
// cross-tag aggregation.
//
// Every aggregation works except AggRate, which needs the ordered raw series and
// is therefore raw-only. Percentiles (p50, p95, p99, and arbitrary pXX) are
@@ -31,8 +32,9 @@ import (
// rollup 桶组成。
//
// query.Tags 会被遵守:只有存储标签集合匹配过滤条件的 rollup 序列会被合入,
// 因此标签过滤会选中与原始点查询相同的数据。默认会把匹配序列合并进每个
// 输出桶;需要保留 entity/tag 身份的调用方可以设置 PreserveSeries。
// 因此标签过滤会选中与原始点查询相同的数据(每个不同标签组合都是自己的
// rollup 序列)。没有标签过滤时,同一桶内的所有标签序列会合并在一起,
// 与原始路径的跨标签聚合一致。
//
// 除 AggRate 外,每种聚合都可用;AggRate 需要有序原始序列,因此只能基于原始点。
// 百分位(p50、p95、p99 和任意 pXX)通过合并每桶 t-digest 回答,因此能在
@@ -56,7 +58,6 @@ func (s *Store) AggregateRollup(ctx context.Context, query AggregateQuery, resol
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),
@@ -67,11 +68,11 @@ func (s *Store) AggregateRollup(ctx context.Context, query AggregateQuery, resol
// 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.scanRollupRowsContained(ctx, q.MetricName, q.EntityID, q.Tags, resolution, q.Start, q.End)
if err != nil {
return nil, err
}
groups := foldRollupRows(nil, rows, query.Interval, comp, query.PreserveSeries, needDigest)
groups := foldRollupRows(nil, rows, query.Interval, comp)
out, err := rollupGroupsToPoints(groups, query)
if err != nil {
@@ -82,55 +83,36 @@ func (s *Store) AggregateRollup(ctx context.Context, query AggregateQuery, resol
// rollupGroupsToPoints turns the merged output buckets into ordered
// AggregatePoints, computing the requested aggregation from each bucket's
// summaries/digest.
// summaries/digest. FillEmpty emits zero-count buckets for gaps, mirroring the
// raw AggregatePoints behavior.
//
// rollupGroupsToPoints 将合并后的输出桶转换为有序 AggregatePoint,并根据
// 每个桶的摘要或 digest 计算请求的聚合。
func rollupGroupsToPoints(groups map[rollupKey]*rollupBucket, query AggregateQuery) ([]AggregatePoint, error) {
if !query.PreserveSeries {
return mergedRollupGroupsToPoints(groups, query)
}
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)
// 每个桶的摘要或 digest 计算请求的聚合。FillEmpty 会为空洞输出零计数桶,
// 与原始 AggregatePoints 行为一致。
func rollupGroupsToPoints(groups map[int64]*rollupBucket, query AggregateQuery) ([]AggregatePoint, error) {
var bucketStarts []int64
if query.FillEmpty {
for t := alignTime(query.Start, query.Interval); !t.After(query.End); t = t.Add(query.Interval) {
bucketStarts = append(bucketStarts, t.UnixNano())
}
tags, err := rollupTagsFromJSON(b.tagsJSON)
if err != nil {
return nil, err
} else {
for k := range groups {
bucketStarts = append(bucketStarts, k)
}
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,
})
sort.Slice(bucketStarts, func(i, j int) bool { return bucketStarts[i] < bucketStarts[j] })
}
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]
out := make([]AggregatePoint, 0, len(bucketStarts))
for _, start := range bucketStarts {
b := groups[start]
if b == nil {
out = append(out, AggregatePoint{
MetricName: query.MetricName,
EntityID: query.EntityID,
Bucket: time.Unix(0, start).UTC(),
})
continue
}
v, ok := b.value(query.Aggregation)
if !ok {
return nil, fmt.Errorf("%w: aggregation %q not supported over rollups", ErrInvalidArgument, query.Aggregation)
@@ -138,10 +120,9 @@ func mergedRollupGroupsToPoints(groups map[rollupKey]*rollupBucket, query Aggreg
out = append(out, AggregatePoint{
MetricName: query.MetricName,
EntityID: query.EntityID,
Bucket: time.Unix(0, key.bucket).UTC(),
Bucket: time.Unix(0, start).UTC(),
Value: v,
Count: int(b.count),
Tags: cloneStringMap(tags),
})
}
return out, nil
@@ -168,7 +149,7 @@ func mergedRollupGroupsToPoints(groups map[rollupKey]*rollupBucket, query Aggreg
// 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) {
func (s *Store) scanRollupRowsContained(ctx context.Context, metricName, entityID string, tags map[string]string, resolution time.Duration, start, end time.Time) ([]storedRollup, error) {
resNano := resolution.Nanoseconds()
startNano := start.UTC().UnixNano()
endNano := end.UTC().UnixNano()
@@ -180,7 +161,7 @@ func (s *Store) scanRollupRowsContained(ctx context.Context, metricName, entityI
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)
rows, err := s.scanRollupRowsBetween(ctx, metricName, entityID, tags, resNano, lower, upper)
if err != nil {
return nil, err
}
@@ -196,7 +177,7 @@ func (s *Store) scanRollupRowsContained(ctx context.Context, metricName, entityI
// 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) {
func (s *Store) scanRollupRowsBetween(ctx context.Context, metricName, entityID string, tags map[string]string, resNano, lowerBucket, upperBucket int64) ([]storedRollup, error) {
args := []any{metricName, resNano, lowerBucket, upperBucket}
parts := []string{
"metric_name = " + s.dialect.placeholder(1),
@@ -212,42 +193,38 @@ func (s *Store) scanRollupRowsBetween(ctx context.Context, metricName, entityID
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 "))
sqlText := fmt.Sprintf(
`SELECT entity_id, tags_hash, tags, bucket_nano, count, sum, sum_sq, min_val, max_val, first_val, first_ts, last_val, last_ts, digest
FROM %s WHERE %s ORDER BY bucket_nano ASC`,
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)
return scanStoredRollups(rows)
}
// 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 folds stored rollup rows into interval-wide output buckets,
// merging summaries (including the t-digest) per bucket. 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 {
// foldRollupRows 将存储的 rollup 行折叠进 interval 宽的输出桶,按桶合并摘要
// (包括 t-digest)。groups 可为 nil,此时会分配新 map;传入已有 map 可让调用方
// 把 rollup 和 raw 的贡献累加到同一批输出桶中。
func foldRollupRows(groups map[int64]*rollupBucket, rows []storedRollup, interval time.Duration, comp float64) map[int64]*rollupBucket {
if groups == nil {
groups = make(map[rollupKey]*rollupBucket)
groups = make(map[int64]*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]
b := groups[bkt]
if b == nil {
b = newRollupBucketWithDigest(comp, needDigest)
b.tagsHash = r.bucketData.tagsHash
b.tagsJSON = r.bucketData.tagsJSON
groups[key] = b
b = newRollupBucket(comp)
groups[bkt] = b
}
b.mergeStored(r.bucketData)
}
@@ -263,69 +240,34 @@ func foldRollupRows(groups map[rollupKey]*rollupBucket, rows []storedRollup, int
// 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) {
func foldRawPoints(groups map[int64]*rollupBucket, points []Point, interval time.Duration, comp float64) map[int64]*rollupBucket {
if groups == nil {
groups = make(map[rollupKey]*rollupBucket)
groups = make(map[int64]*rollupBucket)
}
size := interval.Nanoseconds()
for _, p := range points {
p = p.normalized()
tagsHash, tagsJSON, err := tagsFingerprint(p.Tags)
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]
b := groups[bkt]
if b == nil {
b = newRollupBucketWithDigest(comp, needDigest)
b.tagsHash = tagsHash
b.tagsJSON = tagsJSON
groups[key] = b
b = newRollupBucket(comp)
groups[bkt] = b
}
b.addPoint(p.Value, ts)
}
return groups, nil
return groups
}
func foldedRollupKey(entityID, tagsHash string, bucket int64, preserveSeries bool) rollupKey {
if !preserveSeries {
return rollupKey{bucket: bucket}
}
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 err != nil {
return nil, err
}
return cloneStringMap(tags), nil
}
// scanStoredRollups reconstructs storedRollup rows from a result set whose
// columns are, in order:
// scanStoredRollups reconstructs storedRollup rows (including tag identity and
// the decoded t-digest) 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.
// first_val, first_ts, last_val, last_ts, digest.
//
// scanStoredRollups 从结果集中还原 storedRollup 行。结果集的列顺序必须是:
// entity_id、tags_hash、tags、bucket_nano、
// scanStoredRollups 从结果集中还原 storedRollup 行(包括标签身份和解码后的
// t-digest)。结果集的列顺序必须是: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) {
// last_ts、digest。
func scanStoredRollups(rows *sql.Rows) ([]storedRollup, error) {
var out []storedRollup
for rows.Next() {
var (
@@ -338,20 +280,12 @@ func scanStoredRollups(rows *sql.Rows, needDigest bool) ([]storedRollup, error)
firstTS, lastTS int64
digestBlob []byte
)
scanArgs := []any{&eid, &tagsHash, &rawTags, &bucketNano, &count, &sum, &sumSq, &minV, &maxV, &firstV, &firstTS, &lastV, &lastTS}
if needDigest {
scanArgs = append(scanArgs, &digestBlob)
}
if err := rows.Scan(scanArgs...); err != nil {
if err := rows.Scan(&eid, &tagsHash, &rawTags, &bucketNano, &count, &sum, &sumSq, &minV, &maxV, &firstV, &firstTS, &lastV, &lastTS, &digestBlob); err != nil {
return nil, err
}
var digest *TDigest
if needDigest {
var err error
digest, err = DecodeTDigest(digestBlob)
if err != nil {
return nil, err
}
td, err := DecodeTDigest(digestBlob)
if err != nil {
return nil, err
}
tagsJSON, err := rawTagsToJSON(rawTags)
if err != nil {
@@ -365,7 +299,7 @@ func scanStoredRollups(rows *sql.Rows, needDigest bool) ([]storedRollup, error)
min: minV, max: maxV,
firstVal: firstV, firstTS: firstTS,
lastVal: lastV, lastTS: lastTS,
digest: digest,
digest: td,
tagsHash: tagsHash,
tagsJSON: tagsJSON,
},
@@ -398,68 +332,6 @@ func rawTagsToJSON(v any) (string, error) {
}
}
// 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 (s *Store) CompatibleSeriesInterval(start, now time.Time, interval time.Duration) time.Duration {
policy := s.cfg.RollupPolicy
if interval <= 0 || !policy.Enabled() {
return interval
}
start = start.UTC()
now = now.UTC()
rawCutoff := policy.rawCutoff(now)
if rawCutoff.IsZero() || !start.Before(rawCutoff) {
return interval
}
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 fallback != nil {
if interval < fallback.Interval {
return fallback.Interval
}
if remainder := interval % fallback.Interval; remainder != 0 {
return interval + fallback.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) {
return tier
}
}
return fallback
}
// Series answers an AggregateQuery by transparently choosing the best data
// source for the requested window, given `now`:
//
@@ -471,9 +343,8 @@ func bestRollupTier(policy RollupPolicy, interval time.Duration, start, now time
// - 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.
// - If no tier qualifies, it falls back to raw (which may be incomplete for
// data already aged out) so the call still returns its best answer.
//
// 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
@@ -489,9 +360,8 @@ func bestRollupTier(policy RollupPolicy, interval time.Duration, start, now time
// query.Interval,且 (b) 保留时间能覆盖到窗口起点,然后从该层服务查询。
// - 如果查询跨越原始数据保留期边界,它使用混合方式:读取旧部分的 rollup
// 和最近部分的原始点,然后合并结果,避免丢掉未 compact 的最近数据。
// - 如果起点早于所有层级的保留窗口,它会读取保留时间最长的兼容层级,返回请求
// 窗口与实际保留数据的交集。
// - 只有没有任何层级与输出间隔兼容时才回退到原始点。
// - 如果没有层级符合条件,它会回退到原始点(对于已经过期的数据可能不完整),
// 让调用仍返回当前能给出的最佳答案。
//
// query.Tags 在两条分支上都会被遵守:原始路径已按标签过滤,rollup 路径会按
// 存储的标签集合过滤,因此无论由哪个数据源回答,标签过滤都会选择相同序列。
@@ -512,10 +382,6 @@ func (s *Store) Series(ctx context.Context, query AggregateQuery, now time.Time)
now = now.UTC()
rawCutoff := policy.rawCutoff(now)
watermark, hasWatermark, err := s.compactionWatermark(ctx, q.MetricName)
if err != nil {
return nil, err
}
// Whole window inside raw retention (or raw kept forever) -> raw.
if rawCutoff.IsZero() || !q.Start.Before(rawCutoff) {
@@ -527,25 +393,29 @@ func (s *Store) Series(ctx context.Context, query AggregateQuery, now time.Time)
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(rawCutoff) && !q.End.Before(rawCutoff) {
for _, tier := range policy.Tiers { // finest-first
if query.Interval < tier.Interval || query.Interval%tier.Interval != 0 {
continue
}
if !q.Start.Before(boundary) {
return s.Aggregate(ctx, query)
if now.Add(-tier.Retention).After(q.Start) {
continue
}
return s.seriesHybrid(ctx, query, boundary, servicingTier)
return s.seriesHybrid(ctx, query, rawCutoff, &tier)
}
}
// 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)
// Find the finest tier that can serve the entire window from the start.
var servicingTier *RollupTier
for _, tier := range policy.Tiers { // finest-first
if query.Interval < tier.Interval || query.Interval%tier.Interval != 0 {
continue
}
if now.Add(-tier.Retention).After(q.Start) {
continue // tier doesn't reach back to the window start
}
servicingTier = &tier
break
}
if servicingTier == nil {
@@ -568,10 +438,9 @@ func (s *Store) Series(ctx context.Context, query AggregateQuery, now time.Time)
// 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.
// To avoid double counting or gaps, the caller passes a boundary already aligned
// to a rollup-resolution bucket. The rollup half supplies whole buckets strictly
// before the boundary and the raw half supplies points at or after it.
//
// seriesHybrid 回答跨越原始保留期边界的查询:读取旧部分的 rollup 和近期部分的
// 原始点,并在归约前把两者折叠进同一批 query.Interval 宽的输出桶。
@@ -582,32 +451,32 @@ func (s *Store) Series(ctx context.Context, query AggregateQuery, now time.Time)
// 结果却只剩 raw 样本,count/avg 错误)。把两半折叠进同一个 rollupBucket 即可修复:
// count、sum/avg、min/max 和百分位都在两半的并集上计算。
//
// 包含 raw 截止点的 rollup 桶可能尚未完整;它只包含截止点之前已压缩的数据。读取
// 该桶,再合入截止点及之后的 raw 点,可以在粗粒度层级上避免重叠和缺口。
// 为避免重复计数或留空,调用方传入的边界已经对齐到 rollup 分辨率桶。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)
groups := make(map[int64]*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)
// Old half: rollup buckets fully contained in [Start, min(splitAt, End]].
// "Fully before splitAt" is bucket <= splitAt-resNano (splitAt is aligned);
// also clamp to the query's own inclusive end so a bucket past End is never
// pulled in when splitAt overshoots a short window.
upperBucket := splitAt - resNano
if clamp := endNano - resNano + 1; clamp < upperBucket {
upperBucket = clamp
}
if upperBucket >= startNano {
rows, err := s.scanRollupRowsBetween(ctx, q.MetricName, q.EntityID, q.Tags, resNano, startNano, upperBucket, needDigest)
rows, err := s.scanRollupRowsBetween(ctx, q.MetricName, q.EntityID, q.Tags, resNano, startNano, upperBucket)
if err != nil {
return nil, err
}
foldRollupRows(groups, rows, query.Interval, comp, query.PreserveSeries, needDigest)
foldRollupRows(groups, rows, query.Interval, comp)
}
// Recent half: raw points in [splitAt, End]. Reuse the raw Query path so the
@@ -623,9 +492,7 @@ func (s *Store) seriesHybridWithRollupEnd(ctx context.Context, query AggregateQu
if err != nil {
return nil, err
}
if _, err := foldRawPoints(groups, points, query.Interval, comp, query.PreserveSeries, needDigest); err != nil {
return nil, err
}
foldRawPoints(groups, points, query.Interval, comp)
}
out, err := rollupGroupsToPoints(groups, query)
+167 -182
View File
@@ -3,30 +3,32 @@ package metric
import (
"context"
"database/sql"
"errors"
"fmt"
"sort"
"strings"
"time"
)
// Compact runs the store's RollupPolicy over every metric: it advances the
// rollup tiers to `now` and enforces every retention window (raw points and each
// tier). With finite raw retention, only newly eligible and late raw points are
// propagated. It is safe to call repeatedly and is idempotent for unchanged
// windows.
// Compact runs the store's RollupPolicy over every metric: it rebuilds the
// rollup tiers up to `now` and enforces every retention window (raw points and
// each tier). It is the explicit maintenance entry point — call it from a cron
// job or scheduler. It is safe to call repeatedly and is idempotent for
// unchanged windows.
//
// Returns the number of rollup buckets written across all tiers and metrics.
//
// Compact 会对所有指标执行 Store 的 RollupPolicy:它会把 rollup 层推进到
// `now`,并执行每个保留窗口(原始点和各层级)。原始点保留期有限时,只会级联
// 本轮新到期及迟到的原始点。它可以重复安全调用,未变化窗口保持幂等。
// Compact 会对所有指标执行 Store 的 RollupPolicy:它会重建截至 `now` 的
// rollup 层,并执行每个保留窗口(原始点和各层级)。这是显式维护入口,
// 应从 cron 任务或调度器调用。它可以重复安全调用,未变化窗口保持幂等。
//
// 返回所有指标、所有层级中写入的 rollup 桶数量。
func (s *Store) Compact(ctx context.Context, now time.Time) (int, error) {
if err := s.ensureOpen(); err != nil {
return 0, err
}
if !s.cfg.RollupPolicy.Enabled() {
return 0, nil
}
defs, err := s.ListMetrics(ctx)
if err != nil {
return 0, err
@@ -42,10 +44,10 @@ func (s *Store) Compact(ctx context.Context, now time.Time) (int, error) {
return total, nil
}
// CompactMetric compacts a single metric. With finite raw retention it consumes
// newly eligible raw points as a delta and propagates that delta through every
// tier. When raw is retained forever it falls back to rebuilding tiers. It then
// deletes data that has aged out of each retention window.
// CompactMetric compacts a single metric. It builds the finest tier from raw
// points, then each coarser tier from the tier below it, upserts the resulting
// buckets, and finally deletes data that has aged out of each retention window
// (raw points first, then each tier).
//
// The entire compaction is performed within a SERIALIZABLE transaction to
// guarantee that the raw scan and the raw deletion observe the same snapshot.
@@ -63,9 +65,9 @@ func (s *Store) Compact(ctx context.Context, now time.Time) (int, error) {
// combination (e.g. a GPU device_index) is summarized into its own series and
// can be queried independently after the raw points are gone.
//
// CompactMetric 会压缩单个指标。原始点保留期有限时,它把本轮新到期的原始点
// 作为增量逐层传播;原始点永久保留时则回退到全量重建。最后删除各保留窗口中
// 已过期的数据。
// CompactMetric 会压缩单个指标。它先由原始点构建最细层,再由下一层之下的
// 层级逐层合成更粗层,upsert 生成的桶,最后删除每个保留窗口中过期的数据
// (先删除原始点,再删除各层级)。
//
// 整个 compaction 在一个 SERIALIZABLE 事务内执行,以保证 raw 扫描和 raw 删除
// 看到同一个快照。否则在 PostgreSQL/MySQL 的默认隔离级别下,扫描之后、删除
@@ -78,26 +80,10 @@ func (s *Store) CompactMetric(ctx context.Context, metricName string, now time.T
if err := s.ensureOpen(); err != nil {
return 0, err
}
s.retentionMu.RLock()
defer s.retentionMu.RUnlock()
policy := s.cfg.RollupPolicy
def, err := s.GetMetric(ctx, metricName)
if errors.Is(err, ErrNotFound) {
return 0, nil
}
if err != nil {
return 0, err
}
if def.RetentionDays == 0 {
_, err := s.DeleteSeries(ctx, Query{MetricName: metricName})
return 0, err
}
if !policy.Enabled() {
return 0, nil
}
effectivePolicy := policy.withMetricRetention(time.Duration(def.RetentionDays) * 24 * time.Hour)
obsoleteIntervals := rollupIntervalsOutsidePolicy(policy.Tiers, effectivePolicy.Tiers)
policy = effectivePolicy
now = now.UTC()
// Retry the whole compaction on a transient serialization/deadlock failure.
@@ -107,7 +93,7 @@ func (s *Store) CompactMetric(ctx context.Context, metricName string, now time.T
const maxAttempts = 5
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
written, err := s.compactMetricOnce(ctx, metricName, now, policy, obsoleteIntervals)
written, err := s.compactMetricOnce(ctx, metricName, now)
if err == nil {
return written, nil
}
@@ -122,7 +108,7 @@ func (s *Store) CompactMetric(ctx context.Context, metricName string, now time.T
// compactMetricOnce runs a single compaction attempt inside one transaction.
//
// compactMetricOnce 在单个事务内执行一次 compaction 尝试。
func (s *Store) compactMetricOnce(ctx context.Context, metricName string, now time.Time, policy RollupPolicy, obsoleteIntervals []time.Duration) (int, error) {
func (s *Store) compactMetricOnce(ctx context.Context, metricName string, now time.Time) (int, error) {
// Use a transaction to ensure consistency between raw scan, rollup write, and
// raw deletion. The isolation level is backend-specific (SERIALIZABLE on
// PostgreSQL/MySQL, default on SQLite) so late-arriving points cannot be
@@ -133,16 +119,10 @@ func (s *Store) compactMetricOnce(ctx context.Context, metricName string, now ti
}
defer func() { _ = tx.Rollback() }()
if err := s.deleteRollupsForIntervalsTx(ctx, metricName, obsoleteIntervals, tx); err != nil {
return 0, err
}
written, err := s.compactMetricWithinTx(ctx, metricName, now, policy, tx)
written, err := s.compactMetricWithinTx(ctx, metricName, now, tx)
if err != nil {
return written, err
}
if err := s.persistCompactionWatermarkTx(ctx, metricName, policy.rawCutoff(now), tx); err != nil {
return written, err
}
if err := tx.Commit(); err != nil {
return written, err
@@ -150,20 +130,6 @@ func (s *Store) compactMetricOnce(ctx context.Context, metricName string, now ti
return written, nil
}
func rollupIntervalsOutsidePolicy(configured, effective []RollupTier) []time.Duration {
active := make(map[time.Duration]struct{}, len(effective))
for _, tier := range effective {
active[tier.Interval] = struct{}{}
}
obsolete := make([]time.Duration, 0, len(configured))
for _, tier := range configured {
if _, ok := active[tier.Interval]; !ok {
obsolete = append(obsolete, tier.Interval)
}
}
return obsolete
}
// isRetryableSerializationError reports whether err is a transient
// serialization failure or deadlock that should be retried on a fresh
// transaction. It matches on portable SQLSTATE codes and driver error text so
@@ -206,49 +172,11 @@ func isRetryableSerializationError(err error) bool {
// compactMetricWithinTx is the actual compaction logic, meant to be called
// within a transaction. The tx parameter is passed explicitly to all operations
// that need transactional consistency.
func (s *Store) compactMetricWithinTx(ctx context.Context, metricName string, now time.Time, policy RollupPolicy, tx *sql.Tx) (int, error) {
func (s *Store) compactMetricWithinTx(ctx context.Context, metricName string, now time.Time, tx *sql.Tx) (int, error) {
policy := s.cfg.RollupPolicy
if !policy.Enabled() {
return 0, nil
}
if policy.RawRetention > 0 {
return s.compactMetricIncrementalWithinTx(ctx, metricName, now, policy, tx)
}
return s.compactMetricFullWithinTx(ctx, metricName, now, policy, tx)
}
// compactMetricIncrementalWithinTx rolls only raw points crossing the hot-data
// cutoff and propagates that delta through every coarser tier. Because those raw
// points are deleted in the same transaction, each sample is merged exactly
// once, including late samples for buckets that already exist.
func (s *Store) compactMetricIncrementalWithinTx(ctx context.Context, metricName string, now time.Time, policy RollupPolicy, tx *sql.Tx) (int, error) {
comp := policy.compression()
rawCutoff := policy.rawCutoff(now)
delta, err := s.buildFinestTierBefore(ctx, tx, metricName, policy.Tiers[0].Interval, comp, rawCutoff)
if err != nil {
return 0, err
}
written := 0
for tierIdx, tier := range policy.Tiers {
if tierIdx > 0 {
delta = buildCoarserBucketsFromDelta(delta, tier.Interval, comp)
}
n, err := s.mergeRollupBucketsTx(ctx, metricName, tier.Interval, delta, tx)
if err != nil {
return written, err
}
written += n
}
if err := s.enforceRetentionWithinTx(ctx, metricName, now, policy, tx); err != nil {
return written, err
}
return written, nil
}
// compactMetricFullWithinTx retains the rebuild behavior required when raw
// points are kept forever and therefore cannot act as a consumed delta queue.
func (s *Store) compactMetricFullWithinTx(ctx context.Context, metricName string, now time.Time, policy RollupPolicy, tx *sql.Tx) (int, error) {
comp := policy.compression()
written := 0
@@ -306,52 +234,24 @@ func (s *Store) compactMetricFullWithinTx(ctx context.Context, metricName string
prevDelta = currentDelta
}
if err := s.enforceRetentionWithinTx(ctx, metricName, now, policy, tx); err != nil {
return written, err
}
return written, nil
}
// enforceRetentionWithinTx trims raw and rollup tiers after coarser data has
// been materialized. Cutoffs retain a bucket that straddles the boundary.
func (s *Store) enforceRetentionWithinTx(ctx context.Context, metricName string, now time.Time, policy RollupPolicy, tx *sql.Tx) error {
// Enforce retention windows after all tiers are materialized, so a coarser
// tier is always built before the finer source it depends on is trimmed.
if policy.RawRetention > 0 {
if _, err := s.DeleteBeforeTx(ctx, metricName, policy.rawCutoff(now), tx); err != nil {
return err
cutoff := policy.rawCutoff(now)
if _, err := s.DeleteBeforeTx(ctx, metricName, cutoff, tx); err != nil {
return written, err
}
}
for i, tier := range policy.Tiers {
alignment := tier.Interval
cutoff := now.Add(-tier.Retention)
if i+1 < len(policy.Tiers) {
alignment = policy.Tiers[i+1].Interval
cutoff = alignRollupRetentionCutoff(cutoff, policy.Tiers[i+1].Interval)
}
cutoff := alignRollupRetentionCutoff(now.Add(-tier.Retention), alignment)
if err := s.deleteRollupsBeforeTx(ctx, metricName, tier.Interval, cutoff, tx); err != nil {
return err
return written, err
}
}
return nil
}
// deleteRollupsForIntervalsTx removes rows from resolutions that are no longer
// part of a metric's effective retention policy.
func (s *Store) deleteRollupsForIntervalsTx(ctx context.Context, metricName string, intervals []time.Duration, tx *sql.Tx) error {
if len(intervals) == 0 {
return nil
}
args := make([]any, 1, len(intervals)+1)
args[0] = metricName
placeholders := make([]string, len(intervals))
for i, interval := range intervals {
placeholders[i] = s.dialect.placeholder(i + 2)
args = append(args, interval.Nanoseconds())
}
sqlText := fmt.Sprintf(
`DELETE FROM %s WHERE metric_name = %s AND resolution_nano IN (%s)`,
s.tables.rollups, s.dialect.placeholder(1), strings.Join(placeholders, ", "),
)
_, err := tx.ExecContext(ctx, sqlText, args...)
return err
return written, nil
}
func alignRollupRetentionCutoff(cutoff time.Time, nextInterval time.Duration) time.Time {
@@ -388,25 +288,13 @@ type rollupKey struct {
// buildFinestTier 扫描某指标的原始点,并按给定 interval 分桶,key 为
// (实体、标签集合、桶起点)。每个点的标签 map 决定它属于哪条序列。
func (s *Store) buildFinestTier(ctx context.Context, q querier, metricName string, interval time.Duration, comp float64) (map[rollupKey]*rollupBucket, error) {
return s.buildFinestTierBefore(ctx, q, metricName, interval, comp, time.Time{})
}
// buildFinestTierBefore aggregates only raw points older than before. A zero
// cutoff preserves the full-rebuild behavior used when raw is retained forever.
func (s *Store) buildFinestTierBefore(ctx context.Context, q querier, metricName string, interval time.Duration, comp float64, before time.Time) (map[rollupKey]*rollupBucket, error) {
size := interval.Nanoseconds()
out := make(map[rollupKey]*rollupBucket)
args := []any{metricName}
where := "metric_name = " + s.dialect.placeholder(1)
if !before.IsZero() {
args = append(args, before.UTC().UnixNano())
where += " AND ts_nano < " + s.dialect.placeholder(2)
}
sqlText := fmt.Sprintf(
`SELECT entity_id, ts_nano, value, tags FROM %s WHERE %s ORDER BY ts_nano ASC`,
s.tables.points, where,
`SELECT entity_id, ts_nano, value, tags FROM %s WHERE metric_name = %s ORDER BY ts_nano ASC`,
s.tables.points, s.dialect.placeholder(1),
)
rows, err := q.QueryContext(ctx, sqlText, args...)
rows, err := q.QueryContext(ctx, sqlText, metricName)
if err != nil {
return nil, err
@@ -530,21 +418,30 @@ func (s *Store) scanRollupRows(ctx context.Context, q querier, metricName string
return nil, err
}
defer rows.Close()
return scanStoredRollups(rows, true)
return scanStoredRollups(rows)
}
// mergeRollupBucketsTx merges a consumed raw-data delta into the affected
// rollup buckets. Existing buckets are read by their indexed primary key, so a
// compact run performs work proportional to new/late data rather than to the
// complete retained history.
func (s *Store) mergeRollupBucketsTx(ctx context.Context, metricName string, interval time.Duration, buckets map[rollupKey]*rollupBucket, tx *sql.Tx) (int, error) {
// writeRollupBuckets upserts a set of computed buckets for one resolution.
//
// writeRollupBuckets 将某个分辨率下计算出的 rollup 桶批量 upsert 到数据库。
func (s *Store) writeRollupBuckets(ctx context.Context, metricName string, interval time.Duration, buckets map[rollupKey]*rollupBucket) (int, error) {
return s.writeRollupBucketsWithMergePoint(ctx, metricName, interval, buckets, time.Time{})
}
// writeRollupBucketsTx upserts buckets within an existing transaction.
func (s *Store) writeRollupBucketsTx(ctx context.Context, metricName string, interval time.Duration, buckets map[rollupKey]*rollupBucket, tx *sql.Tx) (int, error) {
return s.writeRollupBucketsWithMergePointTx(ctx, metricName, interval, buckets, time.Time{}, time.Time{}, nil, tx)
}
// writeRollupBucketsWithMergePoint is the internal implementation that optionally
// merges buckets older than a cutoff point instead of replacing them.
func (s *Store) writeRollupBucketsWithMergePoint(ctx context.Context, metricName string, interval time.Duration, buckets map[rollupKey]*rollupBucket, mergeCutoff time.Time) (int, error) {
if len(buckets) == 0 {
return 0, nil
}
keys := make([]rollupKey, 0, len(buckets))
for key := range buckets {
keys = append(keys, key)
for k := range buckets {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].entityID != keys[j].entityID {
@@ -557,39 +454,70 @@ func (s *Store) mergeRollupBucketsTx(ctx context.Context, metricName string, int
})
stmt := s.dialect.upsertRollupSQL(s.tables)
resolution := interval.Nanoseconds()
createdAt := time.Now().UTC().UnixNano()
for _, key := range keys {
bucket := buckets[key]
existing, err := s.readRollupBucketTx(ctx, metricName, key.entityID, key.tagsHash, interval, key.bucket, tx)
if err != nil {
return 0, err
resNano := interval.Nanoseconds()
now := time.Now().UTC().UnixNano()
mergeCutoffNano := mergeCutoff.UnixNano()
run := func(ex execer) error {
for _, k := range keys {
b := buckets[k]
tagsJSON := b.tagsJSON
if tagsJSON == "" {
tagsJSON = "{}"
}
// If mergeCutoff is set and this bucket is older than the cutoff,
// merge with existing data instead of replacing it.
if !mergeCutoff.IsZero() && k.bucket < mergeCutoffNano {
// Read existing row if it exists
existing, err := s.readRollupBucket(ctx, metricName, k.entityID, k.tagsHash, interval, k.bucket)
if err != nil {
return err
}
if existing != nil {
// Merge the new bucket into the existing one
existing.mergeStored(b)
b = existing
}
}
// Column order must match rollupColumns in dialect_rollup.go:
// 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.
_, err := ex.ExecContext(ctx, stmt,
metricName, k.entityID, k.tagsHash, tagsJSON, resNano, k.bucket,
b.count, b.sum, b.sumSq, b.min, b.max,
b.firstVal, b.firstTS, b.lastVal, b.lastTS,
b.digest.Encode(), now,
)
if err != nil {
return err
}
}
if existing != nil {
existing.mergeStored(bucket)
bucket = existing
}
tagsJSON := bucket.tagsJSON
if tagsJSON == "" {
tagsJSON = "{}"
}
if _, err := tx.ExecContext(ctx, stmt,
metricName, key.entityID, key.tagsHash, tagsJSON, resolution, key.bucket,
bucket.count, bucket.sum, bucket.sumSq, bucket.min, bucket.max,
bucket.firstVal, bucket.firstTS, bucket.lastVal, bucket.lastTS,
bucket.digest.Encode(), createdAt,
); err != nil {
return nil
}
if len(keys) == 1 {
if err := run(s.db); err != nil {
return 0, err
}
return len(keys), nil
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer func() { _ = tx.Rollback() }()
if err := run(tx); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return len(keys), nil
}
// writeRollupBucketsTx upserts buckets within an existing transaction.
func (s *Store) writeRollupBucketsTx(ctx context.Context, metricName string, interval time.Duration, buckets map[rollupKey]*rollupBucket, tx *sql.Tx) (int, error) {
return s.writeRollupBucketsWithMergePointTx(ctx, metricName, interval, buckets, time.Time{}, time.Time{}, nil, tx)
}
// writeRollupBucketsWithMergePointTx is the transactional version that executes
// all operations within an existing transaction. deltaCutoff marks coarser
// buckets whose source tier may already have aged out; a smaller recomputed
@@ -674,6 +602,63 @@ func (s *Store) writeRollupBucketsWithMergePointTx(ctx context.Context, metricNa
return written, nil
}
// readRollupBucket reads a single rollup bucket from storage, returning nil if
// the bucket doesn't exist. Used to merge late-arriving data with existing rollups.
func (s *Store) readRollupBucket(ctx context.Context, metricName, entityID, tagsHash string, interval time.Duration, bucketNano int64) (*rollupBucket, error) {
sqlText := fmt.Sprintf(
`SELECT count, sum, sum_sq, min_val, max_val, first_val, first_ts, last_val, last_ts, digest, tags
FROM %s WHERE metric_name = %s AND resolution_nano = %s AND entity_id = %s
AND tags_hash = %s AND bucket_nano = %s`,
s.tables.rollups,
s.dialect.placeholder(1), s.dialect.placeholder(2), s.dialect.placeholder(3),
s.dialect.placeholder(4), s.dialect.placeholder(5),
)
row := s.reader().QueryRowContext(ctx, sqlText, metricName, interval.Nanoseconds(), entityID, tagsHash, bucketNano)
var count int64
var sum, sumSq, minV, maxV, firstV, lastV float64
var firstTS, lastTS int64
var digestBlob []byte
var rawTags any
err := row.Scan(&count, &sum, &sumSq, &minV, &maxV, &firstV, &firstTS, &lastV, &lastTS, &digestBlob, &rawTags)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
td, err := DecodeTDigest(digestBlob)
if err != nil {
return nil, err
}
tagsJSON, err := rawTagsToJSON(rawTags)
if err != nil {
return nil, err
}
return &rollupBucket{
count: count, sum: sum, sumSq: sumSq,
min: minV, max: maxV,
firstVal: firstV, firstTS: firstTS,
lastVal: lastV, lastTS: lastTS,
digest: td,
tagsHash: tagsHash,
tagsJSON: tagsJSON,
}, nil
}
// deleteRollupsBefore deletes stored rollup rows older than a cutoff.
func (s *Store) deleteRollupsBefore(ctx context.Context, metricName string, interval time.Duration, before time.Time) error {
sqlText := fmt.Sprintf(
`DELETE FROM %s WHERE metric_name = %s AND resolution_nano = %s AND bucket_nano < %s`,
s.tables.rollups, s.dialect.placeholder(1), s.dialect.placeholder(2), s.dialect.placeholder(3),
)
_, err := s.db.ExecContext(ctx, sqlText, metricName, interval.Nanoseconds(), before.UTC().UnixNano())
return err
}
// deleteRollupsBeforeTx deletes stored rollup rows within a transaction.
func (s *Store) deleteRollupsBeforeTx(ctx context.Context, metricName string, interval time.Duration, before time.Time, tx *sql.Tx) error {
sqlText := fmt.Sprintf(
+12 -29
View File
@@ -19,7 +19,7 @@ func TestRollupKeepsTagSeriesSeparate(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}}}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "util", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "util", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
@@ -63,9 +63,6 @@ func TestRollupKeepsTagSeriesSeparate(t *testing.T) {
if len(res0) != 1 || res0[0].Count != 10 || math.Abs(res0[0].Value-14.5) > 1e-9 {
t.Fatalf("device 0 should be its own series avg=14.5 count=10, got %#v", res0)
}
if res0[0].Tags["device_index"] != "0" {
t.Fatalf("device 0 rollup should keep tags, got %#v", res0[0])
}
// device 1: avg of 80..89 = 84.5
res1, err := s.AggregateRollup(ctx, q("1"), time.Minute)
if err != nil {
@@ -74,33 +71,19 @@ func TestRollupKeepsTagSeriesSeparate(t *testing.T) {
if len(res1) != 1 || res1[0].Count != 10 || math.Abs(res1[0].Value-84.5) > 1e-9 {
t.Fatalf("device 1 should be its own series avg=84.5 count=10, got %#v", res1)
}
if res1[0].Tags["device_index"] != "1" {
t.Fatalf("device 1 rollup should keep tags, got %#v", res1[0])
}
// No tag filter => both tag series are returned independently, matching
// raw Aggregate and preserving the tag dimension for public query callers.
// No tag filter => both series merge (count 20, avg of all 20 values).
all, err := s.AggregateRollup(ctx, AggregateQuery{
Query: Query{MetricName: "util", EntityID: "host-1", Start: base, End: base.Add(10 * time.Minute)},
Aggregation: AggAvg,
Interval: time.Minute,
PreserveSeries: true,
Query: Query{MetricName: "util", EntityID: "host-1", Start: base, End: base.Add(10 * time.Minute)},
Aggregation: AggAvg,
Interval: time.Minute,
}, time.Minute)
if err != nil {
t.Fatalf("rollup all: %v", err)
}
byDevice := map[string]AggregatePoint{}
for _, point := range all {
byDevice[point.Tags["device_index"]] = point
}
if len(byDevice) != 2 {
t.Fatalf("no-filter should keep both tag series, got %#v", all)
}
if got := byDevice["0"]; got.Count != 10 || math.Abs(got.Value-14.5) > 1e-9 {
t.Fatalf("unexpected no-filter device 0 rollup: %#v", got)
}
if got := byDevice["1"]; got.Count != 10 || math.Abs(got.Value-84.5) > 1e-9 {
t.Fatalf("unexpected no-filter device 1 rollup: %#v", got)
wantAvg := (145.0 + 845.0) / 20.0 // sum(10..19)=145, sum(80..89)=845
if len(all) != 1 || all[0].Count != 20 || math.Abs(all[0].Value-wantAvg) > 1e-9 {
t.Fatalf("no-filter should merge both tags count=20 avg=%.2f, got %#v", wantAvg, all)
}
}
@@ -119,7 +102,7 @@ func TestRollupTagPercentileSurvivesRetention(t *testing.T) {
Tiers: []RollupTier{{Interval: time.Minute, Retention: 365 * 24 * time.Hour}},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "lat", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "lat", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
@@ -184,7 +167,7 @@ func TestSeriesTagFilterRoutesPerTag(t *testing.T) {
Tiers: []RollupTier{{Interval: time.Minute, Retention: 30 * 24 * time.Hour}},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "m", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "m", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
@@ -244,7 +227,7 @@ func TestDeleteSeriesRemovesTaggedTaskAcrossAgents(t *testing.T) {
Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "ping.latency_ms", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "ping.latency_ms", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
@@ -311,7 +294,7 @@ func TestDeleteMetricRemovesRollups(t *testing.T) {
s := newRollupStore(t, RollupPolicy{
Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}},
})
if err := s.CreateMetric(ctx, Definition{Name: "gone", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "gone", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
+10 -499
View File
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"math"
"reflect"
"testing"
"time"
)
@@ -26,50 +25,6 @@ func newRollupStore(t *testing.T, policy RollupPolicy) *Store {
return store
}
func TestLatestBeforeUsesRawAndRollupData(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{
RawRetention: 15 * time.Minute,
Tiers: []RollupTier{
{Interval: time.Minute, Retention: 24 * time.Hour},
},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "counter", Type: TypeCounter, RetentionDays: 1}); err != nil {
t.Fatalf("create metric: %v", err)
}
base := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
if err := s.WriteBatch(ctx, []Point{
{MetricName: "counter", EntityID: "node", Timestamp: base, Value: 10},
{MetricName: "counter", EntityID: "node", Timestamp: base.Add(5 * time.Minute), Value: 20},
}); err != nil {
t.Fatalf("write old counters: %v", err)
}
if _, err := s.Compact(ctx, base.Add(30*time.Minute)); err != nil {
t.Fatalf("compact counters: %v", err)
}
point, ok, err := s.LatestBefore(ctx, "counter", "node", base.Add(4*time.Minute))
if err != nil {
t.Fatalf("latest rollup before boundary: %v", err)
}
if !ok || point.Value != 10 || !point.Timestamp.Equal(base) {
t.Fatalf("unexpected bounded rollup point: %#v, found=%v", point, ok)
}
recent := base.Add(29 * time.Minute)
if err := s.Write(ctx, Point{MetricName: "counter", EntityID: "node", Timestamp: recent, Value: 30}); err != nil {
t.Fatalf("write recent counter: %v", err)
}
point, ok, err = s.LatestBefore(ctx, "counter", "node", base.Add(30*time.Minute))
if err != nil {
t.Fatalf("latest raw before boundary: %v", err)
}
if !ok || point.Value != 30 || !point.Timestamp.Equal(recent) {
t.Fatalf("unexpected latest raw point: %#v, found=%v", point, ok)
}
}
// TestArbitraryPercentileOverRaw verifies arbitrary percentile (pxx)
// aggregation end-to-end over raw points.
//
@@ -78,7 +33,7 @@ func TestLatestBeforeUsesRawAndRollupData(t *testing.T) {
func TestArbitraryPercentileOverRaw(t *testing.T) {
ctx := context.Background()
s := newMemStore(t)
if err := s.CreateMetric(ctx, Definition{Name: "lat", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "lat", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
@@ -116,46 +71,6 @@ func TestArbitraryPercentileOverRaw(t *testing.T) {
}
}
func TestAggregateRollupSkipsDigestForNonPercentile(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{
RawRetention: 2 * time.Minute,
Tiers: []RollupTier{
{Interval: time.Minute, Retention: time.Hour},
},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "latency", Type: TypeGauge, RetentionDays: 1}); err != nil {
t.Fatalf("create metric: %v", err)
}
base := time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)
if err := s.WriteBatch(ctx, []Point{
{MetricName: "latency", EntityID: "node", Timestamp: base.Add(10 * time.Second), Value: 10},
{MetricName: "latency", EntityID: "node", Timestamp: base.Add(20 * time.Second), Value: 20},
}); err != nil {
t.Fatalf("write points: %v", err)
}
if _, err := s.Compact(ctx, base.Add(4*time.Minute)); err != nil {
t.Fatalf("compact: %v", err)
}
if _, err := s.db.ExecContext(ctx, fmt.Sprintf("UPDATE %s SET digest = ?", s.tables.rollups), []byte("invalid")); err != nil {
t.Fatalf("corrupt digest: %v", err)
}
query := Query{MetricName: "latency", EntityID: "node", Start: base, End: base.Add(time.Minute)}
avg, err := s.AggregateRollup(ctx, AggregateQuery{Query: query, Aggregation: AggAvg, Interval: time.Minute}, time.Minute)
if err != nil {
t.Fatalf("average should not read digest: %v", err)
}
if len(avg) != 1 || avg[0].Value != 15 {
t.Fatalf("average = %#v, want one bucket with value 15", avg)
}
if _, err := s.AggregateRollup(ctx, AggregateQuery{Query: query, Aggregation: Pxx(4), Interval: time.Minute}, time.Minute); err == nil {
t.Fatal("percentile query should reject an invalid digest")
}
}
// percentileSortedRange computes an exact percentile for an integer range.
//
// percentileSortedRange 为整数范围计算精确百分位。
@@ -198,7 +113,7 @@ func TestCompactBuildsFinestTier(t *testing.T) {
Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "m", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "m", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
@@ -274,7 +189,7 @@ func TestCompactCascadeFineToCoarse(t *testing.T) {
},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "c", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "c", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
@@ -329,7 +244,7 @@ func TestCompactDoesNotOverwriteCoarseRollupWithPartialFineRows(t *testing.T) {
},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "partial", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "partial", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
@@ -400,7 +315,7 @@ func TestCompactMergesLateFineDeltaLargerThanCoarseBucket(t *testing.T) {
},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "latebig", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "latebig", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
@@ -446,7 +361,7 @@ func TestRetentionDropsRawButPercentileSurvives(t *testing.T) {
Tiers: []RollupTier{{Interval: time.Minute, Retention: 365 * 24 * time.Hour}},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "old", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "old", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
@@ -501,7 +416,7 @@ func TestCompactMergesLateRawIntoExpiredRollup(t *testing.T) {
Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "late", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "late", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
@@ -542,84 +457,6 @@ func TestCompactMergesLateRawIntoExpiredRollup(t *testing.T) {
}
}
func TestCompatibleSeriesIntervalUsesTierCoveringWindow(t *testing.T) {
now := time.Date(2026, 7, 12, 0, 0, 0, 0, time.UTC)
s := &Store{cfg: Config{RollupPolicy: RollupPolicy{
RawRetention: 15 * time.Minute,
Tiers: []RollupTier{
{Interval: time.Minute, Retention: 48 * time.Hour},
{Interval: 5 * time.Minute, Retention: 14 * 24 * time.Hour},
{Interval: time.Hour, Retention: 60 * 24 * time.Hour},
},
}}}
tests := []struct {
name string
start time.Time
interval time.Duration
want time.Duration
}{
{name: "recent raw", start: now.Add(-5 * time.Minute), interval: 5 * time.Second, want: 5 * time.Second},
{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},
{name: "hour tier", start: now.Add(-15 * 24 * time.Hour), interval: 30 * time.Minute, want: time.Hour},
{name: "round to hour multiple", start: now.Add(-15 * 24 * time.Hour), interval: 90 * time.Minute, want: 2 * time.Hour},
{name: "before longest retention", start: now.Add(-61 * 24 * time.Hour), interval: 30 * time.Minute, want: time.Hour},
{name: "before longest retention already compatible", start: now.Add(-61 * 24 * time.Hour), interval: 3 * time.Hour, want: 3 * time.Hour},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := s.CompatibleSeriesInterval(test.start, now, test.interval); got != test.want {
t.Fatalf("CompatibleSeriesInterval() = %s, want %s", got, test.want)
}
})
}
}
func TestSeriesStartBeforeLongestRetentionReturnsAvailableRollup(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{
RawRetention: 15 * time.Minute,
Tiers: []RollupTier{
{Interval: time.Minute, Retention: 48 * time.Hour},
{Interval: 5 * time.Minute, Retention: 14 * 24 * time.Hour},
{Interval: time.Hour, Retention: 90 * 24 * time.Hour},
},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "long-window", Type: TypeGauge, RetentionDays: 90}); err != nil {
t.Fatalf("create: %v", err)
}
now := time.Date(2026, 7, 12, 12, 0, 0, 0, time.UTC)
pointTime := now.Add(-80 * 24 * time.Hour).Add(15 * time.Minute)
if err := s.Write(ctx, Point{MetricName: "long-window", EntityID: "n1", Timestamp: pointTime, Value: 42}); err != nil {
t.Fatalf("write: %v", err)
}
if _, err := s.Compact(ctx, now); err != nil {
t.Fatalf("compact: %v", err)
}
got, err := s.Series(ctx, AggregateQuery{
Query: Query{
MetricName: "long-window",
EntityID: "n1",
Start: now.Add(-91 * 24 * time.Hour),
End: now,
},
Aggregation: AggAvg,
Interval: 3 * time.Hour,
}, now)
if err != nil {
t.Fatalf("series: %v", err)
}
if len(got) != 1 || got[0].Count != 1 || got[0].Value != 42 {
t.Fatalf("expected retained intersection, got %#v", got)
}
}
// TestSeriesRoutesByAge verifies Series auto-routes between raw and rollup data
// based on the query window's age.
//
@@ -632,7 +469,7 @@ func TestSeriesRoutesByAge(t *testing.T) {
Tiers: []RollupTier{{Interval: time.Minute, Retention: 30 * 24 * time.Hour}},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "sr", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "sr", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
@@ -713,7 +550,7 @@ func TestSeriesAcrossRetentionIncludesUncompactedRecentRaw(t *testing.T) {
Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "hybrid", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "hybrid", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
@@ -789,117 +626,6 @@ func TestRollupPolicyValidate(t *testing.T) {
}
}
func TestRollupPolicyWithMetricRetentionPrunesRedundantTiers(t *testing.T) {
policy := RollupPolicy{
RawRetention: 15 * time.Minute,
Tiers: []RollupTier{
{Interval: time.Minute, Retention: 48 * time.Hour},
{Interval: 5 * time.Minute, Retention: 14 * 24 * time.Hour},
{Interval: time.Hour, Retention: 14 * 24 * time.Hour},
},
Compression: 100,
}
tests := []struct {
name string
retention time.Duration
want []RollupTier
}{
{
name: "one day keeps finest tier",
retention: 24 * time.Hour,
want: []RollupTier{
{Interval: time.Minute, Retention: 24 * time.Hour},
},
},
{
name: "seven days keeps minute and five minute tiers",
retention: 7 * 24 * time.Hour,
want: []RollupTier{
{Interval: time.Minute, Retention: 48 * time.Hour},
{Interval: 5 * time.Minute, Retention: 7 * 24 * time.Hour},
},
},
{
name: "thirty days keeps all tiers",
retention: 30 * 24 * time.Hour,
want: []RollupTier{
{Interval: time.Minute, Retention: 48 * time.Hour},
{Interval: 5 * time.Minute, Retention: 14 * 24 * time.Hour},
{Interval: time.Hour, Retention: 30 * 24 * time.Hour},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := policy.withMetricRetention(test.retention)
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 len(policy.Tiers) != 3 || policy.Tiers[2].Retention != 14*24*time.Hour {
t.Fatalf("withMetricRetention mutated source policy: %#v", policy.Tiers)
}
}
func TestCompactRemovesRollupsFromRedundantTiers(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{
RawRetention: 15 * time.Minute,
Tiers: []RollupTier{
{Interval: time.Minute, Retention: 48 * time.Hour},
{Interval: 5 * time.Minute, Retention: 14 * 24 * time.Hour},
{Interval: time.Hour, Retention: 14 * 24 * time.Hour},
},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "daily", Type: TypeGauge, RetentionDays: 1}); err != nil {
t.Fatalf("create metric: %v", err)
}
now := time.Date(2026, 7, 16, 12, 0, 0, 0, time.UTC)
bucketTime := now.Add(-2 * time.Hour)
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
t.Fatalf("begin seed transaction: %v", err)
}
for _, interval := range []time.Duration{time.Minute, 5 * time.Minute, time.Hour} {
bucket := newRollupBucket(policy.compression())
bucket.addPoint(42, bucketTime.UnixNano())
key := rollupKey{entityID: "node", bucket: bucketTime.UnixNano()}
if _, err := s.writeRollupBucketsTx(ctx, "daily", interval, map[rollupKey]*rollupBucket{key: bucket}, tx); err != nil {
_ = tx.Rollback()
t.Fatalf("seed %s rollup: %v", interval, err)
}
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit seed transaction: %v", err)
}
if _, err := s.CompactMetric(ctx, "daily", now); err != nil {
t.Fatalf("compact: %v", err)
}
for _, test := range []struct {
interval time.Duration
want int
}{
{interval: time.Minute, want: 1},
{interval: 5 * time.Minute, want: 0},
{interval: time.Hour, want: 0},
} {
rows, err := s.scanRollupRows(ctx, s.reader(), "daily", test.interval)
if err != nil {
t.Fatalf("scan %s rollups: %v", test.interval, err)
}
if len(rows) != test.want {
t.Fatalf("%s rollup rows = %d, want %d", test.interval, len(rows), test.want)
}
}
}
// TestCompactIdempotent verifies Compact is idempotent for unchanged windows.
//
// TestCompactIdempotent 验证 Compact 对未变化窗口保持幂等。
@@ -907,7 +633,7 @@ func TestCompactIdempotent(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{Tiers: []RollupTier{{Interval: time.Minute, Retention: 24 * time.Hour}}}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "idem", Type: TypeGauge, RetentionDays: 30}); err != nil {
if err := s.CreateMetric(ctx, Definition{Name: "idem", Type: TypeGauge}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
@@ -948,218 +674,3 @@ func TestCompactIdempotent(t *testing.T) {
}
}
}
func TestCompactWithRawRetentionOnlyWritesChangedBuckets(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{
RawRetention: 2 * time.Minute,
Tiers: []RollupTier{
{Interval: time.Minute, Retention: time.Hour},
{Interval: 5 * time.Minute, Retention: 24 * time.Hour},
},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "incremental", Type: TypeGauge, RetentionDays: 30}); err != nil {
t.Fatalf("create: %v", err)
}
base := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC)
if err := s.Write(ctx, Point{MetricName: "incremental", EntityID: "n1", Timestamp: base.Add(10 * time.Second), Value: 1}); err != nil {
t.Fatalf("write: %v", err)
}
now := base.Add(10 * time.Minute)
first, err := s.Compact(ctx, now)
if err != nil {
t.Fatalf("compact first: %v", err)
}
if first != 2 {
t.Fatalf("first compact wrote %d buckets, want 2", first)
}
second, err := s.Compact(ctx, now.Add(time.Minute))
if err != nil {
t.Fatalf("compact unchanged: %v", err)
}
if second != 0 {
t.Fatalf("unchanged compact rewrote %d buckets, want 0", second)
}
if err := s.Write(ctx, Point{MetricName: "incremental", EntityID: "n1", Timestamp: base.Add(20 * time.Second), Value: 2}); err != nil {
t.Fatalf("write late: %v", err)
}
late, err := s.Compact(ctx, now.Add(2*time.Minute))
if err != nil {
t.Fatalf("compact late: %v", err)
}
if late != 2 {
t.Fatalf("late compact wrote %d buckets, want 2", late)
}
got, err := s.AggregateRollup(ctx, AggregateQuery{
Query: Query{MetricName: "incremental", EntityID: "n1", Start: base, End: base.Add(5*time.Minute - time.Nanosecond)},
Aggregation: AggSum,
Interval: 5 * time.Minute,
}, 5*time.Minute)
if err != nil {
t.Fatalf("rollup: %v", err)
}
if len(got) != 1 || got[0].Count != 2 || got[0].Value != 3 {
t.Fatalf("late delta was not merged exactly once: %#v", got)
}
}
func TestCompactHonorsMetricRetentionForCoarsestTier(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{
RawRetention: 15 * time.Minute,
Tiers: []RollupTier{
{Interval: time.Minute, Retention: 48 * time.Hour},
{Interval: 5 * time.Minute, Retention: 14 * 24 * time.Hour},
{Interval: time.Hour, Retention: 90 * 24 * time.Hour},
},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "retained", Type: TypeGauge, RetentionDays: 60}); err != nil {
t.Fatalf("create: %v", err)
}
if err := s.CreateMetric(ctx, Definition{Name: "longer-retained", Type: TypeGauge, RetentionDays: 120}); err != nil {
t.Fatalf("create longer metric: %v", err)
}
now := time.Date(2026, 7, 12, 12, 30, 0, 0, time.UTC)
points := []Point{
{MetricName: "retained", EntityID: "n1", Timestamp: now.Add(-70 * 24 * time.Hour), Value: 1},
{MetricName: "retained", EntityID: "n1", Timestamp: now.Add(-60*24*time.Hour + 10*time.Minute), Value: 2},
{MetricName: "retained", EntityID: "n1", Timestamp: now.Add(-50 * 24 * time.Hour), Value: 3},
{MetricName: "longer-retained", EntityID: "n1", Timestamp: now.Add(-100 * 24 * time.Hour), Value: 4},
}
if err := s.WriteBatch(ctx, points); err != nil {
t.Fatalf("write: %v", err)
}
if _, err := s.Compact(ctx, now); err != nil {
t.Fatalf("compact: %v", err)
}
got, err := s.AggregateRollup(ctx, AggregateQuery{
Query: Query{MetricName: "retained", EntityID: "n1", Start: now.Add(-80 * 24 * time.Hour), End: now},
Aggregation: AggSum,
Interval: time.Hour,
}, time.Hour)
if err != nil {
t.Fatalf("rollup: %v", err)
}
if len(got) != 2 {
t.Fatalf("expected 2 retained buckets, got %#v", got)
}
if got[0].Value != 2 || got[1].Value != 3 {
t.Fatalf("unexpected retained values: %#v", got)
}
longer, err := s.AggregateRollup(ctx, AggregateQuery{
Query: Query{MetricName: "longer-retained", EntityID: "n1", Start: now.Add(-130 * 24 * time.Hour), End: now},
Aggregation: AggSum,
Interval: time.Hour,
}, time.Hour)
if err != nil {
t.Fatalf("longer rollup: %v", err)
}
if len(longer) != 1 || longer[0].Value != 4 {
t.Fatalf("metric retention leaked across definitions: %#v", longer)
}
}
func TestZeroRetentionPurgesDataAndDisablesFurtherPersistence(t *testing.T) {
ctx := context.Background()
policy := RollupPolicy{
RawRetention: 15 * time.Minute,
Tiers: []RollupTier{
{Interval: time.Minute, Retention: 48 * time.Hour},
{Interval: 5 * time.Minute, Retention: 14 * 24 * time.Hour},
{Interval: time.Hour, Retention: 30 * 24 * time.Hour},
},
}
s := newRollupStore(t, policy)
if err := s.CreateMetric(ctx, Definition{Name: "disabled", Type: TypeGauge, RetentionDays: 0}); err != nil {
t.Fatalf("create disabled metric: %v", err)
}
now := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC)
def, err := s.GetMetric(ctx, "disabled")
if err != nil || def.RetentionDays != 0 {
t.Fatalf("CreateMetric did not persist zero retention: %#v, err=%v", def, err)
}
if err := s.Write(ctx, Point{MetricName: "disabled", EntityID: "node", Timestamp: now, Value: 1}); err != nil {
t.Fatalf("write disabled metric: %v", err)
}
raw, err := s.Query(ctx, Query{MetricName: "disabled", EntityID: "node", Start: now.Add(-time.Hour), End: now.Add(time.Hour)})
if err != nil || len(raw) != 0 {
t.Fatalf("CreateMetric zero persisted data: %#v, err=%v", raw, err)
}
if err := s.UpsertMetric(ctx, Definition{Name: "disabled", Type: TypeGauge, RetentionDays: 30}); err != nil {
t.Fatalf("enable metric: %v", err)
}
if err := s.Write(ctx, Point{MetricName: "disabled", EntityID: "node", Timestamp: now.Add(-time.Hour), Value: 1}); err != nil {
t.Fatalf("write initial point: %v", err)
}
if _, err := s.CompactMetric(ctx, "disabled", now); err != nil {
t.Fatalf("compact: %v", err)
}
rollups, err := s.AggregateRollup(ctx, AggregateQuery{
Query: Query{MetricName: "disabled", EntityID: "node", Start: now.Add(-2 * time.Hour), End: now},
Aggregation: AggSum,
Interval: time.Minute,
}, time.Minute)
if err != nil || len(rollups) == 0 {
t.Fatalf("expected persisted rollup before disable, got %#v, err=%v", rollups, err)
}
if err := s.UpsertMetric(ctx, Definition{Name: "disabled", Type: TypeGauge, RetentionDays: 0}); err != nil {
t.Fatalf("upsert zero retention: %v", err)
}
def, err = s.GetMetric(ctx, "disabled")
if err != nil || def.RetentionDays != 0 {
t.Fatalf("UpsertMetric did not persist zero retention: %#v, err=%v", def, err)
}
raw, err = s.Query(ctx, Query{MetricName: "disabled", EntityID: "node", Start: now.Add(-2 * time.Hour), End: now})
if err != nil || len(raw) != 0 {
t.Fatalf("raw data remained after disable: %#v, err=%v", raw, err)
}
rollups, err = s.AggregateRollup(ctx, AggregateQuery{
Query: Query{MetricName: "disabled", EntityID: "node", Start: now.Add(-2 * time.Hour), End: now},
Aggregation: AggSum,
Interval: time.Minute,
}, time.Minute)
if err != nil || len(rollups) != 0 {
t.Fatalf("rollup data remained after disable: %#v, err=%v", rollups, err)
}
if err := s.Write(ctx, Point{MetricName: "disabled", EntityID: "node", Timestamp: now, Value: 2}); err != nil {
t.Fatalf("write disabled metric: %v", err)
}
raw, err = s.Query(ctx, Query{MetricName: "disabled", EntityID: "node", Start: now.Add(-time.Hour), End: now.Add(time.Hour)})
if err != nil || len(raw) != 0 {
t.Fatalf("disabled metric persisted a new point: %#v, err=%v", raw, err)
}
if written, err := s.CompactMetric(ctx, "disabled", now); err != nil || written != 0 {
t.Fatalf("disabled metric compacted %d buckets, err=%v", written, err)
}
}
func TestCompactPurgesZeroRetentionWithoutRollups(t *testing.T) {
ctx := context.Background()
s := newMemStore(t)
if err := s.CreateMetric(ctx, Definition{Name: "disabled", Type: TypeGauge, RetentionDays: 30}); err != nil {
t.Fatalf("create metric: %v", err)
}
now := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC)
if err := s.Write(ctx, Point{MetricName: "disabled", EntityID: "node", Timestamp: now, Value: 1}); err != nil {
t.Fatalf("write point: %v", err)
}
if _, err := s.db.ExecContext(ctx,
fmt.Sprintf("UPDATE %s SET retention_days = ? WHERE name = ?", s.tables.definitions),
0, "disabled",
); err != nil {
t.Fatalf("seed disabled definition: %v", err)
}
if written, err := s.Compact(ctx, now); err != nil || written != 0 {
t.Fatalf("compact = %d, err=%v", written, err)
}
points, err := s.Query(ctx, Query{MetricName: "disabled", EntityID: "node", Start: now.Add(-time.Hour), End: now.Add(time.Hour)})
if err != nil || len(points) != 0 {
t.Fatalf("zero-retention data remained without rollups: %#v, err=%v", points, err)
}
}
+16 -416
View File
@@ -50,14 +50,6 @@ type Store struct {
//
// tables 保存当前 Store 的实际表名。
tables tables
// maintenanceMu serializes physical storage maintenance while allowing
// concurrent size reads.
//
// maintenanceMu 串行化物理存储维护,同时允许并发读取存储大小。
maintenanceMu sync.RWMutex
// retentionMu serializes a retention change with writes and compaction so a
// disabled metric cannot be repopulated by an in-flight operation.
retentionMu sync.RWMutex
// mu protects closed state.
//
// mu 保护 closed 状态。
@@ -72,6 +64,9 @@ type Store struct {
//
// Open 根据配置打开 Store,初始化连接池,并在需要时执行自动迁移。
func Open(ctx context.Context, cfg Config) (*Store, error) {
if cfg.DefaultRetentionDays == 0 {
cfg.DefaultRetentionDays = 90
}
if cfg.TablePrefix == "" {
cfg.TablePrefix = "metric_"
}
@@ -96,7 +91,6 @@ func Open(ctx context.Context, cfg Config) (*Store, error) {
definitions: tableName(cfg.TablePrefix, "definitions"),
points: tableName(cfg.TablePrefix, "points"),
rollups: tableName(cfg.TablePrefix, "rollups"),
watermarks: tableName(cfg.TablePrefix, "compaction_watermarks"),
},
}
@@ -295,8 +289,6 @@ func (s *Store) configureSQLite(ctx context.Context, db *sql.DB) error {
}
if s.cfg.SQLite.TempStoreMemory {
pragmas = append(pragmas, "PRAGMA temp_store = MEMORY")
} else {
pragmas = append(pragmas, "PRAGMA temp_store = FILE")
}
for _, pragma := range pragmas {
@@ -384,7 +376,7 @@ func (s *Store) CreateMetric(ctx context.Context, def Definition) error {
if err := s.ensureOpen(); err != nil {
return err
}
def = def.withDefaults()
def = def.withDefaults(s.cfg.DefaultRetentionDays)
if err := def.Validate(); err != nil {
return err
}
@@ -431,16 +423,10 @@ func (s *Store) UpsertMetric(ctx context.Context, def Definition) error {
if err := s.ensureOpen(); err != nil {
return err
}
def = def.withDefaults()
def = def.withDefaults(s.cfg.DefaultRetentionDays)
if err := def.Validate(); err != nil {
return err
}
if def.RetentionDays == 0 {
// Serialize the zero-retention transition with writes and compaction before
// changing the definition or removing data left by an enabled definition.
s.retentionMu.Lock()
defer s.retentionMu.Unlock()
}
metadata, err := encodeMap(def.Metadata)
if err != nil {
return err
@@ -458,10 +444,6 @@ func (s *Store) UpsertMetric(ctx context.Context, def Definition) error {
now,
now,
)
if err != nil || def.RetentionDays != 0 {
return err
}
_, err = s.DeleteSeries(ctx, Query{MetricName: def.Name})
return err
}
@@ -535,191 +517,12 @@ func (s *Store) DeleteMetric(ctx context.Context, name string) error {
if _, err = tx.ExecContext(ctx, fmt.Sprintf(`DELETE FROM %s WHERE metric_name = %s`, s.tables.points, s.dialect.placeholder(1)), name); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, fmt.Sprintf(`DELETE FROM %s WHERE metric_name = %s`, s.tables.watermarks, s.dialect.placeholder(1)), name); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, fmt.Sprintf(`DELETE FROM %s WHERE name = %s`, s.tables.definitions, s.dialect.placeholder(1)), name); err != nil {
return err
}
return tx.Commit()
}
// UpdateMetricRetention updates one metric's retention policy without deleting
// its existing data. A value of zero disables subsequent persistence. Negative
// values are invalid.
func (s *Store) UpdateMetricRetention(ctx context.Context, name string, retentionDays int) (Definition, error) {
if err := s.ensureOpen(); err != nil {
return Definition{}, err
}
name = strings.TrimSpace(name)
if name == "" {
return Definition{}, fmt.Errorf("%w: metric name is required", ErrInvalidArgument)
}
if retentionDays < 0 {
return Definition{}, fmt.Errorf("%w: retention days cannot be negative", ErrInvalidArgument)
}
s.retentionMu.Lock()
defer s.retentionMu.Unlock()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Definition{}, err
}
defer func() { _ = tx.Rollback() }()
updatedAt := time.Now().UTC().UnixNano()
result, err := tx.ExecContext(ctx,
fmt.Sprintf(`UPDATE %s SET retention_days = %s, updated_at = %s WHERE name = %s`,
s.tables.definitions, s.dialect.placeholder(1), s.dialect.placeholder(2), s.dialect.placeholder(3)),
retentionDays, updatedAt, name,
)
if err != nil {
return Definition{}, err
}
affected, err := result.RowsAffected()
if err != nil {
return Definition{}, err
}
if affected == 0 {
return Definition{}, fmt.Errorf("%w: metric %q", ErrNotFound, name)
}
if err := tx.Commit(); err != nil {
return Definition{}, err
}
return s.GetMetric(ctx, name)
}
// SetMetricRetention updates one metric's retention policy. A value of zero
// disables persistence for that metric and removes its raw and rollup data.
// Negative values are invalid.
func (s *Store) SetMetricRetention(ctx context.Context, name string, retentionDays int) (Definition, error) {
if err := s.ensureOpen(); err != nil {
return Definition{}, err
}
name = strings.TrimSpace(name)
if name == "" {
return Definition{}, fmt.Errorf("%w: metric name is required", ErrInvalidArgument)
}
if retentionDays < 0 {
return Definition{}, fmt.Errorf("%w: retention days cannot be negative", ErrInvalidArgument)
}
s.retentionMu.Lock()
defer s.retentionMu.Unlock()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Definition{}, err
}
defer func() { _ = tx.Rollback() }()
updatedAt := time.Now().UTC().UnixNano()
result, err := tx.ExecContext(ctx,
fmt.Sprintf(`UPDATE %s SET retention_days = %s, updated_at = %s WHERE name = %s`,
s.tables.definitions, s.dialect.placeholder(1), s.dialect.placeholder(2), s.dialect.placeholder(3)),
retentionDays, updatedAt, name,
)
if err != nil {
return Definition{}, err
}
affected, err := result.RowsAffected()
if err != nil {
return Definition{}, err
}
if affected == 0 {
return Definition{}, fmt.Errorf("%w: metric %q", ErrNotFound, name)
}
if retentionDays == 0 {
for _, table := range []string{s.tables.points, s.tables.rollups, s.tables.watermarks} {
if _, err := tx.ExecContext(ctx,
fmt.Sprintf(`DELETE FROM %s WHERE metric_name = %s`, table, s.dialect.placeholder(1)), name,
); err != nil {
return Definition{}, err
}
}
}
if err := tx.Commit(); err != nil {
return Definition{}, err
}
return s.GetMetric(ctx, name)
}
// DeleteMetricData removes all raw, rollup, and watermark data for one metric.
func (s *Store) DeleteMetricData(ctx context.Context, name string) error {
if err := s.ensureOpen(); err != nil {
return err
}
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("%w: metric name is required", ErrInvalidArgument)
}
s.retentionMu.Lock()
defer s.retentionMu.Unlock()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
for _, table := range []string{s.tables.points, s.tables.rollups, s.tables.watermarks} {
if _, err := tx.ExecContext(ctx,
fmt.Sprintf(`DELETE FROM %s WHERE metric_name = %s`, table, s.dialect.placeholder(1)), name,
); err != nil {
return err
}
}
return tx.Commit()
}
// DeleteMetricDataIfDisabled removes a metric's data only while its retention
// policy is still disabled. It prevents a delayed background cleanup from
// deleting data after an administrator has re-enabled the metric.
func (s *Store) DeleteMetricDataIfDisabled(ctx context.Context, name string) (bool, error) {
if err := s.ensureOpen(); err != nil {
return false, err
}
name = strings.TrimSpace(name)
if name == "" {
return false, fmt.Errorf("%w: metric name is required", ErrInvalidArgument)
}
s.retentionMu.Lock()
defer s.retentionMu.Unlock()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return false, err
}
defer func() { _ = tx.Rollback() }()
var retentionDays int
if err := tx.QueryRowContext(ctx,
fmt.Sprintf(`SELECT retention_days FROM %s WHERE name = %s`, s.tables.definitions, s.dialect.placeholder(1)),
name,
).Scan(&retentionDays); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, fmt.Errorf("%w: metric %q", ErrNotFound, name)
}
return false, err
}
if retentionDays != 0 {
return false, nil
}
for _, table := range []string{s.tables.points, s.tables.rollups, s.tables.watermarks} {
if _, err := tx.ExecContext(ctx,
fmt.Sprintf(`DELETE FROM %s WHERE metric_name = %s`, table, s.dialect.placeholder(1)), name,
); err != nil {
return false, err
}
}
if err := tx.Commit(); err != nil {
return false, err
}
return true, nil
}
// DeleteEntity deletes all raw and rollup data for one entity across every metric.
//
// DeleteEntity 删除某个实体在所有指标下的原始点和 rollup 数据。
@@ -796,14 +599,6 @@ func (s *Store) DeleteSeries(ctx context.Context, filter Query) (int64, error) {
}
total += n
}
if strings.TrimSpace(filter.EntityID) == "" && len(filter.Tags) == 0 {
if _, err := tx.ExecContext(ctx,
fmt.Sprintf(`DELETE FROM %s WHERE metric_name = %s`, s.tables.watermarks, s.dialect.placeholder(1)),
filter.MetricName,
); err != nil {
return total, err
}
}
if err := tx.Commit(); err != nil {
return total, err
}
@@ -827,15 +622,6 @@ func (s *Store) WriteBatch(ctx context.Context, points []Point) error {
if len(points) == 0 {
return nil
}
s.retentionMu.RLock()
defer s.retentionMu.RUnlock()
points, err := s.filterDisabledMetricPoints(ctx, points)
if err != nil {
return err
}
if len(points) == 0 {
return nil
}
const batchSize = 1000
// A single chunk is one statement; send it directly. Multiple chunks are
// wrapped in one transaction so the batch is all-or-nothing rather than
@@ -860,32 +646,6 @@ func (s *Store) WriteBatch(ctx context.Context, points []Point) error {
return tx.Commit()
}
// filterDisabledMetricPoints rejects points without a definition and drops
// points whose definition has zero retention. Definitions are the source of
// truth for a metric's retention and lifecycle, so accepting an unknown name
// would create data that compaction and retention cleanup cannot manage.
func (s *Store) filterDisabledMetricPoints(ctx context.Context, points []Point) ([]Point, error) {
defs, err := s.ListMetrics(ctx)
if err != nil {
return nil, err
}
definitions := make(map[string]Definition, len(defs))
for _, def := range defs {
definitions[def.Name] = def
}
filtered := make([]Point, 0, len(points))
for _, point := range points {
def, ok := definitions[point.MetricName]
if !ok {
return nil, fmt.Errorf("%w: metric %q", ErrNotFound, point.MetricName)
}
if def.RetentionDays > 0 {
filtered = append(filtered, point)
}
}
return filtered, nil
}
// execer is satisfied by both *sql.DB and *sql.Tx, letting writeBatch run either
// standalone or inside the batch transaction.
//
@@ -997,63 +757,6 @@ func (s *Store) Query(ctx context.Context, query Query) ([]Point, error) {
return out, nil
}
// EntityIDs returns distinct entity ids that have raw or rollup data matching a query.
//
// EntityIDs 返回在原始点或 rollup 中匹配查询条件的实体 ID。
func (s *Store) EntityIDs(ctx context.Context, query Query) ([]string, error) {
if err := s.ensureOpen(); err != nil {
return nil, err
}
if err := query.Validate(); err != nil {
return nil, err
}
query = query.normalized()
pointsWhere, args := s.buildWhere(query)
rollupArgsStart := len(args)
rollupArgs := []any{query.MetricName, query.Start.UnixNano(), query.End.UnixNano()}
rollupParts := []string{
"metric_name = " + s.dialect.placeholder(rollupArgsStart+1),
"bucket_nano >= " + s.dialect.placeholder(rollupArgsStart+2),
"bucket_nano <= " + s.dialect.placeholder(rollupArgsStart+3),
}
if strings.TrimSpace(query.EntityID) != "" {
rollupArgs = append(rollupArgs, query.EntityID)
rollupParts = append(rollupParts, "entity_id = "+s.dialect.placeholder(rollupArgsStart+len(rollupArgs)))
}
for _, k := range sortedKeys(query.Tags) {
rollupArgs = append(rollupArgs, query.Tags[k])
rollupParts = append(rollupParts, s.dialect.jsonExtractEquals("tags", k, s.dialect.placeholder(rollupArgsStart+len(rollupArgs))))
}
args = append(args, rollupArgs...)
sqlText := fmt.Sprintf(`SELECT DISTINCT entity_id FROM (
SELECT entity_id FROM %s WHERE %s
UNION
SELECT entity_id FROM %s WHERE %s
) AS metric_entities ORDER BY entity_id ASC`,
s.tables.points, pointsWhere,
s.tables.rollups, strings.Join(rollupParts, " AND "),
)
rows, err := s.reader().QueryContext(ctx, sqlText, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var entityID string
if err := rows.Scan(&entityID); err != nil {
return nil, err
}
if entityID != "" {
out = append(out, entityID)
}
}
return out, rows.Err()
}
// Latest loads the newest points for a metric and entity.
//
// Latest 查询某指标和实体的最新采样点。
@@ -1104,97 +807,6 @@ func (s *Store) Latest(ctx context.Context, metricName, entityID string, limit i
return out, rows.Err()
}
// LatestBefore returns the newest retained point before an exclusive boundary.
// It checks both indexed raw points and rollup last-values so callers do not
// need to scan and aggregate an entire retention window to restore a counter.
func (s *Store) LatestBefore(ctx context.Context, metricName, entityID string, before time.Time) (Point, bool, error) {
if err := s.ensureOpen(); err != nil {
return Point{}, false, err
}
if strings.TrimSpace(metricName) == "" {
return Point{}, false, fmt.Errorf("%w: metric name is required", ErrInvalidArgument)
}
if strings.TrimSpace(entityID) == "" {
return Point{}, false, fmt.Errorf("%w: entity id is required", ErrInvalidArgument)
}
if before.IsZero() {
return Point{}, false, fmt.Errorf("%w: before time is required", ErrInvalidArgument)
}
beforeNano := before.UTC().UnixNano()
var latest Point
found := false
rawSQL := fmt.Sprintf(
`SELECT metric_name, entity_id, ts_nano, value, tags, labels FROM %s
WHERE metric_name = %s AND entity_id = %s AND ts_nano < %s
ORDER BY ts_nano DESC LIMIT 1`,
s.tables.points, s.dialect.placeholder(1), s.dialect.placeholder(2), s.dialect.placeholder(3),
)
var rawTags, rawLabels any
var rawNano int64
err := s.reader().QueryRowContext(ctx, rawSQL, metricName, entityID, beforeNano).Scan(
&latest.MetricName, &latest.EntityID, &rawNano, &latest.Value, &rawTags, &rawLabels,
)
if err == nil {
latest.Timestamp = time.Unix(0, rawNano).UTC()
latest.Tags, err = decodeMap(rawTags)
if err != nil {
return Point{}, false, err
}
latest.Labels, err = decodeMap(rawLabels)
if err != nil {
return Point{}, false, err
}
found = true
} else if !errors.Is(err, sql.ErrNoRows) {
return Point{}, false, err
}
if found {
rawCutoff := s.cfg.RollupPolicy.rawCutoff(before)
if rawCutoff.IsZero() || !latest.Timestamp.Before(rawCutoff) {
return latest, true, nil
}
}
for _, tier := range s.cfg.RollupPolicy.Tiers {
rollupSQL := fmt.Sprintf(
`SELECT tags, last_val, last_ts FROM %s
WHERE metric_name = %s AND entity_id = %s AND resolution_nano = %s AND last_ts < %s
ORDER BY last_ts DESC LIMIT 1`,
s.tables.rollups, s.dialect.placeholder(1), s.dialect.placeholder(2),
s.dialect.placeholder(3), s.dialect.placeholder(4),
)
var tags any
var value float64
var timestamp int64
err = s.reader().QueryRowContext(ctx, rollupSQL, metricName, entityID, tier.Interval.Nanoseconds(), beforeNano).Scan(
&tags, &value, &timestamp,
)
if errors.Is(err, sql.ErrNoRows) {
continue
}
if err != nil {
return Point{}, false, err
}
if found && timestamp <= latest.Timestamp.UnixNano() {
continue
}
decodedTags, err := decodeMap(tags)
if err != nil {
return Point{}, false, err
}
latest = Point{
MetricName: metricName,
EntityID: entityID,
Timestamp: time.Unix(0, timestamp).UTC(),
Value: value,
Tags: decodedTags,
}
found = true
}
return latest, found, nil
}
// Aggregate computes bucketed aggregates from raw points.
//
// Aggregate 对原始点执行分桶聚合,能下推到 SQL 的聚合会优先下推。
@@ -1207,9 +819,9 @@ func (s *Store) Aggregate(ctx context.Context, query AggregateQuery) ([]Aggregat
}
// Push simple reductions (avg/min/max/sum/count) down to SQL via GROUP BY on
// a time bucket so large ranges don't pull every raw point into memory.
// Percentiles, first/last and rate need the ordered raw series, so those fall
// back to the in-memory aggregator.
if valueExpr, ok := sqlAggValueExpr(s.cfg.Driver, query.Aggregation); ok {
// Percentiles, first/last, rate and empty-bucket filling need the ordered
// raw series, so those fall back to the in-memory aggregator.
if valueExpr, ok := sqlAggValueExpr(s.cfg.Driver, query.Aggregation); ok && !query.FillEmpty {
return s.aggregateInSQL(ctx, query, valueExpr)
}
// In-memory fallback. Strip the embedded raw-point Limit/Offset so the full
@@ -1269,7 +881,7 @@ func (s *Store) aggregateInSQL(ctx context.Context, query AggregateQuery, valueE
// bucket column so writes stay cheap and the bucket size can vary per query.
bucketExpr := fmt.Sprintf("(ts_nano - ((ts_nano %% %d) + %d) %% %d)", interval, interval, interval)
sqlText := fmt.Sprintf(
`SELECT %s AS bucket, metric_name, entity_id, tags_hash, tags, %s AS agg_value, COUNT(*) AS agg_count FROM %s WHERE %s GROUP BY bucket, metric_name, entity_id, tags_hash, tags ORDER BY bucket ASC, metric_name ASC, entity_id ASC, tags_hash ASC`,
`SELECT %s AS bucket, %s AS agg_value, COUNT(*) AS agg_count FROM %s WHERE %s GROUP BY bucket ORDER BY bucket ASC`,
bucketExpr, valueExpr, s.tables.points, where,
)
// Page over aggregate buckets (BucketLimit/BucketOffset), not raw points.
@@ -1291,25 +903,17 @@ func (s *Store) aggregateInSQL(ctx context.Context, query AggregateQuery, valueE
out := make([]AggregatePoint, 0)
for rows.Next() {
var bucket int64
var metricName, entityID, tagsHash string
var rawTags any
var value float64
var count int
if err := rows.Scan(&bucket, &metricName, &entityID, &tagsHash, &rawTags, &value, &count); err != nil {
return nil, err
}
_ = tagsHash
tags, err := decodeMap(rawTags)
if err != nil {
if err := rows.Scan(&bucket, &value, &count); err != nil {
return nil, err
}
out = append(out, AggregatePoint{
MetricName: metricName,
EntityID: entityID,
MetricName: q.MetricName,
EntityID: q.EntityID,
Bucket: time.Unix(0, bucket).UTC(),
Value: value,
Count: count,
Tags: tags,
})
}
return out, rows.Err()
@@ -1369,15 +973,11 @@ func (s *Store) CleanupExpired(ctx context.Context, now time.Time) (int64, error
}
var total int64
for _, def := range defs {
if def.RetentionDays == 0 {
deleted, err := s.DeleteSeries(ctx, Query{MetricName: def.Name})
if err != nil {
return total, err
}
total += deleted
continue
retentionDays := def.RetentionDays
if retentionDays <= 0 {
retentionDays = s.cfg.DefaultRetentionDays
}
deleted, err := s.DeleteBefore(ctx, def.Name, now.AddDate(0, 0, -def.RetentionDays))
deleted, err := s.DeleteBefore(ctx, def.Name, now.AddDate(0, 0, -retentionDays))
if err != nil {
return total, err
}
+1 -59
View File
@@ -21,7 +21,7 @@ func TestSQLiteStoreWriteQueryAggregate(t *testing.T) {
}
defer store.Close()
if err := store.CreateMetric(ctx, Definition{Name: "cpu.usage", Type: TypeGauge, Unit: "%", RetentionDays: 30}); err != nil {
if err := store.CreateMetric(ctx, Definition{Name: "cpu.usage", Type: TypeGauge, Unit: "%"}); err != nil {
t.Fatalf("create metric: %v", err)
}
@@ -94,9 +94,6 @@ func TestWriteRejectsNonFiniteValues(t *testing.T) {
t.Fatalf("open sqlite store: %v", err)
}
defer store.Close()
if err := store.CreateMetric(ctx, Definition{Name: "bad", Type: TypeGauge, RetentionDays: 1}); err != nil {
t.Fatalf("create metric: %v", err)
}
for _, value := range []float64{math.NaN(), math.Inf(1), math.Inf(-1)} {
err := store.Write(ctx, Point{
@@ -111,61 +108,6 @@ func TestWriteRejectsNonFiniteValues(t *testing.T) {
}
}
func TestWriteBatchRequiresMetricDefinition(t *testing.T) {
ctx := context.Background()
store := newMemStore(t)
point := Point{MetricName: "custom.metric", EntityID: "server-1", Timestamp: time.Now().UTC(), Value: 1}
if err := store.Write(ctx, point); !errors.Is(err, ErrNotFound) {
t.Fatalf("write undefined metric error = %v, want ErrNotFound", err)
}
if err := store.CreateMetric(ctx, Definition{Name: point.MetricName, Type: TypeGauge, RetentionDays: 1}); err != nil {
t.Fatalf("register dynamic metric: %v", err)
}
if err := store.Write(ctx, point); err != nil {
t.Fatalf("write registered dynamic metric: %v", err)
}
}
func TestUpdateMetricRetentionDefersDisabledMetricCleanup(t *testing.T) {
ctx := context.Background()
store := newMemStore(t)
const metricName = "deferred.cleanup"
if err := store.CreateMetric(ctx, Definition{Name: metricName, Type: TypeGauge, RetentionDays: 1}); err != nil {
t.Fatalf("create metric: %v", err)
}
point := Point{MetricName: metricName, EntityID: "server-1", Timestamp: time.Now().UTC(), Value: 1}
if err := store.Write(ctx, point); err != nil {
t.Fatalf("write point: %v", err)
}
if _, err := store.UpdateMetricRetention(ctx, metricName, 0); err != nil {
t.Fatalf("disable metric retention: %v", err)
}
points, err := store.Query(ctx, Query{MetricName: metricName, EntityID: point.EntityID, Start: point.Timestamp.Add(-time.Second), End: point.Timestamp.Add(time.Second)})
if err != nil {
t.Fatalf("query retained point before cleanup: %v", err)
}
if len(points) != 1 {
t.Fatalf("points before cleanup = %d, want 1", len(points))
}
deleted, err := store.DeleteMetricDataIfDisabled(ctx, metricName)
if err != nil {
t.Fatalf("delete disabled metric data: %v", err)
}
if !deleted {
t.Fatal("expected disabled metric data to be deleted")
}
points, err = store.Query(ctx, Query{MetricName: metricName, EntityID: point.EntityID, Start: point.Timestamp.Add(-time.Second), End: point.Timestamp.Add(time.Second)})
if err != nil {
t.Fatalf("query cleaned metric: %v", err)
}
if len(points) != 0 {
t.Fatalf("points after cleanup = %d, want 0", len(points))
}
}
// TestSQLiteInDirCreatesDirectoryAndAppliesPragmas verifies SQLite file setup and PRAGMAs.
//
// TestSQLiteInDirCreatesDirectoryAndAppliesPragmas 验证 SQLite 文件初始化和 PRAGMA 设置。
+10 -15
View File
@@ -143,10 +143,9 @@ type Definition struct {
//
// Unit 表示数值单位,例如 bytes 或 percent。
Unit string `json:"unit,omitempty"`
// RetentionDays controls historical data retention for this metric. A value
// of zero disables persistence and removes existing metric data.
// RetentionDays controls raw-point retention for this metric.
//
// RetentionDays 控制该指标历史数据的保留天数;零表示禁用持久化并清除已有数据。
// RetentionDays 控制该指标原始点的保留天数。
RetentionDays int `json:"retention_days,omitempty"`
// Metadata stores caller-defined metric metadata.
//
@@ -164,11 +163,14 @@ type Definition struct {
// withDefaults fills default values on a metric definition.
//
// withDefaults 为指标定义填充默认类型。
func (d Definition) withDefaults() Definition {
// withDefaults 为指标定义填充默认类型和默认保留天数。
func (d Definition) withDefaults(defaultRetentionDays int) Definition {
if d.Type == "" {
d.Type = TypeGauge
}
if d.RetentionDays == 0 {
d.RetentionDays = defaultRetentionDays
}
return d
}
@@ -343,13 +345,10 @@ type AggregateQuery struct {
//
// Interval 是每个聚合桶的宽度。
Interval time.Duration `json:"interval"`
// PreserveSeries keeps entity/tag identities as separate aggregate series on
// rollup-backed reads. The default preserves the historical rollup behavior
// of merging all matched series into each output bucket.
// FillEmpty emits zero-count buckets for empty intervals.
//
// PreserveSeries 在基于 rollup 的读取中保留 entity/tag 维度为独立聚合序列。
// 默认值保留历史 rollup 行为:把匹配到的序列合并进同一个输出桶。
PreserveSeries bool `json:"preserve_series,omitempty"`
// FillEmpty 会为空时间段输出零计数桶。
FillEmpty bool `json:"fill_empty,omitempty"`
// BucketLimit and BucketOffset page over the produced aggregate buckets, not
// the underlying raw points. They are applied consistently across every
// backend and aggregation type. The embedded Query.Limit/Query.Offset are
@@ -417,10 +416,6 @@ type AggregatePoint struct {
//
// Count 是该桶代表的点数量。
Count int `json:"count"`
// Tags identify the logical series represented by the bucket.
//
// Tags 标识该聚合桶所属的逻辑序列。
Tags map[string]string `json:"tags,omitempty"`
}
// Stats stores or computes summary statistics for a point series.
+30
View File
@@ -0,0 +1,30 @@
package migrations
import "time"
// ClientInfo is the legacy table shape for migrating pre-Client model data.
type ClientInfo struct {
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
Name string `json:"name" gorm:"type:varchar(100);not null"`
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
Arch string `json:"arch" gorm:"type:varchar(50)"`
CpuCores int `json:"cpu_cores" gorm:"type:int"`
OS string `json:"os" gorm:"type:varchar(100)"`
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
Region string `json:"region" gorm:"type:varchar(100)"`
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
Weight int `json:"weight" gorm:"type:int"`
Price float64 `json:"price"`
BillingCycle int `json:"billing_cycle"`
ExpiredAt time.Time `json:"expired_at" gorm:"type:timestamp"`
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -3,13 +3,13 @@ package migrations
import (
"encoding/json"
"fmt"
logger "github.com/komari-monitor/komari/utils/log"
"log"
"reflect"
"strings"
"time"
"github.com/komari-monitor/komari/database/models"
appconfig "github.com/komari-monitor/komari/internal/config"
appconfig "github.com/komari-monitor/komari/pkg/config"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -27,6 +27,8 @@ type legacyModelConfig struct {
EulaAccepted bool `json:"eula_accepted" gorm:"default:false"`
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'ip-api'"`
NezhaCompatEnabled bool `json:"nezha_compat_enabled" gorm:"default:false"`
NezhaCompatListen string `json:"nezha_compat_listen" gorm:"type:varchar(100);default:''"`
OAuthEnabled bool `json:"o_auth_enabled" gorm:"default:false"`
OAuthProvider string `json:"o_auth_provider" gorm:"type:varchar(50);default:'github'"`
DisablePasswordLogin bool `json:"disable_password_login" gorm:"default:false"`
@@ -39,8 +41,11 @@ type legacyModelConfig struct {
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" gorm:"default:7"`
LoginNotification bool `json:"login_notification" gorm:"default:false"`
TrafficLimitPercentage float64 `json:"traffic_limit_percentage" gorm:"default:80.00"`
CreatedAt time.Time
UpdatedAt time.Time
RecordEnabled bool `json:"record_enabled" gorm:"default:true"`
RecordPreserveTime int `json:"record_preserve_time" gorm:"default:720"`
PingRecordPreserveTime int `json:"ping_record_preserve_time" gorm:"default:24"`
CreatedAt models.LocalTime
UpdatedAt models.LocalTime
}
func (legacyModelConfig) TableName() string {
@@ -61,9 +66,12 @@ type legacyConfig struct {
BaseScriptsURLKey string `json:"base_scripts_url"`
GeoIpEnabled bool `json:"geo_ip_enabled"`
GeoIpProvider string `json:"geo_ip_provider"`
NezhaCompatEnabled bool `json:"nezha_compat_enabled"`
NezhaCompatListen string `json:"nezha_compat_listen"`
OAuthEnabled bool `json:"o_auth_enabled"`
OAuthProvider string `json:"o_auth_provider"`
DisablePasswordLogin bool `json:"disable_password_login"`
CloudflareTunnelToken string `json:"cloudflare_tunnel_token"`
CustomHead string `json:"custom_head"`
CustomBody string `json:"custom_body"`
NotificationEnabled bool `json:"notification_enabled"`
@@ -73,6 +81,9 @@ type legacyConfig struct {
ExpireNotificationLeadDays int `json:"expire_notification_lead_days"`
LoginNotification bool `json:"login_notification"`
TrafficLimitPercentage float64 `json:"traffic_limit_percentage"`
RecordEnabled bool `json:"record_enabled"`
RecordPreserveTime int `json:"record_preserve_time"`
PingRecordPreserveTime int `json:"ping_record_preserve_time"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -97,9 +108,6 @@ func Run(ctx Context) error {
}
legacyConfigTable := hasLegacyConfigTable(db)
if err := migrateLegacyTimestampColumns(db); err != nil {
return err
}
if legacyConfigTable {
if err := migrateLegacyOidcConfig(db); err != nil {
@@ -109,6 +117,7 @@ func Run(ctx Context) error {
return err
}
}
if err := migrateLegacyClientInfo(db); err != nil {
return err
}
@@ -123,36 +132,10 @@ func Run(ctx Context) error {
return err
}
}
if err := migrateDeprecatedMetricRetentionConfig(db); err != nil {
return err
}
if err := migrateRemovedCompatibilityConfig(db); err != nil {
return err
}
if err := markTimestampMigrationDone(db); err != nil {
return fmt.Errorf("mark UTC timestamp migration done: %w", err)
}
return nil
}
func migrateDeprecatedMetricRetentionConfig(db *gorm.DB) error {
if !db.Migrator().HasTable(&appconfig.ConfigItem{}) {
return nil
}
return db.Delete(&appconfig.ConfigItem{}, "key = ?", "metric_retention_days").Error
}
func migrateRemovedCompatibilityConfig(db *gorm.DB) error {
if !db.Migrator().HasTable(&appconfig.ConfigItem{}) {
return nil
}
return db.Delete(&appconfig.ConfigItem{}, "key IN ?", []string{
"nezha_compat_enabled",
"nezha_compat_listen",
}).Error
}
func hasLegacyConfigTable(db *gorm.DB) bool {
if !db.Migrator().HasTable("configs") {
return false
@@ -176,7 +159,7 @@ func hasTableColumn(db *gorm.DB, tableName, columnName string) bool {
func migrateLegacyLoadNotification(db *gorm.DB) error {
if db.Migrator().HasColumn(&models.LoadNotification{}, "client") {
logger.InfoArgs("migration", "[>0.1.4] Rebuilding LoadNotification table....")
log.Println("[>0.1.4] Rebuilding LoadNotification table....")
return db.Migrator().DropTable(&models.LoadNotification{})
}
return nil
@@ -187,7 +170,7 @@ func migrateLegacyOidcConfig(db *gorm.DB) error {
return nil
}
logger.InfoArgs("migration", "[>1.0.2] Merge OidcProvider table....")
log.Println("[>1.0.2] Merge OidcProvider table....")
var oldData struct {
OAuthClientID string `gorm:"column:o_auth_client_id"`
OAuthClientSecret string `gorm:"column:o_auth_client_secret"`
@@ -224,7 +207,7 @@ func migrateLegacyMessageSenderConfig(db *gorm.DB) error {
return nil
}
logger.InfoArgs("migration", "[>1.0.2] Migrate MessageSender configuration....")
log.Println("[>1.0.2] Migrate MessageSender configuration....")
var oldData struct {
TelegramBotToken string `gorm:"column:telegram_bot_token"`
TelegramChatID string `gorm:"column:telegram_chat_id"`
@@ -308,7 +291,7 @@ func saveLegacyMessageSenderConfig(db *gorm.DB, name string, config map[string]i
}
func migrateLegacyConfigToItems(db *gorm.DB) error {
logger.InfoArgs("migration", "[>1.1.4] Moving legacy config data...")
log.Println("[>1.1.4] Moving legacy config data...")
var oldData legacyConfig
if err := db.Order("id desc").First(&oldData).Error; err != nil {
@@ -371,7 +354,7 @@ func migrateLegacyClientInfo(db *gorm.DB) error {
return nil
}
logger.InfoArgs("migration", "[>0.0.5] Legacy ClientInfo table detected, starting data migration...")
log.Println("[>0.0.5] Legacy ClientInfo table detected, starting data migration...")
if err := db.AutoMigrate(&models.Client{}); err != nil {
return err
}
@@ -384,7 +367,7 @@ func migrateLegacyClientInfo(db *gorm.DB) error {
for _, info := range clientInfos {
var client models.Client
if err := db.Where("uuid = ?", info.UUID).First(&client).Error; err != nil {
logger.Errorf("migration", "Could not find Client record with UUID %s: %v", info.UUID, err)
log.Printf("Could not find Client record with UUID %s: %v", info.UUID, err)
continue
}
@@ -407,7 +390,7 @@ func migrateLegacyClientInfo(db *gorm.DB) error {
client.Weight = info.Weight
client.Price = info.Price
client.BillingCycle = info.BillingCycle
client.ExpiredAt = info.ExpiredAt
client.ExpiredAt = models.FromTime(info.ExpiredAt)
if err := db.Save(&client).Error; err != nil {
return fmt.Errorf("update Client record %s: %w", info.UUID, err)
}
@@ -416,7 +399,7 @@ func migrateLegacyClientInfo(db *gorm.DB) error {
if err := db.Migrator().RenameTable("client_infos", "client_infos_backup"); err != nil {
return fmt.Errorf("backup legacy ClientInfo table: %w", err)
}
logger.InfoArgs("migration", "Data migration completed, old table has been backed up as client_infos_backup")
log.Println("Data migration completed, old table has been backed up as client_infos_backup")
return nil
}
Loaded 100 of 208 files, more files were not shown because too many files have changed in this diff. Show more