The World Vol 7 · The Living Valley
ch 78 / 105
Chapter 78

The Archive Becomes a Table

The archive load order

Two centuries of The Hollow exist as a file. Every animal that ever stood up in it has a line, every animal that ever stopped has another, and the lines are in the order they happened because the only thing anything has ever done to that file is add to the end of it.

The archive's order is the only order it can be loaded in, and once it is loaded, order stops mattering: any question, in any direction, is one statement. The table does not replace the file, because the file is what the table gets checked against, and every answer on this page is produced twice.

The file is the most trustworthy object this world owns. It is also the most awkward thing in it to ask a question of.

The awkwardness has one cause. A file of records has exactly one reading order, from the top, and it has no way of finding anything except by going past everything in front of it. Ask who a creature's parent was and the program reads the whole archive.

Ask again about a different creature and it reads the whole archive again, unless it keeps the first reading, in which case it is holding every genome the run ever made in memory to answer a question about one integer. Climb a pedigree and that is one search a generation.

The evolution volume did all of that and it was the right thing to do, because there was nowhere else for the answers to come from. There is now. The last three chapters brought a database up on a bridge with no road off it, argued a schema into existence column by column, and worked out when a running world is in a state somebody can write down. The creature table those chapters built has never held a row.

By the end of this page there is a two-century archive on disk, two thousand and twenty-five births and seventeen hundred and eighty-eight deaths in two tables, a query that climbs a pedigree by calling itself, one statement that finds the most recent animal two living creatures both descend from, and a measurement of what the index on a single integer column does to the work a question costs.

Three things are deliberately not on it. Nothing in a tick reads any of this: the architectural rule that opened this database is that the five simulation packages do not know a store exists, and a creature's behaviour may not depend on a row somebody wrote about its grandmother. The chronicle table stays empty, and so does the world table, because what a world calls itself is a different question from who came from whom. And nothing here runs unattended; every command on this page is one somebody typed.

Loading two centuries

Start by making the history, because a page about loading two centuries of anything should load two centuries of something real. The chapter's bench founds the valley this volume has been running, opens an archive before the first founder stands up, and leaves it alone for two hundred years.

▣ Build · stage 1 — the history this page is about
$ go run ./cmd/climb -mode grow -dir /tmp/climb-78
climb: two centuries of the valley, kept as a file and nothing else

  a 16x12 valley, 21 browsers off stream 12 and 8 hunters off stream 22
  every birth and every death appended to climb-78.jsonl, and nothing in the valley reads it

   year   brwsrs  hunters      born      gone       bytes
     25      238        0      1734      1496    15860215
     50      237        0      1872      1635    17125399
     75      238        0      1991      1753    18216308
    100      238        0      1999      1761    18289556
    125      236        0      2001      1765    18308094
    150      235        0      2010      1775    18390624
    175      238        0      2024      1786    18518631
    200      237        0      2025      1788    18527773

  the archive at the end of 200 years
    born records                                    2025
    died records                                    1788
    records in all                                  3813
    bytes                                       18527773
    of those, the born records                  18439280
    and the died records                           88493
    sha256, first 16                    e04e8d6b021b6972
    still walking when it stopped                    237

  /tmp/climb-78/climb-78.jsonl

  719550 ticks in 5m32.469s, 2164 ticks a second (measured here; yours will differ)

Two thousand and twenty-five births and seventeen hundred and eighty-eight deaths, in eighteen and a half megabytes, and the timing on the last line is an eight-core Ryzen 7 3700X talking to itself. Everything else in the block is arithmetic and comes out the same anywhere.

The column of birth counts is the part to sit with, because it is not what a reader expects two centuries to look like. Seventeen hundred and thirty-four of those two thousand births happened in the first twenty-five years. The next hundred and seventy-five years produced two hundred and ninety-one. The population settles at about two hundred and thirty-seven browsers and then very nearly stops changing: this valley sets a child down on an empty cell, so once the ground is crowded a creature with a full store asking for a child is told there is nowhere to put it. A full valley writes almost no history. That is a fact about the valley and it is also the reason to keep an archive: the interesting years are over long before anybody thinks to ask about them.

Now the same file, opened by something that was not there when it was written. That is the only honest way to read an archive, and it is what the bench's next mode does: it gets a path and nothing else, no valley, no roster, no pool.

▣ Build · stage 2 — what the file costs before it says anything
$ podman exec -w /bench world-go go run ./cmd/climb -mode file -dir /tmp/climb-78 | tail -32 | head -16
  /tmp/climb-78/climb-78.jsonl
    records                                         3813
    of those, born                                  2025
    and died                                        1788
    bytes                                       18527773
    creatures still walking                          237

  the deepest line of record in it
    creature                                        1555
    steps up to the founder at the top of it          35
    and that founder is                               21

  the oldest animal still walking and the youngest
           426  born on tick   25887, generation   17, row 0
          2025  born on tick  688536, generation   21, row 0

Three facts and every one of them cost the same thing. The deepest line of record in this history runs thirty-five parents back from creature 1555 to creature 21, which was one of the twenty-nine animals this valley was founded with and has no ancestor above it. The oldest creature still walking is 426, born on tick 25,887, which is inside the eighth year. The youngest is 2025, born on tick 688,536, which is inside the hundred and ninety-second. Those two are alive at the same moment and their births are a hundred and eighty-four years apart.

$ podman exec -w /bench world-go go run ./cmd/climb -mode file -dir /tmp/climb-78 | tail -15
    the most recent ancestor both of them have       421
    born on tick                                   25761
    steps up from the older                            1
    steps up from the younger                          5
    ancestors the two of them share                   37

  and what any of that cost
    records read before the first answer            3813
    bytes parsed doing it                       18527773
    questions answered afterwards, for nothing        5

  a file has one reading order and it is the whole of it. Every question
  above came out of 3813 records held in memory at once, the cheapest of
  them cost the same 3813 as the dearest, and a process that wants one
  answer pays for all of them.

The two of them meet at creature 421, which is 426's own parent and stands five steps above 2025. Thirty-seven animals are ancestors of both. That is a real answer to a real question and the file gave it up without complaint, and the last block is the bill. Three thousand eight hundred and thirteen records parsed and eighteen and a half megabytes read, before any of the five questions above could be answered at all. All five were free afterwards, which sounds like a bargain until you notice what it costs: the only way to make one question cheap is to hold the entire history in memory, and the history is the thing that grows forever.

