Skip to content

Write a segment over instead of writing it again - #412

Open
tamnd wants to merge 1 commit into
mainfrom
write-over-a-segment
Open

Write a segment over instead of writing it again#412
tamnd wants to merge 1 commit into
mainfrom
write-over-a-segment

Conversation

@tamnd

@tamnd tamnd commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Closes the last four boxes on #391.

A segment used to be an index followed by a body, with every chunk immediately after the one before it, so a chunk that grew by a byte moved every chunk after it and the only way to change one value was to write the column again. That is why a one cell write cost the whole column and why an insert cost 143 ms whatever the table held.

The body now comes first and the trailer last, and a chunk is wherever its entry says it is: start, len, and one u64 of whatever the layout needs, the fence for MiniBlock and the zipped size for FullZip. A chunk written again goes to the tail and leaves a hole, live_bytes counts what the entries still reach, and a segment more than half garbage is laid out again on the next write, so the waste is bounded at 2x and the copy is paid once per doubling rather than once per write.

Blocks the unchanged body prefix still covers are kept by pointer, so an append writes about two blocks whatever the column holds and frees none of the ones it had. The decoded pool is keyed on the first block and that key survives a rewrite, so the file forgets the segment's pooled state when it reuses it.

The label bitset gets the same treatment. A change reads the chunks holding the rows it names, one read per chunk rather than one per row, and writes those chunks and the ones past the old end. An appended row takes its table's own label and nothing else unless a change says otherwise.

Checksums move from one per segment to one per block. A full read verifies them and a point read does not, and the reason is read amplification rather than the cost of the crc: a block is 256 KiB and a MiniBlock chunk is 1024 rows, so verifying to answer a point read would mean reading thirty to sixty times more bytes than the answer needs. The point path bounds every access by the meta instead, and rejects extents outside the body, truncated chunks and count mismatches. The commit message on the first draft of this said the crc cost about twice the read, which was wrong and is not the argument.

The zone map becomes a bound rather than a census: a write widens min and max and never narrows them, so a value inside the zone may be absent and a value outside it is certainly absent.

The deferral bounds are re-derived rather than restated. DEFERRED_CELLS is 1024 and its comment said a few hundred cells is a copy of a few kilobytes, which named the wrong number twice. written_cells charges one per appended row, not one per cell, so a row of a wide table costs the same as a cell of a narrow one. A Cell is 24 bytes and a column entry (u32, Cell) is 32, so a row of a ten column table is 320 bytes and a full patch is about 320 KiB copied on every commit that adds to it. That is the ceiling rather than the usual case, and the string bound below it is what keeps a patch of a thousand long strings from being the real number.

Format version and min reader version go to 2. A version 1 binary refuses a version 2 file rather than reading the trailer as a body, and there is no converter because nothing writes version 1 any more.

Workspace tests pass, 194 plus 309 plus the rest, and clippy is clean on --all-targets.

A segment used to be an index followed by a body, with every chunk
sitting immediately after the chunk before it, so a chunk that grew by
a byte moved every chunk after it and the only way to change one value
was to write the column again. That is why a one cell write cost the
whole column and why an insert cost 143 ms whatever the table held.

The body now comes first and the trailer last, and a chunk is wherever
its entry says it is: start, len, and one u64 of whatever the layout
needs, the fence for MiniBlock and the zipped size for FullZip. A chunk
written again goes to the tail and leaves a hole, live_bytes counts
what the entries still reach, and a segment more than half garbage is
laid out again on the next write, so the waste is bounded at 2x and the
copy is paid once per doubling rather than once per write.

Blocks the unchanged body prefix still covers are kept by pointer, so
an append writes about two blocks whatever the column holds and frees
none of the ones it had. The decoded pool is keyed on the first block
and that key survives a rewrite, so the file forgets the segment's
pooled state when it reuses it.

The label bitset gets the same treatment. A change reads the chunks
holding the rows it names, one read per chunk rather than one per row,
and writes those chunks and the ones past the old end. An appended row
takes its table's own label and nothing else unless a change says
otherwise.

Checksums move from one per segment to one per block. A full read
verifies them and a point read does not, because crc32c over a 256 KiB
block costs about twice what the read itself costs and the checkpoint
already promised the block is the one it wrote.

The zone map becomes a bound rather than a census: a write widens min
and max and never narrows them, so a value inside the zone may be
absent and a value outside it is certainly absent.

Format version and min reader version go to 2. A version 1 binary
refuses a version 2 file rather than reading the trailer as a body, and
there is no converter because nothing writes version 1 any more.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant