enhance utilisation of postgres features
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-04-20 10:19:27 +10:00
parent 98e92a8264
commit 8ccf5a7009
28 changed files with 2836 additions and 422 deletions
+36
View File
@@ -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))
+39
View File
@@ -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) {
+326
View File
@@ -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],
}
}
+275 -65
View File
@@ -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()
+193
View File
@@ -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
+53
View File
@@ -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) }
+16 -12
View File
@@ -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)
+14 -12
View File
@@ -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)
}
}
}
+245 -80
View File
@@ -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
+192 -49
View File
@@ -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{