-----------------------------------

Acquista i software ArcGIS tramite Studio A&T srl, rivenditore autorizzato dei prodotti Esri.

I migliori software GIS, il miglior supporto tecnico!

I migliori software GIS, il miglior supporto tecnico!
Azienda operante nel settore GIS dal 2001, specializzata nell’utilizzo della tecnologia ArcGIS e aderente ai programmi Esri Italia Business Network ed Esri Partner Network

-----------------------------------



Visualizzazione post con etichetta SQL Server. Mostra tutti i post
Visualizzazione post con etichetta SQL Server. Mostra tutti i post

giovedì 8 ottobre 2026

SqlClient Pool V2: internals, benchmark e ThreadPool starvation con .NET 10

Deep dive • C# / .NET 10 • SQL Server / Azure SQL
Verifica delle fonti: 8 ottobre 2026. Implementazione analizzata: tag v7.1.0 di dotnet/SqlClient.

Il connection pool è uno dei componenti più sottovalutati di un'applicazione .NET. Finché le connessioni sono disponibili, sembra una cache quasi invisibile. Quando un processo parte, una nuova replica riceve traffico o molte richieste arrivano insieme, diventa invece un sistema di coordinamento: decide chi può riutilizzare una sessione, chi può crearne una e chi deve aspettare.

SqlClient Pool V2 cambia soprattutto il modo in cui il pool cresce e gestisce l'attesa. Per valutarlo seriamente dobbiamo separare quattro fenomeni: apertura fisica, checkout di una connessione già esistente, saturazione delle sessioni disponibili e disponibilità dei worker del runtime. Un unico numero di throughput non descrive questi quattro comportamenti.

Dalla cache di connessioni al sistema di ammissione

Una SqlConnection è l'oggetto logico usato dall'applicazione; la sessione fisica sottostante può sopravvivere a quel singolo oggetto e servire molte operazioni successive. L'apertura fisica comporta rete, negoziazione e autenticazione. Il checkout tenta invece di assegnare una risorsa già disponibile. Chiamare OpenAsync() non significa necessariamente aprire una nuova sessione sul server.

Il pool appartiene al processo. La chiave dipende dalla stringa di connessione e può includere identità, credenziali e configurazione dei token. Stringhe con ordine diverso delle keyword possono produrre pool distinti. Perciò Max Pool Size=100 non è un limite complessivo dell'applicazione distribuita. Dieci processi con tre pool ciascuno hanno un budget teorico di 3.000 sessioni. [3]

Questo suggerisce una prima scelta architetturale: centralizzare la costruzione delle stringhe e mantenere stabile la configurazione di autenticazione. Inserire un identificatore di richiesta in Application Name per migliorare il tracing può distruggere il riuso. L'identificatore della singola richiesta appartiene alla telemetria, non alla configurazione del pool.

Che cosa introduce davvero Pool V2

Microsoft ha presentato Pool V2 il 6 ottobre 2026. È disponibile da Microsoft.Data.SqlClient 7.1.0 e va attivato esplicitamente. La creazione di nuove connessioni può procedere in parallelo; il coordinamento usa Channels. Resta però una limitazione decisiva: alcuni passaggi di rete dell'apertura fisica sono sincroni e, nel percorso asincrono, vengono eseguiti da worker del ThreadPool. [1]

La firma asincrona dell'API e l'asincronia completa della sua implementazione sono proprietà diverse. La prima permette al chiamante di attendere senza bloccare il proprio flusso; la seconda determina quante risorse occupa il driver mentre lavora.

Attivazione e rollback

Impostare lo switch prima di utilizzare SqlClient: i valori vengono memorizzati internamente. In ASP.NET Core posizionarlo prima della creazione del builder. Per un confronto A/B usare processi separati; per il rollback modificare il valore e riavviare. Lo switch non è una keyword della connection string. [2]

AppContext.SetSwitch(
    "Switch.Microsoft.Data.SqlClient.UseConnectionPoolV2", true);

var builder = WebApplication.CreateBuilder(args);
// Registrazione DbContext, servizi e applicazione...

Non serve riscrivere le query. Con EF Core o Dapper, verificare però il pacchetto SqlClient effettivamente risolto: una dipendenza transitiva precedente alla 7.1 non acquisisce questa funzionalità perché il progetto compila per .NET 10. DbContext pooling e connection pooling sono inoltre meccanismi diversi: riutilizzare un contesto non equivale a conservare permanentemente la sua connessione SQL.

Dentro WaitHandleDbConnectionPool

Nel tag analizzato, WaitHandleDbConnectionPool coordina disponibilità, errore e creazione con handle distinti. PoolWaitHandles costruisce il semaforo delle risorse e un CreationSemaphore con capacità uno. TryGetConnection usa WaitHandle.WaitAny: una connessione restituita può sbloccare il chiamante; in alternativa il permesso di creazione consente un nuovo login.

La capacità uno serializza la creazione all'interno del pool. Non serializza tutte le query dell'applicazione e non impedisce di usare contemporaneamente sessioni già assegnate.

Gli open asincroni non risolti subito vengono rappresentati da PendingGetConnection, con owner, scadenza e completion. WaitForPendingOpen drena la coda; un guard con Interlocked evita più consumatori attivi. Il driver avvia un Thread di background per questo lavoro: il commento del metodo specifica che può bloccare a lungo e non deve essere un thread del pool gestito. Questa soluzione separa il chiamante dall'attesa, ma conserva un coordinamento seriale. [5]

Dentro ChannelDbConnectionPool

ChannelDbConnectionPool ha un fast path, TryGetPooledConnectionInline, che può restituire una connessione senza dispatch sul ThreadPool. Nel percorso lento, GetInternalConnection cerca prima una risorsa transazionale compatibile, poi una idle; se necessario tenta OpenNewInternalConnection. Se non ottiene una risorsa, attende sul channel.

Il percorso async usa ReadAsync; quello sync usa ReadChannelSyncOverAsync e blocca il chiamante. La fairness descritta dal codice riguarda i lettori in attesa, non una garanzia che tutte le chiamate applicative terminino nell'ordine di arrivo: fast path, nuove aperture, cancellazioni e scheduling interferiscono con quell'ordine.

Per gli open async non risolti inline, TryGetConnection avvia un Task.Run. Nel percorso di creazione, ConnectionFactory.CreatePooledConnection resta sincrono. Il codice controlla il timeout prima della chiamata, ma la factory non riceve quel cancellation token. Una cancellazione del chiamante non dimostra quindi che ogni lavoro fisico sia già terminato. [4]

Le invarianti che rendono possibile la concorrenza

La lettura ingegneristica del percorso è questa: prima di avviare un login bisogna riservare capacità; in caso di fallimento bisogna liberarla; dopo un clear bisogna evitare che risorse obsolete tornino disponibili; una risorsa restituita deve svegliare chi può usarla. Queste condizioni contano più del tipo di coda scelto.

Nel codice osservato il tracking passa da ConnectionPoolSlots, mentre IdleConnectionChannel incapsula il flusso delle risorse disponibili. La generazione del clear permette di riconoscere connessioni precedenti al cambio di generazione. Il channel può ricevere anche segnali null per far ritentare un'acquisizione: un risveglio non implica necessariamente una connessione pronta. L'eventuale ConcurrencyLimiter interno viene iniettato nel costruttore; il suo valore predefinito è assente. Non è un'impostazione pubblica da aggiungere arbitrariamente alla connection string. Queste sono osservazioni sul tag analizzato, non un contratto delle API pubbliche. [4]

Conseguenza pratica. Un'applicazione può usare un proprio limite di concorrenza anche quando il pool ha capacità residua. Il numero di sessioni consentite, il numero di login contemporanei e il numero di operazioni SQL ammesse sono tre budget diversi.

Un modello quantitativo: crescita, occupazione e code

I modelli seguenti sono strumenti di ragionamento, non misure del driver. Se servono N sessioni nuove e ogni apertura fisica dura mediamente L, una crescita completamente seriale ha un costo indicativo N × L. Con C aperture concorrenti, il termine ideale diventa circa ceil(N/C) × L. Questo limite ideale trascura autenticazione, scheduling, contesa sul server e distribuzione delle latenze.

Parallelizzare sposta il collo di bottiglia: il sistema può passare dall'attesa del permesso di creazione all'attesa di worker, autenticazione o risorse SQL. La presenza di 100 slot liberi nel pool non garantisce che 100 login simultanei siano la scelta migliore.

Per il regime stabile, la legge di Little suggerisce un'altra relazione: sessioni occupate medie ≈ operazioni al secondo × durata media del possesso. A 800 operazioni/s e 40 ms di possesso servono in media circa 32 sessioni; a 250 ms diventano 200. Sono esempi matematici, non risultati di benchmark. La coda richiede ulteriore margine e dipende da burst, variabilità e distribuzione delle durate.

Il possesso include tutto ciò che accade tra checkout e restituzione: lettura, mapping, transazione, eventuali chiamate HTTP e attese applicative. Ridurre una chiamata esterna fatta mentre si tiene aperta una connessione può migliorare la capacità più di un aumento del pool.

I benchmark Microsoft: che cosa possiamo concludere

Cold start Linux, 100 chiamantiPool legacyPool V2Rapporto
Open sincrono630,1 ms117,5 ms5,4×
Open asincrono604,1 ms92,0 ms6,6×

