diff --git a/web/src/lib/components/custom-query.svelte b/web/src/lib/components/custom-query.svelte index 22d0732..d18df0b 100644 --- a/web/src/lib/components/custom-query.svelte +++ b/web/src/lib/components/custom-query.svelte @@ -1,5 +1,5 @@
@@ -133,9 +130,12 @@ {#if execTime !== null} {rows.length} kết quả · {execTime}ms {/if} - {#if db} - - Đã tải: {formatBytes(db.bytesRead)} + {#if db?.lastCost} + + + Truy vấn này: {db.lastCost.requests} yêu cầu · {formatBytes(db.lastCost.bytes)} + · Phiên: {db.requests} yêu cầu · {formatBytes(db.bytesRead)} + {/if}
diff --git a/web/src/lib/search.ts b/web/src/lib/search.ts index 76d780b..b40cf03 100644 --- a/web/src/lib/search.ts +++ b/web/src/lib/search.ts @@ -38,15 +38,20 @@ export async function searchByName(db: RemoteDatabase, query: string): Promise= ? AND w.word < ?${filters.length ? " AND " + filters.join(" AND ") : ""} LIMIT ${MAX_RESULTS}`; - return db.query(sql, [seek, upperBound(seek), ...others.map((w) => `% ${escapeLike(w)}%`)]); + return db.query( + sql, + [seek, upperBound(seek), ...others.map((w) => `% ${escapeLike(w)}%`)], + `name ${JSON.stringify(words.join(" "))} seeking ${JSON.stringify(seek)}`, + ); } /** Exact lookup by exam number: a primary-key seek, a few pages. */ export async function lookupExamId(db: RemoteDatabase, id: string): Promise { - return db.query("SELECT * FROM student WHERE so_bao_danh = ? LIMIT ?", [ - normaliseExamId(id), - MAX_RESULTS, - ]); + return db.query( + "SELECT * FROM student WHERE so_bao_danh = ? LIMIT ?", + [normaliseExamId(id), MAX_RESULTS], + `exam id ${JSON.stringify(normaliseExamId(id))}`, + ); } /** Fold to ASCII and split into words, the same shape name_word was built in. */ @@ -66,7 +71,7 @@ async function rarest(db: RemoteDatabase, words: string[]): Promise { .join(" UNION ALL "); const params = words.flatMap((w) => [w, w, upperBound(w)]); - const counts = await db.query<{ word: string; n: number }>(sql, params); + const counts = await db.query<{ word: string; n: number }>(sql, params, "word frequencies"); let best = words[0]; let bestN = Infinity; for (const { word, n } of counts) { diff --git a/web/src/lib/sqlite.svelte.ts b/web/src/lib/sqlite.svelte.ts index 5ef0f2f..f8e33d5 100644 --- a/web/src/lib/sqlite.svelte.ts +++ b/web/src/lib/sqlite.svelte.ts @@ -1,4 +1,4 @@ -import { createDbWorker, type WorkerHttpvfs } from "sql.js-httpvfs"; +import { createDbWorker, type SqliteStats, type WorkerHttpvfs } from "sql.js-httpvfs"; import workerUrl from "sql.js-httpvfs/dist/sqlite.worker.js?url"; import wasmUrl from "sql.js-httpvfs/dist/sql-wasm.wasm?url"; @@ -25,6 +25,9 @@ export const SEARCH_BUDGET_BYTES = 25 * 1024 * 1024; /** What the SQL tab gets once the user has accepted the cost of a scan. */ export const PLAYGROUND_BUDGET_BYTES = 250 * 1024 * 1024; +/** What one query cost over the network. */ +export type QueryCost = { requests: number; bytes: number; ms: number }; + /** * One remotely-paged database, with the load state the UI needs. * @@ -35,12 +38,18 @@ export const PLAYGROUND_BUDGET_BYTES = 250 * 1024 * 1024; export class RemoteDatabase { ready = $state(false); error = $state(null); - /** Bytes fetched so far, refreshed after every query. */ + /** Bytes fetched by this database so far, prefetch included. */ bytesRead = $state(0); + /** HTTP range requests issued so far. */ + requests = $state(0); + /** What the most recent query cost on its own. */ + lastCost = $state(null); #worker: WorkerHttpvfs | null = null; #opening: Promise; #closed = false; + // Previous cumulative reading, so a query's own cost is a subtraction. + #seen = { requests: 0, bytes: 0 }; constructor( readonly url: string, @@ -50,6 +59,7 @@ export class RemoteDatabase { } async #open(): Promise { + const opened = performance.now(); try { const worker = await createDbWorker( [{ from: "inline", config: { serverMode: "full", url: this.url, requestChunkSize: CHUNK_BYTES } }], @@ -60,6 +70,9 @@ export class RemoteDatabase { if (this.#closed) throw new Error("closed"); this.#worker = worker; this.ready = true; + // Opening is not free either: the header and schema pages are read before + // any query runs, and that shows up in every later session total. + await this.#account(worker, `open ${this.url}`, performance.now() - opened); return worker; } catch (err) { if (!this.#closed) { @@ -70,21 +83,55 @@ export class RemoteDatabase { } } - /** Run a query and return its rows as objects. */ - async query(sql: string, params: unknown[] = []): Promise { + /** + * Run a query and return its rows as objects. + * + * `label` names the query in the console trace — the only way to see what a + * search actually costs, since the byte count depends on how much the read + * heads prefetched, not just on the pages the plan needed. + */ + async query(sql: string, params: unknown[] = [], label?: string): Promise { const worker = this.#worker ?? (await this.#opening); // Comlink erases the generic when it proxies the method across the worker // boundary, so the row type is asserted here rather than inferred. const run = worker.db.query as unknown as (sql: string, ...params: unknown[]) => Promise; + const started = performance.now(); try { return await run(sql, ...params); } finally { - // Comlink proxies the property, so this is a round trip; worth it because - // the number is the only honest feedback about what a query cost. - this.bytesRead = await worker.worker.bytesRead; + await this.#account(worker, label ?? firstLine(sql), performance.now() - started); } } + /** + * Read the cumulative counters and report the delta. + * + * getStats() rather than the worker's `bytesRead`: that one is the budget + * counter and resets itself to zero when a query trips the ceiling, so it + * would under-report exactly when the number matters most. + */ + async #account(worker: WorkerHttpvfs, label: string, ms: number) { + const read = worker.worker.getStats as unknown as () => Promise; + const stats = await read().catch(() => null); + if (!stats) return; + + const cost: QueryCost = { + requests: stats.totalRequests - this.#seen.requests, + bytes: stats.totalFetchedBytes - this.#seen.bytes, + ms, + }; + this.#seen = { requests: stats.totalRequests, bytes: stats.totalFetchedBytes }; + this.requests = stats.totalRequests; + this.bytesRead = stats.totalFetchedBytes; + this.lastCost = cost; + + console.info( + `[httpvfs] ${label} — ${cost.requests} request(s), ${formatBytes(cost.bytes)}, ${ms.toFixed(0)} ms` + + ` · session ${stats.totalRequests} request(s), ${formatBytes(stats.totalFetchedBytes)}` + + ` of ${formatBytes(stats.totalBytes)}`, + ); + } + /** * Drop this database. createDbWorker owns the Worker and exposes no handle to * it, so the thread outlives this call; a page creates at most one per @@ -106,3 +153,13 @@ export function isBudgetError(err: unknown): boolean { function message(err: unknown): string { return err instanceof Error ? err.message : String(err); } + +/** Enough of a query to recognise it in the console. */ +function firstLine(sql: string): string { + const line = sql.trim().split("\n")[0]; + return line.length > 70 ? `${line.slice(0, 70)}…` : line; +} + +export function formatBytes(n: number): string { + return n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / 1048576).toFixed(1)} MB`; +}