Repository navigation
fix: social graph counts only move when the relationship does - #49
Merged
CassOnMars merged 2 commits intoOct 30, 2026
Merged
CassOnMars merged 2 commits into
CassOnMars merged 2 commits into
Conversation
The counters moved on every LinkAdd and LinkRemove, while the relationship writes beside them are keyed by the (target, source) pair and so are idempotent. Any path that processed a link message twice therefore left the sets correct and the counts permanently wrong. process_event and process_batch share process_link_message, and the block backfill re-reads messages the live path may already have indexed, so a replay is reachable in normal operation. On a public node this shows as follower and following counts close to double the lists the same node serves. relationship_exists checks the pair key, taking a write pending in the current transaction into account the way get_count_from_txn_or_db does, so a relationship removed earlier in the same batch reads as absent. LinkAdd now increments only for a pair that is not indexed, LinkRemove decrements only for one that is, and the LinkCompactState target loop guards the same way. The absolute following_count write at the end of the compact state path is unchanged. This stops further drift; counters that have already drifted need a rebuild, which a SOCIAL_GRAPH_SCHEMA_VERSION bump does through the existing clear_all_data and re-backfill path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The guard probes the follower key alone and lets that decide both counters. That is sound because the follower and following entries are one relationship under two prefixes, written and deleted together on every path, and taking a single answer stops the two counters disagreeing about whether a link was new. The invariant was implicit, so a debug assertion now states it and the test suite checks it on every link it indexes. It compiles out of release builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Nov 3, 2026
Merged
CassOnMars
pushed a commit
that referenced
this pull request
Nov 4, 2026
…backfill reads (#52) #49 stopped replays from moving follower/following counts but left counts that had already drifted. Bump the schema to v5 so nodes rebuild them. The rebuild could not have worked: clear_all_data deleted a bare "api_block_backfill" checkpoint, but run_block_backfill keys its checkpoints per indexer ("api_block_backfill:social_graph") since the backfill went per indexer. A schema bump wiped the index and left the backfill marked complete, so it was never refilled. Clear this indexer's real checkpoint instead, leaving other indexers' alone.
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.
Fixes #48.
follower_countandfollowing_countdrift above the real sets because the counters move on everyLinkAdd/LinkRemove, while the relationship writes beside them are keyed by the(target, source)pair and so are idempotent. Any path that processes a link message twice therefore leaves the sets correct and the counts permanently wrong.Both
process_eventandprocess_batchfunnel intoprocess_link_message, and the block backfill re-reads messages the live path may already have indexed, so a replay is reachable in normal operation. On a public node today this shows up as counts close to double the lists the same node serves — measured in #48 across three FIDs, with the lists themselves clean.The change
relationship_existschecks the pair key, taking a write pending in the current transaction into account the same wayget_count_from_txn_or_dbdoes —Some(Some(_))is a put,Some(None)a delete, so a relationship removed earlier in the same batch correctly reads as absent.The counters then move only when the relationship actually changes:
LinkAddincrements only when the pair is not already indexedLinkRemovedecrements only when it isLinkCompactStatetarget loop increments only for pairs not already present. Targets carried over from the previous state are decremented in the loop above it and so are counted again here, which keeps the existing behaviour; the absolutefollowing_countwrite at the end is untouchedNo storage format or key layout changes, so this is a behaviour-only fix to how the counters are maintained.
Rebuilding existing counters
This stops the drift but does not repair counters that have already drifted. Bumping
SOCIAL_GRAPH_SCHEMA_VERSIONdoes that through the existing path —clear_all_datasweeps the wholeSOCIAL_GRAPH_PREFIXrange, count keys included, and the backfill re-runs. I have deliberately not bumped it here, since that forces a re-backfill on every node and is your call to time. Say the word and I will add it.Tests
Three added. I checked each one fails with the guards removed and the tests left in place, so they pin the behaviour rather than just passing alongside it:
test_backfill_after_live_indexing_does_not_inflatetest_replayed_follow_counts_oncetest_replayed_unfollow_does_not_undercountThe first is the shape of the real bug: three follows indexed through
process_event, then the same messages again throughprocess_batch. Unguarded it returns exactly double, which is what the live node shows.The existing social graph tests, including the compact-state and backfill ones, pass unchanged with and without the fix.
Cost
The guard adds one point lookup per
LinkAdd. The counters were already read —increment_*is a read-modify-write — so it is a third read alongside two, not a read where there were none. A replayed link is cheaper than before: one probe, then both counter reads and both counter writes are skipped.Measured on this branch against the real indexer and RocksDB, release build, 200k links across 2,000 source FIDs with scattered targets, four runs each:
Run-to-run spread was under 0.1 us on either side.
Two caveats on that figure, both of which push the real cost up rather than down. A fresh temporary database keeps 200k small keys in the memtable and L0, which is the friendliest case for a point lookup; a node with deep SST levels pays more. And no bloom filter is configured —
apply_block_cache_optssets a block cache andcache_index_and_filter_blocksbut no filter policy — so the negative lookup that a genuinely new link performs is not short-circuited, and it gets more expensive as levels grow. Treat +26% as a floor for the backfill path rather than a prediction.I did not commit the benchmark; it was an
#[ignore]d test in the module, happy to add it if you would find it useful.If that overhead is unacceptable for a full backfill, the alternative is to leave the per-message path alone and instead stop the backfill re-indexing what the live path already covered, which removes the duplicate work rather than making it harmless. This PR takes the robust-by-construction route because it holds whichever path replays, but I am glad to go the other way if you prefer.
A note on running the suite
cargo fmt --all --checkis clean. I could not validate against.github/workflows/verify.yml'sRUSTFLAGS: "-Dwarnings -A mismatched_lifetime_syntaxes", because that currently fails on an unmodified checkout of this commit — 17 errors, all unused imports, dead code and non-camel-case variants acrossworker.rs,webhook_handler.rs,user_data_store.rs,link_store.rs,storage_lend_store.rsand others, none insocial_graph.rs. I verified this by stashing my change and running the same command. The Verify workflow looks like it has not actually executed in a while (the run for this commit has been queued rather than running), which would explain it going unnoticed. Happy to open a separate PR clearing those lints if useful.So the verification here is plain
cargo test, plus the targeted runs above.Unrelated, noticed in passing: building rewrites
Cargo.lock'ssnapchainversion from0.15.0to0.16.0, so the lock is stale againstCargo.toml. I kept it out of this diff.🤖 Generated with Claude Code