muscles

Storage format

This page describes, byte by byte, what muscles writes to disk. It is for an engineer who wants to read the data without the server, or who wants to know what a release writes before trusting it with data. You do not need it to run muscles.

This is the beta format: from the beta on, every release reads the files that an earlier release wrote. A section marked Work in progress describes what the beta writes; its layout is settled, and the muscles code that writes it is being finished against this page.

Every structure has a table of offsets, sizes and meanings, and the worked examples at the end show real bytes with real checksums. The page is written so that a reader can be built from it, and a standalone reader is being written against it.

Conventions

  • Every integer is little-endian. u8, u16, u32, u64 are unsigned, i8 to i64, i128 and i256 are two's complement, f32 and f64 are IEEE 754.
  • var_uint is unsigned LEB128: seven bits per byte, low bits first, the high bit set when another byte follows, at most 10 bytes, and the tenth byte is 00 or 01 so that the value fits a u64, and a writer emits the shortest form.
  • A length or a count in metadata is a var_uint. Offsets and counts inside column data are fixed width, so a reader can index them without a scan.
  • A name is a var_uint length followed by UTF-8 bytes.
  • The checksum everywhere is CRC32C: polynomial 0x1EDC6F41, reflected, initial value and final xor 0xFFFFFFFF. The check value is crc32c("123456789") = 0xE3069283. Where one file records the checksum of another file that ends in a checksum, it records that file's trailer value, because the CRC of a file over its own CRC is always the constant 0x48674BC7.
  • Every file, object and record starts with a 4-byte magic and a 1-byte version. A reader that meets a version it does not know refuses the file. A file is exactly one envelope and bytes after the trailer are an error, except in the S3 bundle, whose files follow its envelope by design.
  • Every length, count and offset is checked against the bytes held and against the limits before a reader allocates or indexes.
  • A time value is a UTC instant. A time zone in a type is a display attribute, so a change of the zone touches metadata and no stored byte.

The envelope Work in progress

Every file that is not a stream of records uses one envelope. A stream, such as a write-ahead log segment, uses it once at the head and its records follow the envelope's checksum.

offsetsizetypefieldmeaning
04bytesmagicone per format, listed in the sections below
41u8versionthe layout of the fixed body; changes only when that layout changes
51u8flagsbit 0: the body is a codec block; bit 1: an optional-field section follows the body; bits 2 to 7 are reserved, must be 0, and a reader refuses the file when one is set
64u32body_lenbytes of the body as stored
10body_lenbytesbodythe format's fixed layout
10 + body_len4u32section_lenpresent when flags bit 1 is set: the bytes of the entries that follow
14 + body_lensection_lenentriesoptional fieldseach entry is tag var_uint, len var_uint, len bytes; at most 256 entries, with tags in strictly ascending order
end − 44u32crc32cover every byte before it

An optional field's tag carries the must-understand bit in bit 0. A reader skips an entry with an even tag it does not know and refuses a file with an odd tag it does not know. Tags 0 and 1 are invalid. Each format assigns its own tags, and a field whose absence would change the meaning of the data gets an odd tag.

The codec block

Compressed bytes always sit behind this 20-byte header: every stream of a column block, a compressed write-ahead log insert record, and a compressed envelope body. The header names the chain that produced the payload, so a reader needs no other source for it.

offsetsizetypefieldmeaning
01u8magic0xC6
11u8version1
21u8n_stages1 to 4
31u8reserved0
484 × (u8, u8)stagescodec id and parameter per stage; unused pairs are 0
124u32raw_lenbytes after decoding, checked against the limit before allocation
164u32stored_lenbytes of the payload
20stored_lenbytespayload

Stages run in order on encode and in reverse on decode. A general compressor (lz4 or zstd) is the last stage and there is at most one; none is always the only stage; the payload of each stage is:

idcodecparameterpayload
0none0the raw bytes
1lz40one LZ4 block in the block format, without a frame
2zstdthe level, 1 to 22one zstd frame that carries its content size
3rle0pairs of byte, run, with the run from 1 to 255
4deltathe lane width, 1, 2, 4 or 8the same length as the input, which is a multiple of the width: lane 0 is v[0], lane i is v[i] − v[i − 1] with wrapping arithmetic
5double_deltathe lane width; 8 at reader level 18-byte lanes. u32 value count, then a bit stream packed most significant bit first: v[0] as 64 bits, then for each next value the delta of deltas, (v[i] − v[i − 1]) − (v[i − 1] − v[i − 2]), with a prefix: 0 when it is zero; 10 then 8 bits; 110 then 16 bits; 1110 then 32 bits; 1111 then 64 bits, each two's complement. The low bits of the last byte are zero padding.
6gorillathe lane width; 8 at reader level 18-byte lanes. u32 value count, then a bit stream packed most significant bit first: v[0] as 64 raw bits, then for each next value the xor with the previous bit pattern: 0 when it is zero; 10 then the meaningful bits in the previous window; 11 then 6 bits of leading-zero count, 6 bits of the meaningful length minus one, then the meaningful bits
7t64reserved; no writer emits it and a reader refuses it like an unknown id
8retirednever reused
9 to 127later codecs
128 to 255experimentsnever in a shipped file

When a general compressor follows a stage whose output length the header does not record (rle, double_delta, gorilla), its payload starts with u32 intermediate_len, the length of the bytes it decodes to; after delta, which keeps the length, that length is raw_len. The width parameter of double_delta and gorilla lets a later reader level add 4-byte lanes; a level-1 reader refuses any value but 8.

A reader that meets an id it does not know refuses that stream with an unsupported-codec error. A new codec therefore costs an older reader one stream of one column in one granule, never the part.

The type descriptor

A column type is written in the ClickHouse binary type encoding: one id byte, the type's parameters, and the descriptors of its children for a composite. The ids are ClickHouse's own, so a type read from a ClickHouse Native export decodes with the same code. The table lists the ids the beta writer emits.

typebytes
Nothing00
UInt8, UInt16, UInt32, UInt64, UInt128, UInt25601 to 06
Int8, Int16, Int32, Int64, Int128, Int25607 to 0C
Float32, Float640D, 0E
Date, Date32, DateTime0F, 10, 11
DateTime('tz')12 var_uint name length, name
DateTime64(P)13 u8 P
DateTime64(P, 'tz')14 u8 P, var_uint name length, name
String15
FixedString(N)16 var_uint N
Enum817 var_uint count, then per member: name, i8 value
Enum1618 var_uint count, then per member: name, i16 value
Decimal32(P, S), Decimal64, Decimal128, Decimal25619 to 1C, then u8 P, u8 S
UUID1D
Array(T)1E T
Tuple(T1, ...)1F var_uint count, T1 ...
Tuple(a T1, ...) (named)20 var_uint count, then per element: name, T
Nullable(T)23 T
LowCardinality(T)26 T
Map(K, V)27 K V
IPv4, IPv628, 29
Bool2D
aggregate stateF0 01, then a ClickHouse AggregateFunction descriptor: 25, var_uint version (0), name, var_uint parameter count, parameters as ClickHouse Field values, var_uint argument count, argument descriptors

