Database
 sql >> Database >  >> RDS >> Database

Completamento SQL. Storie di successo e fallimento

Lavoro per un'azienda che sviluppa IDE per l'interazione con i database da più di cinque anni. Prima di iniziare a scrivere questo articolo, non avevo idea di quante storie fantastiche ci sarebbero state davanti.

Il mio team sviluppa e supporta le funzionalità del linguaggio IDE e il completamento automatico del codice è il principale. Ho affrontato molte cose eccitanti che accadono. Alcune cose le abbiamo fatte benissimo sin dal primo tentativo, altre sono fallite anche dopo diversi scatti.

Analisi SQL e dialetti

SQL è un tentativo di sembrare un linguaggio naturale e il tentativo ha abbastanza successo, dovrei dire. A seconda del dialetto, ci sono diverse migliaia di parole chiave. Per distinguere un'affermazione da un'altra, spesso devi cercare una o due parole (token) in anticipo. Questo approccio è chiamato lookahead .

Esiste una classificazione del parser a seconda di quanto lontano possono guardare avanti:LA(1), LA(2) o LA(*), il che significa che un parser può guardare avanti quanto necessario per definire il fork giusto.

A volte, la fine di una clausola facoltativa corrisponde all'inizio di un'altra clausola facoltativa. Queste situazioni rendono l'analisi molto più difficile da eseguire. T-SQL non semplifica le cose. Inoltre, alcune istruzioni SQL possono avere, ma non necessariamente, terminazioni che possono entrare in conflitto con l'inizio di istruzioni precedenti.

Non ci credi? C'è un modo per descrivere i linguaggi formali attraverso la grammatica. Puoi generare un parser da esso usando questo o quello strumento. Gli strumenti e i linguaggi più importanti che descrivono la grammatica sono YACC e ANTLR.

YACC i parser generati vengono utilizzati nei motori MySQL, MariaDB e PostgreSQL. Potremmo provare a prenderli direttamente dal codice sorgente e sviluppare il completamento del codice e altre funzioni basate sull'analisi SQL utilizzando questi parser. Inoltre, questo prodotto riceverà aggiornamenti di sviluppo gratuiti e il parser si comporterà allo stesso modo del motore di origine.

Allora perché stiamo ancora usando ANTLR ? Supporta saldamente C#/.NET, ha un toolkit decente, la sua sintassi è molto più facile da leggere e scrivere. La sintassi ANTLR è diventata così utile che Microsoft ora la utilizza nella sua documentazione ufficiale C#.

Ma torniamo alla complessità SQL quando si tratta di analisi. Vorrei confrontare le dimensioni grammaticali delle lingue pubblicamente disponibili. In dbForge, utilizziamo i nostri pezzi di grammatica. Sono più completi degli altri. Sfortunatamente, sono sovraccaricati dagli inserti del codice C# per supportare diverse funzioni.

Le dimensioni grammaticali per le diverse lingue sono le seguenti:

JS – 475 righe del parser + 273 lexer =748 righe

Java – 615 righe del parser + 211 lexer =826 righe

C# – 1159 righe del parser + 433 lexer =1592 righe

С++ – 1933 righe

MySQL – 2515 righe del parser + 1189 lexer =3704 righe

T-SQL – 4035 righe del parser + 896 lexer =4931 righe

PL SQL – 6719 righe del parser + 2366 lexer =9085 righe

I finali di alcuni lexer presentano gli elenchi dei caratteri Unicode disponibili nella lingua. Tali elenchi sono inutili per quanto riguarda la valutazione della complessità del linguaggio. Pertanto, il numero di righe che ho preso è sempre terminato prima di questi elenchi.

È discutibile valutare la complessità dell'analisi della lingua in base al numero di righe nella grammatica della lingua. Tuttavia, credo sia importante mostrare i numeri che mostrano un'enorme discrepanza.

Non è tutto. Poiché stiamo sviluppando un IDE, dovremmo occuparci di script incompleti o non validi. Abbiamo dovuto escogitare molti trucchi, ma i clienti inviano ancora molti scenari di lavoro con script incompiuti. Dobbiamo risolverlo.

Guerre predicate

Durante l'analisi del codice, la parola a volte non ti dice quale delle due alternative scegliere. Il meccanismo che risolve questo tipo di imprecisioni è lookahead in ANTLR. Il metodo del parser è la catena inserita di if , e ognuno di loro guarda un passo avanti. Vedi l'esempio della grammatica che genera l'incertezza di questo tipo:

rule1:
  'a' rule2 | rule3
;

rule2:
  'b' 'c' 'd'
;

rule3:
  'b' 'c' 'e'
;

A metà della regola1, quando il token 'a' è già passato, il parser guarderà due passi avanti per scegliere la regola da seguire. Questo controllo verrà eseguito ancora una volta, ma questa grammatica può essere riscritta per escludere il lookahead . Lo svantaggio è che tali ottimizzazioni danneggiano la struttura, mentre l'aumento delle prestazioni è piuttosto limitato.

Ci sono modi più complessi per risolvere questo tipo di incertezza. Ad esempio, il Predicato della sintassi (SynPred) meccanismo in ANTLR3 . Aiuta quando la fine facoltativa di una clausola incrocia l'inizio della successiva clausola facoltativa.

In termini di ANTLR3, un predicato è un metodo generato che esegue un inserimento di testo virtuale secondo una delle alternative . In caso di successo, restituisce il vero valore e il completamento del predicato ha esito positivo. Quando è una voce virtuale, viene chiamata backtracking immissione in modalità. Se un predicato funziona correttamente, si verifica la voce reale.

È solo un problema quando un predicato inizia all'interno di un altro predicato. Quindi una distanza potrebbe essere attraversata centinaia o migliaia di volte.

Esaminiamo un esempio semplificato. Ci sono tre punti di incertezza:(A, B, C).

  1. Il parser inserisce A, ricorda la sua posizione nel testo, avvia una voce virtuale di livello 1.
  2. Il parser inserisce B, ricorda la sua posizione nel testo, avvia una voce virtuale di livello 2.
  3. Il parser inserisce C, ricorda la sua posizione nel testo, avvia una voce virtuale di livello 3.
  4. Il parser completa una voce virtuale di livello 3, torna al livello 2 e passa di nuovo C.
  5. Il parser completa una voce virtuale di livello 2, torna al livello 1 e supera B e C ancora una volta.
  6. Il parser completa una voce virtuale, restituisce ed esegue una voce reale tramite A, B e C.

Di conseguenza, tutti i controlli all'interno di C verranno eseguiti 4 volte, entro B – 3 volte, entro A – 2 volte.

Ma cosa succede se un'alternativa adatta è nella seconda o nella terza nell'elenco? Quindi una delle fasi del predicato fallirà. La sua posizione nel testo verrà ripristinata e verrà eseguito un altro predicato.

Quando analizziamo i motivi del blocco dell'app, spesso ci imbattiamo nella traccia di SynPred giustiziato diverse migliaia di volte. SynPred s sono particolarmente problematici nelle regole ricorsive. Purtroppo, SQL è ricorsivo per sua natura. La possibilità di utilizzare le sottoquery quasi ovunque ha il suo prezzo. Tuttavia, è possibile manipolare la regola per far sparire un predicato.

SynPred danneggia le prestazioni. Ad un certo punto, il loro numero è stato messo sotto rigido controllo. Ma il problema è che quando scrivi codice grammaticale, SynPred può sembrare poco ovvio per te. Inoltre, la modifica di una regola può far apparire SynPred in un'altra regola e ciò rende praticamente impossibile il controllo su di esse.

Abbiamo creato una semplice espressione regolare strumento per controllare il numero di predicati eseguiti dalla speciale attività MSBuild . Se il numero di predicati non corrisponde al numero specificato in un file, l'attività ha immediatamente fallito la compilazione e ha segnalato un errore.

Quando vede l'errore, uno sviluppatore dovrebbe riscrivere il codice della regola più volte per rimuovere i predicati ridondanti. Se uno non può evitare i predicati, lo sviluppatore lo aggiungerebbe a un file speciale che attira maggiore attenzione per la revisione.

In rare occasioni, abbiamo persino scritto i nostri predicati usando C# solo per evitare quelli generati da ANTLR. Fortunatamente esiste anche questo metodo.

Eredità grammaticale

Quando ci sono modifiche ai nostri DBMS supportati, dobbiamo soddisfarle nei nostri strumenti. Il supporto per le costruzioni della sintassi grammaticale è sempre un punto di partenza.

Creiamo una grammatica speciale per ogni dialetto SQL. Consente alcune ripetizioni del codice, ma è più facile che cercare di trovare ciò che hanno in comune.

Abbiamo deciso di scrivere il nostro preprocessore grammaticale ANTLR che esegue l'ereditarietà grammaticale.

È diventato anche ovvio che avevamo bisogno di un meccanismo per il polimorfismo:la capacità non solo di ridefinire la regola nel discendente, ma anche di chiamare quella di base. Vorremmo anche controllare la posizione quando si chiama la regola di base.

