fix(service-disco): centralize cross-service advertisement cleanup#2847
Conversation
| 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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
good point. working on this
There was a problem hiding this comment.
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.updateExistingAdcentralization.
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.
| 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) |
There was a problem hiding this comment.
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] = adThat 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| 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 | ||
|
|
There was a problem hiding this comment.
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...
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