So the rows. Seven integers and a blob, which is what the schema chapter argued for, and the load's job is to produce them without inventing anything.

▣ Build · stage 3 — a born record, as a row
// internal/store/kin.go
// Rows turns an archive into creature rows, in the file's own order and
// in no other.
//
// The order is not a preference. creature.parent and creature.mate are
// foreign keys into creature itself, so a row naming a parent the table
// has not got is refused, and the archive is written in birth order,
// which means a parent is always above its child in the file. Sorting
// these by anything at all is how a load stops working.
//
// Generation is the one column that is not in the file. It is the
// length of the walk from a creature to the founder at the top of its
// line of record, and it is worked out here in one pass rather than by
// walking: the file is in birth order, so a parent's generation is
// already known by the time its child is read, and a child is one more
// than the parent that paid for it.
func Rows(recs []gene.Record) ([]Row, error) {
	gen := map[sim.EntityID]int{}
	var out []Row
	for _, rec := range recs {
		if rec.R != gene.Born {
			continue
		}
		// ... and a refusal for a creature born twice in one archive ...
		g := 0
		if rec.Of != 0 {
			up, ok := gen[rec.Of]
			if !ok {
				return nil, fmt.Errorf(
					"store: creature %d names parent %d, which is not above it in the file: "+
						"an archive out of birth order is an archive nothing can load", rec.ID, rec.Of)
			}
			g = up + 1
		}
		gen[rec.ID] = g

		blob, err := BlobOf(rec)
		if err != nil {
			return nil, err
		}
		raw, err := blob.JSON()
		if err != nil {
			return nil, err
		}
		out = append(out, Row{
			ID: rec.ID, Row: rec.Row, Species: rec.Kind, Tick: rec.Tick,
			Parent: ref(rec.Of), Mate: ref(rec.With), Generation: g, Genome: raw,
		})
	}
	return out, nil
}

Two decisions are in that function and both were made on the schema page. The genome goes in through BlobOf, which is the born record's own blocks under their own names with the row riding along, so the column holds a written form that was already checked every time an archive was opened, and no second description of an animal comes into existence. And ref turns a parent of nought into a null pointer, which the driver sends as SQL's null, because nought is a creature number and a founder has no parent at all.

generation is the column that is not in the file, and the loop is where it gets cheap. The archive is in birth order, so by the time a child is read its parent's generation is already in the map, and a child is one more than that. One pass, no climbing, and a lookup that fails is not a missing number: it is a file with a child above its parent, which is a file no database will take. That is checked here, where the file is still the obvious suspect, and not at the server, where the answer comes back as a constraint name.

$ go test ./internal/store/ -run 'GenerationIsCountedInOnePass|AnArchiveOutOfBirthOrder|TheGenomeColumnIsTheRecordsOwnBlocks|TheDeathsAreOnlyTheDiedRecords|TheRowsComeOutInTheFilesOwnOrder' -v
=== RUN   TestGenerationIsCountedInOnePassOverTheFile
    kin_test.go:60: 5 rows, generations counted without a single walk up a pedigree
--- PASS: TestGenerationIsCountedInOnePassOverTheFile (0.00s)
=== RUN   TestAnArchiveOutOfBirthOrderIsRefused
    kin_test.go:73: store: creature 3 names parent 1, which is not above it in the file: an archive out of birth order is an archive nothing can load
--- PASS: TestAnArchiveOutOfBirthOrderIsRefused (0.00s)
=== RUN   TestTheGenomeColumnIsTheRecordsOwnBlocks
    kin_test.go:96: every block of a born record in the column, and a genome back out of it
--- PASS: TestTheGenomeColumnIsTheRecordsOwnBlocks (0.00s)
=== RUN   TestTheDeathsAreOnlyTheDiedRecords
    kin_test.go:107: 2 deaths, in the order the file wrote them, every one naming a creature that is above it
--- PASS: TestTheDeathsAreOnlyTheDiedRecords (0.00s)
=== RUN   TestTheRowsComeOutInTheFilesOwnOrder
    kin_test.go:127: 5 rows in birth order, and every parent above the child that names it
--- PASS: TestTheRowsComeOutInTheFilesOwnOrder (0.00s)
PASS
ok  	theworld/internal/store	0.00s

Five tests on a pedigree of five creatures, and not one of them opens a socket. The second one is the load's whole safety argument written as something that fails: swap two records so that a child sits above its parent and the rows are refused before anything is sent. The last one is the rule easiest to miss, which is why it is there: the rows come out in the file's order and nothing anywhere is allowed to sort them.

▣ Build · stage 4 — two COPYs
// internal/store/kin.go
// Fill sends every row in one COPY.
//
// One statement is the whole design. A self-referencing foreign key is
// an after-row trigger, and an after-row trigger fires when the
// statement that queued it ends, not when the row lands: so inside one
// COPY every parent is already in the table by the time anything is
// checked. Break the load into two statements and that stops being
// true, which is why Chunks exists and why it is a different function
// with a different rule attached to it.
func (d *DB) Fill(ctx context.Context, rows []Row) (int64, error) {
	n, err := d.pool.CopyFrom(ctx, pgx.Identifier{"creature"}, creatureCols, copyRows(rows))
	if err != nil {
		return n, fmt.Errorf("store: loading %d creatures: %w", len(rows), err)
	}
	return n, nil
}

// Chunks sends the same rows as a run of COPYs of at most size rows
// each, which is what a load of a history too big to hold in memory has
// to become.
//
// Every chunk is its own statement and therefore its own check, so the
// file's order stops being a detail and becomes the thing that makes
// the load possible at all: a parent has to be in a chunk that has
// already gone. It hands back how many chunks it sent and how many rows
// landed, so a caller can say what a database holds after a load that
// stopped in the middle.
func (d *DB) Chunks(ctx context.Context, rows []Row, size int) (int, int64, error) {
	if size < 1 {
		return 0, 0, fmt.Errorf("store: a chunk of %d rows is not a chunk", size)
	}
	sent, done := 0, int64(0)
	for at := 0; at < len(rows); at += size {
		end := min(at+size, len(rows))
		n, err := d.pool.CopyFrom(ctx, pgx.Identifier{"creature"}, creatureCols, copyRows(rows[at:end]))
		done += n
		sent++
		if err != nil {
			return sent, done, fmt.Errorf("store: chunk %d, rows %d to %d: %w", sent, at+1, end, err)
		}
	}
	return sent, done, nil
}

