feat(metastore): Add TableOfContentsWriter.ReplaceIndexPointers method - #21932
Conversation
| if err != nil { | ||
| return false, errors.Wrap(err, "reading rows") | ||
| } | ||
| if n == 0 { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
| if err != nil { | ||
| return errors.Wrap(err, "reading rows") | ||
| } | ||
| if n == 0 { |
|
|
||
| // Three tenants, each with three rows at well-separated time ranges. | ||
| seedRows := []tocRow{ | ||
| {"tenantA", "idx/a-0", 10, 20}, |
There was a problem hiding this comment.
This test would be better if multiple tenants pointed at the same index paths
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
cfef6a2 to
b8b9ba6
Compare
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.
| // 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. |
There was a problem hiding this comment.
(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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
And what does "swapped" indicate? Because it aborts, nothing is actually swapped as far as I can tell.
There was a problem hiding this comment.
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") |
There was a problem hiding this comment.
(nit) fmt.Errorf should be enough according to https://github.com/grafana/loki/blob/main/CODING_STANDARDS.md#error-handling
| window time.Time, | ||
| tenant string, | ||
| oldPaths []string, | ||
| newEntries []TableOfContentsEntry, |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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
| 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 |
There was a problem hiding this comment.
- 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
left a comment
There was a problem hiding this comment.
Left a bunch of comments but overall looks good to me 👍
|
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 |
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'sIndexConsolidatetask needs to replace a tenant's source-index pointers with a single covering-index pointer in one conditional PUT.ReplaceIndexPointersis 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
WriteEntrypattern inpkg/dataobj/metastore/toc_writer.go: boundedbackoffretry loop wrappingbucket.GetAndReplace(which gives us the S3If-Match/If-None-Matchconditional-PUT contract via thesizedGetAndReplaceBucketwrapper inpkg/storage/bucket/client.go). The callback:ReadCloser).scanForMatches: if ANYoldPathis 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.indexobj.BuilderviareplayFiltered, preserving every other tenant's rows verbatim and dropping the target tenant'soldPaths, then appendingnewEntriesfor the target tenant.oldPaths, nonewEntries), the same sentinel is returned withswapped=trueso the operation reports success without writing an empty ToC.objstore.NopCloserWithSize(bytes.NewReader(newBytes))— the size-bearing reader required by the spec's Sharp-edge to preserve (otherwiseIf-Matchis silently dropped by minio-go on S3 multipart uploads).A deterministic fast-path returns
(false, nil)whenlen(oldPaths) == 0without touching object storage, so the no-op contract holds even against a degraded bucket.Retry budget:
MinBackoff 50ms, MaxBackoff 10s, MaxRetries 10. An internalreplaceIndexPointers(..., 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:oldPathsempty, alloldPathsalready 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
oldPathis 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.