xref: /sqlite-3.40.0/ext/wasm/batch-runner.js (revision 1f095d48)
1/*
2  2022-08-29
3
4  The author disclaims copyright to this source code.  In place of a
5  legal notice, here is a blessing:
6
7  *   May you do good and not evil.
8  *   May you find forgiveness for yourself and forgive others.
9  *   May you share freely, never taking more than you give.
10
11  ***********************************************************************
12
13  A basic batch SQL runner for sqlite3-api.js. This file must be run in
14  main JS thread and sqlite3.js must have been loaded before it.
15*/
16'use strict';
17(function(){
18  const toss = function(...args){throw new Error(args.join(' '))};
19  const warn = console.warn.bind(console);
20  let sqlite3;
21
22  const App = {
23    e: {
24      output: document.querySelector('#test-output'),
25      selSql: document.querySelector('#sql-select'),
26      btnRun: document.querySelector('#sql-run'),
27      btnRunNext: document.querySelector('#sql-run-next'),
28      btnRunRemaining: document.querySelector('#sql-run-remaining'),
29      btnExportMetrics: document.querySelector('#export-metrics'),
30      btnClear: document.querySelector('#output-clear'),
31      btnReset: document.querySelector('#db-reset'),
32      cbReverseLog: document.querySelector('#cb-reverse-log-order')
33    },
34    db: Object.create(null),
35    cache:{},
36    metrics:{
37      /**
38         Map of sql-file to timing metrics. We currently only store
39         the most recent run of each file, but we really should store
40         all runs so that we can average out certain values which vary
41         significantly across runs. e.g. a mandelbrot-generating query
42         will have a wide range of runtimes when run 10 times in a
43         row.
44      */
45    },
46    log: console.log.bind(console),
47    warn: console.warn.bind(console),
48    cls: function(){this.e.output.innerHTML = ''},
49    logHtml2: function(cssClass,...args){
50      const ln = document.createElement('div');
51      if(cssClass) ln.classList.add(cssClass);
52      ln.append(document.createTextNode(args.join(' ')));
53      this.e.output.append(ln);
54      //this.e.output.lastElementChild.scrollIntoViewIfNeeded();
55    },
56    logHtml: function(...args){
57      console.log(...args);
58      if(1) this.logHtml2('', ...args);
59    },
60    logErr: function(...args){
61      console.error(...args);
62      if(1) this.logHtml2('error', ...args);
63    },
64
65    openDb: function(fn, unlinkFirst=true){
66      if(this.db.ptr){
67        toss("Already have an opened db.");
68      }
69      const capi = this.sqlite3.capi, wasm = capi.wasm;
70      const stack = wasm.scopedAllocPush();
71      let pDb = 0;
72      try{
73        if(unlinkFirst && fn){
74          if(':'!==fn[0]) capi.wasm.sqlite3_wasm_vfs_unlink(fn);
75          this.clearStorage();
76        }
77        const oFlags = capi.SQLITE_OPEN_CREATE | capi.SQLITE_OPEN_READWRITE;
78        const ppDb = wasm.scopedAllocPtr();
79        const rc = capi.sqlite3_open_v2(fn, ppDb, oFlags, null);
80        pDb = wasm.getPtrValue(ppDb)
81        if(rc){
82          if(pDb) capi.sqlite3_close_v2(pDb);
83          toss("sqlite3_open_v2() failed with code",rc);
84        }
85      }finally{
86        wasm.scopedAllocPop(stack);
87      }
88      this.db.filename = fn;
89      this.db.ptr = pDb;
90      this.logHtml("Opened db:",fn);
91      return this.db.ptr;
92    },
93
94    closeDb: function(unlink=false){
95      if(this.db.ptr){
96        this.sqlite3.capi.sqlite3_close_v2(this.db.ptr);
97        this.logHtml("Closed db",this.db.filename);
98        if(unlink){
99          capi.wasm.sqlite3_wasm_vfs_unlink(this.db.filename);
100          this.clearStorage();
101        }
102        this.db.ptr = this.db.filename = undefined;
103      }
104    },
105
106    /**
107       Loads batch-runner.list and populates the selection list from
108       it. Returns a promise which resolves to nothing in particular
109       when it completes. Only intended to be run once at the start
110       of the app.
111     */
112    loadSqlList: async function(){
113      const sel = this.e.selSql;
114      sel.innerHTML = '';
115      this.blockControls(true);
116      const infile = 'batch-runner.list';
117      this.logHtml("Loading list of SQL files:", infile);
118      let txt;
119      try{
120        const r = await fetch(infile);
121        if(404 === r.status){
122          toss("Missing file '"+infile+"'.");
123        }
124        if(!r.ok) toss("Loading",infile,"failed:",r.statusText);
125        txt = await r.text();
126        const warning = document.querySelector('#warn-list');
127        if(warning) warning.remove();
128      }catch(e){
129        this.logErr(e.message);
130        throw e;
131      }finally{
132        this.blockControls(false);
133      }
134      const list = txt.split(/\n+/);
135      let opt;
136      if(0){
137        opt = document.createElement('option');
138        opt.innerText = "Select file to evaluate...";
139        opt.value = '';
140        opt.disabled = true;
141        opt.selected = true;
142        sel.appendChild(opt);
143      }
144      list.forEach(function(fn){
145        if(!fn) return;
146        opt = document.createElement('option');
147        opt.value = fn;
148        opt.innerText = fn.split('/').pop();
149        sel.appendChild(opt);
150      });
151      this.logHtml("Loaded",infile);
152    },
153
154    /** Fetch ./fn and return its contents as a Uint8Array. */
155    fetchFile: async function(fn, cacheIt=false){
156      if(cacheIt && this.cache[fn]) return this.cache[fn];
157      this.logHtml("Fetching",fn,"...");
158      let sql;
159      try {
160        const r = await fetch(fn);
161        if(!r.ok) toss("Fetch failed:",r.statusText);
162        sql = new Uint8Array(await r.arrayBuffer());
163      }catch(e){
164        this.logErr(e.message);
165        throw e;
166      }
167      this.logHtml("Fetched",sql.length,"bytes from",fn);
168      if(cacheIt) this.cache[fn] = sql;
169      return sql;
170    }/*fetchFile()*/,
171
172    /** Throws if the given sqlite3 result code is not 0. */
173    checkRc: function(rc){
174      if(this.db.ptr && rc){
175        toss("Prepare failed:",this.sqlite3.capi.sqlite3_errmsg(this.db.ptr));
176      }
177    },
178
179    /** Disable or enable certain UI controls. */
180    blockControls: function(disable){
181      document.querySelectorAll('.disable-during-eval').forEach((e)=>e.disabled = disable);
182    },
183
184    /**
185       Converts this.metrics() to a form which is suitable for easy conversion to
186       CSV. It returns an array of arrays. The first sub-array is the column names.
187       The 2nd and subsequent are the values, one per test file (only the most recent
188       metrics are kept for any given file).
189    */
190    metricsToArrays: function(){
191      const rc = [];
192      Object.keys(this.metrics).sort().forEach((k)=>{
193        const m = this.metrics[k];
194        delete m.evalFileStart;
195        delete m.evalFileEnd;
196        const mk = Object.keys(m).sort();
197        if(!rc.length){
198          rc.push(['file', ...mk]);
199        }
200        const row = [k.split('/').pop()/*remove dir prefix from filename*/];
201        rc.push(row);
202        mk.forEach((kk)=>row.push(m[kk]));
203      });
204      return rc;
205    },
206
207    metricsToBlob: function(colSeparator='\t'){
208      const ar = [], ma = this.metricsToArrays();
209      if(!ma.length){
210        this.logErr("Metrics are empty. Run something.");
211        return;
212      }
213      ma.forEach(function(row){
214        ar.push(row.join(colSeparator),'\n');
215      });
216      return new Blob(ar);
217    },
218
219    downloadMetrics: function(){
220      const b = this.metricsToBlob();
221      if(!b) return;
222      const url = URL.createObjectURL(b);
223      const a = document.createElement('a');
224      a.href = url;
225      a.download = 'batch-runner-js-'+((new Date().getTime()/1000) | 0)+'.csv';
226      this.logHtml("Triggering download of",a.download);
227      document.body.appendChild(a);
228      a.click();
229      setTimeout(()=>{
230        document.body.removeChild(a);
231        URL.revokeObjectURL(url);
232      }, 500);
233    },
234
235    /**
236       Fetch file fn and eval it as an SQL blob. This is an async
237       operation and returns a Promise which resolves to this
238       object on success.
239    */
240    evalFile: async function(fn){
241      const sql = await this.fetchFile(fn);
242      const banner = "========================================";
243      this.logHtml(banner,
244                   "Running",fn,'('+sql.length,'bytes)...');
245      const capi = this.sqlite3.capi, wasm = capi.wasm;
246      let pStmt = 0, pSqlBegin;
247      const stack = wasm.scopedAllocPush();
248      const metrics = this.metrics[fn] = Object.create(null);
249      metrics.prepTotal = metrics.stepTotal = 0;
250      metrics.stmtCount = 0;
251      metrics.malloc = 0;
252      metrics.strcpy = 0;
253      this.blockControls(true);
254      if(this.gotErr){
255        this.logErr("Cannot run ["+fn+"]: error cleanup is pending.");
256        return;
257      }
258      // Run this async so that the UI can be updated for the above header...
259      const ff = function(resolve, reject){
260        metrics.evalFileStart = performance.now();
261        try {
262          let t;
263          let sqlByteLen = sql.byteLength;
264          const [ppStmt, pzTail] = wasm.scopedAllocPtr(2);
265          t = performance.now();
266          pSqlBegin = wasm.alloc( sqlByteLen + 1/*SQL + NUL*/) || toss("alloc(",sqlByteLen,") failed");
267          metrics.malloc = performance.now() - t;
268          metrics.byteLength = sqlByteLen;
269          let pSql = pSqlBegin;
270          const pSqlEnd = pSqlBegin + sqlByteLen;
271          t = performance.now();
272          wasm.heap8().set(sql, pSql);
273          wasm.setMemValue(pSql + sqlByteLen, 0);
274          metrics.strcpy = performance.now() - t;
275          let breaker = 0;
276          while(pSql && wasm.getMemValue(pSql,'i8')){
277            wasm.setPtrValue(ppStmt, 0);
278            wasm.setPtrValue(pzTail, 0);
279            t = performance.now();
280            let rc = capi.sqlite3_prepare_v3(
281              this.db.ptr, pSql, sqlByteLen, 0, ppStmt, pzTail
282            );
283            metrics.prepTotal += performance.now() - t;
284            this.checkRc(rc);
285            pStmt = wasm.getPtrValue(ppStmt);
286            pSql = wasm.getPtrValue(pzTail);
287            sqlByteLen = pSqlEnd - pSql;
288            if(!pStmt) continue/*empty statement*/;
289            ++metrics.stmtCount;
290            t = performance.now();
291            rc = capi.sqlite3_step(pStmt);
292            capi.sqlite3_finalize(pStmt);
293            pStmt = 0;
294            metrics.stepTotal += performance.now() - t;
295            switch(rc){
296                case capi.SQLITE_ROW:
297                case capi.SQLITE_DONE: break;
298                default: this.checkRc(rc); toss("Not reached.");
299            }
300          }
301        }catch(e){
302          if(pStmt) capi.sqlite3_finalize(pStmt);
303          this.gotErr = e;
304          //throw e;
305          reject(e);
306          return;
307        }finally{
308          wasm.dealloc(pSqlBegin);
309          wasm.scopedAllocPop(stack);
310          this.blockControls(false);
311        }
312        metrics.evalFileEnd = performance.now();
313        metrics.evalTimeTotal = (metrics.evalFileEnd - metrics.evalFileStart);
314        this.logHtml("Metrics:");//,JSON.stringify(metrics, undefined, ' '));
315        this.logHtml("prepare() count:",metrics.stmtCount);
316        this.logHtml("Time in prepare_v2():",metrics.prepTotal,"ms",
317                     "("+(metrics.prepTotal / metrics.stmtCount),"ms per prepare())");
318        this.logHtml("Time in step():",metrics.stepTotal,"ms",
319                     "("+(metrics.stepTotal / metrics.stmtCount),"ms per step())");
320        this.logHtml("Total runtime:",metrics.evalTimeTotal,"ms");
321        this.logHtml("Overhead (time - prep - step):",
322                     (metrics.evalTimeTotal - metrics.prepTotal - metrics.stepTotal)+"ms");
323        this.logHtml(banner,"End of",fn);
324        resolve(this);
325      }.bind(this);
326      let p;
327      if(1){
328        p = new Promise(function(res,rej){
329          setTimeout(()=>ff(res, rej), 50)/*give UI a chance to output the "running" banner*/;
330        });
331      }else{
332        p = new Promise(ff);
333      }
334      return p.catch((e)=>this.logErr("Error via evalFile("+fn+"):",e.message));
335    }/*evalFile()*/,
336
337    clearStorage: function(){
338      const sz = sqlite3.capi.sqlite3_web_kvvfs_size();
339      const n = sqlite3.capi.sqlite3_web_kvvfs_clear(this.db.filename || '');
340      this.logHtml("Cleared kvvfs local/sessionStorage:",
341                   n,"entries totaling approximately",sz,"bytes.");
342    },
343
344    resetDb: function(){
345      if(this.db.ptr){
346        const fn = this.db.filename;
347        this.closeDb(true);
348        this.openDb(fn,false);
349      }
350    },
351
352    run: function(sqlite3){
353      delete this.run;
354      this.sqlite3 = sqlite3;
355      const capi = sqlite3.capi, wasm = capi.wasm;
356      this.logHtml("Loaded module:",capi.sqlite3_libversion(), capi.sqlite3_sourceid());
357      this.logHtml("WASM heap size =",wasm.heap8().length);
358      this.loadSqlList();
359      let pDir, dbFile;
360      if(sqlite3.capi.sqlite3_vfs_find('kvvfs')){
361        dbFile = 1 ? 'local' : 'session';
362        this.logHtml("Using KVVFS storage:",dbFile);
363      }else{
364        pDir = capi.sqlite3_web_persistent_dir();
365        if(pDir){
366          dbFile = pDir+"/speedtest.db";
367          this.logHtml("Using persistent storage:",dbFile);
368        }else{
369          dbFile = ':memory:';
370          this.logHtml("Using",dbFile,"storage.");
371        }
372      }
373      if(!pDir){
374        document.querySelector('#warn-opfs').remove();
375      }
376      this.openDb(dbFile, true);
377      const who = this;
378      const eReverseLogNotice = document.querySelector('#reverse-log-notice');
379      if(this.e.cbReverseLog.checked){
380        eReverseLogNotice.classList.remove('hidden');
381        this.e.output.classList.add('reverse');
382      }
383      this.e.cbReverseLog.addEventListener('change', function(){
384        if(this.checked){
385          who.e.output.classList.add('reverse');
386          eReverseLogNotice.classList.remove('hidden');
387        }else{
388          who.e.output.classList.remove('reverse');
389          eReverseLogNotice.classList.add('hidden');
390        }
391      }, false);
392      this.e.btnClear.addEventListener('click', ()=>this.cls(), false);
393      this.e.btnRun.addEventListener('click', function(){
394        if(!who.e.selSql.value) return;
395        who.evalFile(who.e.selSql.value);
396      }, false);
397      this.e.btnRunNext.addEventListener('click', function(){
398        ++who.e.selSql.selectedIndex;
399        if(!who.e.selSql.value) return;
400        who.evalFile(who.e.selSql.value);
401      }, false);
402      this.e.btnReset.addEventListener('click', function(){
403        who.resetDb();
404      }, false);
405      this.e.btnExportMetrics.addEventListener('click', function(){
406        who.logHtml2('warning',"Triggering download of metrics CSV. Check your downloads folder.");
407        who.downloadMetrics();
408        //const m = who.metricsToArrays();
409        //console.log("Metrics:",who.metrics, m);
410      });
411      this.e.btnRunRemaining.addEventListener('click', async function(){
412        let v = who.e.selSql.value;
413        const timeStart = performance.now();
414        while(v){
415          await who.evalFile(v);
416          if(who.gotError){
417            who.logErr("Error handling script",v,":",who.gotError.message);
418            break;
419          }
420          ++who.e.selSql.selectedIndex;
421          v = who.e.selSql.value;
422        }
423        const timeTotal = performance.now() - timeStart;
424        who.logHtml("Run-remaining time:",timeTotal,"ms ("+(timeTotal/1000/60)+" minute(s))");
425        who.clearStorage();
426      }, false);
427    }/*run()*/
428  }/*App*/;
429
430  self.sqlite3TestModule.initSqlite3().then(function(theEmccModule){
431    self._MODULE = theEmccModule /* this is only to facilitate testing from the console */;
432    sqlite3 = theEmccModule.sqlite3;
433    App.run(theEmccModule.sqlite3);
434  });
435})();
436