Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

k-bucket support for proper kad bootstrapping #38

Merged
Merged
21 changes: 21 additions & 0 deletions bucket.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
//go:generate go run ./generate

package kbucket

import (
"container/list"
"sync"
"time"

"github.com/libp2p/go-libp2p-core/peer"
)
Expand All @@ -11,14 +14,32 @@ import (
type Bucket struct {
lk sync.RWMutex
list *list.List

lastRefreshedAtLk sync.RWMutex
lastRefreshedAt time.Time // the last time we looked up a key in the bucket
}

func newBucket() *Bucket {
b := new(Bucket)
b.list = list.New()
b.lastRefreshedAt = time.Now()
return b
}

func (b *Bucket) RefreshedAt() time.Time {
b.lastRefreshedAtLk.RLock()
defer b.lastRefreshedAtLk.RUnlock()

return b.lastRefreshedAt
}

func (b *Bucket) ResetRefreshedAt(newTime time.Time) {
b.lastRefreshedAtLk.Lock()
defer b.lastRefreshedAtLk.Unlock()

b.lastRefreshedAt = newTime
}

func (b *Bucket) Peers() []peer.ID {
b.lk.RLock()
defer b.lk.RUnlock()
Expand Down
4,101 changes: 4,101 additions & 0 deletions bucket_prefixmap.go

Large diffs are not rendered by default.

70 changes: 70 additions & 0 deletions generate/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package main

import (
"crypto/sha256"
"encoding/binary"
"fmt"
"os"
"strings"

mh "github.com/multiformats/go-multihash"
)

const bits = 16
const target = 1 << bits
const idLen = 32 + 2

func main() {
pkg := os.Getenv("GOPACKAGE")
file := os.Getenv("GOFILE")
targetFile := strings.TrimSuffix(file, ".go") + "_prefixmap.go"

ids := new([target]uint32)
found := new([target]bool)
count := int32(0)

out := make([]byte, 32)
inp := [idLen]byte{mh.SHA2_256, 32}
hasher := sha256.New()

for i := uint32(0); count < target; i++ {
binary.BigEndian.PutUint32(inp[2:], i)

hasher.Write(inp[:])
out = hasher.Sum(out[:0])
hasher.Reset()

prefix := binary.BigEndian.Uint32(out) >> (32 - bits)
if !found[prefix] {
found[prefix] = true
ids[prefix] = i
count++
}
}

f, err := os.Create(targetFile)
if err != nil {
panic(err)
}

printf := func(s string, args ...interface{}) {
_, err := fmt.Fprintf(f, s, args...)
if err != nil {
panic(err)
}
}

printf("package %s\n\n", pkg)
printf("// Code generated by generate/generate_map.go DO NOT EDIT\n")
printf("var keyPrefixMap = [...]uint32{")
for i, j := range ids[:] {
if i%16 == 0 {
printf("\n\t")
} else {
printf(" ")
}
printf("%d,", j)
}
printf("\n}")
f.Close()
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ require (
github.com/libp2p/go-libp2p-core v0.0.1
github.com/libp2p/go-libp2p-peerstore v0.1.0
github.com/minio/sha256-simd v0.0.0-20190131020904-2d45a736cd16
github.com/multiformats/go-multihash v0.0.1
)
72 changes: 71 additions & 1 deletion table.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@
package kbucket

import (
"encoding/binary"
"errors"
"fmt"
"math/rand"
"sync"
"time"

"github.com/libp2p/go-libp2p-core/peer"
"github.com/libp2p/go-libp2p-core/peerstore"
mh "github.com/multiformats/go-multihash"

logging "github.com/ipfs/go-log"
)
Expand All @@ -20,7 +23,6 @@ var ErrPeerRejectedNoCapacity = errors.New("peer rejected; insufficient capacity

// RoutingTable defines the routing table.
type RoutingTable struct {

// ID of the local peer
local ID

Expand Down Expand Up @@ -57,6 +59,74 @@ func NewRoutingTable(bucketsize int, localID ID, latency time.Duration, m peerst
return rt
}

// GetAllBuckets is safe to call as rt.Buckets is append-only
// caller SHOULD NOT modify the returned slice
func (rt *RoutingTable) GetAllBuckets() []*Bucket {
rt.tabLock.RLock()
defer rt.tabLock.RUnlock()
return rt.Buckets
Copy link
Member

Choose a reason for hiding this comment

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

Can we add a comment explaining why this is safe (it's append-only).

}

// GenRandPeerID generates a random peerID in bucket=bucketID
func (rt *RoutingTable) GenRandPeerID(bucketID int) peer.ID {
if bucketID < 0 {
panic(fmt.Sprintf("bucketID %d is not non-negative", bucketID))
}
rt.tabLock.RLock()
bucketLen := len(rt.Buckets)
rt.tabLock.RUnlock()

var targetCpl uint
if bucketID > (bucketLen - 1) {
targetCpl = uint(bucketLen) - 1
} else {
targetCpl = uint(bucketID)
}

// We can only handle upto 16 bit prefixes
if targetCpl > 16 {
targetCpl = 16
}

var targetPrefix uint16
localPrefix := binary.BigEndian.Uint16(rt.local)
if targetCpl < 16 {
// For host with ID `L`, an ID `K` belongs to a bucket with ID `B` ONLY IF CommonPrefixLen(L,K) is EXACTLY B.
// Hence, to achieve a targetPrefix `T`, we must toggle the (T+1)th bit in L & then copy (T+1) bits from L
// to our randomly generated prefix.
toggledLocalPrefix := localPrefix ^ (uint16(0x8000) >> targetCpl)
randPrefix := uint16(rand.Uint32())

// Combine the toggled local prefix and the random bits at the correct offset
// such that ONLY the first `targetCpl` bits match the local ID.
mask := (^uint16(0)) << (16 - (targetCpl + 1))
targetPrefix = (toggledLocalPrefix & mask) | (randPrefix & ^mask)
} else {
targetPrefix = localPrefix
}

// Convert to a known peer ID.
key := keyPrefixMap[targetPrefix]
id := [34]byte{mh.SHA2_256, 32}
binary.BigEndian.PutUint32(id[2:], key)
return peer.ID(id[:])
}

// Returns the bucket for a given ID
// should NOT modify the peer list on the returned bucket
func (rt *RoutingTable) BucketForID(id ID) *Bucket {
cpl := CommonPrefixLen(id, rt.local)

rt.tabLock.RLock()
defer rt.tabLock.RUnlock()
bucketID := cpl
if bucketID >= len(rt.Buckets) {
bucketID = len(rt.Buckets) - 1
}
Copy link
Member

Choose a reason for hiding this comment

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

Hm. This is very unfortunate. Given our network size, we should have at least 16 buckets). That's 2**16 hashes to find a peer in that last bucket.

I don't know how to fix this on our current DHT. Given how we've defined the protocol, we have to know the un-hashed key.

Honestly, I think the best solution is to pre-compute a table with a few known-good bootstrap keys in each of the first 20 buckets. If you can write a program to do this (ideally highly parallel), I can run it on a really beefy machine for a few days.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm probably missing context here, but a couple comments:

  1. Why do we need a public non-test function for computing a random peer ID?
  2. Even if we needed to generate random peer IDs we don't need to hash anything since the randPeerID function does not return the material that gets hashed into the PeerID. As a result, if I have n buckets and want bucket i that has 20 matching prefix bits with me all I should need to do is randomly generate a single number from 0 to 2^(n-20) and then just prepend the matching 20 bits. So very little computation (one random number generation) should be required.

Copy link
Member

Choose a reason for hiding this comment

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

@aschmahmann this is for libp2p/go-libp2p-kad-dht#375 (I've updated the PR description).

2

Due to how our DHT works, the we need to send the unhashed key with the request. That means we need to know the unhashed key. Kademlia as described by the paper assumes that all keys will be pre-hashed. If we wanted to do that, we'd have to pre-hash the keys when we store records in the datastore (e.g., peer routing information).

We could modify the DHT to send both the unhashed and hash keys but that seems like a lot of extra work just to support bootstrapping.

Copy link
Contributor

Choose a reason for hiding this comment

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

@Stebalien thx for the PR pointer. Fair enough about not wanting to do much changing to the dht message types if we don't have to. However, I'm not sure the solution of pre-generating keys helps. We can pre-generate keys for a single peer, but generating them for all peers is going to need require packaging a ton of keys (because the bucket distance is computed based on your local ID).

In any event it still doesn't seem like we need the hash function inside of randPeerID, just the one in ConvertPeerID.

Copy link
Member

Choose a reason for hiding this comment

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

Ah, yeah, now I feel like an idiot. This isn't going to work (we'd need to ship ~1m peer IDs).

In any event it still doesn't seem like we need the hash function inside of randPeerID, just the one in ConvertPeerID.

The issue is that we need to make a DHT request with this key. To make the DHT request, we need to know the non-hashed key.

Copy link
Member

Choose a reason for hiding this comment

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

Turns out this didn't need a beefy machine and we really don't need 1m peer IDs. I've created a branch with a table mapping 20 bit hash prefixes to numbers that can be used to generate peer IDs that hash to these 20 bit hash prefixes: https://github.com/libp2p/go-libp2p-kbucket/tree/feat/bucket-prefix-gen

More concretely, to find a peer ID in a bucket N, we'll have to:

  1. Generate a random 20 bit number.
  2. Hash our peer ID (sha256)
  3. Replace the first N bits with the first N bits from our hashed peer ID.
  4. Lookup this random number in the table in the table to get SEED.
  5. Encode SEED into a 32 byte slice using BigEndian ordering.
  6. Use this slice as the hash digest in a sha256 multihash.
  7. This multihash is the peer ID.

When this peer ID is hashed, the first N bits should match the result of step (2).

Copy link
Member

Choose a reason for hiding this comment

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

The downside is that this increases our binary size by 4MiB. But I'm not that concerned.

Copy link
Member

Choose a reason for hiding this comment

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

I've reduced it to 16 bits (16 buckets) to reduce the size to 256KiB.

Copy link
Contributor Author

@aarshkshah1992 aarshkshah1992 Aug 11, 2019

Choose a reason for hiding this comment

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

@Stebalien This was a really cool idea ! Have rebased on top of your branch & made all the changes. Let me know what you think.

PS: Made some minor changes to generate/main.go(line 31 & 58) & generated a new prefix-map so we can use it exactly as you've mentioned in the algorithm above.

Basically, instead of crafting the multi-hash like this :

[]byte{mh.Sha256, 32, 24 zero byte slices, 8 bytes of an Uint64}

We craft it as:

[]byte{mh.Sha256, 32, 8 bytes of an Uint64, 24 zero byte slices}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Also, apologies for the delay in getting this up. Things were a bit hectic at work.


return rt.Buckets[bucketID]
}

// Update adds or moves the given peer to the front of its respective bucket
func (rt *RoutingTable) Update(p peer.ID) (evicted peer.ID, err error) {
peerID := ConvertPeerID(p)
Expand Down
46 changes: 46 additions & 0 deletions table_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,52 @@ func TestBucket(t *testing.T) {
}
}

func TestGenRandPeerID(t *testing.T) {
nBuckets := 21
local := test.RandPeerIDFatal(t)
m := pstore.NewMetrics()
rt := NewRoutingTable(1, ConvertPeerID(local), time.Hour, m)

// create nBuckets
for i := 0; i < nBuckets; i++ {
for {
if p := test.RandPeerIDFatal(t); CommonPrefixLen(ConvertPeerID(local), ConvertPeerID(p)) == i {
rt.Update(p)
break
}
}
}

// test bucket for peer
peers := rt.ListPeers()
for _, p := range peers {
b := rt.BucketForID(ConvertPeerID(p))
if !b.Has(p) {
t.Fatalf("bucket should have peers %s", p.String())
}
}

// test generate rand peer ID
for bucketID := 0; bucketID < nBuckets; bucketID++ {
Copy link
Member

Choose a reason for hiding this comment

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

Let's test this with with more buckets (40?) to check for overflow issues.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@Stebalien Generating 40 buckets takes a lot of time as a new bucket is created ONLY IF the last bucket is full. This slows down the tests. So testing with 21 for now as it's still an overflow & hits all the code-paths.

Copy link
Member

Choose a reason for hiding this comment

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

It shouldn't be slow... We shouldn't have to generate 40 buckets, just ask for peer IDs in buckets 1-40 (where everything after 16 would just be random IDs.

But this should be good enough.

Copy link
Contributor Author

@aarshkshah1992 aarshkshah1992 Aug 30, 2019

Choose a reason for hiding this comment

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

@Stebalien

Ah, I think I misunderstood your initial comment. So you meant simply passing in a bucketID of upto 40 to the GenRandPeerID method & not actually having 40 buckets. That makes more sense. Apologies.

Copy link
Member

Choose a reason for hiding this comment

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

Yes. But really, anything over 16 is plenty.

peerID := rt.GenRandPeerID(bucketID)

// for bucketID upto maxPrefixLen of 16, CPL should be Exactly bucketID
if bucketID < 16 {
if CommonPrefixLen(ConvertPeerID(peerID), rt.local) != bucketID {
t.Fatalf("cpl should be %d for bucket %d but got %d, generated peerID is %s", bucketID, bucketID,
CommonPrefixLen(ConvertPeerID(peerID), rt.local), peerID)
}
} else {
// from bucketID 16 onwards, CPL should be ATLEAST 16
if CommonPrefixLen(ConvertPeerID(peerID), rt.local) < 16 {
t.Fatalf("cpl should be ATLEAST 16 for bucket %d but got %d, generated peerID is %s", bucketID,
CommonPrefixLen(ConvertPeerID(peerID), rt.local), peerID)
}
}

}
}

func TestTableCallbacks(t *testing.T) {
local := test.RandPeerIDFatal(t)
m := pstore.NewMetrics()
Expand Down
2 changes: 1 addition & 1 deletion util.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (
ks "github.com/libp2p/go-libp2p-kbucket/keyspace"

u "github.com/ipfs/go-ipfs-util"
sha256 "github.com/minio/sha256-simd"
"github.com/minio/sha256-simd"
)

// Returned if a routing table query returns no results. This is NOT expected
Expand Down