#150 · Release index versions forks no longer read, and compact forks (#145, phase 3)
- timeline-app
- pull request
Phase 3 of #145: what forks no longer read is released, and forks that have diverged a lot are compacted. ## Releasing versions and pins A version is retired only while a pin can see it, and it always covers the highest pin at that moment. So versions are left unread only when pins go away: a fork is deleted, saved as a whole document, or compacted. At each of those moments, `oc_release_index(bases, clocks)` runs for the levels the fork read. For each level it: - recomputes the pins from the lineages that remain (the level's row is locked, as forking it does, so no pin is added meanwhile); - keeps the highest as `index_pinned`, so the level's saves stop retiring versions nobody will read; - deletes the retired versions no pin falls in, looking only at those the released clock saw; - for a deleted level, also deletes the live rows no pin sees, and everything once nothing reads it. The deletion trigger, rewrites of a fork as a whole document, and compaction all call it. Migrating runs it once for every pinned timeline (`releaseIndexes` in `migrate.mjs`), which clears what phase 2 kept after a fork was deleted. ## Compaction After a fork saves, it is checked in the background. If its own moment rows reach half its moments, it is compacted (`compact`, `compactStep`): - What it still reads through to is copied into rows of its own, one page per transaction. They are the same rows, so the fork reads the same meanwhile, and a save waits for one page at most. Saves between pages are fine. - Once nothing is left to copy, its hidden rows and its lineage go and what it read is released. ## A fix to phase 2's scans Scans on a fork exclude the keys of nearer levels by hashing them. PostgreSQL only hashes when it expects the keys to fit in `work_mem`. Beyond that, for a fork with a few hundred thousand rows of its own, it would compare every row with every key, which would effectively never finish. A fork's transactions now raise `hash_mem_multiplier` (64× `work_mem`, about 256 MB by default). I checked that this switches the plan back to hashing. Compaction and retention, which run outside such transactions, check nearer levels row by row instead, which is safe at any size. The row-by-row form isn't used everywhere because it made tags 3.3–3.6× slower on a fork, against about 1.3× hashed. ## Measured at 200,000 moments Setup: a source and two forks; each side moved 5,000 moments. | Step | Result | |---|---| | Compacting the fork | 41 s in 11 steps | | Save of 10 moments to the fork while compacting | 0.27 s | | Save of 10 moments to the compacted fork | 0.21 s | | Deleting the last fork | 0.25 s, releasing the source's 39,506 retired versions | ## Tests - **New:** `test/index-cleanup-postgres.mjs`, added to CI. It covers: - forks at different clocks and a fork of a fork, with random saves; - deleting a fork, a fork saved whole, and paged compaction with saves to the fork and its source between pages; - automatic compaction once half the moments are rewritten (not before); - the migration repair after a deletion that skipped the release; - deleting a mid-level fork that a fork of it still reads. After each step, every timeline must read exactly as one rebuilt from its document, and each level must keep exactly the versions its remaining forks read (highest pin, retired versions, live rows of deleted levels). When nothing reads a level any more, it must keep nothing. - **Fault checks.** The test catches each of three deliberate faults: keeping unread versions, keeping a compacted fork's hidden rows, and not lowering the highest pin. - **Local runs:** all 257 unit tests and all 32 database suites pass. - **Upgrade:** a database on `main`'s schema upgrades, and `migrate.mjs` runs twice. ## Left for phase 4 Remove the background copy where sharing now applies; it stays for lineages past four levels.