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

24 KiB
Raw Permalink Blame History

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_logids 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 loadGET /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_gkeys; 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.