Gli strumenti sono un vantaggio decisivo quando confrontiamo ANTLR con altri strumenti di riconoscimento del linguaggio, Visual Studio e ANTLRWorks. E non vuoi perdere questo vantaggio durante l'implementazione dell'eredità. La soluzione era specificare la grammatica di base in una grammatica ereditata in un formato di commento ANTLR. Per gli strumenti ANTLR è solo un commento, ma possiamo estrarne tutte le informazioni richieste.

Abbiamo scritto un'attività MsBuild incorporata nell'intero sistema di compilazione come azione preliminare alla compilazione. Il compito era svolgere il lavoro di un preprocessore per la grammatica ANTLR generando la grammatica risultante dalla sua base e dai colleghi ereditati. La grammatica risultante è stata elaborata da ANTLR stesso.

Post-elaborazione ANTLR

In molti linguaggi di programmazione, le parole chiave non possono essere utilizzate come nomi di soggetti. Ci possono essere da 800 a 3000 parole chiave in SQL a seconda del dialetto. La maggior parte di essi sono legati al contesto all'interno dei database. Pertanto, vietarli come nomi di oggetti vanificherebbe gli utenti. Ecco perché SQL ha parole chiave riservate e non riservate.

Non puoi nominare il tuo oggetto come parola riservata (SELECT, FROM, ecc.) senza citarlo, ma puoi farlo su una parola non riservata (CONVERSATION, AVAILABILITY, ecc.). Questa interazione rende più difficile lo sviluppo del parser.

Durante l'analisi lessicale, il contesto è sconosciuto, ma un parser richiede già numeri diversi per l'identificatore e la parola chiave. Ecco perché abbiamo aggiunto un'altra post-elaborazione al parser ANTLR. Ha sostituito tutti gli ovvi controlli dell'identificatore chiamando un metodo speciale.

Questo metodo ha un controllo più dettagliato. Se la voce chiama un identificatore e ci aspettiamo che l'identificatore venga soddisfatto in seguito, allora va tutto bene. Ma se una parola non riservata è una voce, dovremmo ricontrollarla. Questo controllo aggiuntivo esamina la ricerca del ramo nel contesto corrente in cui questa parola chiave non riservata può essere una parola chiave. Se non ci sono tali rami, può essere utilizzato come identificatore.

Tecnicamente, questo problema potrebbe essere risolto per mezzo di ANTLR ma questa decisione non è ottimale. Il metodo ANTLR consiste nel creare una regola che elenchi tutte le parole chiave non riservate e un identificatore di lessema. Più avanti, verrà utilizzata una regola speciale al posto di un identificatore di lessema. Questa soluzione fa sì che uno sviluppatore non dimentichi di aggiungere la parola chiave dove viene utilizzata e nella regola speciale. Inoltre, ottimizza il tempo trascorso.

Errori nell'analisi della sintassi senza alberi

L'albero della sintassi è solitamente il risultato del lavoro del parser. È una struttura dati che riflette il testo del programma attraverso la grammatica formale. Se desideri implementare un editor di codice con il completamento automatico della lingua, molto probabilmente otterrai il seguente algoritmo:

  1. Analizza il testo nell'editor. Quindi ottieni un albero della sintassi.
  2. Trova un nodo sotto il carrello e confrontalo con la grammatica.
  3. Scopri quali parole chiave e tipi di oggetti saranno disponibili al Point.

In questo caso, la grammatica è facile da immaginare come un grafico o una macchina a stati.

Sfortunatamente, solo la terza versione di ANTLR era disponibile quando l'IDE dbForge aveva iniziato il suo sviluppo. Tuttavia, non era così agile e sebbene tu potessi dire ad ANTLR come costruire un albero, l'utilizzo non era agevole.

Inoltre, molti articoli su questo argomento hanno suggerito di utilizzare il meccanismo delle "azioni" per eseguire il codice quando il parser stava passando attraverso la regola. Questo meccanismo è molto utile, ma ha portato a problemi di architettura e ha reso più complesso il supporto di nuove funzionalità.

Il fatto è che un singolo file grammaticale ha iniziato ad accumulare "azioni" a causa del gran numero di funzionalità che avrebbero dovuto piuttosto essere distribuite a build diverse. Siamo riusciti a distribuire gestori di azioni a build diverse e apportare una variazione subdola del pattern abbonato-notificatore per quella misura.

ANTLR3 funziona 6 volte più velocemente di ANTLR4 secondo le nostre misurazioni. Inoltre, l'albero della sintassi per script di grandi dimensioni poteva richiedere troppa RAM, il che non era una buona notizia, quindi dovevamo operare all'interno dello spazio degli indirizzi a 32 bit di Visual Studio e SQL Management Studio.

