[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:
@@ -126,13 +126,19 @@ namespace DataConnection.REST.Implementations
|
||||
{
|
||||
_accessToken = tokenResponse.AccessToken;
|
||||
_instanceUrl = tokenResponse.InstanceUrl;
|
||||
_tokenExpiry = DateTime.UtcNow.AddSeconds(3600); // Salesforce doesn't always return expires_in
|
||||
|
||||
// Se Salesforce restituisce expires_in (es. client_credentials), usalo con un margine di 60s;
|
||||
// altrimenti (password flow non restituisce expires_in) fallback al default 1h conservativo.
|
||||
var ttlSeconds = tokenResponse.ExpiresIn.HasValue && tokenResponse.ExpiresIn.Value > 60
|
||||
? tokenResponse.ExpiresIn.Value - 60
|
||||
: 3600;
|
||||
_tokenExpiry = DateTime.UtcNow.AddSeconds(ttlSeconds);
|
||||
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
|
||||
_logger.LogInformation("Salesforce authentication ({Flow}) successful. InstanceUrl={InstanceUrl}, TokenExpiry={Expiry}",
|
||||
flowName, _instanceUrl, _tokenExpiry.ToLocalTime());
|
||||
_logger.LogInformation("Salesforce authentication ({Flow}) successful. InstanceUrl={InstanceUrl}, TokenExpiry={Expiry} (TTL {Ttl}s, server expires_in={ExpiresIn})",
|
||||
flowName, _instanceUrl, _tokenExpiry.ToLocalTime(), ttlSeconds, tokenResponse.ExpiresIn?.ToString() ?? "null");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1616,6 +1622,13 @@ namespace DataConnection.REST.Implementations
|
||||
|
||||
[JsonPropertyName("signature")]
|
||||
public string Signature { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Durata di validità del token in secondi. Presente solo in alcuni flussi OAuth Salesforce
|
||||
/// (es. client_credentials, JWT bearer); assente in altri (es. username/password) — in tal caso 0/null.
|
||||
/// </summary>
|
||||
[JsonPropertyName("expires_in")]
|
||||
public int? ExpiresIn { get; set; }
|
||||
}
|
||||
|
||||
private class SalesforceSObjectsResponse
|
||||
@@ -2161,5 +2174,157 @@ namespace DataConnection.REST.Implementations
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Elimina N record SObject in batch tramite Salesforce Composite API.
|
||||
/// Riduce N HTTP calls a ceil(N/25), eseguite in parallelo.
|
||||
/// </summary>
|
||||
/// <param name="entityName">Nome SObject (es. "Account").</param>
|
||||
/// <param name="entityIds">Lista di Id da eliminare.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Risultato per ogni Id (Success/ErrorMessage).</returns>
|
||||
public async Task<List<CompositeOperationResult>> BatchDeleteEntitiesAsync(
|
||||
string entityName,
|
||||
List<string> entityIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsAuthenticated())
|
||||
{
|
||||
_logger.LogDebug("Error: Not authenticated to Salesforce. Cannot perform batch delete.");
|
||||
return new List<CompositeOperationResult>();
|
||||
}
|
||||
|
||||
if (entityIds == null || entityIds.Count == 0)
|
||||
return new List<CompositeOperationResult>();
|
||||
|
||||
const int maxBatchSize = 25;
|
||||
|
||||
var batches = new List<(List<string> batch, int startIndex, int batchNumber)>();
|
||||
for (int i = 0; i < entityIds.Count; i += maxBatchSize)
|
||||
{
|
||||
var chunk = entityIds.Skip(i).Take(maxBatchSize).ToList();
|
||||
batches.Add((chunk, i, (i / maxBatchSize) + 1));
|
||||
}
|
||||
|
||||
_logger.LogDebug($"--- BatchDelete: {entityIds.Count} record in {batches.Count} batch (parallel) ---");
|
||||
|
||||
var batchTasks = batches.Select(async b =>
|
||||
await ExecuteDeleteBatchAsync(entityName, b.batch, b.startIndex, cancellationToken));
|
||||
|
||||
var batchResults = await Task.WhenAll(batchTasks);
|
||||
|
||||
var allResults = new List<CompositeOperationResult>();
|
||||
foreach (var r in batchResults) allResults.AddRange(r);
|
||||
|
||||
_logger.LogDebug($"All delete batches completed: {allResults.Count(r => r.Success)} success, {allResults.Count(r => !r.Success)} failed");
|
||||
return allResults;
|
||||
}
|
||||
|
||||
private async Task<List<CompositeOperationResult>> ExecuteDeleteBatchAsync(
|
||||
string entityName,
|
||||
List<string> batch,
|
||||
int startIndex,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var compositeUri = $"{_instanceUrl}/services/data/v60.0/composite/";
|
||||
|
||||
var compositeRequest = new SalesforceCompositeRequest();
|
||||
for (int i = 0; i < batch.Count; i++)
|
||||
{
|
||||
var entityId = batch[i];
|
||||
compositeRequest.CompositeRequest.Add(new SalesforceCompositeSubRequest
|
||||
{
|
||||
Method = "DELETE",
|
||||
Url = $"/services/data/v60.0/sobjects/{entityName}/{entityId}",
|
||||
ReferenceId = $"delete_{startIndex + i}"
|
||||
// Body intenzionalmente null per DELETE
|
||||
});
|
||||
}
|
||||
|
||||
var jsonContent = new StringContent(
|
||||
JsonSerializer.Serialize(compositeRequest, SalesforceJsonOptions),
|
||||
System.Text.Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
var response = await _httpClient.PostAsync(compositeUri, jsonContent, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogDebug($"Salesforce Batch Delete failed: {response.StatusCode} - {errorContent}");
|
||||
|
||||
return batch.Select((id, idx) => new CompositeOperationResult
|
||||
{
|
||||
ReferenceId = $"delete_{startIndex + idx}",
|
||||
EntityId = id,
|
||||
Success = false,
|
||||
ErrorMessage = $"Batch operation failed: {response.StatusCode} - {errorContent}"
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var compositeResponse = JsonSerializer.Deserialize<SalesforceCompositeResponse>(responseContent, SalesforceJsonOptions);
|
||||
|
||||
var results = new List<CompositeOperationResult>();
|
||||
if (compositeResponse?.CompositeResponse != null)
|
||||
{
|
||||
for (int i = 0; i < compositeResponse.CompositeResponse.Count; i++)
|
||||
{
|
||||
var sub = compositeResponse.CompositeResponse[i];
|
||||
var originalId = i < batch.Count ? batch[i] : string.Empty;
|
||||
|
||||
var result = new CompositeOperationResult
|
||||
{
|
||||
ReferenceId = sub.ReferenceId,
|
||||
EntityId = originalId,
|
||||
HttpStatusCode = sub.HttpStatusCode,
|
||||
// 204 No Content è la risposta di successo standard per DELETE
|
||||
Success = sub.HttpStatusCode >= 200 && sub.HttpStatusCode < 300
|
||||
};
|
||||
|
||||
if (!result.Success)
|
||||
result.ErrorMessage = sub.Body?.ToString() ?? "Unknown error";
|
||||
|
||||
results.Add(result);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug($"Error during Salesforce batch delete: {ex.Message}");
|
||||
return batch.Select((id, idx) => new CompositeOperationResult
|
||||
{
|
||||
ReferenceId = $"delete_{startIndex + idx}",
|
||||
EntityId = id,
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Bulk PATCH per aggiornare un singolo campo (es. campo di tombstone/mark-as-deleted) su N record.
|
||||
/// </summary>
|
||||
public Task<List<CompositeOperationResult>> BatchPatchSingleFieldAsync(
|
||||
string entityName,
|
||||
IEnumerable<string> entityIds,
|
||||
string fieldName,
|
||||
object fieldValue,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var updates = entityIds
|
||||
.Where(id => !string.IsNullOrEmpty(id))
|
||||
.ToDictionary(
|
||||
id => id,
|
||||
_ => (Dictionary<string, object>)new Dictionary<string, object> { { fieldName, fieldValue } });
|
||||
|
||||
return BatchUpdateEntitiesAsync(entityName, updates, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user