Pôvodne som chcel tento môj "blogpost" pomenovať "Ako som si spomalil Angular", ale počas riešenia toho zádrheľu (keďže inak to pomenovať neviem), som narazil na podobný problém aj na ďalšom projekte, ktorý je skôr plain old JavaScript. Verím, že riešenie od iných developerov niekde na internete existuje, a možno aj o tom bolo spísané nemalé množstvo slov, no pre tieto prípady sa vraví "opakovanie je matka múdrosti", a je to pre mňa príležitosť napísať post sem. Toľko k úvodnému blábolu a poďme k veci.

Čo sa vlastne stalo

(Pre priaznivcov krátkeho čítania a skipovania deja, stačí kliknúť sem)

Už nejaký ten piatoček sa tvárim ako developer skoro všetkého druhu pre FinStat.sk (aha @vlko, reklama). A v rámci tohto projektu som posledným rokom robil na zjednodušení prehľadávania tej noše dát, ktorú sme za tie roky fungovania naakumulovali. Okrem rýchlych exportov zo stránky alebo PDF reportov v detaile firmy sme nemali prostriedok, ako ponúknuť zákazníkovi preddefinovaný subset dát vhodný pre neho na ďalšie spracovanie. Jediná možnosť bola využiť jeden z našich siahodlhých datasetov, respektíve požiadať o export na mieru. Tu sa konečne dostala viac do popredia myšlienka, ktorú malo vedenie v zásobe pre chvíle typu "a čo teraz".

Pozadie riešenia je veľmi jednoduché. Naagregované dáta, ktoré máme už pripravené v rámci veľkých datasetov, vhodne prelejeme do SQL databázy a ponúkneme užívateľom rozhranie, kde si môžu z týchto dát vystrihnúť len toľko, koľko v danej situácii potrebujú na spracovanie. A tak vznikol náš Elite Screener (jé @vlko, opäť reklama). Aby frontend bol pre užívateľa čo najresponzívnejší, zvolil sa Angular ako framework of choice (okrem toho sme ho využívali v iných častiach FinStatu, takže prečo miešať ovocie).

Prvý prototyp po vychytaní všetkých UX, UI a ešte tých BI chrobákov vyzeral sľubne, a na skúšku sme ho spustili so základným datasetom. Užívateľ si nadefinoval, ktoré údaje chce vidieť. Na základe nich sa mu zobrazila prvá strana datasetu a načítali filtre vhodné pre jeho dáta, aby si mohol okresať výsledok na potrebnú úroveň. Aplikácia bežala svižne. Modal okná, loading bary, tabuľky a filtre sa načítavali správne, asynchrónne pomocou všetkých tých ajaxov, promisov, subscriberov, skrátka všetkého, čo na tento účel JavaScript a Angular ponúka.
Plus pre urýchlenie a zamedzenie redundantných volaní sa niektoré informácie in-memory cachujú. Chrumkavo, žltučko, ružovučko.

Po týždňoch úspešného fungovania sme sa rozhodli sprístupniť ďalšiu časť datasetov a z Elite Screenera sa stal Ultimate Screener. Dáta sa naliali do databázy, vytvorili potrebné indexy, doplnili sa štruktúry stĺpcov a filtrov. Šup na testovanie. Bum. Chrumkavosť sa stratila a prišla frustrácia. Aplikácia nespomalila, ale doslova sa v určitom momente zadrhla a nereagoval žiaden z UI prvkov. Po znateľnej chvíľke sa odsekla a opäť fungovala ako mala.

Prvé kolo skúmania išlo na databázu. Chýbali nám indexy? Zadali sme kombináciu dát, ktorá nebola optimalizovaná? Tam to nebolo. Databáza odpovedala na SQL requesty svižne. Dev konzola v prehliadači ukazovala, že odpoveď servera je tiež rýchla, a moc sa nezmenila. Takže niečo asi máme v kóde zle (nebuďme horenosi, nech sme akokoľvek skúsení, niekedy skrátka overengineerneme aj to, čo nemusíme
). Doplnilo sa niekoľko debug pointov, console.log-ov, a spustili sa nástroje na sledovanie Angular komponentov. Tieto všetky opatrenia dokázali, že aplikácia ide pomaly (aké prekvapenie), ale poukazovali na problém na riadky a súbory vzdialený od toho skutočného.

FinStat EliteScreener Slow Run

The "Ahá" moment

Na prvý hint vo veci narazila kolegyňa, ktorú pre účel anonymizácie nazveme Maca. V rámci testovania sa sťažovala na tooltip hodnoty bunky, ktorý nezodpovedal jej účelu. Pôvodne som tomu nevenoval dostatočnú pozornosť - mea culpa, berte si z toho ponaučenie. Tooltip bol totiž z modálneho okna, ktoré som považoval za zavreté. Síce už nebolo vidno, ale DOM document ho ešte nevyhodil zo svojho stromu.

Takže som sa deep divoval do hlbín zdrojových kódov a komponentov tretích strán a narazil som tam na niečo podobné tomuto setTimeout(() => {dialog.close()});. A takýchto priamych alebo inak v subsriberoch zabalených konštruktov používam pomerne dosť.

Tu som si spomenul, ako som na Vianoce vysvetľoval synovcovi, ako funguje jeho darček, Arduino Uno, a prečo mu nereaguje kód jeho tutoriálového blikátka na stlačenie tlačidla, keď je medzi volaniami funkcií niekoľkosekundový delay.