Post-elaborazione del parser ANTLR

Quando si lavora con le stringhe, uno dei momenti più critici è la fase dell'analisi lessicale in cui dividiamo il copione in parole separate.

ANTLR prende come input la grammatica che specifica la lingua e restituisce un parser in una delle lingue disponibili. Ad un certo punto, il parser generato è cresciuto a tal punto che abbiamo avuto paura di eseguirne il debug. Se si preme F11 (entra in) durante il debug e si passa al file del parser, Visual Studio si arresterebbe in modo anomalo.

Si è scoperto che non è riuscito a causa di un'eccezione OutOfMemory durante l'analisi del file del parser. Questo file conteneva più di 200.000 righe di codice.

Ma il debug del parser è una parte essenziale del processo di lavoro e non puoi ometterlo. Con l'aiuto di classi parziali C#, abbiamo analizzato il parser generato usando espressioni regolari e lo abbiamo diviso in pochi file. Visual Studio ha funzionato perfettamente con esso.

Analisi lessicale senza sottostringa prima dell'API Span

Il compito principale dell'analisi lessicale è la classificazione:definire i confini delle parole e confrontarli con un dizionario. Se la parola viene trovata, il lexer restituirà il suo indice. In caso contrario, la parola è considerata un identificatore di oggetto. Questa è una descrizione semplificata dell'algoritmo.

Lexing in background durante l'apertura del file

L'evidenziazione della sintassi si basa sull'analisi lessicale. Questa operazione richiede solitamente molto più tempo rispetto alla lettura del testo dal disco. Qual è il trucco? In un thread, il testo viene letto dal file, mentre l'analisi lessicale viene eseguita in un thread diverso.

Il lexer legge il testo riga per riga. Se richiede una riga che non esiste, si fermerà e attenderà.

BlockingCollection di BCL funziona su una base simile e l'algoritmo comprende un'applicazione tipica di un modello simultaneo produttore-consumatore. L'editor che lavora nel thread principale richiede dati sulla prima riga evidenziata e, se non è disponibile, si fermerà e attenderà. Nel nostro editor abbiamo utilizzato il modello produttore-consumatore e la raccolta-blocco due volte:

  1. Leggere da un file è un Producer, mentre lexer è un Consumer.
  2. Lexer è già un produttore e l'editor di testo è un consumatore.

Questa serie di trucchi ci consente di ridurre notevolmente il tempo impiegato per l'apertura di file di grandi dimensioni. La prima pagina del documento viene mostrata molto rapidamente, tuttavia, il documento potrebbe bloccarsi se gli utenti tentano di spostarsi alla fine del file entro i primi secondi. Succede perché il lettore in background e il lexer devono raggiungere la fine del documento. Tuttavia, se l'utente si sposta lentamente dall'inizio del documento verso la fine, non ci saranno blocchi evidenti.

Ottimizzazione ambigua:analisi lessicale parziale

L'analisi sintattica è solitamente suddivisa in due livelli:

  • il flusso di caratteri di input viene elaborato per ottenere i lessemi (token) in base alle regole del linguaggio:questa è chiamata analisi lessicale
  • il parser consuma il flusso di token controllandolo in base alle regole grammaticali formali e spesso crea un albero della sintassi.

L'elaborazione delle stringhe è un'operazione costosa. Per ottimizzarlo, abbiamo deciso di non eseguire ogni volta un'analisi lessicale completa del testo ma di rianalizzare solo la parte che è stata modificata. Ma come gestire costrutti multilinea come commenti o righe di blocco? Abbiamo memorizzato uno stato di fine riga per ogni riga:"nessun token multilinea" =0, "l'inizio di un commento di blocco" =1, "l'inizio di una stringa letterale multilinea" =2. L'analisi lessicale inizia dalla sezione modificata e termina quando lo stato di fine riga è uguale a quello memorizzato.

C'era un problema con questa soluzione:è estremamente scomodo monitorare i numeri di riga in tali strutture mentre il numero di riga è un attributo obbligatorio di un token ANTLR perché quando una riga viene inserita o eliminata, il numero della riga successiva dovrebbe essere aggiornato di conseguenza. Abbiamo risolto impostando immediatamente un numero di riga, prima di consegnare il token al parser. I test che abbiamo eseguito in seguito hanno dimostrato che le prestazioni sono migliorate del 15-25%. Il miglioramento effettivo è stato ancora maggiore.

