Skip to content

feat(addressbook): prune stale entries by last-seen time#5513

Open
martinconic wants to merge 3 commits into
masterfrom
feat/addressbook-pruning
Open

feat(addressbook): prune stale entries by last-seen time#5513
martinconic wants to merge 3 commits into
masterfrom
feat/addressbook-pruning

Conversation

@martinconic

@martinconic martinconic commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Checklist

  • I have read the coding guide.
  • My change requires a documentation update, and I have done it.
  • I have added tests to cover my changes.
  • I have filled out the description and linked the related issues.

Description

Implements address book pruning (#5491).

The address book recently gained a wrapping verifiedAddress{Address, Verified} struct (v2.8.0, #5477). This PR extends it with a last-seen timestamp and uses it to drop peers we have not seen for over a month, preventing the address book from accumulating stale, unreachable peers indefinitely.

Changes

  • pkg/addressbook
    • Add LastSeen (unix seconds) to verifiedAddress.
    • Put stamps LastSeen with the current time on every write (initial insertion and any record update).
    • New UpdateLastSeen(overlay): read-modify-write that bumps only the timestamp; no-op if the overlay is absent, and throttled to skip the write when the stored value is younger than 24h so per-sighting calls don't amplify disk IO.
    • Put and UpdateLastSeen are serialized by a mutex, so a concurrent Put is never rolled back by UpdateLastSeen's stale read.
    • New Prune(before time.Time): removes entries last seen before the cutoff. Entries with LastSeen == 0 are kept.
    • Inject a clock for testability.
  • pkg/hive: on each gossip sighting of a peer we already know — i.e. when CheckTimestamp rejects the re-presented record as ErrTimestampStale/ErrTimestampTooSoon — call UpdateLastSeen. Peers mint their bzz.Address once and re-gossip it unchanged for their whole uptime, so this is the path that keeps gossip-only peers fresh; the existing Put only fires when the record is genuinely newer. Failures are debug-logged and non-fatal.
  • pkg/topology/kademlia: call UpdateLastSeen on successful connection, both outbound (connectionAttemptsHandler) and inbound (onConnected). Failures are warning-logged and non-fatal.
  • pkg/node: prune entries not seen for 30 days at startup, right after the address book is constructed. Warning-logged and non-fatal so it never blocks boot.
  • pkg/statestore/storeadapter: migration step 10 stamps last_seen = now() onto existing entries that lack it, decoding into the current entry type (whose last_seen is omitempty) so all other fields round-trip unchanged. Without this, the first prune after upgrade would wipe the whole address book.

AI Disclosure

  • This PR contains code that has been generated by an LLM.
  • I have reviewed the AI generated code thoroughly.
  • I possess the technical expertise to responsibly review the code generated in this PR.

@martinconic martinconic self-assigned this Jun 22, 2026
@martinconic martinconic force-pushed the feat/addressbook-pruning branch from 155323e to 1716cdb Compare June 23, 2026 07:49
@martinconic martinconic marked this pull request as ready for review June 23, 2026 07:53
@gacevicljubisa

gacevicljubisa commented Jun 24, 2026

Copy link
Copy Markdown
Member

Here, prune here runs once, at startup only, and it can lead that if we have some stable connections over bigger period of time, where we haven't updated LastSeen, it will remove those on next restart. But those are our most valuable connections.

Maybe we could use manage() method in kademlia, where we could trigger prune addressbook every 24h (those older then 30 days), but excluding all connected peers, and prune should not start before warmup is done (when node is started). Also, we could stamp last contact at the disconnect, so the prune clock starts from when the peer was actually lost.

One gap is left, what do to with the peer we only hear over hive gossip, but we didn't connect to?

now := time.Now().Unix()

for _, e := range batch {
var fields map[string]json.RawMessage

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this is not needed. you can shadow the type which is currently on master as a local type scoped to the function, and use it directly to unmarshal and conversion onto the new type. when i think of it, you could even have the latest version (just annotate the last seen field with the json omitempty instruction), and you could unmarshal the existing old serialization format into it, just set the timestamp and write it back into the db.

Comment thread pkg/topology/kademlia/kademlia.go Outdated
k.connectedPeers.Add(peer.addr)

if err := k.addressBook.UpdateLastSeen(peer.addr); err != nil {
k.logger.Debug("could not update last seen for peer", "peer_address", peer.addr, "error", err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

maybe warn?

Comment thread pkg/topology/kademlia/kademlia.go Outdated
k.connectedPeers.Add(addr)
k.waitNext.Remove(addr)
if err := k.addressBook.UpdateLastSeen(addr); err != nil {
k.logger.Debug("could not update last seen for peer", "peer_address", addr, "error", err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

warn?

@acud

acud commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Here, prune here runs once, at startup only, and it can lead that if we have some stable connections over bigger period of time, where we haven't updated LastSeen, it will remove those on next restart. But those are our most valuable connections.

Maybe we could use manage() method in kademlia, where we could trigger prune addressbook every 24h (those older then 30 days), but excluding all connected peers, and prune should not start before warmup is done (when node is started). Also, we could stamp last contact at the disconnect, so the prune clock starts from when the peer was actually lost.

This is what we've converged to with @janos as a naive starting point to ship.

One gap is left, what do to with the peer we only hear over hive gossip, but we didn't connect to?

For now let's leave them in. The first line of defense is to get rid of garbage which is in the statestore. A peer which is seen over hive and is not connected to is still a potential peer you'd like to connect to. I agree that over time yes, probably you don't wanna keep those. I suggest to ship this first and see it works, then evolve into more well defined scenarios.

@martinconic

martinconic commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

Added two fixes:

  1. Hive never refreshes LastSeen. Peers mint their bzz.Address once and re-gossip it unchanged, so CheckTimestamp rejects it as not-newer and Put never fires again — LastSeen freezes at first-seen, and gossip-only peers get pruned at 30 days. Fix: call UpdateLastSeen on ErrTimestampStale/ErrTimestampTooSoon (both imply a known peer). Other rejections don't refresh. Safe against reviving dead peers, since hive only gossips currently-connected peers.
  2. UpdateLastSeen is a lost update, and fix 1 makes it reachable. Get→mutate→Put with no lock; hive runs a goroutine per gossip message, so it now races its own Put for the same overlay. Worst case: stale write-back desyncs the addressbook from the chequebook registry (swap-on only) until re-mint or restart. Fix: mutex on the store, taken by Put and UpdateLastSeen. Regression test fails without it.

@martinconic martinconic requested a review from acud July 13, 2026 10:55
// Hive reports the same peer on every gossip round, while pruning operates on
// a scale of weeks, so a write is skipped when the stored value is already
// this fresh.
const lastSeenUpdateInterval = 24 * time.Hour

@acud acud Jul 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

don't understand whether this is truly needed. if i see a peer overlay at time.Now(), i set the time last seen to now(). why do we need to add all of this buffering and logic complications? unless there's really an issue with IO resulting from so many writes (i suspect not) then this is a non-issue.

GetPutter
Remover
// UpdateLastSeen marks the overlay as seen at the current time.
UpdateLastSeen(overlay swarm.Address) error

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Seen(peer)?


// mu serializes the read-modify-write in UpdateLastSeen against Put, so a
// concurrent Put is not rolled back by a stale copy of the entry.
mu sync.Mutex

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

i don't see why this is needed. this is not business critical information and i think it is fine with this hypothetical use case happening every now and then, if ever.

// Entries without a recorded last-seen time (LastSeen == 0) are kept, leaving
// pruning to a later run once they have been observed.
func (s *store) Prune(before time.Time) error {
cutoff := before.Unix()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

you added a lock but you don't use it here. i suggest to remove the lock because the edge case it tries to handle is very hypothetical and inconsequential

Comment thread pkg/hive/hive.go
// look stale to the pruner. Both errors imply a known peer, since
// an unknown one short-circuits inside CheckTimestamp.
if errors.Is(err, bzz.ErrTimestampStale) || errors.Is(err, bzz.ErrTimestampTooSoon) {
if err := s.addressBook.UpdateLastSeen(overlayAddr); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

i suggest to update the timestamp outside this error check block, because you anyway update the seen time even on a rejected timestamp here, then why not do it anyway before the check timestamp?

Comment thread pkg/hive/lastseen_test.go
// TestGossipDoesNotRefreshLastSeenOnInvalidRecord makes sure the refresh is
// scoped to records that merely are not newer. A record rejected for any other
// reason is not evidence that the peer is alive.
func TestGossipDoesNotRefreshLastSeenOnInvalidRecord(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

it would be good to avoid WritingWholeSentencesInTestCaseNames. that's what we have comments for :)

Comment thread pkg/node/node.go

// Prune addressbook entries whose overlays have not been seen recently, so
// the address book does not accumulate stale peers indefinitely.
if err := addressbook.Prune(time.Now().Add(-addressbookPruneAfter)); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

since you prune only on startup - there's no need to expose the method over an interface - just make sure that addressbook ctor runs the pruning internally.


k.connectedPeers.Add(peer.addr)

if err := k.addressBook.UpdateLastSeen(peer.addr); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

the changes in kademlia only handle updating on connection event, however this is only partially true. since peers we are connected to are "constantly seen". i suggest to have one update location inside the kademlia's manage loop that fires every now and then, and just iterate over the whole list of connected peers and mark them as seen. in that same breath, it might be useful to make Seen to be variadic, like Seen(...peer)

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants