Skip to content

feat(metastore): Add TableOfContentsWriter.ReplaceIndexPointers method - #21932

Merged
trevorwhitney merged 12 commits into
mainfrom
dataobj-compactor/replace-index-pointers
May 20, 2026
Merged

feat(metastore): Add TableOfContentsWriter.ReplaceIndexPointers method#21932
trevorwhitney merged 12 commits into
mainfrom
dataobj-compactor/replace-index-pointers

Conversation

@trevorwhitney

@trevorwhitney trevorwhitney commented May 15, 2026

Copy link
Copy Markdown
Collaborator

What

Introduces TableOfContentsWriter.ReplaceIndexPointers(ctx, window, tenant, oldPaths, newEntries) (swapped bool, err error) — an atomic, idempotent ToC mutation primitive that swaps a set of index pointers for one tenant within a metastore window while preserving every other tenant's section unchanged.

Why

The existing metastore code only supports append-style writes (WriteEntry / AppendIndexPointer); the compactor's IndexConsolidate task needs to replace a tenant's source-index pointers with a single covering-index pointer in one conditional PUT. ReplaceIndexPointers is the cycle's terminal mutation. This PR ships the primitive alone with no callers — default Loki behavior is unchanged.

In the future we may want to consider an append-only pattern for the ToCs, allowing appends that would negate previous entries, but that is a thought experiment for another time.

How

Mirrors the existing WriteEntry pattern in pkg/dataobj/metastore/toc_writer.go: bounded backoff retry loop wrapping bucket.GetAndReplace (which gives us the S3 If-Match/If-None-Match conditional-PUT contract via the sizedGetAndReplaceBucket wrapper in pkg/storage/bucket/client.go). The callback:

  1. Drains the existing ToC bytes up-front (never returns a drained ReadCloser).
  2. Idempotency / race-loss check via scanForMatches: if ANY oldPath is still present in the target tenant's section, proceed; if none match, abort the conditional PUT via an internal sentinel error (errReplaceNoOp) and return (false, nil). The sentinel intentionally aborts the write so we never materialize an empty blob on the missing-ToC path.
  3. Rebuilds the ToC into a fresh indexobj.Builder via replayFiltered, preserving every other tenant's rows verbatim and dropping the target tenant's oldPaths, then appending newEntries for the target tenant.
  4. Full-drain handling: if the rebuild produces an empty builder (single-tenant ToC, all rows in oldPaths, no newEntries), the same sentinel is returned with swapped=true so the operation reports success without writing an empty ToC.
  5. The successful-rebuild path returns objstore.NopCloserWithSize(bytes.NewReader(newBytes)) — the size-bearing reader required by the spec's Sharp-edge to preserve (otherwise If-Match is silently dropped by minio-go on S3 multipart uploads).

A deterministic fast-path returns (false, nil) when len(oldPaths) == 0 without touching object storage, so the no-op contract holds even against a degraded bucket.

Retry budget: MinBackoff 50ms, MaxBackoff 10s, MaxRetries 10. An internal replaceIndexPointers(..., backoffCfg) helper accepts an explicit backoff config — used only by the retry-exhaustion test (1ms→5ms, MaxRetries=3) to keep wall time <100ms.

Semantics summary

  • (true, nil) — swap applied.
  • (false, nil) — idempotent no-op: oldPaths empty, all oldPaths already gone (race-loss), or full-drain success.
  • (false, err) — transient error after retry exhaustion.

The race-loss check uses ANY-match (per the spec sketch): if ANY oldPath is still present we proceed and drop the matched subset. The godoc explicitly documents this and notes that cross-cycle partial overlaps produce bounded duplicate entries that the next cycle's plan will re-merge.