La quantità di RAM richiesta per tutto questo si è rivelata molto più di quanto ci aspettassimo. Un token ANTLR era composto da:un punto iniziale – 8 byte, un punto finale – 8 byte, un link al testo della parola – 4 o 8 byte (senza menzionare la stringa stessa), un link al testo del documento – 4 o 8 byte, e un tipo di token:4 byte.

Quindi cosa possiamo concludere? Ci siamo concentrati sulle prestazioni e abbiamo ottenuto un consumo eccessivo di RAM in un posto che non ci aspettavamo. Non pensavamo che ciò sarebbe accaduto perché abbiamo cercato di utilizzare strutture leggere anziché classi. Sostituendoli con oggetti pesanti, abbiamo consapevolmente cercato spese di memoria aggiuntive per ottenere prestazioni migliori. Fortunatamente, questo ci ha insegnato una lezione importante, quindi ora ogni ottimizzazione delle prestazioni termina con la profilazione del consumo di memoria e viceversa.

Questa è una storia con una morale. Alcune funzionalità hanno iniziato a funzionare quasi istantaneamente e altre solo un po' più velocemente. Dopotutto, sarebbe impossibile eseguire il trucco dell'analisi lessicale in background se non ci fosse un oggetto in cui uno dei thread potrebbe memorizzare i token.

Tutti gli ulteriori problemi si verificano nel contesto dello sviluppo desktop sullo stack .NET.

Il problema dei 32 bit

Alcuni utenti scelgono di utilizzare versioni standalone dei nostri prodotti. Altri continuano a lavorare all'interno di Visual Studio e SQL Server Management Studio. Molte estensioni sono state sviluppate per loro. Una di queste estensioni è SQL Complete. Per chiarire, fornisce più poteri e funzionalità rispetto allo standard di completamento del codice SSMS e VS per SQL.

L'analisi SQL è un processo molto costoso, sia in termini di risorse di CPU che di RAM. Per richiedere l'elenco degli oggetti negli script utente, senza inutili chiamate al server, memorizziamo la cache degli oggetti nella RAM. Spesso non occupa molto spazio, ma alcuni dei nostri utenti hanno database che contengono fino a un quarto di milione di oggetti.

Lavorare con SQL è abbastanza diverso dal lavorare con altri linguaggi. In C# non ci sono praticamente file anche con mille righe di codice. Nel frattempo, in SQL uno sviluppatore può lavorare con un dump del database composto da diversi milioni di righe di codice. Non c'è niente di insolito.

DLL-Hell inside VS

C'è uno strumento utile per sviluppare plugin in .NET Framework, è un dominio applicativo. Tutto viene eseguito in modo isolato. È possibile scaricare. Per la maggior parte, l'implementazione delle estensioni è, forse, il motivo principale per cui sono stati introdotti i domini applicativi.

Inoltre, c'è il MAF Framework, che è stato progettato da MS per risolvere il problema della creazione di componenti aggiuntivi per il programma. Isola questi componenti aggiuntivi in ​​modo tale da poterli inviare a un processo separato e assumere tutte le comunicazioni. Francamente, questa soluzione è troppo ingombrante e non ha guadagnato molta popolarità.

Sfortunatamente, Microsoft Visual Studio e SQL Server Management Studio basati su di esso implementano il sistema di estensione in modo diverso. Semplifica l'accesso alle applicazioni di hosting per i plug-in, ma li costringe a integrarsi insieme all'interno di un processo e a un dominio con un altro.

Proprio come qualsiasi altra applicazione nel 21° secolo, la nostra ha molte dipendenze. La maggior parte di loro sono librerie ben note, collaudate e popolari nel mondo .NET.

Inserire i messaggi all'interno di un lucchetto

Non è ampiamente noto che .NET Framework pomperà la coda dei messaggi di Windows all'interno di ogni WaitHandle. Per inserirlo in ogni blocco, è possibile chiamare qualsiasi gestore di qualsiasi evento in un'applicazione se questo blocco ha il tempo di passare alla modalità kernel e non viene rilasciato durante la fase di attesa di rotazione.

Ciò può comportare il rientro in alcuni luoghi molto inaspettati. Alcune volte ha portato a problemi come "La raccolta è stata modificata durante l'enumerazione" e varie ArgumentOutOfRangeException.

Aggiunta di un assembly a una soluzione utilizzando SQL

Quando il progetto cresce, il compito di aggiungere assiemi, inizialmente semplice, si sviluppa in una dozzina di passaggi complicati. Una volta, abbiamo dovuto aggiungere una dozzina di assiemi diversi alla soluzione, abbiamo eseguito un grande refactoring. Sono state create quasi 80 soluzioni, comprese quelle di prodotto e di prova, sulla base di circa 300 progetti .NET.