Sono risultati pubblicati da Microsoft, non misure eseguite per questo articolo. Misurano il completamento dell'intera rampa, con connessioni trattenute fino alla conclusione di tutte le aperture. Il confronto usa runtime .NET 9.0.19, SQL Server locale alla VM e Max Pool Size=200. Il laboratorio seguente usa .NET 10 e un harness diverso: non è una replica numerica di quei risultati. [1] [6]

La suite ufficiale include anche riuso immediato, query in regime stabile, possesso variabile e open sincroni sul ThreadPool con pool saturo. Per confrontare le implementazioni usa processi distinti. Questi scenari aiutano a verificare regressioni oltre il cold start. [6]

La domanda corretta per la propria applicazione è: quale componente della latenza rappresenta l'apertura fisica? Se il 95% del tempo è query e trasferimento dati, accelerare il restante 5% di 6,6 volte limita il miglioramento ideale complessivo a circa 1,04 volte. Questa applicazione della legge di Amdahl impedisce di trasformare un benchmark di startup in una promessa sul throughput di tutte le query.

Laboratorio riproducibile con C# e .NET 10

Il laboratorio usa un progetto console, SqlClient 7.1.0 fissato e SQL Server raggiungibile. Non richiede uno schema: esegue SELECT 1. Si può usare un'istanza Developer locale o un database di test in Azure SQL. Usare lo stesso endpoint e la stessa autenticazione nelle due varianti.

Stato della verifica. Il codice è fornito integralmente per riprodurre l'esperimento. È stato controllato staticamente, ma non compilato né eseguito nell'ambiente di preparazione dell'articolo, che non dispone di SDK .NET né di un endpoint SQL configurato. I risultati del laboratorio devono essere prodotti sul proprio ambiente; nessuna tabella seguente contiene misure locali inventate.

1. Progetto

dotnet new console -n PoolV2Lab -f net10.0
cd PoolV2Lab

Sostituire PoolV2Lab.csproj con:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.Data.SqlClient" Version="7.1.0" />
  </ItemGroup>
</Project>

Eseguire dotnet restore, conservare packages.lock.json e usare dotnet restore --locked-mode nei successivi ambienti. Registrare dotnet --info e dotnet list package --include-transitive. Per fissare anche l'SDK, creare un global.json con la versione realmente installata tramite dotnet new globaljson --sdk-version VERSIONE_INSTALLATA --roll-forward disable.

2. Program.cs completo

Parametri: implementazione v1|v2, scenario cold|hot|steady, chiamanti, dimensione massima pool, iterazioni, possesso simulato in millisecondi, API async|sync, minimo worker opzionale, attesa iniziale opzionale per collegare gli strumenti.

using System.Collections.Concurrent;
using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices;
using Microsoft.Data.SqlClient;

if (args.Length < 7)
    throw new ArgumentException(
        "v1|v2 cold|hot|steady callers pool iterations holdMs async|sync [minWorkers] [attachSeconds]");
bool v2 = args[0] switch {
    "v1" => false, "v2" => true, _ => throw new ArgumentException("pool version")
};
AppContext.SetSwitch("Switch.Microsoft.Data.SqlClient.UseConnectionPoolV2", v2);
string scenario = args[1];
int n = int.Parse(args[2]), max = int.Parse(args[3]);
int iterations = int.Parse(args[4]), holdMs = int.Parse(args[5]);
bool sync = args[6] switch {
    "sync" => true, "async" => false, _ => throw new ArgumentException("API mode")
};
if (scenario is not ("cold" or "hot" or "steady") ||
    n < 1 || max < 1 || iterations < 1 || holdMs < 0)
    throw new ArgumentException("Invalid scenario or numeric parameters");
if (scenario == "cold" && n > max)
    throw new ArgumentException("Cold ramp holds every connection: callers must be <= pool");
if (args.Length > 7) {
    ThreadPool.GetMinThreads(out _, out int ioMin);
    if (!ThreadPool.SetMinThreads(int.Parse(args[7]), ioMin))
        throw new ArgumentException("Invalid minWorkers");
}
string input = Environment.GetEnvironmentVariable("POOLLAB_CS")
    ?? throw new InvalidOperationException("Set POOLLAB_CS");
string cs = new SqlConnectionStringBuilder(input) {
    Pooling = true, MinPoolSize = 0, MaxPoolSize = max,
    ConnectTimeout = 30, ApplicationName = "NicoGIS.PoolV2Lab"
}.ConnectionString;
var rows = new ConcurrentQueue<Sample>();
string runId = Guid.NewGuid().ToString("N");
ThreadPool.GetMinThreads(out int workers, out _);
Console.WriteLine($"PID={Environment.ProcessId} run={runId} poolV2={v2} " +
    $"scenario={scenario} api={(sync ? "sync" : "async")} callers={n} pool={max} " +
    $"iterations={iterations} holdMs={holdMs} minWorkers={workers}");
Console.WriteLine($"runtime={RuntimeInformation.FrameworkDescription} " +
    $"os={RuntimeInformation.OSDescription} cpu={Environment.ProcessorCount} " +
    $"driver={typeof(SqlConnection).Assembly.GetName().Version}");
if (args.Length > 8) await Task.Delay(TimeSpan.FromSeconds(int.Parse(args[8])));

// Preflight includes JIT/authentication setup; then cold tests clear the pool.
await using (var probe = new SqlConnection(cs)) {
    await probe.OpenAsync();
    await using var cmd = new SqlCommand("SELECT 1", probe);
    await cmd.ExecuteScalarAsync();
}
SqlConnection.ClearAllPools();

// Open and hold all requested connections. Dispose only after all attempts settle.
async Task Ramp(int count, int iteration, bool record) {
    var gate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
    long releaseTicks = 0;
    async Task<SqlConnection> OpenOne() {
        await gate.Task;
        return await OpenAfterGate();
    }
    async Task<SqlConnection> OpenAfterGate() {
        var c = new SqlConnection(cs);
        long begin = Stopwatch.GetTimestamp();
        try {
            if (sync) c.Open(); else await c.OpenAsync();
            if (record) rows.Enqueue(new(iteration, true,
                Ms(begin), Ms(releaseTicks), ""));
            return c;
        } catch (Exception ex) {
            c.Dispose();
            if (record) rows.Enqueue(new(iteration, false,
                Ms(begin), Ms(releaseTicks), ex.GetType().Name));
            throw;
        }
    }
    Task<SqlConnection>[] tasks = Enumerable.Range(0, count).Select(_ => sync
        ? Task.Factory.StartNew(() => {
            gate.Task.GetAwaiter().GetResult();
            return OpenAfterGate().GetAwaiter().GetResult();
        }, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default)
        : OpenOne()).ToArray();
    releaseTicks = Stopwatch.GetTimestamp();
    gate.SetResult(true);
    try { await Task.WhenAll(tasks); }
    finally {
        foreach (var t in tasks)
            if (t.IsCompletedSuccessfully) t.Result.Dispose();
    }
}

bool rampFailed = false;
if (scenario != "cold") await Ramp(max, -1, false);
long start = Stopwatch.GetTimestamp();
if (scenario == "cold") {
    for (int i = 0; i < iterations; i++) {
        // Exclude clear from the per-ramp clock; total wall time still includes it.
        SqlConnection.ClearAllPools();
        long t = Stopwatch.GetTimestamp();
        try { await Ramp(n, i, true); }
        catch (Exception ex) { rampFailed = true; Console.WriteLine($"rampError={ex.GetType().Name}"); }
        Console.WriteLine(FormattableString.Invariant($"ramp={i} batchMs={Ms(t):F3}"));
        if (rampFailed) break; // Avoid benchmarking cached authentication failures.
    }
} else {
    var gate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
    Task[] tasks = Enumerable.Range(0, n).Select(_ => Task.Run(async () => {
        await gate.Task;
        for (int i = 0; i < iterations; i++) {
            long t = Stopwatch.GetTimestamp();
            double openMs = double.NaN;
            bool ok = false;
            string error = "";
            try {
                // Includes disposal in totalMs because scope ends before finally.
                using var c = new SqlConnection(cs);
                if (sync) c.Open(); else await c.OpenAsync();
                openMs = Ms(t);
                if (scenario == "steady") {
                    using var cmd = new SqlCommand("SELECT 1", c) { CommandTimeout = 30 };
                    if (sync) cmd.ExecuteScalar(); else await cmd.ExecuteScalarAsync();
                }
                if (holdMs > 0) {
                    if (sync) Thread.Sleep(holdMs); else await Task.Delay(holdMs);
                }
                ok = true;
            } catch (Exception ex) { error = ex.GetType().Name; }
            finally { rows.Enqueue(new(i, ok, openMs, Ms(t), error)); }
        }
    })).ToArray();
    gate.SetResult(true);
    await Task.WhenAll(tasks);
}
double wallMs = Ms(start);
Sample[] samples = rows.ToArray();
double[] opens = samples.Where(x => x.Ok && double.IsFinite(x.OpenMs))
    .Select(x => x.OpenMs).Order().ToArray();
double[] totals = samples.Where(x => x.Ok).Select(x => x.TotalMs).Order().ToArray();
double P(double[] values, double p) => values.Length == 0 ? double.NaN :
    values[Math.Clamp((int)Math.Ceiling(p * values.Length) - 1, 0, values.Length - 1)];
Console.WriteLine(FormattableString.Invariant(
    $"ok={samples.Count(x => x.Ok)} errors={samples.Count(x => !x.Ok)} wallMs={wallMs:F3} successPerSecond={samples.Count(x => x.Ok) * 1000 / wallMs:F2} openP50={P(opens,.50):F3} openP95={P(opens,.95):F3} openP99={P(opens,.99):F3} totalP95={P(totals,.95):F3}"));