@trevorwhitney
trevorwhitney requested a review from a team as a code owner May 15, 2026 16:02
@trevorwhitney trevorwhitney changed the title feat(metastore): Add TableOfContentsWriter.ReplaceIndexPointers primitive (A7) May 15, 2026
Comment thread pkg/dataobj/metastore/toc_replace.go Outdated
if err != nil {
return false, errors.Wrap(err, "reading rows")
}
if n == 0 {

@benclive benclive May 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this should trigger a break. AFAIR n == 0 is a valid return and you need to wait for an explicitly EOF

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup, good catch, though I'm not sure in reality how this would happen with the ToCs? isn't the usual case where this could happen when all rows are filtered out via a predicate, which we'll never do here? either way, you're right, and I've removed them.

Comment thread pkg/dataobj/metastore/toc_replace.go Outdated
if err != nil {
return errors.Wrap(err, "reading rows")
}
if n == 0 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done


// Three tenants, each with three rows at well-separated time ranges.
seedRows := []tocRow{
{"tenantA", "idx/a-0", 10, 20},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test would be better if multiple tenants pointed at the same index paths

@trevorwhitney trevorwhitney May 15, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good call, I refactored to have both, as compaction should work both from multi-tenant idx files -> single tenant and from N single-tenant files -> 1

// present in the target tenant's current section, the swap proceeds and
// drops the matched subset (leaving non-matched oldPaths' replacements, if
// they were already swapped in by a concurrent coordinator, untouched).
// Only when ZERO oldPaths match is the call treated as a no-op. Callers

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand the semantics here. Does compaction require some old paths to remove? Is it intended to be a 1:1 swap of old for new, or can the sizes of each set differ?

@trevorwhitney trevorwhitney May 15, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's talking about index paths, we've compacted N indexes -> 1 (or N, actually, for the L0 -> L1 multi-tenant -> single tenant compaction), and is operating under the assumption (which will be true once we start compacting log objects), that we will likely need to run multiple compactions before we converge to a single index file. The "ANY oldPath" is recovery/re-entry logic, in case there was a partial update (ie. some workers failed, but other's succeeded)

…ReplaceIndexPointers

Addresses holistic /code-review findings:

- empty oldPaths must no-op deterministically without storage I/O (was: full GET round-trip subject to retries on degraded buckets)

- partial-match (ANY-match) semantic documented explicitly in godoc

- regression test asserts 0 GetAndReplace calls on empty oldPaths even with a failing bucket

- new delete-only test covers successful swap with empty newEntries
…ters

When a single-tenant ToC has every row listed in oldPaths and newEntries is empty,

the rebuild path appended zero rows and indexobj.Builder.Flush returned ErrBuilderEmpty,

which the retry loop treated as a transient error. The operation logically succeeded —

the caller's view of the source paths is gone — so the primitive now routes the empty-

builder case to the existing errReplaceNoOp sentinel path with swapped=true, leaving

the existing ToC bytes in place. The outer sentinel handler now returns swapped

instead of hardcoding false so the success signal propagates.
dataset.RowReader documents (n==0, err==nil) as a valid "no progress this

call" return — e.g. when a predicate filters every row in a batch — and

callers must keep reading until io.EOF (see

pkg/dataobj/internal/dataset/row_reader.go:114-116 and

row_reader_basic.go:199-200, both citing the io.Reader convention).

scanForMatches and replayFiltered were breaking on n==0 as a safety net,

which would silently truncate iteration if a future predicate ever made

(0, nil) reachable on the indexpointers.RowReader path.

Also switches err == io.EOF to errors.Is for wrapped-EOF tolerance and

uses Go 1.22+ integer range over the batch buffer.
@trevorwhitney
trevorwhitney force-pushed the dataobj-compactor/replace-index-pointers branch from cfef6a2 to b8b9ba6 Compare May 15, 2026 19:42
Adds a second sub-test that seeds a ToC where multiple tenants' sections

reference the same idx/... paths \u2014 the multi-tenant L0 shape \u2014 and asserts

the swap removes those paths from the target tenant ONLY, leaving every

other tenant's references to the shared L0 indexes intact.

This exercises the `sectionTenant == tenant` guard in replayFiltered;

without it the prior single-case test could not have caught a regression

that dropped rows across all tenants when paths collided.
Comment on lines +77 to +80
// Deterministic fast-path: with no oldPaths to remove, the call would always
// converge to a sentinel-aborted no-op inside the callback. Returning here
// makes the no-op behavior independent of bucket availability so callers can
// rely on (false, nil) without error-handling for transient storage issues.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nit) IMHO removing this comment makes things more straightforward 😅


newObj, closer, ferr := builder.Flush()
if ferr != nil {
// Full drain: every row in every section was filtered out and

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this case valid? If it removed all the old paths and didn't add any new ones, shouldn't it be written anyway in order to drop the old paths?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And what does "swapped" indicate? Because it aborts, nothing is actually swapped as far as I can tell.

@ivkalita ivkalita May 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it removed all the old paths and didn't add any new ones

I believe we should detect it way earlier, before the flushing

UPD: I think I misunderstood Ben's comment first. My chain of thoughts was that we should always have at least 1 new path, so maybe it's worth to detect when len(oldPaths) == 0 || len(newPaths) == 0 way earlier and fail there

var rerr error
existingBytes, rerr = io.ReadAll(existing)
if rerr != nil {
return nil, errors.Wrap(rerr, "reading existing ToC")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

window time.Time,
tenant string,
oldPaths []string,
newEntries []TableOfContentsEntry,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about len(newEntries) == 0 case?


newObj, closer, ferr := builder.Flush()
if ferr != nil {
// Full drain: every row in every section was filtered out and

@ivkalita ivkalita May 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it removed all the old paths and didn't add any new ones

I believe we should detect it way earlier, before the flushing

UPD: I think I misunderstood Ben's comment first. My chain of thoughts was that we should always have at least 1 new path, so maybe it's worth to detect when len(oldPaths) == 0 || len(newPaths) == 0 way earlier and fail there

Comment on lines +166 to +196
newObj, closer, ferr := builder.Flush()
if ferr != nil {
// Full drain: every row in every section was filtered out and
// no newEntries were appended, so the rebuilt ToC would be
// empty. Treat this as a successful swap and abort the
// conditional PUT — leaving the existing ToC in place is
// acceptable here because the original entries are gone in
// the caller's view and a future cycle will replace the ToC
// when fresh content arrives. A full-drain ToC delete would
// be a separate primitive.
if stderrors.Is(ferr, indexobj.ErrBuilderEmpty) {
swapped = true
return nil, errReplaceNoOp
}
return nil, errors.Wrap(ferr, "flushing rebuilt ToC")
}
reader, rerr := newObj.Reader(ctx)
if rerr != nil {
_ = closer.Close()
return nil, errors.Wrap(rerr, "opening rebuilt ToC reader")
}
newBytes, rerr := io.ReadAll(reader)
closeErr := stderrors.Join(reader.Close(), closer.Close())
if rerr != nil {
return nil, errors.Wrap(rerr, "reading rebuilt ToC")
}
if closeErr != nil {
return nil, errors.Wrap(closeErr, "closing rebuilt ToC")
}
swapped = true
return objstore.NopCloserWithSize(bytes.NewReader(newBytes)), nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • we flush dataobj to newObj
  • then we create a reader newObj.Reader(ctx)
  • then we read all from this reader and close it
  • finally we create a new reader from the read bytes

is it necessary to create a new reader? Are there benefits comparing to the approach in toc_writer.go where we return a wrapped newObj.Reader(ctx) reader?

@ivkalita ivkalita left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a bunch of comments but overall looks good to me 👍

@trevorwhitney
trevorwhitney merged commit 10fc249 into main May 20, 2026
96 checks passed
@trevorwhitney
trevorwhitney deleted the dataobj-compactor/replace-index-pointers branch May 20, 2026 16:51
@trevorwhitney

Copy link
Copy Markdown
Collaborator Author

I merged so I could keep moving with my local branches which are ahead of this and getting bit complicated to manage. Feedback is addressed in a followup: #21971

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

3 participants