fix(posting): stop applying a negative first at the posting layer, plus review follow-ups for #9809 - #3
Merged
gooohgb merged 6 commits intoSep 3, 2026
Conversation
Uids() returned a different uid set for a negative First depending on whether
calculatedUids happened to be materialized. The memoized branch returned early
and skipped the trim at the bottom of the function, so a warm read handed back
the whole list where a cold one handed back the last N:
first=-2 after=0 isect=nil cold=[8 10] warm=[2 4 6 8 10]
first=-2 after=4 isect=nil cold=[8 10] warm=[6 8 10]
The warm answer is the correct one, so the trim goes rather than being extended
to both paths. Trimming here is unsound for an index read, because the worker
post-filters the list afterwards, in helpProcessTask, after handleUidPostings
has already returned: handleCompareFunction re-checks the real values when the
tokenizer is lossy, and filterGeoFunction re-checks the real geometry. A uid
this trim drops is one that filter never sees, and x.PageRange cannot put it
back.
name: string @index(term) .
0x1,0x2 name "great" 0x3,0x4,0x5 name "great wall"
q(func: eq(name, "great"), first: -2)
The term index is lossy, so the bucket for "great" is {1,2,3,4,5} and
handleCompareFunction is what narrows it to the two exact matches. Trimmed to
the last two first, the bucket is {4,5}, the filter drops both, and the query
answers nothing. This is what a cold read does today; it is what both reads
would do if the paths were aligned the other way.
Nothing depended on the trim. Uids has eight production callers, only three can
carry a nonzero First, and every one of them sits behind a pagination pass:
calculatePaginationParams pushes a count down only when Params.Count != 0, and
applyPagination no-ops only when Count == 0 && Offset == 0, so a pushdown always
has an x.PageRange behind it. The one caller that does rely on worker-side
truncation is `has` at root, which never reaches Uids because checkRoot leaves
it with n == 0.
The early stop for a positive first stays. It is paired with a real saving --
the read stops rather than materializing the rest -- and its own soundness rests
on calculatePaginationParams keeping the functions that read a list per token
and intersect them off the pushdown entirely. That was untested, so
TestPaginationPushdownExcludesIntersectingFunctions now pins it.
Removing the trim also removes a panic: a First of math.MinInt sliced out of
range, because negating it wraps back to itself and the length guard then
passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
opt.First is normalized to math.MaxInt32 at the top of Uids and never assigned again, so the `|| opt.First == 0` bail-out cannot fire, and the `&& applyIntersectWith` in the line below it is already known true. Both read as though a zero First takes some other route through the tail, which it does not. Rename applyIntersectWith to postProcess while renaming is cheap: it gates the truncation as well as the intersect, so the name was already wrong. No behavior change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A failed warm was logged at warning level and then left the list open to being warmed again. Every reader that wins the election on that key repeats the whole walk, and for a multi-part list that means a Badger read per split, so both the wasted work and the log line recur at read QPS. The log rate was the symptom; the repeated walk is the cost. Park the list instead. uidWarmState gains a third state, and a walk that fails leaves it there so no later reader retries. setMutationAfterCommit lifts it again, which matters because remove-on-update defaults to false: an ordinary commit applies in place on the published list rather than replacing the entry, so without that the first failure would stick for the life of the entry. What is left is one attempt, and one log line, per commit to the key -- the same bound doRollup gets from its own per-key dedupe. finishUidWarm now compares and swaps rather than storing, so it is safe to defer alongside abandonUidWarm on the failing path. The warning on the disk path keeps firing unconditionally. It runs per cache miss rather than per read, and it is where an operator should first see that a list has stopped being readable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
uidReadFirst pushes down first + offset, but offset is parsed straight out of the query with no lower bound (params.fill in query/query.go), so a negative one subtracts from the read. `first: 10, offset: -1` pushes down 9 and the query comes back one uid short. A large enough one drops the bound altogether: `first: 10, offset: -100` pushes down -90, and a negative first reads the whole list. Clamp it, which is what x.PageRange itself does with a negative offset when it paginates the result, so the pushdown and the pass behind it now agree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
require.Equal on two *uint64 falls through to reflect.DeepEqual, which follows both pointers and compares the uids they address. The assertion passed for two distinct arrays that happened to start with the same uid, which is exactly the case it was written to rule out: it was meant to show that publishCalculatedUids hands the slice over rather than recomputing it. require.Same compares the pointers. Verified by making publishCalculatedUids copy the slice -- require.Equal still passed, require.Same fails. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment on warmCachedUids said iterating the private copy is safe "because setMutationAfterCommit replaces those maps instead of writing into them". That is a property of one call site, not of the function: only the refresh=true path rebuilds committedEntries and committedUids before writing, and refresh=false writes both in place. Production only reaches a cached list through updateItemInCache, which passes true, so the code is correct -- but the comment as written would tell whoever adds the next caller that a refresh=false commit on a published list is fine, and it is not. That one is a fatal concurrent map access, not a race that might go unnoticed. Also softened the justification for swallowing a warm error. Warming must not be the thing that fails a read, but the read often fails anyway on the same unreadable split once it walks the list itself, so claiming the cache "can otherwise serve" it overstated the case. Comments only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-ups to the review on dgraph-io#9809, based on your
71cae0b9. Six commits, each independent — take, change, or drop any of them. Merging intofix-calculated-uids-materializationfolds them into dgraph-io#9809.Three of these are things I got wrong or left wrong in the last round, so it is mostly cleanup after myself. One of them changed direction under verification, and that is the important one.
1.
fix(posting): stop applying a negative first to the posting readI originally reported this as "the memoized path should trim a negative
firstthe way the walk path does". That was the wrong direction, and adversarial review caught it before it shipped. The trim comes out instead.Trimming in
Uidsis unsound for an index read, because the worker post-filters the list afterwards — inhelpProcessTask, afterhandleUidPostingshas already returned.handleCompareFunctionre-checks the real values when the tokenizer is lossy;filterGeoFunctionre-checks the real geometry. A uid the trim drops is one that filter never sees, andx.PageRangebehind it cannot put it back.The term index is lossy, so the bucket for
"great"is{1,2,3,4,5}andhandleCompareFunctionis what narrows it to the two exact matches. Trimmed to the last two first, the bucket is{4,5}, the filter drops both, and the query answers nothing. That is what a cold read does today. It is what both reads would have done if I had aligned the paths the way I first proposed.allofis the same shape through intersection rather than post-filtering: it is not incalculatePaginationParams' exclusion list,needsIntersectmatches it, and the query layer intersects the per-token lists before paginating.tail(A) ∩ tail(B) ≠ tail(A ∩ B).So the warm answer was the correct one all along, and removing the trim fixes the cold path too — which has been wrong since
441d3033. Over a 28-case matrix ofFirst×AfterUid×Intersect, warm and cold now agree everywhere.Nothing depended on the trim.
Uidshas eight production callers, only three can carry a nonzeroFirst, and every one sits behind a pagination pass:calculatePaginationParamspushes a count down only whenParams.Count != 0, andapplyPaginationno-ops only whenCount == 0 && Offset == 0, so a pushdown always has anx.PageRangebehind it. The one caller that does rely on worker-side truncation ishasat root, which never reachesUidsbecausecheckRootleaves it withn == 0.The early stop for a positive
firststays. It is paired with a real saving, and its soundness rests oncalculatePaginationParamskeeping the per-token-intersect functions off the pushdown entirely. That was untested, soTestPaginationPushdownExcludesIntersectingFunctionsnow pins it.Removing the trim also removes a panic: a
Firstofmath.MinIntsliced out of range, because negating it wraps back to itself and the length guard then passed.This partially reverts
bc52f9351from the last round — the tail copy it added is unreachable once the trim is gone.2.
refactor(posting): drop the unreachable checks in the Uids tailopt.Firstis normalized tomath.MaxInt32at the top and never reassigned, so|| opt.First == 0cannot fire and the&& applyIntersectWithbelow it is already known true. Renamed topostProcess, since it gates the truncation as well as the intersect.3.
perf(posting): stop retrying a UID warm that cannot succeedYou flagged the log rate; the log was the cheap part. Every reader that wins the election on an unreadable key repeats the whole walk, plus a Badger read per split. So the list gets parked instead:
uidWarmStategains a third state, and a failed walk leaves it there.The correction to what I wrote last time: I claimed this would self-heal when the cache entry was replaced. It would not.
remove-on-updatedefaults tofalse, so an ordinary commit applies in place on the published list and keeps the entry — a single failure would have stuck for the life of that entry.setMutationAfterCommitnow lifts the give-up, so a failure costs one attempt per commit to the key, the same bounddoRollupgets from its own per-key dedupe.4.
fix(worker): a negative offset must not shrink a bounded readuidReadFirstis mine from the last round, and it addsoffsetunclamped.offsetis parsed with no lower bound inparams.fill, sofirst: 10, offset: -1pushes down 9 and the query comes back a uid short;offset: -100pushes down -90 and drops the bound entirely. Clamped, which is whatx.PageRangedoes with a negative offset when it paginates behind it.5.
test(posting): assert slice identity in the warm handoverrequire.Equalon two*uint64falls through toreflect.DeepEqual, which follows both pointers and compares the uids they address — so it passed for two distinct arrays starting with the same uid, which is exactly what it was written to rule out.require.Same. Confirmed by makingpublishCalculatedUidscopy the slice:Equalstill passed,Samefails.6.
docs(posting): state the copy-on-write guarantee where it actually holdsMy comment said iterating the private copy is safe "because
setMutationAfterCommitreplaces those maps instead of writing into them". That is true of one call site, not of the function: onlyrefresh=truerebuilds the maps first, andrefresh=falsewrites both in place. Production only reaches a cached list throughupdateItemInCache, which passes true — but the comment as written would tell whoever adds the next caller that arefresh=falsecommit on a published list is fine, and that one is a fatal concurrent map access.Verification
go build ./...,gofmtclean,go vetwith no new findings,go test ./posting/ -racegreen in full,./worker/and./query/green. Each fix confirmed to fail without it, including the two panics and the two weak assertions.Open, and deliberately not in here
A dropped publish still re-arms the warm. This is mine, from the non-blocking warm last round, and I under-described the trade at the time. When a commit lands mid-walk,
publishCalculatedUidsdrops the result andfinishUidWarmre-arms the election, so the next reader walks again. On a key that is both read-hot and write-hot that can loop. It is bounded to one walk in flight per key rather than one per read, since concurrent readers lose the CAS and serve unwarmed — so the cost is a background walk that achieves nothing, not a per-read cost. I did not fix it: parking on a drop would leave a key that stops being committed unwarmed indefinitely, and thedoRollup-style per-key attempt throttle wants a mutex on the read path. Worth its own change with its own thinking.The pushdown is still unsound for a positive
first. Removing the negative trim fixes that sign for every post-filtered and intersected function. The positive early stop has the same problem and cannot be fixed the same way, because it is load-bearing as an optimization.calculatePaginationParamsexcludesregexp/alloftext/allofterms/match/ngram, but notallof(intersected), noteqon a lossy-only index (post-filtered), and not the geo family (post-filtered). Pre-existing and independent of this PR — happy to file it separately.No cluster-level test for the
eq-on-lossy-index case. It needs Docker, which I did not have here, so the end-to-end symptom above is established by reading the code rather than by a test. The unit tests pin the mechanism — thatUidsdoes not apply a negativefirston either path — which is the only way to reintroduce it.Two small things I looked at and left.
resetIfCurrentcallsml.cache.getpurely to compare pointers, and that is not a pure accessor: it books a ristretto hit, which feedsx.PLCacheHitRatio. It is one extra hit per successful warm against every real read, and it is a genuine hit, so I left it rather than resurrect deleted entries to avoid it. AndCachePL.Set(posting/mvcc.go:758) has no callers at all — pre-existing dead code, not mine to delete here.