The prefix F0 followed by a var_uint extension id marks a muscles extension. Extension 1 wraps a ClickHouse AggregateFunction descriptor and says that the cells hold muscles aggregate states. No other extension is defined.

A reader that meets any other ClickHouse id, among them Variant, Dynamic, JSON, Nested and QBit, reports an unsupported type for that column, skips its bytes by the stream lengths, and reads the rest of the part. A later revision of this page defines their layouts; adding one adds an id and bumps no version.

Time types and their ranges: Date as u16 days ends on 2149-06-06 and DateTime as u32 seconds on 2106-02-07; Date32 and DateTime64 reach far past both. For Iceberg interchange, DateTime64(6) maps to timestamp, DateTime64(6, 'UTC') to timestamptz, and Date and Date32 to date.

The column encoding Work in progress

A column block holds n rows of one column as a list of streams, and each stream is its own codec block. A compression transform therefore sees exactly the bytes it expects: the values of a UInt64 column are a stream of 8n bytes with nothing in front of them. The same block appears in write-ahead log insert records, in the granules of a column file, in the dictionary file and in the primary index.

offsetsizetypefieldmeaning
01u8flagsbit 0: the top-level validity stream is omitted and every row holds a value; bit 1: every LowCardinality dictionary of the block is inline, before its indexes; bit 2: compact streams; bits 3 to 7 are reserved, must be 0, and a reader refuses the block when one is set
1..var_uintstream_count
....streamsstreamsper stream: kind u8, then one codec block; in the compact form, kind u8, var_uint length, then the raw bytes

The compact form is for small log inserts, where a 20-byte codec header per stream would outweigh the values; a part never uses it. Stream kinds: 0 validity, 1 values, 2 offsets, 3 data, 4 indexes. A reader refuses a kind from 5 to 127 and may skip one from 128 to 255. The streams of a type come in a fixed order, depth first, and every count follows from a stream's raw_len, so there is no count field:

typestreams
Nullable(T)a validity stream unless block flags bit 0 is set, then the streams of T. The bitmap is ceil(n / 64) × 8 bytes; bit r, least significant bit first within each byte, is 1 when row r holds a value. NULL rows hold zero bytes in the other streams (0, an empty string, an empty array). A nested Nullable always writes its bitmap.
fixed widtha values stream of n × width bytes. Width 1: UInt8, Int8, Bool, Enum8. Width 2: UInt16, Int16, Date, Enum16. Width 4: UInt32, Int32, Float32, Date32, DateTime, IPv4, Decimal32. Width 8: UInt64, Int64, Float64, DateTime64, Decimal64. Width 16: UInt128, Int128, UUID, IPv6, Decimal128. Width 32: UInt256, Int256, Decimal256.
Stringan offsets stream of u32[n + 1] with offsets[0] = 0 and non-decreasing values, then a data stream of offsets[n] bytes. Cell r is data[offsets[r] .. offsets[r + 1]].
FixedString(N)a values stream of n × N bytes
Array(T)an offsets stream in elements, then the streams of T over offsets[n] elements
Map(K, V)as Array(Tuple(K, V)): the offsets, the streams of K, then the streams of V
Tuple(T1, ..., Tk)the streams of T1, then T2, and so on
LowCardinality(T)when block flags bit 1 is set, which is always the case in a log record: the streams of T over the dictionary, then an indexes stream; otherwise the indexes stream alone, into the part's dictionary file for this stream path. The index width is raw_len / n: 1, 2 or 4; when n is 0 the stream is empty and the width is 1. A NULL row points at a dictionary entry whose validity bit is 0.
aggregate stateas String; each cell is one MAS1 record
Nothingno streams

Values of the fixed-width types: integers two's complement; Date days since 1970-01-01 as u16, Date32 as i32; DateTime seconds since the epoch as u32; DateTime64(P) ticks of 10−P seconds as i64; a decimal as its scaled integer; IPv4 as the address value in a u32; IPv6 and UUID as the 16 bytes in the order their text form prints them; Bool as 0 or 1. There is no padding or alignment inside a stream.

Every stream has a path: the top level is empty, a tuple element is its ordinal, a map's key and value are key and value, an array's child is elem, and a nullable's child or a dictionary's element adds nothing. A LowCardinality nested in a composite has its own dictionary file, col-<id>.<path>.dict: Map(LowCardinality(String), LowCardinality(String)) in column 7 has col-7.key.dict and col-7.value.dict. The names col-<id>.<path>.bin are reserved for a later layout that stores one stream in its own file, so that a reader can fetch a map's keys or a null map alone. The beta writes every stream of a column into one file, and a reader ignores a stream file it does not expect.

Identities Work in progress

  • A table has a 128-bit table_uuid, a UUID version 7 assigned at CREATE TABLE and kept across renames. Its lowercase 32-digit hex is the table's directory name.
  • A column has a column_id u32, assigned from 1 when it is created and never reused by the table; the schema record carries the next id to hand out. Files, blocks and manifests name columns by id, so a rename touches no data file.
  • A part has a 128-bit part_uuid, a UUID version 7 assigned when the writer opens it. Every file of the part carries it, so a file from another part fails the check even when the row counts agree.
  • A row is (part_uuid, position), with the position a u64 below the part's row count. A part holds at most 236 rows and 226 granules, the most its marks, primary index and deletion vector can hold within their limits, and a merge that would pass either is not planned. Parts never change, so the pair holds until a merge rewrites the part, and the merge journal names the inputs. No row id is stored in the data.
  • Every file and replication object names its writer, as a text such as muscles 0.1.0-beta.1, and a min_reader u16. Level 1 is this page. A writer raises the level only in a release that changes what existing bytes mean; an addition takes a new id or field instead, so a raised level is rare, and a file that exceeds a level-1 limit carries the level that allows it. A merged part carries the highest level among its inputs and the writer. A reader whose level is below min_reader refuses the file; a reader that knows a writer's defect can skip what that writer got wrong.

Write-ahead log Work in progress

Each table has its own log directory, <wal_dir>/<table_uuid>/, of append-only segments named wal-<seq>.wal with a 16-digit zero-padded sequence, so lexicographic order is segment order. A segment starts with the envelope, magic MWAL, version 1, with this body.