COPY is not an insert with a faster wrapper round it. It is a different protocol: the client opens the copy, streams rows down the connection with no statement parsing and no plan and no round trip a row, and the server takes them until the stream ends. Eighteen megabytes of genome goes over in one exchange. It is also one statement, which turns out to matter more than the speed.

$ podman exec -w /bench world-go go run ./cmd/climb -mode load -dir /tmp/climb-78
climb: two centuries of births, out of a file and into a table, in one COPY

  what the load is about to send
    creature rows                                   2025
    death rows                                      1788
    bytes of genome                             18313213
    the file they came out of                   18527773
    the first row to go is creature 1, which names parent null
    the last  row to go is creature 2025, which names parent 1465

  before it
    creature          0 rows
    death             0 rows

  one COPY, and one after it for the deaths
    creature       2025 rows sent
    death          1788 rows sent
    creature       2025 rows now
    death          1788 rows now

  and what the server put on disk for it
    relation                  bytes   what it holds
    creature                 188416   the heap: the seven integer columns
    creature_genomes        8298496   the genomes, out of line and compressed
    creature_by_mate          57344   an index on mate
    creature_by_parent        65536   an index on parent
    creature_by_species       32768   an index on species
    creature_by_tick          65536   an index on tick
    creature_key              65536   an index on id
                            8773632

    the heap a question is answered out of        188416
    the genomes no question mentions             8298496
    the five indexes kept beside them             286720
    the indexes, as a share of the whole table      3.38%
    the heap,    as a share of the whole table      2.22%

Two statements for a history: one for the births and one for the deaths, in that order because death.id is a foreign key into creature and a death row for an animal the table has never heard of is refused. Within each of the two, the order is the file's.

The lower block is the schema chapter's argument arriving as bytes on a disk, and it came out better than the argument claimed. The genomes went in as eighteen million bytes of JSON and are sitting in eight point three million: Postgres does not keep a large value in the row it belongs to. Anything over about two kilobytes is compressed and moved into a store of its own, and the row keeps a pointer where the value was. So the creature table's heap, the thing a query walks when it is looking for a parent, is a hundred and eighty-eight thousand bytes for two thousand and twenty-five births, which is ninety-three bytes an animal. Two and two-tenths percent of what this history occupies is the part any question has ever mentioned.

The five indexes come to two hundred and eighty-six thousand bytes between them, which is three and four-tenths percent of the table. Four of those five were argued for on the schema page against the row they point into, and here is the row they point into, measured. An index is cheap next to a blob and expensive next to a pair of integers, and the same eight bytes of key is both.

One sentence in Fill's comment is doing a lot of work and it deserves to be checked rather than believed. A foreign key in Postgres is a trigger that runs after a row is written, and after-row triggers do not run as each row lands: they are queued and fired when the statement that queued them finishes. A COPY is one statement. So every row of the load is in the table before the first parent is looked for, and inside one COPY the order the rows arrive in cannot be wrong.

That is a fact about Postgres, it is true, and believing what it seems to imply is the most expensive mistake available on this page.

⚠ Worked failure — a load tidied on the way in

Here is the tidying. The table is going to be climbed by parent, so group the rows by the parent they name before sending them, founders first because they name no parents. Rows that share a parent then land next to each other, the index on parent is built from keys arriving in order, and a query that wants one animal's children finds them on one page. It is a real optimisation and people do it on purpose. The database is killed and started first so it is properly empty; its data directory is a tmpfs, so a restart is a fresh server with nothing in it.

$ podman kill world-db && podman start world-db
world-db
world-db
$ podman exec -w /bench world-go go run ./cmd/climb -mode load -cluster -dir /tmp/climb-78 | tail -29 | head -12
    the first row to go is creature 1, which names parent null
    the last  row to go is creature 2018, which names parent 2017

  before it
    creature          0 rows
    death             0 rows

  one COPY, and one after it for the deaths
    creature       2025 rows sent
    death          1788 rows sent
    creature       2025 rows now
    death          1788 rows now

It works. Two thousand and twenty-five rows in, seventeen hundred and eighty-eight deaths after them, and the order they went in bears no relation to the order they happened: the last row to go is creature 2018, and the last birth this history holds is 2025. Nothing complained. The conclusion anybody would draw from that run is that the order does not matter, and the conclusion is right about the run and wrong about the system.

Now make it a real load. Eighteen megabytes fits in memory and two centuries of a valley this size is a small history; a history kept for a decade does not fit, and a load that cannot fit it reads the file in pieces and sends a COPY a piece. Same rows, same order, five statements instead of one.

$ podman kill world-db && podman start world-db
world-db
world-db
$ podman exec -w /bench world-go go run ./cmd/climb -mode load -cluster -chunk 500 -dir /tmp/climb-78
climb: two centuries of births, out of a file and into a table, in COPYs of 500 rows, grouped by the parent they name

  what the load is about to send
    creature rows                                   2025
    death rows                                      1788
    bytes of genome                             18313213
    the file they came out of                   18527773
    the first row to go is creature 1, which names parent null
    the last  row to go is creature 2018, which names parent 2017

  before it
    creature          0 rows
    death             0 rows

  chunk 1 of 5 was refused
    SQLSTATE  23503
    said      insert or update on table "creature" violates foreign key constraint "creature_mate"
    detail    Key (mate)=(509) is not present in table "creature".
    committed 0 rows, in the 0 chunks that went before it
    and nothing anywhere says the table is 2025 rows short
climb: the load stopped: insert or update on table "creature" violates foreign key constraint "creature_mate"
exit status 1

Read the detail line before anything else, because it names the constraint the first sort missed. The rows were grouped by parent. They were not grouped by mate, and a creature has two parents: the one that paid for the birth and the one it bred with. Creature 509 is somebody's mate and it is not in the first five hundred rows, so the check that runs at the end of chunk one goes looking for it and it is not there.

