diff --git a/MP.Data/DbModels/Utils/StatsErrorModel.cs b/MP.Data/DbModels/Utils/StatsErrorModel.cs new file mode 100644 index 00000000..57efc9ca --- /dev/null +++ b/MP.Data/DbModels/Utils/StatsErrorModel.cs @@ -0,0 +1,17 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace MP.Data.DbModels.Utils +{ + [Table("stats_errors")] + public class StatsErrorModel + { + [Key] public int Id { get; set; } + public string Destination { get; set; } = ""; + public string Type { get; set; } = ""; + public DateTime Hour { get; set; } + public string ErrorMessage { get; set; } = ""; + public long Count { get; set; } + } +} diff --git a/MP.Data/DbModels/Utils/StatsStatusCodeModel.cs b/MP.Data/DbModels/Utils/StatsStatusCodeModel.cs new file mode 100644 index 00000000..18576c9b --- /dev/null +++ b/MP.Data/DbModels/Utils/StatsStatusCodeModel.cs @@ -0,0 +1,17 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace MP.Data.DbModels.Utils +{ + [Table("stats_status_codes")] + public class StatsStatusCodeModel + { + [Key] public int Id { get; set; } + public string Destination { get; set; } = ""; + public string Type { get; set; } = ""; + public DateTime Hour { get; set; } + public int StatusCode { get; set; } + public long Count { get; set; } + } +} diff --git a/MP.Data/Migrations/MoonPro_Utils/20260511071223_AddErrorsAndStatusCodes.Designer.cs b/MP.Data/Migrations/MoonPro_Utils/20260511071223_AddErrorsAndStatusCodes.Designer.cs new file mode 100644 index 00000000..9acf271e --- /dev/null +++ b/MP.Data/Migrations/MoonPro_Utils/20260511071223_AddErrorsAndStatusCodes.Designer.cs @@ -0,0 +1,193 @@ +// +using System; +using MP.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace MP.Data.Migrations.MoonPro_Utils +{ + [DbContext(typeof(MoonPro_UtilsContext))] + [Migration("20260511071223_AddErrorsAndStatusCodes")] + partial class AddErrorsAndStatusCodes + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .UseCollation("SQL_Latin1_General_CP1_CI_AS") + .HasAnnotation("ProductVersion", "6.0.36") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); + + modelBuilder.Entity("MP.Data.DbModels.Mtc.MtcSetupModel", b => + { + b.Property("IdxMacchina") + .HasColumnType("nvarchar(450)"); + + b.Property("MtcDataItemsRaw") + .HasColumnType("nvarchar(max)"); + + b.HasKey("IdxMacchina"); + + b.ToTable("mtc_setup"); + }); + + modelBuilder.Entity("MP.Data.DbModels.Utils.StatsAggregatedModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); + + b.Property("AvgDuration") + .HasColumnType("float"); + + b.Property("Destination") + .HasColumnType("nvarchar(450)"); + + b.Property("Hour") + .HasColumnType("datetime2"); + + b.Property("MachineId") + .HasColumnType("nvarchar(450)"); + + b.Property("MaxDuration") + .HasColumnType("float"); + + b.Property("MinDuration") + .HasColumnType("float"); + + b.Property("NoReply") + .HasColumnType("bigint"); + + b.Property("RequestCount") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Destination", "MachineId", "Hour") + .IsUnique() + .HasDatabaseName("idx_statsaggr_env_mach_hour") + .HasFilter("[Destination] IS NOT NULL AND [MachineId] IS NOT NULL"); + + b.ToTable("stats_aggr"); + }); + + modelBuilder.Entity("MP.Data.DbModels.Utils.StatsDetailModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); + + b.Property("AvgDuration") + .HasColumnType("float"); + + b.Property("Destination") + .HasColumnType("nvarchar(450)"); + + b.Property("Hour") + .HasColumnType("datetime2"); + + b.Property("MaxDuration") + .HasColumnType("float"); + + b.Property("MinDuration") + .HasColumnType("float"); + + b.Property("NoReply") + .HasColumnType("bigint"); + + b.Property("RequestCount") + .HasColumnType("bigint"); + + b.Property("Type") + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("Destination", "Type", "Hour") + .IsUnique() + .HasDatabaseName("idx_statsdet_hour_env_type") + .HasFilter("[Destination] IS NOT NULL AND [Type] IS NOT NULL"); + + b.ToTable("stats_detail"); + }); + + modelBuilder.Entity("MP.Data.DbModels.Utils.StatsErrorModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); + + b.Property("Count") + .HasColumnType("bigint"); + + b.Property("Destination") + .HasColumnType("nvarchar(450)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(450)"); + + b.Property("Hour") + .HasColumnType("datetime2"); + + b.Property("Type") + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("Destination", "Type", "Hour", "ErrorMessage") + .IsUnique() + .HasDatabaseName("idx_statserr_dest_type_hour_err") + .HasFilter("[Destination] IS NOT NULL AND [Type] IS NOT NULL AND [ErrorMessage] IS NOT NULL"); + + b.ToTable("stats_errors"); + }); + + modelBuilder.Entity("MP.Data.DbModels.Utils.StatsStatusCodeModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); + + b.Property("Count") + .HasColumnType("bigint"); + + b.Property("Destination") + .HasColumnType("nvarchar(450)"); + + b.Property("Hour") + .HasColumnType("datetime2"); + + b.Property("StatusCode") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("Destination", "Type", "Hour", "StatusCode") + .IsUnique() + .HasDatabaseName("idx_statscode_dest_type_hour_code") + .HasFilter("[Destination] IS NOT NULL AND [Type] IS NOT NULL"); + + b.ToTable("stats_status_codes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MP.Data/Migrations/MoonPro_Utils/20260511071223_AddErrorsAndStatusCodes.cs b/MP.Data/Migrations/MoonPro_Utils/20260511071223_AddErrorsAndStatusCodes.cs new file mode 100644 index 00000000..abe01d3c --- /dev/null +++ b/MP.Data/Migrations/MoonPro_Utils/20260511071223_AddErrorsAndStatusCodes.cs @@ -0,0 +1,70 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MP.Data.Migrations.MoonPro_Utils +{ + public partial class AddErrorsAndStatusCodes : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "stats_errors", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Destination = table.Column(type: "nvarchar(450)", nullable: true), + Type = table.Column(type: "nvarchar(450)", nullable: true), + Hour = table.Column(type: "datetime2", nullable: false), + ErrorMessage = table.Column(type: "nvarchar(450)", nullable: true), + Count = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_stats_errors", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "stats_status_codes", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Destination = table.Column(type: "nvarchar(450)", nullable: true), + Type = table.Column(type: "nvarchar(450)", nullable: true), + Hour = table.Column(type: "datetime2", nullable: false), + StatusCode = table.Column(type: "int", nullable: false), + Count = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_stats_status_codes", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "idx_statserr_dest_type_hour_err", + table: "stats_errors", + columns: new[] { "Destination", "Type", "Hour", "ErrorMessage" }, + unique: true, + filter: "[Destination] IS NOT NULL AND [Type] IS NOT NULL AND [ErrorMessage] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "idx_statscode_dest_type_hour_code", + table: "stats_status_codes", + columns: new[] { "Destination", "Type", "Hour", "StatusCode" }, + unique: true, + filter: "[Destination] IS NOT NULL AND [Type] IS NOT NULL"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "stats_errors"); + + migrationBuilder.DropTable( + name: "stats_status_codes"); + } + } +} diff --git a/MP.Data/Migrations/MoonPro_Utils/MoonPro_UtilsContextModelSnapshot.cs b/MP.Data/Migrations/MoonPro_Utils/MoonPro_UtilsContextModelSnapshot.cs index 531fb160..aea0326e 100644 --- a/MP.Data/Migrations/MoonPro_Utils/MoonPro_UtilsContextModelSnapshot.cs +++ b/MP.Data/Migrations/MoonPro_Utils/MoonPro_UtilsContextModelSnapshot.cs @@ -119,6 +119,72 @@ namespace MP.Data.Migrations.MoonPro_Utils b.ToTable("stats_detail"); }); + + modelBuilder.Entity("MP.Data.DbModels.Utils.StatsErrorModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); + + b.Property("Count") + .HasColumnType("bigint"); + + b.Property("Destination") + .HasColumnType("nvarchar(450)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(450)"); + + b.Property("Hour") + .HasColumnType("datetime2"); + + b.Property("Type") + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("Destination", "Type", "Hour", "ErrorMessage") + .IsUnique() + .HasDatabaseName("idx_statserr_dest_type_hour_err") + .HasFilter("[Destination] IS NOT NULL AND [Type] IS NOT NULL AND [ErrorMessage] IS NOT NULL"); + + b.ToTable("stats_errors"); + }); + + modelBuilder.Entity("MP.Data.DbModels.Utils.StatsStatusCodeModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); + + b.Property("Count") + .HasColumnType("bigint"); + + b.Property("Destination") + .HasColumnType("nvarchar(450)"); + + b.Property("Hour") + .HasColumnType("datetime2"); + + b.Property("StatusCode") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("Destination", "Type", "Hour", "StatusCode") + .IsUnique() + .HasDatabaseName("idx_statscode_dest_type_hour_code") + .HasFilter("[Destination] IS NOT NULL AND [Type] IS NOT NULL"); + + b.ToTable("stats_status_codes"); + }); #pragma warning restore 612, 618 } } diff --git a/MP.Data/MoonPro_UtilsContext.cs b/MP.Data/MoonPro_UtilsContext.cs index 2875ed97..edb248e1 100644 --- a/MP.Data/MoonPro_UtilsContext.cs +++ b/MP.Data/MoonPro_UtilsContext.cs @@ -49,6 +49,8 @@ namespace MP.Data public virtual DbSet DbSetStatsDet { get; set; } public virtual DbSet DbSetStatsAggr { get; set; } public virtual DbSet DbSetMtcSetup { get; set; } + public virtual DbSet DbSetStatsError { get; set; } + public virtual DbSet DbSetStatusCode { get; set; } #endregion Public Properties @@ -91,6 +93,16 @@ namespace MP.Data .HasDatabaseName("idx_statsaggr_env_mach_hour") .IsUnique(); + modelBuilder.Entity() + .HasIndex(x => new { x.Destination, x.Type, x.Hour, x.ErrorMessage }) + .HasDatabaseName("idx_statserr_dest_type_hour_err") + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(x => new { x.Destination, x.Type, x.Hour, x.StatusCode }) + .HasDatabaseName("idx_statscode_dest_type_hour_code") + .IsUnique(); + OnModelCreatingPartial(modelBuilder); } diff --git a/MP.Data/Repository/Utils/IStatsCodeRepository.cs b/MP.Data/Repository/Utils/IStatsCodeRepository.cs new file mode 100644 index 00000000..fb38eed8 --- /dev/null +++ b/MP.Data/Repository/Utils/IStatsCodeRepository.cs @@ -0,0 +1,36 @@ +using EgwCoreLib.Utils; +using MP.Data.DbModels.Utils; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace MP.Data.Repository.Utils +{ + /// + /// Gestione statistica StatusCode HTML + /// + public interface IStatsCodeRepository + { + /// + /// Recupera l'elenco delle statistiche StatusCode per un periodo specifico. + /// + /// La data di inizio del periodo. + /// La data di fine del periodo. + /// L'elenco delle statistiche aggregate ordinate cronologicamente. + Task> GetFiltAsync(DateTime dtStart, DateTime dtEnd); + + /// + /// Recupera l'intervallo temporale disponibile nel database per le statistiche StatusCode. + /// + /// L'intervallo di date (minima e massima ora presente). + Task GetRangeAsync(); + + /// + /// Inserisce o aggiorna in blocco le statistiche StatusCode nel database. + /// + /// L'elenco dei record da inserire. + /// Se true, elimina preventivamente i record nel periodo richiesto. + /// Il numero di record inseriti. + Task UpsertManyAsync(List listRecords, bool removeOld); + } +} diff --git a/MP.Data/Repository/Utils/IStatsErrRepository.cs b/MP.Data/Repository/Utils/IStatsErrRepository.cs new file mode 100644 index 00000000..fdd704ec --- /dev/null +++ b/MP.Data/Repository/Utils/IStatsErrRepository.cs @@ -0,0 +1,36 @@ +using EgwCoreLib.Utils; +using MP.Data.DbModels.Utils; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace MP.Data.Repository.Utils +{ + /// + /// Gestione statistiche errori + /// + public interface IStatsErrRepository + { + /// + /// Recupera l'elenco delle statistiche errori per un periodo specifico. + /// + /// La data di inizio del periodo. + /// La data di fine del periodo. + /// L'elenco delle statistiche errori ordinate cronologicamente. + Task> GetFiltAsync(DateTime dtStart, DateTime dtEnd); + + /// + /// Recupera l'intervallo temporale disponibile nel database per le statistiche errore. + /// + /// L'intervallo di date (minima e massima ora presente). + Task GetRangeAsync(); + + /// + /// Inserisce o aggiorna in blocco le statistiche errore nel database. + /// + /// L'elenco dei record da inserire. + /// Se true, elimina preventivamente i record nel periodo richiesto. + /// Il numero di record inseriti. + Task UpsertManyAsync(List listRecords, bool removeOld); + } +} diff --git a/MP.Data/Repository/Utils/StatsCodeRepository.cs b/MP.Data/Repository/Utils/StatsCodeRepository.cs new file mode 100644 index 00000000..af6871d3 --- /dev/null +++ b/MP.Data/Repository/Utils/StatsCodeRepository.cs @@ -0,0 +1,141 @@ +using EgwCoreLib.Utils; +using Microsoft.EntityFrameworkCore; +using MP.Data.DbModels.Utils; +using NLog; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace MP.Data.Repository.Utils +{ + public class StatsCodeRepository : BaseRepository, IStatsCodeRepository + { + #region Public Constructors + + public StatsCodeRepository(IDbContextFactory ctxFactory) : base(ctxFactory) + { + } + + #endregion Public Constructors + + #region Public Methods + + /// + public async Task> GetFiltAsync(DateTime dtStart, DateTime dtEnd) + { + await using var dbCtx = await CreateContextAsync(); + return await dbCtx + .DbSetStatusCode + .Where(x => x.Hour >= dtStart && x.Hour <= dtEnd) + .AsNoTracking() + .OrderBy(x => x.Hour) + .ToListAsync(); + } + + /// + public async Task GetRangeAsync() + { + await using var dbCtx = await CreateContextAsync(); + DtUtils.Periodo answ = new DtUtils.Periodo(DtUtils.PeriodSet.Today); + var query = dbCtx.DbSetStatusCode.AsQueryable(); + var minHour = await query.MinAsync(x => x.Hour); + var maxHour = await query.MaxAsync(x => x.Hour); + answ.Inizio = minHour; + answ.Fine = maxHour; + // ritorno! + return answ; + } + + /// + public async Task UpsertManyAsync(List listRecords, bool removeOld) + { + if (listRecords == null || !listRecords.Any()) return 0; + + int ans = 0; + await using var dbCtx = await CreateContextAsync(); + await using var tx = await dbCtx.Database.BeginTransactionAsync(); + + try + { + // 1. Calcolo del range temporale della lista in arrivo per limitare la query di ricerca + var minHour = listRecords.Min(x => x.Hour); + var maxHour = listRecords.Max(x => x.Hour); + + // 2. Se removeOld è true, manteniamo la logica originale (Eliminazione distruttiva) + if (removeOld) + { + // uso direttamente ExecuteDelete quando in EFCore8... +#if false + await dbCtx + .DbSetStatusCode + .Where(x => x.Hour >= startDate && x.Hour <= endDate) + .ExecuteDeleteAsync(); +#endif + + var itemsToRemove = await dbCtx.DbSetStatusCode + .Where(x => x.Hour >= minHour && x.Hour <= maxHour) + .ToListAsync(); + if (itemsToRemove.Any()) + { + dbCtx.DbSetStatusCode.RemoveRange(itemsToRemove); + await dbCtx.SaveChangesAsync(); // Commit parziale per la cancellazione + } + } + + // 3. LOGICA DI UPSERT (Merge) + // Recuperiamo tutti i record esistenti nel database che cadono nello stesso range temporale + // Questo ci permette di confrontare ciò che arriva con ciò che è già presente. + var existingRecords = await dbCtx.DbSetStatusCode + .Where(x => x.Hour >= minHour && x.Hour <= maxHour) + .ToListAsync(); + + // Creiamo un dizionario per ricerca rapida O(1) basato sulla chiave univoca (Dest + Hour) + // Usiamo una Tupla come chiave del dizionario + var lookup = existingRecords.ToDictionary( + x => (x.Destination, x.Type, x.Hour, x.StatusCode), + x => x + ); + + foreach (var incoming in listRecords) + { + var key = (incoming.Destination, incoming.Type, incoming.Hour, incoming.StatusCode); + if (lookup.TryGetValue(key, out var existing)) + { + // --- CASO: UPDATE --- + existing.Count = incoming.Count; + } + else + { + // --- CASO: INSERT --- + await dbCtx.DbSetStatusCode.AddAsync(incoming); + } + } + // 4. Salvataggio finale + ans = await dbCtx.SaveChangesAsync(); + + // Commit della transazione + await tx.CommitAsync(); + + // Pulizia memoria per evitare che il ChangeTracker diventi troppo pesante nei loop lunghi + dbCtx.ChangeTracker.Clear(); + + return ans; + } + catch (Exception ex) + { + await tx.RollbackAsync(); + Log.Error(ex, "Error during UpsertManyAsync"); + throw; + } + } + + #endregion Public Methods + + #region Protected Fields + + protected static NLog.Logger Log = LogManager.GetCurrentClassLogger(); + + #endregion Protected Fields + } +} \ No newline at end of file diff --git a/MP.Data/Repository/Utils/StatsErrRepository.cs b/MP.Data/Repository/Utils/StatsErrRepository.cs new file mode 100644 index 00000000..c916d9de --- /dev/null +++ b/MP.Data/Repository/Utils/StatsErrRepository.cs @@ -0,0 +1,141 @@ +using EgwCoreLib.Utils; +using Microsoft.EntityFrameworkCore; +using MP.Data.DbModels.Utils; +using NLog; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace MP.Data.Repository.Utils +{ + public class StatsErrRepository : BaseRepository, IStatsErrRepository + { + #region Public Constructors + + public StatsErrRepository(IDbContextFactory ctxFactory) : base(ctxFactory) + { + } + + #endregion Public Constructors + + #region Public Methods + + /// + public async Task> GetFiltAsync(DateTime dtStart, DateTime dtEnd) + { + await using var dbCtx = await CreateContextAsync(); + return await dbCtx + .DbSetStatsError + .Where(x => x.Hour >= dtStart && x.Hour <= dtEnd) + .AsNoTracking() + .OrderBy(x => x.Hour) + .ToListAsync(); + } + + /// + public async Task GetRangeAsync() + { + await using var dbCtx = await CreateContextAsync(); + DtUtils.Periodo answ = new DtUtils.Periodo(DtUtils.PeriodSet.Today); + var query = dbCtx.DbSetStatsError.AsQueryable(); + var minHour = await query.MinAsync(x => x.Hour); + var maxHour = await query.MaxAsync(x => x.Hour); + answ.Inizio = minHour; + answ.Fine = maxHour; + // ritorno! + return answ; + } + + /// + public async Task UpsertManyAsync(List listRecords, bool removeOld) + { + if (listRecords == null || !listRecords.Any()) return 0; + + int ans = 0; + await using var dbCtx = await CreateContextAsync(); + await using var tx = await dbCtx.Database.BeginTransactionAsync(); + + try + { + // 1. Calcolo del range temporale della lista in arrivo per limitare la query di ricerca + var minHour = listRecords.Min(x => x.Hour); + var maxHour = listRecords.Max(x => x.Hour); + + // 2. Se removeOld è true, manteniamo la logica originale (Eliminazione distruttiva) + if (removeOld) + { + // uso direttamente ExecuteDelete quando in EFCore8... +#if false + await dbCtx + .DbSetStatsError + .Where(x => x.Hour >= startDate && x.Hour <= endDate) + .ExecuteDeleteAsync(); +#endif + + var itemsToRemove = await dbCtx.DbSetStatsError + .Where(x => x.Hour >= minHour && x.Hour <= maxHour) + .ToListAsync(); + if (itemsToRemove.Any()) + { + dbCtx.DbSetStatsError.RemoveRange(itemsToRemove); + await dbCtx.SaveChangesAsync(); // Commit parziale per la cancellazione + } + } + + // 3. LOGICA DI UPSERT (Merge) + // Recuperiamo tutti i record esistenti nel database che cadono nello stesso range temporale + // Questo ci permette di confrontare ciò che arriva con ciò che è già presente. + var existingRecords = await dbCtx.DbSetStatsError + .Where(x => x.Hour >= minHour && x.Hour <= maxHour) + .ToListAsync(); + + // Creiamo un dizionario per ricerca rapida O(1) basato sulla chiave univoca (Dest + Hour) + // Usiamo una Tupla come chiave del dizionario + var lookup = existingRecords.ToDictionary( + x => (x.Destination, x.Type, x.Hour, x.ErrorMessage), + x => x + ); + + foreach (var incoming in listRecords) + { + var key = (incoming.Destination, incoming.Type, incoming.Hour, incoming.ErrorMessage); + if (lookup.TryGetValue(key, out var existing)) + { + // --- CASO: UPDATE --- + existing.Count = incoming.Count; + } + else + { + // --- CASO: INSERT --- + await dbCtx.DbSetStatsError.AddAsync(incoming); + } + } + // 4. Salvataggio finale + ans = await dbCtx.SaveChangesAsync(); + + // Commit della transazione + await tx.CommitAsync(); + + // Pulizia memoria per evitare che il ChangeTracker diventi troppo pesante nei loop lunghi + dbCtx.ChangeTracker.Clear(); + + return ans; + } + catch (Exception ex) + { + await tx.RollbackAsync(); + Log.Error(ex, "Error during UpsertManyAsync"); + throw; + } + } + + #endregion Public Methods + + #region Protected Fields + + protected static NLog.Logger Log = LogManager.GetCurrentClassLogger(); + + #endregion Protected Fields + } +} \ No newline at end of file diff --git a/MP.Data/Services/Utils/IStatsAggrService.cs b/MP.Data/Services/Utils/IStatsAggrService.cs index a1905070..6723a860 100644 --- a/MP.Data/Services/Utils/IStatsAggrService.cs +++ b/MP.Data/Services/Utils/IStatsAggrService.cs @@ -20,13 +20,6 @@ namespace MP.Data.Services.Utils /// Data fine periodo Task> GetFiltAsync(DateTime dtStart, DateTime dtEnd); - /// - /// Restituisce un dizionario di elaborazioni di tipo Pareto su orizzonte settimanale - /// Ogni elaborazione contiene Dictionary in forma pareto per una data statistica - /// - /// - Task>> GetParetoStatsWeekAsync(); - /// /// Restituisce un dizionario di elaborazioni di tipo Pareto su orizzonte giornaliero /// Ogni elaborazione contiene Dictionary in forma pareto per una data statistica @@ -34,19 +27,19 @@ namespace MP.Data.Services.Utils /// Task>> GetParetoStatsDayAsync(int numDay); + /// + /// Restituisce un dizionario di elaborazioni di tipo Pareto su orizzonte settimanale + /// Ogni elaborazione contiene Dictionary in forma pareto per una data statistica + /// + /// + Task>> GetParetoStatsWeekAsync(); + /// /// Recupera il range di periodi valido per le chiamate aggregate. /// Utilizza la cache automaticamente. /// Task GetRangeAsync(); - /// - /// Inserisce o aggiorna in batch le statistiche aggregate nel database. - /// Opzionalmente elimina i record precedenti nel periodo specificato. - /// - /// Elenco dei record da inserire/aggiornare - /// Se true elimina preventivamente i record nel periodo richiesto - Task UpsertManyAsync(List listRecords, bool removeOld); /// /// Helper conversione dati aggregati in statistiche da inviare a ChartJS /// @@ -56,6 +49,14 @@ namespace MP.Data.Services.Utils /// List GetTimeSeriesData(List rawData, bool groupMach, bool getCount); + /// + /// Inserisce o aggiorna in batch le statistiche aggregate nel database. + /// Opzionalmente elimina i record precedenti nel periodo specificato. + /// + /// Elenco dei record da inserire/aggiornare + /// Se true elimina preventivamente i record nel periodo richiesto + Task UpsertManyAsync(List listRecords, bool removeOld); + #endregion Public Methods } } \ No newline at end of file diff --git a/MP.Data/Services/Utils/IStatsCodeService.cs b/MP.Data/Services/Utils/IStatsCodeService.cs new file mode 100644 index 00000000..10c0239b --- /dev/null +++ b/MP.Data/Services/Utils/IStatsCodeService.cs @@ -0,0 +1,60 @@ +using EgwCoreLib.Utils; +using MP.Core.DTO; +using MP.Data.DbModels.Utils; +using MP.Data.DTO; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace MP.Data.Services.Utils +{ + public interface IStatsCodeService + { + #region Public Methods + + /// + /// Recupera l'elenco delle statistiche StatusCode per un periodo specificato. + /// Utilizza la cache automaticamente. + /// + /// Data inizio periodo + /// Data fine periodo + Task> GetFiltAsync(DateTime dtStart, DateTime dtEnd); + + /// + /// Restituisce un dizionario di elaborazioni di tipo Pareto su orizzonte giornaliero + /// Ogni elaborazione contiene Dictionary in forma pareto per una data statistica + /// + /// + Task>> GetParetoStatsDayAsync(int numDay); + + /// + /// Restituisce un dizionario di elaborazioni di tipo Pareto su orizzonte settimanale + /// Ogni elaborazione contiene Dictionary in forma pareto per una data statistica + /// + /// + Task>> GetParetoStatsWeekAsync(); + + /// + /// Recupera il range di periodi valido per le chiamate StatusCode. + /// Utilizza la cache automaticamente. + /// + Task GetRangeAsync(); + + /// + /// Helper conversione dati aggregati in statistiche da inviare a ChartJS + /// + /// + /// + List GetTimeSeriesData(List rawData); + + /// + /// Inserisce o aggiorna in batch le statistiche StatusCode nel database. + /// Opzionalmente elimina i record precedenti nel periodo specificato. + /// + /// Elenco dei record da inserire/aggiornare + /// Se true elimina preventivamente i record nel periodo richiesto + Task UpsertManyAsync(List listRecords, bool removeOld); + + #endregion Public Methods + } +} \ No newline at end of file diff --git a/MP.Data/Services/Utils/IStatsErrService.cs b/MP.Data/Services/Utils/IStatsErrService.cs new file mode 100644 index 00000000..3141c7e9 --- /dev/null +++ b/MP.Data/Services/Utils/IStatsErrService.cs @@ -0,0 +1,60 @@ +using EgwCoreLib.Utils; +using MP.Core.DTO; +using MP.Data.DbModels.Utils; +using MP.Data.DTO; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace MP.Data.Services.Utils +{ + public interface IStatsErrService + { + #region Public Methods + + /// + /// Recupera l'elenco delle statistiche Error per un periodo specificato. + /// Utilizza la cache automaticamente. + /// + /// Data inizio periodo + /// Data fine periodo + Task> GetFiltAsync(DateTime dtStart, DateTime dtEnd); + + /// + /// Restituisce un dizionario di elaborazioni di tipo Pareto su orizzonte giornaliero + /// Ogni elaborazione contiene Dictionary in forma pareto per una data statistica + /// + /// + Task>> GetParetoStatsDayAsync(int numDay); + + /// + /// Restituisce un dizionario di elaborazioni di tipo Pareto su orizzonte settimanale + /// Ogni elaborazione contiene Dictionary in forma pareto per una data statistica + /// + /// + Task>> GetParetoStatsWeekAsync(); + + /// + /// Recupera il range di periodi valido per le chiamate Error. + /// Utilizza la cache automaticamente. + /// + Task GetRangeAsync(); + + /// + /// Helper conversione dati aggregati in statistiche da inviare a ChartJS + /// + /// + /// + List GetTimeSeriesData(List rawData); + + /// + /// Inserisce o aggiorna in batch le statistiche Error nel database. + /// Opzionalmente elimina i record precedenti nel periodo specificato. + /// + /// Elenco dei record da inserire/aggiornare + /// Se true elimina preventivamente i record nel periodo richiesto + Task UpsertManyAsync(List listRecords, bool removeOld); + + #endregion Public Methods + } +} \ No newline at end of file diff --git a/MP.Data/Services/Utils/StatsCodeService.cs b/MP.Data/Services/Utils/StatsCodeService.cs new file mode 100644 index 00000000..3d2bf4df --- /dev/null +++ b/MP.Data/Services/Utils/StatsCodeService.cs @@ -0,0 +1,186 @@ +using EgwCoreLib.Utils; +using Microsoft.Extensions.Configuration; +using MP.Core.DTO; +using MP.Data.DbModels.Utils; +using MP.Data.DTO; +using MP.Data.Repository.Utils; +using StackExchange.Redis; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace MP.Data.Services.Utils +{ + internal class StatsCodeService : BaseServ, IStatsCodeService + { + #region Public Constructors + + public StatsCodeService( + IConfiguration config, + IConnectionMultiplexer redis, + IStatsCodeRepository repo) : base(config, redis) + { + _className = "StatsStatusCode"; + _repo = repo; + } + + #endregion Public Constructors + + #region Public Methods + + /// + public async Task> GetFiltAsync(DateTime dtStart, DateTime dtEnd) + { + return await TraceAsync($"{_className}.GetFilt", async (activity) => + { + return await GetOrSetCacheAsync( + $"{_redisBaseKey}:{_className}:DT:{dtStart:yyyyMMdd}:{dtEnd:yyyyMMdd}", + async () => await _repo.GetFiltAsync(dtStart, dtEnd), + UltraLongCache + ); + }); + } + + /// + public async Task>> GetParetoStatsDayAsync(int numDay) + { + return await TraceAsync($"{_className}.GetParetoStatsDayAsync", async (activity) => + { + return await GetOrSetCacheAsync( + $"{_redisBaseKey}:{_className}:ParetoDay", + async () => await GetParetoDestAsync(numDay), + LongCache + ); + }); + } + + /// + public async Task>> GetParetoStatsWeekAsync() + { + return await TraceAsync($"{_className}.GetParetoStatsWeekAsync", async (activity) => + { + return await GetOrSetCacheAsync( + $"{_redisBaseKey}:{_className}:ParetoWeek", + async () => await GetParetoDataAsync(), + LongCache + ); + }); + } + + /// + public async Task GetRangeAsync() + { + return await TraceAsync($"{_className}.GetRange", async (activity) => + { + return await GetOrSetCacheAsync( + $"{_redisBaseKey}:{_className}:Range", + async () => await _repo.GetRangeAsync(), + UltraFastCache + ); + }); + } + + /// + public List GetTimeSeriesData(List rawData) + { + DateTime adesso = DateTime.Now; + + List series = new(); + series = rawData + .GroupBy(s => new { s.Destination }) + .Select(group => new ChartSeriesDto + { + SeriesName = group.Key.Destination, + DataPoints = group + .OrderBy(p => p.Hour) + .Select(p => new chartJsData.chartJsTSerie + { + x = p.Hour, + y = p.Count / (p.Hour.Date.Equals(adesso.Date) ? adesso.TimeOfDay.TotalHours : 24) + }) + .ToList() + }) + .ToList(); + + return series; + } + + /// + public async Task UpsertManyAsync(List listRecords, bool removeOld) + { + return await TraceAsync($"{_className}.UpsertMany", async (activity) => + { + string operation = "UpsertMany"; + var success = await _repo.UpsertManyAsync(listRecords, removeOld); + + activity?.SetTag("db.operation", operation); + + if (success > 0) + { + await ClearCacheAsync($"{_redisBaseKey}:{_className}:*"); + } + + return success; + }); + } + + #endregion Public Methods + + #region Protected Methods + + /// + /// metodo locale per recupero e trasformazione dati da includere con processo generare di tracking & cache + /// + /// + protected async Task>> GetParetoDataAsync() + { + Dictionary> result = new(); + DateTime oggi = DateTime.Today; + int numDays = 7; + var rawData = await GetFiltAsync(oggi.AddDays(-numDays), oggi); + // calcolo le varie statistiche... + var pDestRequest = rawData.GroupBy(x => x.Destination) + .Select(g => new StatDataDTO + { + Label = g.Key, + Value = g.Sum(x => x.Count) / numDays + }) + .OrderByDescending(x => x.Value) + .ToList(); + result.Add("Errors (#)", pDestRequest); + + return result; + } + + protected async Task>> GetParetoDestAsync(int numDay) + { + Dictionary> result = new(); + DateTime adesso = DateTime.Now; + DateTime start = DateTime.Today.AddDays(-numDay); + var rawData = await GetFiltAsync(start, adesso); + var numHour = adesso.Subtract(start).TotalHours; + // calcolo le varie statistiche... + var pDestRequest = rawData.GroupBy(x => x.Destination) + .Select(g => new StatDataDTO + { + Label = g.Key, + Value = g.Sum(x => x.Count) / numHour + }) + .OrderByDescending(x => x.Value) + .ToList(); + result.Add("Dest.Request (#/h)", pDestRequest); + + return result; + } + + #endregion Protected Methods + + #region Private Fields + + private readonly string _className; + private readonly IStatsCodeRepository _repo; + + #endregion Private Fields + } +} \ No newline at end of file diff --git a/MP.Data/Services/Utils/StatsErrService.cs b/MP.Data/Services/Utils/StatsErrService.cs new file mode 100644 index 00000000..26e76309 --- /dev/null +++ b/MP.Data/Services/Utils/StatsErrService.cs @@ -0,0 +1,186 @@ +using EgwCoreLib.Utils; +using Microsoft.Extensions.Configuration; +using MP.Core.DTO; +using MP.Data.DbModels.Utils; +using MP.Data.DTO; +using MP.Data.Repository.Utils; +using StackExchange.Redis; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace MP.Data.Services.Utils +{ + public class StatsErrService : BaseServ, IStatsErrService + { + #region Public Constructors + + public StatsErrService( + IConfiguration config, + IConnectionMultiplexer redis, + IStatsErrRepository repo) : base(config, redis) + { + _className = "StatsErr"; + _repo = repo; + } + + #endregion Public Constructors + + #region Public Methods + + /// + public async Task> GetFiltAsync(DateTime dtStart, DateTime dtEnd) + { + return await TraceAsync($"{_className}.GetFilt", async (activity) => + { + return await GetOrSetCacheAsync( + $"{_redisBaseKey}:{_className}:DT:{dtStart:yyyyMMdd}:{dtEnd:yyyyMMdd}", + async () => await _repo.GetFiltAsync(dtStart, dtEnd), + UltraLongCache + ); + }); + } + + /// + public async Task>> GetParetoStatsDayAsync(int numDay) + { + return await TraceAsync($"{_className}.GetParetoStatsDayAsync", async (activity) => + { + return await GetOrSetCacheAsync( + $"{_redisBaseKey}:{_className}:ParetoDay", + async () => await GetParetoDestAsync(numDay), + LongCache + ); + }); + } + + /// + public async Task>> GetParetoStatsWeekAsync() + { + return await TraceAsync($"{_className}.GetParetoStatsWeekAsync", async (activity) => + { + return await GetOrSetCacheAsync( + $"{_redisBaseKey}:{_className}:ParetoWeek", + async () => await GetParetoDataAsync(), + LongCache + ); + }); + } + + /// + public async Task GetRangeAsync() + { + return await TraceAsync($"{_className}.GetRange", async (activity) => + { + return await GetOrSetCacheAsync( + $"{_redisBaseKey}:{_className}:Range", + async () => await _repo.GetRangeAsync(), + UltraFastCache + ); + }); + } + + /// + public List GetTimeSeriesData(List rawData) + { + DateTime adesso = DateTime.Now; + + List series = new(); + series = rawData + .GroupBy(s => new { s.Destination }) + .Select(group => new ChartSeriesDto + { + SeriesName = group.Key.Destination, + DataPoints = group + .OrderBy(p => p.Hour) + .Select(p => new chartJsData.chartJsTSerie + { + x = p.Hour, + y = p.Count / (p.Hour.Date.Equals(adesso.Date) ? adesso.TimeOfDay.TotalHours : 24) + }) + .ToList() + }) + .ToList(); + + return series; + } + + /// + public async Task UpsertManyAsync(List listRecords, bool removeOld) + { + return await TraceAsync($"{_className}.UpsertMany", async (activity) => + { + string operation = "UpsertMany"; + var success = await _repo.UpsertManyAsync(listRecords, removeOld); + + activity?.SetTag("db.operation", operation); + + if (success > 0) + { + await ClearCacheAsync($"{_redisBaseKey}:{_className}:*"); + } + + return success; + }); + } + + #endregion Public Methods + + #region Protected Methods + + /// + /// metodo locale per recupero e trasformazione dati da includere con processo generare di tracking & cache + /// + /// + protected async Task>> GetParetoDataAsync() + { + Dictionary> result = new(); + DateTime oggi = DateTime.Today; + int numDays = 7; + var rawData = await GetFiltAsync(oggi.AddDays(-numDays), oggi); + // calcolo le varie statistiche... + var pDestRequest = rawData.GroupBy(x => x.Destination) + .Select(g => new StatDataDTO + { + Label = g.Key, + Value = g.Sum(x => x.Count) / numDays + }) + .OrderByDescending(x => x.Value) + .ToList(); + result.Add("Errors (#)", pDestRequest); + + return result; + } + + protected async Task>> GetParetoDestAsync(int numDay) + { + Dictionary> result = new(); + DateTime adesso = DateTime.Now; + DateTime start = DateTime.Today.AddDays(-numDay); + var rawData = await GetFiltAsync(start, adesso); + var numHour = adesso.Subtract(start).TotalHours; + // calcolo le varie statistiche... + var pDestRequest = rawData.GroupBy(x => x.Destination) + .Select(g => new StatDataDTO + { + Label = g.Key, + Value = g.Sum(x => x.Count) / numHour + }) + .OrderByDescending(x => x.Value) + .ToList(); + result.Add("Dest.Request (#/h)", pDestRequest); + + return result; + } + + #endregion Protected Methods + + #region Private Fields + + private readonly string _className; + private readonly IStatsErrRepository _repo; + + #endregion Private Fields + } +} \ No newline at end of file diff --git a/MP.IOC/Data/MpDataService.cs b/MP.IOC/Data/MpDataService.cs index abce85b4..922965f3 100644 --- a/MP.IOC/Data/MpDataService.cs +++ b/MP.IOC/Data/MpDataService.cs @@ -4054,7 +4054,7 @@ namespace MP.IOC.Data { Log.Info($"upsertCurrObjItems | idxMacchina: {idxMacchina} | {innovations.Count} innovations"); // leggo i valori attuali... - List actValues = MachineParamList(idxMacchina); + List actValues = await MachineParamListAsync(idxMacchina); // per ogni valOut passatomi faccio insert o update rispetto elenco valori correnti in REDIS foreach (var item in actValues) { @@ -4078,7 +4078,7 @@ namespace MP.IOC.Data string serVal = JsonConvert.SerializeObject(innovations); var currKey = Utils.RedKeyCurrObjItems(idxMacchina, MpIoNS); - RedisValue rawData = redisDb.StringSet(currKey, serVal); + RedisValue rawData = await redisDb.StringSetAsync(currKey, serVal); } return answ; } diff --git a/MP.RIOC/MP.RIOC.csproj b/MP.RIOC/MP.RIOC.csproj index a7f0e7f1..5b179860 100644 --- a/MP.RIOC/MP.RIOC.csproj +++ b/MP.RIOC/MP.RIOC.csproj @@ -5,7 +5,7 @@ enable enable MP.RIOC - 8.16.2605.910 + 8.16.2605.1111 diff --git a/MP.RIOC/RedisScript/RedisUpdateScript_v5.lua b/MP.RIOC/RedisScript/RedisUpdateScript_v5.lua index 1b4a2477..7e12447d 100644 --- a/MP.RIOC/RedisScript/RedisUpdateScript_v5.lua +++ b/MP.RIOC/RedisScript/RedisUpdateScript_v5.lua @@ -4,11 +4,13 @@ local countInc = tonumber(ARGV[1]) or 0 local totalMsInc = tonumber(ARGV[2]) or 0 local newMax = tonumber(ARGV[3]) local newMin = tonumber(ARGV[4]) -local sentinel = tonumber(ARGV[5]) +local noReply = tonumber(ARGV[5]) +local sentinel = tonumber(ARGV[6]) --- Incrementi base +-- Incrementi redis.call('HINCRBY', key, 'count', countInc) redis.call('HINCRBYFLOAT', key, 'totalMs', totalMsInc) +redis.call('HINCRBY', key, 'noReply', noReply) -- MAX local currentMaxStr = redis.call('HGET', key, 'maxMs') diff --git a/MP.RIOC/RedisScript/RedisUpdateScript_v6.lua b/MP.RIOC/RedisScript/RedisUpdateScript_v6.lua index 230ed7f9..0e0cc274 100644 --- a/MP.RIOC/RedisScript/RedisUpdateScript_v6.lua +++ b/MP.RIOC/RedisScript/RedisUpdateScript_v6.lua @@ -4,11 +4,13 @@ local countInc = tonumber(ARGV[1]) or 0 local totalMsInc = tonumber(ARGV[2]) or 0 local newMax = tonumber(ARGV[3]) local newMin = tonumber(ARGV[4]) -local sentinel = tonumber(ARGV[5]) +local noReply = tonumber(ARGV[5]) +local sentinel = tonumber(ARGV[6]) -- Incrementi redis.call('HINCRBY', key, 'count', countInc) redis.call('HINCRBYFLOAT', key, 'totalMs', totalMsInc) +redis.call('HINCRBY', key, 'noReply', noReply) -- MAX local currentMaxStr = redis.call('HGET', key, 'maxMs') diff --git a/MP.RIOC/Resources/ChangeLog.html b/MP.RIOC/Resources/ChangeLog.html index 4bf268b2..e2824b7f 100644 --- a/MP.RIOC/Resources/ChangeLog.html +++ b/MP.RIOC/Resources/ChangeLog.html @@ -1,6 +1,6 @@ Modulo MP-RIOC -