Sulla base delle soluzioni dei prodotti, abbiamo scritto i file Inno Setup. Includevano elenchi di assembly inclusi nell'installazione scaricati dall'utente. L'algoritmo per aggiungere un progetto era il seguente:

  1. Crea un nuovo progetto.
  2. Aggiungi un certificato. Imposta il tag della build.
  3. Aggiungi un file di versione.
  4. Riconfigura i percorsi in cui sta andando il progetto.
  5. Rinomina la cartella in modo che corrisponda alle specifiche interne.
  6. Aggiungi di nuovo il progetto alla soluzione.
  7. Aggiungi un paio di assiemi a cui tutti i progetti necessitano di collegamenti.
  8. Aggiungi la build a tutte le soluzioni necessarie:test e prodotto.
  9. Per tutte le soluzioni del prodotto, aggiungi gli assiemi all'installazione.

Questi 9 passaggi dovevano essere ripetuti circa 10 volte. I passaggi 8 e 9 non sono così banali ed è facile dimenticare di aggiungere build ovunque.

Di fronte a un compito così grande e di routine, qualsiasi programmatore normale vorrebbe automatizzarlo. Questo è esattamente quello che volevamo fare. Ma come indicare esattamente quali soluzioni e installazioni aggiungere al progetto appena creato? Gli scenari sono tanti e per di più, è difficile prevederne alcuni.

Ci è venuta un'idea pazza. Le soluzioni sono collegate a progetti come molti-a-molti, progetti con installazioni allo stesso modo e SQL può risolvere esattamente il tipo di attività che avevamo.

Abbiamo creato un'app .Net Core Console che esegue la scansione di tutti i file .sln nella cartella di origine, recupera l'elenco dei progetti da loro con l'aiuto di DotNet CLI e lo inserisce nel database SQLite. Il programma ha alcune modalità:

  • Nuovo:crea un progetto e tutte le cartelle necessarie, aggiunge un certificato, imposta un tag, aggiunge una versione, assiemi minimi essenziali.
  • Add-Project – aggiunge il progetto a tutte le soluzioni che soddisfano la query SQL che verrà fornita come uno dei parametri. Per aggiungere il progetto alla soluzione, il programma interno utilizza DotNet CLI.
  • Add-ISS – aggiunge il progetto a tutte le installazioni che soddisfano le query SQL.

Sebbene l'idea di indicare l'elenco delle soluzioni tramite la query SQL possa sembrare ingombrante, ha completamente chiuso tutti i casi esistenti e molto probabilmente tutti i casi possibili in futuro.

Lascia che ti dimostri lo scenario. Crea un progetto "A" e aggiungilo a tutte le soluzioni in cui progetti "B" viene utilizzato:

dbforgeasm add-project Folder1\Folder2\A "SELECT s.Id FROM Projects p JOIN Solutions s ON p.SolutionId = s.Id WHERE p.Name = 'B'"

Un problema con LiteDB

Un paio di anni fa, abbiamo avuto il compito di sviluppare una funzione in background per salvare i documenti degli utenti. Aveva due flussi applicativi principali:la possibilità di chiudere istantaneamente l'IDE e uscire, e al ritorno di ricominciare da dove avevi interrotto e la possibilità di ripristinare in situazioni urgenti come blackout o arresti anomali del programma.

Per implementare questa attività, è stato necessario salvare il contenuto dei file da qualche parte sul lato e farlo spesso e rapidamente. Oltre ai contenuti, è stato necessario salvare alcuni metadati, il che ha reso scomodo l'archiviazione diretta nel file system.

A quel punto, abbiamo trovato la libreria LiteDB, che ci ha impressionato per la sua semplicità e prestazioni. LiteDB è un database incorporato veloce e leggero, interamente scritto in C#. La velocità e la semplicità generale ci hanno conquistato.

Nel corso del processo di sviluppo, l'intero team è stato soddisfatto di lavorare con LiteDB. I problemi principali, però, sono iniziati dopo il rilascio.

La documentazione ufficiale garantiva che il database garantisse un funzionamento corretto con accesso simultaneo da più thread e diversi processi. Test sintetici aggressivi hanno mostrato che il database non funziona correttamente in un ambiente multithread.

Per risolvere rapidamente il problema, abbiamo sincronizzato i processi con l'aiuto dell'interprocesso ReadWriteLock auto-scritto. Ora, dopo quasi tre anni, LiteDB funziona molto meglio.

StreamStringList

