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.
$ 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.
$ 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.
// 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.
// 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.
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.
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.
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.
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.
// 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.
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.
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.
- A single
COPYof 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
parentand refused bycreature_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. generationis 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 ALLand the meeting usesUNION. Give the reason for each, and say what the meeting statement would cost if it usedUNION 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.
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.