pf_app/pf_perspective_options.md
Paul Trowbridge c5b12aac76 Record DuckDB virtual-server spike findings; choose display-grain pre-aggregation
Document the outcome of the Option C spike (server-side DuckDB as a
Perspective virtual server) and the resulting architecture decision.

pf_perspective_options.md:
- Spike findings: latency is excellent (~12ms round trip, 5-29ms
  aggregation, ~1.5-1.9s to materialize 534,902 rows) but Perspective's
  GenericSQLVirtualServerModel ignores group_by_depth and has no
  ViewConfig field for per-node expansion state, so interactive
  drill-down is not achievable. This affects options B, C and D alike
  since they share that SQL model.
- Decision: the real lever is grain, not transport. Pre-aggregating to
  display grain collapses 534,902 -> 4,642 rows (~115x) on osm_stack
  while keeping the native Perspective engine, so expand/collapse/
  depth/sort/filter continue to work.
- Two candidate designs (Path A live virtual server vs Path B
  pre-aggregated extract) with the deciding question: do real cuts ever
  exceed the browser's leaf-row ceiling?

pf_spec.md:
- Concrete Path B design: pf.col_meta.in_grain, GET /api/versions/:id/agg,
  synthetic pf_gkey index, and the append-deltas write/undo model that
  mirrors the prior Excel pivot-cache workflow.

Drop pf_ux_mockup.md — an ASCII mockup of UI that is now built; the
views in ui/src/views are the current reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:47:55 -04:00