Work back from that. Sorting by parent satisfied one of the two self-referencing keys and had nothing to say about the other. What a load actually needs is an order that puts every creature after both of the animals that made it, and there is exactly one such order this world produces without being asked for it: the order the archive is written in, because both parents of a child were alive on the tick it was born. Sorting on id would do as well, and on tick, and for the same reason rather than by luck: identities are handed out in birth order and ticks only go forwards. Sorting on anything else is a rearrangement that happens to be legal or happens not to be, and the only way to find out which is to run it against five hundred rows at a time.

The second thing in that block is the two lines under the refusal, and they are the ones to remember on a bigger load. This one stopped on chunk one, so nothing had been committed and the damage is nought rows. Move the offending row three hundred lines later and chunks one and two would have gone in and stayed in, because each COPY is its own statement and its own transaction, and a committed chunk is committed. What is then in the database is a creature table with a perfectly valid thousand rows in it, every constraint satisfied, no row anywhere saying the history is short, and every ancestry query answering confidently out of a pedigree with holes in it. The single COPY could not leave that state and a chunked load can, which is the price of chunking and has to be known before you pay it.

The repair is to stop sorting, not to sort more cleverly. Send the rows in the order the file names them and a chunked load is as safe as a single one, because a parent is always in a chunk that has already gone.

$ podman kill world-db && podman start world-db
world-db
world-db
$ podman exec -w /bench world-go go run ./cmd/climb -mode load -chunk 500 -dir /tmp/climb-78 | tail -22 | head -5
  5 COPYs of at most 500 rows, and one after them for the deaths
    creature       2025 rows sent
    death          1788 rows sent
    creature       2025 rows now
    death          1788 rows now

Five statements, every row landed, and the table is byte for byte the table the single COPY built: the same heap, the same index sizes, the same everything. The general form is not about databases at all. A constraint that a single statement satisfies collectively is a constraint that a sequence of statements has to satisfy in order, and the sequence version is the one that runs in production on the day the input got big. Testing a load on data small enough to send in one go tests the forgiving case.

The recursive pedigree query

There are two thousand and twenty-five rows in a table now and the question the whole load was for is a pedigree: given a creature, who is above it, all the way up. That question has no fixed size. Creature 1555 stands thirty-five generations from a founder and creature 21 stands nought, and nothing about the row tells you which before you start, so no query with a fixed number of joins in it can answer the general case.

SQL's answer is a query that is allowed to mention itself. It comes in two halves and they do different jobs.

How a recursive query climbs a pedigree A recursive query drawn as rounds. At the top, the anchor query selects creature 1555 by id and its one row becomes the working table for round nought. Each following round runs the recursive term against the working table alone: it joins the working table to the creature table on the child's parent column and the rows it produces become the next working table. Round one holds creature 1548, round two holds 1541, and so on down to round thirty-five, which holds creature 21, the founder. Round thirty-six runs, finds that 21 has a null parent, produces nothing, and the recursion stops. On the right, a box for the creature table, with an arrow into it from the anchor and another from the recursive term, labelled one lookup by id, once a round. At the bottom, the result: every working table added up, thirty-six rows. HOW A RECURSIVE QUERY CLIMBS each round sees the round before it and nothing else anchor: WHERE c.id = 1555 runs once round 0: 1555 round 1: 1548 round 2: 1541 ... round 35: 21 round 36: nothing 21 has no parent, so it stops recursive term: JOIN creature c ON c.id = up.parent creature 2,025 rows creature_key on id one lookup by id, once a round the result: every round added up, 36 rows
Figure 78.1 — the two halves of a recursive query on this chapter's deepest pedigree. The anchor runs once. The recursive term runs over and over against the last round's rows alone, never against the whole result so far, which is what keeps a round the same size whatever the climb has already produced. The empty round is how the query knows a founder when it reaches one.
▣ Build · stage 5 — the climb
WITH RECURSIVE up (id, parent, mate, tick, species, row, generation, step) AS (
      SELECT c.id, c.parent, c.mate, c.tick, c.species, c.row, c.generation, 0
        FROM creature c
       WHERE c.id = $1
    UNION ALL
      SELECT c.id, c.parent, c.mate, c.tick, c.species, c.row, c.generation, up.step + 1
        FROM up
        JOIN creature c ON c.id = up.parent
)
SELECT step, id, parent, mate, tick, species, row, generation FROM up ORDER BY step

The first SELECT is the anchor and it runs once: one creature, by its number, at step nought. The second runs over and over. Each time, up inside it means the rows the previous round produced and nothing else, so a round joins one row to the creature table and produces one row, which becomes the next round's up. When a round produces nothing the recursion stops, and a round produces nothing exactly when the creature it was handed has a null parent, because a join finds no match for null. That is the whole loop condition and no special case wrote it.

UNION ALL and not UNION is deliberate here. UNION would throw away a row it had produced before, which costs a comparison against everything so far on every round and buys nothing: a chain up parents of record cannot visit anybody twice, because every parent was born before its child and the ticks only go one way. The next query on this page is the case where that is not true.

ORDER BY step is the last line for the reason every query in this book has one. A recursive query produces its rounds in order and then hands them to whatever comes next, and what comes next is under no obligation to keep them in that order. The ordering is written down or it is luck.

$ podman exec -w /bench world-go go run ./cmd/climb -mode climb -dir /tmp/climb-78 | tail -31 | head -17
  the deepest line of record in this history, from creature 1555

   step   creature     parent       mate      tick  species   row
      0       1555       1548       1266     55212        1     0
      1       1548       1541       1451     54827        1     0
      2       1541       1451       1378     54322        1     0
      3       1451       1249        557     49264        1     0
    ... 29 rows between, one a generation
     33         93         44       null      5724        1     0
     34         44         21         11       902        1     0
     35         21       null       null       901        1     0

    36 rows, and the last of them is the founder: parent null
    steps up to it                                    35
    the walk over the file says                       35
    the generation column on the first row says       35
    three ways of counting one climb, and one answer