string file = $"samples-{args[0]}-{scenario}-{args[6]}-{runId}.csv";
using (var writer = new StreamWriter(file)) {
    writer.WriteLine("iteration,ok,open_ms,total_ms,error");
    foreach (var s in samples)
        writer.WriteLine(string.Join(",", s.Iteration, s.Ok ? 1 : 0,
            s.OpenMs.ToString("F6", CultureInfo.InvariantCulture),
            s.TotalMs.ToString("F6", CultureInfo.InvariantCulture), s.Error));
}
Console.WriteLine($"csv={file}");
if (samples.Any(x => !x.Ok) || rampFailed) Environment.ExitCode = 2;
double Ms(long ticks) => Stopwatch.GetElapsedTime(ticks).TotalMilliseconds;
record Sample(int Iteration, bool Ok, double OpenMs, double TotalMs, string Error);

3. Configurazione ed esecuzione

PowerShell, con credenziali di un database di laboratorio:

$env:POOLLAB_CS = 'Server=localhost;Database=master;Integrated Security=true;Encrypt=true;TrustServerCertificate=true'
dotnet restore
dotnet build -c Release

# Rampa fredda async: 100 sessioni trattenute, limite pool 200.
dotnet bin/Release/net10.0/PoolV2Lab.dll v1 cold 100 200 10 0 async
dotnet bin/Release/net10.0/PoolV2Lab.dll v2 cold 100 200 10 0 async

# Rampa fredda sync: thread dedicati, separata dall'esperimento starvation.
dotnet bin/Release/net10.0/PoolV2Lab.dll v1 cold 100 200 10 0 sync
dotnet bin/Release/net10.0/PoolV2Lab.dll v2 cold 100 200 10 0 sync

# Riuso senza query e senza possesso artificiale.
dotnet bin/Release/net10.0/PoolV2Lab.dll v1 hot 50 100 1000 0 async
dotnet bin/Release/net10.0/PoolV2Lab.dll v2 hot 50 100 1000 0 async

# Regime stabile: più chiamanti che sessioni, SELECT 1 e possesso di 20 ms.
dotnet bin/Release/net10.0/PoolV2Lab.dll v1 steady 100 20 100 20 async
dotnet bin/Release/net10.0/PoolV2Lab.dll v2 steady 100 20 100 20 async

TrustServerCertificate=true è qui una concessione al certificato del laboratorio locale. Con Azure SQL usare il nome completo dell'endpoint e la verifica del certificato, quindi TrustServerCertificate=false. Con SQL authentication sostituire l'identità integrata con le credenziali di test. Non stampare la stringa nei risultati.

4. Che cosa misuriamo, esattamente

  • Cold: dopo il preflight si svuota il pool prima di ogni rampa. Ogni successo trattiene la connessione fino a quando tutti i tentativi sono conclusi. Ciò forza N aperture fisiche invece del riuso accidentale di poche sessioni. callers deve essere minore o uguale a pool.
  • Hot: il warmup apre e trattiene contemporaneamente Max Pool Size sessioni, poi le restituisce. Aprire e chiudere serialmente cento volte non garantirebbe cento sessioni fisiche. La fase misurata non esegue query.
  • Steady: il pool è caldo; ogni worker ripete checkout, SELECT 1, possesso simulato e dispose. Il numero dei chiamanti può superare gli slot.

open_ms è il tempo osservato attorno all'apertura logica; può includere attesa del pool, lavoro del driver e scheduling. Non isola il costo puro del channel. Nel cold, total_ms arriva dal rilascio del gate al completamento dell'open del singolo chiamante; non include il dispose collettivo. batchMs misura invece la rampa fino alla restituzione di tutte le sessioni. Negli scenari hot e steady, total_ms va dall'inizio dell'operazione al termine del dispose.

successPerSecond è utile in hot e steady. Nel cold il wall time globale include i clear tra rampe: confrontare soprattutto la distribuzione dei batchMs, che li esclude. Il preflight rende freddo il pool, non DNS, JIT, provider di autenticazione o processo. Per misurare la readiness reale dell'applicazione occorre anche un esperimento senza preflight, da processo nuovo.

I percentili mostrati sono nearest-rank sui soli successi. Gli errori restano nel CSV e nel conteggio: un run con errori non va dichiarato più veloce perché ha meno campioni lenti. Con 100 osservazioni p99 è una statistica molto fragile. Per le rampe raccogliere più processi e riportare mediana e variabilità tra processi.

Un protocollo di benchmark che resiste alle conclusioni facili

Questo harness è un esperimento di carico controllato, non un microbenchmark isolato con BenchmarkDotNet. Usa allocazioni, timestamp e raccolta di campioni; tali costi fanno parte della misura. Sono presenti in entrambe le varianti, ma possono pesare molto nel test hot. Per differenze di pochi microsecondi affiancare la suite ufficiale e strumenti di profiling.

  1. Fissare driver, SDK, runtime, architettura, GC, endpoint, autenticazione e SNI. Non modificare altri switch mentre si confronta V1 con V2.
  2. Eseguire almeno cinque processi per variante, alternando l'ordine V1/V2 e poi V2/V1. Evitare che la seconda variante benefici sempre dello stato del server lasciato dalla prima.
  3. Separare chiamanti 10, 25, 50 e 100; poi pool inferiori, uguali e superiori alla domanda. Un run diverso per ciascuna combinazione.
  4. Registrare CPU e memoria di client e server, runtime counters, errori e latenza. Conservare i CSV e l'output con tutti i parametri.
  5. Ripetere con rete locale e con l'endpoint reale. Se si introduce ritardo con un proxy o traffic shaping, documentare direzione, jitter e perdita: un RTT aggiunto non è un ritardo uniforme di autenticazione.
  6. Separare sessioni appena create da riuso caldo. Non usare ClearAllPools a intervalli nel test steady: si trasformerebbe il workload in churn.
ScenarioIndicatore principaleDomanda
Cold rampMediana e dispersione batchMsQuanto rapidamente otteniamo N sessioni?
HotOperazioni/s e open p95Il nuovo coordinamento introduce overhead nel riuso?
Steady saturoTotal p95/p99, errori, throughputCome cresce la coda quando la capacità non basta?
Rete lenta e pool freddoBatchMs, worker e queue lengthLa crescita parallela aumenta pressione sui thread?
Sync sul ThreadPoolStack, worker, latenza del probeI chiamanti bloccanti ritardano lavoro non SQL?

Il limite dei worker a ciclo chiuso

In steady ogni worker avvia l'operazione successiva dopo la precedente. Il modello è a ciclo chiuso: quando il sistema rallenta, rallenta anche l'offerta di nuove operazioni. Non misura quindi la latenza di una coda alimentata da un tasso esterno costante e non elimina il problema della coordinated omission.

Per la verifica di uno SLO HTTP aggiungere un generatore a tasso di arrivo controllato su un endpoint reale, in un processo separato. Registrare il momento di arrivo previsto, l'arrivo effettivo e la risposta. Sotto overload, stabilire prima una policy di rifiuto o scadenza: nessun pool può rendere stabile una coda con domanda permanentemente superiore alla capacità di servizio.

ThreadPool starvation: il rischio da misurare

La starvation si verifica quando il runtime non ha worker disponibili per eseguire lavoro pronto. La CPU può restare bassa perché molti thread aspettano. La crescita del numero di thread, un backlog persistente e stack bloccati sono indizi da leggere insieme; un singolo contatore elevato non è una diagnosi. Le euristiche moderne riducono alcuni effetti del blocking, ma non rendono gratuito quel blocking. [7]

Per Pool V2 formuliamo un'ipotesi sperimentale precisa: durante la crescita del pool con rete lenta, molti login fisici possono occupare worker simultaneamente; le continuation applicative competono per gli stessi worker. Il completamento della rampa può migliorare rispetto alla serializzazione e, contemporaneamente, la latenza di lavoro estraneo a SQL può peggiorare. È una possibilità da verificare, non un esito universale.

Due esperimenti diversi, due cause diverse

Esperimento A — limite del driver. Usare cold ... async con endpoint ad alta latenza e minimo worker invariato. Confrontare worker, coda e batch time delle due implementazioni. Per renderlo osservabile raccogliere la trace dall'avvio o aumentare le rampe; il burst locale potrebbe finire prima del primo campionamento.

Esperimento B — blocking applicativo. Usare steady ... sync. Qui Task.Run esegue Open(), query sincrona e Thread.Sleep: il blocking viene introdotto intenzionalmente dal laboratorio. Non attribuire al driver tutti i thread bloccati osservati in questo scenario. Il test è utile proprio per riconoscere la differenza negli stack.

# 30 secondi prima del test per collegare i tool al PID stampato.
dotnet bin/Release/net10.0/PoolV2Lab.dll v2 steady 100 10 100 50 sync 4 30

# Controlli: stessa configurazione, minimo worker diverso o API async.
dotnet bin/Release/net10.0/PoolV2Lab.dll v2 steady 100 10 100 50 sync 32 30
dotnet bin/Release/net10.0/PoolV2Lab.dll v2 steady 100 10 100 50 async 4 30

# Ripetere ogni configurazione anche con v1.

SetMinThreads(4, ...) non impone quattro thread massimi e non precrea necessariamente tutti i thread richiesti. Modifica una soglia del runtime. Conservare anche un run senza override; ripetere in un processo nuovo per evitare che un esperimento precedente abbia già fatto crescere il ThreadPool.

