Skip to content

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

Description

@abernardi597

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:

/** 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

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<String, List<Extent>> 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<String, List<Extent>>? 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

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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions