Give the buffer pool a size, set by --cache-pages, and make it count: every request is a hit or a miss, every miss is a read, every page that leaves a full pool is an eviction, and every dirty page that leaves is a write. Print the counters with pager stats, and write the dirty pages on demand with pager flush.
So far the pager has kept every page it ever read. That was fine while a database was a few pages, and it is what the contract asked: no page left memory before the session ended. Real databases are bigger than the memory they get. A table of a few gigabytes runs in a few hundred megabytes of cache, so pages have to leave memory to make room for others, and come back from the file when they are needed again.
That changes what "fast" means. Reading a page from memory takes nanoseconds; reading it from a disk takes thousands to millions of times longer. A query that touches 40 pages is not slow because of the 40, it is slow when those 40 are not in memory. So databases measure the cost of a query in pages: how many it asked for, how many were already there, how many had to be read, how many had to be written. PostgreSQL prints exactly these numbers when you ask for them, and the rest of this part is about changing them.
You cannot see any of that yet. Every stage after this one changes which pages stay and which go: pinning, the clock, the free list, merging, overflow pages, VACUUM. None of them changes a single row that SQL returns. Without counters every one of them is invisible, both to you while you build it and to the tests that check it. This stage builds the instrument first, the same way rows_examined came before the index scan it measured.
The pages in memory are the buffer pool, and it holds a fixed number of them, its frames. --cache-pages <n> sets that number for the session, from 1 to 1000. Without the flag the pool has 1000 frames, as many as the file can ever have pages, so nothing ever has to leave and everything you built before behaves exactly as it did. The tests of earlier stages never pass the flag.
With a smaller pool, a page that comes in while every frame is taken pushes another page out. That is an eviction. Its frame takes the new page. What happens to the page that left depends on one fact: whether it changed since the file last had it.
A clean page is identical to the file's copy, so it can simply be dropped. A dirty page holds the only copy of a change: it is written to the file before its frame is reused. Lose that write and the change is gone, which is why the eviction of a dirty page is counted as a write. At the end of the session every page still dirty is written, as before, so the file is the same whatever the size of the pool.
Which page leaves is up to you in this stage. Any page will do. The clock-eviction stage fixes the choice and checks it exactly, so for now pick the simplest rule you can write correctly.
In this stage SQL only runs with the default pool of 1000 frames, which the tables in the tests never fill, so no statement sees a page leave. With a small pool, a statement could lose a page it is in the middle of changing. The pin-unpin stage closes that hole, and from then on SQL is checked with 16 frames as well.
PostgreSQL's pool is
shared_buffers, 128 MB by default: 16384 pages of 8 KB. SQLite's default cache is about 2 MB per connection. The 1000 frames here, and the 16 the SQL tests use frompin-unpinon, are test-mode numbers chosen so that a table of a few hundred rows is already bigger than the pool.
A command requests a page each time it needs its bytes. Five counters describe what happened, counted from the start of the session:
| Counter | Counts |
|---|---|
hits | requests for a page already in the pool |
misses | requests for a page that was not |
reads | pages read from the file |
writes | pages written to the file |
evictions | pages that left the pool to make room |
Every miss is one read and every read is a miss, so reads always equals misses. A real engine reads ahead, fetching pages before anyone asks because a scan is probably going to need them, and its two numbers differ. This one does not.
A worked trace, on a file of five pages (the header, then pages 1 to 4) and --cache-pages 2:
| Command | What happens | In the pool after it | reads | hits | evictions |
|---|---|---|---|---|---|
| open | page 0 is read | 0 | 1 | 0 | 0 |
pager get 1 | miss, an empty frame | 0, 1 | 2 | 0 | 0 |
pager get 1 | hit | 0, 1 | 2 | 1 | 0 |
pager get 2 | miss, the pool is full: one page leaves | two of 0, 1, 2 | 3 | 1 | 1 |
pager get 3 | miss, one more page leaves | two of 0, 1, 2, 3 | 4 | 1 | 2 |
Nothing was written: no page was dirty. Had pager write 1 … come first, the eviction of page 1 would have cost one write as well.
Which counts are exact. pager get, pager read, pager write and opening each request exactly the pages they name, once. After those the counters are exact numbers, and the tests check them exactly, as long as the choice of which page leaves cannot change the outcome. SQL is different: a statement may ask for the same page several times while it works, and it is free to. After SQL, tests only check what any correct engine must do, such as a full scan reading at least every leaf of the table.
The number of frames is fixed for the session, before the file is opened.
Every command that needs a page's bytes goes through one place, and that place keeps the counts.
Which page leaves is your choice in this stage. That it is written first, when it changed, is not.
Writing is what makes a change last, and it is counted wherever it happens.
The full definition is on the output contract page, sections 22 and 23.
| Input | Output |
|---|---|
pager stats | [PAGER] Stats: reads=<r> writes=<w> hits=<h> misses=<m> evictions=<e> |
pager flush | [PAGER] Flush: <k> pages written |
--cache-pages <n>, n from 1 to 1000 | the pool holds n pages; the flag may come before or after --db and -c |
--cache-pages with 0, 1001, abc, -1… | [ERROR:00106] <message>, the only line, exit status 1, before the file is opened |
--cache-pages with nothing after it | [ERROR:00102] <message>, the same way |
pager status is unchanged, and its cached is never more than the frames. The pager commands work with any pool, down to one frame. SQL is only run with the default pool in this stage; from pin-unpin it must work with 16 frames or more.
Build a small file first. The first allocation makes the header, page 0:
$droid --db notes.dbWelcome to droid!>pager alloc[PAGER] Alloc: page 1 (4096 bytes, zeroed)>pager alloc[PAGER] Alloc: page 2 (4096 bytes, zeroed)>pager write 1 one[PAGER] Write page 1: 3 bytes>pager write 2 two[PAGER] Write page 2: 3 bytes>pager flush[PAGER] Flush: 3 pages written>pager flush[PAGER] Flush: 0 pages written>.exitexiting.. good bye!
Nothing was read: every page was made in this session, and the writes found pages the allocations had just put in the pool. (How many hits pager alloc itself adds is up to your engine, so the tests never read the counters right after it.) The first flush wrote the three dirty pages, the header included, and the second had nothing left to write.
Now open it again with a pool of one frame:
$droid --db notes.db --cache-pages 1Welcome to droid!>pager stats[PAGER] Stats: reads=1 writes=0 hits=0 misses=1 evictions=0>pager write 1 uno[PAGER] Write page 1: 3 bytes>pager read 2[PAGER] Read page 2: 'two'>pager read 1[PAGER] Read page 1: 'uno'>pager stats[PAGER] Stats: reads=4 writes=1 hits=0 misses=4 evictions=3>.exitexiting.. good bye!
Things to notice. Opening read the header, before any command. Writing page 1 evicted the header, which was clean, so it cost no write. Reading page 2 evicted page 1, which was dirty: one write. Reading page 1 again was a miss, and found uno, because the write happened before its frame was reused.
pager get misses every page SQL requests, and the counters after a statement come out too low.evicted-page-keeps-its-bytes uses one frame.writes=0.pin-unpin stage is about exactly this.--cache-pages must not create a file.cached above the frame count. pager status counts the pages in the pool; with --cache-pages 2 it is never more than 2.The page above is the same for every language. These are the only language-specific hints.
strtoul accepts a sign and spaces: check the flag's characters yourself.std::vector of frames and a std::unordered_map<uint32_t, size_t> from page to frame. Beware references into the vector: they do not survive the frame being reused.&mut [u8] from the pager while the pager is borrowed makes eviction during a request impossible to express, which is the borrow checker pointing at the real problem. Hand out page numbers or indices, or copy, until pin-unpin gives you a better answer. str::parse::<u32> accepts a leading +: check digits first.std.AutoHashMap(u32, usize) maps page to frame. std.fmt.parseInt accepts a sign too.