package tasks import ( "context" "database/sql" "log/slog" "time" "vctp/db" "github.com/jmoiron/sqlx" ) // presenceKeys builds lookup keys for vm presence comparison. func presenceKeys(vmID, vmUUID, name string) []string { keys := make([]string, 0, 3) if vmID != "" { keys = append(keys, "id:"+vmID) } if vmUUID != "" { keys = append(keys, "uuid:"+vmUUID) } if name != "" { keys = append(keys, "name:"+name) } return keys } // 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) { dayStart := truncateDate(snapshotTime) dayEnd := dayStart.Add(24 * time.Hour) candidates, err := loadLifecycleCandidates(ctx, dbConn, vcenter, present) if err != nil || len(candidates) == 0 { return nil, err } tables, err := listHourlyTablesForDay(ctx, dbConn, dayStart, dayEnd) if err != nil { return nil, err } if len(tables) == 0 { return nil, nil } nextPresence := make(map[string]struct{}) if nextTable, nextErr := nextSnapshotAfter(ctx, dbConn, dayEnd, vcenter); nextErr == nil && nextTable != "" { nextPresence = loadPresenceKeys(ctx, dbConn, nextTable, vcenter) } updatedTables := make(map[string]struct{}) for i := range candidates { cand := &candidates[i] deletion, firstMiss, lastSeenTable := findDeletionInTables(ctx, dbConn, tables, vcenter, cand) if deletion == 0 && len(nextPresence) > 0 && firstMiss > 0 { if !isPresent(nextPresence, *cand) { // Single miss at end of day, confirmed by next-day absence. deletion = firstMiss logger.Debug("cross-day deletion inferred from next snapshot", "vcenter", vcenter, "vm_id", cand.vmID, "vm_uuid", cand.vmUUID, "name", cand.name, "deletion", deletion) } } if deletion > 0 { if err := db.MarkVmDeletedWithDetails(ctx, dbConn, vcenter, cand.vmID, cand.vmUUID, cand.name, cand.cluster, deletion); err != nil { logger.Warn("lifecycle backfill mark deleted failed", "vcenter", vcenter, "vm_id", cand.vmID, "vm_uuid", cand.vmUUID, "name", cand.name, "cluster", cand.cluster, "deletion", deletion, "error", err) 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) } } logger.Debug("lifecycle backfill applied", "vcenter", vcenter, "vm_id", cand.vmID, "vm_uuid", cand.vmUUID, "name", cand.name, "cluster", cand.cluster, "deletion", deletion) } } if len(updatedTables) == 0 { return nil, nil } tablesUpdated := make([]string, 0, len(updatedTables)) for table := range updatedTables { tablesUpdated = append(tablesUpdated, table) } return tablesUpdated, nil } type lifecycleCandidate struct { vmID string vmUUID string name string cluster string } func loadLifecycleCandidates(ctx context.Context, dbConn *sqlx.DB, vcenter string, present map[string]InventorySnapshotRow) ([]lifecycleCandidate, error) { rows, err := dbConn.QueryxContext(ctx, ` SELECT "VmId","VmUuid","Name","Cluster" FROM vm_lifecycle_cache WHERE "Vcenter" = ? AND ("DeletedAt" IS NULL OR "DeletedAt" = 0) `, vcenter) if err != nil { return nil, err } defer rows.Close() var cands []lifecycleCandidate for rows.Next() { var vmID, vmUUID, name, cluster sql.NullString if scanErr := rows.Scan(&vmID, &vmUUID, &name, &cluster); scanErr != nil { continue } if vmID.String == "" { continue } if _, ok := present[vmID.String]; ok { continue // still present, skip } cands = append(cands, lifecycleCandidate{ vmID: vmID.String, vmUUID: vmUUID.String, name: name.String, cluster: cluster.String, }) } return cands, nil } type snapshotTable struct { Table string `db:"table_name"` Time int64 `db:"snapshot_time"` Count sql.NullInt64 `db:"snapshot_count"` } func listHourlyTablesForDay(ctx context.Context, dbConn *sqlx.DB, dayStart, dayEnd time.Time) ([]snapshotTable, error) { log := loggerFromCtx(ctx, nil) rows, err := dbConn.QueryxContext(ctx, ` SELECT table_name, snapshot_time, snapshot_count FROM snapshot_registry WHERE snapshot_type = 'hourly' AND snapshot_time >= ? AND snapshot_time < ? ORDER BY snapshot_time ASC `, dayStart.Unix(), dayEnd.Unix()) if err != nil { return nil, err } defer rows.Close() var tables []snapshotTable for rows.Next() { var t snapshotTable if err := rows.StructScan(&t); err != nil { continue } if err := db.ValidateTableName(t.Table); err != nil { continue } // Trust snapshot_count if present; otherwise optimistically include to avoid long probes. if t.Count.Valid && t.Count.Int64 <= 0 { if log != nil { log.Debug("skipping snapshot table with zero count", "table", t.Table, "snapshot_time", t.Time) } continue } tables = append(tables, t) } return tables, nil } func nextSnapshotAfter(ctx context.Context, dbConn *sqlx.DB, after time.Time, vcenter string) (string, error) { rows, err := dbConn.QueryxContext(ctx, ` SELECT table_name FROM snapshot_registry WHERE snapshot_type = 'hourly' AND snapshot_time >= ? ORDER BY snapshot_time ASC LIMIT 1 `, after.Unix()) if err != nil { return "", err } defer rows.Close() for rows.Next() { var name string if err := rows.Scan(&name); err != nil { continue } if err := db.ValidateTableName(name); err != nil { continue } // ensure the snapshot table actually has entries for this vcenter vrows, qerr := querySnapshotRows(ctx, dbConn, name, []string{"VmId"}, `"Vcenter" = ? LIMIT 1`, vcenter) if qerr != nil { continue } hasVcenter := vrows.Next() vrows.Close() if hasVcenter { return name, nil } } return "", nil } func loadPresenceKeys(ctx context.Context, dbConn *sqlx.DB, table, vcenter string) map[string]struct{} { out := make(map[string]struct{}) rows, err := querySnapshotRows(ctx, dbConn, table, []string{"VmId", "VmUuid", "Name"}, `"Vcenter" = ?`, vcenter) if err != nil { return out } defer rows.Close() for rows.Next() { var vmId, vmUuid, name sql.NullString if err := rows.Scan(&vmId, &vmUuid, &name); err == nil { for _, k := range presenceKeys(vmId.String, vmUuid.String, name.String) { out[k] = struct{}{} } } } return out } func isPresent(presence map[string]struct{}, cand lifecycleCandidate) bool { for _, k := range presenceKeys(cand.vmID, cand.vmUUID, cand.name) { if _, ok := presence[k]; ok { return true } } return false } // findDeletionInTables walks ordered hourly tables for a vCenter and returns the first confirmed deletion time // (requiring two consecutive misses), the time of the first miss for cross-day handling, and the last table where // the VM was seen so we can backfill deletion time into that snapshot. func findDeletionInTables(ctx context.Context, dbConn *sqlx.DB, tables []snapshotTable, vcenter string, cand *lifecycleCandidate) (int64, int64, string) { var lastSeen int64 var lastSeenTable string var firstMiss int64 for i, tbl := range tables { rows, err := querySnapshotRows(ctx, dbConn, tbl.Table, []string{"VmId", "VmUuid", "Name", "Cluster"}, `"Vcenter" = ? AND "VmId" = ?`, vcenter, cand.vmID) if err != nil { continue } seen := false if rows.Next() { var vmId, vmUuid, name, cluster sql.NullString if scanErr := rows.Scan(&vmId, &vmUuid, &name, &cluster); scanErr == nil { seen = true lastSeen = tbl.Time lastSeenTable = tbl.Table if cand.vmUUID == "" && vmUuid.Valid { cand.vmUUID = vmUuid.String } if cand.name == "" && name.Valid { cand.name = name.String } if cand.cluster == "" && cluster.Valid { cand.cluster = cluster.String } } } rows.Close() if lastSeen > 0 && !seen && firstMiss == 0 { firstMiss = tbl.Time if i+1 < len(tables) { if seen2, _ := candSeenInTable(ctx, dbConn, tables[i+1].Table, vcenter, cand.vmID); !seen2 { return firstMiss, firstMiss, lastSeenTable } } } } return 0, firstMiss, lastSeenTable } func candSeenInTable(ctx context.Context, dbConn *sqlx.DB, table, vcenter, vmID string) (bool, error) { rows, err := querySnapshotRows(ctx, dbConn, table, []string{"VmId"}, `"Vcenter" = ? AND "VmId" = ? LIMIT 1`, vcenter, vmID) if err != nil { return false, err } defer rows.Close() return rows.Next(), nil }