Statewalk

Interactive essay

Keeping a join alive, one row at a time

Four relations in a chain. A row is live when it can reach the bottom of the chain, and only the top of the chain ever emits a number.

TPC-H Query 10 asks a reporting question. For one quarter of 1993, which customers cost the most in returned goods?

select c_custkey, c_name, sum(l_extendedprice * (1 - l_discount)) as revenue, ...
from customer, orders, lineitem, nation
where c_custkey = o_custkey
  and l_orderkey = o_orderkey
  and o_orderdate >= date '1993-10-01'
  and o_orderdate < date '1993-10-01' + interval '3' month
  and l_returnflag = 'R'
  and c_nationkey = n_nationkey
group by c_custkey, c_name, c_acctbal, c_phone, n_name, c_address, c_comment
order by revenue desc
limit 20;

Against a finished database this is an ordinary four-way join. The interesting version is the other one: rows are still arriving, one at a time, and the answer has to be correct after every single arrival.

Re-running the join per arrival is out — that is millions of rows of work to account for one new order. The obvious repair, “join just the new row against everything else”, sounds cheap and is not. If the new row is a nation, it joins with a sizeable fraction of the database.

What follows is the way out taken by Cquirrel (SIGMOD 2020), narrowed to this one query. There is no mathematics in it. The whole thing rests on a single property of a row, and everything else is bookkeeping to keep that property true. Every figure below is driven by a working implementation of the algorithm rather than by hand-drawn frames, and the last one hands you the buttons.

The chain has a direction

Before anything can be maintained, the four relations have to be put in some order. The order is not the one most people reach for.

Step 1 of 6
Foreign-key direction between the four relationslineitemprimary key: l_orderkey, l_linenumberordersprimary key: o_orderkeycustomerprimary key: c_custkeynationprimary key: n_nationkey
Four relations, and one primary key each. Ignore which one you think of as the big table and just read the equalities in the WHERE clause.

Four relations, and one primary key each. Ignore which one you think of as the big table and just read the equalities in the WHERE clause.

So the shape for the rest of this essay is a vertical chain, with lineitem on top and nation at the bottom. Read each step downward as “references”. Root and leaf follow the arrows, not the row counts: lineitem is the root although there are six million of them, and nation is the leaf although there are twenty-five.

A row is live when it can reach the bottom

Here is the entire idea.

A row is live when it can join with one row in every relation below it, all the way down to the leaf. A leaf row is live for nothing, because it has no reference that could fail.

That is the only definition in this essay. A stored row that is not live is dormant, and dormant is neither an error nor a rejection. The row is present, indexed, and perfectly good. It just cannot reach the bottom yet, so it cannot contribute to the answer yet.

Watch what that buys. Below, four rows arrive in the worst order imaginable: the lineitem first, its nation last.

Step 1 of 8
start empty state
State of the four relationslineitemrootl_orderkey =o_orderkeyorderso_custkey =c_custkeycustomerc_nationkey =n_nationkeynationleaf
  • live
  • dormant
  • reference to a row that is not here
join deltas none
Nothing has arrived. Read the chain downward: a lineitem references its order, an order references its customer, a customer references its nation.

Nothing has arrived. Read the chain downward: a lineitem references its order, an order references its customer, a customer references its nation.

Nothing there required a sorted stream, or a lineitem to be held back until its order showed up, or any row to be read twice. Each arrival asked one question — is the row I reference live? — and then either joined the answer or lay down and waited to be woken.

Now the same four rows in the order you would have picked yourself.

Step 1 of 5
start empty state
State of the four relationslineitemrootl_orderkey =o_orderkeyorderso_custkey =c_custkeycustomerc_nationkey =n_nationkeynationleaf
  • live
  • dormant
  • reference to a row that is not here
join deltas none
The same four rows. This time they arrive in the order a textbook would draw them, from the bottom of the chain up.

The same four rows. This time they arrive in the order a textbook would draw them, from the bottom of the chain up.

Same final picture, same +$2,000. This is worth saying plainly, because it is what makes the approach usable at all: the state after a set of updates does not depend on the order the updates arrived in. Order changes how much work happens and when, never the answer.

Notice too where the number came from. Both times it left at the lineitem, and that is not an accident of the example. A customer becoming live does not change the query’s answer by itself — it changes the answer only by allowing lineitems to become live. So the root is the one place output is produced, and every relation below it exists to push liveness upward toward it.

Nothing is stored that cannot matter

Two of Q10’s predicates look at a single row: the three-month order window and l_returnflag = 'R'. Those run before anything is stored.

Step 1 of 7
start empty state
State of the four relationslineitemrootl_orderkey =o_orderkeyorderso_custkey =c_custkeycustomerc_nationkey =n_nationkeynationleaf
  • live
  • dormant
  • reference to a row that is not here
join deltas none
Q10 does not accept every row it is offered. Two of its predicates read a single relation, and those run first.

Q10 does not accept every row it is offered. Two of its predicates read a single relation, and those run first.

A rejected row is not stored dormant, waiting to be re-examined. It is dropped, and the picture does not move.

That has a consequence worth stating out loud, because it looks like a bug the first time you see it. Inside this maintained state, foreign keys do not hold. An order can reference a customer that is not there — because the customer has not arrived yet, or because the order passed a filter that its neighbours failed. Dangling references are the normal condition here, not corruption.

What one relation remembers

A relation in the middle of the chain keeps three things, and it is worth watching all three move.

Step 1 of 8
start empty state
Live set, dormant set and reverse indexL(orders)joins all the way downN(orders)stored, but not joiningI(orders, customer)every stored row
The orders relation, alone. Three structures, and nothing else: the rows that join all the way down, the rows that do not, and an index from each customer key to the orders that reference it.

The orders relation, alone. Three structures, and nothing else: the rows that join all the way down, the rows that do not, and an index from each customer key to the orders that reference it.

The third structure is the one that does the real work. When a customer becomes live, nobody scans the orders table looking for affected rows: the customer key is looked up in that index, and it hands back exactly the orders that reference it. Membership in L or N then says whether each of those orders actually crossed a boundary, so a row that was already live is silently skipped and the cascade stops early.

The paper gives every row a counter, s(t), holding the number of child relations it is currently live on; a row is live when that number reaches the number of children it has. On a chain every relation has exactly one child, so the counter can only ever be zero or one — which is to say, it is already recorded by which of the two maps the row is sitting in. The Scala carries no counter at all:

private val liveByPrimaryKey = mutable.HashMap.empty[PK, Value]
private val nonLiveByPrimaryKey = mutable.HashMap.empty[PK, Value]
private val parentKeysByChildKey =
  mutable.HashMap.empty[ChildPK, mutable.HashSet[PK]]

Deleting is not inserting backwards

Deletion looks like it should be insertion with the signs flipped, and for the state it very nearly is: rows slide from L back to N, and the deltas come out negative. Step through it, and then look closely at the last panel of the first update.

Step 1 of 11
start empty state
State of the four relationslineitemrootl_orderkey =o_orderkeyorderso_custkey =c_custkeycustomerc_nationkey =n_nationkeynationleafc10Adal100·1$2,000l101·1$1,350n1ALGERIAo1001993-10-15o1011993-11-02
  • live
  • dormant
  • reference to a row that is not here
join deltas none
Ada, two orders inside the window, one returned lineitem on each. Everything is live, and the answer holds $3,350 for Ada.

Ada, two orders inside the window, one returned lineitem on each. Everything is live, and the answer holds $3,350 for Ada.

The two negative deltas say ALGERIA. By the time they were built, no index in the system still held that name — the nation row was removed in the first frame, several frames before the root was reached.

This is the one place where a plausible implementation is quietly wrong. On the way up, a lineitem that has just become live can rebuild its own join by looking downward, because everything below it is live by definition:

private def assembleFromLive(lineitem: Lineitem, sign: Int): Option[JoinDelta] =
  for
    order <- ordersState.live(lineitem.orderkey)
    customer <- customerState.live(order.custkey)
    nation <- nationState.live(customer.nationkey)
  yield Aju.toDelta(lineitem, customer, nation, sign)

On the way down, that same lookup returns nothing, because the deleted row is already gone. So the demotion path does not look anything up. It carries the values it is about to lose down the chain as arguments, gathering one more at each level, until the root has everything a delta needs:

private def demoteLineitems(
    order: Orders,
    customer: Customer,
    nation: Nation,
): List[JoinDelta] =
  lineitemState.childBecameNonLive(order.orderkey).toList.map { lineitem =>
    Aju.toDelta(lineitem, customer, nation, sign = -1)
  }

The second half of that player is the other thing to take from this section. Putting the nation back restores the entire join, and not one order or lineitem had to be re-read to do it. Dormant rows never left their indexes. They were waiting the whole time.

Summing without forgetting how many

The root emits signed money attached to a customer key. The last stage adds it up, and has exactly one problem worth solving.

Step 1 of 7
start empty state
join deltas none
customer groups
customernationrevenuecontributions
no customer has a live returned item
Two customers with live orders and no returned items yet. The join is maintained; the answer is empty.

Two customers with live orders and no returned items yet. The join is maintained; the answer is empty.

A running sum cannot tell “this customer’s returns cancelled out to zero” from “this customer has no returns and should not be in the answer”. Both are zero. So each group carries the number of live joins standing behind it, and it is that count reaching zero — not the money reaching zero — that removes a customer from the result.

Q10 finishes by ranking these groups and keeping the top twenty. That part is not built yet in the implementation these figures come from.

Drive it yourself

Every row button below is a real insert or delete against a real instance of the algorithm. The random-order button is a different kind of aid: it shuffles the accepted rows, then inserts them one at a time so you can watch the exact sequence of clicks and the cascade each click causes. It is still the same instance and the same insert operation — the only script is the pause between clicks.

Run it a few times, then compare the final picture: the order changes the route through dormant state, never the answer. Or make your own order and delete a row in the middle of a live chain — a customer, say — to watch a single removal put four relations to sleep and pull the money back out of the answer.

start empty state
State of the four relationslineitemrootl_orderkey =o_orderkeyorderso_custkey =c_custkeycustomerc_nationkey =n_nationkeynationleaf
join deltas none
answer empty
lineitem
orders
customer
nation
0 updates applied

The two rows with red outlines can never be stored. One is a lineitem that was never returned, the other an order dated outside the window.

What it costs

The usual summary of this algorithm is “constant time per update”, and that is not quite what is true. The bound is amortised, and the cost of a single update is not bounded at all: inserting one nation row can wake every customer in that nation, every one of their in-window orders, and every returned lineitem on those orders. You saw a four-row version of that cascade; there is nothing stopping it from being a two-hundred-thousand-row version.

What is genuinely constant is the work per row that actually changes status. No row is ever scanned speculatively, nothing is recomputed to check whether it changed, and each transition is a handful of hash-map operations. The cost is proportional to how much of the answer really moved, which is the honest thing to want from an incremental system.

Two limits are worth knowing. This works because Q10’s relations form a chain — one child each. When the foreign keys form a branching graph, “live on every child” stops being sufficient, because two children can each be live through paths that reach different rows further down, and the algorithm needs extra state to notice. And every row here has an identity: an update is a delete followed by an insert, never an edit in place. That is what lets a delta be signed, and a signed delta is what lets the answer be maintained instead of recomputed.