VBR-9160
ETL:
14 hours → 13 minutes
Rewriting how we calculate and load portfolio data
What the ETL does
- Reads every client's data from the app
- Ledger metrics — NAV, IRR, MOIC, commitments…
- KPIs, cap tables, financing rounds, industries, stakeholders
- Writes 21 flat tables to the analytics warehouse
- Powers benchmarking & analytics in Dash
KPIs are the biggest table by far — 3.7 million rows.
The ledger metrics are the slowest to produce.
That gap is the whole story: the heavy part isn't the volume, it's the calculation.
How it worked
- One pod, once a week, 6 GB RAM
- All clients, one after another
- Everything through GraphQL, one call at a time
- No way to scale it up
The problem
~14 h
A full run on PROD. Locally the first runs took 8–9 hours.
Weekly cadence — the data was always stale.
skippable
A free win
FX rates
- The ETL loaded and calculated FX rates
- Wrote them to the warehouse as a table
- Nobody ever queried them — Dash converts at query time
- Removed entirely
Pure waste — deleted before optimizing anything else.
Where the time went
portfolioSummary
- The single slowest query in the pipeline
- Returns all metrics for one fund, at one date
- The ETL needs every month-end of a fund's life
- Up to 200+ calls per fund
deep dive
Underneath it
NewSqlLedgerDashboardDetails
- One class, 7,411 lines
- Orchestrates ~200 SQL functions
- Every call recomputes from scratch for a single date
Fix #1
Ask for all dates at once
- One call per date → one call for all dates
- SQL functions extended to accept a list of dates
- Same work, far fewer round trips
The obvious win — we were paying the round-trip tax hundreds of times per fund.
deep dive
What that required
- ~200 SQL functions had to support arrays of dates
- Each returns one row per date instead of one row
- Results grouped and matched back per date
- Behaviour had to stay identical
Which queries use that class?
portfolioSummary
ledgerDashboardDetails
newLedgerDashboardDetailsPerPeriod
newFundLedgerDashboardDetailsPerPeriod
newLedgerDashboardDetailHistory
investmentFilterData
portfolioLatestStages
Seven queries. Each asks for a slightly different slice —
all computing the same underlying numbers.
The opportunity
One query could serve them all
- Same data, different shapes
- A query that takes what you want and when
- Later: delete a lot of overlapping code
Fix #2
The ledgerMetrics query
- Pick any metrics, any dates, any scope
- Replaces several overlapping queries
- One trip fetches what previously took several
- Less data fetched, less repeated work
And a long-overdue cleanup
Before
One file
7,411 lines
After
A module
29 focused files
Split by metric — commitments, costs, IRR, shares, status…
Something we'd wanted to do for a very long time.
Result so far
14 h → ~2 h
Fewer round trips, one unified query. Still all SQL.
Fix #3
Move the resolver to Go
- We already run a Go service for ledger charts
- The query moves there — still calling the same SQL functions
- Node no longer serializes millions of rows
- One ENV switches between Node and Go
Nothing about the calculations changed yet — only who orchestrates them.
Then reality hit
- Locally: a full run was down to 110 minutes
- On DEV it was still slow
- The SQL is limited by database hardware
- My laptop is ~3× faster than the Aurora CPU
- PROD might be faster — no chance to test it
Fix #4 — the big one
Do the maths in Go
- Stop asking the database to calculate
- Read raw events once, compute in the service
- Database does what it's good at: fetching rows
- Scales with cheap app CPU, not the DB
We tried this before
- Years ago:
NewLedgerDashboardDetails — 4,663 lines of TypeScript
- Same idea: load raw events, compute in memory
- It was slow — so we moved the maths into SQL instead
- That became
NewSqlLedgerDashboardDetails — where we started
SQL was the right call then: simple queries, no recursion, fast.
The ledger has grown a lot since — and so has the SQL.
Both classes still exist side by side — this is the third attempt at the same problem.
deep dive
Why Go works where TypeScript didn't
- One CPU core per process → now all cores
decimal.js allocates objects for every operation — millions of them
- Go decimals are cheap; no garbage-collection stalls
- No JSON serialization between calculation and caller
Same algorithms, same results — the language is what changed.
Go vs SQL
| Same data, same results | Time |
| SQL functions | 121 s |
| Go calculations | 62 s |
~3.3× faster across a full ETL run.
92.7% of all calls run in Go — the rest fall back to SQL.
What still falls back — and why
- Everything meaningful is ported — 43 of 44 calculations
- Program-filtered calls — one function
- And — deliberately — any error at all
The fallback is a feature, not a gap: if a Go engine fails for any reason,
the call silently runs the SQL function instead. Worst case is slower, never wrong.
Editing an event used to fall back too — that's now in Go (next slide),
because it's the one path the frontend migration depends on.
Editing an event now runs in Go
- Editing shows the value before that event — recomputed without it
- That path used to drop to SQL — on the screen a user waits on
- Now computed in Go, like everything else
- Needed before the frontend can use the new query
The ETL never excludes an event — this was purely for the app.
And now it scales
Coordinator + workers
- Coordinator — prepares, distributes, finishes
- Workers — each processes clients independently
- Each worker has its own calculation service
- Work is claimed dynamically, not pre-assigned
How one run works
- Coordinator creates staging tables
- Fills a queue with all clients
- Starts N workers
- Each worker claims the next client, repeats until empty
- Coordinator swaps staging → live, atomically
Dynamic claiming means one huge client never blocks a worker's queue — the others keep taking work.
skippable
Why it's safe
- Live tables untouched until the final swap
- Crashed worker → its client is re-claimed
- Failed client → keeps its previous data
- A dead run breaks nothing
The whole journey
| Setup | Full run |
| Original — PROD | ~14 h |
| Original — local | 8–9 h |
| + batched dates, one query, Go resolver | ~2 h |
| + Go calculations, 1 process | 29 min |
| + 6 workers | 13 min |
Last two measured cold — caches flushed, worst case.
skippable
How many workers?
| Workers | Time |
| 4 | 17m 54s |
| 6 | 13m 06s |
| 8 | 13m 48s |
More workers help — up to a point. This laptop has 14 cores, and locally
they all share one calculation service. In the cluster each worker gets its own,
so the sweet spot moves higher.
skippable
More cores, more benefit
| Machine | 1 process | With workers |
| Small laptop — 6 cores | 54 min | 41 min |
| This laptop — 14 cores | 29 min | 13 min |
The same full run on the cheapest MacBook and on mine.
Fewer cores → less to gain from splitting the work.
Which is the argument for the cluster: real nodes have more cores than either.
How we know it's correct
- Same test suites as before — vitest + cypress
- No separate test path for the Go code
- A full ETL run compared value by value against the old path
- Byte-identical — including AU data and all currencies
573 ledger tests green against the Go engines.
Three implementations, one truth
JavaScript = SQL functions = Go calculations
Every ledger test now runs all three and compares them.
A mismatch anywhere fails the build.
This is the real safety net — 573 ledger tests, three ways each.
What happens next
- Use the new query in the ETL only
- After it settles — the 7 queries on the frontend
- Replace the class where it's used directly — 105 files
- Deprecate, then delete the old queries and the class
- Keep the SQL functions as the reference
Step 2's prerequisite — event editing in Go — is done.
Step 3 is the bigger half: exports, integrations, risk, waterfall, CLI —
everything that never went through GraphQL.
deep dive
The replacement already exists
Old
NewSqlLedgerDashboardDetails
12 positional arguments
await .init() first
New
LedgerMetricsRow
named arguments
loads on demand
85 accessors with the same names —
ownership(), entryRound(), multiple()…
Most call sites become a constructor swap.
deep dive
One query needs a bit more
investmentFilterData — used by KPI Comparison
- Wants metrics per company, across all visible funds
ledgerMetrics returns per-investment or summed — not per company
- So: add a per-company row mode
Ownership, multiple and exposure can't simply be added up,
so it has to be computed per company — today it loops one call per company.
All five of its metrics already exist on the new row; only the grouping is missing.
The min/max "ranges" block is just a reduce over the result.
Summary
14 h → 13 min
- 7,411-line class → a structured module
- One query instead of many
- Calculations in Go, scaled across workers
- Every number verified against the old path