Thirty-six rows, with the run printing the two ends of the climb and counting what it left out. The bottom is the interesting part: creature 44 was born on tick 902, one tick after the founders stood up, to parents 21 and 11, and creature 93 has a null mate, which is a birth paid for by one parent with no second animal in reach over the threshold. Every row of the climb sits on species 1 and row 0. This line never crossed a species boundary in thirty-five generations, and it never changed rows, because two animals of different rows are refused each other before any distance between them is computed.

The three counts underneath are the check that makes the rest of this page worth reading. Thirty-five is how many steps the query climbed; thirty-five is what a walk up the file in memory says; thirty-five is what the generation column has held since the load computed it in one pass. The column is the one that could have drifted, because it is derived data written down once, and it agrees.

Ancestry runs the other way too, and the other way is what the index on parent is for. Creature 421 is the animal the file said two living creatures meet at; here are its children, which is one lookup and not a climb.

$ podman exec world-db psql -U world -d world -c 'SELECT id, tick, generation, mate FROM creature WHERE parent = 421 ORDER BY id;'
 id  | tick  | generation | mate
-----+-------+------------+------
 426 | 25887 |         17 |  407
 477 | 26636 |         17 |  444
(2 rows)

Two children, seven hundred and forty-nine ticks apart, by two different mates, and both of them one generation below their parent. The older of the two is the oldest animal in this valley that is still walking. Asking that of the file means reading every record and testing two fields on each; asking it of the table means naming a column an index is kept on.

▣ Build · stage 6 — the most recent common ancestor, in one statement

Climbing up parents of record is a chain. Real ancestry is not: every creature has two parents, so the set of animals above one creature widens as you climb and then narrows again, because a valley founded with twenty-nine animals runs out of ancestors and the same names start appearing on both sides. Two creatures share an ancestor when that ancestor is in both sets, and the useful one is the most recent, because in a history this old nearly everybody shares a founder and being told so says nothing.

WITH RECURSIVE
  mine (id, step) AS (
        SELECT c.id, 0 FROM creature c WHERE c.id = $1
      UNION
        SELECT u.up, m.step + 1
          FROM mine m
          JOIN creature c ON c.id = m.id
          CROSS JOIN LATERAL (VALUES (c.parent), (c.mate)) AS u (up)
         WHERE u.up IS NOT NULL
  ),
  theirs (id, step) AS (
        SELECT c.id, 0 FROM creature c WHERE c.id = $2
      UNION
        SELECT u.up, t.step + 1
          FROM theirs t
          JOIN creature c ON c.id = t.id
          CROSS JOIN LATERAL (VALUES (c.parent), (c.mate)) AS u (up)
         WHERE u.up IS NOT NULL
  ),
  ours  AS (SELECT id, min(step) AS step FROM mine   GROUP BY id),
  yours AS (SELECT id, min(step) AS step FROM theirs GROUP BY id)
SELECT c.id, c.tick, ours.step, yours.step, count(*) OVER ()
  FROM ours
  JOIN yours ON yours.id = ours.id
  JOIN creature c ON c.id = ours.id
 ORDER BY c.tick DESC, c.id DESC
 LIMIT 1

Four pieces. Two recursions, one a creature, each walking both parents instead of one: the VALUES pair turns a row into two candidate ancestors and the WHERE drops the nulls, so a founder contributes nothing and the branch ends. Two little queries that take the shortest number of steps to each ancestor, since a mesh reaches the same animal by several paths and the useful distance is the short one. Then a join, an ordering and a limit.

UNION without ALL is what makes the recursion safe, and it is the opposite decision from the climb for the opposite reason. Adding every path up would count the same ancestor once for every way of reaching it, and in a pedigree thirty-five deep the ways of reaching an animal are counted in thousands. Dropping a pair of numbers already produced bounds the work by how many creatures there are and how deep they stand, which is a number, instead of by how many routes exist, which is not.

count(*) OVER () is the last column and it is free. A window function with an empty OVER runs across the whole result before the LIMIT takes one row from it, so the count of shared ancestors comes back beside the most recent one instead of being a second question with a second walk behind it.

$ podman exec -w /bench world-go go run ./cmd/climb -mode meet -dir /tmp/climb-78
climb: where two living lines come back together

  creatures with a birth row and no death row        237
    the oldest of them       426, born on tick   25887, generation   17
    the youngest            2025, born on tick  688536, generation   21

  one statement, two recursions and a join
    the most recent ancestor both of them have       421
    born on tick                                   25761
    steps up from the older                            1
    steps up from the younger                          5
    ancestors the two of them share                   37

  the same five numbers, walked out of the file in memory
           421       25761           1           5          37
    the file and the table are answering with the same numbers

Two hundred and thirty-seven creatures have a birth row and no death row, which is a left join and a null test and is the whole of what putting deaths in a table of their own bought. The oldest and the youngest of them meet at creature 421, one step above the first and five above the second, with thirty-seven ancestors in common. Five numbers, and the same five came out of a walk over the file in memory, which is the only reason to believe either.

That comparison is the habit to keep. A new way of computing an old answer is worth exactly as much as the old way it agrees with, and the moment the file stops being consulted is the moment a wrong query becomes the truth.

⌥ Tool — psql, and a query that spans lines

A recursive query is too long for -c and quoting it in a shell is miserable. Two ways out, both psql's own: -f file.sql runs a file, and psql -U world -d world <<'SQL' reads a heredoc off standard input, which is how a query gets pasted into a terminal without a single backslash. Inside a session, \e opens the last query in an editor and \i file.sql reads one in. \timing is there and this book does not print what it says. The reference for WITH RECURSIVE, the SEARCH and CYCLE clauses it also has, and the window functions used above is at postgresql.org/docs/16/queries-with.html.

One more question, to show what a table buys that has nothing to do with ancestry. How long did the animals of this history live? The file holds that in two records that are nowhere near each other; the tables hold it in a join.

$ podman exec world-db psql -U world -d world -c 'SELECT c.id, c.tick AS born, d.tick AS died, d.tick - c.tick AS lived FROM creature c JOIN death d ON d.id = c.id ORDER BY lived DESC, c.id LIMIT 5;'
  id  | born  |  died  | lived
