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`;
+}