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. func backfillLifecycleDeletionsToday(ctx context.Context, logger *slog.Logger, dbConn *sqlx.DB, vcenter string, snapshotTime time.Time, present map[string]InventorySnapshotRow) error { dayStart := truncateDate(snapshotTime) dayEnd := dayStart.Add(24 * time.Hour) candidates, err := loadLifecycleCandidates(ctx, dbConn, vcenter, present) if err != nil || len(candidates) == 0 { return err } tables, err := listHourlyTablesForDay(ctx, dbConn, dayStart, dayEnd) if err != nil { return err } if len(tables) == 0 { return nil } nextPresence := make(map[string]struct{}) if nextTable, nextErr := nextSnapshotAfter(ctx, dbConn, dayEnd, vcenter); nextErr == nil && nextTable != "" { nextPresence = loadPresenceKeys(ctx, dbConn, nextTable, vcenter) } for _, cand := range candidates { deletion, firstMiss := 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 } logger.Debug("lifecycle backfill applied", "vcenter", vcenter, "vm_id", cand.vmID, "vm_uuid", cand.vmUUID, "name", cand.name, "cluster", cand.cluster, "deletion", deletion) } } return 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"` } func listHourlyTablesForDay(ctx context.Context, dbConn *sqlx.DB, dayStart, dayEnd time.Time) ([]snapshotTable, error) { rows, err := dbConn.QueryxContext(ctx, ` SELECT table_name, snapshot_time 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 } hasRows, err := db.TableHasRows(ctx, dbConn, t.Table) if err != nil || !hasRows { 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 } func findDeletionInTables(ctx context.Context, dbConn *sqlx.DB, tables []snapshotTable, vcenter string, cand lifecycleCandidate) (int64, int64) { var lastSeen int64 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 { if rows.Next() { var vmId, vmUuid, name, cluster sql.NullString if scanErr := rows.Scan(&vmId, &vmUuid, &name, &cluster); scanErr == nil { lastSeen = tbl.Time if cand.name == "" && name.Valid { cand.name = name.String } if cand.cluster == "" && cluster.Valid { cand.cluster = cluster.String } } } rows.Close() } if lastSeen > 0 && tbl.Time > lastSeen { // first table after last seen -> first miss if seen, _ := candSeenInTable(ctx, dbConn, tbl.Table, vcenter, cand.vmID); !seen { firstMiss = tbl.Time // need two consecutive misses if i+1 < len(tables) { if seen2, _ := candSeenInTable(ctx, dbConn, tables[i+1].Table, vcenter, cand.vmID); !seen2 { return firstMiss, firstMiss } } } } } return 0, firstMiss } 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 }