468 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Perspective Architecture Options
This document weighs how the Forecast view should source data for the Perspective
pivot. The current implementation hits practical limits on initial load
(~30s for 350k rows × ~55 cols), and growth is expected. Choosing an architecture
now should account for both **read** (initial pivot load + interaction) and
**write** (forecasting operations that mutate rows).
---
## Current architecture
### Data flow
- **Transport:** `GET /api/versions/:id/data` returns the full forecast table
as Apache Arrow IPC stream. Server-side: pg cursor (`FETCH 10000`) accumulates
all rows, `tableFromJSON` builds an Arrow table, `tableToIPC` produces one
record batch, response sent with `Content-Length`.
- **Joined columns:** `/data` LEFT JOINs `pf.log` to surface `pf_note` (the
user's note for the operation that produced each row) and `pf_op`
(baseline/scale/recode/clone). Joined at fetch time so note edits are
always live. (Added in `bf85f11`.)
- **Client:** Streams the response body to a `Uint8Array`, hands it to
Perspective's `worker.table()` (`@perspective-dev/client@4.4.0` from CDN).
Perspective's WASM engine owns the table in browser memory; all
pivots/filters/group-bys run locally.
- **Progress UI:** Forecast view reads the response body via
`response.body.getReader()` and shows received-bytes / total-bytes while
loading.
- **Forecasting writes:**
- `scale`/`recode`/`clone` POST → server INSERTs new rows with
`RETURNING *` → client receives JSON rows →
`tableRef.current.update(rows)` appends to Perspective's local table.
**Fast — no reload.**
- `undo` (DELETE) → server removes rows by `pf_logid` → client calls
`initViewer(...)` which **fully reloads** the table.
- `baseline` reload → currently also a full reload.
### Why this specific shape (the bug history)
The current "accumulate all rows, emit one record batch" approach is not
accidental. Two failure modes drove it:
1. **pg returns `bigint` (oid 20) and `numeric` (oid 1700) as JS strings by
default.** That made `tableFromJSON` infer `Dictionary<Utf8>` for ~50 of
55 columns. Fix in `server.js`: register type parsers that coerce both
to `Number` so Arrow infers `Int`/`Float64`.
2. **Per-batch `tableFromJSON` creates independent dictionaries.** When we
streamed batches, the writer emitted ~1230 dictionary REPLACEMENT
messages between batches. Perspective's WASM Arrow reader crashes on
those (`RuntimeError: memory access out of bounds`). Fix: accumulate
rows server-side, build one Arrow table, emit a single record batch.
Reference comment lives in `routes/operations.js` near the cursor loop.
These two bugs explain the ~1015s server stall before the progress bar
appears: the server can't send byte 1 until every row has been fetched,
encoded, and the buffer is sized for `Content-Length`. **Any redesign of
the read path needs to either solve the dictionary-replacement issue
(streaming with stable dictionary IDs declared up front) or replace the
transport entirely (e.g., Parquet, server-side virtual table).**
### Implication for any redesign
The incremental update path (`table.update(rows)`) is what makes
operations feel snappy today. Whatever architecture comes next, writes
need to stay incremental — or get even cheaper. Undo's full reload is
already a known wart.
---
## The options
### A. Stay client-side WASM; optimize the encode path
Keep the architecture. Replace the slow pieces.
- **Encode:** drop `tableFromJSON`. Build Arrow vectors directly from
`cols_meta` types (typed arrays for numerics, dictionary builders for
strings). Eliminates per-row type inference.
- **Stream:** declare schema up front, send dictionaries once, stream record
batches as they come off the cursor. Progress bar starts within ~1s.
- **Trim:** request-level `?cols=` parameter so the server can return only
the columns the active layout needs.
- **Writes:** unchanged — `table.update(rows)` keeps working.
- **Undo:** same path; same wart. Could be improved by surfacing a
`table.remove(pf_ids)` instead of `initViewer`.
| Aspect | Impact |
|---|---|
| Initial load | ~35× faster server encode + parallel transfer; bar appears in ~1s |
| Interaction | Unchanged (already instant) |
| Writes | Unchanged (already fast) |
| Browser memory ceiling | Still limited by Perspective WASM (~12M rows is the rough wall) |
| Code change | Medium: new builder code in `routes/operations.js`, schema declaration; UI mostly unchanged |
| New runtime deps | None |
**Right answer if:** dataset stays under ~1M rows and the goal is "make it
faster without rearchitecting."
---
### B. DuckDB-WASM in the browser (Parquet load + `DuckDBHandler`)
Replace the Arrow IPC payload with a Parquet file. Browser loads it into
DuckDB-WASM. Perspective's `DuckDBHandler` (from
`@perspective-dev/client/dist/esm/virtual_servers/duckdb.js`) backs the
viewer — every pivot interaction becomes a SQL query against the local
DuckDB-WASM instance. Perspective ships the view-config-to-SQL translator;
no custom code there.
- **Initial transfer:** Parquet for a forecast table is typically ~1030 MB
for 350k rows (vs. ~80150 MB for Arrow IPC). Smaller download, no
server-side `tableFromJSON`.
- **Encode:** server-side. DuckDB on the server can `COPY (SELECT ... FROM
postgres_scan(...)) TO 'foo.parquet'`, or pre-stage Parquet on each
forecast write. Either way, no Node-side Arrow encode.
- **Interaction:** instant — local SQL on a columnar engine. No round trips.
- **Writes:** **this is the hard part.** After a `scale`/`recode`/`clone`,
the server has new rows in pg but DuckDB-WASM has a stale snapshot.
Options:
1. **Server returns new rows as Arrow** → client does `INSERT INTO
forecast SELECT * FROM arrow_view` in DuckDB-WASM, then notifies the
`DuckDBHandler` to refresh views.
2. **Re-export Parquet** → re-fetch. Simple but wasteful for small
incremental ops.
3. **Maintain a delta log** → client replays inserts/deletes by `pf_logid`.
- **Undo:** `DELETE FROM forecast WHERE pf_logid = $1` against DuckDB-WASM,
then refresh. Strictly faster than the current full reload.
| Aspect | Impact |
|---|---|
| Initial load | Smaller payload + fast WASM ingest; likely 35× total |
| Interaction | Instant (local SQL) — same as today |
| Writes | New write-sync layer required (medium effort) |
| Browser memory ceiling | DuckDB-WASM handles 10M+ rows comfortably |
| Code change | Significant: new server route for Parquet, new client wiring, write-sync code |
| New runtime deps | DuckDB on server (Node-API or shell), `@duckdb/duckdb-wasm` on client |
**Right answer if:** dataset will grow past ~1M rows but you still want
local interaction speed, *and* you're willing to write the write-sync layer.
---
### C. Server-side DuckDB as a virtual server (no client load)
DuckDB lives on the Node server. Browser uses a `VirtualServerHandler`
implementation that proxies Perspective's view requests (`tableMakeView`,
`viewGetData`, `viewGetMinMax`, `tableSchema`) to a `/perspective` endpoint.
Server runs SQL against DuckDB which queries pg directly via
`postgres_scanner`, or against a Parquet copy.
- **Initial transfer:** essentially zero. Schema + first viewport only.
- **Interaction:** every drag/filter/group-by is a network round trip.
50200ms typical. Imperceptible for most operations; noticeable on
rapid drag interactions.
- **Writes:** simplest. Operations write to pg as today. DuckDB queries
pg live (via `postgres_scanner`) so it always sees current state. No
client-side state to sync.
- **Undo:** same as writes — server state is the source of truth.
| Aspect | Impact |
|---|---|
| Initial load | <1s regardless of dataset size |
| Interaction | 50200ms round trip per interaction |
| Writes | Simple single source of truth on server |
| Browser memory ceiling | Irrelevant data never enters the browser |
| Code change | Significant: custom `VirtualServerHandler` that talks to a new `/perspective` endpoint; server-side translator wiring |
| New runtime deps | DuckDB on server |
**Right answer if:** dataset will outgrow browser memory (10M+ rows) or
multiple users need to see real-time shared state. Pays an interaction
latency tax forever.
**Note:** Perspective-dev also ships a Python `virtual_servers/duckdb`.
If you're willing to add a Python sidecar, you may not need to write the
JS-side handler just stand up the Python server. Significant infra
change for a Node-based app.
---
### D. Hybrid — DuckDB-WASM read, pg write, server-pushed deltas
Same browser stack as B, but writes flow differently. After a forecast
operation, the server pushes back an Arrow batch of new rows (or a list of
`pf_logid`s to delete for undo). The client applies it to DuckDB-WASM via
SQL and refreshes the Perspective view. No re-export of Parquet on every
write.
This is essentially B with the write-sync layer specified. Splitting it out
because the write contract is the architectural decision worth deciding
explicitly:
- **Insert deltas:** server returns new rows as Arrow IPC, client does
`INSERT INTO forecast SELECT * FROM arrow_view`. Already trivial in
DuckDB-WASM.
- **Delete deltas:** server returns `{deleted_logid: N}`, client does
`DELETE FROM forecast WHERE pf_logid = N`.
- **Replace deltas (e.g., note edits):** if `pf_note` is joined at fetch
time (current state after `bf85f11`), edits are invisible until refetch.
Either accept that, or store note on the row and `UPDATE`.
This is the cleanest end state for a forecasting app: bulk read once,
incremental sync after.
---
## Comparison
| | Current | A: optimize | B/D: DuckDB-WASM | C: server DuckDB |
|---|---|---|---|---|
| Initial load (350k rows) | ~30s | ~510s | ~38s | <1s |
| Interaction latency | 0 | 0 | 0 | 50200ms |
| Write feedback | instant | instant | instant (after sync) | instant |
| Undo cost | full reload | full reload (or fix) | local DELETE | server-side |
| Browser memory ceiling | ~1M rows | ~1M rows | 10M+ rows | none |
| New deps | | | DuckDB (server + WASM) | DuckDB (server) |
| Code change | | medium | significant | significant |
| Risk surface | low | low | medium (write sync) | medium (translator wiring) |
---
## Open questions to resolve before choosing
1. **Expected dataset size 12 months out.** If it stays at ~350k1M rows,
option A is enough. If it goes to 5M+, A is dead in the water.
2. **Parquet caching strategy if going B/D.** Re-export on every write is
wasteful; delta replay is more code. Pick one explicitly before
building.
3. **Multi-user scenarios.** If two users edit the same version
concurrently, options B/D need a mechanism for one user's writes to
appear in another's local DuckDB-WASM. Option C gets this for free.
4. **Python-or-Node decision for server-side DuckDB.** Perspective-dev's
Python virtual server might let you skip writing a translator entirely
at the cost of a Python runtime alongside Node. Worth investigating
before committing to a JS-side custom handler.
5. **Should the spec move?** The spec mentions DuckDB only as a faster
bulk-encode path (option A-ish, server-side). Options B/C/D are
architectural shifts the spec doesn't contemplate. Whatever's chosen
should be written into `pf_spec.md` so the reasoning isn't lost again.
---
## Recommendation framing (not a decision)
- **If the immediate problem is "30s loads feel bad":** option A. It's the
smallest change with the highest perceived impact and doesn't paint you
into an architectural corner.
- **If you're already planning for data growth:** option D (DuckDB-WASM +
delta sync). It's the right end state for a single-user-per-version
forecasting tool with mid-to-large datasets.
- **If multi-user real-time becomes a goal:** option C. Pay the latency
tax once and have a cleaner data model.
A reasonable phased path: do A first (fast, low risk, ships value this
week), live with it while planning, then move to D when row counts demand
it. C is a different shape and probably not warranted unless multi-user
emerges as a requirement.
---
## Spike findings — Option C (server-side DuckDB virtual server), 2026-06-18
A working spike was built to measure Option C on the real ~535k-row forecast
(`pf.fc_osm_stack_20`, version 20). It is opt-in behind `?engine=duck` and does
not touch the default client-side WASM path.
**Architecture built.** Perspective's `VirtualServer` + `GenericSQLVirtualServerModel`
run in the browser (reusing the already-loaded viewer WASM) and turn each
interaction into SQL. That SQL is POSTed to a new server endpoint
(`routes/perspective.js`, `POST /api/perspective/sql`) and executed against a
persistent in-process DuckDB that has the forecast table materialized from
Postgres. Only generated SQL + the aggregated viewport (Arrow IPC) cross the
wire. Files: `routes/perspective.js`, `ui/src/duckServerHandler.js`, a branch in
`Forecast.jsx`, and the `duckdb` node dependency.
**Latency — excellent, not the bottleneck.**
| Measure | Result |
|---|---|
| Materialize 534,902 rows into DuckDB (one-time, startup) | ~1.51.9 s |
| Pivot aggregation in DuckDB (1-col / 3-col rollup / filtered) | 529 ms |
| Per-query round trip incl. HTTP + Arrow (browser server) | **~12 ms** |
| Initial load (no rows over the wire just schema + first viewport) | sub-second |
This is far below the doc's earlier 50200 ms estimate and dwarfed by today's
~2-minute cold load on this dataset.
**Blocking limitation — no interactive group-tree drill-down.** Perspective's
generic SQL virtual server cannot drive the expandable rollup tree this app is
built around:
- `group_by_depth` is **ignored** by the SQL generator verified by running
`GenericSQLVirtualServerModel` headlessly: the generated `tableMakeView` /
`viewGetData` SQL is byte-identical for depth 0/1/2/undefined. It always emits
the full `GROUP BY ROLLUP(...)`.
- The imperative `view.set_depth()` emits a `ViewSetDepthReq` the virtual server
does not handle `Abort(): Unhandled request`.
- `ViewConfig` has **no field to express per-node expansion state**, so
expand/collapse cannot be communicated to the SQL model at all. The server
returns a static full rollup; the grid cannot reflow it. (Per-node +/- fires
the SQL round trips but produces no visual change.)
What makes expand/collapse/depth work *today* is Perspective's own WASM view
engine computing the tree. The virtual server **replaces** that engine with the
SQL model, which does not implement dynamic expansion.
**This limitation is not Option-C-specific.** Options **B, C, and D** all use the
same `DuckDBHandler` / `GenericSQLVirtualServerModel`. Any DuckDB-backed virtual
server therefore inherits the same loss of interactive tree expand/collapse/depth.
**Only Option A** keeps rows in a native Perspective `Table`, preserving full
interactivity.
**Implementation gotchas worth recording:**
- The SQL generator must be constructed from the *initialized* viewer WASM module
(`customElements.get('perspective-viewer').__wasm_module__`), not the bare
`@perspective-dev/client` default export the latter's wasm glue is undefined
and `new perspective.GenericSQLVirtualServerModel()` throws.
- The handler must implement **both** `viewColumnSize` (→ `COUNT(*) FROM (DESCRIBE v)`,
column count) **and** `viewSize` (→ `COUNT(*) FROM v`, row count). The reference
`duckdb.ts` only shows `viewColumnSize`; omitting `viewSize` breaks row-count
rendering.
- DuckDB Arrow IPC output needs `INSTALL arrow FROM community; LOAD arrow;`
(the `to_arrow_ipc` table function is not built in to 1.x).
- DuckDB reads the pg forecast tables directly via `postgres_scanner`
(`ATTACH ... TYPE postgres, READ_ONLY`); the spike materializes a native copy so
aggregations are pure-columnar (writes would require a re-materialize/refresh).
**Conclusion.** The latency case for server-side DuckDB is strong, but the generic
SQL virtual server cannot support this app's expandable pivot UX. Choosing any
B/C/D path means either (a) writing a custom virtual server that implements
depth/expansion in SQL (rebuilding what the generic model omits), or (b) accepting
flat/fully-rolled-up views with no interactive drill-down. To keep the current UX,
**Option A** (optimize the encode/transport, keep the native Perspective table) is
the path.
---
## Decision — display-grain pre-aggregation (2026-06-18)
The spike showed the real lever isn't the *transport*, it's the *grain*: the
browser pivots at a coarse display grain (e.g. `rep × customer × month`) but we
ship raw transaction rows. Pre-aggregating to that grain server-side (DuckDB or
plain pg `GROUP BY`) collapses **534,902 → 4,642 rows** (≈115×) on `osm_stack`,
and adding more dimensions barely moves it (4,642 4,743 with director +
channel) because the raw detail (part/plant/day/currency…) all rolls up.
This is the chosen direction. It is essentially "Option A done right" reduce
**rows** by aggregating to grain, not just trim columns and it keeps the
**native** Perspective engine, so expand/collapse/depth/sort/filter keep working
(the capability B/C/D sacrifice). The DB does the heavy aggregation (~25 ms);
the browser receives a few thousand native rows.
**Why it beats the alternatives here:**
- vs. current: ~250 MB / ~2 min a few hundred KB / sub-second; render instant.
- vs. B/C/D (DuckDB virtual server): keeps interactive drill-down; no per-drag
round trips; no custom SQL generator to own.
- The undo "full reload" wart disappears a re-pull is now ~25 ms.
**Trade-offs accepted:** additive measures only (sums exact; averages = sum+count;
distinct-counts not pre-aggregatable fine for forecast); a view only supports
the dimensions in its grain (handled by re-fetching at a new grain when the user
changes group/split/filter, or by choosing a grain that covers the pivot set
while avoiding high-cardinality dims like `part`).
**This is a port of the prior Excel model.** The forecasting process previously
ran in Excel: pre-aggregated rows were sent to a data tab, pivot tables cut them
locally any way the user wanted, double-clicking a pivot cell let VBA capture the
slice and present an adjustment UI, and incremental rows were built in the DB,
**appended** to the data tab, and the pivot cache refreshed to include them. That
maps 1:1 onto native Perspective (pivot cache = the WASM view) which is *why*
the native engine matters and the virtual server was the wrong fit: Excel never
re-queried the DB on every pivot move.
**Write/read model** (append deltas, let the view sum the pivot-cache pattern;
single source of truth = pg raw rows):
- **Initial load** `GET /api/versions/:id/agg`, `GROUP BY` grain × `pf_iter` ×
`pf_logid` Arrow native table indexed by a synthetic `pf_gkey`.
- **Write (scale/recode/clone)** operation's final CTE returns just the new
`pf_logid`'s rows aggregated to grain `table.update()` **appends** them; the
view re-sums. No bucket-total recomputation (keying on `pf_logid` keeps each
op's contribution a distinct row, so appends accumulate).
- **Delete (undo)** `table.remove()` that logid's `pf_gkey`s; the view re-sums.
No re-aggregation, no emptied-bucket handling. (Or re-pull `/agg`, now cheap.)
**Concrete design** (schema flag `col_meta.in_grain`, the `/agg` endpoint, the
synthetic `pf_gkey` index, and the operation/undo SQL templates) is specified in
`pf_spec.md` §Display-grain pre-aggregation.
---
## Two candidate designs for server-side aggregation (open — 2026-06-18)
Both keep **Perspective as the client** and **DuckDB/pg as the aggregation
engine** over raw pg rows (the source of truth), and both rely on additive
measures (sums; avg = sum+count; no distinct-count). The target experience for
both is "Excel PivotTable against a Power BI / SSAS tabular model": a good-looking
pivot driven by a fast columnar engine. They differ in **where the rollup happens**
and therefore **what crosses the wire** which trades off against each other.
### Path A — Live server aggregation (virtual server)
- **Reads:** Perspective runs as a virtual server; each interaction
SQL DuckDB just the viewport cells. Requires a **custom depth-aware SQL
generator** replacing `GenericSQLVirtualServerModel` (honor `group_by_depth`,
reproduce the `__ROW_PATH__`/`__GROUPING_ID__` contract, and eventually
`split_by`'s dynamic pivot + expression columns).
- **Writes/undo:** write raw rows to pg; the next query reflects them (re-query /
invalidate) no client-side row bookkeeping.
- **Pros:** unbounded dataset only the visible viewport ever ships; always live;
the truest "any cut, any size."
- **Cons:** the generator is a real component to build and own; **per-node**
expand/collapse is a hard frontier (the protocol delivers only a global depth to
the JS handler, not per-node expansion state that needs a Rust/protocol fork);
per-interaction latency (~12 ms + RTT, measured in the spike).
- **Excel analog:** live SSAS / Power BI **MDX** connection.
### Path B — Pre-aggregated extract per cut (native table)
- **Reads:** the server ships a flat `GROUP BY` to the **current pivot's field
grain**; the result loads into a **native** Perspective table, and Perspective's
own engine does all rollup/expand/collapse/depth/sort **locally, with zero
calls**. A server round trip happens **only when the field set changes**
(add/remove a dimension, or filter on a non-grain dimension) not on
expand/collapse/sort or moving a field between rows and columns.
- **Writes/undo:** append grain-aggregated delta rows keyed by `pf_logid`; the view
sums them. Undo removes that logid's rows. (The append model concrete design in
`pf_spec.md` §Display-grain pre-aggregation.)
- **Pros:** the server SQL is trivial and fully owned (no tree contract to
reproduce); zero-latency native interaction; native look/feel; payload is small
(only the pivot's columns, only distinct grain rows).
- **Cons:** ships the **entire current-grain leaf set**, not just the viewport so
a high-cardinality cut (anything × `part`-like) can be large and eventually hit
Perspective's ~12M-row WASM ceiling; pays a re-fetch when the field set changes.
- **Excel analog:** Power Query **extract → pivot cache**.
### Comparison
| | Path A live virtual server | Path B pre-agg extract |
|---|---|---|
| Where rollup happens | server, per interaction | client (native), per cut |
| Crosses the wire | visible viewport only | whole current-grain leaf set (pivot cols only) |
| Expand/collapse/sort | server round trip each | local, zero calls |
| Server-side code | custom SQL generator (large) | flat `GROUP BY` (trivial) |
| Dataset ceiling | none (viewport only) | ~12M rows per cut (WASM) |
| Per-node expand | needs protocol/Rust fork | native (free) |
| Interaction latency | ~12 ms + RTT | 0 (local); re-fetch only on field-set change |
### The deciding question
It comes down to one thing: **do real cuts ever produce a leaf grain too large for
the browser?**
- If practical cuts stay under ~1M leaf rows **Path B** dramatically less code,
all of it yours, native feel, no generator to maintain.
- If you need truly unbounded any-cut-any-size **Path A** at the cost of
building/owning the generator and accepting the per-node-expand frontier.
**Hybrid (future option):** default to B; when a requested grain's distinct count
exceeds a threshold, fall back to A (virtual server) for that cut. Best of both,
more moving parts. Worth noting but not for a first build.
Next step toward deciding: map which dimensions are safe in a Path-B grain vs.
which are high-cardinality landmines (`part`, day-level dates, etc.) on the real
data that tells us where B's ceiling actually bites.