Strumenti e segnali

dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-stack
dotnet tool install --global dotnet-trace

# Sostituire 12345 con il PID stampato dal laboratorio.
dotnet-counters monitor --process-id 12345 --counters System.Runtime
dotnet-stack report --process-id 12345
dotnet-trace collect --process-id 12345 --duration 00:00:30

Rilevare anche le versioni dei tool. La trace generica è un punto di partenza: per attribuire precisamente il tempo di attesa usare la raccolta degli eventi di wait documentata nel tutorial Microsoft e correlarla agli stack. Alcuni frame nativi potrebbero richiedere simboli e un profiler adatto alla piattaforma. [7]

Metrica System.RuntimeLettura operativa
dotnet.thread_pool.thread.countQuantità di worker; osservare la traiettoria nel tempo.
dotnet.thread_pool.queue.lengthLavoro in coda; correlarlo con completamenti e latenza.
dotnet.thread_pool.work_item.countContatore cumulativo del lavoro completato: calcolare il tasso su un intervallo.
dotnet.process.cpu.timeTempo CPU cumulativo: usare una derivata temporale per stimare utilizzo.
dotnet.gc.heap.total_allocatedAllocazioni cumulative: osservare il tasso, non soltanto il valore assoluto.

Le metriche del Meter System.Runtime non vanno confuse con i nomi dei vecchi EventCounters. La versione dello strumento può presentare etichette e visualizzazioni differenti. [8]

Un probe opzionale utile è un timer che misura il ritardo della propria continuation, oppure un endpoint che restituisce immediatamente senza accedere a SQL. Se cresce anche la sua latenza durante le rampe, il problema ha una dimensione di scheduling condiviso. Separare comunque la CPU del generatore da quella dell'applicazione, altrimenti il probe misura anche il proprio ambiente di carico.

Backpressure applicativa: limitare prima del checkout

Un limite asincrono può impedire che un burst ammetta contemporaneamente troppe operazioni SQL. Deve coprire tutto il periodo di possesso e precedere l'apertura: acquisire il gate dopo OpenAsync consumerebbe già una sessione. Ecco un pattern indipendente dagli interni del driver:

// Un'unica istanza condivisa per il budget che si vuole applicare.
private static readonly SemaphoreSlim DbGate = new(32, 32);

public static async Task<object?> QueryAsync(string cs, CancellationToken ct)
{
    // Budget per l'ammissione, separato dal timeout SQL.
    if (!await DbGate.WaitAsync(TimeSpan.FromSeconds(2), ct))
        throw new TimeoutException("Budget di ammissione SQL esaurito");
    try
    {
        await using var connection = new SqlConnection(cs);
        await connection.OpenAsync(ct);
        await using var command = new SqlCommand("SELECT 1", connection)
        {
            CommandTimeout = 10
        };
        return await command.ExecuteScalarAsync(ct);
    }
    finally
    {
        DbGate.Release();
    }
}

Trentadue è un valore illustrativo, da scegliere con misure. Il semaforo limita le operazioni ammesse ma non limita da solo il numero dei chiamanti in attesa. Il timeout ne limita la permanenza; una policy con queue limit e rifiuto esplicito controlla anche la cardinalità della coda. Su un servizio multi-tenant decidere se il budget debba essere globale, per tenant o gerarchico, per evitare che un cliente monopolizzi l'ammissione.

Lo stesso criterio vale per l'aumento dei minimi del ThreadPool: testare valori diversi e misurare il beneficio. Più worker possono ridurre il ritardo di iniezione e aumentare pressione su memoria, scheduler e database. Un risultato migliore sul cold start deve essere confermato anche su p99 e throughput stabile.

Timeout, cancellazione e resilienza non sono sinonimi

Distinguere timeout di ammissione, tempo dell'open, timeout del comando e deadline dell'intera richiesta. In SqlClient 7.1 lo switch UseOverallConnectTimeoutForPoolWait permette di condividere il budget tra attesa del pool e connessione di rete; il default resta false. Non attivarlo solo nella variante V2 di un benchmark A/B, altrimenti si cambiano due fattori. [2]

La cancellazione è anche un protocollo di pulizia. Testare che una richiesta annullata non perda capacità, che una risorsa arrivata dopo l'annullamento venga restituita e che il sistema recuperi dopo un timeout. Un errore rapido non è necessariamente un timeout reale: clear e shutdown richiedono diagnosi distinta.

Il repository contiene segnalazioni separate per la classificazione degli errori durante ClearPool/ClearAllPools. L'issue #4737 riguarda il pool legacy nella 7.1.0; #4719 richiede una verifica specifica per il channel pool senza un repro confermato al momento della descrizione. Non generalizzare una segnalazione all'altra implementazione. Questi collegamenti servono a orientare i test, non a certificare lo stato di ogni patch successiva. [9]

Per la prova di resilienza trattenere tutti gli slot, avviare un waiter, poi eseguire separatamente cancellazione, scadenza e clear. Ripetere con entrambe le API. Infine eseguire una nuova operazione e controllare il recupero. Tenere queste prove fuori dal benchmark prestazionale.

Azure SQL e scale-out: il budget è distribuito

Questa è una deduzione architetturale dal modello: ogni processo che scala parte con un proprio pool. Se otto repliche cercano contemporaneamente cento sessioni, la domanda aggregata è fino a ottocento aperture. Parallelizzare la crescita del singolo processo può comprimere quel lavoro in una finestra più breve e aumentare il picco visto da autenticazione e server.

Perciò bisogna misurare startup e scale-out insieme. Un warmup moderato può ridurre la latenza delle prime richieste, ma uno coordinato male può sincronizzare i login. Jitter, limiti di ammissione e retry con budget complessivo rendono la domanda più controllabile. Queste sono scelte progettuali da verificare sulla propria topologia.

Un pool rapido non corregge una transazione mantenuta aperta mentre un agente LLM chiama un servizio esterno. In un orchestratore C#/Azure, leggere i dati, materializzare il risultato e restituire la connessione prima dell'inferenza è spesso la separazione più efficace. Se serve consistenza tra più fasi, progettare esplicitamente versioni, idempotenza o compensazioni: trattenere una sessione durante un'attesa non la crea gratuitamente.

Una decisione basata sui percorsi reali dell'applicazione

Il valore di Pool V2 si valuta osservando quali code scompaiono e quali emergono. Per una dashboard Blazor che apre molte sessioni al primo accesso, la rampa può pesare. Per un worker con transazioni lunghe, può dominare la durata del possesso. Per un'applicazione multi-tenant, può dominare la frammentazione dei pool. Il laboratorio deve riflettere il percorso che produce la latenza dell'utente.

Una sperimentazione utile conserva gli stessi binari e abilita soltanto lo switch su una quota di istanze. Confronta startup, errori, open p95/p99, occupazione e scheduling, mantenendo pronto il rollback con riavvio. Il criterio di accettazione deve includere la latenza di operazioni non SQL e la stabilità del server, oltre al tempo della rampa.

Il connection pool è parte del sistema di controllo della concorrenza. Con Pool V2 abbiamo un nuovo modo di coordinare le risorse e creare sessioni; il lavoro dell'architetto è mantenere coerenti budget di connessioni, login, worker e operazioni ammesse. È in questa coerenza che un miglioramento del driver diventa un miglioramento dell'applicazione.

Fonti e codice verificabile

  1. Microsoft — Try SqlClient’s new connection pool for faster parallel connections, 6 ottobre 2026. Annuncio, benchmark cold start e limite del percorso di rete async.
  2. Microsoft Learn — AppContext switches in SqlClient. Attivazione, caching degli switch e budget complessivo del connect timeout.
  3. Microsoft Learn — SQL Server connection pooling. Identità dei pool e controlli pubblici.
  4. ChannelDbConnectionPool.cs — tag v7.1.0. Analisi effettuata sul file raw dello stesso tag; il branch main può essere diverso.
  5. WaitHandleDbConnectionPool.cs — tag v7.1.0. Semafori, WaitAny e pending opens.
  6. SqlClient — Connection Pool V2 Performance Results. Metodologia e copertura dei benchmark ufficiali; pagina aggiornabile.
  7. Microsoft Learn — Debug ThreadPool starvation. Diagnosi, strumenti e stack.
  8. Microsoft Learn — .NET runtime metrics. Meter System.Runtime e significato delle metriche.
  9. SqlClient #4737 — segnalazione legacy pool e #4719 — verifica channel pool. Issue di tracking, da ricontrollare per la patch scelta.

mercoledì 30 settembre 2026

DiskANN in Azure SQL: Vector Search, RAG e GeoAI direttamente nel database relazionale

La ricerca vettoriale sta progressivamente entrando nel cuore dei database relazionali. Non si tratta semplicemente di aggiungere una nuova funzione SQL: significa poter interrogare gli stessi dati contemporaneamente nello spazio relazionale, nello spazio geografico e nello spazio semantico degli embedding.

Con DiskANN, Azure SQL può memorizzare embedding e utilizzare un indice Approximate Nearest Neighbor direttamente accanto a tabelle, chiavi, transazioni, metadati, filtri di sicurezza e, potenzialmente, dati spaziali.

Per un architect questo apre una domanda interessante: abbiamo davvero bisogno di introdurre un vector database separato per ogni applicazione RAG?

In molti scenari la risposta potrebbe essere no.

Cos'è DiskANN

