Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,19 @@ jobs:
distribution: "temurin"
cache: maven

- name: Resolve build profiles
id: profiles
run: |
v=$(grep -m1 -oE '<questdb\.client\.version>[^<]+' e2e/questdb/core/pom.xml | sed 's/.*>//')
if [[ "$v" == *-SNAPSHOT ]]; then
echo "list=build-binaries,local-client" >> "$GITHUB_OUTPUT"
else
echo "list=build-binaries" >> "$GITHUB_OUTPUT"
fi

- name: Build QuestDB
run:
mvn clean package -f e2e/questdb/pom.xml -DskipTests -P build-binaries
mvn clean package -f e2e/questdb/pom.xml -DskipTests -P ${{ steps.profiles.outputs.list }}

- name: Extract QuestDB
run:
Expand Down
12 changes: 11 additions & 1 deletion .github/workflows/tests_with_context_path.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,19 @@ jobs:
distribution: "temurin"
cache: maven

- name: Resolve build profiles
id: profiles
run: |
v=$(grep -m1 -oE '<questdb\.client\.version>[^<]+' e2e/questdb/core/pom.xml | sed 's/.*>//')
if [[ "$v" == *-SNAPSHOT ]]; then
echo "list=build-binaries,local-client" >> "$GITHUB_OUTPUT"
else
echo "list=build-binaries" >> "$GITHUB_OUTPUT"
fi

- name: Build QuestDB
run:
mvn clean package -f e2e/questdb/pom.xml -DskipTests -P build-binaries
mvn clean package -f e2e/questdb/pom.xml -DskipTests -P ${{ steps.profiles.outputs.list }}

- name: Extract QuestDB
run:
Expand Down
124 changes: 124 additions & 0 deletions BENCHMARK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Result grid benchmarking harness

How we A/B-compare scroll/keyboard performance of the two result grids — the
legacy `grid.js` and the React `ResultGrid` — during the migration. Latest
numbers live in [BENCHMARK_RESULTS.md](BENCHMARK_RESULTS.md).

The harness is **dev-only**: every part is gated behind the `mock.pagination`
localStorage flag, so a normal session is unaffected and the code is inert until
you opt in. It is small enough to keep in the tree; this document is how to use
it (and how to strip it if we ever want to).

## What it measures and why

The thing we care about is **input → fully repainted *and correct***: the
wall-clock time from an action (a scroll, a key) landing to the frame where the
grid has actually settled into the right state. A step's timer stops only once
**all** of these hold (else it waits, up to a timeout, and the step is counted as
a failure):

1. every visible cell shows the value for its own `(row, col)` — cells are seeded
self-describing (`r{row}c{col}`), so this catches blank, stale, *and*
column-misaligned cells;
2. the rendered cells cover the viewport's content area (no half-painted scroll);
3. for a keyboard move, the focused cell is at the exact expected `(row, col)`
with the value for that position.

Raw FPS hides this — a grid can paint empty cells instantly and fill them late,
or a synthetic key can silently do nothing. Asserting the end state is what makes
the per-keystroke numbers trustworthy.

Network/API latency dominates and varies run-to-run, so it would drown out the
render cost we're comparing. The harness removes that variable by serving a
**constant canned page at a fixed 10 ms latency** instead of hitting QuestDB.
Both grids go through the same `paginationFn` and the same `setData`, so the mock
applies to both unchanged.

## Part A — the mock data source

Two pieces, both already in the tree:

1. [`src/scenes/Result/benchmarkMock.ts`](src/scenes/Result/benchmarkMock.ts) —
synthesises a result of any `rows × cols` from one canned page (built once,
served for every fetch) and exposes `isMockPagination`, `seedMock`,
`mockPaginate`. Cell values are **self-describing** (`r{row}c{col}`) so the
runner can assert each rendered cell holds the value for its own position.
2. Three small hooks in
[`src/scenes/Result/index.tsx`](src/scenes/Result/index.tsx), all guarded by
`isMockPagination()`:
- `paginationFn` short-circuits to `mockPaginate` (serves canned pages to
**both** grids).
- an effect publishes `window.__benchSeed(rows, cols)`, which seeds either
grid via `gridRef.setData(...)` — no real query needed.

Enable it at runtime (no rebuild):

```js
localStorage.setItem("mock.pagination", "true")
// reload, then run any query once (e.g. `select 1`) to mount the result pane —
// window.__benchSeed appears once the grid is mounted.
```

`localStorage.removeItem("mock.pagination")` restores normal fetching.

