mirror of
https://github.com/GreptimeTeam/greptimedb.git
synced 2026-11-14 06:46:56 +00:00
fix(mito2): identify series by their exact sparse key, not by tsid (#10330)
* fix(mito2): identify series by their exact sparse key, not by tsid `(table_id, tsid)` is a hash, so distinct series can share it: 2000 label values already produce 20 colliding pairs and a 36k-series SST keeps only 27856 distinct tsids. The columnar series writer silently merged such pairs (first label set kept, row counts added) and the samples writer either reported time_regressed or, with increasing timestamps, attached one series' samples to another's labels. Every one of those decisions used the hash as identity. The SST already stores the exact sparse primary key, so nothing on the storage side changes: `tsid`, the hash function and the key layout are untouched. What changes is that the artifacts, their readers and the prepared read path now decide identity on the canonical sparse key bytes, and `(table_id, tsid)` is only the coarse, order-preserving prefix used for ordering, pruning and bucketing. - `ExactSeriesKey` is the new identity type: equality, ordering and hashing are defined on the key bytes alone, `id()` is documented as a derived prefix that is never compared. `from_sparse_key` validates the entire key, not just the prefix, and bounds it at 8 KiB. - Series format v2 keeps one row per exact key; the descriptor layout and the existing per-row tags already express this once the writer aggregates by key. Coarse page/block/part bounds may now touch, so readers prune conservatively. - Samples format v2 records the owning key per block, appends a `__primary_key` column, and lets a key change restart timestamps and ordinals while the same key keeps the monotonic rules. Exact selection travels in `SamplesReadRequest.keys` next to the coarse `ids`. - The prepared path keys candidates by `ExactSeriesKey`, re-encodes identity from the artifact's own tags, associates samples through `__primary_key`, and uses the exact key for the merge key and the direct-label path. - Both manifests carry a mandatory `identity_encoding` discriminator. Artifacts without it are structurally valid and perfectly ordered yet may contain an already merged row, so they are rejected and rebuilt from SSTs; maintenance rotates them out instead of reusing them, and the local cache version drops the rebuildable store. There is no dual reader and no migration. Verified: `cargo nextest run -p mito2` 1824 passed, 2 skipped (three times, identical); `cargo nextest run -p mito-codec` 57 passed; clippy `-D warnings` and rustfmt clean. Tests pin the real captured pair (`host-020078` / `host-029838`, both retained with their own labels), the silent-loss variant with globally increasing timestamps, sixteen distinct keys under one forced tsid in both PK encodings, candidate isolation end to end, and rejection of a frozen v1 fixture. Signed-off-by: discord9 <discord9@163.com> * fix(mito2): close the exact-identity review findings - The two-phase series scan assumed candidate discovery had already enforced tag predicates through an "exact assigned-series set". An assignment is a `(table_id, tsid)` hash, so a colliding partner shares it without matching the predicate, and skipping tag filters returned its rows. Apply the predicates the precise filter already holds instead of assuming them. - `MAX_KEY_BYTES` was checked when reconstructing a key from an artifact but not when a writer built one, so the writer could publish a key the reader refused; a single 8 KiB label already encodes past the cap. Length is bounded by the containers that store a key (one key per block, descriptor, page and column limits), and long labels are real data, so drop the cap instead of enforcing it on both sides. - The `__primary_key` column copies one key per output row, but the read reservation budgeted a fixed 32 bytes per column and metadata pages did not count the per-block key heap: 8192 rows of 8 KiB keys allocated ~64 MiB against a ~1 MiB estimate. Budget the real bytes and count the heap. - `SamplesReadRequest` carried both `keys` and `ids`, which the only caller derived from the same candidates; selection is key-only now and the reader derives its coarse pruning set, so the two selectors cannot disagree. - Restore the unknown-field-codec projection coverage that the frozen v1 fixture used to carry, against a generated v2 artifact, and assert v1 rejection through the compatibility property rather than a field name. - Drop the redundant columnar increasing-timestamp test (that writer never reads timestamps) and correct the samples format doc: the five internal columns and `__primary_key` are non-null, value fields stay nullable. Verified: `cargo nextest run -p mito2` 1830 passed, 2 skipped; clippy `-D warnings` and rustfmt clean. Each new test was run with its fix reverted first: the two-phase test returned the colliding partner's rows, the long-key tests failed with "series key exceeds 8192 bytes", and the 4 MiB pool test read 8192 rows of 1 KiB keys while allocating ~8 MiB. Signed-off-by: discord9 <discord9@163.com> * fix(mito2): budget the exact key column exactly and drop stale skip-tag claims The read reservation charged the exact-key column its logical bytes, but the column was built with `BinaryArray::from_iter_values`, whose value buffer grows geometrically: measured on a 2135-row fixture it retained 4,989,432 bytes for 2,551,325 bytes of key data (1.956x), so a pool sized from the reservation was oversubscribed. `BinaryArray::new_repeated` allocates the offsets once and reserves the key bytes once (1.003x on the same fixture), which is what the reservation already assumes. The new test asserts the pool covers what the yielded batch retains, that the column keeps no growth slack, and that the reservation is released on drop; 2135 rows sits just past a doubling step so a growing builder fails it. Also make the documentation match the mechanism and the encoder: - `read/pruner.rs` still told readers the data phase skips tag filtering because candidate discovery had applied the predicates exactly once. Assignments are `(table_id, tsid)` hashes, so a colliding partner shares the identifier without matching the predicate, and the data phase does apply tag predicates. - The initial-window fallback test anchored on `reads[5]`, which is the second payload window; it now identifies the initial window by its payload-header offset and asserts the window spans 128 blocks, so a traversal change cannot quietly point it at another read. - The samples format descriptor sizes are recomputed from the encoder (`48 + 44 * C + key_length`): `224`, `45,236`, and with the leaf prefix `45,248`, each `+ key_length`; the page-fit sentence now states the enforced constraint (descriptor and leaf prefix inside one page) instead of claiming the widest descriptor always fits. Verified: `cargo nextest run -p mito2` 1831 passed, 2 skipped; clippy `-D warnings` and rustfmt clean. Signed-off-by: discord9 <discord9@163.com> * fix(mito2): budget each projected sample array's fixed overhead The read reservation charged per-row bytes plus the payload sizes, but not the fixed cost of the arrays it builds: Arrow's `get_array_memory_size` is the array struct plus its buffer capacities, so a one-row, key-only projection reserved `key_length + 72` for the 1344 bytes it actually retained. Add `ARRAY_HEADER_BYTES` (`size_of::<BinaryArray>()`) plus `BUFFER_ALIGNMENT` per projected column, as their own term, and cover the one-row key-only case in `exact_key_output_reservation_covers_the_retained_array`: the pool must cover the batch's reported memory size while it is alive and be empty after drop. Verified: `cargo nextest run -p mito2` 1831 passed, 2 skipped; clippy `-D warnings` and rustfmt clean. Signed-off-by: discord9 <discord9@163.com> * chore(mito2): pin the series-index cache token to 1 The branch inherits `2` from an earlier layout change. This branch is experimental and has no deployments, so it pins the token at `1`: whatever a different build wrote into the rebuildable local store is discarded on startup and rebuilt from the SSTs. The token is not the compatibility boundary on its own. Artifacts are rejected on open when they lack the mandatory `identity_encoding` discriminator, so a hash-only artifact is never served even when the reset has not run yet (a read that precedes it, or an artifact outside this cache). Signed-off-by: discord9 <discord9@163.com> * feat(mito2): keep exact series keys out of the samples descriptor page A block descriptor used to embed the whole exact key, and a descriptor shares a 65,536-byte metadata page with its leaf prefix. That put an accidental ceiling on key length: 65,344 bytes with no value fields, 65,300 with one, and 20,288 with 1,024 fields. Valid SST primary keys can be longer, so their samples builds failed with "sample descriptor exceeds leaf limit" even though the key itself was accepted. The key now lives in a per-part key area and the descriptor carries a reference: part = [header 16][column payloads][key area][leaf pages / root][trailer 120] trailer = root ref 72 | depth 4 | version 4 | file_length 8 | payload_end 8 | key_end 8 (body 104), magic 8, XXH64 over the preceding 112 bytes 8 block = table_id u32 | tsid u64 | key offset u64 | key length u32 | min i64 | max i64 | ordinal u64 | rows u32 | columns u32 (56 bytes, fixed) Descriptor size no longer depends on key length (232 bytes for one present field, 45,244 for 1,024), so the ceilings are gone. Keys are written once per series, back to back in block order and without padding, so an equal reference means "same exact series" and a key may exceed any page limit; a reference is validated against `key_end` with a checked add, an overlap between consecutive new series is rejected, and a resolved key whose `(table_id, tsid)` disagrees with its descriptor is refused before any row is emitted. Reads stay lazy: only blocks that survive coarse selection fetch a key, at most once per series, merged per page and bounded by `MAX_READ_BYTES`, reused from the part's aligned cache when one is configured. Measured on the 260-series fixture: 260 per-series key reads before, 1 merged read after, with the fetched byte count asserted to be exactly the key bytes. The read contract also stops materializing one key per row: `SamplesBatch { batch, key }` carries the block's key once, the exact-key column is refused in projections, and `prepared` attributes a whole batch with a single binary search. For an 8,192-row block with a 1 KiB key the reservation drops from 8,388,608 B to 1,120 B (~7,490x), which is what lets a 4 MiB pool read it; `Candidate` keeps one owned copy of the key instead of two, and `retained_rows` is gone in favour of the `prepare_retained_rows` metric. No version bump: the layout changes in place. Artifacts written by an earlier shape of this branch (112-byte trailer, or keys inside descriptors) are unreadable and must be rebuilt, which the cache token and the required `identity_encoding` discriminator already bring about. One user-visible cost: keys retained by a scan are charged to its memory pool, so a scan that used to fit in 16 KiB needs ~14 KB more for 128 series. The fixture that pinned 16 KiB is raised to 64 KiB with the arithmetic in a comment. Verified: `cargo test -p mito2 --lib -- series_index::samples series_scan::prepared series_index::identity` 72 passed / 0 failed / 2 ignored on six consecutive runs; clippy `-D warnings` and rustfmt clean. Failing-before evidence, both produced by reverting the relevant hunk: - restoring `48 + 44 * C + key_len` fails the >64 KiB round-trip with "sample descriptor exceeds leaf limit"; - the 260-series coalescing assertion fails against per-series reads. Signed-off-by: discord9 <discord9@163.com> * test(mito2): assert the duplicate-drop on the preparation's own rows `overlapping_buckets_retain_only_new_label_rows` compared the deltas of two process-global counters (`prepare_unique_series`, `prepare_retained_rows`). Every preparation in the process increments both, so under parallel test execution the two deltas do not pair up and the test failed intermittently - it always passed in isolation. Assert the property the pairing was standing in for, on this preparation's own rows: read every series of the prepared source and require that no series keeps two rows at the same timestamp. A surviving duplicate from the overlapping bucket would repeat a timestamp; the counters can no longer make the result depend on what else the test binary happens to be running. Signed-off-by: discord9 <discord9@163.com> * test(mito2): drop the now-unused preparation counter helper The duplicate-drop assertion no longer reads the process-global counters, so `prepared_count` has no callers and `-D warnings` rejects the dead function in `cargo check` and `cargo clippy` CI. Remove it. Signed-off-by: discord9 <discord9@163.com> * fix(mito2): scope key references per part, bound key reads, checksum keys Review of the key-area change found three defects that ordinary data can hit and three smaller ones. All six are fixed with a test that fails beforehand. Key references are file-local offsets, and the ordering state kept them across parts: a series continuing into another part looked like an overlapping reference (or a new series with a non-zero ordinal), and two different series in structurally identical parts could be mistaken for one because their offsets and lengths coincided. Reference ordering is now scoped to a part; continuation across a boundary is decided on the exact key bytes plus time/ordinal, and the loaded keys must be nondecreasing, a repeated key must continue its series. A key prefetch fetched the whole window and only then grew the reservation, and the per-key fallback charged after its read, so a small pool could read and assemble a page of long keys before reporting exhaustion. The peak (fetched entry + owned key + alignment/assembly) is now reserved before the read; on refusal the reader drops alignment first, then halves the window, and a returned key is cloned only after the block reserved for it. A test asserts that an unpayable prefetch issues no key read at all. The key area had no integrity check, so flipping one byte of a label - leaving framing and `(table_id, tsid)` intact - could silently change which series a block belongs to. Every stored key now carries its XXH64, excluded from the reference length and verified when fetched. The manifest discriminator now names the layout (`exact-sparse-key-area` for samples; the series index keeps `exact-sparse-key`), so an artifact with inline keys is refused at open instead of being marked reusable by maintenance and failing later at a part read. No version number changes anywhere. A key copy is also counted correctly when a series spans a metadata rollover: the "first copy of this part" decision is taken after the rollover, not before. Verified: `series_index::samples` 51 passed, `series_scan::prepared` 27 passed, `series_index::identity` 4 passed; clippy `-D warnings` and rustfmt clean. Signed-off-by: discord9 <discord9@163.com> * fix(mito2): charge the key peak on every path and chain the order check Four follow-ups from the confirmation review of the key-area change. The per-key fallback reserved only the retained and stored bytes and then fetched through the aligned payload cache, whose aligned/assembly peak it never charged, so a leftover key could read a whole aligned block the pool had not paid for. It now reserves the same peak as the page prefetch: aligned budget first, and on refusal an explicitly cache-less reader with the unaligned peak, before any read. The key carried across a part boundary was a deep copy taken while the per-part reservation was being released, so it lived on uncharged until the next part. It is now held by its own pool consumer, charged before the copy and released when it is replaced or when the boundary falls behind the scan. The content-order check was chained over the last *traversed* block, which an unloaded block overwrote, so a block pruned by selection could hide reversed or restarted keys - and could drop the boundary key entirely when a part ended on a pruned block. It is now chained over the last *loaded* key, with adjacency taken from "the last traversed block is the last loaded one", so a gap still requires increasing order and a strictly greater ordinal but not exactly +1. Finally, the key-area read counter in the tests only saw reads fully inside the key area, missing aligned reads that start before it; it now counts reads that intersect, and the cached-read test is documented as a cache-behaviour guard rather than a failing-before proof for the budget work. Verified: `series_index::samples` 55 passed, `series_scan::prepared` 27 passed, `series_index::identity` 4 passed; clippy `-D warnings` and rustfmt clean. Failing-before evidence: a 16384-byte key-area read issued with only 15080 reserved; a boundary key with 0 of its 1195 bytes charged; reversed keys hidden behind a skipped block; and a pruned block hiding the boundary key. Signed-off-by: discord9 <discord9@163.com> * docs(mito2): qualify the ordinal rule to one exact key The samples format said a loaded key's ordinal must still grow, which reads as if ordinals had to increase across different series too. They do not: only an equal exact key continues, an adjacent block of that key requires +1, and a different key starts at zero. Signed-off-by: discord9 <discord9@163.com> --------- Signed-off-by: discord9 <discord9@163.com>
This commit is contained in:
+147
-38
@@ -1,7 +1,22 @@
|
||||
# Mito columnar samples format
|
||||
|
||||
This document specifies the version 1 on-disk layout, encodings, ordering rules,
|
||||
and validation limits for samples artifacts. It shares artifact conventions with
|
||||
and validation limits for samples artifacts. The version number stays 1: the
|
||||
exact-identity change is enforced by the mandatory `identity_encoding` manifest
|
||||
discriminator, not by a version bump.
|
||||
|
||||
Every block names the exact series identity (the canonical sparse primary key), which
|
||||
the part stores once per series in its [key area](#key-area), followed by that copy's
|
||||
checksum.
|
||||
An artifact written before exact identity has the same version number and recorded
|
||||
only `(table_id, tsid)`, a hash; it can be structurally valid and perfectly ordered
|
||||
while two distinct series were merged. Nothing in such an artifact proves
|
||||
otherwise, so a manifest without the `identity_encoding` discriminator is
|
||||
rejected, and its artifact rebuilt from SSTs; there is no dual reader. The same
|
||||
applies to an artifact of the earlier layout of this format, whose keys sat inside
|
||||
the block descriptors: its `identity_encoding` value is the older
|
||||
`exact-sparse-key`, so it is refused when the manifest is opened rather than when a
|
||||
part is first read. It shares artifact conventions with
|
||||
[Mito columnar series format](mito-series.md).
|
||||
|
||||
Samples store nullable Float64 fields, timestamps, sequences, and operation types
|
||||
@@ -51,6 +66,7 @@ generation is unique for each build. A manifest contains:
|
||||
| --- | --- |
|
||||
| `format` | String `columnar-samples` |
|
||||
| `version` | Integer 1 |
|
||||
| `identity_encoding` | String `exact-sparse-key-area`; required, never defaulted. It names the identity *and* the layout that stores it, so `exact-sparse-key` (keys inline in the block descriptors) is different and not readable |
|
||||
| `region_id` | Physical region ID, u64 |
|
||||
| `source_file_id` | Artifact owner ID matching `SamplesEntry.file_id`; the SST ID for legacy artifacts, or the generation ID for daily artifacts |
|
||||
| `generation` | `FileId` matching the catalog artifact generation |
|
||||
@@ -72,8 +88,14 @@ including the internal names below. Tags are not sample fields and are not store
|
||||
|
||||
The stored logical Arrow schema is `__table_id: UInt32`, `__tsid: UInt64`, the
|
||||
timestamp column with its recorded unit, `__sequence: UInt64`, `__op_type: UInt8`,
|
||||
then nullable Float64 fields in manifest order. The first five columns are
|
||||
non-null. No `__primary_key` or tag columns are stored.
|
||||
then nullable Float64 fields in manifest order, and finally
|
||||
`__primary_key: Binary` naming the exact key of the block that produced each row.
|
||||
The five internal columns and `__primary_key` are non-null; value fields stay
|
||||
nullable, as the manifest declares them. `__table_id`/`__tsid` are the coarse,
|
||||
hash-based prefix of that key. Tag columns are not stored; the exact key is the
|
||||
identity. A reader returns that key once per output batch — each block belongs to
|
||||
exactly one series — instead of materializing the column per row, and rejects a
|
||||
request that projects it.
|
||||
|
||||
Each `files` entry contains `part_id: u32`, `file_size: u64`, `num_rows: u64`,
|
||||
`num_blocks: u64`, `first`, `last`, `min_timestamp: i64`, and `max_timestamp: i64`.
|
||||
@@ -108,10 +130,13 @@ the manifest.
|
||||
│ Block 0: time, sequence, operation, fields │
|
||||
│ Block 1: ... │
|
||||
├──────────────────────────────────────────────┤ ← payload_end
|
||||
│ Key area: one exact series key per series, │
|
||||
│ back to back, not page bounded │
|
||||
├──────────────────────────────────────────────┤ ← key_end
|
||||
│ Leaf pages (one is the root at depth 1) │
|
||||
│ Optional internal root (depth 2) │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ Trailer 112 bytes │
|
||||
│ Trailer 120 bytes │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -130,8 +155,12 @@ Reserved fields must be zero, and decoded structures must have no trailing bytes
|
||||
|
||||
### Column payloads and row semantics
|
||||
|
||||
Each block contains exactly one `(table_id, tsid)`. Identity is stored once in
|
||||
its descriptor. Timestamp, sequence, operation type, and each present value field
|
||||
Each block contains exactly one series and names it by that series' exact canonical
|
||||
sparse primary key, which is stored once per series in the part's [key area](#key-area)
|
||||
and referenced by the block descriptor. The key is the SST's own encoding of
|
||||
`(table_id, tsid, label column-id/value pairs sorted by name, non-null only)`, and
|
||||
`(table_id, tsid)` is only its order-preserving prefix. Two distinct series may
|
||||
share a prefix. Timestamp, sequence, operation type, and each present value field
|
||||
are independent chunks; their references are in the metadata, not inline headers.
|
||||
Emit chunks in that order, with value fields following manifest order.
|
||||
|
||||
@@ -139,13 +168,17 @@ Rows preserve delete markers, duplicate timestamps, and equal-key ties, includin
|
||||
conflicting values at the same sequence. The format does not encode a deduplicated
|
||||
or snapshot-filtered view.
|
||||
|
||||
Identifiers are nondecreasing, timestamps are nondecreasing within an identifier,
|
||||
and sequences are nonincreasing within equal timestamps, including across parts.
|
||||
Equal ordering keys retain input order; operation type is not a sorting key.
|
||||
|
||||
`(table_id, tsid)` is the complete identity under the uniqueness assumption.
|
||||
Equal TSIDs in different tables are distinct. Exact sparse primary keys, tags,
|
||||
and collision discriminators are not stored.
|
||||
Block keys are nondecreasing in byte-lexicographic order, timestamps are
|
||||
nondecreasing within one exact key, and sequences are nonincreasing within equal
|
||||
timestamps, including across parts. A change of exact key restarts timestamps and
|
||||
ordinals, even when the coarse `(table_id, tsid)` prefix is unchanged. Equal
|
||||
ordering keys retain input order; operation type is not a sorting key. The key area
|
||||
follows the same order, one copy per series. Block keys never decrease, so a key that
|
||||
is read must not sort before the key read before it; a repeated key must continue its
|
||||
timestamps and ordinals rather than restart them. Because key bytes are read lazily,
|
||||
the descriptor-only checks use the `(table_id, tsid)` prefix and the order, disjointness,
|
||||
and reuse of key references inside one part, and only the keys a request actually loads
|
||||
are checked for byte order and continuation.
|
||||
|
||||
Blocks split on an identity change or before the next row would exceed a packing
|
||||
limit. There is no hourly split and no padding of short blocks. The default row
|
||||
@@ -187,6 +220,56 @@ offsets to 4 GiB. Verify the checksum before decompression; require exactly the
|
||||
declared decoded length, with bounded output even if a codec frame claims more.
|
||||
Checksums detect accidental corruption, not malicious modification.
|
||||
|
||||
### Key area
|
||||
|
||||
The exact series keys occupy their own region between the column payloads and the
|
||||
metadata pages: `[payload_end, key_end)`. Keys are written back to back with no
|
||||
padding and no alignment, in block order, and every block of one series shares one
|
||||
copy of that series' key. The area therefore holds exactly one key per distinct
|
||||
series of the part, ordered by exact key. A descriptor's key reference is unique per
|
||||
series, ordered, and disjoint from every other series' copy, which is why an equal
|
||||
reference means "the same exact series" without reading the bytes.
|
||||
|
||||
A key's length is bounded by the part, not by a page or a chunk. Version 1 puts no
|
||||
page inside the key area, so one key may be far longer than the 65,536-byte page
|
||||
limit; the `u32` length in a descriptor is validated against `key_end`, so a
|
||||
corrupt length can never address bytes outside the part.
|
||||
|
||||
Each copy is `[key bytes][checksum]`, where the checksum is the u64 XXH64 of those
|
||||
key bytes with seed zero, stored little-endian. A reference's `length` counts the key
|
||||
bytes only, so the checksum is always the eight bytes after `offset + length` and is
|
||||
never part of what the reference names. There is no per-key compression and no other
|
||||
framing: the key bytes between them are stored exactly as the SST encodes them, and
|
||||
their own primary-key framing (a fixed reserved prefix plus dense or sparse label
|
||||
encoding) is what decodes them. The checksum is what protects them. A key previously
|
||||
sat inside a metadata page, whose stored bytes the page reference already checksummed;
|
||||
without a per-key checksum, flipping one label byte would keep the framing and the
|
||||
`(table_id, tsid)` prefix valid and silently attribute the block's rows to another
|
||||
series.
|
||||
|
||||
Reading stays lazy. A reader reads only the byte ranges of the keys of the blocks a
|
||||
request selects, at most once per series, and validates a fetched key's checksum, its
|
||||
framing, and its `(table_id, tsid)` prefix against the descriptor that referenced it. A
|
||||
reference is rejected as soon as its descriptor is decoded — before any row is emitted
|
||||
— when it starts before `payload_end`, when `offset + length + 8` exceeds `key_end`,
|
||||
when the length is zero, or when two consecutive descriptors of one part that do not
|
||||
name one series overlap.
|
||||
|
||||
A key reference is an offset into one part's key area, so the references of two parts
|
||||
are unrelated numbers and are never compared. Whether one exact series continues in
|
||||
another part is decided on the exact key bytes: the last key a request loaded from one
|
||||
part is compared with the first it loads from the next, and an equal key must continue
|
||||
timestamps and ordinals, while a greater key starts a new series at ordinal zero.
|
||||
|
||||
Because keys are only read for blocks a request selects, this content check covers the
|
||||
keys actually loaded: unvisited ranges are validated structurally, not by content. It
|
||||
is chained, though, so a selection that skips a block, a page, or a whole part cannot
|
||||
hide the two keys around the gap: the check compares each loaded key with the previous
|
||||
loaded key, not with the previous traversed block. Loaded keys must be nondecreasing.
|
||||
Within one exact key the ordinal must strictly increase, and only immediately adjacent
|
||||
blocks of that same key require it to be exactly one greater; a different key starts a
|
||||
new series at ordinal zero, so no ordering between the two series' ordinals is required.
|
||||
|
||||
### Metadata pages
|
||||
|
||||
A leaf describes blocks. An internal page references leaves. The root is either
|
||||
@@ -208,16 +291,20 @@ Leaf (kind 0): Internal root (kind 1):
|
||||
```
|
||||
|
||||
Pages are nonempty and limited to 65,536 decoded bytes, including the 12-byte
|
||||
prefix. Descriptors never straddle pages. Unlike series label descriptors,
|
||||
sample descriptors have no variable-size strings, and even the widest supported
|
||||
descriptor fits in one page. No oversized-leaf exception is needed.
|
||||
prefix. Descriptors never straddle pages, and every descriptor is fixed-width because
|
||||
the key it names is not stored in it. The writer requires the 12-byte leaf prefix and
|
||||
one descriptor to fit in a configured page, which for the 1,024-field maximum is
|
||||
`12 + 56 + 44 * 1,027 = 45,256` decoded bytes. The page limit is a bound on
|
||||
descriptors only: it no longer bounds the length of a series key.
|
||||
|
||||
#### Block descriptor
|
||||
|
||||
| Field | Bytes | Meaning |
|
||||
| --- | ---: | --- |
|
||||
| Table ID | 4 | Constant for this block |
|
||||
| TSID | 8 | Constant for this block |
|
||||
| Table ID | 4 | Coarse prefix of the constant key in this block |
|
||||
| TSID | 8 | Coarse prefix of the constant key in this block |
|
||||
| Key offset | 8 | Absolute offset of the block's exact key in the part's key area |
|
||||
| Key length | 4 | Nonzero; the key's `(table_id, tsid)` prefix must equal the two fields above |
|
||||
| Minimum timestamp | 8 | Signed i64, first row's timestamp |
|
||||
| Maximum timestamp | 8 | Signed i64, last row's timestamp |
|
||||
| Block ordinal | 8 | Per-identifier ordinal across the artifact |
|
||||
@@ -225,10 +312,14 @@ descriptor fits in one page. No oversized-leaf exception is needed.
|
||||
| Column count | 4 | Number of following column descriptors |
|
||||
| Column descriptors | 44 each | Defined below |
|
||||
|
||||
The fixed prefix is 44 bytes. Ordinals start at zero for each identity and
|
||||
increase by one across block and part boundaries. Blocks sort lexicographically
|
||||
by `(table_id, tsid, min_timestamp, ordinal)`. Adjacent blocks of the same series
|
||||
satisfy `previous.max_timestamp <= next.min_timestamp`; equality is permitted.
|
||||
The fixed prefix is 56 bytes and does not depend on the key: `4 table ID + 8 TSID +
|
||||
8 key offset + 4 key length + 8 minimum timestamp + 8 maximum timestamp + 8 ordinal +
|
||||
4 row count + 4 column count`. Ordinals start at zero for each exact key and increase
|
||||
by one across block and part boundaries while that key continues. Blocks sort by exact
|
||||
key. Adjacent blocks of the same exact key share one key reference and satisfy
|
||||
`previous.max_timestamp <= next.min_timestamp`; equality is permitted. Blocks of
|
||||
different keys have no timestamp relation at all, and a new series' key copy starts at
|
||||
or after the end of the previous copy.
|
||||
|
||||
Each column descriptor is:
|
||||
|
||||
@@ -247,10 +338,15 @@ all rows null is omitted and synthesized as null by the reader; present value
|
||||
chunks have `null_count < row_count`. No value statistics are stored.
|
||||
|
||||
A block with `P` present fields has `C = 3 + P` chunks and a descriptor of
|
||||
`44 + 44 * C` bytes. This is 220 bytes for one present field and 45,232 bytes for
|
||||
1,024 present fields. The leaf prefix makes the latter 45,244 bytes. All chunks
|
||||
are physically ordered and disjoint in the column area, across blocks as well
|
||||
as within a block. There is no inline block header or block-wide compression.
|
||||
`56 + 44 * C` bytes, whatever its key's length. For one present field that is
|
||||
`56 + 44 * 4 = 232` bytes; at the 1,024-field maximum it is `56 + 44 * 1,027 = 45,244`
|
||||
bytes, and the 12-byte leaf prefix makes that `45,256`. Key length is therefore not
|
||||
part of any page budget: a part whose exact key is far longer than a page produces the
|
||||
same descriptors, and the earlier `base + key_length` ceiling — 65,344 bytes with no
|
||||
value field, 65,300 with one, and 20,288 at the 1,024-field maximum — no longer
|
||||
exists. All chunks are physically ordered and disjoint in the column area, across
|
||||
blocks as well as within a block, and the key area follows the column area. There is no
|
||||
inline block header or block-wide compression.
|
||||
|
||||
#### Page reference and time bounds
|
||||
|
||||
@@ -334,15 +430,17 @@ The series-index format has no sample timestamps or time bounds.
|
||||
| Index depth | 4 | 1 or 2 |
|
||||
| File version | 4 | 1 |
|
||||
| File length | 8 | Includes header and trailer |
|
||||
| `payload_end` | 8 | End of column area, start of metadata area |
|
||||
| `payload_end` | 8 | End of column area, start of key area |
|
||||
| `key_end` | 8 | End of key area, start of metadata area |
|
||||
| Magic | 8 | ASCII `GTSAMPLE` |
|
||||
| Trailer checksum | 8 | XXH64 of the preceding 104 trailer bytes, seed zero |
|
||||
| Trailer checksum | 8 | XXH64 of the preceding 112 trailer bytes (the 104-byte body and the magic), seed zero |
|
||||
|
||||
The trailer is 112 bytes. Locate it using the recorded object size without a stat
|
||||
The trailer is 120 bytes. Locate it using the recorded object size without a stat
|
||||
request. Validate the checksum, magic, version, length, depth, area boundaries,
|
||||
root bounds against the manifest part descriptor, and root end position before
|
||||
traversal. Column references must lie in `[16, payload_end)`; page references lie
|
||||
in `[payload_end, file_length - 112)`. The header and trailer versions must agree.
|
||||
traversal. Column references must lie in `[16, payload_end)`; key references must lie
|
||||
in `[payload_end, key_end)`; page references lie in `[key_end, file_length - 120)`.
|
||||
The header and trailer versions must agree.
|
||||
|
||||
## Encodings and compression
|
||||
|
||||
@@ -434,7 +532,9 @@ field values.
|
||||
|
||||
An individual chunk has at most 1 MiB of encoded bytes after decompression and
|
||||
1 MiB plus 64 KiB of stored bytes. Pages have at most 65,536 decoded and 131,072
|
||||
stored bytes. Bound sizes before allocating, fetching, or decompressing. Validate
|
||||
stored bytes. Series keys are stored in the key area rather than in a page or a
|
||||
chunk, so a key's length is bounded by the part and by its `u32` reference length,
|
||||
not by `PAGE_LIMIT`; a key longer than a page is a supported part of this version. Bound sizes before allocating, fetching, or decompressing. Validate
|
||||
counts against enclosing bytes before allocating descriptor vectors; row counts
|
||||
must also satisfy the decoded-block formula. Null output materialization remains
|
||||
subject to the same block bound even when chunks are omitted.
|
||||
@@ -464,12 +564,21 @@ contain several generations with disjoint source sets (at most 64 SSTs per merge
|
||||
and each generation may contain multiple bounded part files. Source Parquet files
|
||||
are never rewritten.
|
||||
|
||||
The local series-index cache layout version is 2. On upgrade from layout 1,
|
||||
startup resets the rebuildable index cache before opening regions; ordinary SST
|
||||
reads remain available while artifacts rebuild. Rolling back to a layout-1 binary
|
||||
likewise resets the incompatible cache. Allow for rebuild time and I/O in either
|
||||
direction; do not reuse daily catalogs with an older reader outside this startup
|
||||
check. Existing per-SST catalog entries remain readable by the new reader.
|
||||
This branch has no deployments, so the local series-index cache token is pinned to
|
||||
`1`: a cache written by any other build is discarded on startup and rebuilt from
|
||||
the SSTs before regions open, and ordinary SST reads remain available meanwhile.
|
||||
That is the only cache migration this layout has. An artifact written by an earlier
|
||||
build of this branch — keys inside the block descriptors, a key area without stored
|
||||
checksums, a 112-byte trailer without `key_end` — carries a different
|
||||
`identity_encoding` value, `exact-sparse-key` instead of `exact-sparse-key-area`, so
|
||||
it is refused when its manifest is opened. Maintenance treats that refusal as "not
|
||||
reusable" and rebuilds the artifact, so an old artifact is never marked reusable and
|
||||
then fails on the first part read. A part reached without that check would also be
|
||||
rejected: the reader locates a trailer from the recorded object size, reads the last
|
||||
120 bytes of such a part, and refuses the shifted frame instead of decoding it.
|
||||
Allow for rebuild time and I/O in either direction; do not reuse daily catalogs with
|
||||
an older reader outside this startup check. Existing per-SST catalog entries remain
|
||||
readable by the new reader.
|
||||
|
||||
A prepared scan uses a grouped artifact only when all of its sources belong to
|
||||
that scan's assignment or were excluded solely by its time filter. Partial coverage
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
# Mito columnar series format
|
||||
|
||||
This document specifies the version 1 on-disk layout, encodings, ordering rules,
|
||||
and validation limits for series-index artifacts. For samples, see
|
||||
and validation limits for series-index artifacts. The version number stays 1: the
|
||||
exact-identity change is enforced by the mandatory `identity_encoding` manifest
|
||||
discriminator, not by a version bump.
|
||||
|
||||
Rows are keyed by the exact series identity. A row is one distinct canonical
|
||||
sparse primary key, so two series that share `(table_id, tsid)` are two rows with
|
||||
their own labels. An artifact written before exact identity has the same version
|
||||
number and may contain an already merged row that cannot be told apart from a
|
||||
complete one; nothing else in the artifact distinguishes them. A manifest that
|
||||
does not carry `identity_encoding` is therefore rejected, and its artifact rebuilt
|
||||
from SSTs; there is no dual reader. For samples, see
|
||||
[Mito columnar samples format](mito-samples.md).
|
||||
|
||||
## Artifact manifest
|
||||
@@ -25,6 +35,7 @@ One immutable artifact owns the following objects under a unique generation:
|
||||
| --- | --- |
|
||||
| `format` | String `columnar-series` |
|
||||
| `version` | Integer 1 |
|
||||
| `identity_encoding` | String `exact-sparse-key`; required, never defaulted, and the only thing that separates exact-identity artifacts from older ones |
|
||||
| `region_id` | Physical region ID |
|
||||
| `generation` | Index UUID, using the existing `FileId` serialization |
|
||||
| `tags` | Ordered objects with `column_id` and `name`; all are nullable UTF-8 |
|
||||
@@ -101,11 +112,15 @@ as an independent encoded and compressed chunk, then continue with the next
|
||||
block. Chunks have no inline header: their locations, lengths, codecs, and
|
||||
checksums are recorded in the later [metadata pages](#metadata-pages).
|
||||
|
||||
`(__table_id, __tsid)` is the complete identifier **under the current uniqueness
|
||||
assumption**. Equal TSIDs in different tables are distinct. Records are ordered
|
||||
lexicographically by this pair and identifiers do not repeat after aggregation.
|
||||
There is no stored `__primary_key` column. Different label sets may hash to the
|
||||
same TSID within a table; version 1 stores no collision discriminator.
|
||||
`(__table_id, __tsid)` is the coarse, hash-based prefix of the identity, not the
|
||||
identity. Equal TSIDs in different tables are distinct, and different label sets
|
||||
inside one table may hash to the same TSID. Records are ordered by the canonical
|
||||
sparse key, which groups equal prefixes together, and a record repeats only for
|
||||
the same exact key. The block's `first`/`last` identifier bounds stay coarse, so
|
||||
they overlap for colliding series and only ever over-select.
|
||||
|
||||
There is no stored `__primary_key` column: identity is reconstructed by encoding
|
||||
the record's own tags in the artifact order, which reproduces the SST key.
|
||||
|
||||
The row count records input rows aggregated for each identifier. Tags are constant
|
||||
for that identifier under the uniqueness assumption. The count is not necessarily
|
||||
|
||||
@@ -1883,6 +1883,127 @@ async fn check_two_phase_series_scan(
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one two-phase series scan over `filters` and returns `(tag_0, field_0, ts)` per row.
|
||||
async fn two_phase_sparse_rows(
|
||||
engine: &crate::engine::MitoEngine,
|
||||
region_id: RegionId,
|
||||
filters: Vec<datafusion_expr::Expr>,
|
||||
) -> Vec<(String, u64, i64)> {
|
||||
let request = ScanRequest {
|
||||
distribution: Some(TimeSeriesDistribution::PerSeries),
|
||||
filters,
|
||||
..Default::default()
|
||||
};
|
||||
let Scanner::Series(mut scanner) = engine.scanner(region_id, request).await.unwrap() else {
|
||||
panic!("sparse PerSeries request must use SeriesScan");
|
||||
};
|
||||
assert_eq!("two_phase", scanner.mode());
|
||||
let ranges = scanner
|
||||
.properties()
|
||||
.partitions
|
||||
.iter()
|
||||
.flatten()
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
scanner
|
||||
.prepare(
|
||||
PrepareRequest::default()
|
||||
.with_ranges(vec![ranges])
|
||||
.with_target_partitions(1),
|
||||
)
|
||||
.unwrap();
|
||||
let metrics_set = ExecutionPlanMetricsSet::default();
|
||||
let context = store_api::region_engine::QueryScanContext::default();
|
||||
let batches = scanner
|
||||
.scan_partition(&context, &metrics_set, 0)
|
||||
.unwrap()
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.unwrap();
|
||||
let mut rows = Vec::new();
|
||||
for batch in batches {
|
||||
let tags = batch.column_by_name("tag_0").unwrap();
|
||||
let fields = batch
|
||||
.column_by_name("field_0")
|
||||
.unwrap()
|
||||
.as_primitive::<UInt64Type>();
|
||||
let timestamps = batch
|
||||
.column_by_name("ts")
|
||||
.unwrap()
|
||||
.as_primitive::<TimestampMillisecondType>();
|
||||
for row in 0..batch.num_rows() {
|
||||
rows.push((
|
||||
datatypes::arrow_array::string_array_value_at_index(tags, row)
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
fields.value(row),
|
||||
timestamps.value(row),
|
||||
));
|
||||
}
|
||||
}
|
||||
rows.sort();
|
||||
rows
|
||||
}
|
||||
|
||||
/// `tsid` is a hash, so two series that share one are still two series. Candidate discovery
|
||||
/// collapses them into a single identifier, so the data read must apply the tag predicates
|
||||
/// instead of trusting the assignment as an exact series name.
|
||||
#[tokio::test]
|
||||
async fn test_two_phase_series_scan_never_reads_a_colliding_partner() {
|
||||
let mut env = TestEnv::with_prefix("test_two_phase_colliding_series").await;
|
||||
let engine = env
|
||||
.create_engine(MitoConfig {
|
||||
experimental_series_scan_v2: true,
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let region_id = RegionId::new(1, 1);
|
||||
let (metadata, row_schema) = create_two_phase_sparse_region(&engine, region_id).await;
|
||||
let rows = [
|
||||
// One tsid, two label sets.
|
||||
(
|
||||
new_sparse_primary_key(&["host-a", "x"], &metadata, 10, 7),
|
||||
1000,
|
||||
1,
|
||||
),
|
||||
(
|
||||
new_sparse_primary_key(&["host-b", "x"], &metadata, 10, 7),
|
||||
2000,
|
||||
2,
|
||||
),
|
||||
// A distinct tsid collides with nothing.
|
||||
(
|
||||
new_sparse_primary_key(&["host-c", "x"], &metadata, 10, 8),
|
||||
3000,
|
||||
3,
|
||||
),
|
||||
];
|
||||
put_sparse_rows(&engine, region_id, &row_schema, rows.to_vec()).await;
|
||||
test_util::flush_region(&engine, region_id, None).await;
|
||||
|
||||
// Selecting one owner must never return the colliding partner's rows.
|
||||
assert_eq!(
|
||||
vec![("host-a".to_string(), 1, 1000)],
|
||||
two_phase_sparse_rows(&engine, region_id, vec![col("tag_0").eq(lit("host-a"))]).await
|
||||
);
|
||||
// A series without a collision still answers its own selection.
|
||||
assert_eq!(
|
||||
vec![("host-c".to_string(), 3, 3000)],
|
||||
two_phase_sparse_rows(&engine, region_id, vec![col("tag_0").eq(lit("host-c"))]).await
|
||||
);
|
||||
// Without a predicate the path still serves the whole assignment, colliding partner
|
||||
// included: the fix applies the predicates rather than disabling the scan.
|
||||
assert_eq!(
|
||||
vec![
|
||||
("host-a".to_string(), 1, 1000),
|
||||
("host-b".to_string(), 2, 2000),
|
||||
("host-c".to_string(), 3, 3000),
|
||||
],
|
||||
two_phase_sparse_rows(&engine, region_id, Vec::new()).await
|
||||
);
|
||||
engine.stop().await.unwrap();
|
||||
}
|
||||
|
||||
/// Scans all partitions in round-robin fashion and returns rows sorted by (tag, ts).
|
||||
/// Also asserts that each series appears in only one partition.
|
||||
async fn collect_partition_rows_round_robin(
|
||||
|
||||
@@ -193,9 +193,12 @@ pub struct PrunerOptions {
|
||||
///
|
||||
/// The flag is baked into the cached [`FileRangeBuilder`], so it belongs to
|
||||
/// the whole scan. All partitions sharing this pruner must agree on it.
|
||||
/// Two-stage series scans disable this prefilter: candidate discovery applies
|
||||
/// tag predicates, then the data phase calls [`FileRange::precise_filter_flat`]
|
||||
/// with tag filtering skipped so the predicates are applied exactly once.
|
||||
/// Two-stage series scans disable this prefilter because candidate discovery already
|
||||
/// narrowed the assignment by tag predicate, so running it again would only repeat
|
||||
/// that work. This is a pruning-hint trade only: the assigned series are
|
||||
/// `(table_id, tsid)` hashes, so a series that collides with an assigned one shares
|
||||
/// its identifier without matching the predicate, and the data phase still applies
|
||||
/// the tag predicates through [`FileRange::precise_filter_flat`].
|
||||
pub enable_predicate_prefilter: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -272,9 +272,10 @@ impl SeriesReader {
|
||||
/// Creates a reader for the series assigned to one data partition.
|
||||
///
|
||||
/// `partition_pruner` must come from the candidate scanner's pruner, which
|
||||
/// has predicate prefiltering disabled. The file read path applies precise
|
||||
/// filters with tag filtering skipped because candidate discovery has already
|
||||
/// enforced tag predicates through the exact assigned-series set.
|
||||
/// has predicate prefiltering disabled. Candidate discovery narrows the assignment
|
||||
/// by tag predicate, but an assigned series is a `(table_id, tsid)` hash: a series
|
||||
/// that collides with it shares the identifier without matching the predicate. The
|
||||
/// file read path therefore still applies the tag predicates of its precise filter.
|
||||
///
|
||||
/// A single `range_semaphore` covers both the range-build phase and the final
|
||||
/// merge.
|
||||
@@ -547,10 +548,13 @@ fn scan_series_file_ranges(
|
||||
};
|
||||
|
||||
let num_rows_before_filter = record_batch.num_rows();
|
||||
// Tag predicates are applied here, not assumed: the assigned series are
|
||||
// `(table_id, tsid)` hashes, so a colliding partner shares the identifier
|
||||
// without matching the predicate that selected this series.
|
||||
let Some(record_batch) = range.precise_filter_flat(
|
||||
record_batch,
|
||||
range.pre_filter_mode().skip_fields(),
|
||||
true,
|
||||
false,
|
||||
)? else {
|
||||
reader_metrics.filter_metrics.rows_precise_filtered +=
|
||||
num_rows_before_filter;
|
||||
|
||||
@@ -63,8 +63,9 @@ use crate::read::series_scan::{RawScanPreparation, raw};
|
||||
use crate::read::stream::ScanBatch;
|
||||
use crate::series_index::artifact::ArtifactFormat;
|
||||
use crate::series_index::format::{
|
||||
self, FormatReader, SamplesFormatReader, SamplesReadRequest, SeriesReadRequest,
|
||||
self, FormatReader, SamplesBatch, SamplesFormatReader, SamplesReadRequest, SeriesReadRequest,
|
||||
};
|
||||
use crate::series_index::identity::ExactSeriesKey;
|
||||
use crate::series_index::{MetricSeriesId, ROW_COUNT_COLUMN};
|
||||
|
||||
fn invalid(reason: impl Into<String>) -> Error {
|
||||
@@ -313,65 +314,16 @@ fn predicate_conjuncts(predicate: Option<&Predicate>) -> Vec<&Expr> {
|
||||
}
|
||||
|
||||
struct Candidate {
|
||||
/// Coarse prefix of the exact key; only a hash, never identity.
|
||||
id: MetricSeriesId,
|
||||
/// The one owned key allocation: the region-qualified exact key.
|
||||
key: Buffer,
|
||||
/// The canonical sparse key, a view of `key`'s bytes. This is the identity every
|
||||
/// comparison and sample attribution uses, so it is stored once rather than once per
|
||||
/// representation.
|
||||
merge_key: Buffer,
|
||||
tags: HashMap<u32, Option<String>>,
|
||||
metric: Option<SeriesLabels>,
|
||||
metadata_row: Option<(Arc<RecordBatch>, usize)>,
|
||||
}
|
||||
|
||||
/// Checks every physical tag before retaining only rows that introduce new identities.
|
||||
fn compact_candidate_batch(
|
||||
batch: &RecordBatch,
|
||||
candidates: &BTreeMap<MetricSeriesId, Candidate>,
|
||||
tags: &[(u32, String)],
|
||||
) -> Result<RecordBatch> {
|
||||
let table = batch
|
||||
.column_by_name(DATA_SCHEMA_TABLE_ID_COLUMN_NAME)
|
||||
.and_then(|a| a.as_any().downcast_ref::<UInt32Array>())
|
||||
.ok_or_else(|| invalid("invalid table ID column"))?;
|
||||
let tsid = batch
|
||||
.column_by_name(DATA_SCHEMA_TSID_COLUMN_NAME)
|
||||
.and_then(|a| a.as_any().downcast_ref::<UInt64Array>())
|
||||
.ok_or_else(|| invalid("invalid TSID column"))?;
|
||||
let mut new_rows = HashMap::new();
|
||||
let mut selected = Vec::new();
|
||||
for row in 0..batch.num_rows() {
|
||||
let id = MetricSeriesId {
|
||||
table_id: table.value(row),
|
||||
tsid: tsid.value(row),
|
||||
};
|
||||
let previous = candidates
|
||||
.get(&id)
|
||||
.and_then(|c| c.metadata_row.as_ref())
|
||||
.map(|(batch, row)| (batch.as_ref(), *row))
|
||||
.or_else(|| new_rows.get(&id).map(|row| (batch, *row)));
|
||||
if let Some((previous, previous_row)) = previous {
|
||||
for (_, name) in tags {
|
||||
let current = batch
|
||||
.column_by_name(name)
|
||||
.and_then(|a| a.as_any().downcast_ref::<StringArray>())
|
||||
.ok_or_else(|| invalid("invalid series tag array"))?;
|
||||
let previous = previous
|
||||
.column_by_name(name)
|
||||
.and_then(|a| a.as_any().downcast_ref::<StringArray>())
|
||||
.ok_or_else(|| invalid("incompatible duplicate label metadata"))?;
|
||||
if previous.is_null(previous_row) != current.is_null(row)
|
||||
|| (!current.is_null(row) && previous.value(previous_row) != current.value(row))
|
||||
{
|
||||
return Err(invalid("conflicting labels for the same table ID and TSID"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
new_rows.insert(id, row);
|
||||
selected.push(row as u64);
|
||||
}
|
||||
}
|
||||
if selected.len() == batch.num_rows() {
|
||||
return Ok(batch.clone());
|
||||
}
|
||||
take_record_batch(batch, &UInt64Array::from(selected)).context(ComputeArrowSnafu)
|
||||
}
|
||||
|
||||
struct ColumnarPreparedSource {
|
||||
@@ -475,7 +427,8 @@ impl ColumnarPreparedSource {
|
||||
preparation_metrics.counts.insert("prepare_batch_series", 0);
|
||||
let direct_labels = sample_schema.is_some();
|
||||
let conjuncts = predicate_conjuncts(input.predicate_group().predicate());
|
||||
let mut candidates = BTreeMap::<MetricSeriesId, Candidate>::new();
|
||||
let mut retained_rows = 0_usize;
|
||||
let mut candidates = BTreeMap::<ExactSeriesKey, Candidate>::new();
|
||||
for id in indexes {
|
||||
let timer = preparation_metrics.timer("columnar_prepare_series_open_wall");
|
||||
let reader = crate::series_index::columnar::reader::ColumnarReader::open_cached(
|
||||
@@ -554,11 +507,6 @@ impl ColumnarPreparedSource {
|
||||
.or_default() += batch.num_rows() as u64;
|
||||
let started = Instant::now();
|
||||
let mut duplicate_rows = 0_u64;
|
||||
if direct_labels {
|
||||
let rows = batch.num_rows();
|
||||
batch = compact_candidate_batch(&batch, &candidates, &tags)?;
|
||||
duplicate_rows = (rows - batch.num_rows()) as u64;
|
||||
}
|
||||
let table = batch
|
||||
.column_by_name(DATA_SCHEMA_TABLE_ID_COLUMN_NAME)
|
||||
.ok_or_else(|| invalid("missing table ID column"))?
|
||||
@@ -583,7 +531,60 @@ impl ColumnarPreparedSource {
|
||||
Ok((*id, strings))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let shared_batch = direct_labels.then(|| Arc::new(batch.clone()));
|
||||
// Identity is the exact canonical sparse key. `tsid` is a hash, so rows
|
||||
// that differ only in labels are different series and must both survive:
|
||||
// re-encoding the artifact's own tags reproduces the SST key byte for byte.
|
||||
let codec = SparsePrimaryKeyCodec::schemaless();
|
||||
let mut offsets = mito_codec::row_converter::SparseOffsetsCache::new();
|
||||
let mut identity = Vec::new();
|
||||
let mut previous: Option<Vec<u8>> = None;
|
||||
let mut fresh = Vec::<(ExactSeriesKey, usize)>::new();
|
||||
let mut encoded_key_bytes = 0_u64;
|
||||
for row in 0..batch.num_rows() {
|
||||
identity.clear();
|
||||
codec
|
||||
.encode_internal(table.value(row), tsid.value(row), &mut identity)
|
||||
.map_err(|e| invalid(e.to_string()))?;
|
||||
codec
|
||||
.encode_to_vec(
|
||||
tag_arrays.iter().map(|(id, strings)| {
|
||||
(
|
||||
*id,
|
||||
if strings.is_null(row) {
|
||||
ValueRef::Null
|
||||
} else {
|
||||
ValueRef::String(strings.value(row))
|
||||
},
|
||||
)
|
||||
}),
|
||||
&mut identity,
|
||||
)
|
||||
.map_err(|e| invalid(e.to_string()))?;
|
||||
encoded_key_bytes += identity.len() as u64;
|
||||
// Adjacent duplicates are the common case and never allocate a key.
|
||||
if previous.as_deref() == Some(identity.as_slice())
|
||||
|| candidates.contains_key(identity.as_slice())
|
||||
{
|
||||
duplicate_rows += 1;
|
||||
continue;
|
||||
}
|
||||
let key = ExactSeriesKey::from_sparse_key(&identity, &mut offsets)?;
|
||||
previous = Some(identity.clone());
|
||||
fresh.push((key, row));
|
||||
}
|
||||
// Retain only rows that introduce a new identity; the retained batch backs
|
||||
// both explicit labels and the metadata rows of direct-label candidates.
|
||||
let retained = if fresh.len() == batch.num_rows() {
|
||||
batch.clone()
|
||||
} else {
|
||||
let selected: Vec<u64> = fresh.iter().map(|(_, row)| *row as u64).collect();
|
||||
take_record_batch(&batch, &UInt64Array::from(selected))
|
||||
.context(ComputeArrowSnafu)?
|
||||
};
|
||||
if direct_labels {
|
||||
// Only explicit-label chunks retain a batch to resolve labels from.
|
||||
retained_rows += retained.num_rows();
|
||||
}
|
||||
let label_batch = if direct_labels {
|
||||
let RawLabelSource::PrimaryKey { logical_tags, .. } = &labels else {
|
||||
return Err(invalid("expected bound labels"));
|
||||
@@ -597,7 +598,7 @@ impl ColumnarPreparedSource {
|
||||
.ok_or_else(|| invalid("missing bound tag"))?;
|
||||
Ok((
|
||||
tag.name.clone(),
|
||||
batch
|
||||
retained
|
||||
.schema()
|
||||
.index_of(&name.1)
|
||||
.map_err(|e| invalid(e.to_string()))?,
|
||||
@@ -609,7 +610,7 @@ impl ColumnarPreparedSource {
|
||||
.register(&input.scan_memory_pool),
|
||||
);
|
||||
let label_batch =
|
||||
SeriesLabelBatch::try_new(batch.clone(), columns, owner.clone())
|
||||
SeriesLabelBatch::try_new(retained.clone(), columns, owner.clone())
|
||||
.map_err(|e| invalid(e.to_string()))?;
|
||||
owner
|
||||
.try_grow(label_batch.estimated_size())
|
||||
@@ -618,56 +619,9 @@ impl ColumnarPreparedSource {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut encoded_key_bytes = 0_u64;
|
||||
let mut tag_values = 0_u64;
|
||||
let mut batch_series = 0_u64;
|
||||
for row in 0..batch.num_rows() {
|
||||
let id = MetricSeriesId {
|
||||
table_id: table.value(row),
|
||||
tsid: tsid.value(row),
|
||||
};
|
||||
// The flat merger still needs the compact sparse ID prefix.
|
||||
// Explicit-label chunks use a region-qualified identity below.
|
||||
let mut key = if direct_labels {
|
||||
metadata.region_id.as_u64().to_be_bytes().to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let identity_prefix_len = key.len();
|
||||
let codec = SparsePrimaryKeyCodec::schemaless();
|
||||
codec
|
||||
.encode_internal(id.table_id, id.tsid, &mut key)
|
||||
.map_err(|e| invalid(e.to_string()))?;
|
||||
let merge_key_len = key.len() - identity_prefix_len;
|
||||
if !direct_labels {
|
||||
codec
|
||||
.encode_to_vec(
|
||||
tag_arrays.iter().map(|(id, strings)| {
|
||||
(
|
||||
*id,
|
||||
if strings.is_null(row) {
|
||||
ValueRef::Null
|
||||
} else {
|
||||
ValueRef::String(strings.value(row))
|
||||
},
|
||||
)
|
||||
}),
|
||||
&mut key,
|
||||
)
|
||||
.map_err(|e| invalid(e.to_string()))?;
|
||||
encoded_key_bytes += key.len() as u64;
|
||||
}
|
||||
if let Some(previous) = candidates.get(&id) {
|
||||
duplicate_rows += 1;
|
||||
if previous.key.as_slice() != key.as_slice() {
|
||||
return Err(invalid(
|
||||
"conflicting labels for the same table ID and TSID",
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Encode borrowed Arrow strings first. Duplicate identities still undergo
|
||||
// the full label-conflict check, without allocating another owned tag map.
|
||||
for (retained_row, (exact, row)) in fresh.into_iter().enumerate() {
|
||||
let mut values = HashMap::new();
|
||||
if !direct_labels {
|
||||
values.reserve(tag_arrays.len());
|
||||
@@ -679,6 +633,24 @@ impl ColumnarPreparedSource {
|
||||
}
|
||||
}
|
||||
tag_values += values.len() as u64;
|
||||
// `merge_key` is always the exact key bytes. Explicit-label chunks keep
|
||||
// the region-qualified form as their `key`, so `primary_key` stays
|
||||
// region-scoped while identity and ordering stay exact.
|
||||
let key = if direct_labels {
|
||||
let mut buffer = Vec::with_capacity(8 + exact.bytes().len());
|
||||
buffer.extend_from_slice(&metadata.region_id.as_u64().to_be_bytes());
|
||||
buffer.extend_from_slice(exact.bytes());
|
||||
Buffer::from(buffer)
|
||||
} else {
|
||||
Buffer::from(exact.bytes().to_vec())
|
||||
};
|
||||
let merge_key = if direct_labels {
|
||||
key.slice_with_length(8, exact.bytes().len())
|
||||
} else {
|
||||
key.clone()
|
||||
};
|
||||
// The exact key is stored once: `merge_key` is a view of `key`, so the
|
||||
// reservation charges the single key allocation the candidate retains.
|
||||
let bytes = key.len()
|
||||
+ values.values().flatten().map(String::len).sum::<usize>()
|
||||
+ values.capacity() * (std::mem::size_of::<(u32, Option<String>)>() + 16)
|
||||
@@ -696,24 +668,22 @@ impl ColumnarPreparedSource {
|
||||
SeriesLabels::from_batch(
|
||||
Some(metric_name.clone()),
|
||||
label_batch.clone(),
|
||||
row,
|
||||
retained_row,
|
||||
)
|
||||
.map_err(|e| invalid(e.to_string()))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let key = Buffer::from(key);
|
||||
let merge_key = key.slice_with_length(identity_prefix_len, merge_key_len);
|
||||
let id = exact.id();
|
||||
candidates.insert(
|
||||
id,
|
||||
exact,
|
||||
Candidate {
|
||||
id,
|
||||
key,
|
||||
merge_key,
|
||||
tags: values,
|
||||
metric,
|
||||
metadata_row: shared_batch.as_ref().map(|batch| (batch.clone(), row)),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -740,6 +710,9 @@ impl ColumnarPreparedSource {
|
||||
preparation_metrics
|
||||
.counts
|
||||
.insert("prepare_unique_series", candidates.len() as u64);
|
||||
preparation_metrics
|
||||
.counts
|
||||
.insert("prepare_retained_rows", retained_rows as u64);
|
||||
let mut candidates: Vec<_> = candidates.into_values().collect();
|
||||
candidates.sort_unstable_by(|a, b| a.key.as_slice().cmp(b.key.as_slice()));
|
||||
preparation_metrics.add_elapsed("columnar_prepare_sort", started.elapsed());
|
||||
@@ -802,48 +775,59 @@ impl PreparedSeriesSource for ColumnarPreparedSource {
|
||||
let sample_schema = sample_schema.clone();
|
||||
sources.push(Box::pin(async_stream::try_stream! {
|
||||
let reservation = MemoryConsumer::new("ColumnarSamplesRead").register(&context.input.scan_memory_pool);
|
||||
// Account for the ID selection retained by the samples reader, including
|
||||
// B-tree node overhead, before allocating it.
|
||||
let selection_bytes = range.len() * (std::mem::size_of::<MetricSeriesId>() + 64);
|
||||
// Account for the selection retained by the samples reader, including
|
||||
// B-tree node overhead, before allocating it. The exact-key selection and
|
||||
// the coarse identifiers the reader derives from it both live for the
|
||||
// lifetime of the sample stream.
|
||||
let selection_bytes = candidates[range.clone()]
|
||||
.iter()
|
||||
.map(|candidate| {
|
||||
std::mem::size_of::<ExactSeriesKey>()
|
||||
+ candidate.merge_key.len()
|
||||
+ std::mem::size_of::<MetricSeriesId>()
|
||||
+ 64
|
||||
})
|
||||
.sum::<usize>();
|
||||
reservation.try_grow(selection_bytes).context(crate::error::ScanMemoryExhaustedSnafu)?;
|
||||
let ids = candidates[range.clone()].iter().map(|c| c.id).collect::<BTreeSet<_>>();
|
||||
// Exact ownership: a block is read only when its own key is selected,
|
||||
// never because a colliding partner shares the identifier. Coarse
|
||||
// pruning bounds are derived from these keys inside the reader.
|
||||
// The keys are built from the one key the candidate retains, so the
|
||||
// selection costs one owned copy per series rather than a second copy
|
||||
// alongside every candidate.
|
||||
let mut offsets = mito_codec::row_converter::SparseOffsetsCache::new();
|
||||
let keys = candidates[range.clone()]
|
||||
.iter()
|
||||
.map(|c| ExactSeriesKey::from_sparse_key(c.merge_key.as_slice(), &mut offsets))
|
||||
.collect::<Result<BTreeSet<_>>>()?;
|
||||
let mut stage_metrics = crate::metrics::ColumnarReadMetrics::default();
|
||||
let mut batches = reader.read(SamplesReadRequest {
|
||||
projection: sample_projection(&reader.schema(), &context),
|
||||
memory_pool: Some(context.input.scan_memory_pool.clone()),
|
||||
ids: Some(ids),
|
||||
keys: Some(keys),
|
||||
start,
|
||||
end,
|
||||
..Default::default()
|
||||
})?;
|
||||
while let Some(batch) = batches.try_next().await? {
|
||||
let tables = batch.column_by_name(DATA_SCHEMA_TABLE_ID_COLUMN_NAME)
|
||||
.and_then(|a| a.as_any().downcast_ref::<UInt32Array>())
|
||||
.ok_or_else(|| invalid("missing or invalid sample table ID column"))?;
|
||||
let tsids = batch.column_by_name(DATA_SCHEMA_TSID_COLUMN_NAME)
|
||||
.and_then(|a| a.as_any().downcast_ref::<UInt64Array>())
|
||||
.ok_or_else(|| invalid("missing or invalid sample TSID column"))?;
|
||||
let mut offset = 0;
|
||||
while offset < batch.num_rows() {
|
||||
let id = MetricSeriesId { table_id: tables.value(offset), tsid: tsids.value(offset) };
|
||||
// Sparse keys begin with the order-preserving table ID/TSID prefix.
|
||||
let ordinal = candidates[range.clone()].binary_search_by_key(&id, |c| c.id)
|
||||
.map_err(|_| invalid("sample contains an unselected series"))? + range.start;
|
||||
let mut end = offset + 1;
|
||||
while end < batch.num_rows() && tables.value(end) == id.table_id && tsids.value(end) == id.tsid {
|
||||
end += 1;
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
let flat = sample_to_flat(batch.slice(offset, end - offset), &candidates[ordinal], &context, sample_schema.as_ref())?;
|
||||
let flat = filter_visibility(flat, &context)?;
|
||||
stage_metrics.add_elapsed("columnar_sample_adapt", started.elapsed());
|
||||
offset = end;
|
||||
if flat.num_rows() == 0 { continue; }
|
||||
let bytes = flat.get_array_memory_size();
|
||||
reservation.try_grow(bytes).context(crate::error::ScanMemoryExhaustedSnafu)?;
|
||||
yield flat;
|
||||
reservation.shrink(bytes);
|
||||
}
|
||||
while let Some(samples) = batches.try_next().await? {
|
||||
// A block belongs to exactly one series, so the reader returns its
|
||||
// exact key once and the whole batch is attributed at once.
|
||||
let SamplesBatch { batch, key } = samples;
|
||||
let ordinal = candidates[range.clone()]
|
||||
.binary_search_by(|candidate| {
|
||||
candidate.merge_key.as_slice().cmp(key.bytes())
|
||||
})
|
||||
.map_err(|_| invalid("sample contains an unselected series"))?
|
||||
+ range.start;
|
||||
let started = std::time::Instant::now();
|
||||
let flat = sample_to_flat(batch, &candidates[ordinal], &context, sample_schema.as_ref())?;
|
||||
let flat = filter_visibility(flat, &context)?;
|
||||
stage_metrics.add_elapsed("columnar_sample_adapt", started.elapsed());
|
||||
if flat.num_rows() == 0 { continue; }
|
||||
let bytes = flat.get_array_memory_size();
|
||||
reservation.try_grow(bytes).context(crate::error::ScanMemoryExhaustedSnafu)?;
|
||||
yield flat;
|
||||
reservation.shrink(bytes);
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -1122,7 +1106,8 @@ fn filter_visibility(batch: RecordBatch, context: &StreamContext) -> Result<Reco
|
||||
}
|
||||
|
||||
/// The mapper includes projected and residual-filter columns. Identity/time and
|
||||
/// merge visibility columns are always needed by the native source adapter.
|
||||
/// merge visibility columns are always needed by the native source adapter. The exact
|
||||
/// series key is not projected: the samples reader returns it once per batch.
|
||||
fn sample_projection(
|
||||
schema: &datatypes::arrow::datatypes::Schema,
|
||||
context: &StreamContext,
|
||||
@@ -1132,7 +1117,10 @@ fn sample_projection(
|
||||
.fields()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(index, field)| *index < 5 || input.index_of(field.name()).is_ok())
|
||||
.filter(|(index, field)| {
|
||||
field.name() != store_api::storage::consts::PRIMARY_KEY_COLUMN_NAME
|
||||
&& (*index < 5 || input.index_of(field.name()).is_ok())
|
||||
})
|
||||
.map(|(_, field)| field.name().clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -134,6 +134,25 @@ async fn write_with_tags(
|
||||
tags: &[&str],
|
||||
) {
|
||||
let metadata = region.version().metadata.clone();
|
||||
write_primary_key(
|
||||
engine,
|
||||
region,
|
||||
new_sparse_primary_key(tags, &metadata, 10, tsid),
|
||||
timestamp,
|
||||
value,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Writes one row with an explicit sparse key, so a test can force two distinct
|
||||
/// series to share one tsid.
|
||||
async fn write_primary_key(
|
||||
engine: &MitoEngine,
|
||||
region: &MitoRegionRef,
|
||||
primary_key: Vec<u8>,
|
||||
timestamp: i64,
|
||||
value: Option<f64>,
|
||||
) {
|
||||
let mut schema = vec![
|
||||
ColumnSchema {
|
||||
column_name: "__primary_key".into(),
|
||||
@@ -149,7 +168,7 @@ async fn write_with_tags(
|
||||
},
|
||||
];
|
||||
let mut values = vec![
|
||||
ValueData::BinaryValue(new_sparse_primary_key(tags, &metadata, 10, tsid)),
|
||||
ValueData::BinaryValue(primary_key),
|
||||
ValueData::TimestampMillisecondValue(timestamp),
|
||||
];
|
||||
let request = if let Some(value) = value {
|
||||
@@ -1063,7 +1082,9 @@ impl SamplesFormatReader for SamplesReadRecorder {
|
||||
fn read(
|
||||
&self,
|
||||
request: SamplesReadRequest,
|
||||
) -> Result<futures::stream::BoxStream<'static, Result<RecordBatch>>> {
|
||||
) -> Result<
|
||||
futures::stream::BoxStream<'static, Result<crate::series_index::format::SamplesBatch>>,
|
||||
> {
|
||||
self.requests.lock().unwrap().push(request.clone());
|
||||
self.inner.read(request)
|
||||
}
|
||||
@@ -1162,13 +1183,21 @@ async fn ordinal_range_batches_sample_requests_and_pushes_time_bounds() {
|
||||
{
|
||||
let requests = requests.lock().unwrap();
|
||||
assert_eq!(source.samples.len(), requests.len());
|
||||
let ids = source
|
||||
// Selection is exact and key-only: the reader derives its coarse bounds from it.
|
||||
let keys = source
|
||||
.candidates
|
||||
.iter()
|
||||
.map(|c| c.id)
|
||||
.map(|c| c.merge_key.to_vec())
|
||||
.collect::<BTreeSet<_>>();
|
||||
for request in requests.iter() {
|
||||
assert_eq!(Some(&ids), request.ids.as_ref());
|
||||
let requested = request
|
||||
.keys
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|key| key.bytes().to_vec())
|
||||
.collect::<BTreeSet<_>>();
|
||||
assert_eq!(keys, requested);
|
||||
assert_eq!(Some(1001), request.start);
|
||||
assert_eq!(Some(1601), request.end);
|
||||
}
|
||||
@@ -1206,7 +1235,7 @@ async fn ordinal_range_batches_sample_requests_and_pushes_time_bounds() {
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|r| r.ids.as_ref().unwrap().len() == 2)
|
||||
.all(|r| r.keys.as_ref().unwrap().len() == 2)
|
||||
);
|
||||
engine.stop().await.unwrap();
|
||||
}
|
||||
@@ -1608,12 +1637,14 @@ async fn metadata_cache_reuses_structures_and_isolates_storage() {
|
||||
let reader = SamplesReader::open_cached(store.clone(), samples.clone(), context)
|
||||
.await
|
||||
.unwrap();
|
||||
// The exact key is returned with each batch, so it is never a projected column.
|
||||
let request = SamplesReadRequest {
|
||||
projection: reader
|
||||
.schema()
|
||||
.fields()
|
||||
.iter()
|
||||
.map(|field| field.name().clone())
|
||||
.filter(|name| name != store_api::storage::consts::PRIMARY_KEY_COLUMN_NAME)
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -2330,10 +2361,27 @@ async fn overlapping_buckets_retain_only_new_label_rows() {
|
||||
.unwrap();
|
||||
let prepared = scan.prepared.source.get().unwrap().as_ref().unwrap();
|
||||
assert_eq!(34, prepared.candidates.len());
|
||||
let mut retained = HashMap::new();
|
||||
// Duplicate rows from the overlapping bucket are dropped before labels are retained, so no
|
||||
// series keeps the same timestamp twice. Asserted on this preparation's own rows instead of
|
||||
// on the process-global counters: concurrent preparations in the same process move those
|
||||
// counters too, which made a delta comparison fail under parallel test runs.
|
||||
let chunks = prepared
|
||||
.read_series(0..prepared.num_series())
|
||||
.unwrap()
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.unwrap();
|
||||
for chunk in &chunks {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for i in 0..chunk.time.len() {
|
||||
let timestamp = chunk.time.timestamp(i).unwrap();
|
||||
assert!(
|
||||
seen.insert(timestamp),
|
||||
"series retained two rows at the same timestamp {timestamp}"
|
||||
);
|
||||
}
|
||||
}
|
||||
for candidate in prepared.candidates.iter() {
|
||||
let (batch, _) = candidate.metadata_row.as_ref().unwrap();
|
||||
retained.insert(Arc::as_ptr(batch), batch.num_rows());
|
||||
let expected = if candidate.id.tsid == 0 {
|
||||
"x".to_string()
|
||||
} else {
|
||||
@@ -2344,7 +2392,6 @@ async fn overlapping_buckets_retain_only_new_label_rows() {
|
||||
Some(expected.as_str())
|
||||
);
|
||||
}
|
||||
assert_eq!(34, retained.values().sum::<usize>());
|
||||
engine.stop().await.unwrap();
|
||||
}
|
||||
|
||||
@@ -2352,7 +2399,7 @@ async fn overlapping_buckets_retain_only_new_label_rows() {
|
||||
#[case::direct(false)]
|
||||
#[case::mixed_fallback(true)]
|
||||
#[tokio::test]
|
||||
async fn overlapping_buckets_reject_conflicting_physical_tags(#[case] mixed: bool) {
|
||||
async fn overlapping_buckets_keep_colliding_physical_tags(#[case] mixed: bool) {
|
||||
let (_env, engine, region) = fixture().await;
|
||||
write_with_tags(
|
||||
&engine,
|
||||
@@ -2385,16 +2432,232 @@ async fn overlapping_buckets_reject_conflicting_physical_tags(#[case] mixed: boo
|
||||
// The conflict is in an unbound physical tag, which must still be checked.
|
||||
*logical_tags = logical_tags[..1].to_vec().into();
|
||||
}
|
||||
let error = scan
|
||||
.prepare_series(
|
||||
&QueryScanContext::default(),
|
||||
&ExecutionPlanMetricsSet::new(),
|
||||
&binding,
|
||||
)
|
||||
scan.prepare_series(
|
||||
&QueryScanContext::default(),
|
||||
&ExecutionPlanMetricsSet::new(),
|
||||
&binding,
|
||||
)
|
||||
.await
|
||||
.expect("one tsid with two label sets prepares as two series");
|
||||
let source = scan.prepared.source.get().unwrap().as_ref().unwrap();
|
||||
// Same `(table_id, tsid)`, different physical tags: two series, never one merged row.
|
||||
assert_eq!(2, source.num_series());
|
||||
let keys: HashSet<Vec<u8>> = (0..source.num_series())
|
||||
.map(|ordinal| source.series_info(ordinal).unwrap().primary_key.to_vec())
|
||||
.collect();
|
||||
assert_eq!(2, keys.len());
|
||||
let chunks = source
|
||||
.read_series(0..source.num_series())
|
||||
.unwrap()
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.err()
|
||||
.expect("conflicting physical tags must fail preparation");
|
||||
assert!(error.output_msg().contains("conflicting labels"), "{error}");
|
||||
.unwrap();
|
||||
// The differing physical tag is unbound here, so only the exact key separates the
|
||||
// two candidates; the previous code compared labels and failed preparation.
|
||||
let identities: HashSet<Vec<u8>> = source
|
||||
.candidates
|
||||
.iter()
|
||||
.map(|candidate| candidate.merge_key.to_vec())
|
||||
.collect();
|
||||
assert_eq!(2, identities.len(), "colliding series keep distinct keys");
|
||||
if !mixed {
|
||||
// Every returned chunk names one of the candidates' label sets; a chunk whose
|
||||
// labels match neither would mean the pair had been merged again.
|
||||
let labels = |metric: &SeriesLabels| {
|
||||
metric
|
||||
.tags()
|
||||
.iter()
|
||||
.map(|label| (label.name.to_string(), label.value.to_string()))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let expected: HashSet<Vec<(String, String)>> = (0..source.num_series())
|
||||
.map(|ordinal| {
|
||||
labels(
|
||||
source
|
||||
.series_info(ordinal)
|
||||
.unwrap()
|
||||
.metric
|
||||
.as_ref()
|
||||
.unwrap(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for chunk in &chunks {
|
||||
let actual = labels(chunk.metric.as_ref().expect("explicit labels"));
|
||||
assert!(
|
||||
expected.contains(&actual),
|
||||
"chunk labels {actual:?} match no candidate: {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
engine.stop().await.unwrap();
|
||||
}
|
||||
|
||||
/// Two series forced to share one `tsid` must never receive each other's samples:
|
||||
/// candidate discovery, sample attribution, and the merged primary key are all exact.
|
||||
#[tokio::test]
|
||||
async fn colliding_tsids_isolate_samples_and_labels_per_candidate() {
|
||||
use common_time::Timestamp;
|
||||
use common_time::range::TimestampRange;
|
||||
|
||||
let (_env, engine, region) = fixture().await;
|
||||
let metadata = region.version().metadata.clone();
|
||||
let first = new_sparse_primary_key(&["host-020078", "z"], &metadata, 10, 0);
|
||||
let partner = new_sparse_primary_key(&["host-029838", "z"], &metadata, 10, 0);
|
||||
// Same `(table_id, tsid)`; only the labels differ.
|
||||
for (key, base) in [(first, 0.0), (partner, 100.0)] {
|
||||
for (index, timestamp) in [1000, 2000, 3000].into_iter().enumerate() {
|
||||
write_primary_key(
|
||||
&engine,
|
||||
®ion,
|
||||
key.clone(),
|
||||
timestamp,
|
||||
Some(base + index as f64),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
flush_region(&engine, region.region_id, None).await;
|
||||
build(&engine, ®ion).await;
|
||||
let make_scan = |predicates: Vec<datafusion_expr::Expr>, range: Option<TimestampRange>| {
|
||||
let mut input = scan_input(
|
||||
®ion,
|
||||
region.series_index_store.clone().unwrap(),
|
||||
SelectedFormat::Columnar,
|
||||
(0..metadata.column_metadatas.len()).collect(),
|
||||
);
|
||||
// Replace the fixture's default predicate, which would hide both hosts.
|
||||
input.predicate = PredicateGroup::new(&metadata, &predicates).unwrap();
|
||||
if let Some(range) = range {
|
||||
input.time_range = Some(range);
|
||||
}
|
||||
SeriesScan::new(input, false)
|
||||
};
|
||||
// One colliding tsid keeps both series, and each ordinal owns only its own rows.
|
||||
let scan = make_scan(Vec::new(), None);
|
||||
let ctx = QueryScanContext::default();
|
||||
let metrics = ExecutionPlanMetricsSet::new();
|
||||
let binding = labels(scan.metadata());
|
||||
scan.prepare_series(&ctx, &metrics, &binding)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let source = scan.prepared.source.get().unwrap().as_ref().unwrap();
|
||||
assert_eq!(3, source.num_series(), "both colliding series must survive");
|
||||
let mut value_sets = Vec::new();
|
||||
for ordinal in 0..source.num_series() {
|
||||
let chunks = source
|
||||
.read_series(ordinal..ordinal + 1)
|
||||
.unwrap()
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.unwrap();
|
||||
value_sets.push(
|
||||
rows(&chunks)
|
||||
.into_iter()
|
||||
.map(|(_, _, value)| value)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
assert!(value_sets.contains(&vec![0.0, 1.0, 2.0]), "{value_sets:?}");
|
||||
assert!(
|
||||
value_sets.contains(&vec![100.0, 101.0, 102.0]),
|
||||
"{value_sets:?}"
|
||||
);
|
||||
assert!(
|
||||
!value_sets
|
||||
.iter()
|
||||
.any(|values| values.contains(&100.0) && values.contains(&0.0)),
|
||||
"a candidate must not mix both colliding series: {value_sets:?}"
|
||||
);
|
||||
// Filtering one host, inside a time range, returns only that host's samples.
|
||||
let scan = make_scan(
|
||||
vec![col("tag_0").eq(lit("host-020078"))],
|
||||
TimestampRange::new(
|
||||
Timestamp::new_millisecond(1500),
|
||||
Timestamp::new_millisecond(2500),
|
||||
),
|
||||
);
|
||||
let binding = labels(scan.metadata());
|
||||
scan.prepare_series(&ctx, &metrics, &binding)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let source = scan.prepared.source.get().unwrap().as_ref().unwrap();
|
||||
assert_eq!(1, source.num_series());
|
||||
let chunks = source
|
||||
.read_series(0..source.num_series())
|
||||
.unwrap()
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
rows(&chunks)
|
||||
.into_iter()
|
||||
.map(|(_, timestamp, value)| (timestamp, value))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(2000, 1.0)],
|
||||
"the partner's samples must never be attached to the filtered host"
|
||||
);
|
||||
engine.stop().await.unwrap();
|
||||
}
|
||||
|
||||
/// A key past 8 KiB must prepare and read like any other: the series-index writer, the
|
||||
/// samples writer, and preparation all accept the same key, so no artifact can be published
|
||||
/// that preparation then refuses.
|
||||
#[tokio::test]
|
||||
async fn long_keys_prepare_and_read() {
|
||||
let (_env, engine, region) = fixture().await;
|
||||
let metadata = region.version().metadata.clone();
|
||||
let label = "x".repeat(8 * 1024);
|
||||
let key = new_sparse_primary_key(&[label.as_str(), "z"], &metadata, 10, 0);
|
||||
assert!(key.len() > 8 * 1024, "{}", key.len());
|
||||
for (timestamp, value) in [(1000, 1.0), (2000, 2.0)] {
|
||||
write_primary_key(&engine, ®ion, key.clone(), timestamp, Some(value)).await;
|
||||
}
|
||||
flush_region(&engine, region.region_id, None).await;
|
||||
build(&engine, ®ion).await;
|
||||
|
||||
let mut input = scan_input(
|
||||
®ion,
|
||||
region.series_index_store.clone().unwrap(),
|
||||
SelectedFormat::Columnar,
|
||||
(0..metadata.column_metadatas.len()).collect(),
|
||||
);
|
||||
// The fixture's default predicate would hide this series.
|
||||
input.predicate = PredicateGroup::new(&metadata, &[]).unwrap();
|
||||
let scan = SeriesScan::new(input, false);
|
||||
let ctx = QueryScanContext::default();
|
||||
let metrics = ExecutionPlanMetricsSet::new();
|
||||
let binding = labels(scan.metadata());
|
||||
scan.prepare_series(&ctx, &metrics, &binding)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let source = scan.prepared.source.get().unwrap().as_ref().unwrap();
|
||||
// The fixture's own series prepare beside this one; the long key must survive preparation
|
||||
// byte for byte and own its samples. Served samples make `primary_key` region-qualified.
|
||||
let mut expected = metadata.region_id.as_u64().to_be_bytes().to_vec();
|
||||
expected.extend_from_slice(&key);
|
||||
let ordinal = (0..source.num_series())
|
||||
.find(|ordinal| {
|
||||
source.series_info(*ordinal).unwrap().primary_key.as_ref() as &[u8]
|
||||
== expected.as_slice()
|
||||
})
|
||||
.expect("preparation must keep the series whose key is longer than 8 KiB");
|
||||
let chunks = source
|
||||
.read_series(ordinal..ordinal + 1)
|
||||
.unwrap()
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
rows(&chunks)
|
||||
.into_iter()
|
||||
.map(|(_, timestamp, value)| (timestamp, value))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(1000, 1.0), (2000, 2.0)]
|
||||
);
|
||||
engine.stop().await.unwrap();
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ pub(crate) mod columnar;
|
||||
pub(crate) mod format;
|
||||
#[cfg(test)]
|
||||
mod format_tests;
|
||||
pub(crate) mod identity;
|
||||
mod maintenance;
|
||||
mod purger;
|
||||
pub(crate) mod samples;
|
||||
|
||||
@@ -34,6 +34,7 @@ use crate::error::{InvalidMetaSnafu, Result};
|
||||
use crate::series_index::artifact::ArtifactFormat;
|
||||
use crate::series_index::catalog::SeriesIndexEntry;
|
||||
use crate::series_index::format::{FormatReader, FormatWriter, SeriesIndexFormat};
|
||||
use crate::series_index::identity::IdentityEncoding;
|
||||
use crate::series_index::{
|
||||
MetricSeriesId, ROW_COUNT_COLUMN, SeriesIndexFileHandle, TABLE_ID_COLUMN, TSID_COLUMN,
|
||||
};
|
||||
@@ -115,6 +116,8 @@ pub(crate) struct Part {
|
||||
pub(crate) struct Manifest {
|
||||
pub(crate) format: ArtifactFormat,
|
||||
pub(crate) version: u32,
|
||||
/// Mandatory discriminator; see [`IdentityEncoding`]. Never defaulted on read.
|
||||
pub(crate) identity_encoding: IdentityEncoding,
|
||||
pub(crate) region_id: u64,
|
||||
pub(crate) generation: FileId,
|
||||
pub(crate) tags: Vec<Tag>,
|
||||
|
||||
@@ -213,11 +213,11 @@ impl Block {
|
||||
let last = c.u64()?;
|
||||
let rows = c.u32()?;
|
||||
let count = c.u32()? as usize;
|
||||
// `first`/`last` are the coarse `(table_id, tsid)` prefix, so several rows may
|
||||
// share one identifier when their exact keys differ. Only their order is fixed,
|
||||
// so the count is bounded by the block limit and not by the identifier span.
|
||||
require(
|
||||
rows > 0
|
||||
&& rows as usize <= codec::COLUMN_LIMIT / 8
|
||||
&& first <= last
|
||||
&& u64::from(rows - 1) <= last - first,
|
||||
rows > 0 && rows as usize <= codec::COLUMN_LIMIT / 8 && first <= last,
|
||||
"invalid series block bounds/count",
|
||||
)?;
|
||||
require(
|
||||
@@ -363,8 +363,11 @@ impl Page {
|
||||
for _ in 0..count {
|
||||
let block = Block::decode(&mut c, names, payload_end, &mut end)?;
|
||||
if let Some(previous) = blocks.last() {
|
||||
// Bounds are the coarse `(table_id, tsid)` prefix and stay
|
||||
// conservative: two blocks may share one prefix when their exact
|
||||
// keys differ, so equality is allowed and only ever over-selects.
|
||||
require(
|
||||
previous.last_id() < block.first_id(),
|
||||
previous.last_id() <= block.first_id(),
|
||||
"unordered series blocks",
|
||||
)?;
|
||||
}
|
||||
@@ -390,7 +393,10 @@ impl Page {
|
||||
.validate(end, reference.payload.offset, true)?;
|
||||
end = child.payload.end()?;
|
||||
if let Some(previous) = children.last() {
|
||||
require(previous.last < child.first, "unordered series child ranges")?;
|
||||
require(
|
||||
previous.last <= child.first,
|
||||
"unordered series child ranges",
|
||||
)?;
|
||||
}
|
||||
children.push(child);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ use object_store::ObjectStore;
|
||||
use snafu::ResultExt;
|
||||
|
||||
use crate::cache::columnar::{self as metadata_cache, MetadataCache};
|
||||
use crate::error::{NewRecordBatchSnafu, OpenDalSnafu, RecordBatchSnafu, Result, SerdeJsonSnafu};
|
||||
use crate::error::{NewRecordBatchSnafu, OpenDalSnafu, RecordBatchSnafu, Result};
|
||||
use crate::metrics::ColumnarReadMetrics;
|
||||
use crate::series_index::artifact::ArtifactFormat;
|
||||
use crate::series_index::columnar::codec::{self, Dictionary};
|
||||
@@ -37,6 +37,7 @@ use crate::series_index::columnar::{
|
||||
MANIFEST_LIMIT, Manifest, VERSION, invalid, require, unsupported,
|
||||
};
|
||||
use crate::series_index::format::{FormatReader, SeriesReadRequest};
|
||||
use crate::series_index::identity::IdentityEncoding;
|
||||
use crate::series_index::{
|
||||
MetricSeriesId, ROW_COUNT_COLUMN, SeriesIndexFileHandle, TABLE_ID_COLUMN, TSID_COLUMN,
|
||||
};
|
||||
@@ -114,10 +115,23 @@ impl ColumnarReader {
|
||||
"series manifest exceeds size limit",
|
||||
)?;
|
||||
let bytes = read_range(&store, &path, 0, length).await?;
|
||||
let manifest: Manifest = serde_json::from_slice(&bytes).context(SerdeJsonSnafu)?;
|
||||
// A manifest that the current format cannot read predates exact identity or is
|
||||
// corrupt; either way it must never be reused.
|
||||
let manifest: Manifest = serde_json::from_slice(&bytes).map_err(|error| {
|
||||
invalid(format!(
|
||||
"series manifest is not readable; rebuild the index: {error}"
|
||||
))
|
||||
})?;
|
||||
if manifest.version != VERSION {
|
||||
return Err(unsupported("series manifest version"));
|
||||
}
|
||||
// Old artifacts can be structurally valid and perfectly ordered, so identity
|
||||
// encoding is the only thing that keeps their lost labels out of reads.
|
||||
if manifest.identity_encoding != IdentityEncoding::CURRENT_SERIES_INDEX {
|
||||
return Err(unsupported(
|
||||
"series manifest identity encoding; rebuild the series index",
|
||||
));
|
||||
}
|
||||
require(
|
||||
manifest.format == ArtifactFormat::ColumnarSeries
|
||||
&& manifest.region_id == region
|
||||
@@ -155,7 +169,8 @@ impl ColumnarReader {
|
||||
"invalid series part entry",
|
||||
)?;
|
||||
if let Some(last) = previous {
|
||||
require(last < part.first, "unordered series parts")?;
|
||||
// Coarse bounds may touch when two colliding series straddle a part.
|
||||
require(last <= part.first, "unordered series parts")?;
|
||||
}
|
||||
previous = Some(part.last);
|
||||
let part_path = manifest.part_path(part.part_id);
|
||||
@@ -479,10 +494,12 @@ async fn load_column_chunk(
|
||||
let result = if integer {
|
||||
let values = codec::decode_integers(&bytes, rows)?;
|
||||
if column.name == TSID_COLUMN {
|
||||
// Rows are ordered by exact key, so their coarse TSIDs are non-decreasing:
|
||||
// a distinct series may repeat its partner's TSID.
|
||||
require(
|
||||
values.first() == Some(&first)
|
||||
&& values.last() == Some(&last)
|
||||
&& values.windows(2).all(|v| v[0] < v[1]),
|
||||
&& values.windows(2).all(|v| v[0] <= v[1]),
|
||||
"invalid series TSID ordering/bounds",
|
||||
)?;
|
||||
} else {
|
||||
|
||||
@@ -298,6 +298,271 @@ async fn aggregation_projection_and_independent_reads() {
|
||||
assert!(reader.read(req).is_err());
|
||||
}
|
||||
|
||||
/// Reads every series row as `(tsid, row count, tag_0)` in artifact order.
|
||||
async fn read_rows(
|
||||
format: &ColumnarSeriesFormat,
|
||||
store: &ObjectStore,
|
||||
handle: SeriesIndexFileHandle,
|
||||
) -> Vec<(u64, u64, String)> {
|
||||
let batches = format
|
||||
.reader(store.clone(), handle)
|
||||
.await
|
||||
.unwrap()
|
||||
.read(request(&[TSID_COLUMN, ROW_COUNT_COLUMN, "tag_0"]))
|
||||
.unwrap()
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.unwrap();
|
||||
let mut rows = Vec::new();
|
||||
for batch in &batches {
|
||||
let ids = batch
|
||||
.column(0)
|
||||
.as_any()
|
||||
.downcast_ref::<UInt64Array>()
|
||||
.unwrap();
|
||||
let counts = batch
|
||||
.column(1)
|
||||
.as_any()
|
||||
.downcast_ref::<UInt64Array>()
|
||||
.unwrap();
|
||||
let labels = batch
|
||||
.column(2)
|
||||
.as_any()
|
||||
.downcast_ref::<StringArray>()
|
||||
.unwrap();
|
||||
for row in 0..batch.num_rows() {
|
||||
rows.push((
|
||||
ids.value(row),
|
||||
counts.value(row),
|
||||
labels.value(row).to_owned(),
|
||||
));
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// Publishes a writer that was created from the returned `entry`.
|
||||
async fn handle_of(
|
||||
store: &ObjectStore,
|
||||
mut entry: SeriesIndexEntry,
|
||||
writer: Box<dyn crate::series_index::format::FormatWriter>,
|
||||
) -> SeriesIndexFileHandle {
|
||||
let artifact = writer.finish().await.unwrap();
|
||||
entry.file_size = artifact.file_size;
|
||||
entry.artifact = Some(artifact);
|
||||
let (purger, _) = series_index_channel(store.clone());
|
||||
SeriesIndexFileHandle::new(metadata().region_id, entry, purger)
|
||||
}
|
||||
|
||||
/// The captured failure pair: two distinct series with one `table_id`/`tsid` whose
|
||||
/// timestamps restart. The old writer folded the partner into the first series' row
|
||||
/// and kept the first series' labels; both rows and both label sets must survive.
|
||||
#[tokio::test]
|
||||
async fn colliding_tsids_are_retained_not_merged() {
|
||||
let store = store();
|
||||
let meta = metadata();
|
||||
let format = ColumnarSeriesFormat::default();
|
||||
let first = new_sparse_primary_key(&["host-020078", "z"], &meta, 1, 7);
|
||||
let partner = new_sparse_primary_key(&["host-029838", "z"], &meta, 1, 7);
|
||||
for dictionary in [false, true] {
|
||||
let entry = entry();
|
||||
let mut writer = format
|
||||
.writer(meta.clone(), store.clone(), &entry)
|
||||
.await
|
||||
.unwrap();
|
||||
for timestamp in [100, 200] {
|
||||
writer
|
||||
.write(&batch(
|
||||
&[first.clone(), first.clone()],
|
||||
dictionary,
|
||||
timestamp,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
// The partner restarts its timestamps and shares the tsid.
|
||||
for timestamp in [100, 200] {
|
||||
writer
|
||||
.write(&batch(
|
||||
&[partner.clone(), partner.clone()],
|
||||
dictionary,
|
||||
timestamp,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let handle = handle_of(&store, entry, writer).await;
|
||||
// Two batches of two rows per series: both rows and both label sets survive,
|
||||
// and each series keeps its own count.
|
||||
assert_eq!(
|
||||
read_rows(&format, &store, handle).await,
|
||||
vec![
|
||||
(7, 4, "host-020078".to_owned()),
|
||||
(7, 4, "host-029838".to_owned()),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Many distinct keys under one forced tsid, through both primary-key inputs.
|
||||
#[tokio::test]
|
||||
async fn many_distinct_keys_under_one_tsid_are_retained() {
|
||||
let store = store();
|
||||
let meta = metadata();
|
||||
let format = ColumnarSeriesFormat::default();
|
||||
let keys: Vec<_> = (0..16)
|
||||
.map(|index| {
|
||||
let label = format!("host-{index:06}");
|
||||
new_sparse_primary_key(&[label.as_str(), "z"], &meta, 1, 5)
|
||||
})
|
||||
.collect();
|
||||
for dictionary in [false, true] {
|
||||
let entry = entry();
|
||||
let mut writer = format
|
||||
.writer(meta.clone(), store.clone(), &entry)
|
||||
.await
|
||||
.unwrap();
|
||||
writer.write(&batch(&keys, dictionary, 0)).await.unwrap();
|
||||
let handle = handle_of(&store, entry, writer).await;
|
||||
let rows = read_rows(&format, &store, handle).await;
|
||||
assert_eq!(rows.len(), keys.len());
|
||||
for (index, row) in rows.iter().enumerate() {
|
||||
assert_eq!(*row, (5, 1, format!("host-{index:06}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Distinct identifiers keep every series, with or without a collision in the input.
|
||||
#[tokio::test]
|
||||
async fn distinct_identifiers_keep_every_series() {
|
||||
let store = store();
|
||||
let meta = metadata();
|
||||
let format = ColumnarSeriesFormat::default();
|
||||
let keys: Vec<_> = (0..16)
|
||||
.map(|tsid| new_sparse_primary_key(&["a", "x"], &meta, 1, tsid))
|
||||
.collect();
|
||||
for dictionary in [false, true] {
|
||||
let handle = build(&format, &store, &[batch(&keys, dictionary, 0)]).await;
|
||||
let rows = read_rows(&format, &store, handle).await;
|
||||
assert_eq!(rows.len(), keys.len());
|
||||
for (tsid, row) in rows.iter().enumerate() {
|
||||
assert_eq!(*row, (tsid as u64, 1, "a".to_owned()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One exact series continuing across input batches, blocks, leaves and parts keeps
|
||||
/// aggregating into a single row with its labels.
|
||||
#[tokio::test]
|
||||
async fn same_exact_series_continues_across_batches_leaves_and_parts() {
|
||||
let store = store();
|
||||
let meta = metadata();
|
||||
// One row per block, one descriptor per leaf, two leaves per root.
|
||||
let format = ColumnarSeriesFormat {
|
||||
block_rows: 1,
|
||||
page_bytes: 64,
|
||||
root_capacity: 2,
|
||||
compression: Compression::None,
|
||||
..Default::default()
|
||||
};
|
||||
let anchor = new_sparse_primary_key(&["a", "x"], &meta, 1, 0);
|
||||
let rest: Vec<_> = (1..5)
|
||||
.map(|tsid| new_sparse_primary_key(&["a", "x"], &meta, 1, tsid))
|
||||
.collect();
|
||||
let mut entry = entry();
|
||||
let mut writer = format
|
||||
.writer(meta.clone(), store.clone(), &entry)
|
||||
.await
|
||||
.unwrap();
|
||||
for timestamp in [100, 200, 300] {
|
||||
writer
|
||||
.write(&batch(&[anchor.clone(), anchor.clone()], false, timestamp))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
for (index, key) in rest.iter().enumerate() {
|
||||
writer
|
||||
.write(&batch(std::slice::from_ref(key), true, 100 + index as i64))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let artifact = writer.finish().await.unwrap();
|
||||
entry.file_size = artifact.file_size;
|
||||
entry.artifact = Some(artifact);
|
||||
let (purger, _) = series_index_channel(store.clone());
|
||||
let handle = SeriesIndexFileHandle::new(meta.region_id, entry, purger);
|
||||
assert!(manifest(&store, &handle).await.files.len() > 1);
|
||||
let batches = format
|
||||
.reader(store.clone(), handle)
|
||||
.await
|
||||
.unwrap()
|
||||
.read(request(&[TSID_COLUMN, ROW_COUNT_COLUMN, "tag_0"]))
|
||||
.unwrap()
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.unwrap();
|
||||
let mut rows = Vec::new();
|
||||
for batch in &batches {
|
||||
let ids = batch
|
||||
.column(0)
|
||||
.as_any()
|
||||
.downcast_ref::<UInt64Array>()
|
||||
.unwrap();
|
||||
let counts = batch
|
||||
.column(1)
|
||||
.as_any()
|
||||
.downcast_ref::<UInt64Array>()
|
||||
.unwrap();
|
||||
let labels = batch
|
||||
.column(2)
|
||||
.as_any()
|
||||
.downcast_ref::<StringArray>()
|
||||
.unwrap();
|
||||
for row in 0..batch.num_rows() {
|
||||
rows.push((
|
||||
ids.value(row),
|
||||
counts.value(row),
|
||||
labels.value(row).to_owned(),
|
||||
));
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
rows[0],
|
||||
(0, 6, "a".to_owned()),
|
||||
"the anchor series aggregates across batches"
|
||||
);
|
||||
assert_eq!(rows.len(), 5);
|
||||
for (index, row) in rows[1..].iter().enumerate() {
|
||||
assert_eq!(*row, ((index + 1) as u64, 1, "a".to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Regressions in sparse-key order are refused instead of opening a second row for
|
||||
/// a series that was already emitted.
|
||||
#[tokio::test]
|
||||
async fn out_of_order_series_are_refused() {
|
||||
let store = store();
|
||||
let meta = metadata();
|
||||
let format = ColumnarSeriesFormat::default();
|
||||
let first = new_sparse_primary_key(&["a", "x"], &meta, 1, 1);
|
||||
let second = new_sparse_primary_key(&["a", "x"], &meta, 1, 2);
|
||||
let mut writer = format
|
||||
.writer(meta.clone(), store.clone(), &entry())
|
||||
.await
|
||||
.unwrap();
|
||||
writer
|
||||
.write(&batch(&[first.clone(), second], false, 0))
|
||||
.await
|
||||
.unwrap();
|
||||
let error = writer
|
||||
.write(&batch(&[first], false, 1))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("not sorted by primary key"), "{error}");
|
||||
writer.abort().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inconsistent_object_sizes_are_rejected() {
|
||||
let store = store();
|
||||
|
||||
@@ -35,6 +35,7 @@ use crate::series_index::columnar::{
|
||||
ColumnarSeriesFormat, MANIFEST_LIMIT, Manifest, Part, Tag, VERSION, invalid, require,
|
||||
};
|
||||
use crate::series_index::format::FormatWriter;
|
||||
use crate::series_index::identity::{ExactSeriesKey, IdentityEncoding};
|
||||
use crate::series_index::writer::{decode_series_identity, tag_columns, validate_metadata};
|
||||
use crate::series_index::{MetricSeriesId, ROW_COUNT_COLUMN, TSID_COLUMN};
|
||||
|
||||
@@ -43,7 +44,8 @@ const BLOCK_BYTE_BUDGET: usize = 8 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Row {
|
||||
id: MetricSeriesId,
|
||||
/// The exact series identity; `id` is only its prefix.
|
||||
key: ExactSeriesKey,
|
||||
count: u64,
|
||||
tags: Vec<Option<String>>,
|
||||
}
|
||||
@@ -89,6 +91,7 @@ impl ColumnarWriter {
|
||||
let manifest = Manifest {
|
||||
format: ArtifactFormat::ColumnarSeries,
|
||||
version: VERSION,
|
||||
identity_encoding: IdentityEncoding::CURRENT_SERIES_INDEX,
|
||||
region_id: metadata.region_id.as_u64(),
|
||||
generation: entry.index_uuid,
|
||||
tags: tags
|
||||
@@ -140,7 +143,7 @@ impl ColumnarWriter {
|
||||
|| self
|
||||
.rows
|
||||
.first()
|
||||
.is_some_and(|first| first.id.table_id != row.id.table_id)
|
||||
.is_some_and(|first| first.key.id().table_id != row.key.id().table_id)
|
||||
{
|
||||
self.flush_block().await?;
|
||||
}
|
||||
@@ -153,8 +156,14 @@ impl ColumnarWriter {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Aggregates one row of input.
|
||||
///
|
||||
/// Identity is the exact sparse primary key, never `(table_id, tsid)`: distinct
|
||||
/// series may share a `tsid`, and both keep their own row and labels. Rows carry
|
||||
/// their tags and are ordered by exact key, so the format can represent the pair
|
||||
/// without inventing a second identity.
|
||||
async fn append_key(&mut self, key: &[u8]) -> Result<()> {
|
||||
if key == self.last_key && self.current.is_some() {
|
||||
if self.current.is_some() && key == self.last_key.as_slice() {
|
||||
if let Some(row) = self.current.as_mut() {
|
||||
row.count = row
|
||||
.count
|
||||
@@ -163,24 +172,26 @@ impl ColumnarWriter {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let (id, tags) =
|
||||
let (exact, tags) =
|
||||
decode_series_identity(key, &self.tags, &mut self.offsets, &mut self.buffer)?;
|
||||
if let Some(row) = self.current.as_mut() {
|
||||
require(id >= row.id, "series identifiers are not sorted")?;
|
||||
if id == row.id {
|
||||
row.count = row
|
||||
.count
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| invalid("series row count overflow"))?;
|
||||
self.last_key.clear();
|
||||
self.last_key.extend(key);
|
||||
return Ok(());
|
||||
}
|
||||
if self.current.is_some() {
|
||||
// Input arrives in sparse-key order. A regression is a contract violation,
|
||||
// and the previous key cannot be the current one: equal keys return above.
|
||||
// A different key that shares `(table_id, tsid)` is a different series and
|
||||
// becomes its own row.
|
||||
require(
|
||||
key > self.last_key.as_slice(),
|
||||
"series input is not sorted by primary key",
|
||||
)?;
|
||||
}
|
||||
self.push_current().await?;
|
||||
self.current = Some(Row { id, count: 1, tags });
|
||||
self.current = Some(Row {
|
||||
key: exact,
|
||||
count: 1,
|
||||
tags,
|
||||
});
|
||||
self.last_key.clear();
|
||||
self.last_key.extend(key);
|
||||
self.last_key.extend_from_slice(key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -421,8 +432,13 @@ fn encode_block(
|
||||
let first = rows
|
||||
.first()
|
||||
.ok_or_else(|| invalid("empty series block"))?
|
||||
.id;
|
||||
let last = rows.last().ok_or_else(|| invalid("empty series block"))?.id;
|
||||
.key
|
||||
.id();
|
||||
let last = rows
|
||||
.last()
|
||||
.ok_or_else(|| invalid("empty series block"))?
|
||||
.key
|
||||
.id();
|
||||
let mut block = Block {
|
||||
table_id: first.table_id,
|
||||
first: first.tsid,
|
||||
@@ -434,7 +450,7 @@ fn encode_block(
|
||||
for (name, values) in [
|
||||
(
|
||||
TSID_COLUMN,
|
||||
rows.iter().map(|r| r.id.tsid).collect::<Vec<_>>(),
|
||||
rows.iter().map(|r| r.key.id().tsid).collect::<Vec<_>>(),
|
||||
),
|
||||
(ROW_COUNT_COLUMN, rows.iter().map(|r| r.count).collect()),
|
||||
] {
|
||||
|
||||
@@ -33,6 +33,7 @@ use store_api::storage::consts::ReservedColumnId;
|
||||
use crate::error::{InvalidRecordBatchSnafu, Result};
|
||||
use crate::series_index::artifact::{ArtifactFormat, ArtifactMetadata, SamplesFileHandle};
|
||||
use crate::series_index::catalog::SeriesIndexEntry;
|
||||
use crate::series_index::identity::ExactSeriesKey;
|
||||
use crate::series_index::{MetricSeriesId, SeriesIndexFileHandle};
|
||||
use crate::sst::PARQUET_FIELD_ID_KEY;
|
||||
|
||||
@@ -89,14 +90,37 @@ pub(crate) struct SamplesReadRequest {
|
||||
pub(crate) memory_pool: Option<Arc<dyn datafusion::execution::memory_pool::MemoryPool>>,
|
||||
pub(crate) projection: Vec<String>,
|
||||
pub(crate) table_id: Option<u32>,
|
||||
pub(crate) ids: Option<BTreeSet<MetricSeriesId>>,
|
||||
/// Exact selection. A block whose key is absent here is never read, even when its
|
||||
/// coarse `(table_id, tsid)` is present. The reader derives its coarse pruning set
|
||||
/// from these keys, so the selection cannot disagree with itself.
|
||||
pub(crate) keys: Option<BTreeSet<ExactSeriesKey>>,
|
||||
pub(crate) start: Option<i64>,
|
||||
pub(crate) end: Option<i64>,
|
||||
}
|
||||
|
||||
/// One samples output batch and the exact series that owns every row in it.
|
||||
///
|
||||
/// A sample block belongs to exactly one series, so the exact key travels with the batch
|
||||
/// instead of being repeated once per row: consumers attribute the whole batch at once.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct SamplesBatch {
|
||||
pub(crate) batch: RecordBatch,
|
||||
pub(crate) key: ExactSeriesKey,
|
||||
}
|
||||
|
||||
/// Batches read as batches: the key is the only column the wrapped batch does not carry.
|
||||
impl std::ops::Deref for SamplesBatch {
|
||||
type Target = RecordBatch;
|
||||
|
||||
fn deref(&self) -> &RecordBatch {
|
||||
&self.batch
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) trait SamplesFormatReader: Send + Sync {
|
||||
fn schema(&self) -> SchemaRef;
|
||||
fn read(&self, request: SamplesReadRequest) -> Result<BoxStream<'static, Result<RecordBatch>>>;
|
||||
fn read(&self, request: SamplesReadRequest)
|
||||
-> Result<BoxStream<'static, Result<SamplesBatch>>>;
|
||||
}
|
||||
|
||||
/// Builds and opens immutable per-SST samples artifacts.
|
||||
@@ -159,13 +183,23 @@ pub(crate) fn field_id(field: &Field) -> Result<u32> {
|
||||
|
||||
/// Checks whether samples can be read with the current region schema.
|
||||
pub(crate) fn validate_sample_schema(schema: &SchemaRef, metadata: &RegionMetadata) -> Result<()> {
|
||||
// Exact identity: readers attribute every sample row by this column, so an
|
||||
// artifact that cannot expose it is unusable.
|
||||
let key = schema
|
||||
.field_with_name("__primary_key")
|
||||
.map_err(|_| invalid("samples lack the exact series key column; rebuild series indexes"))?;
|
||||
if *key.data_type() != datatypes::arrow::datatypes::DataType::Binary {
|
||||
return Err(invalid(
|
||||
"samples series key column is not binary; rebuild series indexes",
|
||||
));
|
||||
}
|
||||
let mut ids = HashSet::new();
|
||||
for field in schema
|
||||
.fields()
|
||||
.iter()
|
||||
.skip(2)
|
||||
.filter(|f| f.name() != "__sequence" && f.name() != "__op_type")
|
||||
{
|
||||
for field in schema.fields().iter().skip(2).filter(|f| {
|
||||
!matches!(
|
||||
f.name().as_str(),
|
||||
"__sequence" | "__op_type" | "__primary_key"
|
||||
)
|
||||
}) {
|
||||
let id = field_id(field)?;
|
||||
let column = metadata
|
||||
.column_by_id(id)
|
||||
@@ -260,6 +294,10 @@ mod tests {
|
||||
)
|
||||
}),
|
||||
);
|
||||
if !tags {
|
||||
// Current artifacts expose the exact series key of each row.
|
||||
fields.push(Field::new("__primary_key", DataType::Binary, false));
|
||||
}
|
||||
let validate = |fields: Vec<Field>| {
|
||||
let schema = Arc::new(Schema::new(fields));
|
||||
if tags {
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
// Copyright 2023 Greptime Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! The exact series identity that SSTs already store.
|
||||
//!
|
||||
//! `tsid` is a hash, so two distinct series can share one [`MetricSeriesId`]. The
|
||||
//! sparse primary key stored by the SST is exact, so every place that decides
|
||||
//! whether two rows belong to the same series decides it on [`ExactSeriesKey`].
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::fmt;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use mito_codec::row_converter::SparseOffsetsCache;
|
||||
use mito_codec::row_converter::sparse::SparsePrimaryKeyView;
|
||||
use snafu::ResultExt;
|
||||
use store_api::storage::ColumnId;
|
||||
|
||||
use crate::error::{DecodeSnafu, Result};
|
||||
use crate::series_index::MetricSeriesId;
|
||||
|
||||
/// The identity semantics *and storage layout* an artifact was written with.
|
||||
///
|
||||
/// This is the one mandatory discriminator every artifact manifest carries. Artifacts
|
||||
/// written under hash-only identity are structurally valid and perfectly ordered, so
|
||||
/// nothing else in them distinguishes them from exact ones: an already collapsed series
|
||||
/// index looks fine and its lost labels cannot be recovered. A manifest that does not
|
||||
/// name this encoding is therefore not reusable, whatever its coverage says.
|
||||
///
|
||||
/// The serialized value names the layout as well as the identity, because two artifacts can
|
||||
/// store the same exact key in incompatible places: the samples format kept one copy per block
|
||||
/// inside its pages, then moved to one copy per series in a dedicated key area with a stored
|
||||
/// checksum. An artifact of the older layout is structurally valid and ordered too, so without
|
||||
/// the layout in this value maintenance would mark it reusable and only a part read would fail.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub(crate) enum IdentityEncoding {
|
||||
/// Identity is [`ExactSeriesKey`], stored inside the artifact's own pages: the
|
||||
/// series-index layout, and the retired samples layout that repeated the key in every
|
||||
/// block descriptor.
|
||||
#[serde(rename = "exact-sparse-key")]
|
||||
ExactSparseKey,
|
||||
/// Identity is [`ExactSeriesKey`], stored once per series in a part-level key area after
|
||||
/// the column payloads, each copy followed by its checksum.
|
||||
#[serde(rename = "exact-sparse-key-area")]
|
||||
ExactSparseKeyArea,
|
||||
}
|
||||
|
||||
impl IdentityEncoding {
|
||||
/// The encoding new samples artifacts are written with; their readers require exactly this.
|
||||
///
|
||||
/// Distinct from the series-index encoding on purpose: it names the key-area layout, so an
|
||||
/// artifact written before the key area cannot be taken for a current one.
|
||||
pub(crate) const CURRENT_SAMPLES: Self = Self::ExactSparseKeyArea;
|
||||
|
||||
/// The encoding new series-index artifacts are written with; their readers require this.
|
||||
pub(crate) const CURRENT_SERIES_INDEX: Self = Self::ExactSparseKey;
|
||||
}
|
||||
|
||||
/// The exact identity of one series: its canonical sparse primary-key bytes.
|
||||
///
|
||||
/// The key is the SST's own encoding of
|
||||
/// `(table_id, tsid, label column-id/value pairs sorted by name, non-null only)`,
|
||||
/// as produced by [`mito_codec::row_converter::sparse::SparsePrimaryKeyCodec`]. No
|
||||
/// second label encoding exists: callers hand in the bytes they read from the SST.
|
||||
///
|
||||
/// # Ordering
|
||||
///
|
||||
/// Identity and order are both defined on the encoded bytes only:
|
||||
///
|
||||
/// * `PartialEq`/`Hash` use the bytes, so two keys are the same series iff their
|
||||
/// encodings are equal.
|
||||
/// * `Ord` is byte-lexicographic. The reserved `table_id`/`tsid` prefix is fixed
|
||||
/// width and order preserving, so this order groups every key that shares one
|
||||
/// [`MetricSeriesId`] together and sorts those groups by `MetricSeriesId`.
|
||||
/// Inside one group it orders by the encoded `(column_id, value)` label sequence.
|
||||
///
|
||||
/// This is also the order SSTs store rows in, so artifact writers receive input in
|
||||
/// key order and can reject regressions instead of guessing.
|
||||
///
|
||||
/// `id` is always derived from `key` and never participates in comparison: every
|
||||
/// comparison below reads `key` only.
|
||||
///
|
||||
/// # Assumptions
|
||||
///
|
||||
/// This is *storage* identity, not Prometheus label-set identity. It relies on the
|
||||
/// existing sparse format's invariants, none of which it redefines:
|
||||
///
|
||||
/// * `tsid` is a stable hash for a label set within one logical table; a rebuild of
|
||||
/// the same rows writes the same `tsid`.
|
||||
/// * Column IDs are stable for a region, so the encoded `(column_id, value)` pairs
|
||||
/// identify the same labels over time. Re-creating a region with different IDs
|
||||
/// changes the key bytes.
|
||||
/// * Labels are encoded in the codec's canonical order (sorted by column name), and
|
||||
/// null labels are omitted while empty strings are encoded. This type never
|
||||
/// normalizes either case.
|
||||
///
|
||||
/// Equal keys therefore mean "the same series as far as this database stores it".
|
||||
/// They are not claimed to be equal to any Prometheus series identity.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ExactSeriesKey {
|
||||
/// Order-preserving `(table_id, tsid)` prefix decoded from `key`.
|
||||
id: MetricSeriesId,
|
||||
/// Canonical sparse primary-key encoding of the whole series.
|
||||
key: Box<[u8]>,
|
||||
}
|
||||
|
||||
impl ExactSeriesKey {
|
||||
/// Wraps one encoded sparse primary key after validating its framing.
|
||||
///
|
||||
/// # Length
|
||||
///
|
||||
/// This entry point deliberately has no length cap, and neither does any other:
|
||||
/// key length is bounded by the containers that store a key (one key per block,
|
||||
/// plus the descriptor, page, and column limits), and a cap enforced on only one
|
||||
/// entry point would let a writer publish a key another one refuses. Long labels
|
||||
/// are real data — a single 8 KiB label already encodes to a larger sparse key —
|
||||
/// so framing validation is the only requirement here.
|
||||
pub(crate) fn from_sparse_key(key: &[u8], offsets: &mut SparseOffsetsCache) -> Result<Self> {
|
||||
let mut view = SparsePrimaryKeyView::new(key, offsets).context(DecodeSnafu)?;
|
||||
// `new` validates the reserved prefix only; walk the labels so a truncated or
|
||||
// trailing-garbage key cannot become an identity that happens to share a prefix.
|
||||
view.visit_label_ids(|_| Ok(())).context(DecodeSnafu)?;
|
||||
Ok(Self::from_view(&view, key))
|
||||
}
|
||||
|
||||
/// The reserved `(table_id, tsid)` prefix. It is a coarse bucket, not identity.
|
||||
pub(crate) fn id(&self) -> MetricSeriesId {
|
||||
self.id
|
||||
}
|
||||
|
||||
/// The canonical bytes; the only thing that decides identity.
|
||||
pub(crate) fn bytes(&self) -> &[u8] {
|
||||
&self.key
|
||||
}
|
||||
|
||||
/// Builds the key from a view already validated against `key`.
|
||||
fn from_view(view: &SparsePrimaryKeyView<'_, '_>, key: &[u8]) -> Self {
|
||||
Self {
|
||||
id: MetricSeriesId {
|
||||
table_id: view.table_id(),
|
||||
tsid: view.tsid(),
|
||||
},
|
||||
key: key.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only `key` participates in identity. `id` is a prefix function of `key`, so a
|
||||
// future field cannot accidentally join (or reorder) the identity.
|
||||
|
||||
impl PartialEq for ExactSeriesKey {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.key == other.key
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for ExactSeriesKey {}
|
||||
|
||||
impl PartialOrd for ExactSeriesKey {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for ExactSeriesKey {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.key.cmp(&other.key)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ExactSeriesKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("ExactSeriesKey")
|
||||
.field("id", &self.id)
|
||||
.field("key", &Hex(&self.key))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Lets a map keyed by the exact key be probed with the raw canonical bytes it was
|
||||
/// built from, so hot loops do not allocate a key per row. `Ord`/`Eq` are byte
|
||||
/// comparisons, which is exactly `[u8]`'s order, so the borrow is consistent.
|
||||
impl std::borrow::Borrow<[u8]> for ExactSeriesKey {
|
||||
fn borrow(&self) -> &[u8] {
|
||||
&self.key
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for ExactSeriesKey {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.key.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
/// Prints at most [`HEX_LIMIT`] bytes of a key, so logs stay bounded.
|
||||
struct Hex<'a>(&'a [u8]);
|
||||
|
||||
const HEX_LIMIT: usize = 64;
|
||||
|
||||
impl fmt::Debug for Hex<'_> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
for byte in self.0.iter().take(HEX_LIMIT) {
|
||||
write!(f, "{byte:02x}")?;
|
||||
}
|
||||
if self.0.len() > HEX_LIMIT {
|
||||
write!(f, "...({} bytes)", self.0.len())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes one sparse primary key into its exact identity and `tag_columns` labels.
|
||||
///
|
||||
/// This is the only decoder artifact writers use, so the identity they aggregate and
|
||||
/// the labels they persist always come from the same key.
|
||||
pub(crate) fn decode_series_identity(
|
||||
primary_key: &[u8],
|
||||
tag_columns: &[(ColumnId, String)],
|
||||
offsets: &mut SparseOffsetsCache,
|
||||
buf: &mut Vec<u8>,
|
||||
) -> Result<(ExactSeriesKey, Vec<Option<String>>)> {
|
||||
let mut view = SparsePrimaryKeyView::new(primary_key, offsets).context(DecodeSnafu)?;
|
||||
// Validate every label once and cache its offset; the tag lookups below reuse it,
|
||||
// and a truncated key never reaches a persisted row.
|
||||
view.visit_label_ids(|_| Ok(())).context(DecodeSnafu)?;
|
||||
let key = ExactSeriesKey::from_view(&view, primary_key);
|
||||
let mut tags = Vec::with_capacity(tag_columns.len());
|
||||
for (column_id, _) in tag_columns {
|
||||
tags.push(
|
||||
view.label(*column_id, buf)
|
||||
.context(DecodeSnafu)?
|
||||
.map(str::to_owned),
|
||||
);
|
||||
}
|
||||
Ok((key, tags))
|
||||
}
|
||||
|
||||
/// Decodes only the reserved `(table_id, tsid)` prefix, without allocating the key.
|
||||
///
|
||||
/// Callers that already compared the raw bytes must not use this to decide identity;
|
||||
/// it only exists where the coarse bucket is the output.
|
||||
pub(crate) fn decode_series_id(
|
||||
primary_key: &[u8],
|
||||
offsets: &mut SparseOffsetsCache,
|
||||
) -> Result<MetricSeriesId> {
|
||||
let view = SparsePrimaryKeyView::new(primary_key, offsets).context(DecodeSnafu)?;
|
||||
Ok(MetricSeriesId {
|
||||
table_id: view.table_id(),
|
||||
tsid: view.tsid(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use store_api::codec::PrimaryKeyEncoding;
|
||||
|
||||
use super::*;
|
||||
use crate::test_util::sst_util::{new_sparse_primary_key, sst_region_metadata_with_encoding};
|
||||
|
||||
fn metadata() -> std::sync::Arc<store_api::metadata::RegionMetadata> {
|
||||
std::sync::Arc::new(sst_region_metadata_with_encoding(
|
||||
PrimaryKeyEncoding::Sparse,
|
||||
))
|
||||
}
|
||||
|
||||
fn key(tags: &[&str], table_id: u32, tsid: u64) -> Vec<u8> {
|
||||
new_sparse_primary_key(tags, &metadata(), table_id, tsid)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_is_the_exact_key_not_the_tsid() {
|
||||
let mut offsets = SparseOffsetsCache::new();
|
||||
let first =
|
||||
ExactSeriesKey::from_sparse_key(&key(&["host-020078", "z"], 1, 7), &mut offsets)
|
||||
.unwrap();
|
||||
let partner =
|
||||
ExactSeriesKey::from_sparse_key(&key(&["host-029838", "z"], 1, 7), &mut offsets)
|
||||
.unwrap();
|
||||
assert_eq!(first.id(), partner.id());
|
||||
assert_ne!(first, partner);
|
||||
assert_ne!(first.bytes(), partner.bytes());
|
||||
let same = ExactSeriesKey::from_sparse_key(&key(&["host-020078", "z"], 1, 7), &mut offsets)
|
||||
.unwrap();
|
||||
assert_eq!(first, same);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_groups_equal_identifiers_and_sorts_groups_by_identifier() {
|
||||
let mut offsets = SparseOffsetsCache::new();
|
||||
let mut keys = Vec::new();
|
||||
for (table_id, tsid, label) in [
|
||||
(1, 2, "b"),
|
||||
(1, 2, "a"),
|
||||
(1, 3, "a"),
|
||||
(2, 1, "a"),
|
||||
(1, 2, "c"),
|
||||
] {
|
||||
keys.push(
|
||||
ExactSeriesKey::from_sparse_key(&key(&[label, "z"], table_id, tsid), &mut offsets)
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
let mut sorted = keys.clone();
|
||||
sorted.sort();
|
||||
// Byte order sorts by (table_id, tsid) first, then by the encoded labels, and
|
||||
// every key sharing an identifier stays adjacent.
|
||||
let ids: Vec<_> = sorted.iter().map(|key| key.id()).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec![
|
||||
MetricSeriesId {
|
||||
table_id: 1,
|
||||
tsid: 2
|
||||
},
|
||||
MetricSeriesId {
|
||||
table_id: 1,
|
||||
tsid: 2
|
||||
},
|
||||
MetricSeriesId {
|
||||
table_id: 1,
|
||||
tsid: 2
|
||||
},
|
||||
MetricSeriesId {
|
||||
table_id: 1,
|
||||
tsid: 3
|
||||
},
|
||||
MetricSeriesId {
|
||||
table_id: 2,
|
||||
tsid: 1
|
||||
},
|
||||
]
|
||||
);
|
||||
// Ordering is decided on the encoded bytes alone.
|
||||
for pair in keys.windows(2) {
|
||||
assert_eq!(pair[0].cmp(&pair[1]), pair[0].bytes().cmp(pair[1].bytes()));
|
||||
}
|
||||
assert!(sorted.is_sorted());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_keys_are_identity_not_an_error() {
|
||||
let mut offsets = SparseOffsetsCache::new();
|
||||
// One 8 KiB label already encodes to a sparse key past 8 KiB. Both entry points
|
||||
// must accept it: the containers that store a key bound its length, and a cap on
|
||||
// one entry point alone would publish keys another refuses.
|
||||
let label = "x".repeat(8 * 1024);
|
||||
let encoded = key(&[&label, "z"], 1, 7);
|
||||
assert!(encoded.len() > 8 * 1024, "{}", encoded.len());
|
||||
let exact = ExactSeriesKey::from_sparse_key(&encoded, &mut offsets).unwrap();
|
||||
assert_eq!(encoded, exact.bytes());
|
||||
let tag_columns = [(0, "tag_0".to_string()), (1, "tag_1".to_string())];
|
||||
let (decoded, tags) =
|
||||
decode_series_identity(&encoded, &tag_columns, &mut offsets, &mut Vec::new()).unwrap();
|
||||
assert_eq!(exact, decoded);
|
||||
assert_eq!(Some(label), tags[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_keys_are_rejected() {
|
||||
let mut offsets = SparseOffsetsCache::new();
|
||||
assert!(ExactSeriesKey::from_sparse_key(&[], &mut offsets).is_err());
|
||||
assert!(ExactSeriesKey::from_sparse_key(&[0; 13], &mut offsets).is_err());
|
||||
let good = key(&["a", "b"], 1, 1);
|
||||
assert!(ExactSeriesKey::from_sparse_key(&good[..good.len() - 1], &mut offsets).is_err());
|
||||
assert_eq!(
|
||||
decode_series_id(&good, &mut offsets).unwrap(),
|
||||
MetricSeriesId {
|
||||
table_id: 1,
|
||||
tsid: 1
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -287,14 +287,23 @@ async fn build_index_version(
|
||||
.await
|
||||
.context(crate::error::OpenDalSnafu)?;
|
||||
}
|
||||
// Manual builds must repair schema changes even when SST coverage is unchanged.
|
||||
if manual
|
||||
&& reusable
|
||||
// Columnar artifacts must be revalidated even when SST coverage is unchanged:
|
||||
// an artifact written under another identity encoding, or with a stale tag
|
||||
// schema, is structurally valid and ordered, so only opening it tells them
|
||||
// apart. An unreadable artifact is rebuilt, never reused and never an error.
|
||||
if reusable
|
||||
&& selected_format == ArtifactFormat::ColumnarSeries
|
||||
&& let Some(backend) = &formats.index
|
||||
{
|
||||
let reader = backend.reader(store.clone(), handle.clone()).await?;
|
||||
reusable = format::validate_series_schema(&reader.schema(), &version.metadata).is_ok();
|
||||
// An artifact written under another identity encoding, or with a stale tag
|
||||
// schema, is structurally valid and ordered, so only opening it tells them
|
||||
// apart. An unreadable artifact is rebuilt, never reused and never an error.
|
||||
reusable = match backend.reader(store.clone(), handle.clone()).await {
|
||||
Ok(reader) => {
|
||||
format::validate_series_schema(&reader.schema(), &version.metadata).is_ok()
|
||||
}
|
||||
Err(_) => false,
|
||||
};
|
||||
}
|
||||
if !reusable {
|
||||
unusable.insert(entry.index_uuid);
|
||||
@@ -315,10 +324,15 @@ async fn build_index_version(
|
||||
.await
|
||||
.context(crate::error::OpenDalSnafu)?;
|
||||
}
|
||||
if manual && reusable && backend.format() == ArtifactFormat::ColumnarSamples {
|
||||
let reader = backend.reader(store.clone(), handle.clone()).await?;
|
||||
reusable =
|
||||
format::validate_sample_schema(&reader.schema(), &version.metadata).is_ok();
|
||||
// Same rule as series indexes: coverage, object existence and format name
|
||||
// cannot prove that a samples artifact was written with exact identity.
|
||||
if reusable && backend.format() == ArtifactFormat::ColumnarSamples {
|
||||
reusable = match backend.reader(store.clone(), handle.clone()).await {
|
||||
Ok(reader) => {
|
||||
format::validate_sample_schema(&reader.schema(), &version.metadata).is_ok()
|
||||
}
|
||||
Err(_) => false,
|
||||
};
|
||||
}
|
||||
if reusable {
|
||||
reusable_samples.insert(*id);
|
||||
|
||||
@@ -35,6 +35,7 @@ use crate::error::Result;
|
||||
use crate::series_index::artifact::{ArtifactFormat, SamplesFileHandle};
|
||||
use crate::series_index::columnar::{invalid, require, unsupported};
|
||||
use crate::series_index::format::{FormatWriter, SamplesFormat, SamplesFormatReader};
|
||||
use crate::series_index::identity::IdentityEncoding;
|
||||
use crate::series_index::{MetricSeriesId, TABLE_ID_COLUMN, TSID_COLUMN};
|
||||
|
||||
const VERSION: u32 = 1;
|
||||
@@ -44,6 +45,8 @@ const MAX_FIELDS: usize = 1024;
|
||||
const BLOCK_BYTES: usize = 256 * 1024;
|
||||
const SEQUENCE: &str = "__sequence";
|
||||
const OPERATION: &str = "__op_type";
|
||||
/// Output column naming the exact series key that owns each returned sample row.
|
||||
const PRIMARY_KEY: &str = store_api::storage::consts::PRIMARY_KEY_COLUMN_NAME;
|
||||
|
||||
/// Internal packing knobs; the backend is disabled unless selected explicitly.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -151,6 +154,8 @@ struct Part {
|
||||
struct Manifest {
|
||||
format: ArtifactFormat,
|
||||
version: u32,
|
||||
/// Mandatory discriminator; see [`IdentityEncoding`]. Never defaulted on read.
|
||||
identity_encoding: IdentityEncoding,
|
||||
region_id: u64,
|
||||
source_file_id: FileId,
|
||||
generation: FileId,
|
||||
@@ -185,7 +190,13 @@ impl Manifest {
|
||||
(1..=MAX_FIELDS).contains(&self.fields.len()),
|
||||
"invalid samples field count",
|
||||
)?;
|
||||
let mut names = HashSet::from([TABLE_ID_COLUMN, TSID_COLUMN, SEQUENCE, OPERATION]);
|
||||
let mut names = HashSet::from([
|
||||
TABLE_ID_COLUMN,
|
||||
TSID_COLUMN,
|
||||
SEQUENCE,
|
||||
OPERATION,
|
||||
PRIMARY_KEY,
|
||||
]);
|
||||
require(
|
||||
names.insert(&self.timestamp.name),
|
||||
"duplicate samples timestamp name",
|
||||
@@ -221,8 +232,17 @@ impl Manifest {
|
||||
fields.extend(self.fields.iter().map(|f| {
|
||||
crate::sst::with_field_id(Field::new(&f.name, DataType::Float64, true), f.column_id)
|
||||
}));
|
||||
// Appended after the data fields so stored row indices stay stable. The column is the
|
||||
// artifact's exact-identity marker: readers attribute every row of a batch by the key
|
||||
// they return with it and never project this column.
|
||||
fields.push(Field::new(PRIMARY_KEY, DataType::Binary, false));
|
||||
Arc::new(Schema::new(fields))
|
||||
}
|
||||
|
||||
/// Index of the exact series key column in [`Self::schema`].
|
||||
fn primary_key_index(&self) -> usize {
|
||||
5 + self.fields.len()
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_rows(rows: usize, fields: usize) -> bool {
|
||||
|
||||
@@ -22,10 +22,27 @@ use crate::series_index::samples::{
|
||||
|
||||
pub(super) const MAGIC: &[u8; 8] = b"GTSAMPLE";
|
||||
pub(super) const HEADER_SIZE: u64 = 16;
|
||||
pub(super) const TRAILER_SIZE: u64 = 112;
|
||||
pub(super) const TRAILER_SIZE: u64 = 120;
|
||||
pub(super) const PAGE_LIMIT: usize = 65536;
|
||||
pub(super) const ROOT_CAPACITY: usize = (PAGE_LIMIT - 12) / 72;
|
||||
const CHUNK_LIMIT: usize = 1024 * 1024;
|
||||
/// Fixed part of a block descriptor, before its 44-byte column descriptors.
|
||||
///
|
||||
/// The exact series key is not part of it. A descriptor keeps a fixed-size [`KeyRef`] into
|
||||
/// the part's key area instead, so descriptor size is independent of key length and a key
|
||||
/// longer than a metadata page is representable.
|
||||
pub(super) const BLOCK_FIXED_BYTES: usize = 56;
|
||||
/// Smallest possible block descriptor: the fixed part plus its three mandatory columns.
|
||||
const MIN_BLOCK_BYTES: usize = BLOCK_FIXED_BYTES + 3 * 44;
|
||||
|
||||
/// Descriptor bytes for a block with `columns` column descriptors.
|
||||
///
|
||||
/// Independent of the key's length: the descriptor references the key, it does not hold it.
|
||||
/// This is the one place the descriptor size formula lives, for the writer's page planning
|
||||
/// and for readers that inspect decoded pages.
|
||||
pub(super) fn descriptor_bytes(columns: usize) -> usize {
|
||||
BLOCK_FIXED_BYTES + 44 * columns
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(super) struct PayloadReference {
|
||||
@@ -201,32 +218,219 @@ pub(super) struct Column {
|
||||
pub payload: PayloadReference,
|
||||
pub nulls: u32,
|
||||
}
|
||||
/// The location of one exact series key inside a part's key area.
|
||||
///
|
||||
/// This reference is the descriptor's whole identity. The key bytes themselves are stored
|
||||
/// once per series in the part's key area, outside every bounded metadata page, so a key may
|
||||
/// be longer than a page and the descriptor stays 12 bytes whatever its length is.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct KeyRef {
|
||||
/// Absolute offset of the first key byte from the start of the part.
|
||||
pub offset: u64,
|
||||
/// Key length in bytes; nonzero.
|
||||
pub length: u32,
|
||||
}
|
||||
|
||||
impl KeyRef {
|
||||
pub fn end(&self) -> Result<u64> {
|
||||
self.offset
|
||||
.checked_add(u64::from(self.length))
|
||||
.ok_or_else(|| invalid("sample key offset overflow"))
|
||||
}
|
||||
|
||||
/// End of this key's stored entry: its bytes followed by its checksum.
|
||||
///
|
||||
/// `length` covers the key only, so the stored checksum is exactly [`KEY_CHECKSUM_BYTES`]
|
||||
/// past [`Self::end`] and never part of what the reference itself names.
|
||||
pub fn checksum_end(&self) -> Result<u64> {
|
||||
self.end()?
|
||||
.checked_add(KEY_CHECKSUM_BYTES)
|
||||
.ok_or_else(|| invalid("sample key offset overflow"))
|
||||
}
|
||||
|
||||
fn encode(&self, out: &mut Vec<u8>) {
|
||||
put_u64(out, self.offset);
|
||||
put_u32(out, self.length);
|
||||
}
|
||||
|
||||
fn decode(c: &mut Cursor<'_>) -> Result<Self> {
|
||||
Ok(Self {
|
||||
offset: c.u64()?,
|
||||
length: c.u32()?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Requires this reference to name a nonempty key entry inside `area`.
|
||||
///
|
||||
/// The entry includes the stored checksum, so a reference whose key ends inside the area but
|
||||
/// whose checksum would not is rejected like any other out-of-area reference.
|
||||
pub fn validate(&self, area: KeyArea) -> Result<()> {
|
||||
require(
|
||||
self.length > 0 && self.offset >= area.start && self.checksum_end()? <= area.end,
|
||||
"sample key reference outside key area",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes of the XXH64 stored after each key, seed zero.
|
||||
///
|
||||
/// A key copy is `[key bytes][checksum]`; the descriptor's reference names only the key, so a
|
||||
/// fetched key can be verified before it is used to attribute rows to a series. Without it a
|
||||
/// flipped label byte would silently move rows to another series, which the old inline-key layout
|
||||
/// could not suffer: its keys sat in a page whose stored bytes were already checksummed.
|
||||
pub(super) const KEY_CHECKSUM_BYTES: u64 = std::mem::size_of::<u64>() as u64;
|
||||
|
||||
/// A part's key area: exact series keys stored back to back, outside every metadata page.
|
||||
///
|
||||
/// Keys are appended in block order and are never page-bounded, so their length is bounded by
|
||||
/// the part, not by `PAGE_LIMIT`. Every copy is followed by its checksum. Because blocks are
|
||||
/// ordered by exact key, every block of one series is adjacent and shares a single copy of the
|
||||
/// key, which makes an equal [`KeyRef`] mean "the same exact series".
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(super) struct KeyArea {
|
||||
pub start: u64,
|
||||
pub end: u64,
|
||||
}
|
||||
|
||||
/// The three regions of one part, in file order: column payloads, exact series keys, pages.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(super) struct Areas {
|
||||
/// End of the column payload area and start of the key area.
|
||||
pub payload_end: u64,
|
||||
/// The key area every block descriptor references.
|
||||
pub keys: KeyArea,
|
||||
/// Start of the metadata area, which is the end of the key area.
|
||||
pub metadata_start: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct Block {
|
||||
/// Coarse `(table_id, tsid)` prefix decoded from the block's exact key.
|
||||
///
|
||||
/// It prunes candidates but never names the owner: `(table_id, tsid)` is a hash, so two
|
||||
/// distinct series can share it. The owner is the key [`Self::key`] references.
|
||||
pub id: MetricSeriesId,
|
||||
/// Fixed-size reference to the block's exact key in the part's key area.
|
||||
///
|
||||
/// Equal references mean the same exact series; a different series that collides on the
|
||||
/// identifier always carries a different reference.
|
||||
pub key: KeyRef,
|
||||
pub min: i64,
|
||||
pub max: i64,
|
||||
pub ordinal: u64,
|
||||
pub rows: u32,
|
||||
pub columns: Vec<Column>,
|
||||
}
|
||||
/// The ordering boundary retained between pages, without their column descriptors.
|
||||
#[derive(Clone, Copy)]
|
||||
/// The ordering boundary retained between pages and parts, without column descriptors.
|
||||
///
|
||||
/// A [`KeyRef`] is an offset inside one part's key area, so two references are comparable only
|
||||
/// when they belong to the same part; `part` carries that scope in the boundary itself so no
|
||||
/// caller can compare references across a part boundary by accident. Two parts hold unrelated
|
||||
/// key areas, which is why a series that spans parts is recognised by [`BlockOrder::continues`]
|
||||
/// on the exact key bytes instead.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct BlockOrder {
|
||||
part: u32,
|
||||
id: MetricSeriesId,
|
||||
key: KeyRef,
|
||||
min: i64,
|
||||
max: i64,
|
||||
ordinal: u64,
|
||||
}
|
||||
|
||||
impl BlockOrder {
|
||||
/// The part this block belongs to.
|
||||
pub(crate) fn part(&self) -> u32 {
|
||||
self.part
|
||||
}
|
||||
|
||||
/// The coarse `(table_id, tsid)` prefix, which is ordered across parts as well.
|
||||
pub(crate) fn id(&self) -> MetricSeriesId {
|
||||
self.id
|
||||
}
|
||||
|
||||
/// The block's key reference, a location inside [`Self::part`]'s key area.
|
||||
pub(crate) fn key(&self) -> KeyRef {
|
||||
self.key
|
||||
}
|
||||
|
||||
/// Checks the next block of the *same* part against this boundary.
|
||||
///
|
||||
/// Ownership and ordering of a part are decided on the exact `(table_id, tsid)` and on the
|
||||
/// key reference only: two blocks of one part that share one reference share one key copy,
|
||||
/// so they are one exact series, while a different series always has its own copy, placed
|
||||
/// after the previous one and restarting the ordinal. Only descriptor fields are read, so a
|
||||
/// block this request prunes is never fetched and its key content is not validated.
|
||||
pub(crate) fn precedes(&self, next: &Self, adjacent: bool) -> Result<()> {
|
||||
require(
|
||||
self.part == next.part,
|
||||
"sample key references compared across parts",
|
||||
)?;
|
||||
require(self.id <= next.id, "unordered sample series keys")?;
|
||||
if next.key == self.key {
|
||||
self.continues(next, adjacent)?;
|
||||
} else {
|
||||
// A different series must place its one key copy after the previous series' copy.
|
||||
// Overlap, or a second copy of a key that is still current, is a corrupt reference
|
||||
// rather than a silent second read of the same bytes.
|
||||
require(
|
||||
self.key.checksum_end()? <= next.key.offset,
|
||||
"overlapping sample key references",
|
||||
)?;
|
||||
if adjacent {
|
||||
require(next.ordinal == 0, "first sample block has nonzero ordinal")?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Requires `next` to continue one exact series in time and ordinal.
|
||||
///
|
||||
/// Equal exact key bytes are what make two blocks the same series across a part boundary,
|
||||
/// where their references belong to different key areas. A repeated key that restarts its
|
||||
/// ordinal or goes back in time is a corrupt part, not a second series.
|
||||
pub(crate) fn continues(&self, next: &Self, adjacent: bool) -> Result<()> {
|
||||
require(
|
||||
self.max <= next.min && self.ordinal < next.ordinal,
|
||||
"unordered sample blocks",
|
||||
)?;
|
||||
if adjacent {
|
||||
require(
|
||||
self.ordinal.checked_add(1) == Some(next.ordinal),
|
||||
"nonconsecutive sample ordinal",
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Block {
|
||||
pub(crate) fn order(&self) -> BlockOrder {
|
||||
/// The ordering boundary this block contributes, scoped to its part.
|
||||
pub(crate) fn order(&self, part: u32) -> BlockOrder {
|
||||
BlockOrder {
|
||||
part,
|
||||
id: self.id,
|
||||
key: self.key,
|
||||
min: self.min,
|
||||
max: self.max,
|
||||
ordinal: self.ordinal,
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks this block against the previous block of the same page.
|
||||
///
|
||||
/// A page never spans parts, so both references name one key area and are comparable; a block
|
||||
/// in another part is compared through [`BlockOrder::continues`] and its exact key instead.
|
||||
fn follows_in_page(&self, previous: &Self, adjacent: bool) -> Result<()> {
|
||||
// One scope for both sides: a page belongs to exactly one part, and no reference built
|
||||
// here outlives the page.
|
||||
const PAGE_SCOPE: u32 = 0;
|
||||
previous
|
||||
.order(PAGE_SCOPE)
|
||||
.precedes(&self.order(PAGE_SCOPE), adjacent)
|
||||
}
|
||||
|
||||
/// The coarse bounds used for pruning. Exact ownership lives in `key`.
|
||||
pub fn bounds(&self) -> Bounds {
|
||||
Bounds {
|
||||
first: self.id,
|
||||
@@ -235,12 +439,10 @@ impl Block {
|
||||
max: self.max,
|
||||
}
|
||||
}
|
||||
pub fn size(&self) -> usize {
|
||||
44 + 44 * self.columns.len()
|
||||
}
|
||||
pub fn encode(&self, out: &mut Vec<u8>) {
|
||||
put_u32(out, self.id.table_id);
|
||||
put_u64(out, self.id.tsid);
|
||||
self.key.encode(out);
|
||||
put_u64(out, self.min as u64);
|
||||
put_u64(out, self.max as u64);
|
||||
put_u64(out, self.ordinal);
|
||||
@@ -256,13 +458,19 @@ impl Block {
|
||||
fn decode(
|
||||
c: &mut Cursor<'_>,
|
||||
manifest: &Manifest,
|
||||
payload_end: u64,
|
||||
areas: Areas,
|
||||
previous_end: &mut u64,
|
||||
) -> Result<Self> {
|
||||
let id = MetricSeriesId {
|
||||
table_id: c.u32()?,
|
||||
tsid: c.u64()?,
|
||||
};
|
||||
// Only the key's location is decoded here. The bytes stay in the key area, so a
|
||||
// descriptor never grows with the key and a request that prunes this block never
|
||||
// reads it; the reader validates the reference and its `(table_id, tsid)` prefix
|
||||
// when it fetches the key for a block it actually selects.
|
||||
let key = KeyRef::decode(c)?;
|
||||
key.validate(areas.keys)?;
|
||||
let min = c.u64()? as i64;
|
||||
let max = c.u64()? as i64;
|
||||
let ordinal = c.u64()?;
|
||||
@@ -317,7 +525,7 @@ impl Block {
|
||||
};
|
||||
require(valid, "sample codec role mismatch")?;
|
||||
}
|
||||
payload.validate(*previous_end, payload_end, false)?;
|
||||
payload.validate(*previous_end, areas.payload_end, false)?;
|
||||
*previous_end = payload.end()?;
|
||||
columns.push(Column {
|
||||
role,
|
||||
@@ -328,6 +536,7 @@ impl Block {
|
||||
}
|
||||
Ok(Self {
|
||||
id,
|
||||
key,
|
||||
min,
|
||||
max,
|
||||
ordinal,
|
||||
@@ -335,24 +544,6 @@ impl Block {
|
||||
columns,
|
||||
})
|
||||
}
|
||||
pub fn follows(&self, previous: &BlockOrder, adjacent: bool) -> Result<()> {
|
||||
require(previous.id <= self.id, "unordered sample identifiers")?;
|
||||
if self.id == previous.id {
|
||||
require(
|
||||
previous.max <= self.min && previous.ordinal < self.ordinal,
|
||||
"unordered sample blocks",
|
||||
)?;
|
||||
if adjacent {
|
||||
require(
|
||||
previous.ordinal.checked_add(1) == Some(self.ordinal),
|
||||
"nonconsecutive sample ordinal",
|
||||
)?;
|
||||
}
|
||||
} else if adjacent {
|
||||
require(self.ordinal == 0, "first sample block has nonzero ordinal")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub(super) enum Page {
|
||||
@@ -368,6 +559,8 @@ impl Page {
|
||||
}
|
||||
Self::Leaf(blocks) => {
|
||||
blocks.capacity() * std::mem::size_of::<Block>()
|
||||
// Each block owns its column descriptors on the heap. Its exact
|
||||
// series key is not retained: it stays in the part's key area.
|
||||
+ blocks
|
||||
.iter()
|
||||
.map(|block| block.columns.capacity() * std::mem::size_of::<Column>())
|
||||
@@ -407,11 +600,16 @@ impl Page {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Decodes one metadata page.
|
||||
///
|
||||
/// `areas` bounds what the page may reference: column payloads end where the key area
|
||||
/// starts, keys live in their own area, and the page area starts at `areas.metadata_start`
|
||||
/// so every child page must lie after it and before its parent.
|
||||
pub fn decode(
|
||||
bytes: &[u8],
|
||||
reference: &PageReference,
|
||||
depth: u32,
|
||||
payload_end: u64,
|
||||
areas: Areas,
|
||||
manifest: &Manifest,
|
||||
) -> Result<Self> {
|
||||
require(bytes.len() <= PAGE_LIMIT, "sample page exceeds limit")?;
|
||||
@@ -422,15 +620,15 @@ impl Page {
|
||||
let page = match (kind, depth) {
|
||||
(0, 1) => {
|
||||
require(
|
||||
count <= c.remaining() / 176,
|
||||
count <= c.remaining() / MIN_BLOCK_BYTES,
|
||||
"sample block count exceeds page",
|
||||
)?;
|
||||
let mut blocks: Vec<Block> = Vec::with_capacity(count);
|
||||
let mut end = HEADER_SIZE;
|
||||
for _ in 0..count {
|
||||
let block = Block::decode(&mut c, manifest, payload_end, &mut end)?;
|
||||
let block = Block::decode(&mut c, manifest, areas, &mut end)?;
|
||||
if let Some(previous) = blocks.last() {
|
||||
block.follows(&previous.order(), true)?;
|
||||
block.follows_in_page(previous, true)?;
|
||||
}
|
||||
blocks.push(block);
|
||||
}
|
||||
@@ -442,7 +640,7 @@ impl Page {
|
||||
"sample root exceeds capacity",
|
||||
)?;
|
||||
let mut children: Vec<PageReference> = Vec::with_capacity(count);
|
||||
let mut end = payload_end;
|
||||
let mut end = areas.metadata_start;
|
||||
for _ in 0..count {
|
||||
let child = PageReference::decode(&mut c)?;
|
||||
child
|
||||
@@ -488,7 +686,10 @@ pub(super) struct Trailer {
|
||||
pub root: PageReference,
|
||||
pub depth: u32,
|
||||
pub file_length: u64,
|
||||
/// End of the payload area, which is where the key area starts.
|
||||
pub payload_end: u64,
|
||||
/// End of the key area, which is where the metadata pages start.
|
||||
pub key_end: u64,
|
||||
}
|
||||
impl Trailer {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
@@ -498,6 +699,7 @@ impl Trailer {
|
||||
put_u32(&mut out, VERSION);
|
||||
put_u64(&mut out, self.file_length);
|
||||
put_u64(&mut out, self.payload_end);
|
||||
put_u64(&mut out, self.key_end);
|
||||
out.extend(MAGIC);
|
||||
let sum = codec::checksum(&out);
|
||||
put_u64(&mut out, sum);
|
||||
@@ -517,18 +719,26 @@ impl Trailer {
|
||||
}
|
||||
let length = c.u64()?;
|
||||
let payload_end = c.u64()?;
|
||||
let key_end = c.u64()?;
|
||||
require(
|
||||
c.take(8)? == MAGIC && c.u64()? == codec::checksum(&bytes[..104]),
|
||||
// The checksum covers everything before it, magic included: the trailer body plus
|
||||
// the 8-byte magic, which is `TRAILER_SIZE - 8` and not a frozen constant.
|
||||
c.take(8)? == MAGIC
|
||||
&& c.u64()?
|
||||
== codec::checksum(
|
||||
&bytes[..TRAILER_SIZE as usize - std::mem::size_of::<u64>()],
|
||||
),
|
||||
"sample trailer checksum/magic",
|
||||
)?;
|
||||
require(
|
||||
length == file_length
|
||||
&& payload_end >= HEADER_SIZE
|
||||
&& key_end >= payload_end
|
||||
&& file_length >= HEADER_SIZE + TRAILER_SIZE,
|
||||
"invalid sample framing",
|
||||
)?;
|
||||
root.payload
|
||||
.validate(payload_end, file_length - TRAILER_SIZE, true)?;
|
||||
.validate(key_end, file_length - TRAILER_SIZE, true)?;
|
||||
require(
|
||||
root.payload.end()? == file_length - TRAILER_SIZE,
|
||||
"sample root must end at trailer",
|
||||
@@ -538,6 +748,24 @@ impl Trailer {
|
||||
depth,
|
||||
file_length,
|
||||
payload_end,
|
||||
key_end,
|
||||
})
|
||||
}
|
||||
|
||||
/// The key area every block descriptor in this part references.
|
||||
pub fn keys(&self) -> KeyArea {
|
||||
KeyArea {
|
||||
start: self.payload_end,
|
||||
end: self.key_end,
|
||||
}
|
||||
}
|
||||
|
||||
/// The part's three regions, as page decoding needs them.
|
||||
pub fn areas(&self) -> Areas {
|
||||
Areas {
|
||||
payload_end: self.payload_end,
|
||||
keys: self.keys(),
|
||||
metadata_start: self.key_end,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -32,15 +32,16 @@ use crate::error::{Result, SerdeJsonSnafu};
|
||||
use crate::series_index::artifact::{ArtifactFormat, ArtifactMetadata, ArtifactObject};
|
||||
use crate::series_index::artifact_writer::Outputs;
|
||||
use crate::series_index::format::FormatWriter;
|
||||
use crate::series_index::identity::{ExactSeriesKey, IdentityEncoding, decode_series_id};
|
||||
use crate::series_index::samples::codec;
|
||||
use crate::series_index::samples::metadata::{
|
||||
self, Block, Column, Page, PageReference, Trailer, pack,
|
||||
self, Block, Column, KeyRef, Page, PageReference, Trailer, pack,
|
||||
};
|
||||
use crate::series_index::samples::{
|
||||
ColumnarSamplesFormat, MANIFEST_LIMIT, Manifest, MetricSeriesId, Part, TimestampColumn,
|
||||
TimestampUnit, VERSION, ValueColumn, invalid, require, valid_rows,
|
||||
};
|
||||
use crate::series_index::writer::{decode_series_identity, validate_metadata};
|
||||
use crate::series_index::writer::validate_metadata;
|
||||
|
||||
use crate::sst::DEFAULT_WRITE_BUFFER_SIZE;
|
||||
|
||||
@@ -82,22 +83,101 @@ struct Row {
|
||||
operation: u8,
|
||||
fields: Vec<Option<u64>>,
|
||||
}
|
||||
/// A block descriptor whose exact key has not been placed in the part's key area yet.
|
||||
///
|
||||
/// The key bytes are held here until the part ends, when they are written to their own area
|
||||
/// and replaced by a fixed-size [`KeyRef`]. That is why no descriptor — and therefore no
|
||||
/// metadata page — ever has to fit a key.
|
||||
struct PendingBlock {
|
||||
key: ExactSeriesKey,
|
||||
min: i64,
|
||||
max: i64,
|
||||
ordinal: u64,
|
||||
rows: u32,
|
||||
columns: Vec<Column>,
|
||||
}
|
||||
|
||||
impl PendingBlock {
|
||||
/// Descriptor bytes once the key reference replaces the key bytes.
|
||||
fn size(&self) -> usize {
|
||||
metadata::descriptor_bytes(self.columns.len())
|
||||
}
|
||||
}
|
||||
|
||||
/// Places one copy of each series key in the part's key area and turns pending blocks into
|
||||
/// descriptors that reference it.
|
||||
///
|
||||
/// Blocks are ordered by exact key, so every block of one series is adjacent and shares one
|
||||
/// copy of the key. Consecutive equal keys therefore reuse the previous reference, while every
|
||||
/// new series appends its bytes after the previous copy: references are ordered, disjoint, and
|
||||
/// unique per series, which is what lets a reader treat an equal reference as the same series
|
||||
/// without reading the bytes. Each copy is followed by its XXH64, which the reference does not
|
||||
/// count: a reader verifies the bytes before they can name a series.
|
||||
fn place_keys(leaves: Vec<Vec<PendingBlock>>, start: u64) -> Result<(Vec<Vec<Block>>, Vec<u8>)> {
|
||||
let mut area: Vec<u8> = Vec::new();
|
||||
let mut previous: Option<(Vec<u8>, KeyRef)> = None;
|
||||
let mut placed = Vec::with_capacity(leaves.len());
|
||||
for pending in leaves {
|
||||
let mut blocks = Vec::with_capacity(pending.len());
|
||||
for block in pending {
|
||||
let reuse = previous
|
||||
.as_ref()
|
||||
.filter(|(key, _)| key.as_slice() == block.key.bytes())
|
||||
.map(|(_, reference)| *reference);
|
||||
let reference = match reuse {
|
||||
Some(reference) => reference,
|
||||
None => {
|
||||
let length = u32::try_from(block.key.bytes().len())
|
||||
.map_err(|_| invalid("sample series key exceeds 4 GiB"))?;
|
||||
let offset = start
|
||||
.checked_add(
|
||||
u64::try_from(area.len())
|
||||
.map_err(|_| invalid("sample key area overflow"))?,
|
||||
)
|
||||
.ok_or_else(|| invalid("sample key offset overflow"))?;
|
||||
area.extend_from_slice(block.key.bytes());
|
||||
area.extend_from_slice(&codec::checksum(block.key.bytes()).to_le_bytes());
|
||||
let reference = KeyRef { offset, length };
|
||||
previous = Some((block.key.bytes().to_vec(), reference));
|
||||
reference
|
||||
}
|
||||
};
|
||||
blocks.push(Block {
|
||||
id: block.key.id(),
|
||||
key: reference,
|
||||
min: block.min,
|
||||
max: block.max,
|
||||
ordinal: block.ordinal,
|
||||
rows: block.rows,
|
||||
columns: block.columns,
|
||||
});
|
||||
}
|
||||
placed.push(blocks);
|
||||
}
|
||||
Ok((placed, area))
|
||||
}
|
||||
pub(super) struct SamplesWriter {
|
||||
options: ColumnarSamplesFormat,
|
||||
manifest: Manifest,
|
||||
outputs: Outputs,
|
||||
output_buffer: SampleBuffer,
|
||||
offsets: SparseOffsetsCache,
|
||||
key_buffer: Vec<u8>,
|
||||
/// Canonical sparse key of the series that owns `rows` and `last`.
|
||||
series_key: Option<ExactSeriesKey>,
|
||||
rows: Vec<Row>,
|
||||
last: Option<(MetricSeriesId, i64, u64)>,
|
||||
ordinal: u64,
|
||||
leaves: Vec<Vec<Block>>,
|
||||
leaves: Vec<Vec<PendingBlock>>,
|
||||
leaf_bytes: usize,
|
||||
descriptor_bytes: usize,
|
||||
position: u64,
|
||||
part_rows: u64,
|
||||
part_blocks: u64,
|
||||
/// Bytes this part's key area is charged, one copy per series with its checksum, counted
|
||||
/// before they are written.
|
||||
part_key_bytes: u64,
|
||||
/// The last series whose key this part already counts, to charge one copy per series.
|
||||
part_last_key: Option<ExactSeriesKey>,
|
||||
manifest_bytes: usize,
|
||||
failed: bool,
|
||||
}
|
||||
@@ -142,6 +222,7 @@ impl SamplesWriter {
|
||||
let manifest = Manifest {
|
||||
format: ArtifactFormat::ColumnarSamples,
|
||||
version: VERSION,
|
||||
identity_encoding: IdentityEncoding::CURRENT_SAMPLES,
|
||||
region_id: metadata.region_id.as_u64(),
|
||||
source_file_id,
|
||||
generation,
|
||||
@@ -174,7 +255,7 @@ impl SamplesWriter {
|
||||
},
|
||||
output_buffer: SampleBuffer::default(),
|
||||
offsets: SparseOffsetsCache::new(),
|
||||
key_buffer: Vec::new(),
|
||||
series_key: None,
|
||||
rows: Vec::new(),
|
||||
last: None,
|
||||
ordinal: 0,
|
||||
@@ -184,6 +265,8 @@ impl SamplesWriter {
|
||||
position: 0,
|
||||
part_rows: 0,
|
||||
part_blocks: 0,
|
||||
part_key_bytes: 0,
|
||||
part_last_key: None,
|
||||
manifest_bytes,
|
||||
failed: false,
|
||||
})
|
||||
@@ -248,27 +331,49 @@ impl SamplesWriter {
|
||||
)?;
|
||||
values.value(index)
|
||||
};
|
||||
// TODO: coordinate exact keys and future job/instance identity components
|
||||
// with the series format. This port assumes (table_id, tsid) is unique.
|
||||
let (id, _) =
|
||||
decode_series_identity(key, &[], &mut self.offsets, &mut self.key_buffer)?;
|
||||
let time = times.value(i);
|
||||
let seq = sequence.value(i);
|
||||
let op = operations.value(i);
|
||||
require(op <= 1, "unknown sample operation")?;
|
||||
if let Some((last_id, last_time, last_seq)) = self.last {
|
||||
require(
|
||||
id >= last_id
|
||||
&& (id != last_id
|
||||
|| time > last_time
|
||||
|| (time == last_time && seq <= last_seq)),
|
||||
"sample input is not sorted",
|
||||
)?;
|
||||
if id != last_id {
|
||||
// Identity is the exact sparse key, not `(table_id, tsid)`: two distinct
|
||||
// series may share a `tsid`, and every block records which exact key owns it.
|
||||
let new_series = self
|
||||
.series_key
|
||||
.as_ref()
|
||||
.is_none_or(|current| current.bytes() != key);
|
||||
if new_series {
|
||||
// Validate the whole key once per series: identity is the exact key, and a
|
||||
// malformed one must not reach a block descriptor.
|
||||
let exact = ExactSeriesKey::from_sparse_key(key, &mut self.offsets)?;
|
||||
if self.last.is_some() {
|
||||
// Rows arrive in canonical sparse-key order.
|
||||
let previous = self
|
||||
.series_key
|
||||
.as_ref()
|
||||
.ok_or_else(|| invalid("missing sample series key"))?;
|
||||
require(
|
||||
exact > *previous,
|
||||
"sample input is not sorted by primary key",
|
||||
)?;
|
||||
// A different key that shares `(table_id, tsid)` is a different
|
||||
// series: it opens its own block, which records its exact key, so
|
||||
// timestamps and ordinals may restart.
|
||||
self.flush_block().await?;
|
||||
self.ordinal = 0;
|
||||
}
|
||||
self.series_key = Some(exact);
|
||||
}
|
||||
let id = match self.last {
|
||||
Some((id, last_time, last_seq)) if !new_series => {
|
||||
// The same exact series continues: the existing monotonic rules hold.
|
||||
require(
|
||||
time > last_time || (time == last_time && seq <= last_seq),
|
||||
"sample input is not sorted",
|
||||
)?;
|
||||
id
|
||||
}
|
||||
_ => decode_series_id(key, &mut self.offsets)?,
|
||||
};
|
||||
if self.rows.len() == self.options.block_rows
|
||||
|| !valid_rows(self.rows.len() + 1, fields.len())
|
||||
{
|
||||
@@ -299,12 +404,22 @@ impl SamplesWriter {
|
||||
return Ok(());
|
||||
}
|
||||
let rows = std::mem::take(&mut self.rows);
|
||||
let (mut block, chunks) = encode_block(&rows, &self.manifest, self.ordinal)?;
|
||||
let key = self
|
||||
.series_key
|
||||
.as_ref()
|
||||
.ok_or_else(|| invalid("sample block has no series key"))?;
|
||||
let (mut block, chunks) = encode_block(&rows, key, &self.manifest, self.ordinal)?;
|
||||
// Bytes one copy of this series' key costs in a part's key area: the key and its stored
|
||||
// checksum.
|
||||
let key_len = key.bytes().len() as u64 + metadata::KEY_CHECKSUM_BYTES;
|
||||
self.ordinal = self
|
||||
.ordinal
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| invalid("sample ordinal overflow"))?;
|
||||
let size = block.size();
|
||||
// The descriptor carries a fixed-size key reference, not the key, so this bound is a
|
||||
// function of the field count only: a key longer than a whole metadata page is no
|
||||
// longer a build failure, and only a narrower configured page can reject a descriptor.
|
||||
require(
|
||||
size + 12 <= self.options.page_bytes,
|
||||
"sample descriptor exceeds leaf limit",
|
||||
@@ -344,7 +459,18 @@ impl SamplesWriter {
|
||||
.last_mut()
|
||||
.ok_or_else(|| invalid("missing sample leaf"))?
|
||||
.push(block);
|
||||
if self.position >= self.options.part_bytes
|
||||
// Keys live in their own area but are still bytes of this part: count one copy per
|
||||
// series so the part target covers them too, and a key-heavy part still rolls over.
|
||||
// This is decided after the rollover above, which cleared the part's accounting: a series
|
||||
// that starts a new part still needs its copy counted in that part.
|
||||
if self.part_last_key.as_ref() != self.series_key.as_ref() {
|
||||
self.part_key_bytes = self
|
||||
.part_key_bytes
|
||||
.checked_add(key_len)
|
||||
.ok_or_else(|| invalid("sample key area overflow"))?;
|
||||
self.part_last_key = self.series_key.clone();
|
||||
}
|
||||
if self.position.saturating_add(self.part_key_bytes) >= self.options.part_bytes
|
||||
|| self.descriptor_bytes >= self.options.metadata_bytes
|
||||
{
|
||||
self.finish_part().await?;
|
||||
@@ -363,8 +489,24 @@ impl SamplesWriter {
|
||||
return Ok(());
|
||||
}
|
||||
let payload_end = self.position;
|
||||
// Keys go to their own area after the payload. Every block of one series shares one
|
||||
// copy, so the area holds one key per distinct series of this part.
|
||||
let (leaves, key_area) = place_keys(std::mem::take(&mut self.leaves), payload_end)?;
|
||||
if !key_area.is_empty() {
|
||||
self.position = self
|
||||
.position
|
||||
.checked_add(
|
||||
u64::try_from(key_area.len())
|
||||
.map_err(|_| invalid("sample key area overflow"))?,
|
||||
)
|
||||
.ok_or_else(|| invalid("sample file length overflow"))?;
|
||||
self.output_buffer
|
||||
.write(&mut self.outputs, &key_area)
|
||||
.await?;
|
||||
}
|
||||
let key_end = self.position;
|
||||
let mut children = Vec::new();
|
||||
for leaf in std::mem::take(&mut self.leaves) {
|
||||
for leaf in leaves {
|
||||
children.push(self.write_page(Page::Leaf(leaf)).await?);
|
||||
}
|
||||
let depth = if children.len() == 1 { 1 } else { 2 };
|
||||
@@ -386,6 +528,7 @@ impl SamplesWriter {
|
||||
depth,
|
||||
file_length,
|
||||
payload_end,
|
||||
key_end,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
@@ -413,6 +556,8 @@ impl SamplesWriter {
|
||||
self.position = 0;
|
||||
self.part_rows = 0;
|
||||
self.part_blocks = 0;
|
||||
self.part_key_bytes = 0;
|
||||
self.part_last_key = None;
|
||||
self.descriptor_bytes = 0;
|
||||
self.leaf_bytes = 12;
|
||||
Ok(())
|
||||
@@ -452,6 +597,23 @@ impl SamplesWriter {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl SamplesWriter {
|
||||
/// The current part's accounting, for the rollover tests: `(position, counted key bytes,
|
||||
/// blocks in the current part, finished parts)`.
|
||||
///
|
||||
/// A key copy that is not counted defers the part boundary silently, and the parts it makes
|
||||
/// are still readable, so only the accounting itself shows the difference.
|
||||
pub(super) fn part_accounting(&self) -> (u64, u64, u64, usize) {
|
||||
(
|
||||
self.position,
|
||||
self.part_key_bytes,
|
||||
self.part_blocks,
|
||||
self.manifest.files.len(),
|
||||
)
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl FormatWriter for SamplesWriter {
|
||||
async fn write(&mut self, batch: &RecordBatch) -> Result<()> {
|
||||
@@ -476,11 +638,20 @@ impl FormatWriter for SamplesWriter {
|
||||
self.outputs.cleanup().await
|
||||
}
|
||||
}
|
||||
fn encode_block(rows: &[Row], manifest: &Manifest, ordinal: u64) -> Result<(Block, Vec<Vec<u8>>)> {
|
||||
fn encode_block(
|
||||
rows: &[Row],
|
||||
key: &ExactSeriesKey,
|
||||
manifest: &Manifest,
|
||||
ordinal: u64,
|
||||
) -> Result<(PendingBlock, Vec<Vec<u8>>)> {
|
||||
let first = rows.first().ok_or_else(|| invalid("empty sample block"))?;
|
||||
let last = rows.last().ok_or_else(|| invalid("empty sample block"))?;
|
||||
let mut block = Block {
|
||||
id: first.id,
|
||||
require(
|
||||
rows.iter().all(|row| row.id == key.id()),
|
||||
"sample block mixes series",
|
||||
)?;
|
||||
let mut block = PendingBlock {
|
||||
key: key.clone(),
|
||||
min: first.time,
|
||||
max: last.time,
|
||||
ordinal,
|
||||
|
||||
@@ -27,7 +27,6 @@ use datatypes::arrow::record_batch::RecordBatch;
|
||||
use datatypes::prelude::ConcreteDataType;
|
||||
use datatypes::timestamp::timestamp_array_to_primitive;
|
||||
use mito_codec::row_converter::SparseOffsetsCache;
|
||||
use mito_codec::row_converter::sparse::SparsePrimaryKeyView;
|
||||
use object_store::ObjectStore;
|
||||
use parquet::file::metadata::KeyValue;
|
||||
use snafu::{OptionExt, ResultExt, ensure};
|
||||
@@ -36,9 +35,7 @@ use store_api::metadata::RegionMetadataRef;
|
||||
use store_api::storage::ColumnId;
|
||||
use store_api::storage::consts::ReservedColumnId;
|
||||
|
||||
use crate::error::{
|
||||
DecodeSnafu, InvalidMetaSnafu, InvalidRecordBatchSnafu, NewRecordBatchSnafu, Result,
|
||||
};
|
||||
use crate::error::{InvalidMetaSnafu, InvalidRecordBatchSnafu, NewRecordBatchSnafu, Result};
|
||||
use crate::series_index::{
|
||||
MAX_TS_COLUMN, MIN_TS_COLUMN, ROW_COUNT_COLUMN, TABLE_ID_COLUMN, TSID_COLUMN,
|
||||
};
|
||||
@@ -595,9 +592,9 @@ fn decode_primary_key(
|
||||
offsets: &mut SparseOffsetsCache,
|
||||
buf: &mut Vec<u8>,
|
||||
) -> Result<SeriesIndexRow> {
|
||||
let (id, tags) = decode_series_identity(primary_key, tag_columns, offsets, buf)?;
|
||||
let table_id = id.table_id;
|
||||
let tsid = id.tsid;
|
||||
let (key, tags) = decode_series_identity(primary_key, tag_columns, offsets, buf)?;
|
||||
let table_id = key.id().table_id;
|
||||
let tsid = key.id().tsid;
|
||||
|
||||
Ok(SeriesIndexRow {
|
||||
min_ts,
|
||||
@@ -610,27 +607,10 @@ fn decode_primary_key(
|
||||
}
|
||||
|
||||
/// Shared sparse-key extraction, independent of timestamp aggregation.
|
||||
pub(crate) fn decode_series_identity(
|
||||
primary_key: &[u8],
|
||||
tag_columns: &[(ColumnId, String)],
|
||||
offsets: &mut SparseOffsetsCache,
|
||||
buf: &mut Vec<u8>,
|
||||
) -> Result<(crate::series_index::MetricSeriesId, Vec<Option<String>>)> {
|
||||
let mut view = SparsePrimaryKeyView::new(primary_key, offsets).context(DecodeSnafu)?;
|
||||
let id = crate::series_index::MetricSeriesId {
|
||||
table_id: view.table_id(),
|
||||
tsid: view.tsid(),
|
||||
};
|
||||
let mut tags = Vec::with_capacity(tag_columns.len());
|
||||
for (column_id, _) in tag_columns {
|
||||
tags.push(
|
||||
view.label(*column_id, buf)
|
||||
.context(DecodeSnafu)?
|
||||
.map(str::to_owned),
|
||||
);
|
||||
}
|
||||
Ok((id, tags))
|
||||
}
|
||||
///
|
||||
/// The exact key it returns is what decides series identity; `MetricSeriesId` is
|
||||
/// only its coarse, hash-based prefix.
|
||||
pub(crate) use crate::series_index::identity::decode_series_identity;
|
||||
|
||||
/// Builds a timestamp array in `unit` from raw i64 values.
|
||||
fn ts_array(timestamps: impl Iterator<Item = i64>, unit: TimeUnit) -> ArrayRef {
|
||||
|
||||
@@ -531,8 +531,14 @@ async fn series_index_store_from_config(
|
||||
Ok(Some(store))
|
||||
}
|
||||
|
||||
/// Bump when the rebuildable local index layout becomes incompatible.
|
||||
const SERIES_INDEX_CACHE_VERSION: &str = "2";
|
||||
/// Marks the rebuildable local index layout, and resets the store when it changes.
|
||||
///
|
||||
/// This branch is experimental and has no deployments, so it pins the token at `1`: a
|
||||
/// cache written by any other build is discarded on startup and rebuilt from the SSTs.
|
||||
/// Artifacts are additionally rejected on open when they lack the mandatory
|
||||
/// `identity_encoding` discriminator, so a stale artifact is never served even if the
|
||||
/// reset did not happen (a read that precedes it, or an artifact outside this cache).
|
||||
const SERIES_INDEX_CACHE_VERSION: &str = "1";
|
||||
|
||||
/// Resets incompatible caches before any worker or reader can use their catalogs.
|
||||
async fn initialize_series_index_cache(store: &ObjectStore) -> Result<()> {
|
||||
|
||||
@@ -5,7 +5,7 @@ This fixture freezes the version-1 sample wire contract in
|
||||
the owning Mito Rust test, not SQLness discovery:
|
||||
|
||||
```shell
|
||||
cargo nextest run -p mito2 frozen_v1_samples_fixture_and_unknown_codec
|
||||
cargo nextest run -p mito2 frozen_v1_samples_fixture_is_rejected
|
||||
```
|
||||
|
||||
`v1-manifest.json` and `v1-part.hex` describe a 397-byte part with raw compression,
|
||||
@@ -26,11 +26,23 @@ little-endian fields and XXH64 with seed zero. Its layout is:
|
||||
| 53..285 | Leaf/root: one block, four column references |
|
||||
| 285..397 | Trailer with root reference and checksum |
|
||||
|
||||
The test installs these exact bytes and recorded object sizes, opens them through
|
||||
`SamplesFormat`, and verifies the values and order. It also changes a column
|
||||
codec to an unknown ID while updating checksums to test that unrequested columns
|
||||
can be skipped and requested unknown codecs fail explicitly. Legacy catalog
|
||||
identifier coverage remains in `series_index::catalog` tests.
|
||||
The current reader writes the same wire version as this fixture, so the version
|
||||
number alone does not reject it. The manifest carries no `identity_encoding`
|
||||
discriminator, and the part records only `(table_id, tsid)`, which is a hash: a
|
||||
part whose two series were merged is structurally valid and ordered exactly like a
|
||||
correct one. The test installs these exact bytes and recorded object sizes and
|
||||
asserts that opening them through `SamplesFormat` fails on the missing
|
||||
`identity_encoding` discriminator, which is what makes maintenance rebuild the
|
||||
artifact from its SSTs instead of reusing it. Legacy catalog identifier coverage
|
||||
remains in `series_index::catalog` tests.
|
||||
|
||||
The part bytes above also predate two later version-1 shapes, neither of which is
|
||||
migrated: block descriptors now carry a fixed-size key reference into a separate key
|
||||
area instead of the key bytes, and the trailer records that area (`key_end`) and is
|
||||
120 bytes, not 112. The fixture is still rejected for the same reason — the
|
||||
discriminator is missing — and that rejection happens while opening the manifest,
|
||||
before any part byte is decoded, so the frozen bytes are not a reader for the current
|
||||
part layout and must not be regenerated to look like one.
|
||||
|
||||
Do not regenerate this fixture just to make a reader change pass. Preserve its
|
||||
bytes for future reader compatibility; introduce a separate versioned fixture
|
||||
|
||||
Reference in New Issue
Block a user