DiskANN significa Disk-based Approximate Nearest Neighbor. È una famiglia di tecniche sviluppate per eseguire rapidamente ricerche di similarità su collezioni vettoriali di grandi dimensioni.

Non genera embedding, non interpreta documenti e non è un Large Language Model. DiskANN interviene dopo che i dati sono già stati trasformati in vettori.

Document
    ↓
Text extraction
    ↓
Chunking
    ↓
Embedding model
    ↓
Vector
    ↓
Azure SQL
    ↓
DiskANN
    ↓
Nearest neighbors

Il problema che risolve è apparentemente semplice: dato un vettore q, trovare i vettori del database che gli sono più vicini.

Dallo spazio semantico allo spazio vettoriale

Un embedding model trasforma un oggetto, ad esempio una frase:

ArcGIS Enterprise supports federated GIS servers.

in una sequenza numerica:

[0.0182, -0.0317, 0.0041, ..., 0.0227]

Formalmente possiamo pensare all'embedding come a una funzione:

f : X → ℝᵈ

dove X è lo spazio degli oggetti originali e d è la dimensionalità del vettore.

La singola coordinata non ha generalmente un significato interpretabile isolatamente. È la posizione complessiva del vettore nello spazio multidimensionale a rappresentare le caratteristiche apprese dal modello.

Se due contenuti hanno significato simile, il modello tende a collocarli in regioni vicine dello spazio vettoriale.

Exact Nearest Neighbor: perché non basta

Il modo più semplice di trovare i vettori più vicini consiste nel confrontare la query con ogni vettore presente nella tabella.

query
  │
  ├── distance(vector 1)
  ├── distance(vector 2)
  ├── distance(vector 3)
  ├── ...
  └── distance(vector N)

Con N vettori e dimensionalità D, il lavoro cresce approssimativamente come:

O(N × D)

Con poche migliaia di record può essere perfettamente accettabile.

Con:

10.000.000 vectors
×
1.536 dimensions

la scansione esaustiva diventa invece molto più costosa.

È qui che entra in gioco l'Approximate Nearest Neighbor Search.

ANN: sacrificare un po' di perfezione per molta velocità

Un algoritmo ANN non garantisce necessariamente di restituire sempre gli stessi vicini che otterremmo esaminando esaustivamente l'intero dataset.

Accetta un compromesso:

Exactness
    ↓ leggermente

Search space
    ↓ drasticamente

Latency
    ↓ drasticamente

Supponiamo che i veri dieci nearest neighbors siano:

A B C D E F G H I J

e che ANN restituisca:

A B C D E F G H I K

Il recall@10 sarebbe:

9 / 10 = 0.90

Per molte pipeline RAG questo trade-off è estremamente conveniente: non abbiamo bisogno di dimostrare matematicamente quale chunk sia il più vicino, ma di recuperare molto rapidamente un insieme di candidati altamente rilevanti.

DiskANN e grafi di prossimità

DiskANN utilizza un approccio graph-based. Possiamo immaginare ogni embedding come un nodo collegato ad altri nodi utili per navigare nello spazio vettoriale.

              ●──────●
             /        \
        ●───●          ●
        │    \        /
        ●─────●──────●
               \
                ●────●

La ricerca non deve quindi fare:

query → confronta tutti i vettori

ma può procedere concettualmente attraverso il grafo:

query
  ↓
entry point
  ↓
candidate neighbors
  ↓
better neighbors
  ↓
better neighbors
  ↓
Top-K

Il grafo consente di raggiungere rapidamente regioni promettenti dello spazio senza valutare ogni riga.

Perché si chiama DiskANN

Uno degli elementi caratteristici del progetto DiskANN è l'attenzione alla possibilità di lavorare con collezioni vettoriali molto grandi senza dipendere esclusivamente dalla RAM.

              RAM
       ┌────────────────┐
       │ search state   │
       │ cache          │
       │ graph metadata │
       └───────┬────────┘
               │
               │ selective access
               ▼
       ┌────────────────┐
       │ SSD / storage  │
       │ vector data    │
       └────────────────┘

Naturalmente, quando utilizziamo DiskANN tramite Azure SQL non dobbiamo confondere il principio algoritmico con l'implementazione interna del servizio managed: cache, storage engine, I/O scheduling, quantizzazione e query optimization sono responsabilità del database engine.

DiskANN è ora production-ready su Azure SQL

Il 29 settembre 2026 Microsoft ha annunciato la General Availability di DiskANN Vector Index & Search su Azure SQL Database, su Azure SQL Managed Instance configurato con la policy di aggiornamento always-up-to-date e su SQL database in Microsoft Fabric.

La versione GA include elementi particolarmente importanti per workload reali:

  • Approximate Nearest Neighbor Search basata su DiskANN;
  • supporto a INSERT, UPDATE e DELETE;
  • manutenzione asincrona dell'indice vettoriale;
  • iterative filtering;
  • integrazione con il query optimizer;
  • possibilità di combinare vector search e predicati relazionali.

Il tipo VECTOR di SQL

Il database può memorizzare direttamente un embedding in una colonna vettoriale.

Un modello dati orientato a RAG potrebbe essere:

CREATE TABLE dbo.Documents
(
    Id          BIGINT IDENTITY PRIMARY KEY,
    TenantId    INT NOT NULL,
    FileName    NVARCHAR(500) NOT NULL,
    Title       NVARCHAR(500) NULL,
    MimeType    NVARCHAR(100) NULL,
    CreatedAt   DATETIME2 NOT NULL
        DEFAULT SYSUTCDATETIME()
);

I chunk possono invece essere mantenuti separatamente:

CREATE TABLE dbo.DocumentChunks
(
    Id               BIGINT IDENTITY PRIMARY KEY,
    DocumentId       BIGINT NOT NULL,
    TenantId         INT NOT NULL,

    PageNumber       INT NULL,
    ChunkNumber      INT NOT NULL,

    Content          NVARCHAR(MAX) NOT NULL,

    EmbeddingModel   NVARCHAR(100) NOT NULL,
    ChunkingVersion  INT NOT NULL,

    Embedding        VECTOR(1536) NOT NULL,

    CONSTRAINT FK_DocumentChunks_Documents
        FOREIGN KEY (DocumentId)
        REFERENCES dbo.Documents(Id)
);

1536 è solamente un esempio. La dimensionalità deve essere coerente con il modello che genera gli embedding.

Versionare embedding e pipeline

Un embedding dovrebbe essere trattato come dato derivato, non come sorgente primaria.

Conviene quindi conservare almeno:

Original document
Extracted content
Chunk
ChunkingVersion
Embedding
EmbeddingModel
EmbeddingVersion
CreatedAt

Questa informazione diventa fondamentale quando cambiamo modello.

Due modelli differenti possono produrre spazi vettoriali differenti: non possiamo semplicemente mischiare embedding generati da modelli diversi e aspettarci che le distanze rimangano semanticamente confrontabili.

Creazione dell'indice DiskANN

Sulla colonna vettoriale possiamo creare un indice:

CREATE VECTOR INDEX IX_DocumentChunks_Embedding
ON dbo.DocumentChunks(Embedding)
WITH
(
    METRIC = 'cosine',
    TYPE = 'diskann'
);

Le metriche supportate dalla vector search SQL comprendono:

  • cosine;
  • dot;
  • euclidean.

La metrica non va scelta arbitrariamente: deve essere coerente con il modello di embedding e con il modo in cui è stato addestrato.

Exact search con VECTOR_DISTANCE

Possiamo eseguire una ricerca esatta calcolando la distanza:

DECLARE @query VECTOR(1536) = @QueryEmbedding;

SELECT TOP (10)
    Id,
    DocumentId,
    Content,
    VECTOR_DISTANCE(
        'cosine',
        @query,
        Embedding
    ) AS Distance
FROM dbo.DocumentChunks
ORDER BY Distance;

Questa strategia è utile anche come baseline per misurare la qualità dell'indice ANN.

Possiamo infatti confrontare:

Exact Top-K
     vs
DiskANN Top-K

e misurare il recall effettivo sul nostro dataset.

Approximate search con VECTOR_SEARCH

Con gli indici vettoriali di nuova generazione la sintassi corrente utilizza VECTOR_SEARCH insieme a:

TOP (N) WITH APPROXIMATE

Ad esempio:

DECLARE @query VECTOR(1536) = @QueryEmbedding;

SELECT TOP (10) WITH APPROXIMATE
    c.Id,
    c.DocumentId,
    c.Content,
    r.distance
FROM VECTOR_SEARCH(
    TABLE = dbo.DocumentChunks AS c,
    COLUMN = Embedding,
    SIMILAR_TO = @query,
    METRIC = 'cosine'
) AS r
ORDER BY r.distance;

La precedente forma basata sul parametro:

TOP_N = 10

appartiene alla generazione precedente dell'indice ed è deprecata per i nuovi indici.

WITH APPROXIMATE non è solo sintassi

Con:

SELECT TOP (10) WITH APPROXIMATE

stiamo dicendo esplicitamente al motore che accettiamo risultati ANN.

Senza WITH APPROXIMATE, VECTOR_SEARCH può eseguire una ricerca kNN esatta.

VECTOR_SEARCH
      │
      ├── exact kNN
      │
      └── approximate ANN / DiskANN

La distinzione è molto importante quando facciamo benchmarking.

Il query optimizer entra nella vector search

Uno degli aspetti architetturalmente più interessanti è che la ricerca vettoriale non viene trattata come una scatola completamente separata dal motore SQL.

