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