Questo problema è l'opposto del caso dell'analisi lessicale parziale. Quando lavoriamo con un testo, è più conveniente lavorarci come un elenco di stringhe. Le stringhe possono essere richieste in ordine casuale, ma è ancora presente una certa densità di accesso alla memoria. Ad un certo punto, è stato necessario eseguire diverse attività per elaborare file molto grandi senza un carico di memoria completo. L'idea era la seguente:

  1. Per leggere il file riga per riga. Ricorda gli offset nel file.
  2. Su richiesta, emettere la riga successiva, impostare un offset richiesto e restituire i dati.

Il compito principale è completato. Questa struttura non occupa molto spazio rispetto alla dimensione del file. Nella fase di test, controlliamo accuratamente l'impronta di memoria per file grandi e molto grandi. I file di grandi dimensioni sono stati elaborati per molto tempo e quelli piccoli verranno elaborati immediatamente.

Non c'era alcun riferimento per controllare il tempo di esecuzione . La RAM è chiamata memoria ad accesso casuale:è il suo vantaggio competitivo rispetto all'SSD e in particolare all'HDD. Questi driver iniziano a funzionare male per l'accesso casuale. Si è scoperto che questo approccio ha rallentato il lavoro di quasi 40 volte, rispetto al caricamento completo di un file in memoria. Inoltre, leggiamo il file 2,5 -10 a tempo pieno a seconda del contesto.

La soluzione era semplice e il miglioramento era sufficiente in modo che l'operazione richiedesse solo un po' più di tempo rispetto a quando il file è completamente caricato in memoria.

Allo stesso modo, anche il consumo di RAM è stato insignificante. Abbiamo trovato ispirazione nel principio di caricare i dati dalla RAM in un processore cache. Quando accedi a un elemento dell'array, il processore copia dozzine di elementi vicini nella sua cache perché gli elementi necessari si trovano spesso nelle vicinanze.

Molte strutture di dati utilizzano questa ottimizzazione del processore per ottenere le massime prestazioni. È a causa di questa particolarità che l'accesso casuale agli elementi dell'array è molto più lento dell'accesso sequenziale. Abbiamo implementato un meccanismo simile:leggiamo un insieme di mille stringhe e ne ricordiamo gli offset. Quando accediamo alla stringa 1001, eliminiamo le prime 500 stringhe e carichiamo le 500 successive. Nel caso avessimo bisogno di una delle prime 500 righe, andiamo ad essa separatamente, perché abbiamo già l'offset.

Il programmatore non ha necessariamente bisogno di formulare e verificare accuratamente i requisiti non funzionali. Di conseguenza, abbiamo ricordato per i casi futuri che dobbiamo lavorare in sequenza con la memoria persistente.

Analisi delle eccezioni

È possibile raccogliere facilmente i dati sull'attività degli utenti sul Web. Tuttavia, non è il caso dell'analisi delle applicazioni desktop. Non esiste uno strumento del genere in grado di fornire un incredibile set di metriche e strumenti di visualizzazione come Google Analytics. Come mai? Ecco le mie ipotesi:

  1. Nel corso della maggior parte della storia dello sviluppo di applicazioni desktop, non hanno avuto un accesso stabile e permanente al Web.
  2. Ci sono molti strumenti di sviluppo per applicazioni desktop. Pertanto, è impossibile creare uno strumento di raccolta dati utente multiuso per tutti i framework e le tecnologie dell'interfaccia utente.

Un aspetto chiave della raccolta dei dati è tenere traccia delle eccezioni. Ad esempio, raccogliamo dati sugli arresti anomali. In precedenza, i nostri utenti dovevano scrivere loro stessi un'e-mail di assistenza clienti, aggiungendo uno Stack Trace di un errore, che veniva copiato da una finestra speciale dell'app. Pochi utenti hanno seguito tutti questi passaggi. I dati raccolti sono completamente anonimi, il che ci priva dell'opportunità di scoprire le fasi di riproduzione o qualsiasi altra informazione dall'utente.

D'altra parte, i dati di errore sono nel database di Postgres e questo apre la strada a un controllo istantaneo di dozzine di ipotesi. Puoi ottenere immediatamente le risposte semplicemente effettuando query SQL sul database. Spesso non è chiaro da un solo stack o tipo di eccezione come si è verificata l'eccezione, ecco perché tutte queste informazioni sono fondamentali per studiare il problema.

Inoltre, hai l'opportunità di analizzare tutti i dati raccolti e trovare i moduli e le classi più problematici. Basandosi sui risultati dell'analisi, puoi pianificare refactoring o test aggiuntivi per coprire queste parti del programma.

Servizio di decodifica dello stack