------+-------+--------+--------
 1297 | 43497 | 701382 | 657885
 1258 | 41394 | 549391 | 507997
 1282 | 43341 | 549484 | 506143
 1255 | 41218 | 501265 | 460047
  591 | 27509 | 486363 | 458854
(5 rows)

The longest life this valley has recorded is creature 1297, born on tick 43,497 and struck off on tick 701,382: six hundred and fifty-seven thousand ticks, which at thirty-six hundred ticks a year is a hundred and eighty-two years. All five of the top five were born inside the first thirteen years and died after the hundred and thirtieth. Nothing in this world sets a lifespan; an animal dies when its store closes a tick at nothing, and in a valley where the ground is full and no animal can afford to breed there is food and there is nothing to spend it on. The archive has said that since the day it was written. Nothing had asked.

Rows touched by the parent index

Every claim so far has been about what can be asked. This section is about what asking costs, and the awkward part is measuring it. A timing is this machine on this afternoon. A planner's cost estimate is a guess the server made before it ran anything, in units of its own. Neither reproduces on a second machine and neither is printed anywhere in this book.

What does reproduce is a count of work. EXPLAIN ANALYZE runs a query and reports, for every node of its plan, how many rows that node handed up and how many times it ran. Ask for it with COSTS OFF, TIMING OFF and SUMMARY OFF and every estimate and every clock disappears from the answer, leaving counts of things that actually happened. Multiply the rows by the runs and you have a number that is the same on any machine holding the same rows: how much of the table the server had to look at.

▣ Build · stage 7 — the same climb, three ways
// internal/store/kin.go
// How says which of the two ways of finding a row the server is allowed
// to use while it answers, which is how this book asks what an index is
// worth: the same question, the same rows and the same answer, reached
// once each way and counted both times.
//
// Chosen leaves the decision where it belongs. Descend forbids reading
// a table straight through, so anything with an index on it is reached
// through the index. Sweep forbids the three ways of reaching a row
// through an index, so the only road left is reading everything.
type How int

const (
	Chosen How = iota
	Descend
	Sweep
)

var forced = map[How][]string{
	Chosen:  nil,
	Descend: {"SET LOCAL enable_seqscan = off"},
	Sweep: {"SET LOCAL enable_indexscan = off",
		"SET LOCAL enable_indexonlyscan = off",
		"SET LOCAL enable_bitmapscan = off"},
}

// Steady is what every plan in this book is asked for: no costs, no
// timings, no summary, and no parallel workers, so that what comes back
// is the same on two machines and on two afternoons.
const steady = "EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, FORMAT JSON) "

The three settings are SET LOCAL, so they last until the transaction ends and change nothing about the database. They do not make the server wrong; they take a road away and make it answer the same question by another one. That is the only way to ask what a road is worth without a second database to compare against.

$ podman exec -w /bench world-go go run ./cmd/climb -mode cost -dir /tmp/climb-78 | tail -30 | head -10
  climbing creature 1555, 35 steps up to a founder, over 2025 rows

                                                  rows touched
    walking the file, records parsed first                3813
    the query, as the server chose                       70876
    the query, made to use the index                        37
    the query, made to read everything                   70876

    the file over the query on the index                 103.1
    the sweep over the query on the index               1915.6

Thirty-seven against seventy thousand eight hundred and seventy-six. The index turns a climb that reads the whole table once a generation into one that reads about a row a generation, and against the file it is a hundred and three to one on records touched. Those two numbers are the answer this chapter was asked for.

The second line is the one that changes the argument. Left to itself, the server read everything. The plan it chose and the plan it is forced into when the index is taken away are the same plan, to the row, and the plan itself says so if you ask for it with no numbers in it at all.

$ podman exec -w /bench world-go go run ./cmd/climb -mode climb -dir /tmp/climb-78 | tail -13
  the plan the server made, with the costs switched off
    Sort
      Sort Key: up.step
      CTE up
        ->  Recursive Union
              ->  Index Scan using creature_key on creature c
                    Index Cond: (id = '1555'::bigint)
              ->  Hash Join
                    Hash Cond: (c_1.id = up_1.parent)
                    ->  Seq Scan on creature c_1
                    ->  Hash
                          ->  WorkTable Scan on up up_1
      ->  CTE Scan on up

That is the whole of a plan with COSTS OFF on it: node types, what each one reads, and the conditions it reads them under. There is not a number in it to drift. The anchor is an Index Scan using creature_key because the anchor asks for one row by its number and there is nothing to weigh up. Underneath it, the recursive term is a Hash Join whose outer side is a Seq Scan on creature: every round, the server builds a hash of the one row the last round produced and then reads the entire table past it looking for a match. The WorkTable Scan is that one row. This is the plan the counts below are counting.

$ podman exec -w /bench world-go go run ./cmd/climb -mode cost -dir /tmp/climb-78 | tail -19 | head -10
  what each plan actually read, node by node
    as the server chose                        rows  loops  rows touched
      Index Scan using creature_key               1      1             1
      Seq Scan on creature                     2025     35         70875
    made to use the index                      rows  loops  rows touched
      Index Scan using creature_key               1      1             1
      Index Scan using creature_key               1     36            36
    made to read everything                    rows  loops  rows touched
      Seq Scan on creature                        1      1             1
      Seq Scan on creature                     2025     35         70875

Two nodes a plan, because a recursive query has two halves and each half reads the table once. In the plan the server picked, the anchor uses the index and the recursive term does not: thirty-five sweeps of two thousand and twenty-five rows to find thirty-five parents. Made to descend, both halves use creature_key and the recursive term runs thirty-six times, once a round including the empty round at the end. A plan reports rows as an average over the runs of a node, so the last descent, which found no row, is rounded up into the thirty-six; the honest reading of that line is thirty-six lookups that between them fetched thirty-five rows.

Now the part that makes this a measurement and not a demonstration. Two of the three numbers cannot move. Sweeping a table of two thousand and twenty-five rows thirty-five times is seventy thousand eight hundred and seventy-five rows on any machine that ever runs it, and descending an index thirty-six times is thirty-six lookups. The first line is different in kind: it is a decision the server made, and it made it because reading this table straight through is not expensive. The heap is a hundred and eighty-eight thousand bytes, which is twenty-three pages, and a server reads pages that lie next to each other far more cheaply than pages it has to jump between. Twenty-three pages read in order against thirty-five jumps into an index and thirty-five more into the heap is not the landslide the row counts make it look like.