## Part B — the measurement script

[`e2e/benchmark/gridBench.js`](e2e/benchmark/gridBench.js) is a grid-agnostic,
in-page runner. It detects which grid is mounted and drives the right DOM and key
transport:

| | new `ResultGrid` | legacy `grid.js` |
|---|---|---|
| viewport | `[data-hook="grid-viewport"]` | `.qg-viewport` |
| cell | `[data-hook="grid-cell"]` | `.qg-c` |
| active cell | `[aria-selected="true"]` + `cell-{row}-{col}` id | `.qg-c-active` + `.columnIndex` / parent `.rowIndex` |
| key target | `[role="grid"]` (React synthetic key) | `.qg-canvas` (`keyCode`) |

Paste the file's contents into the console (or inject via `page.evaluate`) to
define `window.__gridBench`, then:

```js
await window.__gridBench.run("vscroll_1m") // → { median, p95, min, max, total, failures, ... }
window.__gridBench.cases // all case keys
```

Each `run(key)` seeds the matching `rows × cols`, waits for the grid to fill,
drives the case asserting the end state of every step (focused cell index +
value, and all visible cells correct), and returns median / p95 / min / max /
total settle times plus a **`failures`** count (a step whose assertion never held
within the timeout). `failures` should be `0`; a non-zero count with `sampleFail`
means the run is not trustworthy.

### The seven cases

| key | what it drives | data |
|---|---|---|
| `vscroll_1m` | 100 randomized vertical scrolls | 1,000,000 × 20 |
| `hscroll_10k` | 100 randomized horizontal scrolls | 2,000 × 10,000 |
| `homeend_cols` | 100 End→Home combinations (200 presses) | 2,000 × 10,000 |
| `pagedn_10k` | PageDown ×100 then PageUp ×100 | 10,000 × 20 |
| `corners_1m_10k` | bottom-right → top-left corner jumps via shortcuts, ×100 | 1,000,000 × 10,000 |
| `arrow_right_1k` | 999 ArrowRight presses through the columns | 2,000 × 1,000 |
| `arrow_down_1k` | 999 ArrowDown presses through the rows | 1,000 × 20 |

## Part C — comparing the two grids

The grid is chosen by the `feature.new.grid` flag, settable from the URL:

1. Same window size and the same machine for both runs (viewport size changes the
visible-cell count and thus the numbers).
2. **New grid:** open `http://localhost:9999/?useNewGrid=1`. **Legacy grid:**
`http://localhost:9999/?useNewGrid=0`. The param persists the flag and is then
stripped from the URL.
3. In each: `localStorage.setItem("mock.pagination", "true")`, reload, run any
query once, inject `gridBench.js`, then run each case and record the row.

The numbers in [BENCHMARK_RESULTS.md](BENCHMARK_RESULTS.md) were collected this
way, driving the running dev server with the Playwright browser.

## Removing the harness (if ever needed)

Delete [`src/scenes/Result/benchmarkMock.ts`](src/scenes/Result/benchmarkMock.ts),
the three `isMockPagination()`-guarded hooks and the two imports in
[`src/scenes/Result/index.tsx`](src/scenes/Result/index.tsx), and
[`e2e/benchmark/`](e2e/benchmark/). `yarn typecheck && yarn lint` should be clean.
171 changes: 171 additions & 0 deletions BENCHMARK_RESULTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# Result grid benchmark results — legacy `grid.js` vs `ResultGrid`

Generated by the harness in [BENCHMARK.md](BENCHMARK.md) using
[`e2e/benchmark/gridBench.js`](e2e/benchmark/gridBench.js) and the
`mock.pagination` data source ([`src/scenes/Result/benchmarkMock.ts`](src/scenes/Result/benchmarkMock.ts)).

## Setup

| | |
|---|---|
| Date | 2026-06-15 |
| Grids | legacy `grid.js` (`?useNewGrid=0`) vs React `ResultGrid` (`?useNewGrid=1`) |
| Viewport | 1600 × 900 window; grid viewport ≈ 1510 × 340–420 px (~10–14 visible rows) |
| Data | synthetic, self-describing cells `r{row}c{col}`, served from a constant canned page at a fixed 10 ms latency (zero network/API variance) |
| Metric | **input → fully repainted *and correct*** (see below) |

## What "settled" means here — every step is asserted

A step's timer stops only on the frame where **all** of these hold (otherwise it
keeps waiting, up to a 4 s timeout, and the step is counted as a **failure**):