Azure SQL può valutare se utilizzare la strategia ANN oppure altre strategie in funzione della query e della selettività.

Supponiamo di avere:

100.000.000 chunks

ma di applicare:

WHERE TenantId = 42

e che il tenant 42 contenga soltanto 5.000 chunk. In determinate condizioni un piano differente potrebbe risultare più conveniente rispetto all'attraversamento del grafo ANN globale.

FORCE_ANN_ONLY

Quando vogliamo imporre l'utilizzo dell'indice Approximate Nearest Neighbor, Azure SQL mette a disposizione anche l'hint:

FORCE_ANN_ONLY

Concettualmente:

SELECT TOP (10) WITH APPROXIMATE
    ...
FROM VECTOR_SEARCH(
    TABLE = dbo.DocumentChunks AS c,
    COLUMN = Embedding,
    SIMILAR_TO = @query,
    METRIC = 'cosine'
) AS r WITH (FORCE_ANN_ONLY)
ORDER BY r.distance;

È utile soprattutto per test, benchmark o casi in cui sappiamo esattamente quale strategia vogliamo utilizzare.

Come ogni hint SQL, non dovrebbe diventare automaticamente la scelta predefinita.

Iterative filtering: fondamentale per sistemi reali

Immaginiamo una piattaforma SaaS multi-tenant.

Il retrieval deve rispettare:

TenantId = 42

Una implementazione ANN ingenua potrebbe fare:

DiskANN
   ↓
10 nearest vectors
   ↓
WHERE TenantId = 42
   ↓
2 risultati validi

Gli altri otto vettori potrebbero appartenere ad altri tenant.

Con l'iterative filtering la ricerca può continuare nel grafo finché non vengono trovati abbastanza risultati che soddisfano il predicato:

DiskANN
   ↓
candidate set
   ↓
relational filter
   ↓
enough results?
   │
   ├── yes → return Top-K
   │
   └── no
        ↓
   continue graph traversal
        ↓
   more candidates
        ↓
   relational filter

Questa caratteristica è importantissima per:

  • multi-tenancy;
  • ACL;
  • categorie;
  • stati applicativi;
  • vincoli temporali;
  • filtri geografici o amministrativi;
  • security policies.

Vector search e security boundary

In un'applicazione RAG enterprise la sicurezza non deve essere applicata dopo il retrieval.

Il flusso corretto deve essere progettato come:

User
 ↓
Identity
 ↓
Tenant / ACL / Security Policy
 ↓
Vector retrieval
 ↓
Permitted chunks
 ↓
LLM

In altre parole: il retrieval fa parte del security boundary dell'applicazione.

Il vero RAG inizia prima di DiskANN

Una delle semplificazioni più pericolose è pensare che una pipeline RAG sia:

PDF → embedding → vector database

Una pipeline seria assomiglia molto di più a:

                   PDF
                    │
          ┌─────────┴──────────┐
          │                    │
     Native text          Scanned PDF
          │                    │
   Text extraction            OCR
          │                    │
          └─────────┬──────────┘
                    ↓
             normalization
                    ↓
          structure analysis
                    ↓
               chunking
                    ↓
               embedding
                    ↓
               Azure SQL
                    ↓
                DiskANN

PDF nativi e PDF scansionati

Un PDF può contenere un vero text layer. In questo caso è possibile estrarre direttamente:

Page
 ├── Heading
 ├── Paragraph
 ├── Paragraph
 └── Table

Un PDF scansionato può invece contenere essenzialmente immagini:

PDF
 ├── page-001 image
 ├── page-002 image
 └── page-003 image

In questo caso serve OCR.

In ambiente Azure una pipeline possibile è:

Azure Blob Storage
        ↓
Document ingestion service
        ↓
Azure AI Document Intelligence
        ↓
OCR + Layout + Tables + Structure
        ↓
Semantic chunking
        ↓
Embedding model
        ↓
Azure SQL
        ↓
DiskANN

Il punto importante è che non basta recuperare caratteri: serve preservare il più possibile la struttura semantica del documento.

Chunking: spesso più importante del vector database

Consideriamo un manuale tecnico di 500 pagine.

Creare:

manual.pdf → one embedding

è generalmente poco utile.

È molto più efficace ottenere:

manual.pdf
   │
   ├── chunk 001 → embedding
   ├── chunk 002 → embedding
   ├── chunk 003 → embedding
   ├── ...
   └── chunk 850 → embedding

Fixed-size chunking

L'approccio più semplice consiste nel suddividere il testo per numero di token:

chunk 1 → tokens   0 - 499
chunk 2 → tokens 450 - 949
chunk 3 → tokens 900 - 1399

L'overlap aiuta a evitare che un concetto venga spezzato esattamente sul confine tra due chunk.

Semantic chunking

Per documentazione tecnica possiamo però fare di meglio.

Section
 ├── paragraph
 ├── paragraph
 ├── code sample
 ├── table
 └── note

L'obiettivo è costruire unità informative semanticamente coerenti, non semplicemente blocchi di lunghezza costante.

Hierarchical chunking

Possiamo mantenere una gerarchia:

Document
   ↓
Chapter
   ↓
Section
   ↓
Chunk

Se DiskANN recupera il chunk 37 possiamo, ad esempio, recuperare anche:

chunk 36
chunk 37
chunk 38

oppure la sezione padre.

Per questo conviene conservare:

DocumentId
SectionId
ParentChunkId
PageNumber
ChunkNumber

Embedding generation lato applicazione

Una pipeline .NET può essere concettualmente molto semplice:

var chunks = chunker.Split(document);

foreach (var chunk in chunks)
{
    var embedding =
        await embeddingService.GenerateAsync(chunk.Text);

    db.DocumentChunks.Add(
        new DocumentChunk
        {
            DocumentId = documentId,
            TenantId = tenantId,
            PageNumber = chunk.Page,
            ChunkNumber = chunk.Index,
            Content = chunk.Text,
            EmbeddingModel = embedding.Model,
            ChunkingVersion = 1,
            Embedding = embedding.Vector
        });
}

await db.SaveChangesAsync();

Dal punto di vista architetturale terrei ben separati:

Extraction
Chunking
Embedding
Persistence
Indexing
Retrieval
Generation

Questo rende la pipeline versionabile, osservabile e riprocessabile.

Azure SQL può generare embedding direttamente

Lo stack SQL moderno consente anche di registrare modelli esterni e invocarli dal database.

Un external model può essere definito concettualmente così:

CREATE EXTERNAL MODEL MyEmbeddingModel
WITH
(
    LOCATION = '...',
    API_FORMAT = 'Azure OpenAI',
    MODEL_TYPE = EMBEDDINGS,
    MODEL = '...',
    CREDENTIAL = ...
);

e successivamente:

AI_GENERATE_EMBEDDINGS(
    N'ArcGIS Server federation'
    USE MODEL MyEmbeddingModel
)

Microsoft supporta inoltre scenari in cui AI_GENERATE_CHUNKS e AI_GENERATE_EMBEDDINGS vengono combinati direttamente da T-SQL.

Questo rende possibile una pipeline:

SQL row
   ↓
AI_GENERATE_CHUNKS
   ↓
AI_GENERATE_EMBEDDINGS
   ↓
VECTOR
   ↓
DiskANN

Embedding nel database o nell'applicazione?

Il fatto che qualcosa sia possibile in SQL non significa che debba necessariamente essere eseguito lì.

Generare embedding nell'application layer offre generalmente maggiore controllo su:

  • retry;
  • rate limiting;
  • batching;
  • fallback;
  • observability;
  • model routing;
  • versioning della pipeline.

Generarli direttamente nel database può invece semplificare alcune pipeline data-centric.

È quindi una decisione architetturale, non semplicemente sintattica.

RAG: Retrieve Wide, Rank Narrow

DiskANN non deve necessariamente restituire direttamente i chunk che finiranno nel prompt.

Una pipeline più sofisticata può essere:

10.000.000 chunks
       │
       │ DiskANN
       ↓
      50
       │
       │ reranker
       ↓
       8
       │
       │ context builder
       ↓
      LLM

Questo pattern viene spesso sintetizzato come:

Retrieve wide
     ↓
Rank narrow

Reranking

La prima fase privilegia throughput e recall.

Una seconda fase può utilizzare un modello più costoso per stimare meglio la rilevanza rispetto alla domanda originale:

Question
   +
50 candidate chunks
        ↓
     Reranker
        ↓
   Best 5 - 10

DiskANN e reranker risolvono quindi problemi differenti:

DiskANN:
find plausible candidates quickly

Reranker:
order those candidates accurately

Hybrid Search

Gli embedding non sostituiscono necessariamente la ricerca lessicale.

Una query come:

KB5034123 ArcGIS Server TLS

contiene token precisi, codici e identificativi che possono essere estremamente significativi.

Una strategia moderna può combinare:

Vector similarity
       +
Keyword search
       +
Relational filters
       +
Reranking

La vector search è quindi un ulteriore retrieval signal, non necessariamente l'unico.

.NET ed Entity Framework Core

Per chi sviluppa applicazioni .NET è importante distinguere le versioni.

In EF Core 10 Microsoft ha introdotto il supporto alla traduzione di VectorDistance(), quindi alla distanza vettoriale esatta.

In EF Core 11 Microsoft documenta inoltre le API dedicate a vector index e VECTOR_SEARCH.

Ad esempio:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<DocumentChunk>()
        .HasVectorIndex(
            x => x.Embedding,
            "cosine");
}