Le build .NET contengono codice IL, che può essere facilmente riconvertito in codice C#, accurato per l'operatore, utilizzando diversi programmi speciali. Uno dei modi per proteggere il codice del programma è il suo offuscamento. I programmi possono essere rinominati; è possibile sostituire metodi, variabili e classi; il codice può essere sostituito con il suo equivalente, ma è davvero incomprensibile.

La necessità di offuscare il codice sorgente appare quando distribuisci il tuo prodotto in un modo che suggerisce che l'utente ottiene le build della tua applicazione. Le applicazioni desktop sono quei casi. Tutte le build, comprese le build intermedie per i tester, vengono accuratamente offuscate.

La nostra unità di garanzia della qualità utilizza strumenti di decodifica dello stack dello sviluppatore dell'offuscatore. Per avviare la decodifica, devono eseguire l'applicazione, trovare le mappe di deoffuscamento pubblicate da CI per una build specifica e inserire lo stack di eccezioni nel campo di input.

Versioni ed editor diversi erano offuscati in modo diverso, il che rendeva difficile per uno sviluppatore studiare il problema o addirittura metterlo sulla strada sbagliata. Era ovvio che questo processo doveva essere automatizzato.

Il formato della mappa di deoffuscamento si è rivelato piuttosto semplice. Lo abbiamo facilmente annullato e abbiamo scritto un programma di decodifica dello stack. Poco prima, è stata sviluppata un'interfaccia utente Web per visualizzare le eccezioni in base alle versioni del prodotto e raggrupparle in base allo stack. Era un sito Web .NET Core con un database in SQLite.

SQLite è uno strumento accurato per piccole soluzioni. Abbiamo cercato di inserire anche le mappe di deoffuscamento lì. Ogni build ha generato circa 500mila coppie di crittografia e decrittografia. SQLite non è in grado di gestire una velocità di inserimento così aggressiva.

Mentre i dati su una build sono stati inseriti nel database, altri due sono stati aggiunti alla coda. Non molto tempo prima di quel problema, stavo ascoltando un rapporto su Clickhouse ed ero ansioso di provarlo. Si è rivelato eccellente, la velocità di inserimento è aumentata di oltre 200 volte.

Detto questo, la decodifica dello stack (lettura dal database) è rallentata di quasi 50 volte, ma poiché ogni stack ha impiegato meno di 1 ms, non è stato conveniente dedicare tempo allo studio di questo problema.

ML.NET for classification of exceptions

On the subject of the automatic processing of exceptions, we made a few more enhancements.

We already had the Web-UI for a convenient review of exceptions grouped by stacks. We had a Grafana for high-level analysis of product stability at the level of versions and product lines. But a programmer’s eye, constantly craving optimization, caught another aspect of this process.

Historically, dbForge line development was divided among 4 teams. Each team had its own functionality to work on, though the borderline was not always obvious. Our technical support team, relying on their experience, read the stack and assigned it to this or that team. They managed it quite well, yet, in some cases, mistakes occurred. The information on errors from analytics came to Jira on its own, but the support team still needed to classify tasks by team.

In the meantime, Microsoft introduced a new library – ML.NET. And we still had this classification task. A library of that kind was exactly what we needed. It extracted stacks of all resolved exceptions from Redmine, a project management system that we used earlier, and Jira, which we use at present.

We obtained a data array that contained some 5 thousand pairs of Exception StackTrace and command. We put 100 exceptions aside and used the rest of the exceptions to teach a model. The accuracy was about 75%. Again, we had 4 teams, hence, random and round-robin would only attain 25%. It sufficiently saved up their time.

To my way of thinking, if we considerably clean up incoming data array, make a thorough examination of the ML.NET library, and theoretical foundation in machine learning, on the whole, we can improve these results. At the same time, I was impressed with the simplicity of this library:with no special knowledge in AI and ML, we managed to gain real cost-benefits in less than an hour.

Conclusion

Hopefully, some of the readers happen to be users of the products I describe in this article, and some lines shed light on the reasons why this or that function was implemented this way.

And now, let me conclude:

We should make decisions based on data and not assumptions. It is about behavior analytics and insights that we can obtain from it.

We ought to constantly invest in tools. There is nothing wrong if we need to develop something for it. In the next few months, it will save us a lot of time and rid us of routine. Routine on top of time expenditure can be very demotivating.

When we develop some internal tools, we get a super chance to try out new technologies, which can be applied in production solutions later on.

There are infinitely many tools for data analysis. Still, we managed to extract some crucial information using SQL tools. This is the most powerful tool to formulate a question to data and receive an answer in a structured form.