Skip to content

fix(service-disco): centralize cross-service advertisement cleanup#2847

Merged
SionoiS merged 3 commits into
fix-ads-pruningfrom
fix-ads-pruning-centralize-cleanup
Jul 17, 2026
Merged

fix(service-disco): centralize cross-service advertisement cleanup#2847
SionoiS merged 3 commits into
fix-ads-pruningfrom
fix-ads-pruning-centralize-cleanup

Conversation

@tinniaru3005

Copy link
Copy Markdown
Contributor

Summary

Centralized the cross-service ad cleanup into one helper (removeAdvertisementEverywhere), so a shared advertisement gets removed from every service list in one explicit pass instead of relying on each service's prune loop to lazily stumble onto it.

Follow-up to #2845

Affected Areas

  • Gossipsub

  • Transports

  • Peer Management / Discovery

  • Protocol Logic

  • Build / Tooling

  • Other

Compatibility & Downstream Validation

Reference PRs / branches / commits demonstrating successful integration:

  • Nimbus:

  • Waku:

  • Codex:

Impact on Library Users

Risk Assessment

References

Additional Notes

@tinniaru3005
tinniaru3005 requested a review from a team as a code owner July 17, 2026 08:43
@tinniaru3005
tinniaru3005 requested review from gmelodie and richard-ramos and removed request for a team July 17, 2026 08:43
Comment on lines +57 to +70
proc removeAdvertisementEverywhere(registrar: Registrar, ad: Advertisement) =
let key = ad.toAdvertisementKey()

if key in registrar.cacheTimestamps:
registrar.ipTree.removeAd(ad)
registrar.cacheTimestamps.del(key)

for _, sads in registrar.cache.mpairs:
var j = 0
while j < sads.len:
if sads[j].toAdvertisementKey() == key:
sads.delete(j)
else:
inc(j)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Allow me to also jump in here.
From what I see, this removes the ad from ipTree only once, but insertNewAd inserts into ipTree once per service cache insertion afaik.
If insertNewAd was called 3 times, so ipTree.insertAd(ad) was also called 3 times. The new removeAdvertisementEverywhere deletes all 3 cache entries, but only calls registrar.ipTree.removeAd(ad) once.
Unless I am missing something this leaves ipTree counters inflated.
I would either remove from ipTree once per removed cache entry, or change insertion so ipTree is only incremented once per AdvertisementKey.
Also, updateExistingAd can still orphan old copies of a replaced advertisement across other service caches, so the centralized cleanup should be used there too. Does this makes sense?

I think this really requires adding more tests to the codebase.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

good point. working on this

@tinniaru3005 tinniaru3005 Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

went with your first option — remove from ipTree once per removed cache entry, not once per AdvertisementKey.

Fixed in three places:

  • insertNewAd — IP tree insert is unconditional, once per service acceptance.
  • removeAdvertisementEverywhere — now removes from the IP tree once per matching cache entry found in the sweep, not once total.
  • updateExistingAd centralization.

New tests: same ad across 3 services counts the tree 3x on insert; a shared ad's IP tree entries all clear on removal; an update through one service replaces the stale copy in a second service and keeps the tree balanced through that replacement. All passing.

@SionoiS
SionoiS merged commit b06a38d into fix-ads-pruning Jul 17, 2026
39 of 40 checks passed
@SionoiS
SionoiS deleted the fix-ads-pruning-centralize-cleanup branch July 17, 2026 10:59
@github-project-automation github-project-automation Bot moved this from new to done in nim-libp2p Jul 17, 2026
Comment on lines +381 to +389
let oldKey = existing.toAdvertisementKey()
for _, sads in registrar.cache.mpairs:
for j in 0 ..< sads.len:
if sads[j].toAdvertisementKey() == oldKey:
registrar.ipTree.removeAd(sads[j])
registrar.ipTree.insertAd(ad)
sads[j] = ad

registrar.cacheTimestamps.del(oldKey)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I think this is wrong...
This updates every service cache entry matching the old (peerId, seqNo) to the new advertisement, without checking whether the new advertisement advertises that service.
This can put an invalid ad under a service cache:

oldAd(seq=1) advertises [svcA, svcB]
cache[svcA] = [oldAd]
cache[svcB] = [oldAd]

Then the peer registers:

newAd(seq=2) advertises [svcA]

through svcA.
isValidAdvertisement only checks that newAd advertises the requested serviceId:

if not ad.advertisesService(serviceId):
  return err("message & advertisement service mismatch")

So newAd is valid for svcA, but not necessarily for svcB.
The new updateExistingAd loop then does:

for _, sads in registrar.cache.mpairs:
  for j in 0 ..< sads.len:
    if sads[j].toAdvertisementKey() == oldKey:
      registrar.ipTree.removeAd(sads[j])
      registrar.ipTree.insertAd(ad)
      sads[j] = ad

That means cache[svcB] can now contain newAd, even though newAd.advertisesService(svcB) == false.
This breaks the cache and can later produce invalid GET_ADS results for svcB. The discoverer does defensively reject these via validAds:

if not ad.advertisesService(serviceId):
  error "advert service mismatch", serviceId
  continue

Comment on lines +1343 to +1376
test "higher seqNo replaces stale copies in every other service that cached it":
let registrar = Registrar.new()
let serviceId1 = makeServiceId(1)
let serviceId2 = makeServiceId(2)
let privateKey = PrivateKey.random(rng()).get()
let oldAd = makeAdvertisement(
privateKey = privateKey, seqNo = 1, addrs = @[makeMultiAddress("10.0.0.1")]
)
let newAd = makeAdvertisement(
privateKey = privateKey, seqNo = 2, addrs = @[makeMultiAddress("192.168.1.1")]
)

registrar.cache[serviceId1] = @[oldAd]
registrar.cache[serviceId2] = @[oldAd]
registrar.cacheTimestamps[oldAd.toAdvertisementKey()] = initMoment(1000)
# Each service's copy is a separate ad_cache admission
registrar.ipTree.insertAd(oldAd)
registrar.ipTree.insertAd(oldAd)
check registrar.ipTree.root.counter == 2

var ads1 = registrar.cache[serviceId1]
let changed = registrar.updateExistingAd(ads1, 0, newAd, initMoment(2000))
registrar.cache[serviceId1] = ads1

check changed
check registrar.cache[serviceId1].len == 1
check registrar.cache[serviceId1][0].data.seqNo == 2
# serviceId2 was never passed into updateExistingAd directly.
check registrar.cache[serviceId2].len == 1
check registrar.cache[serviceId2][0].data.seqNo == 2
check oldAd.toAdvertisementKey() notin registrar.cacheTimestamps
check newAd.toAdvertisementKey() in registrar.cacheTimestamps
check registrar.ipTree.root.counter == 2

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The new test added at does not catch what I mentioned because both oldAd and newAd use the default "test-service" payload while being manually stored under arbitrary makeServiceId(1) / makeServiceId(2) keys. It should use real matching service IDs, then test the case where the new ad drops one of the old services...

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

Labels

None yet

Projects

Status: done

Development

Successfully merging this pull request may close these issues.

3 participants