offsetsizetypefieldmeaning
08u64segment_seqthe sequence in the file name
88u64lsn_floorno record in this segment carries a lower log sequence number
168u64created_atUnix time in nanoseconds
2416bytestable_uuid
40..namewriterthe program and version that wrote the segment
..2u16min_reader1 for the beta
..4u32max_insert_raw_bytesthe largest raw columns section the writer emitted; a reader allocates up to it, within the format limit

Record framing

Records follow the envelope's checksum, each in one frame.

offsetsizetypefieldmeaning
04u32lenbytes of the body
4lenbytesbodykind u8, flags u8, the kind's fixed fields, then an optional-field section in the envelope's form when flags bit 1 is set
4 + len8u64syncedhow far the segment's last completed fsync reached when this frame was written; never past the frame's own start
12 + len4u32crc32cover the body and the synced stamp

A write is acknowledged after the frame is on disk and synced. On replay, an incomplete frame at the end of the last segment, or a frame with a bad checksum that no later frame's synced stamp covers, is a torn tail: the reader truncates there. A bad frame at offset p is corruption when a later intact frame carries synced > p, because a completed sync covered it; a reader reports it and does not skip it. A last segment whose envelope is incomplete or fails its checksum holds no acknowledged record and is read as empty; the same in an earlier segment is corruption. The directory is synced after a segment is created, renamed or deleted. A kind from 0 to 127 must be understood, and a reader refuses a segment with one it does not know. A kind from 128 to 255 may be skipped.

Insert record, kind 1

offsetsizetypefieldmeaning
01u8kind1
11u8flagsbit 0: the columns section is one codec block; bit 1: optional fields follow it
28u64first_lsnrow i of the record has sequence number first_lsn + i
104u32row_count
142u16column_count
16..columnscolumns sectionper column: column_id var_uint, the descriptor as a var_uint length and bytes, then one column block of row_count rows

Below 4096 bytes the columns section is stored raw and its blocks use compact streams; from 4096 bytes on, flags bit 0 is set and the section is one LZ4 codec block whose raw bytes hold every stream as a codec block under none; a table setting selects ZSTD instead. Optional field 2 (may be skipped) is the 16-byte insert token that lets a client retry a batch. Optional field 5 (must be understood) is present when one statement spans frames: statement_uuid (16 bytes), seq u32, last u8; replay applies such a statement only when its last frame is present.

Checkpoint, kind 2

offsetsizetypefieldmeaning
01u8kind2
11u8flagsbit 1: optional fields follow
28u64flushed_lsnevery row at or below this sequence number is in a part; replay skips inserts up to it

Optional field 3 is a Kafka position, partition i32 and offset i64, the point from which ingest resumes. Its tag is odd: a reader that resumes ingest must understand it.

Schema record, kind 3

The record is the whole table definition. A later record with a higher epoch replaces an earlier one, so a reader keeps the one with the highest schema_epoch.

offsetsizetypefieldmeaning
01u8kind3
11u8flagsbit 1: optional fields follow
28u64schema_epochrises with every change to the table
1016bytestable_uuid
264u32next_column_idthe id the next new column takes; never lower than any id ever used
30..nametable name
....var_uintcolumn_count
....columnscolumnsper column: column_id u32; name; descriptor; col_flags u8 (bit 0: an initial default follows as a ClickHouse Field; bit 1: a default expression follows as text); the codec chain as n_stages u8 and (id, param) pairs; a per-column section as a var_uint length and optional-field entries in the envelope's form (even tags: 2 ALIAS expression, 4 MATERIALIZED expression, 6 column TTL, 8 comment)
....sort keysort keyvar_uint count, then per entry: column_id u32 (0 for an expression), direction u8 (0 ascending, 1 descending), nulls_first u8, the expression as text (empty for a plain column), and the key's descriptor
..1u8engine0 plain, 1 replacing, 2 aggregating, 3 collapsing
....engine keyengine keyvar_uint count and column_id u32 per column, then version_column u32 and is_deleted_column u32, 0 when absent

A col_flags bit a reader does not know refuses the record. When older segments are deleted, the newest schema record is carried forward into the oldest kept segment, so a log always starts with its schema. The initial default is the value a row written before the column existed reads; the default expression is what an insert that omits the column computes. Where a part's descriptor differs from the schema's, the part's descriptor says what the bytes are and a reader casts the values to the schema's type. The optional fields with even-numbered tags carry what a reader of the data does not need: tag 2 the TTL expression, tag 4 the skip-index declarations, tag 6 the Kafka source, tag 8 the table settings. A ClickHouse Field is the value encoding ClickHouse uses for aggregate function parameters: a tag byte (00 NULL, 01 var_uint, 02 zigzag var_int, 07 f64, 0C string as var_uint length and bytes), then the value.

Delete record, kind 4

offsetsizetypefieldmeaning
01u8kind4
11u8flagsbit 1: optional fields follow
216bytesstatement_uuidthe DELETE statement this frame belongs to
184u32seqthe frame's number within the statement, from 0
221u8last1 on the statement's last frame; replay applies a statement only when its last frame is present
23..var_uintpart_count
....partspartsper part: part_uuid, generation u64 of the deletion vector this produces, form u8, then the rows: form 0, var_uint count and u64 positions ascending; form 1, var_uint count and (start u64, length u64) ranges; form 2, row_count u64 and a bitmap of u64 words

Dedup record, kind 5

kind u8 = 5, flags u8, var_uint count, then 16-byte insert tokens that the table still remembers after older segments were deleted.

Parts Work in progress

A part is an immutable directory under <parts_dir>/<table_uuid>/, named <table_uuid>-<seq>.part when a memtable flush wrote it and <table_uuid>-m<seq>.part when a merge did. The writer builds it under a .tmp name, syncs every file and the directory, and publishes it with one atomic rename. A directory whose name ends in .tmp is not a part.

filemagiccontent
part.manifestMUS1 v1row count, columns, zone maps, checksums of the other files, the merge journal
col-<id>.binMCOL v1one column: a 32-byte header, then one column block per granule
col-<id>.mrkMMRK v1where each granule of that column starts, its length and its checksum
primary.idxMPIX v1the first row of each granule and the sort key of that row
col-<id>.dict, col-<id>.<path>.dictMDCT v1the dictionary of a LowCardinality column or of one nested in a composite
rows.delMDLV v1the deletion vector; absent when no row is deleted
minmax-<id>.mski, set-<id>.sski, bloom-<id>.bski, textbloom-<id>.tbkiMSKI, SSKI, BSKI, TBKIskip indexes; a reader may ignore them
mapkeys-<id>.mkd, sidx-<id>.mftx, sidx-<id>.mvixMKD1, MFTX, MVIXthe map-key digest, the full-text index and the vector index; a reader may ignore them
part.tombstone, part.deleting, part.cold, part.moved, part.replicatedMTMB, MDEL, MCLD, MMOV, MREPmarkers, described under directories and markers