1. **Every visible cell shows the value for its own `(row, col)`.** Cells are
seeded as `r{row}c{col}`, so this single check catches blank, stale, *and*
column-misaligned cells.
2. **The rendered cells cover the viewport's content area** — a half-painted
scroll doesn't count as done.
3. **For keyboard moves, the focused cell is at the exact expected `(row, col)`**
and holds the value for that position.

So these numbers are *time to a verified-correct repaint*, not just "a frame went
by." **Across both grids and all 7 cases (~5,600 asserted steps), there were 0
failures** — every scroll/keystroke landed correctly.

### How to read the numbers

- **median / p95 / min / max** are per-step settle latencies (lower is better).
- **total** is the summed settle time; step counts match between the two grids
for every case, so it's directly comparable.
- Two effects are baked into both grids equally and are not pure render:
- **Paging debounce + latency** (~75 ms + 10 ms) on a scroll/jump into an
unloaded region — dominates the vertical-scroll and corner medians.
- **Settle floor** of ~one animation frame (~8 ms here) for a move needing no
fetch and no new columns.

## Summary (median ms, lower is better)

| Case | Legacy | ResultGrid | Winner |
|---|---:|---:|:--|
| Randomized vertical scroll — 1,000,000 rows (×100) | 99.7 | 107.9 | ≈ even (legacy +8%) |
| **Randomized horizontal scroll — 10,000 columns (×100)** | 114.0 | **18.8** | **ResultGrid 6.1×** |
| **Home / End across 10,000 columns (100 combinations)** | 150.6 | **30.8** | **ResultGrid 4.9×** |
| PageDown ×100 then PageUp ×100 — 10,000 rows | 8.3 | 8.8 | ≈ even |
| **Corner jumps — bottom-right → top-left (×100, 1M × 10k)** | 263.0 | **132.0** | **ResultGrid 2.0×** |
| **Right arrow through 1,000 columns (×999)** | 17.8 | **8.3** | **ResultGrid 2.1×** |
| Down arrow through 1,000 rows (×999) | 8.3 | 8.3 | ≈ even |

**Takeaway:** vertical paging and single-row stepping are tied. Everything that
touches **columns** — horizontal scroll, Home/End, corner jumps, even
column-by-column arrow stepping — is markedly faster on `ResultGrid` because it
virtualizes columns; the legacy grid renders the whole visible column band.

---

## Per-case results

### 1. Randomized vertical scroll — 1,000,000 rows (20 columns, 100 scrolls)

| Grid | steps | median | p95 | min | max | total | failures |
|---|---:|---:|---:|---:|---:|---:|---:|
| legacy `grid.js` | 100 | 99.7 | 104.3 | 7.3 | 107.2 | 9917.0 | 0 |
| `ResultGrid` | 100 | 107.9 | 110.6 | 14.1 | 111.3 | 10676.5 | 0 |

Both dominated by the load debounce + latency on each jump into a new page; the
`min` rows are scrolls that land inside an already-cached page.

### 2. Randomized horizontal scroll — 10,000 columns (2,000 rows, 100 scrolls)

| Grid | steps | median | p95 | min | max | total | failures |
|---|---:|---:|---:|---:|---:|---:|---:|
| legacy `grid.js` | 100 | 114.0 | 150.0 | 98.7 | 179.1 | 11914.3 | 0 |
| `ResultGrid` | 100 | **18.8** | **22.9** | 16.6 | 29.1 | **1929.9** | 0 |

No fetch (every row already holds all columns), so this is pure horizontal render
cost. `ResultGrid` virtualizes columns; legacy lays out every column in the
viewport band — ~6.1× slower at the median.

### 3. Home / End across 10,000 columns — 100 End→Home combinations (2,000 rows, 200 presses)

| Grid | steps | median | p95 | min | max | total | failures |
|---|---:|---:|---:|---:|---:|---:|---:|
| legacy `grid.js` | 200 | 150.6 | 177.9 | 128.9 | 221.8 | 30291.9 | 0 |
| `ResultGrid` | 200 | **30.8** | **35.2** | 23.9 | 62.7 | **6205.7** | 0 |

Each press jumps the focused cell from column 0 to column 9,999 (and back) and
repaints the destination band. `ResultGrid` is ~4.9× faster.

### 4. PageDown ×100 then PageUp ×100 — 10,000 rows (20 columns, 200 presses)