La ricerca ANN può essere espressa concettualmente come:

var results = await db.DocumentChunks
    .VectorSearch(
        x => x.Embedding,
        queryEmbedding,
        "cosine")
    .OrderBy(x => x.Distance)
    .Take(10)
    .WithApproximate()
    .ToListAsync();

WithApproximate() corrisponde semanticamente alla richiesta:

TOP (...) WITH APPROXIMATE

È però importante notare che Microsoft documenta attualmente queste API di EF Core 11 come funzionalità ancora soggette a evoluzione.

Per chi oggi usa .NET 10

In un'applicazione .NET 10 / EF Core 10 possiamo comunque utilizzare pienamente Azure SQL Vector Search attraverso T-SQL, stored procedure, raw SQL o VectorDistance() dove appropriato.

Non è quindi necessario aspettare EF Core 11 per costruire una soluzione DiskANN.

.NET 10
   ↓
EF Core / ADO.NET
   ↓
VECTOR_SEARCH T-SQL
   ↓
Azure SQL
   ↓
DiskANN

Architettura RAG completa su Azure

                 ┌────────────────────┐
                 │ Azure Blob Storage │
                 └─────────┬──────────┘
                           │
                           ▼
                 ┌────────────────────┐
                 │ .NET Ingestion     │
                 │ Worker / Service   │
                 └─────────┬──────────┘
                           │
            ┌──────────────┴──────────────┐
            │                             │
            ▼                             ▼
     Text extraction          Document Intelligence
                                     OCR/Layout
            │                             │
            └──────────────┬──────────────┘
                           ▼
                    Semantic Chunking
                           │
                           ▼
                     Embedding Model
                           │
                           ▼
                   ┌────────────────┐
                   │   Azure SQL    │
                   │                │
                   │ relational     │
                   │ metadata       │
                   │ security       │
                   │ chunks         │
                   │ vectors        │
                   │ DiskANN        │
                   └───────┬────────┘
                           │
                           ▼
                       Retrieval
                           │
                           ▼
                        Reranker
                           │
                           ▼
                    Context Builder
                           │
                           ▼
                          LLM
                           │
                           ▼
                       Response

Vector database dedicato o Azure SQL?

Non esiste una risposta universale.

Un vector database specializzato può essere preferibile quando:

  • la vector search domina completamente il workload;
  • la scala è estremamente elevata;
  • servono feature vector-native molto specializzate;
  • l'architettura richiede distribuzione specificamente progettata per ANN.

Azure SQL è invece particolarmente interessante quando:

  • i dati applicativi sono già relazionali;
  • servono transazioni;
  • la sicurezza dipende dal modello relazionale;
  • i filtri sui metadati sono importanti;
  • vogliamo evitare sincronizzazione tra database;
  • vector search integra, ma non sostituisce, il workload applicativo.

La domanda corretta diventa quindi:

Which vector database should I add?

oppure:

Do I actually need another database?

Sono due domande architetturalmente molto diverse.

DiskANN + Spatial SQL: il collegamento con il GIS

Per chi lavora con GIS la parte più interessante inizia probabilmente qui.

Una feature geografica potrebbe avere contemporaneamente:

Feature
 ├── Id
 ├── Geometry
 ├── Attributes
 ├── Description
 ├── Category
 ├── Temporal information
 └── Embedding

La stessa entità appartiene quindi a tre spazi differenti.

Relational space
Geographic space
Semantic vector space

Tre tipi diversi di distanza

Consideriamo due aree territoriali.

La distanza geografica può essere:

180 km

ma la loro distanza nello spazio degli embedding può indicare una fortissima similarità:

Semantic similarity = very high

Due territori possono essere lontani fisicamente ma molto simili rispetto a:

  • demografia;
  • uso del suolo;
  • mobilità;
  • densità commerciale;
  • accessibilità;
  • servizi;
  • indicatori economici;
  • caratteristiche ambientali.

GeoAI: da distanza geometrica a similarità funzionale

Una possibile query concettuale potrebbe essere:

Find areas

within 50 km

AND

land_use = 'industrial'

AND

area > 50000 m²

AND

semantically similar to:

"areas suitable for logistics,
close to major transport infrastructure,
with low residential pressure"

Qui stiamo combinando:

Spatial predicate
       +
Relational predicate
       +
Vector similarity

Spatial Index e Vector Index sono concetti differenti

Uno spatial index indicizza oggetti come:

POINT
LINESTRING
POLYGON

Un vector index indicizza invece:

[0.018, -0.032, 0.004, ..., 0.071]

Il fatto che entrambi utilizzino la parola "vector" in alcuni contesti non deve creare confusione.

Sono strutture pensate per risolvere problemi completamente differenti.

Ma possono lavorare insieme.

Un possibile modello GeoAI

CREATE TABLE GeoAssets
(
    Id           BIGINT PRIMARY KEY,
    TenantId     INT NOT NULL,

    Name         NVARCHAR(250),
    Category     NVARCHAR(100),

    Geometry     GEOGRAPHY,

    Description  NVARCHAR(MAX),

    Embedding    VECTOR(1536)
);

Concettualmente potremmo eseguire:

Relational filtering
        ↓
Spatial filtering
        ↓
Vector retrieval
        ↓
Reranking
        ↓
GeoAI result

Embedding territoriali

Possiamo andare ancora oltre e generare un embedding non da un semplice testo, ma da una rappresentazione complessa del territorio.

Territory
   │
   ├── Population
   ├── Income
   ├── Land use
   ├── Mobility
   ├── POIs
   ├── Accessibility
   ├── Satellite features
   └── Demographics
             │
             ▼
          Encoder
             │
             ▼
       Territory embedding

A quel punto possiamo chiedere:

Find areas similar to this area

senza limitarci a una distanza euclidea o geografica.

Passiamo dalla prossimità geometrica alla similarità funzionale appresa.

Osservabilità

Una pipeline RAG production-grade va misurata end-to-end.

Almeno:

Document extraction latency
OCR latency
Chunking throughput
Embedding latency
Embedding failures
Indexing throughput
ANN latency
Exact KNN latency
Recall@K
Reranking latency
Context size
LLM latency
Token usage
End-to-end latency

Possiamo modellare la latenza totale come:

Ttotal =
    Textract
  + Tembed
  + Tretrieve
  + Trerank
  + Tcontext
  + Tllm

Ottimizzare DiskANN di 10 ms ha poco senso se la generazione dell'LLM richiede diversi secondi.

Valutare scientificamente il retrieval

Un buon sistema RAG non dovrebbe essere valutato soltanto chiedendo:

La risposta sembra buona?

Dobbiamo costruire un dataset di valutazione:

Question
Expected relevant chunks
Expected source documents

e misurare:

  • Recall@K;
  • Precision@K;
  • MRR;
  • NDCG;
  • latency;
  • cost per query.

Possiamo così confrontare in modo ripetibile:

Embedding Model A vs B

Chunk size 300 vs 800

Overlap 50 vs 150

Exact KNN vs DiskANN

Top-10 vs Top-50

With reranker vs without reranker

Vector-only vs Hybrid Search

DiskANN non sostituisce il database design

La presenza di embedding non rende improvvisamente irrilevanti:

  • primary key;
  • foreign key;
  • indici B-tree;
  • statistics;
  • query plans;
  • partitioning;
  • security;
  • transactions;
  • data lifecycle;
  • backup e disaster recovery.

La vector search è un'altra modalità di accesso al dato, non un sostituto del modello relazionale.

La vera convergenza

L'aspetto più interessante di Azure SQL + DiskANN non è poter eseguire una query con cosine distance.

È poter mantenere nello stesso dominio dati:

Transactions
Business entities
Users
Security
Documents
Metadata
Spatial objects
Embeddings

e interrogarli utilizzando:

Relational Search
Full-Text Search
Spatial Search
Vector Search

L'AI non deve necessariamente possedere una copia parallela del database aziendale.

Può diventare un'altra modalità con cui l'applicazione interroga gli stessi dati operativi.

Conclusione

DiskANN è interessante dal punto di vista algoritmico perché rende possibile una Approximate Nearest Neighbor Search efficiente su collezioni vettoriali di grandi dimensioni.

Ma il cambiamento più importante è probabilmente architetturale.

Azure SQL
   │
   ├── Relational data
   ├── Transactions
   ├── Security
   ├── Documents
   ├── Spatial data
   ├── Vectors
   └── DiskANN

In una moderna applicazione AI questo può ridurre la necessità di introdurre un'infrastruttura dati parallela esclusivamente per il retrieval vettoriale.

E nel GIS il concetto diventa ancora più interessante.

Relational space
      +
Geographic space
      +
Semantic vector space
      ↓
     GeoAI

La domanda GIS tradizionale:

What is near this location?

può evolvere verso:

What is geographically, semantically and functionally similar to this location?

È qui che DiskANN smette di essere semplicemente una tecnologia per "fare RAG sui PDF" e diventa qualcosa di più generale: un nuovo strumento per interrogare dati, conoscenza e territorio.

Riferimenti tecnici

domenica 28 novembre 2010

SQL Server Reporting services Map & ArcGIS Server

Con la nuova versione di SQL Server 2008 R2, possiamo visualizzare mappe nelle report che distribuiamo con RS (anche con la versione Express).

Questo è possibile grazie al nuovo controllo map. Qui potete trovare maggiori dettagli su tale controllo.