A file in a part directory that this table does not name is ignored. The column data is always authoritative: a damaged or missing index costs a reader speed and never a wrong row.

The manifest

The envelope's magic is MUS1 and the version is 1. The body:

offsetsizetypefieldmeaning
016bytespart_uuid
1616bytestable_uuid
32..namewriterthe program and version that wrote the part
..2u16min_reader1 for the beta
..8u64row_countphysical rows, including deleted ones
..4u32granule_rowsthe most rows a granule holds; a granule may hold fewer
..4u32granule_count
..8u64blockinstall sequence on the node that wrote the part
..8u64content_ida hash of the rows, used to recognise an identical part
..8u64first_lsnthe first log sequence number the part covers, plus one; 0 when the part covers none
..8u64last_lsnthe last one, plus one
..8u64schema_epochthe schema the part was written under
..8u64written_atUnix time in nanoseconds
..4u32max_block_raw_bytesthe largest raw stream in the part; a reader allocates up to it, within the format limit
....nametable name
....sort keysort keyvar_uint count, then entries as in the schema record
..12u64, u32primary indexthe length of primary.idx and its checksum (its trailer)
....var_uintcolumn_count
....columnscolumnsper column: column_id u32; name; descriptor; col_flags u8; file_len u64 and file_crc u32 over every byte of col-<id>.bin; marks_len u64 and marks_crc u32, the length and trailer of col-<id>.mrk; when col_flags bit 1 is set, a var_uint count and per dictionary file its stream path, file_len u64 and trailer u32; the zone map as a var_uint length and bytes, length 0 when there is none; a per-column section as a var_uint length and optional-field entries in the envelope's form
....var_uintreplaces_countthe merge journal
....inputsreplacesper input part this part replaced: part_uuid, name, masked_rows u64

