Prometheus TSDB (Part 4): Persistent Block and its Index
Introduction
In Part 1, Part 2, and Part 3, we have covered most of the things related to the Head (in-memory) block (i.e. at the time of writing this post, more things to come in Head). In this blog post, we will dive deeper into the persistent blocks which reside on disk.
There is a lot of information to digest here, so sit back and relax, and maybe grab a coffee.
What's a persistent block and when is it created
A block on disk is a collection of chunks for a fixed time range consisting of its own index. It is a directory with multiple files inside it. Every block has a unique ID, which is a Universally Unique Lexicographically Sortable Identifier (ULID).
A block has an interesting property that the samples in it are immutable. If you want to add more samples, or delete some, or update some, you have to rewrite the entire block with the required modifications and the new block has a new ID. There is no relationship between these 2 blocks. We have deletions on blocks via tombstones while not touching the samples, since re-writing a block on every delete request does not sound sane; we will discuss more about it in this blog post.
We saw in Part 1 that when the Head block fills up with data ranging chunkRange*3/2 in time, we take the first chunkRange of data and convert into a persistent block.
Here we call that chunkRange as blockRange in the context of blocks, and the first block cut from the Head spans 2h by default in Prometheus.
Looking at the overall picture of TSDB below
When the blocks get old, multiple blocks are compacted (or merged) to form a new bigger block while the old ones are deleted. So we have 2 ways of creating a block, from the Head and from existing blocks. We will look into compaction in future blog posts.
Contents of a block
A block consists of 4 parts
meta.json(file): the metadata of the block.chunks(directory): contains the raw chunks without any metadata about the chunks.index(file): the index of this block.tombstones(file): deletion markers to exclude samples when querying the block.
With 01EM6Q6A1YPX4G9TEB20J22B2R as an example of block ID, here is how the files look on disk
data
├── 01EM6Q6A1YPX4G9TEB20J22B2R
| ├── chunks
| | ├── 000001
| | └── 000002
| ├── index
| ├── meta.json
| └── tombstones
├── chunks_head
| ├── 000001
| └── 000002
└── wal
├── checkpoint.000003
| ├── 000000
| └── 000001
├── 000004
└── 000005
Let's dive deeper into each one of them.
1. meta.json
This contains all the required metadata for the block as a whole. Here is an example:
{
"ulid": "01EM6Q6A1YPX4G9TEB20J22B2R",
"minTime": 1602237600000,
"maxTime": 1602244800000,
"stats": {
"numSamples": 553673232,
"numSeries": 1346066,
"numChunks": 4440437
},
"compaction": {
"level": 1,
"sources": [
"01EM65SHSX4VARXBBHBF0M0FDS",
"01EM6GAJSYWSQQRDY782EA5ZPN"
]
},
"version": 1
}
version tells us how to parse the meta file.
Though the directory name is set to the ULID, only the one present in the meta.json as ulid is the valid ID, the directory name can be anything.
minTime and maxTime is the absolute minumum and maximum timestamp among all the chunks present in the block.
stats tell the number of series, samples, and chunks present in the block.
compaction tells the history of the block. level tells how many generations has this block seen. sources tell from which blocks was this block created (i.e. block which were merged to form this block). If it was created from Head block, then the sources is set to itself (01EM6Q6A1YPX4G9TEB20J22B2R in this case).
2. chunks
The chunks directory contains a sequence of numbered files similar to the WAL/checkpoint/head chunks. Each file is capped at 512MiB. This is the format of an individual file inside this directory:
┌──────────────────────────────┐
│ magic(0x85BD40DD) <4 byte> │
├──────────────────────────────┤
│ version(1) <1 byte> │
├──────────────────────────────┤
│ padding(0) <3 byte> │
├──────────────────────────────┤
│ ┌──────────────────────────┐ │
│ │ Chunk 1 │ │
│ ├──────────────────────────┤ │
│ │ ... │ │
│ ├──────────────────────────┤ │
│ │ Chunk N │ │
│ └──────────────────────────┘ │
└──────────────────────────────┘
It looks very similar to the memory-mapped head chunks file. The magic number identifies this file as a chunks file. version tells us how to parse this file. padding is for any future headers. This is then followed by a list of chunks.
Here is the format of an indivudual chunk:
┌───────────────┬───────────────────┬──────────────┬────────────────┐
│ len <uvarint> │ encoding <1 byte> │ data <bytes> │ CRC32 <4 byte> │
└───────────────┴───────────────────┴──────────────┴────────────────┘
It again looks similar to the memory-mapped head chunks on disk except that it is missing the series ref, mint and maxt. We needed this additional information for the Head chunks to recreate the in-memory index during startup. But in the case of blocks, we have this additonal information in the index, because index is the place where it finally belongs, hence we don't need it here.
To access these chunks, we again need the chunk reference that we talked in Part 3. Repeating what I had said: The reference is 8 bytes long. The first 4 bytes tell the file number in which the chunk exists, and the last 4 bytes tell the offset in the file where the chunk starts (i.e. the first byte of the len). If the chunk was in the file 00093 and the len of the chunk starts at byte offset 1234 in the file, then the reference of that chunk would be (92 << 32) | 1234 (left shift bits and then bitwise OR). While the file names use 1 based indexing, the chunks references use 0 based indexing. Hence 00093 got converted to 92 when calculating the chunk reference.
Here is the link for the upstream docs on the chunks format.
3. index
Index contains all that you need to query the data of this block. It does not share any data with any other blocks or external entity which makes it possible to read/query the block without any dependencies.
The index is an "inverted index" which is also widely used in indexing documents. Fabian talks more about inverted index in his blog post, hence I am skipping that topic here since this post is too long already.
Here is the high level view of the index which we will dive into shortly.
┌────────────────────────────┬─────────────────────┐
│ magic(0xBAAAD700) <4b> │ version(1) <1 byte> │
├────────────────────────────┴─────────────────────┤
│ ┌──────────────────────────────────────────────┐ │
│ │ Symbol Table │ │
│ ├──────────────────────────────────────────────┤ │
│ │ Series │ │
│ ├──────────────────────────────────────────────┤ │
│ │ Label Index 1 │ │
│ ├──────────────────────────────────────────────┤ │
│ │ ... │ │
│ ├──────────────────────────────────────────────┤ │
│ │ Label Index N │ │
│ ├──────────────────────────────────────────────┤ │
│ │ Postings 1 │ │
│ ├──────────────────────────────────────────────┤ │
│ │ ... │ │
│ ├──────────────────────────────────────────────┤ │
│ │ Postings N │ │
│ ├──────────────────────────────────────────────┤ │
│ │ Label Offset Table │ │
│ ├──────────────────────────────────────────────┤ │
│ │ Postings Offset Table │ │
│ ├──────────────────────────────────────────────┤ │
│ │ TOC │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
Same as other files, the magic number identifies this file as an index file. version tells us how to parse this file. The entry point to this index is the TOC, which stands for Table Of Contents. So we will first start from TOC and learn about other parts of the index.
A. TOC
┌─────────────────────────────────────────┐
│ ref(symbols) <8b> │ -> Symbol Table
├─────────────────────────────────────────┤
│ ref(series) <8b> │ -> Series
├─────────────────────────────────────────┤
│ ref(label indices start) <8b> │ -> Label Index 1
├─────────────────────────────────────────┤
│ ref(label offset table) <8b> │ -> Label Offset Table
├─────────────────────────────────────────┤
│ ref(postings start) <8b> │ -> Postings 1
├─────────────────────────────────────────┤
│ ref(postings offset table) <8b> │ -> Postings Offset Table
├─────────────────────────────────────────┤
│ CRC32 <4b> │
└─────────────────────────────────────────┘
It tells us where exactly (the byte offset in the file) do the individual components of the index start. I have marked what do each reference point to in the index format above. The starting point of next component also tell us where do individual components end. If any of the reference is 0, it indicates that the corresponding section does not exist in the index, and hence should be skipped while reading.
Since TOC is fixed size, the last 52 bytes of the file can be taken as the TOC.
As you will notice in the coming sections, each component will have its own checksum, i.e. CRC32 to check for the integrity of the underlying data.
B. Symbol Table
This section holds a sorted list of deduplicated strings which are found in label pairs of all the series in this block. For example if the series is {a="y", x="b"}, then the symbols would be "a", "b", "x", "y".
┌────────────────────┬─────────────────────┐
│ len <4b> │ #symbols <4b> │
├────────────────────┴─────────────────────┤
│ ┌──────────────────────┬ ───────────────┐ │
│ │ len(str_1) <uvarint> │ str_1 <bytes> │ │
│ ├──────────────────────┴───────────────┤ │
│ │ . . . │ │
│ ├──────────────────────┬───────────────┤ │
│ │ len(str_n) <uvarint> │ str_n <bytes> │ │
│ └──────────────────────┴───────────────┘ │
├──────────────────────────────────────────┤
│ CRC32 <4b> │
└──────────────────────────────────────────┘
The len <4b> is the number of bytes in this section and #symbols is the number of symbols. It is followed by #symbols number of utf-8 encoded strings, where each string has its length prefixed followed by the raw bytes of the string. Checksum (CRC32) for integrity.
The other sections in the index can refer to this symbol table for any strings and hence significantly reduce the index size. The byte offset at which the symbol starts in the file (i.e. the start of len(str_i)) forms the reference for the corresponding symbol which can be used in other places instead of the actual string. When you want the actual string, you can use the offset to get it from this table.
C. Series
This section contains a list of all the series information present in this blocks. The series are sorted lexicographically by their label sets.
┌───────────────────────────────────────┐
│ ┌───────────────────────────────────┐ │
│ │ series_1 │ │
│ ├───────────────────────────────────┤ │
│ │ . . . │ │
│ ├───────────────────────────────────┤ │
│ │ series_n │ │
│ └───────────────────────────────────┘ │
└───────────────────────────────────────┘
Each series entry is 16 byte aligned, which means the byte offset at which the series starts is divisible by 16. Hence we set the ID of the series to be offset/16 where offset points to the start of the series entry. This ID is used to reference this series and whenever you want to access the series, you can get the location in the index by doing ID*16.
Since the series are lexicographically sorted by their label sets, a sorted list of series IDs implies a sorted list of series label sets.
Here comes a confusing part for many in the index: what is a posting? The above series ID is a posting. So whenever we say posting in the context of Prometheus TSDB, it refers to a series ID. But why posting? Here is my best guess: in the world of indexing the documents and its words with an inverted index, the document IDs are usually called a "posting" in the index. Here you can consider a series to be a document and a label-value pair of a series to be words in the document. Series ID -> document ID, document ID -> posting, series ID -> posting.
Each entry holds the label set of the series and references to all the chunks belonging to this series (the reference is the one from the chunks directory).
┌──────────────────────────────────────────────────────┐
│ len <uvarint> │
├──────────────────────────────────────────────────────┤
│ ┌──────────────────────────────────────────────────┐ │
│ │ labels count <uvarint64> │ │
│ ├──────────────────────────────────────────────────┤ │
│ │ ┌─────────────────────────────────────── ─────┐ │ │
│ │ │ ref(l_i.name) <uvarint32> │ │ │
│ │ ├────────────────────────────────────────────┤ │ │
│ │ │ ref(l_i.value) <uvarint32> │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ │ ... │ │
│ ├──────────────────────────────────────────────────┤ │
│ │ chunks count <uvarint64> │ │
│ ├──────────────────────────────────────────────────┤ │
│ │ ┌────────────────────────────────────────────┐ │ │
│ │ │ c_0.mint <varint64> │ │ │
│ │ ├────────────────────────────────────────────┤ │ │
│ │ │ c_0.maxt - c_0.mint <uvarint64> │ │ │
│ │ ├────────────────────────────────────────────┤ │ │
│ │ │ ref(c_0.data) <uvarint64> │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ │ ┌────────────────────────────────────────────┐ │ │
│ │ │ c_i.mint - c_i-1.maxt <uvarint64> │ │ │
│ │ ├────────────────────────────────────────────┤ │ │
│ │ │ c_i.maxt - c_i.mint <uvarint64> │ │ │
