[Feature/Perf] Ottimizzazioni bulk pre-discovery, batch deletion sync e supporto OLE DB / Salesforce client_credentials

## Bulk Pre-Discovery e riduzione query SQLite/SOQL

### KeyAssociationService — FindAssociationsByKeyValuesBulkAsync (nuovo)
- Aggiunta query bulk 'WHERE KeyValue IN (...)' per recuperare N associazioni con 1 sola query SQLite
  (chunking a 500 chiavi per rispettare il limite ~999 parametri di SQLite)
- Aggiunta interfaccia IKeyAssociationService e delegata in DataConnectionCredentialService / IDataConnectionCredentialService

### AssociationService — BatchFindOrCreateAssociationsAsync (nuovo)
- Nuovo metodo bulk che sostituisce i loop per-record durante l'analisi composite:
  1) 1 query SQLite bulk per tutte le chiavi
  2) Per le chiavi non trovate: SOQL 'IN (...)' su Salesforce in chunk da 200 via BatchExecuteQueriesAsync
     (ceil(K/25) HTTP Composite call invece di K singole)
  3) Salvataggio parallelo delle associazioni pre-discovery scoperte
- Fallback per-record automatico per client REST non Salesforce
- Aggiornata interfaccia IAssociationService con documentazione XML completa

