apache / apache/lucene

Expose per-field on-disk byte-range layout from codec readers

Open
#16,392 6 comments 0 reactions 0 assignees View on GitHub
type:enhancement
Dominant language
Java
Stars
3.6k
Forks
1.4k
Avg merge
2d 11h
Merged PRs (30d)
88

Description

# Expose per-field on-disk byte-range layout from codec readers

Lucene readers know a great deal about how bytes are laid out on disk — which byte ranges of a segment file belong to which field — but that information is not observable today. There is no general way to ask "how many bytes of file X does field Y occupy?" across postings, doc values, KNN vectors, points, etc.

This makes it hard to answer operationally important questions:
- Which fields dominate a segment's on-disk footprint?
- Given a memory-residency signal (e.g. `mincore(2)`/`cachestat(2)`), which fields are "cold" (least resident in the page cache) and therefore candidates for cost or latency attention?
- How is a field's storage split across the formats that store it (postings vs. doc values vs. points)?

Today the only ways to get at this are indirect and fragile: summing whole-file sizes (no per-field breakdown), or reflecting into codec internals to recover per-field file pointers (brittle, breaks on every codec change).

## Precedent

`KnnVectorsReader#getOffHeapByteSize(FieldInfo)` (#14426) already established that a reader can report its per-field on-disk size, and that this is useful enough to live on the reader API. That method returns a per-field, per-extension **total**. This proposal generalizes the same idea in two ways:
1. To **any** format (not just KNN vectors)
2. From a per-field *total* to a per-field *byte-range layout* so a consumer can correlate ranges with offset-addressed signals like page-cache residency, read-access histograms, etc.

## Proposed shape

### The Data: an `Extent` and its `Attribution`

A contiguous byte range, and what it belongs to:

```java
/** A contiguous byte range within a file, and a descriptor of its purpose. */
record Extent(long offset, long length, Attribution attribution) {}

/** What the bytes in an Extent belong to. */
sealed interface Attribution {
/** Bytes belonging to a single field. */
record Field(String name) implements Attribution {}
/** Codec-declared overhead: header, footer, padding, or other field-independent metadata. */
record Structural() implements Attribution {}
/** Bytes no reader described: a gap within a file, or a file no reader reports. */
record Unclaimed() implements Attribution {}
}
```

Modeling attribution as a sealed type (rather than a nullable field name or a set) distinguishes the cases that consumers genuinely need to treat differently (attributed, structural overhead, and not-yet-described) while leaving room to add other kinds later (e.g. a range shared by several fields) without silently breaking existing consumers, who get a compile error until they handle the new case.

### The API

```java
public interface DiskAccountable {
/**
* On-disk layout of the files this component manages, keyed by file name. Each value is
* a list of non-overlapping Extents sorted by offset that together cover the whole file
* [0, fileLength): ranges no reader describes appear as Attribution.Unclaimed rather than
* being omitted, so every byte is accounted for exactly once. Returns an empty map by default.
*/
default Map> getDiskLayout() throws IOException {
return Map.of();
}
}
```

The natural home is per-segment, and the file key is built up as the layout bubbles up through the reader tiers — each tier contributing the part of the name it knows:
1. **Leaf codec readers** (`FieldsProducer`, `DocValuesProducer`, `KnnVectorsReader`, `PointsReader`, ...) describe the files they manage. A reader has its extension and its `segmentSuffix` (from `SegmentReadState`) but not the segment name, so it can name a file by everything *except* that prefix — e.g. a flat vectors reader's `.vec`.

2. **Per-field formats** (`PerFieldKnnVectorsFormat`, etc.) delegate to one inner reader per format+instance, each constructed with a distinguishing `segmentSuffix` (`Lucene99_0`, `Lucene99_1`, ...). That suffix is what separates the multiple physical files a per-field format produces — `_Lucene99_0.vec` vs `_Lucene99_1.vec`.

3. **`CodecReader`** merges its child readers' layouts, and **`SegmentReader`** — the only tier that knows the segment name and holds the segment's file list — completes each key into a full file name (`_5_Lucene99_0.vec`) and reconciles the merged result against `SegmentCommitInfo#files()`: any file no child reader described is added as a whole-file `Unclaimed` extent. The segment-level result is therefore keyed by globally-unique full file names and covers every file in the segment — including formats that do not (yet) implement `DiskAccountable`, which simply surface as `Unclaimed` and can be attributed incrementally as readers adopt the interface.

A consumer walks a `SegmentReader`'s layout, correlates each `Extent` with whatever offset-addressed signal it cares about, and aggregates per `Attribution`.

## Open questions for discussion

1. **Cost.** Building a per-file `Extent` graph for every field of every segment on every call has a GC/CPU cost that grows with field count (which can be high). Should the base API lean toward a visitor/callback style (accumulate into a caller-supplied sink, allocating nothing) rather than materializing an actual `Map>`? A segment's layout should not change after it is written, so caching by segment **core** identity (`LeafReader#getCoreCacheHelper`, which is stable across new deletions) is the intended mitigation, but the base API shape still matters. Interested in preferences here.

2. **Attribution kinds.** `Field` / `Structural` / `Unclaimed` covers the basics. Is there appetite for modeling a range shared by multiple fields (e.g. deduplicated vectors), or is that better left out until a concrete need exists? Sharing also breaks the one-field-one-contiguous-run intuition — a shared range belongs partly to several fields at once — so any per-field total over shared ranges needs an explicit split policy (equal? by reference count?) rather than a simple sum.

3. **Which readers.** A useful first cut covers postings, doc values, KNN vectors, and points (BKD). Stored fields and term vectors are block-compressed with interleaved fields and are much harder to attribute per-field; they would surface as `Unclaimed` until/unless someone tackles them.

4. **Full coverage vs. best-effort.** This sketch proposes the segment-level result cover `[0, fileLength)` for every file (gaps and unreported files become `Unclaimed`), which makes totals reconcile against the directory's file sizes. The alternative — allow gaps, omit unreported files — is cheaper but leaves the caller to reconcile. Preference? Reconciliation is a diff against `SegmentCommitInfo#files()` in both directions: a file in `files()` that no reader described becomes a whole-file `Unclaimed` (the common case). The reverse — a reader reporting a file *not* in `files()` — would indicate the reader's view and the commit's file list disagree; the sketch treats that as a bug to surface (fail fast) rather than silently drop, but the API contract for it is worth pinning down.

5. **Keying.** Lucene already models a file name as `(segmentName, segmentSuffix, ext)` and constructs it with `IndexFileNames#segmentFileName(...)`; a reader has its `segmentSuffix` and `ext` at construction (via `SegmentReadState`) but not the segment name. So rather than have readers emit an opaque suffix string that a higher tier concatenates, the layout key could align with that existing model — readers key by `segmentSuffix`/`ext`, and `SegmentReader` completes the name with the canonical `segmentFileName(...)` (the same call the write path used, so it round-trips against `files()` exactly). This avoids re-implementing name construction and its edge cases. Worth deciding what the key type should be (structured vs. string) at the interface level.

6. **Compound files.** Under `CompoundFormat`, child readers describe *logical* files (`.doc`, `.dvd`, ...) at offsets within those logical files, but on disk there is only the physical `.cfs` (plus `.cfe`). A raw merge would therefore key by logical files that do not physically exist. `SegmentReader` is the tier that could reconcile this: it knows whether the segment is compound and holds the compound `Directory`, which maps each logical file to its offset within `.cfs`. Options range from translating logical extents into `.cfs`-relative ones (preserves whole-byte accounting, but needs the compound directory to expose per-entry offsets), to reporting the `.cfs` as a single `Unclaimed` file (no per-field breakdown inside compound segments), to leaving CFS out of scope initially. This likely needs input from folks who own `CompoundFormat`.

## Prior art / relationship to existing APIs

- `getOffHeapByteSize` (#14426): per-field, per-extension totals for KNN vectors. This is the range-level generalization, across all formats.
- `Accountable`/`ramBytesUsed`: RAM, not disk; whole-component, not per-field.
- `CheckIndex` / `SegmentInfos#files()`: whole-file sizes, no per-field breakdown.

Happy to put up a draft PR implementing this across the readers above plus the `CodecReader`/`SegmentReader` composition, if there's interest in the direction.

Contributor guide

Open the contributing guide

Research direction

Start by reviewing the proposed DiskAccountable API alongside FieldsProducer, DocValuesProducer, KnnVectorsReader, PointsReader, CodecReader, and SegmentReader. Resolve the open questions around file naming, compound files, coverage, attribution, and API shape before defining a first implementation scope. Done means an agreed design and tests demonstrating correct layout composition and file accounting.

Written by the indexing model from the issue text.

Assessment

Tech stack
java
Domain
backend, search
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Needs clarification
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.