This commit is contained in:
@@ -31,6 +31,9 @@ const (
|
||||
defaultAuthJWTIssuer = "vctp"
|
||||
defaultAuthJWTAudience = "vctp-api"
|
||||
defaultAuthClockSkewSeconds = 60
|
||||
|
||||
scheduledAggregationEngineGo = "go"
|
||||
scheduledAggregationEngineSQL = "sql"
|
||||
)
|
||||
|
||||
type Settings struct {
|
||||
@@ -94,6 +97,11 @@ type SettingsYML struct {
|
||||
HourlySnapshotTimeoutSeconds int `yaml:"hourly_snapshot_timeout_seconds"`
|
||||
HourlySnapshotRetrySeconds int `yaml:"hourly_snapshot_retry_seconds"`
|
||||
HourlySnapshotMaxRetries int `yaml:"hourly_snapshot_max_retries"`
|
||||
CaptureWriteBatchSize int `yaml:"capture_write_batch_size"`
|
||||
SnapshotTableCompatMode *bool `yaml:"snapshot_table_compat_mode"`
|
||||
AsyncReportGeneration *bool `yaml:"async_report_generation"`
|
||||
PostgresVmHourlyPartitioning *bool `yaml:"postgres_vm_hourly_partitioning_enabled"`
|
||||
ScheduledAggregationEngine string `yaml:"scheduled_aggregation_engine"`
|
||||
DailyJobTimeoutSeconds int `yaml:"daily_job_timeout_seconds"`
|
||||
MonthlyJobTimeoutSeconds int `yaml:"monthly_job_timeout_seconds"`
|
||||
MonthlyAggregationGranularity string `yaml:"monthly_aggregation_granularity"`
|
||||
@@ -250,6 +258,29 @@ func applyDefaultsAndValidateSettings(cfg *SettingsYML) error {
|
||||
if s.AuthClockSkewSeconds == 0 {
|
||||
s.AuthClockSkewSeconds = defaultAuthClockSkewSeconds
|
||||
}
|
||||
if s.CaptureWriteBatchSize <= 0 {
|
||||
s.CaptureWriteBatchSize = 1000
|
||||
}
|
||||
if s.SnapshotTableCompatMode == nil {
|
||||
v := true
|
||||
s.SnapshotTableCompatMode = &v
|
||||
}
|
||||
if s.AsyncReportGeneration == nil {
|
||||
v := true
|
||||
s.AsyncReportGeneration = &v
|
||||
}
|
||||
if s.PostgresVmHourlyPartitioning == nil {
|
||||
v := false
|
||||
s.PostgresVmHourlyPartitioning = &v
|
||||
}
|
||||
s.ScheduledAggregationEngine = strings.ToLower(strings.TrimSpace(s.ScheduledAggregationEngine))
|
||||
if s.ScheduledAggregationEngine == "" {
|
||||
s.ScheduledAggregationEngine = scheduledAggregationEngineGo
|
||||
}
|
||||
s.MonthlyAggregationGranularity = strings.ToLower(strings.TrimSpace(s.MonthlyAggregationGranularity))
|
||||
if s.MonthlyAggregationGranularity == "" {
|
||||
s.MonthlyAggregationGranularity = "daily"
|
||||
}
|
||||
s.AuthJWTSigningKey = strings.TrimSpace(s.AuthJWTSigningKey)
|
||||
s.LDAPBindAddress = strings.TrimSpace(s.LDAPBindAddress)
|
||||
s.LDAPBaseDN = strings.TrimSpace(s.LDAPBaseDN)
|
||||
@@ -265,6 +296,11 @@ func applyDefaultsAndValidateSettings(cfg *SettingsYML) error {
|
||||
if s.AuthClockSkewSeconds < 0 {
|
||||
return errors.New("settings.auth_clock_skew_seconds must be >= 0")
|
||||
}
|
||||
switch s.ScheduledAggregationEngine {
|
||||
case scheduledAggregationEngineGo, scheduledAggregationEngineSQL:
|
||||
default:
|
||||
return fmt.Errorf("settings.scheduled_aggregation_engine must be %q or %q", scheduledAggregationEngineGo, scheduledAggregationEngineSQL)
|
||||
}
|
||||
|
||||
if len(s.AuthGroupRoleMappings) > 0 {
|
||||
normalized := make(map[string]string, len(s.AuthGroupRoleMappings))
|
||||
|
||||
@@ -63,6 +63,45 @@ func TestReadYMLSettingsAppliesAuthDefaults(t *testing.T) {
|
||||
if got.AuthClockSkewSeconds != defaultAuthClockSkewSeconds {
|
||||
t.Fatalf("expected default auth_clock_skew_seconds=%d, got %d", defaultAuthClockSkewSeconds, got.AuthClockSkewSeconds)
|
||||
}
|
||||
if got.CaptureWriteBatchSize != 1000 {
|
||||
t.Fatalf("expected default capture_write_batch_size=1000, got %d", got.CaptureWriteBatchSize)
|
||||
}
|
||||
if got.SnapshotTableCompatMode == nil || !*got.SnapshotTableCompatMode {
|
||||
t.Fatalf("expected default snapshot_table_compat_mode=true, got %#v", got.SnapshotTableCompatMode)
|
||||
}
|
||||
if got.AsyncReportGeneration == nil || !*got.AsyncReportGeneration {
|
||||
t.Fatalf("expected default async_report_generation=true, got %#v", got.AsyncReportGeneration)
|
||||
}
|
||||
if got.PostgresVmHourlyPartitioning == nil || *got.PostgresVmHourlyPartitioning {
|
||||
t.Fatalf("expected default postgres_vm_hourly_partitioning_enabled=false, got %#v", got.PostgresVmHourlyPartitioning)
|
||||
}
|
||||
if got.ScheduledAggregationEngine != scheduledAggregationEngineGo {
|
||||
t.Fatalf("expected default scheduled_aggregation_engine=%q, got %q", scheduledAggregationEngineGo, got.ScheduledAggregationEngine)
|
||||
}
|
||||
if got.MonthlyAggregationGranularity != "daily" {
|
||||
t.Fatalf("expected default monthly_aggregation_granularity=daily, got %q", got.MonthlyAggregationGranularity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadYMLSettingsRejectsInvalidScheduledAggregationEngine(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
settingsPath := filepath.Join(tmpDir, "vctp.yml")
|
||||
content := `settings:
|
||||
scheduled_aggregation_engine: "hybrid"
|
||||
`
|
||||
if err := os.WriteFile(settingsPath, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("failed to write settings file: %v", err)
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
s := New(logger, settingsPath)
|
||||
err := s.ReadYMLSettings()
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid scheduled_aggregation_engine to fail")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "scheduled_aggregation_engine") {
|
||||
t.Fatalf("expected error to mention scheduled_aggregation_engine, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadYMLSettingsRejectsInvalidAuthMode(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
"vctp/db"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type AggregationBenchmarkStats struct {
|
||||
Runs int
|
||||
Min time.Duration
|
||||
Median time.Duration
|
||||
Avg time.Duration
|
||||
Max time.Duration
|
||||
}
|
||||
|
||||
type AggregationBenchmarkReport struct {
|
||||
Runs int
|
||||
|
||||
DailyWindowStart time.Time
|
||||
DailyWindowEnd time.Time
|
||||
DailyGo AggregationBenchmarkStats
|
||||
DailySQL AggregationBenchmarkStats
|
||||
DailyGoRowsWritten int64
|
||||
DailySQLRowsWritten int64
|
||||
|
||||
MonthlyWindowStart time.Time
|
||||
MonthlyWindowEnd time.Time
|
||||
MonthlyGo AggregationBenchmarkStats
|
||||
MonthlySQL AggregationBenchmarkStats
|
||||
MonthlyGoRowsWritten int64
|
||||
MonthlySQLRowsWritten int64
|
||||
}
|
||||
|
||||
// RunCanonicalAggregationBenchmark compares Go and SQL aggregation cores on canonical cache tables.
|
||||
func (c *CronTask) RunCanonicalAggregationBenchmark(ctx context.Context, runs int) (AggregationBenchmarkReport, error) {
|
||||
if runs <= 0 {
|
||||
runs = 3
|
||||
}
|
||||
report := AggregationBenchmarkReport{Runs: runs}
|
||||
dbConn := c.Database.DB()
|
||||
|
||||
hourlyStart, hourlyEnd, err := latestDailyWindowFromHourlyCache(ctx, dbConn)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
if !hourlyStart.IsZero() {
|
||||
report.DailyWindowStart = hourlyStart
|
||||
report.DailyWindowEnd = hourlyEnd
|
||||
goDurations := make([]time.Duration, 0, runs)
|
||||
sqlDurations := make([]time.Duration, 0, runs)
|
||||
var goRows, sqlRows int64
|
||||
for i := 0; i < runs; i++ {
|
||||
dur, rows, runErr := c.benchmarkDailyGoCore(ctx, hourlyStart, hourlyEnd)
|
||||
if runErr != nil {
|
||||
return report, fmt.Errorf("daily go benchmark run %d failed: %w", i+1, runErr)
|
||||
}
|
||||
goDurations = append(goDurations, dur)
|
||||
goRows = rows
|
||||
|
||||
dur, rows, runErr = c.benchmarkDailySQLCore(ctx, hourlyStart, hourlyEnd)
|
||||
if runErr != nil {
|
||||
return report, fmt.Errorf("daily sql benchmark run %d failed: %w", i+1, runErr)
|
||||
}
|
||||
sqlDurations = append(sqlDurations, dur)
|
||||
sqlRows = rows
|
||||
}
|
||||
report.DailyGo = summarizeDurations(goDurations)
|
||||
report.DailySQL = summarizeDurations(sqlDurations)
|
||||
report.DailyGoRowsWritten = goRows
|
||||
report.DailySQLRowsWritten = sqlRows
|
||||
}
|
||||
|
||||
monthlyStart, monthlyEnd, err := latestMonthlyWindowFromDailyRollup(ctx, dbConn)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
if !monthlyStart.IsZero() {
|
||||
report.MonthlyWindowStart = monthlyStart
|
||||
report.MonthlyWindowEnd = monthlyEnd
|
||||
goDurations := make([]time.Duration, 0, runs)
|
||||
sqlDurations := make([]time.Duration, 0, runs)
|
||||
var goRows, sqlRows int64
|
||||
for i := 0; i < runs; i++ {
|
||||
dur, rows, runErr := c.benchmarkMonthlyGoCore(ctx, monthlyStart, monthlyEnd)
|
||||
if runErr != nil {
|
||||
return report, fmt.Errorf("monthly go benchmark run %d failed: %w", i+1, runErr)
|
||||
}
|
||||
goDurations = append(goDurations, dur)
|
||||
goRows = rows
|
||||
|
||||
dur, rows, runErr = c.benchmarkMonthlySQLCore(ctx, monthlyStart, monthlyEnd)
|
||||
if runErr != nil {
|
||||
return report, fmt.Errorf("monthly sql benchmark run %d failed: %w", i+1, runErr)
|
||||
}
|
||||
sqlDurations = append(sqlDurations, dur)
|
||||
sqlRows = rows
|
||||
}
|
||||
report.MonthlyGo = summarizeDurations(goDurations)
|
||||
report.MonthlySQL = summarizeDurations(sqlDurations)
|
||||
report.MonthlyGoRowsWritten = goRows
|
||||
report.MonthlySQLRowsWritten = sqlRows
|
||||
}
|
||||
|
||||
if report.DailyWindowStart.IsZero() && report.MonthlyWindowStart.IsZero() {
|
||||
return report, fmt.Errorf("no benchmarkable canonical windows found (vm_hourly_stats/vm_daily_rollup are empty)")
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func (c *CronTask) benchmarkDailyGoCore(ctx context.Context, dayStart, dayEnd time.Time) (time.Duration, int64, error) {
|
||||
tableName, err := benchmarkSummaryTableName("benchmark_daily_go")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
dbConn := c.Database.DB()
|
||||
if err := db.EnsureSummaryTable(ctx, dbConn, tableName); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer dropSnapshotTable(ctx, dbConn, tableName)
|
||||
|
||||
started := time.Now()
|
||||
aggMap, snapTimes, err := c.scanHourlyCache(ctx, dayStart, dayEnd)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if len(aggMap) == 0 || len(snapTimes) == 0 {
|
||||
return 0, 0, fmt.Errorf("no daily rows found in canonical hourly cache")
|
||||
}
|
||||
totalSamplesByVcenter := sampleCountsByVcenter(aggMap)
|
||||
if err := c.insertDailyAggregates(ctx, tableName, aggMap, len(snapTimes), totalSamplesByVcenter); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
elapsed := time.Since(started)
|
||||
rows, err := db.TableRowCount(ctx, dbConn, tableName)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return elapsed, rows, nil
|
||||
}
|
||||
|
||||
func (c *CronTask) benchmarkDailySQLCore(ctx context.Context, dayStart, dayEnd time.Time) (time.Duration, int64, error) {
|
||||
tableName, err := benchmarkSummaryTableName("benchmark_daily_sql")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
dbConn := c.Database.DB()
|
||||
if err := db.EnsureSummaryTable(ctx, dbConn, tableName); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer dropSnapshotTable(ctx, dbConn, tableName)
|
||||
|
||||
insertQuery, err := db.BuildDailySummaryInsert(tableName, buildCanonicalHourlySummaryUnion(dayStart, dayEnd))
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
started := time.Now()
|
||||
if _, err := dbConn.ExecContext(ctx, insertQuery); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
elapsed := time.Since(started)
|
||||
rows, err := db.TableRowCount(ctx, dbConn, tableName)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return elapsed, rows, nil
|
||||
}
|
||||
|
||||
func (c *CronTask) benchmarkMonthlyGoCore(ctx context.Context, monthStart, monthEnd time.Time) (time.Duration, int64, error) {
|
||||
tableName, err := benchmarkSummaryTableName("benchmark_monthly_go")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
dbConn := c.Database.DB()
|
||||
if err := db.EnsureSummaryTable(ctx, dbConn, tableName); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer dropSnapshotTable(ctx, dbConn, tableName)
|
||||
|
||||
started := time.Now()
|
||||
aggMap, err := c.scanDailyRollup(ctx, monthStart, monthEnd)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if len(aggMap) == 0 {
|
||||
return 0, 0, fmt.Errorf("no monthly rows found in canonical daily rollup")
|
||||
}
|
||||
if err := c.insertMonthlyAggregates(ctx, tableName, aggMap); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
elapsed := time.Since(started)
|
||||
rows, err := db.TableRowCount(ctx, dbConn, tableName)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return elapsed, rows, nil
|
||||
}
|
||||
|
||||
func (c *CronTask) benchmarkMonthlySQLCore(ctx context.Context, monthStart, monthEnd time.Time) (time.Duration, int64, error) {
|
||||
tableName, err := benchmarkSummaryTableName("benchmark_monthly_sql")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
dbConn := c.Database.DB()
|
||||
if err := db.EnsureSummaryTable(ctx, dbConn, tableName); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer dropSnapshotTable(ctx, dbConn, tableName)
|
||||
|
||||
insertQuery, err := db.BuildMonthlySummaryInsert(tableName, buildCanonicalDailyRollupSummaryUnion(monthStart, monthEnd))
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
started := time.Now()
|
||||
if _, err := dbConn.ExecContext(ctx, insertQuery); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
elapsed := time.Since(started)
|
||||
rows, err := db.TableRowCount(ctx, dbConn, tableName)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return elapsed, rows, nil
|
||||
}
|
||||
|
||||
func benchmarkSummaryTableName(prefix string) (string, error) {
|
||||
return db.SafeTableName(fmt.Sprintf("%s_%d", prefix, time.Now().UTC().UnixNano()))
|
||||
}
|
||||
|
||||
func latestDailyWindowFromHourlyCache(ctx context.Context, dbConn *sqlx.DB) (time.Time, time.Time, error) {
|
||||
if !db.TableExists(ctx, dbConn, "vm_hourly_stats") {
|
||||
return time.Time{}, time.Time{}, nil
|
||||
}
|
||||
query := dbConn.Rebind(`
|
||||
SELECT MAX("SnapshotTime")
|
||||
FROM vm_hourly_stats
|
||||
WHERE "SnapshotTime" > ?
|
||||
`)
|
||||
var maxSnapshot sql.NullInt64
|
||||
if err := dbConn.GetContext(ctx, &maxSnapshot, query, 0); err != nil {
|
||||
return time.Time{}, time.Time{}, err
|
||||
}
|
||||
if !maxSnapshot.Valid || maxSnapshot.Int64 <= 0 {
|
||||
return time.Time{}, time.Time{}, nil
|
||||
}
|
||||
dayStart := time.Unix(maxSnapshot.Int64, 0).UTC()
|
||||
dayStart = time.Date(dayStart.Year(), dayStart.Month(), dayStart.Day(), 0, 0, 0, 0, time.UTC)
|
||||
dayEnd := dayStart.AddDate(0, 0, 1)
|
||||
|
||||
countQuery := dbConn.Rebind(`
|
||||
SELECT COUNT(1)
|
||||
FROM vm_hourly_stats
|
||||
WHERE "SnapshotTime" >= ? AND "SnapshotTime" < ?
|
||||
`)
|
||||
var count int64
|
||||
if err := dbConn.GetContext(ctx, &count, countQuery, dayStart.Unix(), dayEnd.Unix()); err != nil {
|
||||
return time.Time{}, time.Time{}, err
|
||||
}
|
||||
if count == 0 {
|
||||
return time.Time{}, time.Time{}, nil
|
||||
}
|
||||
return dayStart, dayEnd, nil
|
||||
}
|
||||
|
||||
func latestMonthlyWindowFromDailyRollup(ctx context.Context, dbConn *sqlx.DB) (time.Time, time.Time, error) {
|
||||
if !db.TableExists(ctx, dbConn, "vm_daily_rollup") {
|
||||
return time.Time{}, time.Time{}, nil
|
||||
}
|
||||
query := dbConn.Rebind(`
|
||||
SELECT MAX("Date")
|
||||
FROM vm_daily_rollup
|
||||
WHERE "Date" > ?
|
||||
`)
|
||||
var maxDate sql.NullInt64
|
||||
if err := dbConn.GetContext(ctx, &maxDate, query, 0); err != nil {
|
||||
return time.Time{}, time.Time{}, err
|
||||
}
|
||||
if !maxDate.Valid || maxDate.Int64 <= 0 {
|
||||
return time.Time{}, time.Time{}, nil
|
||||
}
|
||||
monthStart := time.Unix(maxDate.Int64, 0).UTC()
|
||||
monthStart = time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
monthEnd := monthStart.AddDate(0, 1, 0)
|
||||
|
||||
countQuery := dbConn.Rebind(`
|
||||
SELECT COUNT(1)
|
||||
FROM vm_daily_rollup
|
||||
WHERE "Date" >= ? AND "Date" < ?
|
||||
`)
|
||||
var count int64
|
||||
if err := dbConn.GetContext(ctx, &count, countQuery, monthStart.Unix(), monthEnd.Unix()); err != nil {
|
||||
return time.Time{}, time.Time{}, err
|
||||
}
|
||||
if count == 0 {
|
||||
return time.Time{}, time.Time{}, nil
|
||||
}
|
||||
return monthStart, monthEnd, nil
|
||||
}
|
||||
|
||||
func summarizeDurations(values []time.Duration) AggregationBenchmarkStats {
|
||||
if len(values) == 0 {
|
||||
return AggregationBenchmarkStats{}
|
||||
}
|
||||
sorted := append([]time.Duration(nil), values...)
|
||||
slices.Sort(sorted)
|
||||
total := time.Duration(0)
|
||||
for _, v := range sorted {
|
||||
total += v
|
||||
}
|
||||
median := sorted[len(sorted)/2]
|
||||
if len(sorted)%2 == 0 {
|
||||
median = (sorted[(len(sorted)/2)-1] + sorted[len(sorted)/2]) / 2
|
||||
}
|
||||
return AggregationBenchmarkStats{
|
||||
Runs: len(sorted),
|
||||
Min: sorted[0],
|
||||
Median: median,
|
||||
Avg: total / time.Duration(len(sorted)),
|
||||
Max: sorted[len(sorted)-1],
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"vctp/internal/metrics"
|
||||
"vctp/internal/report"
|
||||
"vctp/internal/settings"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// RunVcenterDailyAggregate summarizes hourly snapshots into a daily summary table.
|
||||
@@ -34,15 +36,15 @@ func (c *CronTask) RunVcenterDailyAggregate(ctx context.Context, logger *slog.Lo
|
||||
targetTime := time.Now().AddDate(0, 0, -1)
|
||||
logger.Info("Daily summary job starting", "target_date", targetTime.Format("2006-01-02"))
|
||||
// Always force regeneration on the scheduled run to refresh data even if a manual run happened earlier.
|
||||
return c.aggregateDailySummary(jobCtx, targetTime, true)
|
||||
return c.aggregateDailySummaryWithMode(jobCtx, targetTime, true, true)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *CronTask) AggregateDailySummary(ctx context.Context, date time.Time, force bool) error {
|
||||
return c.aggregateDailySummary(ctx, date, force)
|
||||
return c.aggregateDailySummaryWithMode(ctx, date, force, false)
|
||||
}
|
||||
|
||||
func (c *CronTask) aggregateDailySummary(ctx context.Context, targetTime time.Time, force bool) error {
|
||||
func (c *CronTask) aggregateDailySummaryWithMode(ctx context.Context, targetTime time.Time, force bool, scheduled bool) error {
|
||||
jobStart := time.Now()
|
||||
dayStart := time.Date(targetTime.Year(), targetTime.Month(), targetTime.Day(), 0, 0, 0, 0, targetTime.Location())
|
||||
dayEnd := dayStart.AddDate(0, 0, 1)
|
||||
@@ -71,10 +73,31 @@ func (c *CronTask) aggregateDailySummary(ctx context.Context, targetTime time.Ti
|
||||
}
|
||||
}
|
||||
|
||||
// If enabled, use the Go fan-out/reduce path to parallelize aggregation.
|
||||
if os.Getenv("DAILY_AGG_GO") == "1" {
|
||||
if scheduled && c.scheduledAggregationEngine() == "sql" {
|
||||
c.Logger.Info("scheduled_aggregation_engine=sql enabled; using canonical SQL daily aggregation path")
|
||||
if err := c.aggregateDailySummarySQLCanonical(ctx, dayStart, dayEnd, summaryTable); err != nil {
|
||||
c.Logger.Warn("scheduled canonical SQL daily aggregation failed; falling back to go path", "error", err)
|
||||
} else {
|
||||
metrics.RecordDailyAggregation(time.Since(jobStart), nil)
|
||||
c.Logger.Debug("Finished daily inventory aggregation (SQL canonical path)", "summary_table", summaryTable)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Canonical Go aggregation is the default for both scheduled and manual runs.
|
||||
// Legacy SQL/union aggregation stays available as a manual fallback/backfill path.
|
||||
forceGoAgg := os.Getenv("DAILY_AGG_GO") == "1"
|
||||
forceSQLAgg := !scheduled && os.Getenv("DAILY_AGG_SQL") == "1"
|
||||
useGoAgg := scheduled || forceGoAgg || !forceSQLAgg
|
||||
if forceSQLAgg && !forceGoAgg {
|
||||
c.Logger.Info("DAILY_AGG_SQL=1 enabled; using SQL fallback path for manual daily aggregation")
|
||||
}
|
||||
if useGoAgg {
|
||||
c.Logger.Debug("Using go implementation of aggregation")
|
||||
if err := c.aggregateDailySummaryGo(ctx, dayStart, dayEnd, summaryTable, force); err != nil {
|
||||
if err := c.aggregateDailySummaryGo(ctx, dayStart, dayEnd, summaryTable, force, scheduled); err != nil {
|
||||
if scheduled {
|
||||
return err
|
||||
}
|
||||
c.Logger.Warn("go-based daily aggregation failed, falling back to SQL path", "error", err)
|
||||
} else {
|
||||
metrics.RecordDailyAggregation(time.Since(jobStart), nil)
|
||||
@@ -200,7 +223,7 @@ func (c *CronTask) aggregateDailySummary(ctx context.Context, targetTime time.Ti
|
||||
|
||||
reportStart := time.Now()
|
||||
c.Logger.Debug("Generating daily report", "table", summaryTable)
|
||||
if err := c.generateReport(ctx, summaryTable); err != nil {
|
||||
if err := c.generateReportWithPolicy(ctx, summaryTable); err != nil {
|
||||
c.Logger.Warn("failed to generate daily report", "error", err, "table", summaryTable)
|
||||
metrics.RecordDailyAggregation(time.Since(jobStart), err)
|
||||
return err
|
||||
@@ -225,34 +248,106 @@ func dailySummaryTableName(t time.Time) (string, error) {
|
||||
return db.SafeTableName(fmt.Sprintf("inventory_daily_summary_%s", t.Format("20060102")))
|
||||
}
|
||||
|
||||
func (c *CronTask) aggregateDailySummarySQLCanonical(ctx context.Context, dayStart, dayEnd time.Time, summaryTable string) error {
|
||||
jobStart := time.Now()
|
||||
dbConn := c.Database.DB()
|
||||
if !db.TableExists(ctx, dbConn, "vm_hourly_stats") {
|
||||
return fmt.Errorf("vm_hourly_stats table not found for canonical SQL daily aggregation")
|
||||
}
|
||||
|
||||
unionQuery := buildCanonicalHourlySummaryUnion(dayStart, dayEnd)
|
||||
insertQuery, err := db.BuildDailySummaryInsert(summaryTable, unionQuery)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := dbConn.ExecContext(ctx, insertQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if applied, err := db.ApplyLifecycleDeletionToSummary(ctx, dbConn, summaryTable, dayStart.Unix(), dayEnd.Unix()); err != nil {
|
||||
c.Logger.Warn("failed to apply lifecycle deletions to daily summary (SQL canonical)", "error", err, "table", summaryTable)
|
||||
} else {
|
||||
c.Logger.Info("Daily aggregation deletion times", "source_lifecycle_cache", applied)
|
||||
}
|
||||
if applied, err := db.ApplyLifecycleCreationToSummary(ctx, dbConn, summaryTable); err != nil {
|
||||
c.Logger.Warn("failed to apply lifecycle creations to daily summary (SQL canonical)", "error", err, "table", summaryTable)
|
||||
} else {
|
||||
c.Logger.Info("Daily aggregation creation times", "source_lifecycle_cache", applied)
|
||||
}
|
||||
if err := db.RefineCreationDeletionFromUnion(ctx, dbConn, summaryTable, buildHourlyCacheLifecycleUnion(dayStart, dayEnd)); err != nil {
|
||||
c.Logger.Warn("failed to refine creation/deletion times (SQL canonical)", "error", err, "table", summaryTable)
|
||||
}
|
||||
if err := db.UpdateSummaryPresenceByWindow(ctx, dbConn, summaryTable, dayStart.Unix(), dayEnd.Unix()); err != nil {
|
||||
c.Logger.Warn("failed to update daily AvgIsPresent from lifecycle window (SQL canonical)", "error", err, "table", summaryTable)
|
||||
}
|
||||
|
||||
db.AnalyzeTableIfPostgres(ctx, dbConn, summaryTable)
|
||||
rowCount, err := db.TableRowCount(ctx, dbConn, summaryTable)
|
||||
if err != nil {
|
||||
c.Logger.Warn("unable to count daily summary rows (SQL canonical)", "error", err, "table", summaryTable)
|
||||
}
|
||||
if rowCount == 0 {
|
||||
return fmt.Errorf("no VM records aggregated for %s", dayStart.Format("2006-01-02"))
|
||||
}
|
||||
logMissingCreationSummary(ctx, c.Logger, c.Database, summaryTable, rowCount)
|
||||
if err := report.RegisterSnapshot(ctx, c.Database, "daily", summaryTable, dayStart, rowCount); err != nil {
|
||||
c.Logger.Warn("failed to register daily snapshot (SQL canonical)", "error", err, "table", summaryTable)
|
||||
}
|
||||
if refreshed, err := db.ReplaceVcenterAggregateTotalsFromSummary(ctx, dbConn, summaryTable, "daily", dayStart.Unix()); err != nil {
|
||||
c.Logger.Warn("failed to refresh vcenter daily aggregate totals cache (SQL canonical)", "error", err, "table", summaryTable)
|
||||
} else {
|
||||
c.Logger.Debug("refreshed vcenter daily aggregate totals cache", "table", summaryTable, "rows", refreshed)
|
||||
}
|
||||
if err := c.generateReportWithPolicy(ctx, summaryTable); err != nil {
|
||||
c.Logger.Warn("failed to generate daily report (SQL canonical)", "error", err, "table", summaryTable)
|
||||
return err
|
||||
}
|
||||
driver := strings.ToLower(dbConn.DriverName())
|
||||
action, checkpointErr := db.CheckpointDatabase(ctx, dbConn)
|
||||
if checkpointErr != nil {
|
||||
c.Logger.Warn("failed to run database checkpoint after daily aggregation (SQL canonical)", "driver", driver, "action", action, "error", checkpointErr)
|
||||
}
|
||||
c.Logger.Debug("Finished daily inventory aggregation (SQL canonical path)", "summary_table", summaryTable, "duration", time.Since(jobStart))
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildCanonicalHourlySummaryUnion(start, end time.Time) string {
|
||||
return fmt.Sprintf(`
|
||||
SELECT
|
||||
NULL AS "InventoryId",
|
||||
COALESCE("Name",'') AS "Name",
|
||||
COALESCE("Vcenter",'') AS "Vcenter",
|
||||
COALESCE("VmId",'') AS "VmId",
|
||||
NULL AS "EventKey",
|
||||
NULL AS "CloudId",
|
||||
COALESCE("CreationTime",0) AS "CreationTime",
|
||||
COALESCE("DeletionTime",0) AS "DeletionTime",
|
||||
COALESCE("ResourcePool",'') AS "ResourcePool",
|
||||
COALESCE("Datacenter",'') AS "Datacenter",
|
||||
COALESCE("Cluster",'') AS "Cluster",
|
||||
COALESCE("Folder",'') AS "Folder",
|
||||
COALESCE("ProvisionedDisk",0) AS "ProvisionedDisk",
|
||||
COALESCE("VcpuCount",0) AS "VcpuCount",
|
||||
COALESCE("RamGB",0) AS "RamGB",
|
||||
COALESCE("IsTemplate",'') AS "IsTemplate",
|
||||
COALESCE("PoweredOn",'') AS "PoweredOn",
|
||||
COALESCE("SrmPlaceholder",'') AS "SrmPlaceholder",
|
||||
COALESCE("VmUuid",'') AS "VmUuid",
|
||||
"SnapshotTime"
|
||||
FROM vm_hourly_stats
|
||||
WHERE "SnapshotTime" >= %d
|
||||
AND "SnapshotTime" < %d
|
||||
AND %s
|
||||
`, start.Unix(), end.Unix(), templateExclusionFilter())
|
||||
}
|
||||
|
||||
// aggregateDailySummaryGo performs daily aggregation by reading hourly tables in parallel,
|
||||
// reducing in Go, and writing the summary table. It mirrors the outputs of the SQL path
|
||||
// as closely as possible while improving CPU utilization on multi-core hosts.
|
||||
func (c *CronTask) aggregateDailySummaryGo(ctx context.Context, dayStart, dayEnd time.Time, summaryTable string, force bool) error {
|
||||
func (c *CronTask) aggregateDailySummaryGo(ctx context.Context, dayStart, dayEnd time.Time, summaryTable string, force bool, canonicalOnly bool) error {
|
||||
jobStart := time.Now()
|
||||
dbConn := c.Database.DB()
|
||||
|
||||
hourlySnapshots, err := report.SnapshotRecordsWithFallback(ctx, c.Database, "hourly", "inventory_hourly_", "epoch", dayStart, dayEnd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hourlySnapshots = filterRecordsInRange(hourlySnapshots, dayStart, dayEnd)
|
||||
hourlySnapshots = filterSnapshotsWithRows(ctx, dbConn, hourlySnapshots)
|
||||
c.Logger.Info("Daily aggregation hourly snapshot count (go path)", "count", len(hourlySnapshots), "date", dayStart.Format("2006-01-02"))
|
||||
if len(hourlySnapshots) == 0 {
|
||||
return fmt.Errorf("no hourly snapshot tables found for %s", dayStart.Format("2006-01-02"))
|
||||
} else {
|
||||
c.Logger.Debug("Found hourly snapshot tables for daily aggregation", "date", dayStart.Format("2006-01-02"), "tables", len(hourlySnapshots))
|
||||
}
|
||||
|
||||
hourlyTables := make([]string, 0, len(hourlySnapshots))
|
||||
for _, snapshot := range hourlySnapshots {
|
||||
hourlyTables = append(hourlyTables, snapshot.TableName)
|
||||
}
|
||||
unionQuery, err := buildUnionQuery(hourlyTables, summaryUnionColumns, templateExclusionFilter())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hourlyTables := make([]string, 0, 64)
|
||||
unionQuery := ""
|
||||
|
||||
// Clear existing summary if forcing.
|
||||
if rowsExist, err := db.TableHasRows(ctx, dbConn, summaryTable); err != nil {
|
||||
@@ -266,41 +361,75 @@ func (c *CronTask) aggregateDailySummaryGo(ctx context.Context, dayStart, dayEnd
|
||||
}
|
||||
}
|
||||
|
||||
totalSamples := len(hourlyTables)
|
||||
totalSamples := 0
|
||||
var (
|
||||
aggMap map[dailyAggKey]*dailyAggVal
|
||||
snapTimes []int64
|
||||
)
|
||||
|
||||
if db.TableExists(ctx, dbConn, "vm_hourly_stats") {
|
||||
if canonicalOnly {
|
||||
if !db.TableExists(ctx, dbConn, "vm_hourly_stats") {
|
||||
return fmt.Errorf("vm_hourly_stats table not found for canonical daily aggregation")
|
||||
}
|
||||
cacheAgg, cacheTimes, cacheErr := c.scanHourlyCache(ctx, dayStart, dayEnd)
|
||||
if cacheErr != nil {
|
||||
c.Logger.Warn("failed to use hourly cache, falling back to table scans", "error", cacheErr)
|
||||
} else if len(cacheAgg) > 0 {
|
||||
c.Logger.Debug("using hourly cache for daily aggregation", "date", dayStart.Format("2006-01-02"), "snapshots", len(cacheTimes), "vm_count", len(cacheAgg))
|
||||
aggMap = cacheAgg
|
||||
snapTimes = cacheTimes
|
||||
totalSamples = len(cacheTimes)
|
||||
return cacheErr
|
||||
}
|
||||
}
|
||||
|
||||
if aggMap == nil {
|
||||
var errScan error
|
||||
aggMap, errScan = c.scanHourlyTablesParallel(ctx, hourlySnapshots)
|
||||
if errScan != nil {
|
||||
return errScan
|
||||
}
|
||||
c.Logger.Debug("scanned hourly tables for daily aggregation", "date", dayStart.Format("2006-01-02"), "tables", len(hourlySnapshots), "vm_count", len(aggMap))
|
||||
if len(aggMap) == 0 {
|
||||
if len(cacheAgg) == 0 {
|
||||
return fmt.Errorf("no VM records aggregated for %s", dayStart.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
// Build ordered list of snapshot times for deletion inference.
|
||||
snapTimes = make([]int64, 0, len(hourlySnapshots))
|
||||
for _, snap := range hourlySnapshots {
|
||||
snapTimes = append(snapTimes, snap.SnapshotTime.Unix())
|
||||
c.Logger.Debug("using canonical hourly cache for daily aggregation", "date", dayStart.Format("2006-01-02"), "snapshots", len(cacheTimes), "vm_count", len(cacheAgg))
|
||||
aggMap = cacheAgg
|
||||
snapTimes = cacheTimes
|
||||
totalSamples = len(cacheTimes)
|
||||
unionQuery = buildHourlyCacheLifecycleUnion(dayStart, dayEnd)
|
||||
} else {
|
||||
hourlySnapshots, err := report.SnapshotRecordsWithFallback(ctx, c.Database, "hourly", "inventory_hourly_", "epoch", dayStart, dayEnd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hourlySnapshots = filterRecordsInRange(hourlySnapshots, dayStart, dayEnd)
|
||||
hourlySnapshots = filterSnapshotsWithRows(ctx, dbConn, hourlySnapshots)
|
||||
c.Logger.Info("Daily aggregation hourly snapshot count (go path)", "count", len(hourlySnapshots), "date", dayStart.Format("2006-01-02"))
|
||||
if len(hourlySnapshots) == 0 {
|
||||
return fmt.Errorf("no hourly snapshot tables found for %s", dayStart.Format("2006-01-02"))
|
||||
}
|
||||
for _, snapshot := range hourlySnapshots {
|
||||
hourlyTables = append(hourlyTables, snapshot.TableName)
|
||||
}
|
||||
unionQuery, err = buildUnionQuery(hourlyTables, summaryUnionColumns, templateExclusionFilter())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
totalSamples = len(hourlyTables)
|
||||
if db.TableExists(ctx, dbConn, "vm_hourly_stats") {
|
||||
cacheAgg, cacheTimes, cacheErr := c.scanHourlyCache(ctx, dayStart, dayEnd)
|
||||
if cacheErr != nil {
|
||||
c.Logger.Warn("failed to use hourly cache, falling back to table scans", "error", cacheErr)
|
||||
} else if len(cacheAgg) > 0 {
|
||||
c.Logger.Debug("using hourly cache for daily aggregation", "date", dayStart.Format("2006-01-02"), "snapshots", len(cacheTimes), "vm_count", len(cacheAgg))
|
||||
aggMap = cacheAgg
|
||||
snapTimes = cacheTimes
|
||||
totalSamples = len(cacheTimes)
|
||||
}
|
||||
}
|
||||
if aggMap == nil {
|
||||
var errScan error
|
||||
aggMap, errScan = c.scanHourlyTablesParallel(ctx, hourlySnapshots)
|
||||
if errScan != nil {
|
||||
return errScan
|
||||
}
|
||||
c.Logger.Debug("scanned hourly tables for daily aggregation", "date", dayStart.Format("2006-01-02"), "tables", len(hourlySnapshots), "vm_count", len(aggMap))
|
||||
if len(aggMap) == 0 {
|
||||
return fmt.Errorf("no VM records aggregated for %s", dayStart.Format("2006-01-02"))
|
||||
}
|
||||
// Build ordered list of snapshot times for deletion inference.
|
||||
snapTimes = make([]int64, 0, len(hourlySnapshots))
|
||||
for _, snap := range hourlySnapshots {
|
||||
snapTimes = append(snapTimes, snap.SnapshotTime.Unix())
|
||||
}
|
||||
slices.Sort(snapTimes)
|
||||
}
|
||||
slices.Sort(snapTimes)
|
||||
}
|
||||
|
||||
lifecycleDeletions := c.applyLifecycleDeletions(ctx, aggMap, dayStart, dayEnd)
|
||||
@@ -316,22 +445,36 @@ func (c *CronTask) aggregateDailySummaryGo(ctx context.Context, dayStart, dayEnd
|
||||
c.Logger.Info("Daily aggregation creation times", "source_inventory", inventoryCreations)
|
||||
|
||||
// Get the first hourly snapshot on/after dayEnd to help confirm deletions that happen on the last snapshot of the day.
|
||||
var nextSnapshotTable string
|
||||
nextSnapshotQuery := dbConn.Rebind(`
|
||||
var (
|
||||
nextSnapshotTable string
|
||||
nextSnapshotTime int64
|
||||
)
|
||||
nextPresenceByVcenter := make(map[string]map[string]struct{}, 8)
|
||||
if canonicalOnly {
|
||||
presence, snapshotTime, err := loadNextHourlyCachePresence(ctx, dbConn, dayEnd)
|
||||
if err != nil {
|
||||
c.Logger.Warn("failed to load next-hourly presence from canonical cache", "error", err)
|
||||
} else {
|
||||
nextPresenceByVcenter = presence
|
||||
nextSnapshotTime = snapshotTime
|
||||
}
|
||||
} else {
|
||||
nextSnapshotQuery := dbConn.Rebind(`
|
||||
SELECT table_name
|
||||
FROM snapshot_registry
|
||||
WHERE snapshot_type = 'hourly' AND snapshot_time >= ?
|
||||
ORDER BY snapshot_time ASC
|
||||
LIMIT 1
|
||||
`)
|
||||
nextSnapshotRows, nextErr := c.Database.DB().QueryxContext(ctx, nextSnapshotQuery, dayEnd.Unix())
|
||||
if nextErr == nil {
|
||||
if nextSnapshotRows.Next() {
|
||||
if scanErr := nextSnapshotRows.Scan(&nextSnapshotTable); scanErr != nil {
|
||||
nextSnapshotTable = ""
|
||||
nextSnapshotRows, nextErr := c.Database.DB().QueryxContext(ctx, nextSnapshotQuery, dayEnd.Unix())
|
||||
if nextErr == nil {
|
||||
if nextSnapshotRows.Next() {
|
||||
if scanErr := nextSnapshotRows.Scan(&nextSnapshotTable); scanErr != nil {
|
||||
nextSnapshotTable = ""
|
||||
}
|
||||
}
|
||||
nextSnapshotRows.Close()
|
||||
}
|
||||
nextSnapshotRows.Close()
|
||||
}
|
||||
|
||||
// Build per-vCenter snapshot timelines from observed VM samples so deletion
|
||||
@@ -362,7 +505,6 @@ LIMIT 1
|
||||
vcenterSnapTimes[vcenter] = times
|
||||
}
|
||||
|
||||
nextPresenceByVcenter := make(map[string]map[string]struct{}, 8)
|
||||
if nextSnapshotTable != "" && db.TableExists(ctx, dbConn, nextSnapshotTable) {
|
||||
rows, err := querySnapshotRows(ctx, dbConn, nextSnapshotTable, []string{"Vcenter", "VmId", "VmUuid", "Name"}, "")
|
||||
if err == nil {
|
||||
@@ -439,7 +581,7 @@ LIMIT 1
|
||||
if !presentByID && !presentByUUID && !presentByName {
|
||||
v.deletion = firstMiss
|
||||
inferredDeletions++
|
||||
c.Logger.Debug("cross-day deletion inferred from next snapshot", "vcenter", v.key.Vcenter, "vm_id", v.key.VmId, "vm_uuid", v.key.VmUuid, "name", v.key.Name, "deletion", firstMiss, "next_table", nextSnapshotTable)
|
||||
c.Logger.Debug("cross-day deletion inferred from next snapshot", "vcenter", v.key.Vcenter, "vm_id", v.key.VmId, "vm_uuid", v.key.VmUuid, "name", v.key.Name, "deletion", firstMiss, "next_table", nextSnapshotTable, "next_snapshot_time", nextSnapshotTime)
|
||||
}
|
||||
}
|
||||
if v.deletion == 0 {
|
||||
@@ -521,7 +663,7 @@ LIMIT 1
|
||||
}
|
||||
reportStart := time.Now()
|
||||
c.Logger.Debug("Generating daily report", "table", summaryTable)
|
||||
if err := c.generateReport(ctx, summaryTable); err != nil {
|
||||
if err := c.generateReportWithPolicy(ctx, summaryTable); err != nil {
|
||||
c.Logger.Warn("failed to generate daily report", "error", err, "table", summaryTable)
|
||||
return err
|
||||
}
|
||||
@@ -1115,6 +1257,74 @@ WHERE "SnapshotTime" >= ? AND "SnapshotTime" < ?`
|
||||
return agg, snapTimes, rows.Err()
|
||||
}
|
||||
|
||||
func buildHourlyCacheLifecycleUnion(start, end time.Time) string {
|
||||
return fmt.Sprintf(`
|
||||
SELECT
|
||||
"VmId","VmUuid","Name","Vcenter","CreationTime","DeletionTime","SnapshotTime"
|
||||
FROM vm_hourly_stats
|
||||
WHERE "SnapshotTime" >= %d AND "SnapshotTime" < %d
|
||||
`, start.Unix(), end.Unix())
|
||||
}
|
||||
|
||||
func loadNextHourlyCachePresence(ctx context.Context, dbConn *sqlx.DB, dayEnd time.Time) (map[string]map[string]struct{}, int64, error) {
|
||||
presence := make(map[string]map[string]struct{}, 8)
|
||||
query := dbConn.Rebind(`
|
||||
WITH next_by_vcenter AS (
|
||||
SELECT "Vcenter", MIN("SnapshotTime") AS snapshot_time
|
||||
FROM vm_hourly_stats
|
||||
WHERE "SnapshotTime" >= ?
|
||||
GROUP BY "Vcenter"
|
||||
)
|
||||
SELECT h."Vcenter", h."VmId", h."VmUuid", h."Name", n.snapshot_time
|
||||
FROM next_by_vcenter n
|
||||
JOIN vm_hourly_stats h
|
||||
ON h."Vcenter" = n."Vcenter"
|
||||
AND h."SnapshotTime" = n.snapshot_time
|
||||
`)
|
||||
rows, err := dbConn.QueryxContext(ctx, query, dayEnd.Unix())
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var minSnapshotTime int64
|
||||
for rows.Next() {
|
||||
var (
|
||||
vcenter string
|
||||
vmID, vmUUID sql.NullString
|
||||
name sql.NullString
|
||||
snapshotTime sql.NullInt64
|
||||
)
|
||||
if err := rows.Scan(&vcenter, &vmID, &vmUUID, &name, &snapshotTime); err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(vcenter) == "" {
|
||||
continue
|
||||
}
|
||||
if snapshotTime.Valid && snapshotTime.Int64 > 0 && (minSnapshotTime == 0 || snapshotTime.Int64 < minSnapshotTime) {
|
||||
minSnapshotTime = snapshotTime.Int64
|
||||
}
|
||||
vcPresence := presence[vcenter]
|
||||
if vcPresence == nil {
|
||||
vcPresence = make(map[string]struct{}, 1024)
|
||||
presence[vcenter] = vcPresence
|
||||
}
|
||||
if vmID.Valid && strings.TrimSpace(vmID.String) != "" {
|
||||
vcPresence["id:"+strings.TrimSpace(vmID.String)] = struct{}{}
|
||||
}
|
||||
if vmUUID.Valid && strings.TrimSpace(vmUUID.String) != "" {
|
||||
vcPresence["uuid:"+strings.TrimSpace(vmUUID.String)] = struct{}{}
|
||||
}
|
||||
if name.Valid && strings.TrimSpace(name.String) != "" {
|
||||
vcPresence["name:"+strings.ToLower(strings.TrimSpace(name.String))] = struct{}{}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return presence, minSnapshotTime, nil
|
||||
}
|
||||
|
||||
func (c *CronTask) insertDailyAggregates(ctx context.Context, table string, agg map[dailyAggKey]*dailyAggVal, totalSamples int, totalSamplesByVcenter map[string]int) error {
|
||||
dbConn := c.Database.DB()
|
||||
tx, err := dbConn.Beginx()
|
||||
|
||||
@@ -3,6 +3,7 @@ package tasks
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"vctp/db"
|
||||
@@ -18,6 +19,15 @@ func insertHourlyCache(ctx context.Context, dbConn *sqlx.DB, rows []InventorySna
|
||||
return err
|
||||
}
|
||||
driver := strings.ToLower(dbConn.DriverName())
|
||||
if isPostgresDriver(driver) {
|
||||
if len(rows) > 0 {
|
||||
if err := db.EnsureVmHourlyStatsPartitionForSnapshot(ctx, dbConn, rows[0].SnapshotTime); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return insertHourlyCachePostgresMultiRow(ctx, dbConn, rows)
|
||||
}
|
||||
|
||||
conflict := ""
|
||||
verb := "INSERT INTO"
|
||||
if driver == "sqlite" {
|
||||
@@ -73,10 +83,64 @@ func insertHourlyCache(ctx context.Context, dbConn *sqlx.DB, rows []InventorySna
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func insertHourlyCachePostgresMultiRow(ctx context.Context, dbConn *sqlx.DB, rows []InventorySnapshotRow) error {
|
||||
cols := []string{
|
||||
"SnapshotTime", "Vcenter", "VmId", "VmUuid", "Name", "CreationTime", "DeletionTime", "ResourcePool",
|
||||
"Datacenter", "Cluster", "Folder", "ProvisionedDisk", "VcpuCount", "RamGB", "IsTemplate", "PoweredOn", "SrmPlaceholder",
|
||||
}
|
||||
conflict := ` ON CONFLICT ("Vcenter","VmId","SnapshotTime") DO UPDATE SET
|
||||
"VmUuid"=EXCLUDED."VmUuid",
|
||||
"Name"=EXCLUDED."Name",
|
||||
"CreationTime"=EXCLUDED."CreationTime",
|
||||
"DeletionTime"=EXCLUDED."DeletionTime",
|
||||
"ResourcePool"=EXCLUDED."ResourcePool",
|
||||
"Datacenter"=EXCLUDED."Datacenter",
|
||||
"Cluster"=EXCLUDED."Cluster",
|
||||
"Folder"=EXCLUDED."Folder",
|
||||
"ProvisionedDisk"=EXCLUDED."ProvisionedDisk",
|
||||
"VcpuCount"=EXCLUDED."VcpuCount",
|
||||
"RamGB"=EXCLUDED."RamGB",
|
||||
"IsTemplate"=EXCLUDED."IsTemplate",
|
||||
"PoweredOn"=EXCLUDED."PoweredOn",
|
||||
"SrmPlaceholder"=EXCLUDED."SrmPlaceholder"`
|
||||
|
||||
tx, err := dbConn.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
maxRows := postgresMaxRowsPerStatement(len(cols))
|
||||
for start := 0; start < len(rows); start += maxRows {
|
||||
end := min(start+maxRows, len(rows))
|
||||
chunk := rows[start:end]
|
||||
args := make([]any, 0, len(chunk)*len(cols))
|
||||
for _, row := range chunk {
|
||||
args = append(args,
|
||||
row.SnapshotTime, row.Vcenter, row.VmId, row.VmUuid, row.Name, row.CreationTime, row.DeletionTime, row.ResourcePool,
|
||||
row.Datacenter, row.Cluster, row.Folder, row.ProvisionedDisk, row.VcpuCount, row.RamGB, row.IsTemplate, row.PoweredOn, row.SrmPlaceholder,
|
||||
)
|
||||
}
|
||||
stmt := buildPostgresMultiRowInsertSQL("vm_hourly_stats", cols, len(chunk), conflict)
|
||||
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func insertHourlyBatch(ctx context.Context, dbConn *sqlx.DB, tableName string, rows []InventorySnapshotRow) error {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := db.SafeTableName(tableName); err != nil {
|
||||
return err
|
||||
}
|
||||
driver := strings.ToLower(dbConn.DriverName())
|
||||
if isPostgresDriver(driver) {
|
||||
return insertHourlyBatchPostgresMultiRow(ctx, dbConn, tableName, rows)
|
||||
}
|
||||
|
||||
tx, err := dbConn.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -168,6 +232,135 @@ func insertHourlyBatch(ctx context.Context, dbConn *sqlx.DB, tableName string, r
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func insertHourlyBatchPostgresMultiRow(ctx context.Context, dbConn *sqlx.DB, tableName string, rows []InventorySnapshotRow) error {
|
||||
baseCols := []string{
|
||||
"InventoryId", "Name", "Vcenter", "VmId", "EventKey", "CloudId", "CreationTime", "DeletionTime",
|
||||
"ResourcePool", "Datacenter", "Cluster", "Folder", "ProvisionedDisk", "VcpuCount",
|
||||
"RamGB", "IsTemplate", "PoweredOn", "SrmPlaceholder", "VmUuid", "SnapshotTime",
|
||||
}
|
||||
err := execHourlySnapshotInsertPostgres(ctx, dbConn, tableName, baseCols, rows, false)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !isLegacyIsPresentError(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
withLegacy := append(append([]string{}, baseCols...), "IsPresent")
|
||||
if legacyErr := execHourlySnapshotInsertPostgres(ctx, dbConn, tableName, withLegacy, rows, true); legacyErr != nil {
|
||||
return legacyErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func execHourlySnapshotInsertPostgres(ctx context.Context, dbConn *sqlx.DB, tableName string, cols []string, rows []InventorySnapshotRow, includeLegacyIsPresent bool) error {
|
||||
tx, err := dbConn.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
maxRows := postgresMaxRowsPerStatement(len(cols))
|
||||
for start := 0; start < len(rows); start += maxRows {
|
||||
end := min(start+maxRows, len(rows))
|
||||
chunk := rows[start:end]
|
||||
args := make([]any, 0, len(chunk)*len(cols))
|
||||
for _, row := range chunk {
|
||||
args = append(args,
|
||||
row.InventoryId,
|
||||
row.Name,
|
||||
row.Vcenter,
|
||||
row.VmId,
|
||||
row.EventKey,
|
||||
row.CloudId,
|
||||
row.CreationTime,
|
||||
row.DeletionTime,
|
||||
row.ResourcePool,
|
||||
row.Datacenter,
|
||||
row.Cluster,
|
||||
row.Folder,
|
||||
row.ProvisionedDisk,
|
||||
row.VcpuCount,
|
||||
row.RamGB,
|
||||
row.IsTemplate,
|
||||
row.PoweredOn,
|
||||
row.SrmPlaceholder,
|
||||
row.VmUuid,
|
||||
row.SnapshotTime,
|
||||
)
|
||||
if includeLegacyIsPresent {
|
||||
args = append(args, "TRUE")
|
||||
}
|
||||
}
|
||||
stmt := buildPostgresMultiRowInsertSQL(tableName, cols, len(chunk), "")
|
||||
if _, err := tx.ExecContext(ctx, stmt, args...); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func isPostgresDriver(driver string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(driver)) {
|
||||
case "pgx", "postgres":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func postgresMaxRowsPerStatement(colCount int) int {
|
||||
if colCount <= 0 {
|
||||
return 1
|
||||
}
|
||||
const maxBindParams = 65535
|
||||
rows := maxBindParams / colCount
|
||||
if rows <= 0 {
|
||||
return 1
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func buildPostgresMultiRowInsertSQL(tableName string, cols []string, rowCount int, suffix string) string {
|
||||
if rowCount <= 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(`INSERT INTO `)
|
||||
b.WriteString(tableName)
|
||||
b.WriteString(` ("`)
|
||||
b.WriteString(strings.Join(cols, `","`))
|
||||
b.WriteString(`") VALUES `)
|
||||
|
||||
param := 1
|
||||
for row := 0; row < rowCount; row++ {
|
||||
if row > 0 {
|
||||
b.WriteString(`,`)
|
||||
}
|
||||
b.WriteString(`(`)
|
||||
for col := 0; col < len(cols); col++ {
|
||||
if col > 0 {
|
||||
b.WriteString(`,`)
|
||||
}
|
||||
b.WriteString(`$`)
|
||||
b.WriteString(strconv.Itoa(param))
|
||||
param++
|
||||
}
|
||||
b.WriteString(`)`)
|
||||
}
|
||||
if suffix != "" {
|
||||
b.WriteString(suffix)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func isLegacyIsPresentError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "ispresent")
|
||||
}
|
||||
|
||||
func dropSnapshotTable(ctx context.Context, dbConn *sqlx.DB, table string) error {
|
||||
if _, err := db.SafeTableName(table); err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package tasks
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPostgresMaxRowsPerStatement(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cols int
|
||||
expect int
|
||||
}{
|
||||
{name: "zero columns", cols: 0, expect: 1},
|
||||
{name: "hourly cache columns", cols: 17, expect: 3855},
|
||||
{name: "hourly snapshot columns", cols: 20, expect: 3276},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := postgresMaxRowsPerStatement(tc.cols)
|
||||
if got != tc.expect {
|
||||
t.Fatalf("unexpected max rows: cols=%d got=%d want=%d", tc.cols, got, tc.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPostgresMultiRowInsertSQL(t *testing.T) {
|
||||
got := buildPostgresMultiRowInsertSQL("vm_hourly_stats", []string{"A", "B"}, 2, "")
|
||||
want := `INSERT INTO vm_hourly_stats ("A","B") VALUES ($1,$2),($3,$4)`
|
||||
if got != want {
|
||||
t.Fatalf("unexpected SQL\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
|
||||
withSuffix := buildPostgresMultiRowInsertSQL("vm_hourly_stats", []string{"A"}, 1, ` ON CONFLICT ("A") DO NOTHING`)
|
||||
wantSuffix := `INSERT INTO vm_hourly_stats ("A") VALUES ($1) ON CONFLICT ("A") DO NOTHING`
|
||||
if withSuffix != wantSuffix {
|
||||
t.Fatalf("unexpected SQL with suffix\nwant: %s\ngot: %s", wantSuffix, withSuffix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLegacyIsPresentError(t *testing.T) {
|
||||
if !isLegacyIsPresentError(assertErr(`null value in column "IsPresent" violates not-null constraint`)) {
|
||||
t.Fatal("expected legacy IsPresent error to be detected")
|
||||
}
|
||||
if isLegacyIsPresentError(assertErr("duplicate key value violates unique constraint")) {
|
||||
t.Fatal("expected non-IsPresent errors to be ignored")
|
||||
}
|
||||
}
|
||||
|
||||
type testErr string
|
||||
|
||||
func (e testErr) Error() string { return string(e) }
|
||||
|
||||
func assertErr(msg string) error { return testErr(msg) }
|
||||
@@ -247,13 +247,15 @@ func updateDeletionTimeInHourlyCache(ctx context.Context, dbConn *sqlx.DB, vcent
|
||||
}
|
||||
|
||||
// markMissingFromPrevious marks VMs that were present in the previous snapshot but missing now.
|
||||
// When updateCompatSnapshot is true, legacy hourly snapshot tables are updated as well.
|
||||
func (c *CronTask) markMissingFromPrevious(ctx context.Context, dbConn *sqlx.DB, prevTable string, vcenter string, snapshotTime time.Time,
|
||||
currentByID map[string]InventorySnapshotRow, currentByUuid map[string]struct{}, currentByName map[string]struct{},
|
||||
invByID map[string]queries.Inventory, invByUuid map[string]queries.Inventory, invByName map[string]queries.Inventory) (int, bool) {
|
||||
invByID map[string]queries.Inventory, invByUuid map[string]queries.Inventory, invByName map[string]queries.Inventory, updateCompatSnapshot bool) (int, bool) {
|
||||
|
||||
if err := db.ValidateTableName(prevTable); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
prevSnapUnix, _ := parseSnapshotTime(prevTable)
|
||||
|
||||
type prevRow struct {
|
||||
VmId sql.NullString `db:"VmId"`
|
||||
@@ -342,17 +344,19 @@ func (c *CronTask) markMissingFromPrevious(ctx context.Context, dbConn *sqlx.DB,
|
||||
if err := db.MarkVmDeletedWithDetails(ctx, dbConn, vcenter, inv.VmId.String, vmUUID, inv.Name, inv.Cluster.String, delTime.Int64); err != nil {
|
||||
c.Logger.Warn("failed to mark lifecycle cache deleted from previous snapshot", "error", err, "vm_id", inv.VmId.String, "vm_uuid", vmUUID, "vcenter", vcenter)
|
||||
}
|
||||
if rowsAffected, err := updateDeletionTimeInSnapshot(ctx, dbConn, prevTable, vcenter, inv.VmId.String, vmUUID, inv.Name, delTime.Int64); err != nil {
|
||||
c.Logger.Warn("failed to update hourly snapshot deletion time", "error", err, "table", prevTable, "vm_id", inv.VmId.String, "vm_uuid", vmUUID, "vcenter", vcenter)
|
||||
} else if rowsAffected > 0 {
|
||||
tableUpdated = true
|
||||
c.Logger.Debug("updated hourly snapshot deletion time", "table", prevTable, "vm_id", inv.VmId.String, "vm_uuid", vmUUID, "vcenter", vcenter, "deletion_time", delTime.Int64)
|
||||
if snapUnix, ok := parseSnapshotTime(prevTable); ok {
|
||||
if cacheRows, err := updateDeletionTimeInHourlyCache(ctx, dbConn, vcenter, inv.VmId.String, vmUUID, inv.Name, snapUnix, delTime.Int64); err != nil {
|
||||
c.Logger.Warn("failed to update hourly cache deletion time", "error", err, "snapshot_time", snapUnix, "vm_id", inv.VmId.String, "vm_uuid", vmUUID, "vcenter", vcenter)
|
||||
} else if cacheRows > 0 {
|
||||
c.Logger.Debug("updated hourly cache deletion time", "snapshot_time", snapUnix, "vm_id", inv.VmId.String, "vm_uuid", vmUUID, "vcenter", vcenter, "deletion_time", delTime.Int64)
|
||||
}
|
||||
if prevSnapUnix > 0 {
|
||||
if cacheRows, err := updateDeletionTimeInHourlyCache(ctx, dbConn, vcenter, inv.VmId.String, vmUUID, inv.Name, prevSnapUnix, delTime.Int64); err != nil {
|
||||
c.Logger.Warn("failed to update hourly cache deletion time", "error", err, "snapshot_time", prevSnapUnix, "vm_id", inv.VmId.String, "vm_uuid", vmUUID, "vcenter", vcenter)
|
||||
} else if cacheRows > 0 {
|
||||
c.Logger.Debug("updated hourly cache deletion time", "snapshot_time", prevSnapUnix, "vm_id", inv.VmId.String, "vm_uuid", vmUUID, "vcenter", vcenter, "deletion_time", delTime.Int64)
|
||||
}
|
||||
}
|
||||
if updateCompatSnapshot {
|
||||
if rowsAffected, err := updateDeletionTimeInSnapshot(ctx, dbConn, prevTable, vcenter, inv.VmId.String, vmUUID, inv.Name, delTime.Int64); err != nil {
|
||||
c.Logger.Warn("failed to update hourly snapshot deletion time", "error", err, "table", prevTable, "vm_id", inv.VmId.String, "vm_uuid", vmUUID, "vcenter", vcenter)
|
||||
} else if rowsAffected > 0 {
|
||||
tableUpdated = true
|
||||
c.Logger.Debug("updated hourly snapshot deletion time", "table", prevTable, "vm_id", inv.VmId.String, "vm_uuid", vmUUID, "vcenter", vcenter, "deletion_time", delTime.Int64)
|
||||
}
|
||||
}
|
||||
c.Logger.Debug("Detected VM missing compared to previous snapshot", "name", inv.Name, "vm_id", inv.VmId.String, "vm_uuid", inv.VmUuid.String, "vcenter", vcenter, "snapshot_time", snapshotTime, "prev_table", prevTable)
|
||||
|
||||
@@ -29,7 +29,7 @@ func presenceKeys(vmID, vmUUID, name string) []string {
|
||||
// backfillLifecycleDeletionsToday looks for VMs in the lifecycle cache that are not in the current inventory,
|
||||
// have no DeletedAt, and determines their deletion time from today's hourly snapshots, optionally checking the next snapshot (next day) to confirm.
|
||||
// It returns any hourly snapshot tables that were updated with deletion times.
|
||||
func backfillLifecycleDeletionsToday(ctx context.Context, logger *slog.Logger, dbConn *sqlx.DB, vcenter string, snapshotTime time.Time, present map[string]InventorySnapshotRow) ([]string, error) {
|
||||
func backfillLifecycleDeletionsToday(ctx context.Context, logger *slog.Logger, dbConn *sqlx.DB, vcenter string, snapshotTime time.Time, present map[string]InventorySnapshotRow, updateCompatSnapshot bool) ([]string, error) {
|
||||
dayStart := truncateDate(snapshotTime)
|
||||
dayEnd := dayStart.Add(24 * time.Hour)
|
||||
|
||||
@@ -68,17 +68,19 @@ func backfillLifecycleDeletionsToday(ctx context.Context, logger *slog.Logger, d
|
||||
continue
|
||||
}
|
||||
if lastSeenTable != "" {
|
||||
if rowsAffected, err := updateDeletionTimeInSnapshot(ctx, dbConn, lastSeenTable, vcenter, cand.vmID, cand.vmUUID, cand.name, deletion); err != nil {
|
||||
logger.Warn("lifecycle backfill failed to update hourly snapshot deletion time", "vcenter", vcenter, "vm_id", cand.vmID, "vm_uuid", cand.vmUUID, "name", cand.name, "cluster", cand.cluster, "table", lastSeenTable, "deletion", deletion, "error", err)
|
||||
} else if rowsAffected > 0 {
|
||||
updatedTables[lastSeenTable] = struct{}{}
|
||||
logger.Debug("lifecycle backfill updated hourly snapshot deletion time", "vcenter", vcenter, "vm_id", cand.vmID, "vm_uuid", cand.vmUUID, "name", cand.name, "cluster", cand.cluster, "table", lastSeenTable, "deletion", deletion)
|
||||
if snapUnix, ok := parseSnapshotTime(lastSeenTable); ok {
|
||||
if cacheRows, err := updateDeletionTimeInHourlyCache(ctx, dbConn, vcenter, cand.vmID, cand.vmUUID, cand.name, snapUnix, deletion); err != nil {
|
||||
logger.Warn("lifecycle backfill failed to update hourly cache deletion time", "vcenter", vcenter, "vm_id", cand.vmID, "vm_uuid", cand.vmUUID, "name", cand.name, "snapshot_time", snapUnix, "deletion", deletion, "error", err)
|
||||
} else if cacheRows > 0 {
|
||||
logger.Debug("lifecycle backfill updated hourly cache deletion time", "vcenter", vcenter, "vm_id", cand.vmID, "vm_uuid", cand.vmUUID, "name", cand.name, "snapshot_time", snapUnix, "deletion", deletion)
|
||||
}
|
||||
if snapUnix, ok := parseSnapshotTime(lastSeenTable); ok {
|
||||
if cacheRows, err := updateDeletionTimeInHourlyCache(ctx, dbConn, vcenter, cand.vmID, cand.vmUUID, cand.name, snapUnix, deletion); err != nil {
|
||||
logger.Warn("lifecycle backfill failed to update hourly cache deletion time", "vcenter", vcenter, "vm_id", cand.vmID, "vm_uuid", cand.vmUUID, "name", cand.name, "snapshot_time", snapUnix, "deletion", deletion, "error", err)
|
||||
} else if cacheRows > 0 {
|
||||
logger.Debug("lifecycle backfill updated hourly cache deletion time", "vcenter", vcenter, "vm_id", cand.vmID, "vm_uuid", cand.vmUUID, "name", cand.name, "snapshot_time", snapUnix, "deletion", deletion)
|
||||
}
|
||||
}
|
||||
if updateCompatSnapshot {
|
||||
if rowsAffected, err := updateDeletionTimeInSnapshot(ctx, dbConn, lastSeenTable, vcenter, cand.vmID, cand.vmUUID, cand.name, deletion); err != nil {
|
||||
logger.Warn("lifecycle backfill failed to update hourly snapshot deletion time", "vcenter", vcenter, "vm_id", cand.vmID, "vm_uuid", cand.vmUUID, "name", cand.name, "cluster", cand.cluster, "table", lastSeenTable, "deletion", deletion, "error", err)
|
||||
} else if rowsAffected > 0 {
|
||||
updatedTables[lastSeenTable] = struct{}{}
|
||||
logger.Debug("lifecycle backfill updated hourly snapshot deletion time", "vcenter", vcenter, "vm_id", cand.vmID, "vm_uuid", cand.vmUUID, "name", cand.name, "cluster", cand.cluster, "table", lastSeenTable, "deletion", deletion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,7 @@ func (c *CronTask) RunVcenterSnapshotHourly(ctx context.Context, logger *slog.Lo
|
||||
if err := c.Settings.ReadYMLSettings(); err != nil {
|
||||
return err
|
||||
}
|
||||
db.SetVmHourlyStatsPostgresPartitioningEnabled(c.postgresVmHourlyPartitioningEnabled())
|
||||
ctx = settings.MarkReloadedInContext(ctx, c.Settings)
|
||||
|
||||
if c.FirstHourlySnapshotCheck {
|
||||
@@ -143,15 +144,20 @@ func (c *CronTask) RunVcenterSnapshotHourly(ctx context.Context, logger *slog.Lo
|
||||
c.FirstHourlySnapshotCheck = false
|
||||
}
|
||||
|
||||
tableName, err := hourlyInventoryTableName(startTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbConn := c.Database.DB()
|
||||
db.ApplySQLiteTuning(ctx, dbConn)
|
||||
if err := ensureDailyInventoryTable(ctx, dbConn, tableName); err != nil {
|
||||
return err
|
||||
compatMode := c.snapshotTableCompatModeEnabled()
|
||||
tableName := ""
|
||||
if compatMode {
|
||||
tableName, err = hourlyInventoryTableName(startTime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureDailyInventoryTable(ctx, dbConn, tableName); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
c.Logger.Info("Snapshot table compatibility mode disabled; writing canonical hourly cache only")
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
@@ -202,17 +208,21 @@ func (c *CronTask) RunVcenterSnapshotHourly(ctx context.Context, logger *slog.Lo
|
||||
return err
|
||||
}
|
||||
|
||||
rowCount, err := db.TableRowCount(ctx, dbConn, tableName)
|
||||
if err != nil {
|
||||
c.Logger.Warn("unable to count hourly snapshot rows", "error", err, "table", tableName)
|
||||
rowCount = -1
|
||||
}
|
||||
if err := report.RegisterSnapshot(ctx, c.Database, "hourly", tableName, startTime, rowCount); err != nil {
|
||||
c.Logger.Warn("failed to register hourly snapshot", "error", err, "table", tableName)
|
||||
rowCount := int64(-1)
|
||||
if tableName != "" {
|
||||
var countErr error
|
||||
rowCount, countErr = db.TableRowCount(ctx, dbConn, tableName)
|
||||
if countErr != nil {
|
||||
c.Logger.Warn("unable to count hourly snapshot rows", "error", countErr, "table", tableName)
|
||||
rowCount = -1
|
||||
}
|
||||
if err := report.RegisterSnapshot(ctx, c.Database, "hourly", tableName, startTime, rowCount); err != nil {
|
||||
c.Logger.Warn("failed to register hourly snapshot", "error", err, "table", tableName)
|
||||
}
|
||||
}
|
||||
|
||||
metrics.RecordHourlySnapshot(startTime, rowCount, err)
|
||||
var deferredTables []string
|
||||
deferredTables := make([]string, 0, 8)
|
||||
deferredReportTables.Range(func(key, _ any) bool {
|
||||
name, ok := key.(string)
|
||||
if ok && strings.TrimSpace(name) != "" && name != tableName {
|
||||
@@ -220,17 +230,31 @@ func (c *CronTask) RunVcenterSnapshotHourly(ctx context.Context, logger *slog.Lo
|
||||
}
|
||||
return true
|
||||
})
|
||||
sort.Strings(deferredTables)
|
||||
for _, reportTable := range deferredTables {
|
||||
if err := c.generateReport(ctx, reportTable); err != nil {
|
||||
c.Logger.Warn("failed to regenerate deferred hourly report after deletions", "error", err, "table", reportTable)
|
||||
} else {
|
||||
c.Logger.Debug("Regenerated deferred hourly report after deletions", "table", reportTable)
|
||||
if tableName != "" {
|
||||
deferredTables = append(deferredTables, tableName)
|
||||
}
|
||||
deferredTables = normalizeReportTables(deferredTables)
|
||||
reportStageStart := time.Now()
|
||||
reportMode := "sync"
|
||||
if c.asyncReportGenerationEnabled() {
|
||||
reportMode = "async"
|
||||
c.queueReportGeneration(deferredTables)
|
||||
} else {
|
||||
for _, reportTable := range deferredTables {
|
||||
if err := c.generateReport(ctx, reportTable); err != nil {
|
||||
c.Logger.Warn("failed to regenerate deferred hourly report after deletions", "error", err, "table", reportTable)
|
||||
} else {
|
||||
c.Logger.Debug("Regenerated deferred hourly report after deletions", "table", reportTable)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := c.generateReport(ctx, tableName); err != nil {
|
||||
c.Logger.Warn("failed to generate hourly report", "error", err, "table", tableName)
|
||||
}
|
||||
c.Logger.Info(
|
||||
"Hourly snapshot stage complete",
|
||||
"stage", "report_generation",
|
||||
"mode", reportMode,
|
||||
"tables", len(deferredTables),
|
||||
"duration", time.Since(reportStageStart),
|
||||
)
|
||||
|
||||
c.Logger.Debug("Finished hourly vcenter snapshot", "vcenter_count", len(c.Settings.Values.Settings.VcenterAddresses), "table", tableName, "row_count", rowCount)
|
||||
return nil
|
||||
@@ -631,6 +655,13 @@ func intWithDefault(value int, fallback int) int {
|
||||
return value
|
||||
}
|
||||
|
||||
func boolWithDefault(value *bool, fallback bool) bool {
|
||||
if value == nil {
|
||||
return fallback
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func durationFromSeconds(seconds int, fallback time.Duration) time.Duration {
|
||||
if seconds > 0 {
|
||||
return time.Duration(seconds) * time.Second
|
||||
@@ -665,6 +696,96 @@ func (c *CronTask) reportsDir() string {
|
||||
return "/var/lib/vctp/reports"
|
||||
}
|
||||
|
||||
func (c *CronTask) captureWriteBatchSize() int {
|
||||
if c.Settings != nil && c.Settings.Values != nil {
|
||||
return intWithDefault(c.Settings.Values.Settings.CaptureWriteBatchSize, 1000)
|
||||
}
|
||||
return 1000
|
||||
}
|
||||
|
||||
func (c *CronTask) snapshotTableCompatModeEnabled() bool {
|
||||
if c.Settings != nil && c.Settings.Values != nil {
|
||||
return boolWithDefault(c.Settings.Values.Settings.SnapshotTableCompatMode, true)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *CronTask) asyncReportGenerationEnabled() bool {
|
||||
if c.Settings != nil && c.Settings.Values != nil {
|
||||
return boolWithDefault(c.Settings.Values.Settings.AsyncReportGeneration, true)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *CronTask) postgresVmHourlyPartitioningEnabled() bool {
|
||||
if c.Settings != nil && c.Settings.Values != nil {
|
||||
return boolWithDefault(c.Settings.Values.Settings.PostgresVmHourlyPartitioning, false)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *CronTask) scheduledAggregationEngine() string {
|
||||
if c.Settings == nil || c.Settings.Values == nil {
|
||||
return "go"
|
||||
}
|
||||
engine := strings.ToLower(strings.TrimSpace(c.Settings.Values.Settings.ScheduledAggregationEngine))
|
||||
if engine == "" {
|
||||
return "go"
|
||||
}
|
||||
switch engine {
|
||||
case "go", "sql":
|
||||
return engine
|
||||
default:
|
||||
return "go"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeReportTables(tables []string) []string {
|
||||
if len(tables) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(tables))
|
||||
out := make([]string, 0, len(tables))
|
||||
for _, table := range tables {
|
||||
trimmed := strings.TrimSpace(table)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *CronTask) queueReportGeneration(tables []string) {
|
||||
tables = normalizeReportTables(tables)
|
||||
if len(tables) == 0 {
|
||||
return
|
||||
}
|
||||
c.Logger.Info("Queueing async report generation", "tables", len(tables))
|
||||
go func(reportTables []string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
|
||||
defer cancel()
|
||||
for _, reportTable := range reportTables {
|
||||
if err := c.generateReport(ctx, reportTable); err != nil {
|
||||
c.Logger.Warn("failed to generate async report", "table", reportTable, "error", err)
|
||||
}
|
||||
}
|
||||
}(append([]string(nil), tables...))
|
||||
}
|
||||
|
||||
func (c *CronTask) generateReportWithPolicy(ctx context.Context, table string) error {
|
||||
if c.asyncReportGenerationEnabled() {
|
||||
c.queueReportGeneration([]string{table})
|
||||
return nil
|
||||
}
|
||||
return c.generateReport(ctx, table)
|
||||
}
|
||||
|
||||
func (c *CronTask) generateReport(ctx context.Context, tableName string) error {
|
||||
dest := c.reportsDir()
|
||||
start := time.Now()
|
||||
@@ -1332,6 +1453,7 @@ func (c *CronTask) captureHourlySnapshotForVcenter(ctx context.Context, startTim
|
||||
log := c.Logger.With("vcenter", url)
|
||||
ctx = db.WithLoggerContext(ctx, log)
|
||||
started := time.Now()
|
||||
captureStageStart := time.Now()
|
||||
log.Debug("connecting to vcenter for hourly snapshot", "url", url)
|
||||
vc, resources, cleanup, err := c.initVcenterResources(ctx, log, url, startTime, started)
|
||||
if err != nil {
|
||||
@@ -1365,12 +1487,54 @@ func (c *CronTask) captureHourlySnapshotForVcenter(ctx context.Context, startTim
|
||||
for _, row := range presentSnapshots {
|
||||
batch = append(batch, row)
|
||||
}
|
||||
log.Info(
|
||||
"Hourly snapshot stage complete",
|
||||
"stage", "capture",
|
||||
"duration", time.Since(captureStageStart),
|
||||
"present_rows", len(presentSnapshots),
|
||||
"inventory_rows", len(inventoryRows),
|
||||
"batch_rows", len(batch),
|
||||
)
|
||||
|
||||
log.Debug("inserting hourly snapshot batch", "vcenter", url, "rows", len(batch))
|
||||
writeBatchSize := c.captureWriteBatchSize()
|
||||
for start := 0; start < len(batch); start += writeBatchSize {
|
||||
end := min(start+writeBatchSize, len(batch))
|
||||
chunk := batch[start:end]
|
||||
if err := insertHourlyCache(ctx, dbConn, chunk); err != nil {
|
||||
log.Warn("failed to insert hourly cache rows", "vcenter", url, "error", err, "chunk_start", start, "chunk_size", len(chunk))
|
||||
}
|
||||
if tableName != "" {
|
||||
if err := insertHourlyBatch(ctx, dbConn, tableName, chunk); err != nil {
|
||||
metrics.RecordVcenterSnapshot(url, time.Since(started), totals.VmCount, err)
|
||||
if upErr := db.UpsertSnapshotRun(ctx, c.Database.DB(), url, startTime, false, err.Error()); upErr != nil {
|
||||
log.Warn("failed to record snapshot run", "url", url, "error", upErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Record per-vCenter totals snapshot.
|
||||
totalsStageStart := time.Now()
|
||||
if err := db.InsertVcenterTotals(ctx, dbConn, url, startTime, totals.VmCount, totals.VcpuTotal, totals.RamTotal); err != nil {
|
||||
slog.Warn("failed to insert vcenter totals", "vcenter", url, "snapshot_time", startTime.Unix(), "error", err)
|
||||
}
|
||||
log.Info(
|
||||
"Hourly snapshot stage complete",
|
||||
"stage", "totals_refresh",
|
||||
"duration", time.Since(totalsStageStart),
|
||||
"vm_count", totals.VmCount,
|
||||
)
|
||||
|
||||
log.Debug("checking inventory for missing VMs")
|
||||
|
||||
reconcileStageStart := time.Now()
|
||||
missingCount, deletionsMarked, candidates := prepareDeletionCandidates(ctx, log, dbConn, q, url, inventoryRows, presentSnapshots, presentByUuid, presentByName, startTime)
|
||||
newCount := 0
|
||||
prevTableName := ""
|
||||
reportTables := make(map[string]struct{})
|
||||
compatSnapshotUpdates := strings.TrimSpace(tableName) != ""
|
||||
|
||||
// If deletions detected, refine deletion time using vCenter events in a small window.
|
||||
if missingCount > 0 {
|
||||
@@ -1461,18 +1625,20 @@ func (c *CronTask) captureHourlySnapshotForVcenter(ctx context.Context, startTim
|
||||
if name == "" {
|
||||
name = snapRow.Name
|
||||
}
|
||||
if rowsAffected, err := updateDeletionTimeInSnapshot(ctx, dbConn, snapTable, url, cand.vmID, vmUUID, name, delTs.Int64); err != nil {
|
||||
log.Warn("failed to update hourly snapshot deletion time from event", "table", snapTable, "vm_id", cand.vmID, "vm_uuid", vmUUID, "vcenter", url, "error", err)
|
||||
} else if rowsAffected > 0 {
|
||||
reportTables[snapTable] = struct{}{}
|
||||
deletionsMarked = true
|
||||
log.Debug("updated hourly snapshot deletion time from event", "table", snapTable, "vm_id", cand.vmID, "vm_uuid", vmUUID, "vcenter", url, "event_time", t)
|
||||
if snapUnix, ok := parseSnapshotTime(snapTable); ok {
|
||||
if cacheRows, err := updateDeletionTimeInHourlyCache(ctx, dbConn, url, cand.vmID, vmUUID, name, snapUnix, delTs.Int64); err != nil {
|
||||
log.Warn("failed to update hourly cache deletion time from event", "snapshot_time", snapUnix, "vm_id", cand.vmID, "vm_uuid", vmUUID, "vcenter", url, "error", err)
|
||||
} else if cacheRows > 0 {
|
||||
log.Debug("updated hourly cache deletion time from event", "snapshot_time", snapUnix, "vm_id", cand.vmID, "vm_uuid", vmUUID, "vcenter", url, "event_time", t)
|
||||
}
|
||||
if snapUnix, ok := parseSnapshotTime(snapTable); ok {
|
||||
if cacheRows, err := updateDeletionTimeInHourlyCache(ctx, dbConn, url, cand.vmID, vmUUID, name, snapUnix, delTs.Int64); err != nil {
|
||||
log.Warn("failed to update hourly cache deletion time from event", "snapshot_time", snapUnix, "vm_id", cand.vmID, "vm_uuid", vmUUID, "vcenter", url, "error", err)
|
||||
} else if cacheRows > 0 {
|
||||
log.Debug("updated hourly cache deletion time from event", "snapshot_time", snapUnix, "vm_id", cand.vmID, "vm_uuid", vmUUID, "vcenter", url, "event_time", t)
|
||||
}
|
||||
}
|
||||
if compatSnapshotUpdates {
|
||||
if rowsAffected, err := updateDeletionTimeInSnapshot(ctx, dbConn, snapTable, url, cand.vmID, vmUUID, name, delTs.Int64); err != nil {
|
||||
log.Warn("failed to update hourly snapshot deletion time from event", "table", snapTable, "vm_id", cand.vmID, "vm_uuid", vmUUID, "vcenter", url, "error", err)
|
||||
} else if rowsAffected > 0 {
|
||||
reportTables[snapTable] = struct{}{}
|
||||
deletionsMarked = true
|
||||
log.Debug("updated hourly snapshot deletion time from event", "table", snapTable, "vm_id", cand.vmID, "vm_uuid", vmUUID, "vcenter", url, "event_time", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1496,27 +1662,9 @@ func (c *CronTask) captureHourlySnapshotForVcenter(ctx context.Context, startTim
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug("inserting hourly snapshot batch", "vcenter", url, "rows", len(batch))
|
||||
|
||||
if err := insertHourlyCache(ctx, dbConn, batch); err != nil {
|
||||
log.Warn("failed to insert hourly cache rows", "vcenter", url, "error", err)
|
||||
}
|
||||
|
||||
if err := insertHourlyBatch(ctx, dbConn, tableName, batch); err != nil {
|
||||
metrics.RecordVcenterSnapshot(url, time.Since(started), totals.VmCount, err)
|
||||
if upErr := db.UpsertSnapshotRun(ctx, c.Database.DB(), url, startTime, false, err.Error()); upErr != nil {
|
||||
log.Warn("failed to record snapshot run", "url", url, "error", upErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
// Record per-vCenter totals snapshot.
|
||||
if err := db.InsertVcenterTotals(ctx, dbConn, url, startTime, totals.VmCount, totals.VcpuTotal, totals.RamTotal); err != nil {
|
||||
slog.Warn("failed to insert vcenter totals", "vcenter", url, "snapshot_time", startTime.Unix(), "error", err)
|
||||
}
|
||||
|
||||
// Discover previous snapshots once per run (serial) to avoid concurrent probes across vCenters.
|
||||
var prevTableTouched bool
|
||||
prevTableName, newCount, missingCount, prevTableTouched = c.compareWithPreviousSnapshot(ctx, dbConn, url, startTime, presentSnapshots, presentByUuid, presentByName, inventoryByVmID, inventoryByUuid, inventoryByName, missingCount)
|
||||
prevTableName, newCount, missingCount, prevTableTouched = c.compareWithPreviousSnapshot(ctx, dbConn, url, startTime, presentSnapshots, presentByUuid, presentByName, inventoryByVmID, inventoryByUuid, inventoryByName, missingCount, compatSnapshotUpdates)
|
||||
if prevTableTouched && prevTableName != "" {
|
||||
reportTables[prevTableName] = struct{}{}
|
||||
deletionsMarked = true
|
||||
@@ -1527,15 +1675,6 @@ func (c *CronTask) captureHourlySnapshotForVcenter(ctx context.Context, startTim
|
||||
// Fallback: locate a previous table only if we didn't already find one.
|
||||
if prevTableName == "" {
|
||||
if prevTable, err := latestHourlySnapshotBefore(ctx, dbConn, startTime, loggerFromCtx(ctx, c.Logger)); err == nil && prevTable != "" {
|
||||
moreMissing, tableUpdated := c.markMissingFromPrevious(ctx, dbConn, prevTable, url, startTime, presentSnapshots, presentByUuid, presentByName, inventoryByVmID, inventoryByUuid, inventoryByName)
|
||||
if moreMissing > 0 {
|
||||
missingCount += moreMissing
|
||||
}
|
||||
if tableUpdated {
|
||||
reportTables[prevTable] = struct{}{}
|
||||
deletionsMarked = true
|
||||
}
|
||||
// Reuse this table name for later snapshot lookups when correlating deletion events.
|
||||
prevTableName = prevTable
|
||||
}
|
||||
}
|
||||
@@ -1599,18 +1738,20 @@ func (c *CronTask) captureHourlySnapshotForVcenter(ctx context.Context, startTim
|
||||
tableToUpdate = prevTableName
|
||||
}
|
||||
if tableToUpdate != "" {
|
||||
if rowsAffected, err := updateDeletionTimeInSnapshot(ctx, dbConn, tableToUpdate, url, vmID, inv.VmUuid.String, inv.Name, delTs.Int64); err != nil {
|
||||
c.Logger.Warn("count-drop: failed to update hourly snapshot deletion time from event", "table", tableToUpdate, "vm_id", vmID, "vcenter", url, "error", err)
|
||||
} else if rowsAffected > 0 {
|
||||
reportTables[tableToUpdate] = struct{}{}
|
||||
deletionsMarked = true
|
||||
c.Logger.Debug("count-drop: updated hourly snapshot deletion time from event", "table", tableToUpdate, "vm_id", vmID, "vm_uuid", inv.VmUuid.String, "vcenter", url, "event_time", t)
|
||||
if snapUnix, ok := parseSnapshotTime(tableToUpdate); ok {
|
||||
if cacheRows, err := updateDeletionTimeInHourlyCache(ctx, dbConn, url, vmID, inv.VmUuid.String, inv.Name, snapUnix, delTs.Int64); err != nil {
|
||||
c.Logger.Warn("count-drop: failed to update hourly cache deletion time", "snapshot_time", snapUnix, "vm_id", vmID, "vm_uuid", inv.VmUuid.String, "vcenter", url, "error", err)
|
||||
} else if cacheRows > 0 {
|
||||
c.Logger.Debug("count-drop: updated hourly cache deletion time", "snapshot_time", snapUnix, "vm_id", vmID, "vm_uuid", inv.VmUuid.String, "vcenter", url, "event_time", t)
|
||||
}
|
||||
if snapUnix, ok := parseSnapshotTime(tableToUpdate); ok {
|
||||
if cacheRows, err := updateDeletionTimeInHourlyCache(ctx, dbConn, url, vmID, inv.VmUuid.String, inv.Name, snapUnix, delTs.Int64); err != nil {
|
||||
c.Logger.Warn("count-drop: failed to update hourly cache deletion time", "snapshot_time", snapUnix, "vm_id", vmID, "vm_uuid", inv.VmUuid.String, "vcenter", url, "error", err)
|
||||
} else if cacheRows > 0 {
|
||||
c.Logger.Debug("count-drop: updated hourly cache deletion time", "snapshot_time", snapUnix, "vm_id", vmID, "vm_uuid", inv.VmUuid.String, "vcenter", url, "event_time", t)
|
||||
}
|
||||
}
|
||||
if compatSnapshotUpdates {
|
||||
if rowsAffected, err := updateDeletionTimeInSnapshot(ctx, dbConn, tableToUpdate, url, vmID, inv.VmUuid.String, inv.Name, delTs.Int64); err != nil {
|
||||
c.Logger.Warn("count-drop: failed to update hourly snapshot deletion time from event", "table", tableToUpdate, "vm_id", vmID, "vcenter", url, "error", err)
|
||||
} else if rowsAffected > 0 {
|
||||
reportTables[tableToUpdate] = struct{}{}
|
||||
deletionsMarked = true
|
||||
c.Logger.Debug("count-drop: updated hourly snapshot deletion time from event", "table", tableToUpdate, "vm_id", vmID, "vm_uuid", inv.VmUuid.String, "vcenter", url, "event_time", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1621,7 +1762,7 @@ func (c *CronTask) captureHourlySnapshotForVcenter(ctx context.Context, startTim
|
||||
}
|
||||
|
||||
// Backfill lifecycle deletions for VMs missing from inventory and without DeletedAt.
|
||||
if backfillTables, err := backfillLifecycleDeletionsToday(ctx, log, dbConn, url, startTime, presentSnapshots); err != nil {
|
||||
if backfillTables, err := backfillLifecycleDeletionsToday(ctx, log, dbConn, url, startTime, presentSnapshots, compatSnapshotUpdates); err != nil {
|
||||
log.Warn("failed to backfill lifecycle deletions for today", "vcenter", url, "error", err)
|
||||
} else if len(backfillTables) > 0 {
|
||||
for _, table := range backfillTables {
|
||||
@@ -1629,6 +1770,14 @@ func (c *CronTask) captureHourlySnapshotForVcenter(ctx context.Context, startTim
|
||||
}
|
||||
deletionsMarked = true
|
||||
}
|
||||
log.Info(
|
||||
"Hourly snapshot stage complete",
|
||||
"stage", "reconcile",
|
||||
"duration", time.Since(reconcileStageStart),
|
||||
"missing_marked", missingCount,
|
||||
"created_since_prev", newCount,
|
||||
"tables_touched", len(reportTables),
|
||||
)
|
||||
|
||||
log.Info("Hourly snapshot summary",
|
||||
"vcenter", url,
|
||||
@@ -1644,25 +1793,40 @@ func (c *CronTask) captureHourlySnapshotForVcenter(ctx context.Context, startTim
|
||||
if upErr := db.UpsertSnapshotRun(ctx, c.Database.DB(), url, startTime, true, ""); upErr != nil {
|
||||
log.Warn("failed to record snapshot run", "url", url, "error", upErr)
|
||||
}
|
||||
reportStageStart := time.Now()
|
||||
queuedReports := 0
|
||||
generatedReports := 0
|
||||
if deletionsMarked {
|
||||
if len(reportTables) == 0 {
|
||||
if len(reportTables) == 0 && strings.TrimSpace(tableName) != "" {
|
||||
reportTables[tableName] = struct{}{}
|
||||
}
|
||||
if deferredReportTables != nil {
|
||||
for reportTable := range reportTables {
|
||||
deferredReportTables.Store(reportTable, struct{}{})
|
||||
queuedReports++
|
||||
}
|
||||
log.Debug("Queued hourly report regeneration after deletions", "tables", len(reportTables))
|
||||
} else {
|
||||
for reportTable := range reportTables {
|
||||
if err := c.generateReport(ctx, reportTable); err != nil {
|
||||
if err := c.generateReportWithPolicy(ctx, reportTable); err != nil {
|
||||
log.Warn("failed to regenerate hourly report after deletions", "error", err, "table", reportTable)
|
||||
} else {
|
||||
generatedReports++
|
||||
log.Debug("Regenerated hourly report after deletions", "table", reportTable)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Info(
|
||||
"Hourly snapshot stage complete",
|
||||
"stage", "report_generation",
|
||||
"duration", time.Since(reportStageStart),
|
||||
"deletions_marked", deletionsMarked,
|
||||
"tables", len(reportTables),
|
||||
"queued_tables", queuedReports,
|
||||
"generated_tables", generatedReports,
|
||||
"deferred", deferredReportTables != nil,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1680,6 +1844,7 @@ func (c *CronTask) compareWithPreviousSnapshot(
|
||||
inventoryByUuid map[string]queries.Inventory,
|
||||
inventoryByName map[string]queries.Inventory,
|
||||
missingCount int,
|
||||
updateCompatSnapshot bool,
|
||||
) (string, int, int, bool) {
|
||||
prevTableName, prevTableErr := latestHourlySnapshotBefore(ctx, dbConn, startTime, loggerFromCtx(ctx, c.Logger))
|
||||
if prevTableErr != nil {
|
||||
@@ -1691,7 +1856,7 @@ func (c *CronTask) compareWithPreviousSnapshot(
|
||||
newCount := 0
|
||||
prevTableTouched := false
|
||||
if prevTableName != "" {
|
||||
moreMissing, tableUpdated := c.markMissingFromPrevious(ctx, dbConn, prevTableName, url, startTime, presentSnapshots, presentByUuid, presentByName, inventoryByVmID, inventoryByUuid, inventoryByName)
|
||||
moreMissing, tableUpdated := c.markMissingFromPrevious(ctx, dbConn, prevTableName, url, startTime, presentSnapshots, presentByUuid, presentByName, inventoryByVmID, inventoryByUuid, inventoryByName, updateCompatSnapshot)
|
||||
missingCount += moreMissing
|
||||
if tableUpdated {
|
||||
prevTableTouched = true
|
||||
|
||||
@@ -32,15 +32,15 @@ func (c *CronTask) RunVcenterMonthlyAggregate(ctx context.Context, logger *slog.
|
||||
now := time.Now()
|
||||
firstOfThisMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
targetMonth := firstOfThisMonth.AddDate(0, -1, 0)
|
||||
return c.aggregateMonthlySummary(jobCtx, targetMonth, false)
|
||||
return c.aggregateMonthlySummaryWithMode(jobCtx, targetMonth, false, true)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *CronTask) AggregateMonthlySummary(ctx context.Context, month time.Time, force bool) error {
|
||||
return c.aggregateMonthlySummary(ctx, month, force)
|
||||
return c.aggregateMonthlySummaryWithMode(ctx, month, force, false)
|
||||
}
|
||||
|
||||
func (c *CronTask) aggregateMonthlySummary(ctx context.Context, targetMonth time.Time, force bool) error {
|
||||
func (c *CronTask) aggregateMonthlySummaryWithMode(ctx context.Context, targetMonth time.Time, force bool, scheduled bool) error {
|
||||
jobStart := time.Now()
|
||||
if err := report.EnsureSnapshotRegistry(ctx, c.Database); err != nil {
|
||||
return err
|
||||
@@ -48,11 +48,14 @@ func (c *CronTask) aggregateMonthlySummary(ctx context.Context, targetMonth time
|
||||
|
||||
granularity := strings.ToLower(strings.TrimSpace(c.Settings.Values.Settings.MonthlyAggregationGranularity))
|
||||
if granularity == "" {
|
||||
granularity = "hourly"
|
||||
granularity = "daily"
|
||||
}
|
||||
if scheduled {
|
||||
granularity = "daily"
|
||||
}
|
||||
if granularity != "hourly" && granularity != "daily" {
|
||||
c.Logger.Warn("unknown monthly aggregation granularity; defaulting to hourly", "granularity", granularity)
|
||||
granularity = "hourly"
|
||||
c.Logger.Warn("unknown monthly aggregation granularity; defaulting to daily", "granularity", granularity)
|
||||
granularity = "daily"
|
||||
}
|
||||
|
||||
monthStart := time.Date(targetMonth.Year(), targetMonth.Month(), 1, 0, 0, 0, 0, targetMonth.Location())
|
||||
@@ -60,7 +63,14 @@ func (c *CronTask) aggregateMonthlySummary(ctx context.Context, targetMonth time
|
||||
dbConn := c.Database.DB()
|
||||
db.SetPostgresWorkMem(ctx, dbConn, c.Settings.Values.Settings.PostgresWorkMemMB)
|
||||
driver := strings.ToLower(dbConn.DriverName())
|
||||
useGoAgg := os.Getenv("MONTHLY_AGG_GO") == "1"
|
||||
// Canonical Go aggregation is the default for both scheduled and manual runs.
|
||||
// Legacy SQL/union aggregation stays available as a manual fallback/backfill path.
|
||||
forceGoAgg := os.Getenv("MONTHLY_AGG_GO") == "1"
|
||||
forceSQLAgg := !scheduled && os.Getenv("MONTHLY_AGG_SQL") == "1"
|
||||
useGoAgg := scheduled || forceGoAgg || !forceSQLAgg
|
||||
if forceSQLAgg && !forceGoAgg {
|
||||
c.Logger.Info("MONTHLY_AGG_SQL=1 enabled; using SQL fallback path for manual monthly aggregation")
|
||||
}
|
||||
if !useGoAgg && granularity == "hourly" && driver == "sqlite" {
|
||||
c.Logger.Warn("SQL monthly aggregation is slow on sqlite; overriding to Go path", "granularity", granularity)
|
||||
useGoAgg = true
|
||||
@@ -68,26 +78,28 @@ func (c *CronTask) aggregateMonthlySummary(ctx context.Context, targetMonth time
|
||||
|
||||
var snapshots []report.SnapshotRecord
|
||||
var unionColumns []string
|
||||
if granularity == "daily" {
|
||||
dailySnapshots, err := report.SnapshotRecordsWithFallback(ctx, c.Database, "daily", "inventory_daily_summary_", "20060102", monthStart, monthEnd)
|
||||
if err != nil {
|
||||
return err
|
||||
if !scheduled {
|
||||
if granularity == "daily" {
|
||||
dailySnapshots, err := report.SnapshotRecordsWithFallback(ctx, c.Database, "daily", "inventory_daily_summary_", "20060102", monthStart, monthEnd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dailySnapshots = filterRecordsInRange(dailySnapshots, monthStart, monthEnd)
|
||||
dailySnapshots = filterSnapshotsWithRows(ctx, dbConn, dailySnapshots)
|
||||
snapshots = dailySnapshots
|
||||
unionColumns = monthlyUnionColumns
|
||||
} else {
|
||||
hourlySnapshots, err := report.SnapshotRecordsWithFallback(ctx, c.Database, "hourly", "inventory_hourly_", "epoch", monthStart, monthEnd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hourlySnapshots = filterRecordsInRange(hourlySnapshots, monthStart, monthEnd)
|
||||
hourlySnapshots = filterSnapshotsWithRows(ctx, dbConn, hourlySnapshots)
|
||||
snapshots = hourlySnapshots
|
||||
unionColumns = summaryUnionColumns
|
||||
}
|
||||
dailySnapshots = filterRecordsInRange(dailySnapshots, monthStart, monthEnd)
|
||||
dailySnapshots = filterSnapshotsWithRows(ctx, dbConn, dailySnapshots)
|
||||
snapshots = dailySnapshots
|
||||
unionColumns = monthlyUnionColumns
|
||||
} else {
|
||||
hourlySnapshots, err := report.SnapshotRecordsWithFallback(ctx, c.Database, "hourly", "inventory_hourly_", "epoch", monthStart, monthEnd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hourlySnapshots = filterRecordsInRange(hourlySnapshots, monthStart, monthEnd)
|
||||
hourlySnapshots = filterSnapshotsWithRows(ctx, dbConn, hourlySnapshots)
|
||||
snapshots = hourlySnapshots
|
||||
unionColumns = summaryUnionColumns
|
||||
}
|
||||
if len(snapshots) == 0 {
|
||||
if !scheduled && len(snapshots) == 0 {
|
||||
return fmt.Errorf("no %s snapshot tables found for %s", granularity, targetMonth.Format("2006-01"))
|
||||
}
|
||||
|
||||
@@ -110,12 +122,26 @@ func (c *CronTask) aggregateMonthlySummary(ctx context.Context, targetMonth time
|
||||
}
|
||||
}
|
||||
|
||||
if scheduled && c.scheduledAggregationEngine() == "sql" {
|
||||
c.Logger.Info("scheduled_aggregation_engine=sql enabled; using canonical SQL monthly aggregation path")
|
||||
if err := c.aggregateMonthlySummarySQLCanonical(ctx, monthStart, monthEnd, monthlyTable); err != nil {
|
||||
c.Logger.Warn("scheduled canonical SQL monthly aggregation failed; falling back to go path", "error", err)
|
||||
} else {
|
||||
metrics.RecordMonthlyAggregation(time.Since(jobStart), nil)
|
||||
c.Logger.Debug("Finished monthly inventory aggregation (SQL canonical path)", "summary_table", monthlyTable)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Optional Go-based aggregation path.
|
||||
if useGoAgg {
|
||||
switch granularity {
|
||||
case "daily":
|
||||
c.Logger.Debug("Using go implementation of monthly aggregation (daily)")
|
||||
if err := c.aggregateMonthlySummaryGo(ctx, monthStart, monthEnd, monthlyTable, snapshots); err != nil {
|
||||
if err := c.aggregateMonthlySummaryGo(ctx, monthStart, monthEnd, monthlyTable, snapshots, scheduled); err != nil {
|
||||
if scheduled {
|
||||
return err
|
||||
}
|
||||
c.Logger.Warn("go-based monthly aggregation failed, falling back to SQL path", "error", err)
|
||||
} else {
|
||||
metrics.RecordMonthlyAggregation(time.Since(jobStart), nil)
|
||||
@@ -123,6 +149,9 @@ func (c *CronTask) aggregateMonthlySummary(ctx context.Context, targetMonth time
|
||||
return nil
|
||||
}
|
||||
case "hourly":
|
||||
if scheduled {
|
||||
return fmt.Errorf("scheduled monthly aggregation does not support hourly source mode")
|
||||
}
|
||||
c.Logger.Debug("Using go implementation of monthly aggregation (hourly)")
|
||||
if err := c.aggregateMonthlySummaryGoHourly(ctx, monthStart, monthEnd, monthlyTable, snapshots); err != nil {
|
||||
c.Logger.Warn("go-based monthly aggregation failed, falling back to SQL path", "error", err)
|
||||
@@ -135,6 +164,9 @@ func (c *CronTask) aggregateMonthlySummary(ctx context.Context, targetMonth time
|
||||
c.Logger.Warn("MONTHLY_AGG_GO is set but granularity is unsupported; using SQL path", "granularity", granularity)
|
||||
}
|
||||
}
|
||||
if scheduled {
|
||||
return fmt.Errorf("scheduled monthly aggregation requires go daily-rollup path")
|
||||
}
|
||||
|
||||
tables := make([]string, 0, len(snapshots))
|
||||
for _, snapshot := range snapshots {
|
||||
@@ -190,7 +222,7 @@ func (c *CronTask) aggregateMonthlySummary(ctx context.Context, targetMonth time
|
||||
|
||||
db.AnalyzeTableIfPostgres(ctx, dbConn, monthlyTable)
|
||||
|
||||
if err := c.generateReport(ctx, monthlyTable); err != nil {
|
||||
if err := c.generateReportWithPolicy(ctx, monthlyTable); err != nil {
|
||||
c.Logger.Warn("failed to generate monthly report", "error", err, "table", monthlyTable)
|
||||
metrics.RecordMonthlyAggregation(time.Since(jobStart), err)
|
||||
return err
|
||||
@@ -205,6 +237,52 @@ func monthlySummaryTableName(t time.Time) (string, error) {
|
||||
return db.SafeTableName(fmt.Sprintf("inventory_monthly_summary_%s", t.Format("200601")))
|
||||
}
|
||||
|
||||
func (c *CronTask) aggregateMonthlySummarySQLCanonical(ctx context.Context, monthStart, monthEnd time.Time, summaryTable string) error {
|
||||
jobStart := time.Now()
|
||||
dbConn := c.Database.DB()
|
||||
if !db.TableExists(ctx, dbConn, "vm_daily_rollup") {
|
||||
return fmt.Errorf("vm_daily_rollup table not found for canonical SQL monthly aggregation")
|
||||
}
|
||||
|
||||
unionQuery := buildCanonicalDailyRollupSummaryUnion(monthStart, monthEnd)
|
||||
insertQuery, err := db.BuildMonthlySummaryInsert(summaryTable, unionQuery)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := dbConn.ExecContext(ctx, insertQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if applied, err := db.ApplyLifecycleDeletionToSummary(ctx, dbConn, summaryTable, monthStart.Unix(), monthEnd.Unix()); err != nil {
|
||||
c.Logger.Warn("failed to apply lifecycle deletions to monthly summary (SQL canonical)", "error", err, "table", summaryTable)
|
||||
} else {
|
||||
c.Logger.Info("Monthly aggregation deletion times", "source_lifecycle_cache", applied)
|
||||
}
|
||||
if err := db.RefineCreationDeletionFromUnion(ctx, dbConn, summaryTable, buildDailyRollupLifecycleUnion(monthStart, monthEnd)); err != nil {
|
||||
c.Logger.Warn("failed to refine creation/deletion times (monthly SQL canonical)", "error", err, "table", summaryTable)
|
||||
}
|
||||
if err := db.UpdateSummaryPresenceByWindow(ctx, dbConn, summaryTable, monthStart.Unix(), monthEnd.Unix()); err != nil {
|
||||
c.Logger.Warn("failed to update monthly AvgIsPresent from lifecycle window (SQL canonical)", "error", err, "table", summaryTable)
|
||||
}
|
||||
|
||||
db.AnalyzeTableIfPostgres(ctx, dbConn, summaryTable)
|
||||
rowCount, err := db.TableRowCount(ctx, dbConn, summaryTable)
|
||||
if err != nil {
|
||||
c.Logger.Warn("unable to count monthly summary rows (SQL canonical)", "error", err, "table", summaryTable)
|
||||
}
|
||||
if rowCount == 0 {
|
||||
return fmt.Errorf("no VM records aggregated for %s", monthStart.Format("2006-01"))
|
||||
}
|
||||
if err := report.RegisterSnapshot(ctx, c.Database, "monthly", summaryTable, monthStart, rowCount); err != nil {
|
||||
c.Logger.Warn("failed to register monthly snapshot (SQL canonical)", "error", err, "table", summaryTable)
|
||||
}
|
||||
if err := c.generateReportWithPolicy(ctx, summaryTable); err != nil {
|
||||
c.Logger.Warn("failed to generate monthly report (SQL canonical)", "error", err, "table", summaryTable)
|
||||
return err
|
||||
}
|
||||
c.Logger.Debug("Finished monthly inventory aggregation (SQL canonical path)", "summary_table", summaryTable, "duration", time.Since(jobStart))
|
||||
return nil
|
||||
}
|
||||
|
||||
// aggregateMonthlySummaryGoHourly aggregates hourly snapshots directly into the monthly summary table.
|
||||
func (c *CronTask) aggregateMonthlySummaryGoHourly(ctx context.Context, monthStart, monthEnd time.Time, summaryTable string, hourlySnapshots []report.SnapshotRecord) error {
|
||||
jobStart := time.Now()
|
||||
@@ -311,7 +389,7 @@ func (c *CronTask) aggregateMonthlySummaryGoHourly(ctx context.Context, monthSta
|
||||
if err := report.RegisterSnapshot(ctx, c.Database, "monthly", summaryTable, monthStart, rowCount); err != nil {
|
||||
c.Logger.Warn("failed to register monthly snapshot (Go hourly)", "error", err, "table", summaryTable)
|
||||
}
|
||||
if err := c.generateReport(ctx, summaryTable); err != nil {
|
||||
if err := c.generateReportWithPolicy(ctx, summaryTable); err != nil {
|
||||
c.Logger.Warn("failed to generate monthly report (Go hourly)", "error", err, "table", summaryTable)
|
||||
return err
|
||||
}
|
||||
@@ -328,7 +406,7 @@ func (c *CronTask) aggregateMonthlySummaryGoHourly(ctx context.Context, monthSta
|
||||
|
||||
// aggregateMonthlySummaryGo mirrors the SQL-based monthly aggregation but performs the work in Go,
|
||||
// reading daily summaries in parallel and reducing them to a single monthly summary table.
|
||||
func (c *CronTask) aggregateMonthlySummaryGo(ctx context.Context, monthStart, monthEnd time.Time, summaryTable string, dailySnapshots []report.SnapshotRecord) error {
|
||||
func (c *CronTask) aggregateMonthlySummaryGo(ctx context.Context, monthStart, monthEnd time.Time, summaryTable string, dailySnapshots []report.SnapshotRecord, canonicalOnly bool) error {
|
||||
jobStart := time.Now()
|
||||
dbConn := c.Database.DB()
|
||||
|
||||
@@ -336,26 +414,39 @@ func (c *CronTask) aggregateMonthlySummaryGo(ctx context.Context, monthStart, mo
|
||||
return err
|
||||
}
|
||||
|
||||
// Build union query for lifecycle refinement after inserts.
|
||||
dailyTables := make([]string, 0, len(dailySnapshots))
|
||||
for _, snapshot := range dailySnapshots {
|
||||
dailyTables = append(dailyTables, snapshot.TableName)
|
||||
}
|
||||
unionQuery, err := buildUnionQuery(dailyTables, monthlyUnionColumns, templateExclusionFilter())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
unionQuery := ""
|
||||
var (
|
||||
aggMap map[monthlyAggKey]*monthlyAggVal
|
||||
err error
|
||||
)
|
||||
if canonicalOnly {
|
||||
aggMap, err = c.scanDailyRollup(ctx, monthStart, monthEnd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
unionQuery = buildDailyRollupLifecycleUnion(monthStart, monthEnd)
|
||||
} else {
|
||||
// Build union query for lifecycle refinement after inserts.
|
||||
dailyTables := make([]string, 0, len(dailySnapshots))
|
||||
for _, snapshot := range dailySnapshots {
|
||||
dailyTables = append(dailyTables, snapshot.TableName)
|
||||
}
|
||||
unionQuery, err = buildUnionQuery(dailyTables, monthlyUnionColumns, templateExclusionFilter())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aggMap, err := c.scanDailyTablesParallel(ctx, dailySnapshots)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(aggMap) == 0 {
|
||||
cacheAgg, cacheErr := c.scanDailyRollup(ctx, monthStart, monthEnd)
|
||||
if cacheErr == nil && len(cacheAgg) > 0 {
|
||||
aggMap = cacheAgg
|
||||
} else if cacheErr != nil {
|
||||
c.Logger.Warn("failed to read daily rollup cache; using table scan", "error", cacheErr)
|
||||
aggMap, err = c.scanDailyTablesParallel(ctx, dailySnapshots)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(aggMap) == 0 {
|
||||
cacheAgg, cacheErr := c.scanDailyRollup(ctx, monthStart, monthEnd)
|
||||
if cacheErr == nil && len(cacheAgg) > 0 {
|
||||
aggMap = cacheAgg
|
||||
} else if cacheErr != nil {
|
||||
c.Logger.Warn("failed to read daily rollup cache; using table scan", "error", cacheErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(aggMap) == 0 {
|
||||
@@ -387,7 +478,7 @@ func (c *CronTask) aggregateMonthlySummaryGo(ctx context.Context, monthStart, mo
|
||||
if err := report.RegisterSnapshot(ctx, c.Database, "monthly", summaryTable, monthStart, rowCount); err != nil {
|
||||
c.Logger.Warn("failed to register monthly snapshot", "error", err, "table", summaryTable)
|
||||
}
|
||||
if err := c.generateReport(ctx, summaryTable); err != nil {
|
||||
if err := c.generateReportWithPolicy(ctx, summaryTable); err != nil {
|
||||
c.Logger.Warn("failed to generate monthly report (Go)", "error", err, "table", summaryTable)
|
||||
return err
|
||||
}
|
||||
@@ -666,6 +757,58 @@ WHERE "Date" >= ? AND "Date" < ?
|
||||
return agg, rows.Err()
|
||||
}
|
||||
|
||||
func buildDailyRollupLifecycleUnion(start, end time.Time) string {
|
||||
return fmt.Sprintf(`
|
||||
SELECT
|
||||
"VmId","VmUuid","Name","Vcenter","CreationTime","DeletionTime","Date" AS "SnapshotTime"
|
||||
FROM vm_daily_rollup
|
||||
WHERE "Date" >= %d AND "Date" < %d
|
||||
`, start.Unix(), end.Unix())
|
||||
}
|
||||
|
||||
func buildCanonicalDailyRollupSummaryUnion(start, end time.Time) string {
|
||||
return fmt.Sprintf(`
|
||||
SELECT
|
||||
NULL AS "InventoryId",
|
||||
COALESCE("Name",'') AS "Name",
|
||||
COALESCE("Vcenter",'') AS "Vcenter",
|
||||
COALESCE("VmId",'') AS "VmId",
|
||||
NULL AS "EventKey",
|
||||
NULL AS "CloudId",
|
||||
COALESCE("CreationTime",0) AS "CreationTime",
|
||||
COALESCE("DeletionTime",0) AS "DeletionTime",
|
||||
COALESCE("LastResourcePool",'') AS "ResourcePool",
|
||||
COALESCE("LastDatacenter",'') AS "Datacenter",
|
||||
COALESCE("LastCluster",'') AS "Cluster",
|
||||
COALESCE("LastFolder",'') AS "Folder",
|
||||
COALESCE("LastProvisionedDisk",0) AS "ProvisionedDisk",
|
||||
COALESCE("LastVcpuCount",0) AS "VcpuCount",
|
||||
COALESCE("LastRamGB",0) AS "RamGB",
|
||||
COALESCE("IsTemplate",'') AS "IsTemplate",
|
||||
COALESCE("PoweredOn",'') AS "PoweredOn",
|
||||
COALESCE("SrmPlaceholder",'') AS "SrmPlaceholder",
|
||||
COALESCE("VmUuid",'') AS "VmUuid",
|
||||
COALESCE("SamplesPresent",0) AS "SamplesPresent",
|
||||
CASE WHEN COALESCE("TotalSamples",0) > 0 THEN 1.0 * COALESCE("SumVcpu",0) / "TotalSamples" ELSE NULL END AS "AvgVcpuCount",
|
||||
CASE WHEN COALESCE("TotalSamples",0) > 0 THEN 1.0 * COALESCE("SumRam",0) / "TotalSamples" ELSE NULL END AS "AvgRamGB",
|
||||
CASE WHEN COALESCE("TotalSamples",0) > 0 THEN 1.0 * COALESCE("SumDisk",0) / "TotalSamples" ELSE NULL END AS "AvgProvisionedDisk",
|
||||
CASE WHEN COALESCE("TotalSamples",0) > 0 THEN 1.0 * COALESCE("SamplesPresent",0) / "TotalSamples" ELSE NULL END AS "AvgIsPresent",
|
||||
CASE WHEN COALESCE("SamplesPresent",0) > 0 THEN 100.0 * COALESCE("TinHits",0) / "SamplesPresent" ELSE NULL END AS "PoolTinPct",
|
||||
CASE WHEN COALESCE("SamplesPresent",0) > 0 THEN 100.0 * COALESCE("BronzeHits",0) / "SamplesPresent" ELSE NULL END AS "PoolBronzePct",
|
||||
CASE WHEN COALESCE("SamplesPresent",0) > 0 THEN 100.0 * COALESCE("SilverHits",0) / "SamplesPresent" ELSE NULL END AS "PoolSilverPct",
|
||||
CASE WHEN COALESCE("SamplesPresent",0) > 0 THEN 100.0 * COALESCE("GoldHits",0) / "SamplesPresent" ELSE NULL END AS "PoolGoldPct",
|
||||
CASE WHEN COALESCE("SamplesPresent",0) > 0 THEN 100.0 * COALESCE("TinHits",0) / "SamplesPresent" ELSE NULL END AS "Tin",
|
||||
CASE WHEN COALESCE("SamplesPresent",0) > 0 THEN 100.0 * COALESCE("BronzeHits",0) / "SamplesPresent" ELSE NULL END AS "Bronze",
|
||||
CASE WHEN COALESCE("SamplesPresent",0) > 0 THEN 100.0 * COALESCE("SilverHits",0) / "SamplesPresent" ELSE NULL END AS "Silver",
|
||||
CASE WHEN COALESCE("SamplesPresent",0) > 0 THEN 100.0 * COALESCE("GoldHits",0) / "SamplesPresent" ELSE NULL END AS "Gold",
|
||||
"Date" AS "SnapshotTime"
|
||||
FROM vm_daily_rollup
|
||||
WHERE "Date" >= %d
|
||||
AND "Date" < %d
|
||||
AND %s
|
||||
`, start.Unix(), end.Unix(), templateExclusionFilter())
|
||||
}
|
||||
|
||||
func (c *CronTask) insertMonthlyAggregates(ctx context.Context, summaryTable string, aggMap map[monthlyAggKey]*monthlyAggVal) error {
|
||||
dbConn := c.Database.DB()
|
||||
columns := []string{
|
||||
|
||||
Reference in New Issue
Block a user