Versione: 8.16.2605.910

+

Versione: 8.16.2605.1111


Note di rilascio:
  • diff --git a/MP.RIOC/Resources/VersNum.txt b/MP.RIOC/Resources/VersNum.txt index c8db13cb..dcaf613a 100644 --- a/MP.RIOC/Resources/VersNum.txt +++ b/MP.RIOC/Resources/VersNum.txt @@ -1 +1 @@ -8.16.2605.910 +8.16.2605.1111 diff --git a/MP.RIOC/Resources/manifest.xml b/MP.RIOC/Resources/manifest.xml index f74a5184..d59bada6 100644 --- a/MP.RIOC/Resources/manifest.xml +++ b/MP.RIOC/Resources/manifest.xml @@ -1,6 +1,6 @@ - 8.16.2605.910 + 8.16.2605.1111 https://nexus.steamware.net/repository/SWS/MP-RIOC/stable/LAST/MP.RIOC.zip https://nexus.steamware.net/repository/SWS/MP-RIOC/stable/LAST/ChangeLog.html false diff --git a/MP.RIOC/Services/MetricsCalcService.cs b/MP.RIOC/Services/MetricsCalcService.cs index 81c164c4..39fe50cc 100644 --- a/MP.RIOC/Services/MetricsCalcService.cs +++ b/MP.RIOC/Services/MetricsCalcService.cs @@ -48,6 +48,7 @@ namespace MP.RIOC.Services private class AggregatedStats { public long Count; + public long NoReply; public double TotalMs; public double MaxMs; public double MinMs = double.MaxValue; @@ -111,6 +112,10 @@ namespace MP.RIOC.Services var hoursIndex = HoursIndexKey(dest, method); var hourScore = ToEpochSeconds(hourStart); + // Calcolo NoReply: Somma di tutti i codici >= 400 o errori espliciti + long noReplyCount = stat.ErrorMessages.Sum(x => x.Value); + + // 1. INVIO BUCKET PRINCIPALE (con NoReply) // Usiamo lo script Lua per l'aggiornamento atomico dell'ora tasks.Add(batch.ScriptEvaluateAsync(_updateScript, new RedisKey[] { hourKey }, @@ -119,9 +124,34 @@ namespace MP.RIOC.Services totalMs.ToString(CultureInfo.InvariantCulture), maxMs.ToString(CultureInfo.InvariantCulture), minMs.ToString(CultureInfo.InvariantCulture), + noReplyCount.ToString(CultureInfo.InvariantCulture), SentinelValue })); + // 2. INVIO DISTRIBUZIONE STATUS CODES + if (stat.StatusCodes.Any()) + { + var statusKey = hourKey + ":status"; + foreach (var status in stat.StatusCodes) + { + tasks.Add(batch.HashIncrementAsync(statusKey, status.Key.ToString(), status.Value)); + } + // Aggiungiamo anche questa chiave all'indice per la pulizia automatica + tasks.Add(batch.SortedSetAddAsync(hoursIndex, statusKey, hourScore)); + } + + // 3. INVIO DETTAGLIO ERRORI + if (stat.ErrorMessages.Any()) + { + var errorKey = hourKey + ":errors"; + foreach (var error in stat.ErrorMessages) + { + // Usiamo HashIncrement per aggregare messaggi uguali + tasks.Add(batch.HashIncrementAsync(errorKey, error.Key, error.Value)); + } + tasks.Add(batch.SortedSetAddAsync(hoursIndex, errorKey, hourScore)); + } + tasks.Add(batch.SortedSetAddAsync(hoursIndex, hourKey, hourScore)); // --- LOGICA DAILY (Aggregazione locale per evitare sovrascritture nel loop) --- @@ -136,6 +166,7 @@ namespace MP.RIOC.Services } agg.Count += count; + agg.NoReply += noReplyCount; agg.TotalMs += totalMs; agg.MaxMs = Math.Max(agg.MaxMs, maxMs); if (minMs != double.MaxValue) @@ -162,6 +193,7 @@ namespace MP.RIOC.Services agg.TotalMs.ToString(CultureInfo.InvariantCulture), agg.MaxMs.ToString(CultureInfo.InvariantCulture), finalMin.ToString(CultureInfo.InvariantCulture), + agg.NoReply.ToString(CultureInfo.InvariantCulture), SentinelValue })); } diff --git a/MP.RIOC/Services/MetricsDbFlushService.cs b/MP.RIOC/Services/MetricsDbFlushService.cs index ba6444b2..4d2cbd71 100644 --- a/MP.RIOC/Services/MetricsDbFlushService.cs +++ b/MP.RIOC/Services/MetricsDbFlushService.cs @@ -27,7 +27,7 @@ namespace MP.RIOC.Services protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - var interval = _config.GetValue("RouteMan:MetricFlushIntervalSeconds", 180); + var interval = _config.GetValue("RouteMan:MetricFlushIntervalSeconds", 240); while (!stoppingToken.IsCancellationRequested) { @@ -79,7 +79,7 @@ namespace MP.RIOC.Services DateTime now = DateTime.Now; // Confini temporali per proteggere i dati in corso - DateTime currentDayStart = new DateTime(now.Year, now.Month, now.Day, 0, 0, 0); + DateTime currentDayStart = DateTime.Today; var endpoints = _mux.GetEndPoints(); @@ -110,17 +110,14 @@ namespace MP.RIOC.Services foreach (var statKey in memberKeys) { var sKey = (RedisKey)$"{statKey}"; +#if false if (!TryParseKeyMetadata(sKey, out string dest, out string method, out string machId, out DateTime timestamp, out bool isHourType)) - continue; - - // Verifica se la chiave fosse scaduta rispetto all'orario corrente - bool isExpired = isHourType - //? timestamp < currentHourStart - ? false - : timestamp < currentDayStart; + continue; +#endif + if (!TryParseKeyMetadata(sKey, out var meta) || meta.IsHourType) continue; // Se fosse scaduta e abbiamo il permesso, segnamola per la cancellazione - if (isExpired && deleteConfirmed) + if (meta.Timestamp < currentDayStart && deleteConfirmed) { // 1. Segna la chiave Hash (Dati) per l'eliminazione keysToDelete.Add(sKey); @@ -152,16 +149,23 @@ namespace MP.RIOC.Services if (count <= 0) continue; + // recupero se presente noReply + long noReply = 0; + if (dict.TryGetValue("noReply", out var sNoReply)) + { + noReply = long.Parse(sNoReply); + } + aggrRecordsToInsert.Add(new StatsAggregatedModel { - Destination = dest, - MachineId = machId, - Hour = timestamp, + Destination = meta.Dest, + MachineId = meta.MachId, + Hour = meta.Timestamp, RequestCount = count, AvgDuration = totalMs / count, MinDuration = minMs, MaxDuration = maxMs, - NoReply = 0 + NoReply = noReply }); } } @@ -171,7 +175,7 @@ namespace MP.RIOC.Services } // --- FASE UPSERT DB --- - if (aggrRecordsToInsert.Count > 0) + if (aggrRecordsToInsert.Any()) { await using var scope = _scopeFactory.CreateAsyncScope(); var aggrService = scope.ServiceProvider.GetRequiredService(); @@ -180,7 +184,7 @@ namespace MP.RIOC.Services } // --- FASE PULIZIA REDIS --- - if (deleteConfirmed && keysToDelete.Count > 0) + if (deleteConfirmed && keysToDelete.Any()) { var batch = _db.CreateBatch(); int deletedCount = 0; @@ -239,17 +243,20 @@ namespace MP.RIOC.Services foreach (var statKey in memberKeys) { var sKey = (RedisKey)$"{statKey}"; + if (!TryParseKeyMetadata(sKey, out var meta) || !meta.IsHourType) continue; +#if false if (!TryParseKeyMetadata(sKey, out string dest, out string method, out string machId, out DateTime timestamp, out bool isHourType)) - continue; + continue; // Verifica se la chiave fosse scaduta rispetto all'orario corrente bool isExpired = isHourType ? timestamp < currentHourStart - : false; + : false; //: timestamp < currentDayStart; +#endif // Se fosse scaduta e abbiamo il permesso, segnamola per la cancellazione - if (isExpired && deleteConfirmed) + if (meta.Timestamp < currentHourStart && deleteConfirmed) { // 1. Segna la chiave Hash (Dati) per l'eliminazione keysToDelete.Add(sKey); @@ -280,17 +287,23 @@ namespace MP.RIOC.Services if (maxMs >= SentinelValue) maxMs = SentinelValue; if (count <= 0) continue; + // recupero se presente noReply + long noReply = 0; + if (dict.TryGetValue("noReply", out var sNoReply)) + { + noReply = long.Parse(sNoReply); + } detailRecordsToInsert.Add(new StatsDetailModel { - Destination = dest, - Type = method, - Hour = timestamp, + Destination = meta.Dest, + Type = meta.Method, + Hour = meta.Timestamp, RequestCount = count, AvgDuration = totalMs / count, MinDuration = minMs, MaxDuration = maxMs, - NoReply = 0 + NoReply = noReply }); } } @@ -322,7 +335,35 @@ namespace MP.RIOC.Services Log.Info($"[CLEANUP HOUR] Deleted {deletedCount} expired metric keys from Redis"); } } + private bool TryParseKeyMetadata(RedisKey key, out KeyMeta meta) + { + meta = new KeyMeta(); + try + { + string k = key.ToString(); + string relativeKey = k.Replace($"{_redisBaseKey}:", ""); + var p = relativeKey.Split(':'); + if (p.Length < 4) return false; + meta.IsHourType = p[1].Equals("hour", StringComparison.InvariantCultureIgnoreCase); + meta.Dest = p[2]; + if (meta.IsHourType) + { + meta.Method = p[3]; + if (p.Length >= 5) DateTime.TryParseExact(p[4], "yyyyMMddHH", CultureInfo.InvariantCulture, DateTimeStyles.None, out meta.Timestamp); + } + else + { + meta.Method = "DAILY"; + meta.MachId = p[3]; + if (p.Length >= 5) DateTime.TryParseExact(p[4], "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out meta.Timestamp); + } + return meta.Timestamp != DateTime.MinValue; + } + catch { return false; } + } + +#if false private bool TryParseKeyMetadata(RedisKey key, out string dest, out string method, out string machId, out DateTime timestamp, out bool isHourType) { dest = "NA"; @@ -374,6 +415,16 @@ namespace MP.RIOC.Services } catch { } return false; + } +#endif + + private record KeyMeta + { + public string Dest = "NA"; + public string Method = "NA"; + public string MachId = "ALL"; + public DateTime Timestamp = DateTime.MinValue; + public bool IsHourType = true; } #endregion Private Methods diff --git a/MP.RIOC/Services/RouteManager.cs b/MP.RIOC/Services/RouteManager.cs index 2dd96b89..e6961d85 100644 --- a/MP.RIOC/Services/RouteManager.cs +++ b/MP.RIOC/Services/RouteManager.cs @@ -70,11 +70,9 @@ namespace MP.RIOC.Services var (oldW, newW) = _weightProvider.GetWeightsFor(metodo); var pickNew = DecideByWeights(oldW, newW); var targetLabel = pickNew ? "IOC" : "IO"; + string sKey = $"{targetLabel}|{metodo}|{id}"; - var destBase = pickNew - ? _config["ServerConf:NewApiUrl"] - : _config["ServerConf:OldApiUrl"]; - + var destBase = pickNew ? _config["ServerConf:NewApiUrl"] : _config["ServerConf:OldApiUrl"]; if (string.IsNullOrEmpty(destBase)) { context.Response.StatusCode = 502; @@ -82,42 +80,57 @@ namespace MP.RIOC.Services return; } + // Verifica destinazione + // per evitare il "doppio slash" (es. .../api/IOB//metodo) + if (!destBase.EndsWith("/")) destBase += "/"; + + // avvio registrazione statistice + _stats.Record(sKey); + // 4. PREPARAZIONE FORWARDING var originalPath = context.Request.Path; var originalPathBase = context.Request.PathBase; try { - string sKey = $"{targetLabel}|{metodo}|{id}"; - _stats.Record(sKey); - - // Assicuriamoci che la destinazione finisca con / e il path inizi senza / - // per evitare il "doppio slash" (es. .../api/IOB//metodo) - if (!destBase.EndsWith("/")) destBase += "/"; - context.Request.Path = new PathString("/" + relativePath); context.Request.PathBase = PathString.Empty; - // Esecuzione + // ESECUZIONE FORWARDING var error = await _forwarder.SendAsync(context, destBase, _httpClientInvoker, _forwarderConfig, HttpTransformer.Default, context.RequestAborted); // commento transformer custom //var error = await _forwarder.SendAsync(context, destBase, _httpClientInvoker, _forwarderConfig, _transformer, context.RequestAborted); + sw.Stop(); _stats.RecordDuration(sKey, sw.Elapsed); + // REGISTRAZIONE STATUS CODE (Sempre, se disponibile) + _stats.RecordStatusCode(sKey, context.Response.StatusCode); + if (error != ForwarderError.None) { var feat = context.GetForwarderErrorFeature(); - Log.Error(feat?.Exception, "Forwarder error to {DestBase} for {Method}", destBase, metodo); + var errorMsg = feat?.Exception?.Message ?? error.ToString(); + + // REGISTRAZIONE ERRORE DETTAGLIATO + _stats.RecordError(sKey, errorMsg); + + Log.Error(feat?.Exception, "Forwarder error to {DestBase} for {Method}: {Msg}", destBase, metodo, errorMsg); if (!context.Response.HasStarted) { context.Response.StatusCode = 502; - await context.Response.WriteAsync($"Forward error: {feat?.Exception?.Message ?? error.ToString()}"); + await context.Response.WriteAsync($"Forward error: {errorMsg}"); } } } + catch (Exception ex) + { + sw.Stop(); + _stats.RecordError(sKey, ex.Message); + Log.Fatal(ex, "Critical error in RouteManager"); + } finally { context.Request.Path = originalPath; diff --git a/MP.RIOC/Services/RouteStatsManager.cs b/MP.RIOC/Services/RouteStatsManager.cs index 9274826a..7bba986c 100644 --- a/MP.RIOC/Services/RouteStatsManager.cs +++ b/MP.RIOC/Services/RouteStatsManager.cs @@ -9,6 +9,7 @@ namespace MP.RIOC.Services public TimeSpan MaxDuration = TimeSpan.Zero; public TimeSpan MinDuration = TimeSpan.MaxValue; public ConcurrentDictionary StatusCodes = new(); + public ConcurrentDictionary ErrorMessages = new(); public TimeSpan AvgDuration => TotalDuration / (Count > 0 ? Count : 1); } @@ -51,6 +52,21 @@ namespace MP.RIOC.Services } } + /// + /// Registrazione errore + /// + /// + /// + public void RecordError(string dest_method, string errorMessage) + { + if (_map.TryGetValue(dest_method, out var stat)) + { + // Puliamo il messaggio per evitare chiavi infinite (es. togliamo timestamp o ID dinamici) + var cleanMsg = errorMessage.Length > 100 ? errorMessage[..100] + "..." : errorMessage; + stat.ErrorMessages.AddOrUpdate(cleanMsg, 1, (_, v) => v + 1); + } + } + public void RecordStatusCode(string method, int statusCode) { if (_map.TryGetValue(method, out var stat))