So the honest statement of the result has two halves. The index reduces the work of this question by a factor of nineteen hundred, measured. And at this table's size the server does not take it, and is right not to. What makes that reproducible is that everything in the block except the first line is arithmetic over a fixed number of rows: run it on a bigger machine, a slower disk, a different afternoon, and thirty-six descents are still thirty-six descents.

$ podman exec -w /bench world-go go run ./cmd/climb -mode cost -dir /tmp/climb-78 | tail -8
  and the meeting of creatures 426 and 2025, the same three ways
    walking the file, records parsed first                3813
    the statement, as the server chose                  131627
    the statement, made to use the index                  3412
    the statement, made to read everything              131627

  the file is 18527773 bytes and the whole of it is parsed before the first
  answer; the table holds 2025 rows and the climb reads 37 of them.

The meeting statement is a harder question and the numbers say so. Two recursions over a widening mesh touch three thousand four hundred and twelve rows on the index, against a hundred and thirty-one thousand reading everything, and against three thousand eight hundred and thirteen records for the file. That is the one place on this page where the file and the index are within a factor of two of each other, and the reason is plain: the mesh above two creatures in this valley is a large fraction of the valley, so a query that visits all of it is doing most of the work a full pass does. The file wins nothing by it, because the file has to do the same work again for the next pair and the table does not.

Two things underneath all of that are checkable without a server anywhere near them, and both of them are the sort of thing that would be embarrassing to have wrong. Rows touched is rows times runs, added up over the nodes that read one table and no others. And the rule this whole database was built under still holds on the day a package in it learned to read a pedigree.

$ go test ./internal/store/ -run 'RowsTouchedIsRowsTimesLoops|NoSimulationPackageImportsTheStore' -v
=== RUN   TestRowsTouchedIsRowsTimesLoops
    kin_test.go:151: 34 rows out of the creature table across 34 index descents, and no other node counted
--- PASS: TestRowsTouchedIsRowsTimesLoops (0.00s)
=== RUN   TestNoSimulationPackageImportsTheStore
    rule_test.go:61: every file in every sealed package read, and not one of them names it
--- PASS: TestNoSimulationPackageImportsTheStore (0.00s)
PASS
ok  	theworld/internal/store	0.00s

The first is arithmetic over a plan somebody wrote down by hand, so a node that reads a different table is not counted and a node with no table under it is not counted either. The second is the rule from the chapter that opened this database, run again here because this chapter is where the temptation lives: it would have been so easy to let internal/gene ask the creature table who a genome's parent was. It does not, nothing else does, and a valley's tick still cannot open a transaction.

∑ Math Interlude — a climb, counted three ways

Numbers first, all four of them off the runs above. The table holds N = 2,025 rows. The archive holds R = 3,813 records, one a birth and one a death. The climb is G = 35 steps from creature 1555 to creature 21. And an ordered index on one column is a tree you walk down by halving, so finding one row in N of them takes log2 N comparisons rounded up, which at 2,025 is 11.

The sweep first, because it is the simplest. The recursive term runs once a step and each run reads the whole table, so the rows it touches are G × N = 35 × 2,025 = 70,875, and the anchor's own read makes 70,876. The block above says 70,876. The descent is the other extreme: one lookup a round, G + 1 rounds because the last one has to run to find nothing, and one for the anchor, which is 37 as the plan accounts for it. The two divide: 70,876 ÷ 37 = 1,915.6, which is the ratio the run printed.

Now the file, which is a different kind of number and has to be set beside those two carefully. The file costs R once and everything after it is free, so R ÷ 37 = 103.1 is the ratio for the first question and R ÷ 370 for the tenth. What decides between them is the growing, and not the arithmetic. R goes up with every birth and every death this world ever has; what a descent costs does not go up at all. One lookup is log2 N comparisons, 11 at 2,025 rows and 17 at a hundred thousand, and a climb of 35 steps is 37 lookups whatever N has become. Put a hundred thousand births in the table and the file is parsing two hundred thousand records to answer one question while the query is still doing 37 lookups, which is 200,000 ÷ 37, better than five thousand to one. A file's cost is the history. An index's cost is the logarithm of it.

One last sum, and it is the one that explains why the server ignored all of this. The heap is 188,416 bytes and a page here is 8,192 bytes, so the table is 188,416 ÷ 8,192 = 23 pages. A sweep reads 23 pages in sequence. A descent reads a handful of pages it has to jump to, and a server prices a jump several times higher than a read in sequence because that is what a disk does. Thirty-five sweeps of 23 sequential pages against thirty-five journeys into an index and back is close enough that the planner picks the sweep, and it will keep picking it until the table is large enough that 23 becomes a number to avoid. Meanwhile the index sits there, doing nothing, waiting for the table to grow into it.

Nrows in the creature table: 2,025 here, one a birth
Rrecords in the archive: 3,813 here, one a birth and one a death
Gsteps in the climb: 35, from creature 1555 to the founder above it
log2 Nhow many halvings it takes to get from N to one: 11 at 2,025, 17 at 100,000
a pagethe block a server reads and writes at once: 8,192 bytes, and 23 of them hold this table's seven integer columns
a × ba multiplied by b
a ÷ ba divided by b

Why load order stops mattering

There is one idea holding this page together and it is about when order is data. The archive has an order and the order carries meaning: a record above another record happened before it, and that is the only reason a load is possible at all, because a self-referencing foreign key wants the thing before the thing that names it. The table has no order. Rows sit wherever the server put them and a query that wants them in an order says so. Loading a history is the moment those two facts meet, and every mistake available at that moment comes from thinking the destination's freedom is available at the door.

The second idea is about statements. A single statement is a unit the database checks all at once, and inside one, a great many things that look like ordering constraints are not constraints at all. Split the statement and they become constraints, immediately and without warning, because the checks now happen at five moments instead of one. The general form travels well past databases: a batch that succeeds is not evidence that the items in it were independent, and the day the batch gets split is the day you find out.

