VBR-9160

ETL:
14 hours → 13 minutes

Rewriting how we calculate and load portfolio data

What the ETL does

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

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

Pure waste — deleted before optimizing anything else.

Where the time went

portfolioSummary

deep dive

Underneath it

Fix #1

Ask for all dates at once

The obvious win — we were paying the round-trip tax hundreds of times per fund.

deep dive

What that required

Which queries use that class?

Seven queries. Each asks for a slightly different slice — all computing the same underlying numbers.

The opportunity

One query could serve them all

Fix #2

The ledgerMetrics query

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

Nothing about the calculations changed yet — only who orchestrates them.

Then reality hit

Fix #4 — the big one

Do the maths in Go

We tried this before

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

Same algorithms, same results — the language is what changed.

Go vs SQL

Same data, same resultsTime
SQL functions121 s
Go calculations62 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

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

The ETL never excludes an event — this was purely for the app.

And now it scales

Coordinator + workers

How one run works

  1. Coordinator creates staging tables
  2. Fills a queue with all clients
  3. Starts N workers
  4. Each worker claims the next client, repeats until empty
  5. 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

The whole journey

SetupFull run
Original — PROD~14 h
Original — local8–9 h
+ batched dates, one query, Go resolver~2 h
+ Go calculations, 1 process29 min
+ 6 workers13 min

Last two measured cold — caches flushed, worst case.

skippable

How many workers?

WorkersTime
417m 54s
613m 06s
813m 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

Machine1 processWith workers
Small laptop — 6 cores54 min41 min
This laptop — 14 cores29 min13 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

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

  1. Use the new query in the ETL only
  2. After it settles — the 7 queries on the frontend
  3. Replace the class where it's used directly — 105 files
  4. Deprecate, then delete the old queries and the class
  5. 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

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