Repository navigation
feat(engine): log key changes at commit and write pages at checkpoints - #88
Merged
Merged
Conversation
Add a Changes record to the write-ahead log. It holds the net change to every tree a commit touched, found by diffing the roots before and after the commit: removed keys, and the new value of every added or changed key. Values that hold nested trees (edge members and order, promoted posting lists, index roots) log those trees the same way, against the nested tree of the value they replace. A tree that changed in more than 4,096 places, such as one a bulk load built, is logged as a reference to the pages the commit wrote instead. Commits still write their pages and Root records, and recovery still uses those. Branch creation and closing log no Changes; their BranchMeta record already says what the roots become.
… trees StorageEngine.verifyChangeLog reads the log, applies every commit's Changes record to the roots of the generation before it and compares the result, entry by entry and through nested trees, with the roots the commit published. Random workloads in the engine and the database layer run it over set and ordered edges, relabels, deletes, bulk and aborted loads, branches, request ids, property writes with validity, property and JSON index backfills, documents and embeddings.
A commit used to copy the path of every tree it changed into new pages and write them before it returned. One property update wrote about 15 KiB, and every write left dead pages for compaction to move. A commit now logs only its Changes record and publishes roots that point at the new nodes in memory. Pages are written by checkpoints: - A checkpoint takes the published generation, writes the nodes no earlier checkpoint wrote, outside the commit lock, and syncs them. It then counts references from the previous checkpointed generation to this one, publishes the catalog, and swaps the written pages into the head. Only that swap holds the commit lock. The log is kept from the end of that generation's commit, so later commits replay. - Written nodes are stamped with their page and the checkpoint attempt. The next checkpoint reuses those pages instead of writing them again, and Ref.same treats a node as the page it became once its checkpoint published, so a transaction that began before the swap sees no conflict. Two small leaves that would be stored inline compare by content. - Recovery replays Changes records onto the checkpointed roots, and the checkpoint taken at open writes them. History older than the last checkpoint is no longer kept across a restart. - Reference counts change only at checkpoints. Pages a commit writes, spills and trees too large to log key by key, wait as provisional pages until a checkpoint counts them or marks them dead. Pages found without a count at open wait for the first checkpoint the same way. - Memory held by unwritten nodes is limited by writeBufferBytes, 64 MiB by default. The maintenance pass checkpoints at half of it, and a commit that finds it full wakes that pass and waits. - A commit batch syncs the segment files only if one of its commits wrote pages. The page budget counts the nodes a transaction adds. - The maintenance interval is an engine option, so tests can keep the background pass out of their way. Storage format goes to 7.
When a tree gained or lost more than 4,096 entries in one commit, the change log still diffed 4,097 entries, and encoded their values, before falling back to writing the tree's pages. The entry counts are on the references already, so check them first and go straight to the pages.
Checkpoints write far less than commits did, so a 256 MiB segment can take a long time to fill. Compaction only takes sealed segments, so the dead pages in the one still being written stayed on disk. In the scale 1 benchmark, disk after a full compaction was 234 MB before this change and 95 MB with it. When the active segment's live bytes are below compaction_live_ratio, seal it so the pass can move its pages out. COMPACT does this at any size. The background pass waits until the segment holds a quarter of its capacity, so it doesn't leave a trail of small files.
Recovery seeded the history with the catalog image's history and then added the generations it replayed, but never the checkpointed generation itself. After a restart with anything to replay, the oldest generation time travel could reach was the first replayed one, one past the generation the checkpoint had written.
Recovery replays each commit's changed keys since format 7, and the laptop runs put it at 171 ms against 90 ms on format 6 and 123 ms for HyperGraphDB. The published tables don't include recover, so the losses list was the only place to say so.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #85.
Why
Reads were ahead of HyperGraphDB after #83 to #85. Small writes were the one place HStore still lost: in the #85 runs, a commit that changed one value wrote 14.9 KiB and took 214 µs at p50, against 10.1 KiB and 121 µs for HyperGraphDB. Every commit copied the path of each tree it touched into new pages and wrote them before returning, and the dead pages it left behind were what compaction spent its time on.
What changes
Commits log keys, checkpoints write pages.
Changesrecord to the log, holding the keys it changed in every tree. Nested trees, such as an edge's members inside its catalog record, are logged the same way. The roots it publishes point at the new nodes in memory.Changesrecords onto the last checkpoint.Written nodes are stamped.
Ref.sametreats the node as that page once the checkpoint has published, so a transaction that started before the swap sees no conflict. Two small leaves that would be stored inline compare by content.Reference counts change only at checkpoints.
Memory is capped.
write_buffer_mb(default 64) limits the heap held by unwritten nodes, counted from the head.Smaller changes.
Storage format goes to 7.
What gets worse
history_limit. Keeping branch fork generations across restarts is a possible follow-up.recoverwent from 90 ms on perf: warm reads up fairly and visit incident edges without records #85 to 171 ms, so HStore now trails HyperGraphDB there (123 ms), where it led before.docs/benchmarks.mdlists it with the other losses.Testing
StorageEngine.verifyChangeLogreplays every commit still in memory from the log and compares it with the generation the commit published, entry by entry and through nested trees.ChangeLogTestruns it over random engine work: set and ordered edges, relabels, deletes, bulk and aborted loads, branches and request ids.ChangeLogReplayTestruns it over random database work: property writes with validity, property and JSON index backfills, documents, embeddings, deletes, loads and branches.DeferredWritesTest:CHECKPOINT_PAGES,CHECKPOINT_CATALOGandCHECKPOINT_JOURNAL, while another thread keeps committing, loses no acknowledged commit and leaves the counts exact.Changeson recovery, provisional pages, the swap into the head, and page reuse. Each one fails tests; page reuse needed the bulk-load test added for it.examples/clinical-claims.hql,hstore checkverifies the trees and the counts.COMPACT;andcheckpass afterwards.Numbers
HStore against HyperGraphDB, scale 1, async, 64 MiB caches, the median of 3 runs each. These are from a laptop under load, so only the ratios mean much; one HyperGraphDB run took 16 minutes instead of 90 s, so the machine was disturbed during it. The Azure rerun is still to do.
latency.commitlatency.commitp50 / p99 / p99.9write.updatemixed.writep99ingest.nodesingest.edgeschurn.deletediskafter loaddisk.churneddisk.compactedrecoverBytes written per operation, HStore against HyperGraphDB:
latency.commit649 B against 9.6 KiB,write.update221 B against 9.9 KiB,mixed.write350 B against 26.4 KiB. Every read workload is still ahead of HyperGraphDB. Some read medians are lower than in the #85 runs, for exampleread.members3.46M to 2.98M per second andread.incidence.parallel27.3M to 13.3M, though one run of the latter reached 26.0M. I haven't run the two branches back to back, so I can't tell yet whether that is the machine or this change.The same comparison against this branch's base (#85), HStore only. The #85 column is the median of its two complete benchmark runs:
latency.commitlatency.commitp99write.updatediskafter loaddisk.churneddisk.compactedreopenrecoverdisk.compactedis higher than before. My guess is that dead pages are now released one checkpoint later, after the retained history has moved past it, so some are still in the files when the measurement is taken. I haven't confirmed that yet.