chore(kv): elide redundant index rewrites on identical in-place updates (#279) - #284
Conversation
958cc19 to
4c5197d
Compare
farhan-syah
left a comment
There was a problem hiding this comment.
Request changes. The elision is correct only when the index already holds the entry. It does not always hold it.
Blocker
set.rs:172 skips remove+insert when the old and new indexed bytes are equal. That assumes (value, primary_key) is already indexed. A row that predates a backfill=false registration is not indexed.
KvFieldIndex::entries states the case: backfill=false omits pre-existing rows. On main, the next PUT of such a row files it in — remove no-ops, insert adds it. With this patch, a PUT that leaves the field unchanged is elided and the row never enters the index. lookup_eq misses it permanently.
wal_replay_kv_index.rs:279 asserts this guarantee. It still passes only because it writes a new key, not a rewrite.
Measured, not inferred. Probe added to the KvIndexSet tests:
let mut set = KvIndexSet::new();
set.add_index("status", 0); // k1 predates the index
set.on_put(b"k1", &[("status", b"active")], Some(&[("status", b"active")]));
assert_eq!(set.lookup_eq("status", b"active").len(), 1);main: PASS- this branch: FAIL —
left: 0, right: 1
Fix: gate the skip on membership, not equality. Add KvFieldIndex::contains(value, pk) — a BTreeMap lookup plus a BTreeSet lookup, O(log n), no allocation. Skip only when the bytes match and the pair is present. The hot path is present-and-unchanged, so the win survives: one tree probe replaces two Vec allocations. Apply the same gate to the composite index.
Should-fix
| Item | Where | Action |
|---|---|---|
| Branch duplication | set.rs:185 |
The else arm re-implements the insert half. Under a contains gate both arms collapse into one loop, with old_val = None for a new row. |
.find() vs loop |
set.rs:167 |
The update arm takes the first match per field, the insert arm inserts every match. extract_all_field_values_from_msgpack emits one pair per key, so behavior is unchanged today. The two arms must not disagree on the contract. Unification removes it. |
| Test coverage | set.rs tests |
d6_identical_update_elides_index_writes covers the entry-present single-field case only. Add the entry-absent case and a composite identical-tuple case. |
| Test command | PR body | Use cargo nextest run, not cargo test. The nextest test-group config does not apply otherwise. |
Verified
- 23
index_settests andd6_identical_update_elides_index_writespass on this branch. Your claim holds. - Removed-field remove-only and added-field insert-only paths preserved.
- Composite
filter_maplength guard carried through unchanged. - One file, scoped to the issue, no unrelated edits.
| .iter() | ||
| .find(|(field, _)| *field == f) | ||
| .map(|(_, v)| *v); | ||
| if new_val == old_val { |
There was a problem hiding this comment.
Blocker. This assumes the index already holds (value, primary_key). A row written before a backfill=false registration is absent from the index. On main the next PUT files it in — remove no-ops, insert adds it. Here that PUT is elided and the row never becomes visible to lookup_eq.
Verified: a probe asserting the row is indexed after an identical rewrite passes on main and fails here (left: 0, right: 1).
Gate the skip on membership instead. Add KvFieldIndex::contains(value, pk) — BTreeMap plus BTreeSet lookup, O(log n), no allocation — and skip only when the bytes match and the pair is present. The common case stays present-and-unchanged, so the optimization keeps its win.
| }) | ||
| .collect(); | ||
| if old_vals.len() == ci.fields().len() && new_vals.len() == ci.fields().len() { | ||
| if old_vals != new_vals { |
There was a problem hiding this comment.
Same shape as set.rs:172: equality alone does not prove the composite entry is present. No production path registers a composite index today, so this is latent, not live. Fix it under the same contains gate in this pass.
e5bd6d9 to
03f4d7f
Compare
There was a problem hiding this comment.
Request changes. The round-1 blocker is fixed correctly. The contains gate plus d6_backfill_absent_pair_still_gets_inserted and d6_composite_backfill_absent_pair_still_gets_inserted close the backfill=false gap on both index kinds. Two things block merge.
Verified on the branch, not taken on trust:
cargo nextest run -p nodedb --lib -E 'test(index_set) or test(d6_)'— 26/26 pass.- The elision is semantically safe.
KvFieldIndex::insertfiles into aBTreeSet, so remove+insert of an identical pair was already idempotent. The win is twoto_vec()allocations per index per PUT plus an honest write-amp number, not a correctness change. - The sorted-index claim holds.
OrderStatTree::insertreturns early when the sort key is unchanged (sorted_index/tree.rs:72).
Blockers
cargo fmt --all -- --checkis red —set.rs:520andset.rs:533. Verified on the branch. Clippy is unverified — run it locally before you resubmit.- Both
debug_assert!guards are tautological —set.rs:177andset.rs:239. Detail inline.
Should fix
- The composite value extraction is written three times —
set.rs:216,226,263. Detail inline. - A claim in the PR body is wrong. "BTreeMap + BTreeSet lookup, no allocation" does not hold for composite:
KvCompositeIndex::containscallsbuild_key, which allocates aVec<u8>per call. It still beats the remove+insert it replaces. Correct the wording.
Notes, no change requested
- Scope. The Calvin marker commit is unrelated to #279, and it is now handled on
maindirectly. Drop that commit and rebase — see the inline note. - Commit message. The last commit names an internal branch and "option B". Drop that when you amend for the fmt fix.
write_amp_ratio(engine_index.rs:155) now reports mutations performed rather than maintenance ops issued. That is the point of the change. Flagging it because the numbers it exposes will drop.- The same allocation win is still available on the sorted path:
sorted_index/manager.rs:193builds a sort key and aprimary_key.to_vec()on every PUT, theninsertdiscards both when the key is unchanged. Out of scope here.
Resubmitting
Squash to one commit before the next push. Four commits — a fix, a review fix, a refactor of that fix, and a CI marker — is more history than one chore needs, and the middle two only describe review rounds.
git fetch origin
git reset --soft $(git merge-base HEAD origin/main)
git commit # write the single message below
cargo fmt --all
cargo clippy -p nodedb --all-targets -- -D warnings
cargo nextest run -p nodedb --lib -E 'test(index_set) or test(d6_)'
git push --force-with-leaseSuggested message:
chore(kv): elide redundant index rewrites on identical in-place updates
An in-place update that left an indexed column unchanged still removed
and re-inserted the entry, at two index writes per index per PUT. Both
writes were correct, so this is churn rather than a defect.
Single-field and composite indexes now skip both operations when the
extracted bytes are unchanged AND the (value, primary key) pair is
already present. The membership gate matters: a row written before a
backfill=false registration is absent from the index, and the next
identical PUT has to file it in rather than elide it. Changed values
keep the existing remove+insert path. Sorted indexes are untouched --
OrderStatTree::insert already returns early on an identical sort key.
Fixes #279
| if new_val == old_val { | ||
| if let Some(v) = new_val { | ||
| if idx.contains(v, primary_key) { | ||
| debug_assert!( |
There was a problem hiding this comment.
This can never fire. The condition duplicates the if on the line above, and nothing mutates the index between the two. It is always true when reached.
The commit message says these guards "catch backfill regression in debug/CI". They cannot. In a debug build they only repeat the BTreeMap plus BTreeSet lookup.
Remove it. d6_backfill_absent_pair_still_gets_inserted is the real guard against that regression, and it works.
| if old_vals.len() == ci.fields().len() && new_vals.len() == ci.fields().len() { | ||
| if old_vals == new_vals { | ||
| if ci.contains(&new_vals, primary_key) { | ||
| debug_assert!( |
There was a problem hiding this comment.
Same defect as the single-field guard: the condition duplicates the enclosing if, so it can never fail.
Worse here. KvCompositeIndex::contains calls build_key, so a debug build allocates a second key Vec to re-prove something already known.
Remove it. d6_composite_backfill_absent_pair_still_gets_inserted covers the case.
| // Composite entry, eliding the same no-op update case (#279). | ||
| match old_field_values { | ||
| Some(old_values) => { | ||
| let old_vals: Vec<&[u8]> = ci |
There was a problem hiding this comment.
This filter_map plus find block appears three times in one function — here, at line 226, and at line 263. Only the source slice differs.
Hoist it to one helper, fn composite_vals(ci: &KvCompositeIndex, values: &[(&str, &[u8])]) -> Vec<&[u8]>. Then the None arm collapses to two lines and the Some arm loses a nesting level. on_put is now around 90 lines at five levels of nesting; the helper takes most of that back.
| &[("status", b"active")], | ||
| Some(&[("status", b"active")]), // old == new, but never indexed | ||
| ); | ||
| assert_eq!(writes, 1, "backfill=false: absent pair must insert, got {writes}"); |
There was a problem hiding this comment.
cargo fmt --all -- --check rewrites this and line 533 into a multi-line assert_eq!. Run it before you resubmit.
| } | ||
|
|
||
| /// Whether the index contains the composite (field_values, primary_key) pair. | ||
| pub(crate) fn contains(&self, field_values: &[&[u8]], primary_key: &[u8]) -> bool { |
There was a problem hiding this comment.
build_key allocates a Vec<u8> on every call, so this is not the allocation-free lookup the PR body claims. The code is fine — it still costs less than the remove+insert it replaces. Correct the description.
| tid, | ||
| DatabaseId::DEFAULT, | ||
| crate::types::VShardId::new(0), | ||
| // no-determinism: test-only dummy deadline, not written to Calvin state |
There was a problem hiding this comment.
No change needed here — drop this commit and rebase. The marker is going onto main directly, so this file falls out of the PR.
For the record, the root cause sits in the gate script rather than in this file. check_calvin_determinism.sh anchors its skip on /^#\[cfg(test)\]/, so an indented #[cfg(test)] on an item is never recognised. Every test-only helper added under a scanned path keeps tripping the gate. That fix is worth its own PR.
644f586 to
d7b1a97
Compare
farhan-syah
left a comment
There was a problem hiding this comment.
Request changes. The logic is correct. What blocks merge is the record, not the code.
Round-2 scorecard
| Point | Status |
|---|---|
cargo fmt --all -- --check red |
Fixed |
Two tautological debug_assert! guards |
Fixed, both removed |
| Composite extraction written three times | Fixed, hoisted into composite_vals |
"no allocation" claim wrong for composite |
Open |
| Drop the unrelated marker commit | Done |
| Squash before resubmit | Open, three commits |
Verified on the branch
cargo fmt --all -- --check— clean.cargo clippy -p nodedb --all-targets --all-features -- -D warnings— clean.cargo nextest run -p nodedb --lib -E 'test(index_set) or test(d6_) or test(kv_index)'— 30 pass, including all fourwal_replay_kv_indexbackfill tests.- The membership gate is correct on both index kinds. Every single-field branch matches
mainexcept present-and-unchanged, which is the elision.
Blockers
-
The PR body describes code that is no longer here. It says the skip fires "when equal" and never mentions the membership gate.
How to teststill runscargo test, which ignores the nextest test-group config. The"BTreeMap + BTreeSet lookup, no allocation"line in commit03f4d7fis false for composite:KvCompositeIndex::containscallsbuild_key, which allocates. A squash-merge makes this body the permanent record. Rewrite it to match the code. -
Four comments narrate the review, not the code —
set.rs:172,set.rs:221,set.rs:380,set.rs:484. Details inline.
Should fix
-
Empty
ifarm atset.rs:228. Inline. -
Squash to one commit. The middle two commits describe rounds of this review.
Notes, no change requested
- The update arm still uses
.find()where the insert-only arm loops. I raised it in round 1 and dropped it in round 2. Not reopening —extract_all_field_values_from_msgpackemits one pair per key, so the arms cannot disagree. - The composite
Nonearm repeats theelse if new_vals.len() == ci.fields().len()arm. Collapsible, not worth a round.
Resubmitting
git fetch origin
git reset --soft $(git merge-base HEAD origin/main)
git commit
cargo fmt --all
cargo clippy -p nodedb --all-targets --all-features -- -D warnings
cargo nextest run -p nodedb --lib -E 'test(index_set) or test(d6_)'
git push --force-with-leaseReuse the commit message from my round-2 review. It already describes the membership gate. Give the PR body the same content.
Last round. Fix these four and it merges.
| // Single-field indexes. Elide a no-op in-place update: when the old | ||
| // indexed value equals the new one *and* the pair is already present, | ||
| // remove+insert is pure churn (issue #279). A row written before a | ||
| // backfill=false registration is absent from the index; the next PUT | ||
| // with identical bytes must insert it. |
There was a problem hiding this comment.
Drop (issue #279). Same at line 221. An issue number is provenance, not an invariant. It goes stale when the tracker moves, and a reader here cannot follow it.
Keep the rest. It states why equality alone is not enough, which is what this code needs.
| let new_vals = composite_vals(ci, field_values); | ||
| if old_vals.len() == ci.fields().len() && new_vals.len() == ci.fields().len() { | ||
| if old_vals == new_vals { | ||
| if ci.contains(&new_vals, primary_key) { |
There was a problem hiding this comment.
Empty if arm with the work in the else. Invert it:
if !ci.contains(&new_vals, primary_key) {
ci.insert(&new_vals, primary_key.to_vec());
writes += 1;
}Same shape as the single-field arm above. Drops a level from a block already five deep.
|
|
||
| #[test] | ||
| fn d6_identical_update_elides_index_writes() { | ||
| // CLAIM D6: on_put unconditionally removes old + inserts new even when |
There was a problem hiding this comment.
CLAIM D6: and the description of the old behavior address a reviewer, not the next reader. After merge there is no "unconditionally removes old + inserts new" to contrast against.
State what the test pins:
// An in-place update that leaves the indexed value unchanged must not
// rewrite the index when the pair is already present.| // Farhan's blocker: old==new alone isn't enough to elide — the pair | ||
| // must actually be present. A row written before a backfill=false | ||
| // registration never got indexed, so the next identical PUT has to | ||
| // insert it, not skip it. |
There was a problem hiding this comment.
Remove Farhan's blocker:. Who found the case is not a property of the case. The name also dates the comment, and it reads as if the invariant holds because someone asked.
Drop the first three words:
// old == new alone is not enough to elide — the pair must actually be
// present. A row written before a backfill=false registration was never
// indexed, so the next identical PUT has to insert it.d7b1a97 to
c7a41c4
Compare
An in-place update that left an indexed column unchanged still removed and re-inserted the entry, at two index writes per index per PUT. Both writes were correct, so this is churn rather than a defect. Single-field and composite indexes now skip both operations when the extracted bytes are unchanged AND the (value, primary key) pair is already present. The membership gate matters: a row written before a backfill=false registration is absent from the index, and the next identical PUT has to file it in rather than elide it. Changed values keep the existing remove+insert path. Sorted indexes are untouched -- OrderStatTree::insert already returns early on an identical sort key. Fixes NodeDB-Lab#279
c7a41c4 to
7f7db31
Compare
chore(kv): elide redundant index rewrites on identical in-place updates
Fixes #279
Problem
An in-place update that left an indexed column unchanged still removed and re-inserted the entry, at two index writes per index per PUT. Both writes were correct, so this is churn rather than a defect.
Change
Single-field and composite indexes now skip both operations when the extracted bytes are unchanged AND the (value, primary key) pair is already present. The membership gate matters: a row written before a backfill=false registration is absent from the index, and the next identical PUT has to file it in rather than elide it.
Changed values keep the existing remove+insert path. Sorted indexes are untouched —
OrderStatTree::insertalready returns early on an identical sort key.How to test
Covers the KV index unit tests, the three new elision tests (
identical_update_elides_index_writes, plus the backfill-absent insert cases for single-field and composite indexes), and the fourwal_replay_kv_indexbackfill tests.Verified
cargo fmt --all -- --check— cleancargo clippy -p nodedb --all-targets --all-features -- -D warnings— cleanbash scripts/ci/check_calvin_determinism.sh— clean as submitted at c7a41c4