Column flags: bit 0 the column holds at least one NULL, bit 1 dictionary files follow; bits 2 to 7 are reserved, and a reader refuses the manifest when one is set. The per-column section takes even tags: 2 null_count u64, 4 raw_bytes u64 (the sum of the column's raw stream lengths), 8 the initial default as a ClickHouse Field. The manifest's own optional fields: 3 a Kafka position (partition i32, offset i64), 4 the list of sidecar files with their checksums, 6 the value-sorted flag of a collapsing merge key.

Zone maps

Every column has a zone map: the least and greatest non-NULL value in the part, so a WHERE can skip the part before it reads anything. The bytes start with kind u8 and bound_flags u8, then the minimum and the maximum unless bit 3 is set. A kind or a flag bit a reader does not know means there is no zone map and the column is scanned.

  • Kind 1, fixed width: each bound at the column's lane width. The order is the type's own, so a reader compares UInt64 values unsigned and Int64 values signed and never compares the bytes. Floats compare numerically, with −0 equal to +0; as in Parquet, a writer stores a zero minimum as −0 and a zero maximum as +0. NaN is left out of the bounds, and bit 2 says the column holds a NaN, so a predicate that could match NaN does not prune.
  • Kind 2, prefix, for strings: each bound as a var_uint length and up to 64 bytes, ordered as unsigned bytes. A minimum that was cut is a prefix of the true minimum and still a lower bound; bit 0 says it was cut. A maximum that was cut would not be an upper bound, so the writer increments the last byte of the prefix that is not FF and drops the bytes after it; when every byte is FF there is no upper bound and bit 1 says so. Bit 4 says the maximum was cut: it is an upper bound and not the true maximum, so a reader never answers max from it.
  • Bit 3 says there are no bounds, because every row is NULL or NaN.

Marks and the primary index

A column file is split into granules: runs of up to granule_rows rows, each a column block of its own. Each column has a marks file, magic MMRK version 1, so a reader fetches the marks of the columns it needs and no others:

offsetsizetypefieldmeaning
016bytespart_uuid
164u32column_id
204u32granule_countequal to the manifest's
2416 per granulemarksmarksper granule: offset u64 of the column block in the column file, length u32 of the block, crc32c u32 of the block

The primary index, magic MPIX version 1, holds what every column shares: where each granule starts, and the sort key of its first row.

offsetsizetypefieldmeaning
016bytespart_uuid
164u32granule_count
208 per granuleu64row_indexthe first row of each granule
....var_uintkey_countthe number of sort-key entries
....blockskeysper sort-key entry, one column block of granule_count values: the key of the first row of each granule

A granule's row count is the difference of adjacent row_index values, and the last granule's against row_count. The stored length in the marks lets a reader fetch one granule from S3 with one range request. The key blocks are typed by the sort key's descriptors, so a 128-bit, decimal or string key needs nothing special.

Column files

A column file starts with a 32-byte header: the magic MCOL, version u8 = 1, flags u8 = 0, reserved u16 = 0, part_uuid, column_id u32, reserved u32 = 0. Then the granule blocks follow back to back, each a column block whose streams are codec blocks. The marks give the offset, the length and the checksum of each block; the manifest gives the length and checksum of the whole file.

A LowCardinality column also has col-<id>.dict: an envelope, magic MDCT version 1, whose body is part_uuid, column_id u32, dict_count u32, then one column block of the element type with dict_count rows. Every granule of the column stores indexes into it, and a nested LowCardinality has a file of its own, named by its stream path, with that path in the body. A column whose dictionary would pass the dictionary limit is written with an inline dictionary in every granule instead.

The deletion vector

DELETE never rewrites a published part. It writes rows.del next to it: an envelope, magic MDLV version 1, with the body below. A reader drops the masked rows from every scan; a later merge leaves them out and the new part has no vector.

offsetsizetypefieldmeaning
016bytespart_uuidmust equal the manifest's
168u64generationrises with every DELETE on the part; the highest generation is the current vector
248u64row_countequal to the manifest's
328u64deleted_countthe number of deleted rows, a cross-check
401u8form0 bitmap, 1 ranges, 2 Roaring
41..rowsrowsform 0: 8 × ceil(row_count / 64) bytes of u64 words, bit r of word r / 64 is 1 when row r is deleted; allowed up to 233 rows. Form 1: u64 count, then (start u64, length u64) ranges, ascending and disjoint. Form 2: a 64-bit Roaring bitmap in the portable serialization of the CRoaring library, the form Iceberg version 3 uses. A reader supports all three; a writer picks the smallest allowed form.

Skip indexes

A skip index summarises blocks of granularity granules of one column so that a scan can skip a block before it reads it. Each is an envelope whose body starts with the same header, then one entry per block, then nothing else. A reader cross-checks part_uuid, row_count and granule_rows against the manifest and ignores the file on a mismatch.

offsetsizetypefieldmeaning
016bytespart_uuid
164u32column_id
204u32granule_rowsas in the manifest
244u32granularitygranules per block, at least 1
288u64row_countas in the manifest
364u32block_count
40..entriesblocksper kind, below
  • MSKI, minmax: per block flags u8 (bit 0 the block holds a NULL, bit 1 the bounds are unusable and the block always survives), then a zone map in the manifest's form.
  • SSKI, set: per block flags u8 (bit 0 has NULL, bit 1 the set overflowed and the block always survives), u32 count, then the distinct values at lane width, or as var_uint length and bytes for strings.
  • BSKI, bloom filter: after the header m_bits u32 (a multiple of 8), k u8, seed u64; per block flags u8 (bit 0 has NULL) and m_bits / 8 bytes. A value's hash h is the 64-bit wyhash with seed over its canonical bytes; with h1 = h mod 232 and h2 = (h >> 32) | 1, probe i of k tests bit (h1 + i × h2) mod m_bits, computed in 64-bit unsigned arithmetic.
  • TBKI, text bloom filter: as BSKI with subtype u8 (0 n-gram, 1 token) and n u8 before m_bits; each token or n-gram of a cell is hashed as a string.

The map-key digest, the full-text index and the vector index are rebuilt from the column data by the server, so their bodies are not frozen by this page, and a reader ignores a sidecar version it does not know. The full-text index records its tokenizer: id 0 splits on ASCII punctuation and folds ASCII case; id 1 is ngram, whose spec holds min_gram u8 and max_gram u8, the lengths in code points of the terms it indexes. A query side that does not know the tokenizer id scans instead.

The S3 bundle

A part moved to the cold tier is one object: an envelope, magic MPBL version 1, whose body is part_uuid, table_uuid and a file table (var_uint count; per file a name, offset u64, length u64, crc32c u32), followed after the envelope's checksum by the files back to back, with offsets relative to the end of the envelope. The stub part.cold left in the part directory carries part_uuid, the object key relative to the configured prefix, the object length and a copy of the table, so a reader can fetch one granule with one ranged request.

Aggregate states Work in progress

A column of the aggregate-state type holds one state per row, as the result of a -State combinator, a rollup rung or an aggregating materialized view. Each cell is one record: an envelope with magic MAS1, version 1, body kind u8 then the kind's payload. A reader that copies parts can carry a state as opaque bytes; to finish or merge it, it needs the payload. A kind id is never redefined: a changed payload takes a new id.

A held value inside a payload is a tag u8 followed by the value: 0 nothing, 1 i64, 2 f64, 3 a string as var_uint length and bytes, 4 u64, 5 i128, 6 u128, 7 sixteen raw bytes (UUID, IPv6), 8 thirty-two raw bytes (256-bit integers and decimals).

Canonical bytes, for every hash in this format: a signed integer of any width, and a decimal as its scaled integer, as i64 little-endian with wyhash seed 1, or as its 16 or 32 raw bytes with seed 4 when it does not fit; an unsigned one as u64 with seed 2; a float as the f64 bit pattern with seed 3; a string, FixedString, UUID, IPv6 and the wide integers as their raw bytes with seed 4. Hashing the widened value keeps a sketch valid when a column is widened within its class, signed to wider signed, unsigned to wider unsigned, Float32 to Float64; a change of class, such as UInt32 to Int64, rebuilds the sketches. The hash is wyhash final version 4 as the Zig standard library implements it. Test vectors: wyhash(1, i64 1) = 0x736c1462d905b0f3, wyhash(2, u64 1) = 0x3816548725c03c86, wyhash(3, f64 1.5) = 0xc76a38da4e33d8ee, wyhash(4, "ann") = 0x2733844f6ddc4d99, wyhash(0, "") = 0x0409638ee2bde459.

kindfunctionpayload
0countu64
1sumlane u8 (1 integer, 2 float, 4 unsigned), i256 integer sum, f64 float sum
2avgthe sum payload, then u64 count
3, 4min, maxone held value; tag 0 for an empty group
5uniqExactu32 count and i64 keys, then u32 count and string keys
6, 7argMin, argMaxhave u8, the key as a held value, the value as a held value
8groupArrayelem u8 (0 none, 1 integer, 2 float, 3 string), u32 count, then the items as i64, f64 or strings
9uniqprecision u8 (the HyperLogLog has 2precision registers; 14 in the beta), form u8: 0 sparse, then u32 count and u32 entries of register << 8 | rank in ascending register order; 1 dense, then u32 length and the register bytes. The register is the top precision bits of the canonical hash; the rank is one plus the leading zeros of the remaining bits, with a guard bit so that it never exceeds 64 − precision + 1.
10topKu32 k, elem u8 (as groupArray, plus 4 unsigned), u32 count, per item: the item, u64 count, u64 error
11quantilea t-digest: f64 compression, n_levels u8, f64 levels, f64 total weight, u32 centroid count, then (f64 mean, f64 weight) per centroid
12quantileExactn_levels u8, f64 levels, u32 count, f64 values
13 to 16varPop, varSamp, stddevPop, stddevSampu64 n, f64 mean, f64 m2
17 to 20covarPop, covarSamp, corr, simpleLinearRegressionu64 n, f64 mean x, mean y, co-moment, m2 x, m2 y
21, 22stochasticLinearRegression, stochasticLogisticRegressionloss u8, f64 learning rate, u32 batch size, u32 max features, u64 n, u32 feature count, then feature count plus one f64 weights
23quantileExactInterpolatedas 12
24 to 26groupBitAnd, groupBitOr, groupBitXoru64 accumulator, seen u8, unsigned u8
27groupBitmapu32 count, u64 keys
28deltaSumelem u8, i64 sum, first, last, f64 sum, first, last
29, 30any, anyLasthave u8, one held value
31uniqUpTou32 threshold, u32 count, u32 item count, then held values
32 to 34sumMap, minMap, maxMapkey_kind u8, val_kind u8, u32 count, then the integer keys as i64 each with its value (i64 or f64 by val_kind), then the string keys each with its value
35windowFunneli64 window, events u8, flags u8, sorted u8, u32 count, then (i64 ts, u8 event)
36retentionevents u8, u32 mask
37, 38sequenceMatch, sequenceCountevents u8, sorted u8, u32 conditions met, u32 count, then (i64 ts, u32 mask)
39 to 42skewPop, skewSamp, kurtPop, kurtSampf64 m0, m1, m2, m3, m4
43entropyu32 count of (i64 key, u64 n), u32 count of (u64 float bits, u64 n), u32 count of (string, u64 n)
44, 45studentTTest, welchTTestf64 nx, ny, x1, x2, y1, y2
46, 47mannWhitneyUTest, kolmogorovSmirnovTestu32 count and f64 x values, u32 count and f64 y values
48avgWeightedf64 numerator, f64 denominator
49, 50first_value, last_valueas 6 and 7: the key is the row's time, the value is x; on a tie in time last_value keeps the greater x and first_value the smaller, with NULL as the least

The exact kinds carry a cap on what they hold: uniqExact 220 keys, groupArray 220 items, quantileExact and quantileExactInterpolated 220 values, groupBitmap 220 keys, entropy 220 keys, mannWhitneyUTest and kolmogorovSmirnovTest 220 samples per side, uniqUpTo its threshold and topK its k, so every state stays below the cell limit. A merge of two states can still pass a cap, and the rule is that no value is ever dropped: a part merge that would pass a cap leaves the inputs uncollapsed and reports it, and a query that needs the collapsed value fails with an error that names the aggregate. The sketch kinds, uniq, quantile and the moment and regression families, are bounded in size whatever the input and always merge.

Replication objects Work in progress

Three replication objects outlive a release and follow the same rules as the files above: the commit record in the Kafka topic, the shard manifest and the schema snapshot in S3, each of them an envelope.

  • MRCL version 1, a commit record: kind u8 (1 part added, 2 part removed, 3 part masked, 4 generation barrier, 5 DDL set, 6 DDL drop, 7 DDL snapshot), the writer, min_reader u16, generation u64, emitter u64, shard_id u32, schema_epoch u64, then the kind's fields. A part kind carries table_uuid, part_uuid, block u64, content_id u64, row_count u64 and the part name, and the bundle key is parts/<name>-<content_id>.part; an added part also carries its insert tokens, and a masked part masked_rows u64. A DDL kind carries the object kind as a word, the name, the SHA-256 of the text, the text (empty when withheld) and, for a table, the body of its schema record, so a replica takes the table id, every column id and the next column id from the primary. A snapshot carries watermark i64, the SHA-256 and the object key. Optional field 3 is the Ed25519 signature over the body.
  • MSHM version 3, the shard manifest: the writer, min_reader u16, watermark i64, generation u64, shard_id u32, key_fingerprint u64, u32 count, then per live part table_uuid, part_uuid, content_id u64, block u64, row_count u64 and the part name. The bundle key is parts/<name>-<content_id>.part, relative to the configured prefix.
  • MSCM version 3, the schema snapshot: the writer, min_reader u16, watermark i64, generation u64, shard_id u32, schema_epoch u64, key_fingerprint u64, the topic name, u32 count, then per entry the object kind as a word, flags u8, created_epoch u64, the name, the text and, for a table, its schema record body, with the signature as optional field 3.

Directories, catalogs and markers

A data directory has two roots, set by MUSCLES_WAL_DIR and MUSCLES_PARTS_DIR. Under each, one directory per table carries the table's id in hex. A reader needs no catalog file to read the data: the table list is the directory list, the table name, the column names and types and the sort key are in the latest schema record of the table's log and in each part's manifest.

Which part directories are live follows from the markers. A directory with a part.tombstone or a part.deleting marker is not live. A part.cold stub means the column files are in the S3 bundle it names. A part.moved redirect names the disk the part was moved to. A part.replicated marker means the part was installed from another node. Each marker is an envelope with the magic in the parts table and part_uuid in its body; the tombstone, deleting and replicated markers carry nothing else.

The definitions of views, rollups, dictionaries, policies, recording rules and search indexes are envelope files that hold SQL text. A reader of the data does not need them.

Versioning and extension rules

  • Every file, object and record starts with a magic and a version. The version changes only when the fixed body changes, and a reader refuses a version it does not know.
  • Every file names its writer and a minimum reader level, and a reader below that level refuses the file.
  • Every durable record carries a CRC32C: the envelope over the whole file, the log frame over its body and sync stamp, the marks over each granule block, the manifest over each column file, each marks file and the primary index.
  • Reserved flag bits are 0 and a reader refuses a set one. A later version can therefore give a bit a must-understand meaning.
  • An optional field with an even tag may be skipped and one with an odd tag must be understood. A new piece of information that a reader may ignore is an even field; one that changes the meaning of the data is an odd field.
  • Column ids, table ids and part ids are stable: a rename, a dropped column or a merge never changes an existing id, and a dropped column's id is never reused.
  • A new column type is a new ClickHouse id or a new extension id in the descriptor. An older reader reports an unsupported type for that column, skips its streams, and reads the others.
  • A new codec is a new id in the block header. An older reader refuses that stream and reads the others.
  • A new stream kind from 128 up may be skipped by an older reader; one below 128 is refused. The same rule holds for a new log record kind.
  • A new sidecar is a new file in the part directory. A reader ignores files it does not know.
  • A limit that a writer may raise is stored in the file it applies to, and a reader honours the stored value up to the format's own limit. A format limit changes only with the reader level, never with a version.
  • All integers are little-endian, everywhere, including inside the ClickHouse type descriptor.

Limits a reader enforces

A reader checks each of these before it allocates or indexes, and refuses a file that passes one. They are fixed for reader level 1; the two writer settings, max_insert_raw_bytes and max_block_raw_bytes, are stored in the files and bounded by the raw stream limit.

whatlimit
envelope body256 MiB unless a row below says otherwise; a manifest 64 MiB
col-<id>.mrk1 GiB, which holds 226 granules
primary.idx2 GiB
rows.del2 GiB; the bitmap form up to 233 rows, the range or Roaring form above
dictionary file1 GiB and 224 entries; past either, the column uses inline dictionaries
optional-field section256 entries, 16 MiB
var_uint10 bytes, the tenth at most 01
type descriptor64 KiB, nesting depth 32, 65536 enum members, names of 256 bytes, time zone names of 64 bytes
log frame body1 GiB plus 64 KiB; a segment 1 GiB plus one frame
insert record1,048,576 rows, 4096 columns, a columns section of 1 GiB raw
raw stream1 GiB
part236 rows and 226 granules; granule_rows from 1 to 1,048,576; a merge whose output would pass either is not planned
string or state cell128 MiB

Worked examples

The examples use one table, events, with two columns: id UInt32 (column id 1) and name Nullable(String) (column id 2), sorted by id. Three rows are inserted: (1, 'ann'), (2, NULL), (3, 'bob'). The table id is 0193b2f4-e5c0-7a1b-8c3d-4e5f60718293 and the writer is muscles 0.1.0-beta.1. Every checksum below is computed over the bytes shown, and a script that walks every structure on this page and recomputes every checksum is run before the page is published.

The log segment header

The segment starts with the envelope MWAL, version 1, flags 0, body length 67: sequence 1, floor 0, created_at, the table id, the writer, the reader level and the writer's insert limit, then the checksum, 81 bytes in all.

0000  4d 57 41 4c 01 00 43 00 00 00                    "MWAL", version 1, flags 0, body_len 67
000a  01 00 00 00 00 00 00 00                          segment_seq = 1
0012  00 00 00 00 00 00 00 00                          lsn_floor = 0
001a  00 40 ac c6 3d 28 74 18                          created_at
0022  01 93 b2 f4 e5 c0 7a 1b 8c 3d 4e 5f 60 71 82 93  table_uuid
0032  14 6d 75 73 63 6c 65 73 20 30 2e 31 2e 30 2d 62  writer: 20 bytes, "muscles 0.1.0-b
0042  65 74 61 2e 31                                   eta.1"
0047  01 00                                            min_reader = 1
0049  00 00 00 40                                      max_insert_raw_bytes = 1 GiB
004d  64 ff 58 77                                      crc32c = 0x7758ff64

A log insert record

The columns section is 61 bytes, below the compression threshold, so flags bit 0 is clear, the section is stored raw and its blocks use compact streams. The frame is 93 bytes, and its sync stamp says the 81 bytes of the header were synced before it was written.

0000  4d 00 00 00                                      len = 77
0004  01                                               kind = 1, insert
0005  00                                               flags = 0
0006  00 00 00 00 00 00 00 00                          first_lsn = 0
000e  03 00 00 00                                      row_count = 3
0012  02 00                                            column_count = 2
0014  01                                               column_id = 1
0015  01 03                                            descriptor: 1 byte, 03 = UInt32
0017  04                                               block flags = 4: compact streams
0018  01                                               stream_count = 1
0019  01 0c                                            stream kind 1 = values, length 12
001b  01 00 00 00 02 00 00 00 03 00 00 00              values 1, 2, 3
0027  02                                               column_id = 2
0028  02 23 15                                         descriptor: 2 bytes, 23 15 = Nullable(String)
002b  04                                               block flags = 4: compact streams, validity present
002c  03                                               stream_count = 3
002d  00 08                                            stream kind 0 = validity, length 8
002f  05 00 00 00 00 00 00 00                          bits 0 and 2 set: rows 0 and 2 hold a value
0037  02 10                                            stream kind 2 = offsets, length 16
0039  00 00 00 00 03 00 00 00 03 00 00 00 06 00 00 00  offsets 0, 3, 3, 6
0049  03 06                                            stream kind 3 = data, length 6
004b  61 6e 6e 62 6f 62                                data "ann" "bob"
0051  51 00 00 00 00 00 00 00                          synced = 81
0059  e4 6d 9a 36                                      crc32c over bytes 4 to 0x58 = 0x369a6de4

The column files of the flushed part

After the flush, the part has one granule. col-1.bin is 67 bytes: the 32-byte header, then a 35-byte column block with the same stream as the log record, as a codec block.

0000  4d 43 4f 4c 01 00 00 00                          "MCOL", version 1, flags 0, reserved
0008  01 93 b3 01 6f 3e 7b 2c 9d 4e 5f 60 71 82 93 04  part_uuid
0018  01 00 00 00                                      column_id = 1
001c  00 00 00 00                                      reserved
0020  00 01 01                                         column block: flags 0, 1 stream, kind values
0023  c6 01 01 00 00 00 00 00 00 00 00 00              codec block header, chain none
002f  0c 00 00 00 0c 00 00 00                          raw_len = 12, stored_len = 12
0037  01 00 00 00 02 00 00 00 03 00 00 00              values 1, 2, 3

col-2.bin is 127 bytes: the header, then the 95-byte block of the name column.

0000  4d 43 4f 4c 01 00 00 00                          "MCOL", version 1, flags 0, reserved
0008  01 93 b3 01 6f 3e 7b 2c 9d 4e 5f 60 71 82 93 04  part_uuid
0018  02 00 00 00                                      column_id = 2
001c  00 00 00 00                                      reserved
0020  00 03                                            column block: flags 0, 3 streams
0022  00                                               stream kind 0 = validity
0023  c6 01 01 00 00 00 00 00 00 00 00 00              codec block header, chain none
002f  08 00 00 00 08 00 00 00                          raw_len = 8, stored_len = 8
0037  05 00 00 00 00 00 00 00                          bits 0 and 2 set
003f  02                                               stream kind 2 = offsets
0040  c6 01 01 00 00 00 00 00 00 00 00 00              codec block header, chain none
004c  10 00 00 00 10 00 00 00                          raw_len = 16, stored_len = 16
0054  00 00 00 00 03 00 00 00 03 00 00 00 06 00 00 00  offsets 0, 3, 3, 6
0064  03                                               stream kind 3 = data
0065  c6 01 01 00 00 00 00 00 00 00 00 00              codec block header, chain none
0071  06 00 00 00 06 00 00 00                          raw_len = 6, stored_len = 6
0079  61 6e 6e 62 6f 62                                data "ann" "bob"

The marks and the primary index

col-1.mrk records the one granule of column 1: its block starts at byte 32 of the column file, is 35 bytes long, and has the checksum shown. col-2.mrk has the same shape with column id 2, length 95 and checksum 80 a9 1d b5; its trailer is 7d 5b 3f 94.

0000  4d 4d 52 4b 01 00 28 00 00 00                    "MMRK", version 1, flags 0, body_len 40
000a  01 93 b3 01 6f 3e 7b 2c 9d 4e 5f 60 71 82 93 04  part_uuid
001a  01 00 00 00                                      column_id = 1
001e  01 00 00 00                                      granule_count = 1
0022  20 00 00 00 00 00 00 00                          granule 0: offset 32
002a  23 00 00 00                                      length 35
002e  cd 63 bd fd                                      crc32c of the block = 0xfdbd63cd
0032  26 2b 40 20                                      crc32c = 0x20402b26
0000  4d 4d 52 4b 01 00 28 00 00 00                    "MMRK", version 1, flags 0, body_len 40
000a  01 93 b3 01 6f 3e 7b 2c 9d 4e 5f 60 71 82 93 04  part_uuid
001a  02 00 00 00                                      column_id = 2
001e  01 00 00 00                                      granule_count = 1
0022  20 00 00 00 00 00 00 00                          granule 0: offset 32
002a  5f 00 00 00                                      length 95
002e  80 a9 1d b5                                      crc32c of the block = 0xb51da980
0032  7d 5b 3f 94                                      crc32c = 0x943f5b7d

primary.idx says the granule starts at row 0 and that the sort key of that row is id = 1.

0000  4d 50 49 58 01 00 38 00 00 00                    "MPIX", version 1, flags 0, body_len 56
000a  01 93 b3 01 6f 3e 7b 2c 9d 4e 5f 60 71 82 93 04  part_uuid
001a  01 00 00 00                                      granule_count = 1
001e  00 00 00 00 00 00 00 00                          granule 0 starts at row 0
0026  01                                               key_count = 1
0027  00 01 01                                         column block: flags 0, 1 stream, kind values
002a  c6 01 01 00 00 00 00 00 00 00 00 00              codec block header, chain none
0036  04 00 00 00 04 00 00 00                          raw_len = 4, stored_len = 4
003e  01 00 00 00                                      first key of granule 0 = 1
0042  41 2b e8 44                                      crc32c = 0x44e82b41

The part manifest

The manifest is 303 bytes. The part id is 0193b301-6f3e-7b2c-9d4e-5f6071829304; the log coverage is sequence numbers 0 to 2, stored plus one. The checksums it holds for the marks files and the primary index are those files' trailers; the checksums for the column files cover every byte of them.

0000  4d 55 53 31 01 00 21 01 00 00                    "MUS1", version 1, flags 0, body_len 289
000a  01 93 b3 01 6f 3e 7b 2c 9d 4e 5f 60 71 82 93 04  part_uuid
001a  01 93 b2 f4 e5 c0 7a 1b 8c 3d 4e 5f 60 71 82 93  table_uuid
002a  14 6d 75 73 63 6c 65 73 20 30 2e 31 2e 30 2d 62  writer: 20 bytes, "muscles 0.1.0-b
003a  65 74 61 2e 31                                   eta.1"
003f  01 00                                            min_reader = 1
0041  03 00 00 00 00 00 00 00                          row_count = 3
0049  00 20 00 00                                      granule_rows = 8192
004d  01 00 00 00                                      granule_count = 1
0051  01 00 00 00 00 00 00 00                          block = 1
0059  60 4e 7d 2b 3f 9e 1a 5c                          content_id
0061  01 00 00 00 00 00 00 00                          first_lsn = 0 + 1
0069  03 00 00 00 00 00 00 00                          last_lsn = 2 + 1
0071  01 00 00 00 00 00 00 00                          schema_epoch = 1
0079  00 0a 47 02 3e 28 74 18                          written_at
0081  00 00 00 02                                      max_block_raw_bytes = 32 MiB
0085  06 65 76 65 6e 74 73                             table name "events"
008c  01                                               sort key: 1 entry
008d  01 00 00 00 00 00 00 01 03                       column_id 1, ascending, NULLs last, no expression, UInt32
0096  46 00 00 00 00 00 00 00 41 2b e8 44              primary.idx: 70 bytes, checksum 0x44e82b41
00a2  02                                               column_count = 2
00a3  01 00 00 00                                      column_id = 1
00a7  02 69 64                                         name "id"
00aa  01 03                                            descriptor UInt32
00ac  00                                               col_flags = 0
00ad  43 00 00 00 00 00 00 00 67 1e f0 67              col-1.bin: 67 bytes, crc32c 0x67f01e67
00b9  36 00 00 00 00 00 00 00 26 2b 40 20              col-1.mrk: 54 bytes, checksum 0x20402b26
00c5  0a 01 00 01 00 00 00 03 00 00 00                 zone map, 10 bytes: kind 1, flags 0, min 1, max 3
00d0  14 02 08 00 00 00 00 00 00 00 00                 section, 20 bytes: tag 2 null_count = 0,
00db  04 08 0c 00 00 00 00 00 00 00                    tag 4 raw_bytes = 12
00e5  02 00 00 00                                      column_id = 2
00e9  04 6e 61 6d 65                                   name "name"
00ee  02 23 15                                         descriptor Nullable(String)
00f1  01                                               col_flags: bit 0, the column holds a NULL
00f2  7f 00 00 00 00 00 00 00 bf 4b f3 ec              col-2.bin: 127 bytes, crc32c 0xecf34bbf
00fe  36 00 00 00 00 00 00 00 7d 5b 3f 94              col-2.mrk: 54 bytes, checksum 0x943f5b7d
010a  0a 02 00 03 61 6e 6e 03 62 6f 62                 zone map, 10 bytes: kind 2, flags 0, min "ann", max "bob"
0115  14 02 08 01 00 00 00 00 00 00 00                 section, 20 bytes: tag 2 null_count = 1,
0120  04 08 1e 00 00 00 00 00 00 00                    tag 4 raw_bytes = 30
012a  00                                               replaces_count = 0
012b  d5 75 6c ab                                      crc32c = 0xab6c75d5

A deletion vector and an aggregate state

A DELETE of the row with the NULL name writes rows.del in the bitmap form with bit 1 set, 63 bytes:

0000  4d 44 4c 56 01 00 31 00 00 00                    "MDLV", version 1, flags 0, body_len 49
000a  01 93 b3 01 6f 3e 7b 2c 9d 4e 5f 60 71 82 93 04  part_uuid
001a  01 00 00 00 00 00 00 00                          generation = 1
0022  03 00 00 00 00 00 00 00                          row_count = 3
002a  01 00 00 00 00 00 00 00                          deleted_count = 1
0032  00                                               form 0 = bitmap
0033  02 00 00 00 00 00 00 00                          word 0: bit 1 set, row 1 is deleted
003b  04 77 3f 7e                                      crc32c = 0x7e3f7704

The state of avg(id) over the three rows, as a rollup stores it, is a 64-byte record: the integer sum 6 as 32 bytes, the float sum 0.0, and the count 3.

0000  4d 41 53 31 01 00 32 00 00 00                    "MAS1", version 1, flags 0, body_len 50
000a  02                                               kind 2 = avg
000b  01                                               lane 1 = integer
000c  06 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  sum_int = 6 as i256
001c  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
002c  00 00 00 00 00 00 00 00                          sum_float = 0.0
0034  03 00 00 00 00 00 00 00                          count = 3
003c  77 ed 2b 34                                      crc32c = 0x342bed77