The third is what an index actually is: a second copy of one column, kept sorted, so that finding a value is a walk down a tree instead of a walk along a table. Speed is what that buys and not what it is, and what it buys is a logarithm where there was a count, and the difference between those two grows without limit as the history does. It costs a fixed number of bytes a row, which on this table is nothing next to the blob beside it and would be a fifth of a table of two integers. Both of those are arithmetic and neither depends on how the server feels about the table this afternoon.

Which leads to the fourth, and it is the one this chapter measured by accident and then kept. An index is a thing the server uses when using it is cheaper, and cheaper is a judgement about pages, not about rows. On a table of twenty-three pages the judgement comes out against the index, and the seventy thousand rows the sweep reads cost less than the thirty-six jumps the descent would make. Nothing has failed there. An index is a fixed price paid now against a question that gets asked for the next twenty years, and the thing that repays it is that no operator has to do anything on the day it starts winning. The table gets bigger, the sums come out the other way, the plan changes, and no code anywhere is edited.

And the last one is about keeping both copies. The archive was not deleted when the table was loaded, and every answer on this page came out twice: once from a walk over the file in memory and once from a statement. They agreed on all of it. A load is a translation, translations have bugs, and the only way to find a bug in one is to still own the thing it was translated from. The file is append-only and it fits on a disk. No argument for throwing it away survives what being wrong about the load would cost.

✓ Checkpoint — the load, the climb and the count
  • A single COPY of the clustered rows landed every row and a chunked one stopped on its first statement. Say what changed between them, in terms of when a foreign key is checked.
  • The chunked load was grouped by parent and refused by creature_mate. Say what an order has to guarantee to get a chunked load past both self-referencing keys, and name two columns of this table that already guarantee it.
  • generation is computed in one pass over the file rather than by climbing. Say what property of the archive makes that possible, and what the code does when that property does not hold.
  • The climb uses UNION ALL and the meeting uses UNION. Give the reason for each, and say what the meeting statement would cost if it used UNION ALL.
  • Two of the three numbers in the cost table cannot move on another machine and one of them can. Name which is which and say what makes the difference.
  • The creature table's heap is 2.22 percent of what the table occupies. Say where the rest of it is, and what that does to the cost of a query that never mentions the genome.
⚡ Exercises — try first, then reveal
Exercise 1 — cluster it anyway. The clustered load worked in one COPY. Run it, look at what the five indexes came to, and hold them against the load in file order before you decide the tidying was free.

It was not free and it was not a disaster either, which is the useful answer. A b-tree built from keys arriving in order packs its pages full; one built from keys arriving in a jumble splits pages half empty. Grouping by parent made the index on parent smaller and made the two indexes whose keys were already in order, id and tick, bigger.

$ podman kill world-db && podman start world-db
world-db
world-db
$ podman exec -w /bench world-go go run ./cmd/climb -mode load -cluster -dir /tmp/climb-78 | tail -14
    creature                 188416   the heap: the seven integer columns
    creature_genomes        8298496   the genomes, out of line and compressed
    creature_by_mate          57344   an index on mate
    creature_by_parent        57344   an index on parent
    creature_by_species       32768   an index on species
    creature_by_tick          90112   an index on tick
    creature_key              81920   an index on id
                            8806400

    the heap a question is answered out of        188416
    the genomes no question mentions             8298496
    the five indexes kept beside them             319488
    the indexes, as a share of the whole table      3.76%
    the heap,    as a share of the whole table      2.22%

Two hundred and eighty-six thousand bytes of index in file order against three hundred and nineteen thousand clustered: eleven percent more, spent to make one of five indexes eight kilobytes smaller. The heap and the genomes are identical to the byte, because the rows are the same rows. That run leaves the creature table loaded from a clustered file, which is a fine place to stop reading and a bad place to start measuring: reload it in file order before you compare anything.

Exercise 2 — climb the other way. The climb goes up parent. Write the recursive query that goes down it instead, listing everything descended from creature 21, and predict which index it uses before you ask.

Swap the join condition and the anchor. Going up, a round joins the working table's parent to a row's id, which is the primary key. Going down, it joins the working table's id to a row's parent, which is creature_by_parent, and that is exactly what the schema page put that index there for.

WITH RECURSIVE down (id, parent, tick, step) AS (
      SELECT c.id, c.parent, c.tick, 0 FROM creature c WHERE c.id = 21
    UNION ALL
      SELECT c.id, c.parent, c.tick, down.step + 1
        FROM down JOIN creature c ON c.parent = down.id
)
SELECT step, count(*) AS creatures, min(tick) AS first, max(tick) AS last
  FROM down GROUP BY step ORDER BY step;

Two things to notice when you run it. The counts a step get larger and then smaller, which is a lineage spreading and then thinning, and the whole thing is a different size from the climb because going down a pedigree branches and going up the parent of record does not. Then add c.mate = down.id to the join with an OR and watch the numbers jump, because half of every animal's descendants reach it through the parent that did not pay for the birth.

Exercise 3 — make the index win. The server read the whole table thirty-five times rather than descend an index. Find out how much bigger the table would have to be before it changed its mind, without changing the table.

The planner's judgement is about pages and about how much a jump costs against a read in sequence, and both of those are settings you can change for the length of one transaction. SET LOCAL random_page_cost is the price of a page the server has to jump to; lower it towards the price of a sequential page, which is what a solid-state disk actually looks like, and re-run the plan.

Do it in psql with BEGIN; SET LOCAL random_page_cost = 1.1; EXPLAIN (COSTS OFF) ...; ROLLBACK; and find the value where Seq Scan turns into Index Scan. Then work the other way and ask what table size that value corresponds to: the sweep costs pages proportional to the table and the descent costs a roughly fixed number of jumps, so the crossover moves with the page count and not with the row count. A history ten times this size is two hundred and thirty pages, and the arithmetic in the interlude tells you what happens next without your having to run two centuries again to see it.

Two of the seven tables hold something now. The creature table has two thousand and twenty-five births in it and the death table seventeen hundred and eighty-eight endings, and between them they answer every question anybody has ever put to this history. The world table is empty. The chronicle is empty. Every name in this chapter has been a number, and a valley that has run for two centuries under a seed has never been told what it is called, what its ground is called, or who wrote it down.