### DataCoupler.razor.cs — STEP A/B nel flusso COMPOSITE
- Pre-Discovery spostata FUORI dal loop parallelo (STEP A, prima dell'analisi)
- associationsByKey pre-popolato con BatchFindOrCreateAssociationsAsync
- STEP B: il loop parallelo usa TryGetValue O(1) invece di query async per record
- Rimozione blocco ~40 righe di per-record lookup / fallback duplicati

## Salesforce Composite API — Batch Delete e Patch

### SalesforceServiceClient — metodi batch (nuovi)
- BatchDeleteEntitiesAsync: elimina N record con ceil(N/25) Composite call invece di N
- BatchPatchSingleFieldAsync: aggiorna un singolo campo su N record tramite BatchUpdateEntitiesAsync

### DeletionSyncService — refactoring batch
- ExecuteBatchedSalesforceDeletionsAsync: orchestrazione batch per Delete / Deactivate / Mark su Salesforce
- ExecuteSequentialDeletionsAsync: loop sequenziale esistente estratto in metodo riutilizzabile
- Dispatcher: Salesforce -> batch Composite, altri client REST -> sequenziale

## Supporto OLE DB (database)

### DatabaseSchemaProviderFactory
- Aggiunto case DatabaseType.OleDb -> new OleDbSchemaProvider() nel factory switch

### DatabaseMethod.cs
- Aggiunto metodo IsOleDbConnection() (parallelo a IsOdbcConnection())
- Query validation e manager temporaneo estesi a OLE DB oltre che ODBC
- GetLimitedQuery: aggiunto case OleDb -> 'SELECT TOP N FROM (subquery)'

## Salesforce OAuth2 — fix client_credentials

### CredentialService.cs
- Aggiunto 'GrantType' alla HashSet serviceSpecificKeys per preservarlo nella serializzazione AdditionalParameters

### DataConnectionCredentialService.cs
- Refactored BuildRestServiceOptions in helper statico riutilizzato da entrambi i metodi GetRestServiceOptions
- Mapping coerente ClientId/ClientSecret/GrantType per Salesforce (allineato a DataConnectionFactory)
- TestSalesforceOAuthLogin: branch esplicito per client_credentials (no username/password/token)
  con validazione preventiva ClientId+ClientSecret obbligatori
- Log flow label (password|client_credentials) in tutti i messaggi di autenticazione

## VS Code tasks

### .vscode/tasks.json
- Rimosso task generico 'Publish Data_Coupler'
- Aggiunti due task separati: win-x64 e win-x86, entrambi SingleFile + Self-Contained + ReadyToRun
This commit is contained in:
Alessio Dal Santo
2026-05-28 11:15:18 +02:00
parent 82e0d6bc77
commit 344853fde9
13 changed files with 886 additions and 223 deletions
+239
View File
@@ -1,11 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using CredentialManager.Models;
using CredentialManager.Services;
using DataConnection.CredentialManagement.Interfaces;
using DataConnection.REST.Implementations;
using DataConnection.REST.Interfaces;
using Microsoft.Extensions.Logging;
@@ -69,6 +71,227 @@ public class AssociationService : IAssociationService
return null;
}
/// <summary>
/// Versione bulk del find-or-create — vedi <see cref="IAssociationService.BatchFindOrCreateAssociationsAsync"/>.
/// </summary>
public async Task<Dictionary<string, KeyAssociation>> BatchFindOrCreateAssociationsAsync(
IEnumerable<string> sourceKeys,
PreDiscoveryRequest commonRequest)
{
if (commonRequest == null)
throw new ArgumentNullException(nameof(commonRequest));
var distinctKeys = sourceKeys
.Where(k => !string.IsNullOrEmpty(k))
.Distinct()
.ToList();
var result = new Dictionary<string, KeyAssociation>(StringComparer.Ordinal);
if (distinctKeys.Count == 0)
return result;
// STEP 1 — Bulk lookup nel DB locale (1 query SQLite invece di N)
Dictionary<string, KeyAssociation> localMatches;
try
{
localMatches = await _credentialService.FindKeyAssociationsByValuesBulkAsync(
distinctKeys, commonRequest.DestinationEntity, commonRequest.CredentialName);
}
catch (Exception ex)
{
_logger.LogError(ex, "BULK PRE-DISCOVERY: bulk lookup locale fallito, fallback per-record");
// Fallback per-record (mantiene comportamento esistente in caso di problemi)
foreach (var key in distinctKeys)
{
var req = ClonePreDiscoveryRequest(commonRequest, key);
var found = await FindOrCreateAssociationAsync(req);
if (found != null) result[key] = found;
}
return result;
}
foreach (var kvp in localMatches)
result[kvp.Key] = kvp.Value;
var missingKeys = distinctKeys.Where(k => !result.ContainsKey(k)).ToList();
_logger.LogInformation("BULK PRE-DISCOVERY: {Local}/{Total} associazioni trovate localmente, {Missing} da cercare nella destinazione",
localMatches.Count, distinctKeys.Count, missingKeys.Count);
if (missingKeys.Count == 0 || !commonRequest.EnablePreDiscovery)
return result;
// STEP 2 — Pre-Discovery batched sulla destinazione REST
// Verifica che il campo chiave sia mappato
if (string.IsNullOrEmpty(commonRequest.SourceKeyField) ||
!commonRequest.FieldMappings.TryGetValue(commonRequest.SourceKeyField, out var mappedField))
{
_logger.LogWarning("BULK PRE-DISCOVERY: campo chiave '{SourceKeyField}' non mappato; skip discovery",
commonRequest.SourceKeyField);
return result;
}
// Solo SalesforceServiceClient supporta SOQL IN ottimizzata; per altri client si ricade al per-record.
if (commonRequest.RestClient is SalesforceServiceClient sfClient)
{
var discovered = await PerformBulkPreDiscoverySalesforceAsync(
sfClient, missingKeys, mappedField, commonRequest);
foreach (var kvp in discovered)
result[kvp.Key] = kvp.Value;
}
else
{
_logger.LogDebug("BULK PRE-DISCOVERY: client REST non Salesforce, fallback per-record per {Count} chiavi", missingKeys.Count);
foreach (var key in missingKeys)
{
var req = ClonePreDiscoveryRequest(commonRequest, key);
var found = await PerformPreDiscoveryAsync(req);
if (found != null) result[key] = found;
}
}
return result;
}
/// <summary>
/// Pre-Discovery batched specifica per Salesforce: usa SOQL <c>WHERE field IN (...)</c>
/// per recuperare in pochissime chiamate API tutti i record che matchano una qualsiasi delle chiavi mancanti.
/// </summary>
private async Task<Dictionary<string, KeyAssociation>> PerformBulkPreDiscoverySalesforceAsync(
SalesforceServiceClient sfClient,
List<string> missingKeys,
string mappedDestinationField,
PreDiscoveryRequest commonRequest)
{
var output = new Dictionary<string, KeyAssociation>(StringComparer.Ordinal);
// Chunk per stare sotto il limite SOQL/URL (~16 KB GET): ~200 valori per query
const int chunkSize = 200;
var queries = new List<string>();
for (int i = 0; i < missingKeys.Count; i += chunkSize)
{
var chunk = missingKeys.Skip(i).Take(chunkSize).ToList();
var sb = new StringBuilder();
sb.Append("SELECT Id, ");
sb.Append(mappedDestinationField);
sb.Append(" FROM ");
sb.Append(commonRequest.DestinationEntity);
sb.Append(" WHERE ");
sb.Append(mappedDestinationField);
sb.Append(" IN (");
sb.Append(string.Join(",", chunk.Select(v => $"'{v.Replace("'", "\\'")}'")));
sb.Append(')');
queries.Add(sb.ToString());
}
_logger.LogInformation("BULK PRE-DISCOVERY: {QueryCount} SOQL IN-query (~{ChunkSize} chiavi/query, Composite API ceil(N/25) HTTP call)",
queries.Count, chunkSize);
// BatchExecuteQueriesAsync raggruppa fino a 25 query in 1 Composite request
var batchResults = await sfClient.BatchExecuteQueriesAsync(queries);
// Indicizza i risultati per chiave: dal record letto leggiamo il valore di mappedDestinationField
var entityIdByKey = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var batchResult in batchResults.Where(r => r.Success && r.Records != null))
{
foreach (var record in batchResult.Records)
{
if (!record.TryGetValue(mappedDestinationField, out var keyVal) || keyVal == null)
continue;
var keyStr = keyVal.ToString();
if (string.IsNullOrEmpty(keyStr))
continue;
var idStr = ExtractDestinationId(record);
if (string.IsNullOrEmpty(idStr))
continue;
// In caso di duplicati in Salesforce, prendiamo il primo
if (!entityIdByKey.ContainsKey(keyStr))
entityIdByKey[keyStr] = idStr;
}
}
_logger.LogInformation("BULK PRE-DISCOVERY: trovati {Found}/{Missing} record esistenti nella destinazione",
entityIdByKey.Count, missingKeys.Count);
if (entityIdByKey.Count == 0)
return output;
// Salvataggio associazioni Pre-Discovery in parallelo
var saveTasks = entityIdByKey.Select(async kvp =>
{
try
{
var newAssoc = new KeyAssociation
{
KeyValue = kvp.Key,
SourceKeyField = commonRequest.SourceKeyField,
DestinationKeyField = commonRequest.DestinationKeyField ?? "Id",
MappedDestinationField = mappedDestinationField,
DestinationEntity = commonRequest.DestinationEntity,
DestinationId = kvp.Value,
RestCredentialName = commonRequest.CredentialName,
CreatedAt = DateTime.UtcNow,
LastVerifiedAt = DateTime.UtcNow,
IsActive = true,
AdditionalInfo = JsonSerializer.Serialize(new Dictionary<string, object>
{
{ "CreatedBy", "PreDiscovery" },
{ "DiscoveredAt", DateTime.UtcNow },
{ "MappingCount", commonRequest.FieldMappings.Count },
{ "BulkPreDiscovery", true },
{ "ScheduledTransfer", commonRequest.IsScheduledTransfer },
{ "SourceType", commonRequest.SourceType ?? string.Empty }
})
};
var id = commonRequest.UseParallelMethod
? await _credentialService.SaveKeyAssociationParallelAsync(newAssoc)
: await _credentialService.SaveKeyAssociationAsync(newAssoc);
newAssoc.Id = id;
return (kvp.Key, newAssoc);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "BULK PRE-DISCOVERY: errore nel salvataggio associazione per KeyValue '{KeyValue}'", kvp.Key);
return (kvp.Key, (KeyAssociation?)null);
}
});
var savedResults = await Task.WhenAll(saveTasks);
foreach (var (key, assoc) in savedResults)
{
if (assoc != null) output[key] = assoc;
}
return output;
}
private static PreDiscoveryRequest ClonePreDiscoveryRequest(PreDiscoveryRequest source, string sourceKey)
{
return new PreDiscoveryRequest
{
SourceKey = sourceKey,
SourceKeyField = source.SourceKeyField,
DestinationEntity = source.DestinationEntity,
CredentialName = source.CredentialName,
DestinationKeyField = source.DestinationKeyField,
FieldMappings = source.FieldMappings,
RestClient = source.RestClient,
CurrentDataHash = source.CurrentDataHash,
EnablePreDiscovery = source.EnablePreDiscovery,
UseParallelMethod = source.UseParallelMethod,
IsScheduledTransfer = source.IsScheduledTransfer,
SourceType = source.SourceType
};
}
/// <summary>
/// Verifica se un'associazione è stata creata dal Pre-Discovery
/// controllando il campo AdditionalInfo
@@ -285,6 +508,22 @@ public interface IAssociationService
{
Task<KeyAssociation?> FindOrCreateAssociationAsync(PreDiscoveryRequest request);
bool IsPreDiscoveryAssociation(KeyAssociation association);
/// <summary>
/// Versione bulk del find-or-create.
/// 1) Una sola query SQLite (WHERE KeyValue IN …) per recuperare le associazioni esistenti.
/// 2) Per le chiavi non trovate localmente, una manciata di SOQL "IN" su Salesforce
/// (~200 chiavi per query, Composite API: ceil(K/25) HTTP call) invece di K chiamate singole.
/// 3) Le associazioni Pre-Discovery scoperte vengono salvate e restituite.
/// </summary>
/// <param name="sourceKeys">Lista (non vuota) dei valori chiave sorgente per tutti i record da analizzare.</param>
/// <param name="commonRequest">Parametri condivisi (entity, credential, restClient, mappings, ecc.).
/// <see cref="PreDiscoveryRequest.SourceKey"/> e <see cref="PreDiscoveryRequest.CurrentDataHash"/>
/// sono ignorati; vengono presi dal parametro <paramref name="sourceKeys"/>.</param>
/// <returns>Dizionario KeyValue → KeyAssociation (solo per chiavi trovate/create).</returns>
Task<Dictionary<string, KeyAssociation>> BatchFindOrCreateAssociationsAsync(
IEnumerable<string> sourceKeys,
PreDiscoveryRequest commonRequest);
}
/// <summary>
+189 -69
View File
@@ -1,5 +1,6 @@
using CredentialManager.Models;
using DataConnection.CredentialManagement.Interfaces;
using DataConnection.REST.Implementations;
using DataConnection.REST.Interfaces;
using Microsoft.Extensions.Logging;
@@ -79,76 +80,18 @@ public class DeletionSyncService : IDeletionSyncService
_logger.LogInformation("Trovate {Count} cancellazioni in attesa di sincronizzazione",
pendingDeletions.Count);
// Step 3: Esegui le cancellazioni nella destinazione
foreach (var deletion in pendingDeletions)
// Step 3: Esegui le cancellazioni nella destinazione.
// Per Salesforce usiamo le Composite API in batch (ceil(N/25) HTTP call invece di N);
// per gli altri client REST manteniamo il loop sequenziale (nessun batch supportato).
if (restClient is SalesforceServiceClient salesforceClient)
{
try
{
bool syncSuccess = false;
string errorMessage = "";
switch (options.Action)
{
case DeletionAction.Delete:
// Elimina fisicamente il record
syncSuccess = await DeleteRecordAsync(
restClient, destinationEntity, deletion.DestinationId);
break;
case DeletionAction.Deactivate:
// Marca il record come inattivo
syncSuccess = await DeactivateRecordAsync(
restClient, destinationEntity, deletion.DestinationId);
break;
case DeletionAction.Mark:
// Imposta un campo personalizzato
if (string.IsNullOrEmpty(options.MarkField) || string.IsNullOrEmpty(options.MarkValue))
{
errorMessage = "MarkField e MarkValue devono essere specificati per DeletionAction.Mark";
_logger.LogWarning(errorMessage);
result.Errors.Add($"KeyValue: {deletion.KeyValue} - {errorMessage}");
continue;
}
syncSuccess = await MarkRecordAsync(
restClient, destinationEntity, deletion.DestinationId,
options.MarkField, options.MarkValue);
break;
default:
errorMessage = $"Azione di cancellazione non supportata: {options.Action}";
_logger.LogWarning(errorMessage);
result.Errors.Add($"KeyValue: {deletion.KeyValue} - {errorMessage}");
continue;
}
if (syncSuccess)
{
// Marca la cancellazione come sincronizzata
await _credentialService.MarkDeletionSyncedAsync(deletion.Id);
result.DeletedRecordsSynced++;
_logger.LogInformation(
"Cancellazione sincronizzata: KeyValue={KeyValue}, DestinationId={DestinationId}, Action={Action}",
deletion.KeyValue, deletion.DestinationId, options.Action);
}
else
{
result.SyncErrors++;
var error = $"Errore nella sincronizzazione della cancellazione per KeyValue: {deletion.KeyValue}";
result.Errors.Add(error);
_logger.LogWarning(error);
}
}
catch (Exception ex)
{
result.SyncErrors++;
var error = $"Errore durante la sincronizzazione della cancellazione per KeyValue: {deletion.KeyValue} - {ex.Message}";
result.Errors.Add(error);
_logger.LogError(ex, "Errore nella sincronizzazione della cancellazione per {KeyValue}",
deletion.KeyValue);
}
await ExecuteBatchedSalesforceDeletionsAsync(
salesforceClient, destinationEntity, pendingDeletions, options, result);
}
else
{
await ExecuteSequentialDeletionsAsync(
restClient, destinationEntity, pendingDeletions, options, result);
}
result.IsSuccess = result.SyncErrors == 0;
@@ -170,6 +113,183 @@ public class DeletionSyncService : IDeletionSyncService
return result;
}
/// <summary>
/// Esegue le cancellazioni in batch via Salesforce Composite API.
/// Riduce N round-trip HTTP a ceil(N/25) batch in parallelo.
/// </summary>
private async Task ExecuteBatchedSalesforceDeletionsAsync(
SalesforceServiceClient salesforceClient,
string destinationEntity,
List<KeyAssociation> pendingDeletions,
DeletionSyncOptions options,
DeletionSyncResult result)
{
// Per Mark serve MarkField e MarkValue: validazione preventiva (un solo log)
if (options.Action == DeletionAction.Mark &&
(string.IsNullOrEmpty(options.MarkField) || string.IsNullOrEmpty(options.MarkValue)))
{
const string err = "MarkField e MarkValue devono essere specificati per DeletionAction.Mark";
_logger.LogWarning(err);
foreach (var d in pendingDeletions)
{
result.SyncErrors++;
result.Errors.Add($"KeyValue: {d.KeyValue} - {err}");
}
return;
}
// Mappa entityId → KeyAssociation per ricostruire l'associazione dal risultato batch
var deletionsById = pendingDeletions
.Where(d => !string.IsNullOrEmpty(d.DestinationId))
.GroupBy(d => d.DestinationId)
.ToDictionary(g => g.Key, g => g.First()); // se duplicati, prima occorrenza
var entityIds = deletionsById.Keys.ToList();
if (entityIds.Count == 0)
return;
_logger.LogInformation("DELETION SYNC (Salesforce batched): {Count} record, action={Action}",
entityIds.Count, options.Action);
List<DataConnection.REST.Implementations.SalesforceServiceClient.CompositeOperationResult> batchResults;
try
{
switch (options.Action)
{
case DeletionAction.Delete:
batchResults = await salesforceClient.BatchDeleteEntitiesAsync(destinationEntity, entityIds);
break;
case DeletionAction.Deactivate:
// Aggiorna IsActive/Active = false in batch.
// Non sappiamo a priori quale dei due campi esista sull'SObject: proviamo IsActive,
// se Salesforce ritorna errore il record verrà segnalato come fallito.
var deactivateUpdates = entityIds.ToDictionary(
id => id,
_ => (Dictionary<string, object>)new Dictionary<string, object>
{
{ "IsActive", false }
});
batchResults = await salesforceClient.BatchUpdateEntitiesAsync(destinationEntity, deactivateUpdates);
break;
case DeletionAction.Mark:
batchResults = await salesforceClient.BatchPatchSingleFieldAsync(
destinationEntity, entityIds, options.MarkField!, options.MarkValue!);
break;
default:
_logger.LogWarning("DELETION SYNC: azione non supportata: {Action}", options.Action);
foreach (var d in pendingDeletions)
{
result.SyncErrors++;
result.Errors.Add($"KeyValue: {d.KeyValue} - Azione non supportata: {options.Action}");
}
return;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "DELETION SYNC: errore nell'esecuzione del batch Salesforce");
foreach (var d in pendingDeletions)
{
result.SyncErrors++;
result.Errors.Add($"KeyValue: {d.KeyValue} - {ex.Message}");
}
return;
}
// Aggiorna lo stato delle cancellazioni in DB in parallelo per i record sincronizzati con successo
var markSyncedTasks = new List<Task>();
foreach (var br in batchResults)
{
if (!deletionsById.TryGetValue(br.EntityId ?? string.Empty, out var deletion))
continue;
if (br.Success)
{
result.DeletedRecordsSynced++;
markSyncedTasks.Add(_credentialService.MarkDeletionSyncedAsync(deletion.Id));
_logger.LogDebug(
"DELETION SYNC: KeyValue={KeyValue}, DestinationId={DestinationId}, Action={Action} OK",
deletion.KeyValue, deletion.DestinationId, options.Action);
}
else
{
result.SyncErrors++;
var msg = $"KeyValue: {deletion.KeyValue} - {br.ErrorMessage ?? "Unknown error"}";
result.Errors.Add(msg);
_logger.LogWarning("DELETION SYNC fallita: {Msg}", msg);
}
}
if (markSyncedTasks.Count > 0)
await Task.WhenAll(markSyncedTasks);
}
/// <summary>
/// Fallback sequenziale per client REST non Salesforce.
/// </summary>
private async Task ExecuteSequentialDeletionsAsync(
IRestServiceClient restClient,
string destinationEntity,
List<KeyAssociation> pendingDeletions,
DeletionSyncOptions options,
DeletionSyncResult result)
{
foreach (var deletion in pendingDeletions)
{
try
{
bool syncSuccess = false;
string errorMessage = "";
switch (options.Action)
{
case DeletionAction.Delete:
syncSuccess = await DeleteRecordAsync(restClient, destinationEntity, deletion.DestinationId);
break;
case DeletionAction.Deactivate:
syncSuccess = await DeactivateRecordAsync(restClient, destinationEntity, deletion.DestinationId);
break;
case DeletionAction.Mark:
if (string.IsNullOrEmpty(options.MarkField) || string.IsNullOrEmpty(options.MarkValue))
{
errorMessage = "MarkField e MarkValue devono essere specificati per DeletionAction.Mark";
_logger.LogWarning(errorMessage);
result.Errors.Add($"KeyValue: {deletion.KeyValue} - {errorMessage}");
continue;
}
syncSuccess = await MarkRecordAsync(restClient, destinationEntity, deletion.DestinationId,
options.MarkField, options.MarkValue);
break;
default:
errorMessage = $"Azione di cancellazione non supportata: {options.Action}";
_logger.LogWarning(errorMessage);
result.Errors.Add($"KeyValue: {deletion.KeyValue} - {errorMessage}");
continue;
}
if (syncSuccess)
{
await _credentialService.MarkDeletionSyncedAsync(deletion.Id);
result.DeletedRecordsSynced++;
}
else
{
result.SyncErrors++;
result.Errors.Add($"Errore nella sincronizzazione della cancellazione per KeyValue: {deletion.KeyValue}");
}
}
catch (Exception ex)
{
result.SyncErrors++;
result.Errors.Add($"KeyValue: {deletion.KeyValue} - {ex.Message}");
_logger.LogError(ex, "Errore nella sincronizzazione della cancellazione per {KeyValue}", deletion.KeyValue);
}
}
}
/// <summary>
/// Elimina fisicamente un record dalla destinazione
/// </summary>
@@ -895,10 +895,45 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
// Crea lista indicizzata per mantenere il record number
var indexedRecords = sourceRecords.Select((record, index) => new { Record = record, RecordNumber = index + 1 }).ToList();
_logger.LogInformation("COMPOSITE SCHEDULED: Inizio analisi parallela di {RecordCount} record", indexedRecords.Count);
_logger.LogInformation("COMPOSITE SCHEDULED: Inizio analisi di {RecordCount} record", indexedRecords.Count);
var analysisStartTime = DateTime.UtcNow;
// Processa tutti i record in parallelo
// === STEP A: Bulk Pre-Discovery (1 query SQLite + poche SOQL IN invece di 2N+N) ===
// Pre-calcolo locale: source key per ogni record (operazione thread-safe)
var sourceKeyByRecordIndex = new Dictionary<int, string>(indexedRecords.Count);
foreach (var idx in indexedRecords)
{
var key = GenerateSourceKey(idx.Record, profile.SourceKeyField);
if (!string.IsNullOrEmpty(key))
sourceKeyByRecordIndex[idx.RecordNumber] = key;
}
Dictionary<string, KeyAssociation> associationsByKey = new(StringComparer.Ordinal);
if (currentUseRecordAssociations && !string.IsNullOrEmpty(profile.SourceKeyField) && sourceKeyByRecordIndex.Count > 0)
{
var commonRequest = new PreDiscoveryRequest
{
SourceKeyField = profile.SourceKeyField,
DestinationEntity = currentEntityName,
CredentialName = currentCredentialName,
DestinationKeyField = "Id",
FieldMappings = fieldMappings,
RestClient = restClient,
EnablePreDiscovery = true,
UseParallelMethod = true,
IsScheduledTransfer = true,
SourceType = profile.SourceType
};
associationsByKey = await _associationService.BatchFindOrCreateAssociationsAsync(
sourceKeyByRecordIndex.Values, commonRequest);
_logger.LogInformation("COMPOSITE SCHEDULED: Bulk Pre-Discovery completata - {Found}/{Total} associazioni risolte",
associationsByKey.Count, sourceKeyByRecordIndex.Count);
}
// === STEP B: Analisi locale parallela per decidere create/update/skip ===
// Nessuna chiamata DB o REST in questo loop — solo memoria.
var processingTasks = indexedRecords.Select(async indexedRecord =>
{
try
@@ -916,49 +951,7 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
// Analizza le associazioni per capire se aggiornare, creare o saltare
if (currentUseRecordAssociations && !string.IsNullOrEmpty(sourceKey))
{
_logger.LogDebug("COMPOSITE SCHEDULED: Cerco associazione per KeyValue: '{KeyValue}', Entity: '{Entity}', Credential: '{Credential}'",
sourceKey, currentEntityName, currentCredentialName);
// Cerca associazione esistente usando il metodo parallelo
var existingAssociation = await _dataConnectionCredentialService.FindKeyAssociationByValueParallelAsync(
sourceKey, currentEntityName, currentCredentialName);
// FALLBACK: Se non troviamo l'associazione con tutti i parametri, proviamo solo con il KeyValue
if (existingAssociation == null)
{
existingAssociation = await _dataConnectionCredentialService.FindKeyAssociationByValueParallelAsync(sourceKey);
if (existingAssociation != null)
{
// Verifica compatibilità
if (existingAssociation.DestinationEntity != currentEntityName ||
existingAssociation.RestCredentialName != currentCredentialName)
{
existingAssociation = null;
}
}
}
// 🔍 PRE-DISCOVERY: Usa il servizio centralizzato
if (existingAssociation == null && !string.IsNullOrEmpty(profile.SourceKeyField))
{
var preDiscoveryRequest = new PreDiscoveryRequest
{
SourceKey = sourceKey,
SourceKeyField = profile.SourceKeyField,
DestinationEntity = currentEntityName,
CredentialName = currentCredentialName,
DestinationKeyField = "Id",
FieldMappings = fieldMappings,
RestClient = restClient,
CurrentDataHash = currentDataHash,
EnablePreDiscovery = true,
UseParallelMethod = true, // Usa metodi paralleli thread-safe
IsScheduledTransfer = true,
SourceType = profile.SourceType
};
existingAssociation = await _associationService.FindOrCreateAssociationAsync(preDiscoveryRequest);
}
associationsByKey.TryGetValue(sourceKey, out var existingAssociation);
if (existingAssociation != null && existingAssociation.IsActive)
{