Contestualmente all'aggiunta in ArcGIS Server dei query layer,  possiamo effettuare una sorta di integrazione tra ArcGIS Server ed il controllo di mappa di RS senza utilizzare gli shapefile.

In questo video illustro una possibile integrazione:


Code:

<%@ WebHandler Language="C#" Class="rsMap" %>
 
using System;
using System.Web;
//Author: Domenico Ciavarella
//www.studioat.it
public class rsMap : IHttpHandler {
 
 public void ProcessRequest (HttpContext context) {
  
  bool isJson = false;
  bool isPrettyJson = false;
  string format = null;
  if (context.Request.QueryString["f"] != null)
  {
   format = context.Request.QueryString["f"].ToLower();
   if (format == "json" || format == "pjson")
   {
    isJson = true;
    isPrettyJson = format == "pjson";
   }
  }
 
  
  
  reportExecution.ReportExecutionService rs = new reportExecution.ReportExecutionService();
  rs.Credentials = new System.Net.NetworkCredential(System.Configuration.ConfigurationManager.AppSettings.Get("userReport"), System.Configuration.ConfigurationManager.AppSettings.Get("passwordReport"), System.Configuration.ConfigurationManager.AppSettings.Get("domain"));
  
  // Render arguments
  byte[] result = null;
  string reportPath = "/rsMappe/rsMap";
  string formatOutput = "PDF";
  string historyID = null;
 
  string encoding;
  string mimeType;
  string extension;
  reportExecution.Warning[] warnings = null;
  string[] streamIDs = null;
 
 
  
 
 
  reportExecution.ExecutionInfo execInfo = new reportExecution.ExecutionInfo();
  reportExecution.ExecutionHeader execHeader = new reportExecution.ExecutionHeader();
 
  rs.ExecutionHeaderValue = execHeader;
 
  execInfo = rs.LoadReport(reportPath, historyID);
  
  String SessionId = rs.ExecutionHeaderValue.ExecutionID;
 
  string oidValue = context.Server.HtmlEncode(context.Request.QueryString["oid"]);
  
  if (string.IsNullOrEmpty(oidValue))
  {
   context.Response.Write("Parametri non passati!");
   return;
  }
  
 
 
 
  reportExecution.ParameterValue[] parameters = new reportExecution.ParameterValue[1 ];
 
    
  parameters[0] = new reportExecution.ParameterValue();
  parameters[0].Name = "pFID";
  parameters[0].Value = oidValue;
  
  rs.SetExecutionParameters(parameters, "it-IT");
   
  result = rs.Render(formatOutput, null, out extension, out encoding, out mimeType, out warnings, out streamIDs);
 
 
  if (isJson)
  {
   string fileName = "_ags_" + Guid.NewGuid().ToString() + ".pdf";
   string pathfileName = @"c:\arcgisserver\arcgisoutput\" + fileName;
   string urlfileName = "http://localhost/arcgisoutput/" + fileName;
   using (System.IO.FileStream stream = System.IO.File.OpenWrite(pathfileName))
   {
    stream.Write(result, 0, result.Length);
   }
 
   GenerateJson(context, new Result() { url = urlfileName }, isPrettyJson);
   return;
  }
  
  
  context.Response.ClearContent();
  context.Response.AppendHeader("content-length", result.Length.ToString());
  context.Response.ContentType = "application/pdf";
  context.Response.BinaryWrite(result);
  context.Response.Flush();
  context.Response.Close();
 }
 
 public bool IsReusable {
  get {
   return false;
  }
 }
 
 
 private static void GenerateJson(HttpContext context, Result result, bool isPrettyJson)
 {
  context.Response.ContentType = "text/plain";
  string newLine = "\n";
  string tab = " ";
  if (!isPrettyJson)
  {
   newLine = tab = "";
  }
  context.Response.Write(string.Format("{{\"results\": {0}", newLine, tab));
 
  context.Response.Write(string.Format("{2}{1}{2}{2}{{\"url\":\"{0}\"}}{1}", result.url, newLine, tab));
    
  context.Response.Write(string.Format("{0}}}", newLine, tab));
 }
 
 public struct Result {
  public string url;
 }
 
 
}


page HTML:

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
<meta http-equiv="X-UA-Compatible" content="IE=7" />
  <title>ArcGIS JavaScript API: LimitiComuni</title>
<style type="text/css">
  @import "http://serverapi.arcgisonline.com/jsapi/arcgis/2.1/js/dojo/dijit/themes/tundra/tundra.css";
html, body { height: 100%; width: 100%; margin: 0; padding: 0; }
      .tundra .dijitSplitContainer-dijitContentPane, .tundra .dijitBorderContainer-dijitContentPane#navtable { 
        PADDING-BOTTOM: 5px; MARGIN: 0px 0px 3px; PADDING-TOP: 0px; BORDER-BOTTOM: #000 1px solid; BORDER-TOP: #000 1px solid; BACKGROUND-COLOR: #E5EFF7;
      }
      .tundra .dijitSplitContainer-dijitContentPane, .tundra .dijitBorderContainer-dijitContentPane#map {
        overflow:hidden; border:solid 1px black; padding: 0;
      }
      #breadcrumbs {
        PADDING-RIGHT: 0px; PADDING-LEFT: 11px; FONT-SIZE: 0.8em; FONT-WEIGHT: bold; PADDING-BOTTOM: 5px; MARGIN: 0px 0px 3px; PADDING-TOP: 0px;
      }
      #help {
        PADDING-RIGHT: 11px; PADDING-LEFT: 0px; FONT-SIZE: 0.70em; PADDING-BOTTOM: 5px; MARGIN: 0px 0px 3px; PADDING-TOP: 3px; 
</style>
<script type="text/javascript" src="http://serverapi.arcgisonline.com/jsapi/arcgis?v=2.1"></script>
<script type="text/javascript">
    dojo.require("esri.map");
    dojo.require("dijit.layout.ContentPane");
    dojo.require("dijit.layout.BorderContainer");
    dojo.require("dijit.Dialog");
 
    var map;
    var printDlg;
 
    function Init() {
        dojo.style(dojo.byId("map"), { width: dojo.contentBox("map").w + "px", height: (esri.documentBox.h - dojo.contentBox("navTable").h - 40) + "px" });
        var initExtent = new esri.geometry.Extent({ "xmin": 6.55059928850006, "ymin": 45.0006208151578,
            "xmax": 8.22229873550003, "ymax": 45.6702338525465, "spatialReference": { "wkid": 4326 }
        });
        map = new esri.Map("map", { extent: initExtent });
        
        printDlg = new dijit.Dialog({
            title: "Stampa",
            style: "width: 825px; height: 650px"
        });
   
        var layer = new esri.layers.ArcGISDynamicMapServiceLayer("http://localhost/ArcGIS/rest/services/LimitiComuni/MapServer", { "opacity": 0.5 });
        var layerBaseMap = new esri.layers.ArcGISTiledMapServiceLayer("http://server.arcgisonline.com/ArcGIS/rest/services/ESRI_StreetMap_World_2D/MapServer");
        map.addLayer(layerBaseMap);
        map.addLayer(layer);
        var resizeTimer;
        dojo.connect(map, 'onLoad', function (theMap) {
            dojo.connect(map, "onClick", doIdentify);
 
            identifyTask = new esri.tasks.IdentifyTask("http://localhost/ArcGIS/rest/services/LimitiComuni/MapServer");
 
            identifyParams = new esri.tasks.IdentifyParameters();
            identifyParams.tolerance = 3;
            identifyParams.returnGeometry = false;
            identifyParams.layerIds = [0];
            identifyParams.layerOption = esri.tasks.IdentifyParameters.LAYER_OPTION_ALL;
            identifyParams.width = map.width;
            identifyParams.height = map.height;
            
            dojo.connect(dijit.byId('map'), 'resize', function () {
                clearTimeout(resizeTimer);
                resizeTimer = setTimeout(function () {
                    map.resize();
                    map.reposition();
                }, 500);
            });
        });
    }
    dojo.addOnLoad(Init);
 
 
    function doIdentify(evt) {
        identifyParams.geometry = evt.mapPoint;
        identifyParams.mapExtent = map.extent;
        identifyTask.execute(identifyParams, function (idResults) { addToMap(idResults, evt); });
    }
    function addToMap(idResults, evt) {
        var idResult = idResults[0];
        print(idResult.feature.attributes.FID);
    }
 
    function print(id) {
   var url = esri.urlToObject("http://localhost/rsMap/rsMap.ashx?f=json&oidIndirizzo=" + id);

            var requestHandle = esri.request({
                url: url.path,
                content: url.query,
                handleAs: "json",
                load: function (data) {
                    printDlg.attr("content", "<div><object data='" + data.results.url + "' type='application/pdf' width='800' height='600'></object></div>");
                    printDlg.show();
                },
                error: function (error) {
                    alert("Errore di stampa");
                }
            });
}
 
 
</script>
</head>
<body class="tundra">
 
<table style="width:100%">
<tr>
<td>
<table id="navTable" width="100%">
<tbody>
<tr valign="top">
<td id="breadcrumbs">
ArcGIS JavaScript API: LimitiComuni
</td>
<td align="right" id="help">
Built using the  <a href="http://resources.esri.com/arcgisserver/apis/javascript/arcgis">ArcGIS JavaScript API</a>
</td>
</tr>
</tbody>
</table>
 
</td>
</tr>
</table>
<div id="map" style="margin:auto;width:97%;border:1px solid #000;"></div>
</body>
</html>