Celá skladačka zapadla ako zadok na nočník a už som videl v hlave tú kaskádovosť udalostí, ktorá nastala. Keďže ako všetci vieme, a občas na to pri dnešnej rýchlosti prehliadačov zabudneme, napriek všetkým ajaxom, promisom a iným subscriberom, javascript si skrátka takéto volanie uchováva a zavolá ho až v momente, keď sa trošku uvoľní. A o to viac, keď jedna "asynchrónna" udalosť odpáli niekoľko ďalších, ktoré bez výsledku predošlých nevedia pracovať. Povedzme, že niektoré z nich sa dali presunúť a odštartovať skôr, alebo inéspojiť dokopy, čo sme aj spravili, len stále to nebolo ono.

Code

Nakoniec som vytvoril do aplikácie tento servis. (TypeScript for scale)

export class Task {
    public priority: number;
    public name: string;
    public func: () => void;
}

export class ScheduledTask extends Task {
    public delay: number;
}

export class TaskService {
    private checkTaskDelay: number = 24;
    private runTaskBatchSize: number = 3;
    private taskList: Task[] = [];
    private scheduledTaskList: ScheduledTask[] = []
    private isTaskRunning: boolean = false;

    constructor(
    ) {
        this.taskList = [];
        this.scheduledTaskList = [];
        this.isTaskRunning = false;
    }

    invokeScheduledTask(delay: number, priority: number, func: () => void, name: string = null, doCheckTask: boolean = true) {
        if (func !== undefined && func !== null) {
            let task: ScheduledTask = {
                priority: priority,
                name: name,
                func: func,
                delay: delay + this.checkTaskDelay, // + for first check cycle
            };
            var index = this.scheduledTaskList.findIndex(x => x.delay > delay);
            if (index < 0) {
                this.scheduledTaskList.push(task);
                index = this.scheduledTaskList.length;
            } else {
                this.scheduledTaskList.splice(index, 0, task);
            }
            //console.log(`Scheduled Task Enqueued: ${name} as position ${index}. Scheduled Tasks in queue: ${this.scheduledTaskList.length}`);
            if (doCheckTask) {
                this.checkTasks();
            }
        }
    }

    invokeTask(priority: number, func: () => void, name: string = null, doCheckTask: boolean = true) {
        if (func !== undefined && func !== null) {
            let task: Task = {
                priority: priority,
                name: name,
                func: func
            };
            var index = this.taskList.findIndex(x => x.priority > priority);
            if (index < 0) {
                this.taskList.push(task);
                index = this.taskList.length;
            } else {
                this.taskList.splice(index, 0, task);
            }
            //console.log(`Task Enqueued: ${name} as position ${index}. Tasks in queue: ${this.taskList.length}`);
            if (doCheckTask) {
                this.checkTasks();
            }
        }
    }

    checkTasks() {
        if (!this.isTaskRunning && (this.taskList.length > 0 || this.scheduledTaskList.length > 0)) {
            this.isTaskRunning = true;
            this.scheduledTaskList.forEach((t) => {
                t.delay -= this.checkTaskDelay;
                if (t.delay <= 0) {
                    this.invokeTask(t.priority, t.func, t.name, false);
                }
            });
            this.scheduledTaskList = this.scheduledTaskList.filter(x => x.delay > 0);

            let batch = this.runTaskBatchSize;
            do {
                if (this.taskList.length > 0) {
                    const task = this.taskList.shift();
                    //console.log(`Starting task ${task.name}. Tasks in queue: ${this.taskList.length}`);
                    //console.time(task.name);
                    task.func();
                    batch--;
                    //console.timeEnd(task.name);
                } else {
                    batch = 0;
                }
            } while (batch > 0);
            
            if (this.taskList.length > 0 || this.scheduledTaskList.length > 0) {
                let self = this;
                setTimeout(function () {
                    self.checkTasks();
                }, this.checkTaskDelay);
            }
            this.isTaskRunning = false;
        }
    }
}

A každé kritické a poajaxové volanie som zabalil do volania taskService.invokeTask(). Veľa nekontrolovaných setTimeout podobných volaní som úmyselne bottleneckol do tejto fronty a cyklu, ktorý ho v nastaviteľnom intervale postupne vykonáva. Následok tohto riešenia je síce, že som si aplikáciu úmyselne "spomalil". Ale z user experience hľadiska je opäť v chrumkavom stave, a pre užívateľa je to spomalenie nebadateľné.

FinStat Elite Screener how should it work

Ak ste si kód aspoň zbežne pozreli, pridal som tam parameter priority, aby som vedel ľahšie prioritizovať spracovanie. Ďalším updatom bolo pridanie metódy invokeScheduledTask, ktorá má oneskorene spustiť úlohu namiesto setTimeout s príslušným časovým parametrom.

Chýba funkcia na opakujúcu sa úlohu. Tá tam nie je úmyselne, keďže podľa, možno nielen, môjho názoru namiesto nastavenia volania setInterval , zavolám setTimeout s funkciou, ktorá na konci spracovanianaplánuje jej opätovné spustenie, a nemusím sa starať o synchronizáciu kódu, keď náhodou novšia keď náhodou novšia úloha dobehne skôr ako staršia. Takže niečo podobné viem zabezpečiť vo svojom service volaním invokeTask a invokeScheduledTask.

TL;NR

Tak sme na konci tohto siahodlhého výlevu. Kde sa niekto možno dozvedel niečo nové, niekto možno pousmial nad mojou profesionálnou naivitou. A ak ste sa dostali až sem, ďakujem vám, a máte Bludišťáka.