| Grid | steps | median | p95 | min | max | total | failures |
|---|---:|---:|---:|---:|---:|---:|---:|
| legacy `grid.js` | 200 | 8.3 | 11.1 | 5.1 | 19.5 | 1685.2 | 0 |
| `ResultGrid` | 200 | 8.8 | 11.1 | 4.9 | 18.6 | 1833.9 | 0 |

100 PageDowns (~1,200 rows) then 100 PageUps, mostly inside a loaded region.
Effectively tied at the median; `ResultGrid` takes an extra frame on the page
that crosses a fetch boundary (higher max). The page step is calibrated from
the first move and then asserted exactly on every subsequent press.

### 5. Corner jumps — bottom-right → top-left ×100 (1,000,000 rows × 10,000 columns, 200 presses)

| Grid | steps | median | p95 | min | max | total | failures |
|---|---:|---:|---:|---:|---:|---:|---:|
| legacy `grid.js` | 200 | 263.0 | 314.6 | 233.8 | 399.0 | 53497.2 | 0 |
| `ResultGrid` | 200 | **132.0** | **140.5** | 123.2 | 159.8 | **26526.5** | 0 |

The heaviest case: every jump moves both axes at once (tail-row fetch + a
10,000-column-wide repaint). `ResultGrid` is ~2.0× faster and far more consistent
(tighter p95/max).

> Shortcuts used: `ResultGrid` reaches a corner with one chord (`Ctrl+End` /
> `Ctrl+Home`). The legacy grid has no single corner chord, so the harness sends
> its equivalent — a column key then a `Cmd`+arrow row key (`End`→`Cmd+↓`,
> `Home`→`Cmd+↑`) — and asserts the focused cell lands exactly on the corner.

### 6. Right arrow through 1,000 columns (2,000 rows, 999 presses)

| Grid | steps | median | p95 | min | max | total | failures |
|---|---:|---:|---:|---:|---:|---:|---:|
| legacy `grid.js` | 999 | 17.8 | 21.1 | 14.9 | 33.8 | 18104.8 | 0 |
| `ResultGrid` | 999 | **8.3** | **9.7** | 5.9 | 14.9 | **8322.3** | 0 |

With the assertion verifying the focus actually advanced one column each press,
the legacy grid's per-column cost shows: ~18 ms vs the new grid's ~8 ms (a single
React focus move + a virtualized column render). ~2.1× faster.

### 7. Down arrow through 1,000 rows (20 columns, 999 presses)

| Grid | steps | median | p95 | min | max | total | failures |
|---|---:|---:|---:|---:|---:|---:|---:|
| legacy `grid.js` | 999 | 8.3 | 9.4 | 6.3 | 21.7 | 8318.5 | 0 |
| `ResultGrid` | 999 | 8.3 | 9.4 | 4.8 | 165.5 | 8604.6 | 0 |

Single-row vertical stepping is one frame on both grids — identical at the median
(the new grid's lone `max` outlier is a single GC/layout hitch, not a trend).

---

## Caveats

- **Column count drives the wide-result deltas.** The self-describing values are
short, so columns are narrow (~70 px) and ~20 are visible at once — which is
what makes cases 2/3/5/6 stress column rendering so hard. With wider columns
(fewer visible), the gap shrinks: an earlier run with mixed-width data showed
horizontal scroll at 28.9 vs 16.4 ms (1.8×) instead of 6.1×. The *direction* is
the same; the *magnitude* scales with visible-column count.
- **Debounce-bound cases.** Vertical-scroll and corner medians are mostly the
~75 ms load debounce + 10 ms mock latency, not paint.
- **Small viewport** (~10–14 visible rows); a taller pane renders more cells per
frame and would widen the deltas further.
- **Virtual-row mapping — same idea on both grids.** For 1,000,000 rows neither
grid maps the full height proportionally: both cap the scroll canvas at
10,000,000 px (~333k rows at 30 px) and reach the head 1:1, then **leap to the
tail** when the scrollbar bottoms out, so the middle isn't addressable by
dragging on either. Legacy does it imperatively — `y += ΔscrollTop` with an
explicit "final leap to bottom" in `grid.js` (its `M = yMax/h` ratio is
computed but unused); `ResultGrid` does it with a pure `toAbsoluteIndex` map
(head 1:1 + a fixed 1,000-row tail) in `virtualRowMapping.ts`. The one thing
that genuinely differs is the **focus coordinate space**: the new grid's
keyboard focus row is the *virtual* row (≤ ~333k), the legacy grid's is the
*absolute* row (up to 999,999). The harness tracks the expected focus cell in
each grid's own space, so the corner-jump assertions are correct on both.
Loading
Loading