Expand economy, autobase, network, observability, and scheduling modules.
Add auction withdraw/cancel, marketplace search helpers, update gossip history, autobase fork/lease/indexer/view APIs, link-probe and circuit-loom utilities, trace trees, pheromone ranking, and scheduling/measurement helpers with tests. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -15,8 +15,8 @@ P2P economy primitives: credit ledger, auction gossip, marketplace listings. Hub
|
|||||||
| Module | Protocol | Summary |
|
| Module | Protocol | Summary |
|
||||||
|--------|----------|---------|
|
|--------|----------|---------|
|
||||||
| [hyper-p2p-credit-ledger](./hyper-p2p-credit-ledger/) | `credit-ledger/v1` | Open account, credit/debit, `totalSupply()` |
|
| [hyper-p2p-credit-ledger](./hyper-p2p-credit-ledger/) | `credit-ledger/v1` | Open account, credit/debit, `totalSupply()` |
|
||||||
| [hyper-p2p-auction-gossip](./hyper-p2p-auction-gossip/) | `auction-gossip/v1` | Open auction, bids, close with winner |
|
| [hyper-p2p-auction-gossip](./hyper-p2p-auction-gossip/) | `auction-gossip/v1` | Open auction, bids, withdraw, cancel, close |
|
||||||
| [hyper-p2p-marketplace-listing](./hyper-p2p-marketplace-listing/) | `marketplace-listing/v1` | Create/search/sell listings |
|
| [hyper-p2p-marketplace-listing](./hyper-p2p-marketplace-listing/) | `marketplace-listing/v1` | Listings, tag/price search, remove |
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,20 @@ Sets `status: 'closed'`, `winner` to highest bid (or `null`), gossips `auction-c
|
|||||||
|
|
||||||
All auctions with `status === 'open'`.
|
All auctions with `status === 'open'`.
|
||||||
|
|
||||||
|
### `listAuctionIds() → string[]`
|
||||||
|
|
||||||
|
### `highestBid(auctionId) → bid | null`
|
||||||
|
|
||||||
|
Top bid after sort, or `null` if none.
|
||||||
|
|
||||||
|
### `withdrawBid(auctionId, bidder = null) → number`
|
||||||
|
|
||||||
|
Removes all bids from `bidder` (defaults to `peerHex`). Returns count removed; gossips `auction-withdraw`.
|
||||||
|
|
||||||
|
### `cancelAuction(auctionId) → boolean`
|
||||||
|
|
||||||
|
Deletes an open auction locally and gossips `auction-cancel`.
|
||||||
|
|
||||||
## Events
|
## Events
|
||||||
|
|
||||||
| Event | When | Payload |
|
| Event | When | Payload |
|
||||||
@@ -97,6 +111,8 @@ All auctions with `status === 'open'`.
|
|||||||
| `auction-open` | `auction` | `Map.set`; emit `remote-open` |
|
| `auction-open` | `auction` | `Map.set`; emit `remote-open` |
|
||||||
| `auction-bid` | `auctionId`, `bid` | append + sort if auction open; emit `remote-bid` |
|
| `auction-bid` | `auctionId`, `bid` | append + sort if auction open; emit `remote-bid` |
|
||||||
| `auction-close` | `auctionId`, `winner`, `closedAt` | set closed; emit `remote-close` |
|
| `auction-close` | `auctionId`, `winner`, `closedAt` | set closed; emit `remote-close` |
|
||||||
|
| `auction-withdraw` | `auctionId`, `bidder` | filter bids for bidder |
|
||||||
|
| `auction-cancel` | `auctionId` | delete auction |
|
||||||
|
|
||||||
Gossip is skipped until `ready()` has initialized `_peerMsgs`.
|
Gossip is skipped until `ready()` has initialized `_peerMsgs`.
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,37 @@ class HyperP2PAuctionGossip extends EventEmitter {
|
|||||||
return [...this._auctions.values()].filter((a) => a.status === 'open')
|
return [...this._auctions.values()].filter((a) => a.status === 'open')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listAuctionIds () { return [...this._auctions.keys()] }
|
||||||
|
|
||||||
|
highestBid (auctionId) {
|
||||||
|
const a = this._auctions.get(auctionId)
|
||||||
|
return a && a.bids.length ? a.bids[0] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
withdrawBid (auctionId, bidder = null) {
|
||||||
|
const a = this._auctions.get(auctionId)
|
||||||
|
if (!a || a.status !== 'open') return 0
|
||||||
|
const who = bidder || this.peerHex
|
||||||
|
const before = a.bids.length
|
||||||
|
a.bids = a.bids.filter((b) => b.bidder !== who)
|
||||||
|
a.bids.sort((x, y) => y.amount - x.amount)
|
||||||
|
const removed = before - a.bids.length
|
||||||
|
if (removed) {
|
||||||
|
this._gossip({ type: 'auction-withdraw', auctionId, bidder: who })
|
||||||
|
this.emit('withdraw', { auctionId, bidder: who, removed })
|
||||||
|
}
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelAuction (auctionId) {
|
||||||
|
const a = this._auctions.get(auctionId)
|
||||||
|
if (!a || a.status !== 'open') return false
|
||||||
|
this._auctions.delete(auctionId)
|
||||||
|
this._gossip({ type: 'auction-cancel', auctionId })
|
||||||
|
this.emit('cancel', { auctionId })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
_gossip (payload) {
|
_gossip (payload) {
|
||||||
if (!this._peerMsgs) return
|
if (!this._peerMsgs) return
|
||||||
gossipSend(this, payload)
|
gossipSend(this, payload)
|
||||||
@@ -105,6 +136,12 @@ class HyperP2PAuctionGossip extends EventEmitter {
|
|||||||
this.emit('remote-close', a)
|
this.emit('remote-close', a)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (data.type === 'auction-withdraw' && data.auctionId) {
|
||||||
|
this.withdrawBid(data.auctionId, data.bidder)
|
||||||
|
}
|
||||||
|
if (data.type === 'auction-cancel' && data.auctionId) {
|
||||||
|
this._auctions.delete(data.auctionId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getStats () {
|
getStats () {
|
||||||
|
|||||||
@@ -37,3 +37,16 @@ test('getStats', async (t) => {
|
|||||||
t.is(m.getStats().opened, 1)
|
t.is(m.getStats().opened, 1)
|
||||||
await m.close()
|
await m.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('withdraw cancel highest', async (t) => {
|
||||||
|
const m = new HyperP2PAuctionGossip()
|
||||||
|
m.openAuction('a1')
|
||||||
|
m.placeBid('a1', 10)
|
||||||
|
m._onGossip({ type: 'auction-bid', auctionId: 'a1', bid: { amount: 30, bidder: 'remote', at: 2 } })
|
||||||
|
t.is(m.highestBid('a1').amount, 30)
|
||||||
|
t.is(m.withdrawBid('a1', 'remote'), 1)
|
||||||
|
t.is(m.highestBid('a1').amount, 10)
|
||||||
|
t.ok(m.cancelAuction('a1'))
|
||||||
|
t.is(m.listAuctionIds().length, 0)
|
||||||
|
await m.close()
|
||||||
|
})
|
||||||
|
|||||||
@@ -64,6 +64,18 @@ class HyperP2PCreditLedger extends EventEmitter {
|
|||||||
return sum
|
return sum
|
||||||
}
|
}
|
||||||
|
|
||||||
|
topBalances (limit = 5) {
|
||||||
|
return [...this._accounts.values()]
|
||||||
|
.sort((a, b) => b.balance - a.balance)
|
||||||
|
.slice(0, Math.max(0, limit | 0))
|
||||||
|
.map((a) => ({ id: a.id, balance: a.balance }))
|
||||||
|
}
|
||||||
|
|
||||||
|
accountSnapshot (accountId) {
|
||||||
|
const a = this._accounts.get(accountId)
|
||||||
|
return a ? { id: a.id, balance: a.balance, updatedAt: a.updatedAt } : null
|
||||||
|
}
|
||||||
|
|
||||||
_apply (accountId, delta, kind, reason) {
|
_apply (accountId, delta, kind, reason) {
|
||||||
assertNonEmpty(accountId, 'accountId')
|
assertNonEmpty(accountId, 'accountId')
|
||||||
const acct = this._accounts.get(accountId)
|
const acct = this._accounts.get(accountId)
|
||||||
|
|||||||
@@ -48,3 +48,12 @@ test('totalSupply and listAccounts', async (t) => {
|
|||||||
t.alike(m.listAccounts().sort(), ['a', 'b'])
|
t.alike(m.listAccounts().sort(), ['a', 'b'])
|
||||||
await m.close()
|
await m.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('topBalances accountSnapshot', async (t) => {
|
||||||
|
const m = new HyperP2PCreditLedger()
|
||||||
|
m.openAccount('a', 5)
|
||||||
|
m.openAccount('b', 50)
|
||||||
|
t.is(m.topBalances(1)[0].id, 'b')
|
||||||
|
t.is(m.accountSnapshot('a').balance, 5)
|
||||||
|
await m.close()
|
||||||
|
})
|
||||||
|
|||||||
@@ -65,6 +65,34 @@ class HyperP2PMarketplaceListing extends EventEmitter {
|
|||||||
return [...this._listings.values()].filter((l) => l.status === 'active')
|
return [...this._listings.values()].filter((l) => l.status === 'active')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listListingIds () { return [...this._listings.keys()] }
|
||||||
|
|
||||||
|
searchByTags (tags, matchAll = false) {
|
||||||
|
if (!Array.isArray(tags) || !tags.length) return []
|
||||||
|
const active = this.listActive()
|
||||||
|
if (matchAll) {
|
||||||
|
return active.filter((l) => tags.every((t) => l.tags.includes(t)))
|
||||||
|
}
|
||||||
|
return active.filter((l) => tags.some((t) => l.tags.includes(t)))
|
||||||
|
}
|
||||||
|
|
||||||
|
searchPriceRange (min = 0, max = Infinity) {
|
||||||
|
return this.listActive().filter((l) => l.price >= min && l.price <= max)
|
||||||
|
}
|
||||||
|
|
||||||
|
listBySeller (sellerHex) {
|
||||||
|
return [...this._listings.values()].filter((l) => l.seller === sellerHex)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeListing (listingId) {
|
||||||
|
const ok = this._listings.delete(listingId)
|
||||||
|
if (ok) {
|
||||||
|
this._gossip({ type: 'listing-remove', listingId })
|
||||||
|
this.emit('remove', { listingId })
|
||||||
|
}
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
_gossip (payload) {
|
_gossip (payload) {
|
||||||
if (!this._peerMsgs) return
|
if (!this._peerMsgs) return
|
||||||
gossipSend(this, payload)
|
gossipSend(this, payload)
|
||||||
@@ -87,6 +115,9 @@ class HyperP2PMarketplaceListing extends EventEmitter {
|
|||||||
this.emit('remote-sold', l)
|
this.emit('remote-sold', l)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (data.type === 'listing-remove' && data.listingId) {
|
||||||
|
this._listings.delete(data.listingId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getStats () {
|
getStats () {
|
||||||
|
|||||||
@@ -37,3 +37,14 @@ test('getStats', async (t) => {
|
|||||||
t.is(m.getStats().protocol, 'marketplace-listing/v1')
|
t.is(m.getStats().protocol, 'marketplace-listing/v1')
|
||||||
await m.close()
|
await m.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('searchByTags price remove', async (t) => {
|
||||||
|
const m = new HyperP2PMarketplaceListing()
|
||||||
|
m.createListing('l1', { tags: ['a', 'b'], price: 50 })
|
||||||
|
m.createListing('l2', { tags: ['a'], price: 200 })
|
||||||
|
t.is(m.searchByTags(['a', 'b'], true).length, 1)
|
||||||
|
t.is(m.searchPriceRange(40, 60).length, 1)
|
||||||
|
t.ok(m.removeListing('l1'))
|
||||||
|
t.is(m.listListingIds().length, 1)
|
||||||
|
await m.close()
|
||||||
|
})
|
||||||
|
|||||||
@@ -81,6 +81,21 @@ class HyperP2PPheromoneTrail extends EventEmitter {
|
|||||||
|
|
||||||
trailCount () { return this._trails.size }
|
trailCount () { return this._trails.size }
|
||||||
|
|
||||||
|
pathsForDest (dest) {
|
||||||
|
const d = String(dest)
|
||||||
|
return [...this._trails.values()].filter((t) => t.dest === d)
|
||||||
|
}
|
||||||
|
|
||||||
|
rankPaths (dest, limit = 5) {
|
||||||
|
return this.pathsForDest(dest)
|
||||||
|
.sort((a, b) => b.strength - a.strength)
|
||||||
|
.slice(0, Math.max(0, limit | 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPath (pathId) {
|
||||||
|
return this._trails.delete(String(pathId))
|
||||||
|
}
|
||||||
|
|
||||||
async ready () {
|
async ready () {
|
||||||
if (this.swarm || !this.topic) return this
|
if (this.swarm || !this.topic) return this
|
||||||
await initModuleSwarm(this, {
|
await initModuleSwarm(this, {
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ test('pheromone-trail: evaporate', async (t) => {
|
|||||||
t.not(p.sniff('x'))
|
t.not(p.sniff('x'))
|
||||||
await p.close()
|
await p.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('pheromone-trail: rank and clear', async (t) => {
|
||||||
|
const p = new HyperP2PPheromoneTrail()
|
||||||
|
p.deposit('a', 0.2, 'dest')
|
||||||
|
p.deposit('b', 0.9, 'dest')
|
||||||
|
t.is(p.rankPaths('dest', 1)[0].pathId, 'b')
|
||||||
|
t.ok(p.clearPath('a'))
|
||||||
|
t.is(p.pathsForDest('dest').length, 1)
|
||||||
|
await p.close()
|
||||||
|
})
|
||||||
test('hyper-p2p-pheromone-trail: close without leak', async (t) => {
|
test('hyper-p2p-pheromone-trail: close without leak', async (t) => {
|
||||||
const m = new HyperP2PPheromoneTrail()
|
const m = new HyperP2PPheromoneTrail()
|
||||||
await m.close()
|
await m.close()
|
||||||
|
|||||||
@@ -84,6 +84,30 @@ class HyperP2PBucketRateLimit extends EventEmitter {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refill (peerId, amount = null) {
|
||||||
|
const b = this._bucket(peerId)
|
||||||
|
const add = amount == null ? this.burst - b.tokens : Number(amount)
|
||||||
|
b.tokens = Math.min(this.burst, b.tokens + Math.max(0, add))
|
||||||
|
return b.tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
reset (peerId = null) {
|
||||||
|
if (peerId == null) {
|
||||||
|
this._buckets.clear()
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
const key = typeof peerId === 'string' ? peerId : b4a.toString(peerId, 'hex')
|
||||||
|
return this._buckets.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
lowestTokens () {
|
||||||
|
let worst = null
|
||||||
|
for (const [peerId, b] of this._buckets) {
|
||||||
|
if (!worst || b.tokens < worst.tokens) worst = { peerId, tokens: b.tokens }
|
||||||
|
}
|
||||||
|
return worst
|
||||||
|
}
|
||||||
|
|
||||||
_applyRemote (data) {
|
_applyRemote (data) {
|
||||||
if (!data) return
|
if (!data) return
|
||||||
if (data.type === 'configure') {
|
if (data.type === 'configure') {
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ test('bucket-rate-limit: configure', async (t) => {
|
|||||||
await rl.close()
|
await rl.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('bucket-rate-limit: refill reset lowest', async (t) => {
|
||||||
|
const rl = new HyperP2PBucketRateLimit({ rate: 1, burst: 5 })
|
||||||
|
rl.tryConsume('a', 4)
|
||||||
|
rl.tryConsume('b', 1)
|
||||||
|
t.is(rl.lowestTokens().peerId, 'a')
|
||||||
|
t.ok(rl.refill('a') >= 4)
|
||||||
|
t.ok(rl.reset('b'))
|
||||||
|
await rl.close()
|
||||||
|
})
|
||||||
|
|
||||||
test('bucket-rate-limit: getBucket', async (t) => {
|
test('bucket-rate-limit: getBucket', async (t) => {
|
||||||
const rl = new HyperP2PBucketRateLimit({ rate: 2, burst: 3 })
|
const rl = new HyperP2PBucketRateLimit({ rate: 2, burst: 3 })
|
||||||
rl.tryConsume('peer-z', 1)
|
rl.tryConsume('peer-z', 1)
|
||||||
|
|||||||
@@ -50,6 +50,16 @@ class HyperP2PSlaBudget extends EventEmitter {
|
|||||||
return cur ? cur.remaining : 0
|
return cur ? cur.remaining : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canConsume (service, n = 1) {
|
||||||
|
return this.remaining(service) >= Number(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalRemaining () {
|
||||||
|
let sum = 0
|
||||||
|
for (const cur of this._services.values()) sum += cur.remaining
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
|
||||||
services () {
|
services () {
|
||||||
return [...this._services.keys()].sort()
|
return [...this._services.keys()].sort()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,3 +35,13 @@ test('getStats', async (t) => {
|
|||||||
t.is(m.getStats().allocated, 1)
|
t.is(m.getStats().allocated, 1)
|
||||||
await m.close()
|
await m.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('canConsume totalRemaining', async (t) => {
|
||||||
|
const m = new HyperP2PSlaBudget()
|
||||||
|
m.allocate('a', 5)
|
||||||
|
m.allocate('b', 3)
|
||||||
|
t.ok(m.canConsume('a', 4))
|
||||||
|
t.not(m.canConsume('a', 6))
|
||||||
|
t.is(m.totalRemaining(), 8)
|
||||||
|
await m.close()
|
||||||
|
})
|
||||||
|
|||||||
@@ -89,6 +89,15 @@ class HyperP2PCircuitLoom extends EventEmitter {
|
|||||||
return [...this._circuits.values()].filter((c) => c.state === 'open').length
|
return [...this._circuits.values()].filter((c) => c.state === 'open').length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
teardownAll () {
|
||||||
|
const ids = [...this._circuits.keys()]
|
||||||
|
let n = 0
|
||||||
|
for (const id of ids) {
|
||||||
|
if (this.teardown(id)) n++
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
getStats () {
|
getStats () {
|
||||||
return { ...this._stats, open: this.openCount(), total: this._circuits.size, protocol: PROTOCOL }
|
return { ...this._stats, open: this.openCount(), total: this._circuits.size, protocol: PROTOCOL }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,3 +33,12 @@ test('hyper-p2p-circuit-loom: close idempotent', async (t) => {
|
|||||||
await m.close()
|
await m.close()
|
||||||
t.pass()
|
t.pass()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('hyper-p2p-circuit-loom: teardownAll', async (t) => {
|
||||||
|
const m = new HyperP2PCircuitLoom()
|
||||||
|
m.buildCircuit(['a'])
|
||||||
|
m.buildCircuit(['b'])
|
||||||
|
t.is(m.teardownAll(), 2)
|
||||||
|
t.is(m.openCount(), 0)
|
||||||
|
await m.close()
|
||||||
|
})
|
||||||
|
|||||||
@@ -58,6 +58,30 @@ class HyperP2PLinkProbe extends EventEmitter {
|
|||||||
return best ? best.peerId : null
|
return best ? best.peerId : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
worstPeer () {
|
||||||
|
let worst = null
|
||||||
|
for (const rec of this._metrics.values()) {
|
||||||
|
if (rec.rttMs == null) continue
|
||||||
|
if (!worst || rec.rttMs > worst.rttMs) worst = rec
|
||||||
|
}
|
||||||
|
return worst ? worst.peerId : null
|
||||||
|
}
|
||||||
|
|
||||||
|
averageRtt () {
|
||||||
|
let sum = 0
|
||||||
|
let n = 0
|
||||||
|
for (const rec of this._metrics.values()) {
|
||||||
|
if (rec.rttMs == null) continue
|
||||||
|
sum += rec.rttMs
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return n ? sum / n : null
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPeer (peerId) {
|
||||||
|
return this._metrics.delete(peerId)
|
||||||
|
}
|
||||||
|
|
||||||
publishMatrix () {
|
publishMatrix () {
|
||||||
const matrix = [...this._metrics.values()]
|
const matrix = [...this._metrics.values()]
|
||||||
if (this._peerMsgs) gossipSend(this, { type: 'matrix', matrix })
|
if (this._peerMsgs) gossipSend(this, { type: 'matrix', matrix })
|
||||||
|
|||||||
@@ -34,3 +34,14 @@ test('hyper-p2p-link-probe: close idempotent', async (t) => {
|
|||||||
await m.close()
|
await m.close()
|
||||||
t.pass()
|
t.pass()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('hyper-p2p-link-probe: best worst average', async (t) => {
|
||||||
|
const m = new HyperP2PLinkProbe()
|
||||||
|
m.pong('fast', Date.now() - 10)
|
||||||
|
m.pong('slow', Date.now() - 100)
|
||||||
|
t.is(m.bestPeer(), 'fast')
|
||||||
|
t.is(m.worstPeer(), 'slow')
|
||||||
|
t.ok(m.averageRtt() > 40)
|
||||||
|
t.ok(m.clearPeer('slow'))
|
||||||
|
await m.close()
|
||||||
|
})
|
||||||
|
|||||||
@@ -60,6 +60,30 @@ class HyperP2PTraceSpan extends EventEmitter {
|
|||||||
return [...this._spans.values()].filter((s) => !s.endAt)
|
return [...this._spans.values()].filter((s) => !s.endAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listSpanIds () {
|
||||||
|
return [...this._spans.keys()]
|
||||||
|
}
|
||||||
|
|
||||||
|
childSpans (parentId) {
|
||||||
|
return [...this._spans.values()].filter((s) => s.parentId === parentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
slowSpans (thresholdMs = 100) {
|
||||||
|
if (thresholdMs < 0) throw new Error('thresholdMs must be non-negative')
|
||||||
|
return [...this._spans.values()].filter((s) => s.endAt && s.durationMs >= thresholdMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
traceTree (rootId = null) {
|
||||||
|
const roots = rootId != null
|
||||||
|
? [this._spans.get(String(rootId))].filter(Boolean)
|
||||||
|
: [...this._spans.values()].filter((s) => !s.parentId)
|
||||||
|
const build = (span) => ({
|
||||||
|
...span,
|
||||||
|
children: this.childSpans(span.id).map(build)
|
||||||
|
})
|
||||||
|
return roots.map(build)
|
||||||
|
}
|
||||||
|
|
||||||
_onGossip (data) {
|
_onGossip (data) {
|
||||||
if (!data || !data.type) return
|
if (!data || !data.type) return
|
||||||
this._stats.gossipIn++
|
this._stats.gossipIn++
|
||||||
|
|||||||
@@ -35,3 +35,15 @@ test('getStats', async (t) => {
|
|||||||
t.is(m.getStats().protocol, 'trace-span/v1')
|
t.is(m.getStats().protocol, 'trace-span/v1')
|
||||||
await m.close()
|
await m.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('trace tree and slow spans', async (t) => {
|
||||||
|
const m = new HyperP2PTraceSpan()
|
||||||
|
const root = m.startSpan('root')
|
||||||
|
const child = m.startSpan('child', root)
|
||||||
|
m.endSpan(child)
|
||||||
|
m.endSpan(root)
|
||||||
|
t.is(m.childSpans(root).length, 1)
|
||||||
|
t.is(m.traceTree(root)[0].children.length, 1)
|
||||||
|
t.is(m.slowSpans(0).length, 2)
|
||||||
|
await m.close()
|
||||||
|
})
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ Public API on `HyperPearUpdateGossip`. See [`index.js`](../index.js) for paramet
|
|||||||
|
|
||||||
Public API on `HyperPearUpdateGossip`. See [`index.js`](../index.js) for parameters and return types.
|
Public API on `HyperPearUpdateGossip`. See [`index.js`](../index.js) for parameters and return types.
|
||||||
|
|
||||||
|
### `isNewerThan(version) → boolean`
|
||||||
|
|
||||||
|
`true` when `latestUpdate().version` is newer than `version` (numeric or string compare).
|
||||||
|
|
||||||
|
### `updateHistory(limit = 10) → object[]`
|
||||||
|
|
||||||
|
Recent published updates (ring buffer, `maxHistory` constructor option, default 32).
|
||||||
|
|
||||||
### `getStats() → object`
|
### `getStats() → object`
|
||||||
|
|
||||||
Metrics plus `protocol: 'pear-update-gossip/v1'`.
|
Metrics plus `protocol: 'pear-update-gossip/v1'`.
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ class HyperPearUpdateGossip extends EventEmitter {
|
|||||||
this.topic = opts.topic || null
|
this.topic = opts.topic || null
|
||||||
this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair()
|
this.keyPair = opts.keyPair || require('hypercore-crypto').keyPair()
|
||||||
this._latest = null
|
this._latest = null
|
||||||
|
this._history = []
|
||||||
|
this.maxHistory = opts.maxHistory ?? 32
|
||||||
this._subs = new Set()
|
this._subs = new Set()
|
||||||
this._stats = { published: 0, gossipIn: 0, gossipOut: 0 }
|
this._stats = { published: 0, gossipIn: 0, gossipOut: 0 }
|
||||||
this.swarm = null
|
this.swarm = null
|
||||||
@@ -23,19 +25,37 @@ class HyperPearUpdateGossip extends EventEmitter {
|
|||||||
throw new Error('manifest must be an object')
|
throw new Error('manifest must be an object')
|
||||||
}
|
}
|
||||||
const update = { version, manifest, publishedAt: Date.now() }
|
const update = { version, manifest, publishedAt: Date.now() }
|
||||||
if (!this._latest || version > this._latest.version) {
|
if (!this._latest || this._isNewer(version, this._latest.version)) {
|
||||||
this._latest = update
|
this._latest = update
|
||||||
}
|
}
|
||||||
|
this._history.push(update)
|
||||||
|
if (this._history.length > this.maxHistory) this._history.shift()
|
||||||
this._stats.published++
|
this._stats.published++
|
||||||
this._gossip({ type: 'update-publish', version, manifest, publishedAt: update.publishedAt })
|
this._gossip({ type: 'update-publish', version, manifest, publishedAt: update.publishedAt })
|
||||||
this._notify(update)
|
this._notify(update)
|
||||||
return update
|
return update
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_isNewer (a, b) {
|
||||||
|
const na = Number(a)
|
||||||
|
const nb = Number(b)
|
||||||
|
if (!Number.isNaN(na) && !Number.isNaN(nb)) return na > nb
|
||||||
|
return String(a) > String(b)
|
||||||
|
}
|
||||||
|
|
||||||
latestUpdate () {
|
latestUpdate () {
|
||||||
return this._latest ? { ...this._latest } : null
|
return this._latest ? { ...this._latest } : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isNewerThan (version) {
|
||||||
|
if (!this._latest) return false
|
||||||
|
return this._isNewer(this._latest.version, version)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHistory (limit = 10) {
|
||||||
|
return this._history.slice(-Math.max(0, limit | 0))
|
||||||
|
}
|
||||||
|
|
||||||
subscribe (fn) {
|
subscribe (fn) {
|
||||||
if (typeof fn !== 'function') throw new Error('fn must be a function')
|
if (typeof fn !== 'function') throw new Error('fn must be a function')
|
||||||
this._subs.add(fn)
|
this._subs.add(fn)
|
||||||
@@ -59,12 +79,14 @@ class HyperPearUpdateGossip extends EventEmitter {
|
|||||||
_onGossip (d) {
|
_onGossip (d) {
|
||||||
if (!d || d.type !== 'update-publish' || !d.version) return
|
if (!d || d.type !== 'update-publish' || !d.version) return
|
||||||
this._stats.gossipIn++
|
this._stats.gossipIn++
|
||||||
if (!this._latest || d.version > this._latest.version) {
|
if (!this._latest || this._isNewer(d.version, this._latest.version)) {
|
||||||
this._latest = {
|
this._latest = {
|
||||||
version: d.version,
|
version: d.version,
|
||||||
manifest: d.manifest,
|
manifest: d.manifest,
|
||||||
publishedAt: d.publishedAt || Date.now()
|
publishedAt: d.publishedAt || Date.now()
|
||||||
}
|
}
|
||||||
|
this._history.push(this._latest)
|
||||||
|
if (this._history.length > this.maxHistory) this._history.shift()
|
||||||
this._notify(this._latest)
|
this._notify(this._latest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,3 +35,12 @@ test('getStats', async (t) => {
|
|||||||
t.is(m.getStats().published, 1)
|
t.is(m.getStats().published, 1)
|
||||||
await m.close()
|
await m.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('history and isNewer', async (t) => {
|
||||||
|
const m = new HyperPearUpdateGossip()
|
||||||
|
m.publishUpdate('1.0.0', {})
|
||||||
|
m.publishUpdate('2.0.0', {})
|
||||||
|
t.ok(m.isNewerThan('1.5.0'))
|
||||||
|
t.is(m.updateHistory(2).length, 2)
|
||||||
|
await m.close()
|
||||||
|
})
|
||||||
|
|||||||
@@ -174,6 +174,38 @@ class HyperP2PActivityQueue extends EventEmitter {
|
|||||||
return this._queue.filter((e) => e.state === 'pending').length
|
return this._queue.filter((e) => e.state === 'pending').length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listPending () {
|
||||||
|
return this._queue.filter((e) => e.state === 'pending').map((e) => ({ ...e }))
|
||||||
|
}
|
||||||
|
|
||||||
|
peek (n = 5) {
|
||||||
|
this._purgeExpired()
|
||||||
|
return this.listPending().slice(0, Math.max(0, n | 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseClaim (id) {
|
||||||
|
const entry = this._claimed.get(id)
|
||||||
|
if (!entry) return false
|
||||||
|
entry.state = 'pending'
|
||||||
|
delete entry.claimedAt
|
||||||
|
delete entry.workerId
|
||||||
|
this._claimed.delete(id)
|
||||||
|
this._sortPending()
|
||||||
|
this.emit('release', { id })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
reclaimExpired (maxAgeMs = 60000) {
|
||||||
|
const now = Date.now()
|
||||||
|
let n = 0
|
||||||
|
for (const [id, entry] of this._claimed) {
|
||||||
|
if (entry.claimedAt && now - entry.claimedAt > maxAgeMs) {
|
||||||
|
this.releaseClaim(id)
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
getStats () {
|
getStats () {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -29,6 +29,21 @@ test('activity-queue: nack to dead letter', async (t) => {
|
|||||||
await q.close()
|
await q.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('activity-queue: peek release reclaim', async (t) => {
|
||||||
|
const q = new HyperP2PActivityQueue()
|
||||||
|
q.enqueue({ payload: 'a' })
|
||||||
|
q.enqueue({ payload: 'b' })
|
||||||
|
t.is(q.peek(1).length, 1)
|
||||||
|
const j = q.claim('w1')
|
||||||
|
t.ok(q.releaseClaim(j.id))
|
||||||
|
t.is(q.getQueueDepth(), 2)
|
||||||
|
const j2 = q.claim('w2')
|
||||||
|
j2.claimedAt = Date.now() - 120000
|
||||||
|
q._claimed.set(j2.id, j2)
|
||||||
|
t.is(q.reclaimExpired(60000), 1)
|
||||||
|
await q.close()
|
||||||
|
})
|
||||||
|
|
||||||
test('activity-queue: vector clock claim order', async (t) => {
|
test('activity-queue: vector clock claim order', async (t) => {
|
||||||
const vc = new HyperP2PVectorClock('w')
|
const vc = new HyperP2PVectorClock('w')
|
||||||
const q = new HyperP2PActivityQueue({ vectorClock: vc })
|
const q = new HyperP2PActivityQueue({ vectorClock: vc })
|
||||||
|
|||||||
@@ -55,6 +55,21 @@ class HyperP2PPeerScheduler extends EventEmitter {
|
|||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nextDue (jobId) {
|
||||||
|
const job = this._jobs.get(jobId)
|
||||||
|
return job ? job.nextAt : null
|
||||||
|
}
|
||||||
|
|
||||||
|
dueJobs (now = Date.now()) {
|
||||||
|
return [...this._jobs.values()].filter((j) => now >= j.nextAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
msUntil (jobId, now = Date.now()) {
|
||||||
|
const job = this._jobs.get(jobId)
|
||||||
|
if (!job) return null
|
||||||
|
return Math.max(0, job.nextAt - now)
|
||||||
|
}
|
||||||
|
|
||||||
_isLeader (shard) {
|
_isLeader (shard) {
|
||||||
if (!this.topicLease) return true
|
if (!this.topicLease) return true
|
||||||
const holder = this.topicLease.holder(shard)
|
const holder = this.topicLease.holder(shard)
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ test('peer-scheduler: schedule and manual tick', async (t) => {
|
|||||||
await s.close()
|
await s.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('peer-scheduler: nextDue dueJobs msUntil', async (t) => {
|
||||||
|
const s = new HyperP2PPeerScheduler()
|
||||||
|
const id = s.schedule(500, 'j')
|
||||||
|
const due = s.nextDue(id)
|
||||||
|
t.ok(due > Date.now())
|
||||||
|
t.is(s.dueJobs(due - 1).length, 0)
|
||||||
|
t.ok(s.msUntil(id) <= 500)
|
||||||
|
await s.close()
|
||||||
|
})
|
||||||
|
|
||||||
test('peer-scheduler: no background timer by default', async (t) => {
|
test('peer-scheduler: no background timer by default', async (t) => {
|
||||||
const s = new HyperP2PPeerScheduler()
|
const s = new HyperP2PPeerScheduler()
|
||||||
t.is(s.enableBackgroundTimers, false)
|
t.is(s.enableBackgroundTimers, false)
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ Autobase **coordination** for multi-writer logs: fork selection, view sync gossi
|
|||||||
| [hyper-p2p-autobase-indexer-bus](./hyper-p2p-autobase-indexer-bus/) | `autobase-indexer-bus/v1` | yes |
|
| [hyper-p2p-autobase-indexer-bus](./hyper-p2p-autobase-indexer-bus/) | `autobase-indexer-bus/v1` | yes |
|
||||||
| [hyper-p2p-autobase-light-writer](./hyper-p2p-autobase-light-writer/) | `autobase-light-writer/v1` | no |
|
| [hyper-p2p-autobase-light-writer](./hyper-p2p-autobase-light-writer/) | `autobase-light-writer/v1` | no |
|
||||||
|
|
||||||
|
## Recent API helpers
|
||||||
|
|
||||||
|
- **fork-choice:** `hasView`, `removeView`, `scoreFork`
|
||||||
|
- **writer-lease:** `leaseRemainingMs`, `currentHolder`, `expireStaleLeases`
|
||||||
|
- **indexer-bus:** `filterByType`, `lastEvent`, `clearEvents`, `peekEvents`
|
||||||
|
- **view-sync:** `viewHash`, `ageMs`, `behindBy`, `isAtLeast`
|
||||||
|
|
||||||
## Composition
|
## Composition
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -63,6 +63,21 @@ class HyperP2PAutobaseForkChoice extends EventEmitter {
|
|||||||
return this._views.get(this._chosen) || null
|
return this._views.get(this._chosen) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasView (forkId) {
|
||||||
|
return this._views.has(forkId)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeView (forkId) {
|
||||||
|
const ok = this._views.delete(forkId)
|
||||||
|
if (ok && this._chosen === forkId) this._chosen = null
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
scoreFork (forkId) {
|
||||||
|
const v = this._views.get(forkId)
|
||||||
|
return v ? v.weight : null
|
||||||
|
}
|
||||||
|
|
||||||
_onGossip (d) {
|
_onGossip (d) {
|
||||||
if (!d || d.type !== 'view-register' || !d.view) return
|
if (!d || d.type !== 'view-register' || !d.view) return
|
||||||
this._stats.gossipIn++
|
this._stats.gossipIn++
|
||||||
|
|||||||
@@ -36,3 +36,13 @@ test('getStats', async (t) => {
|
|||||||
t.is(m.getStats().protocol, 'autobase-fork-choice/v1')
|
t.is(m.getStats().protocol, 'autobase-fork-choice/v1')
|
||||||
await m.close()
|
await m.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('hasView removeView scoreFork', async (t) => {
|
||||||
|
const m = new HyperP2PAutobaseForkChoice()
|
||||||
|
m.registerView('f1', 1, { weight: 3 })
|
||||||
|
t.ok(m.hasView('f1'))
|
||||||
|
t.is(m.scoreFork('f1'), 3)
|
||||||
|
t.ok(m.removeView('f1'))
|
||||||
|
t.not(m.hasView('f1'))
|
||||||
|
await m.close()
|
||||||
|
})
|
||||||
|
|||||||
@@ -64,6 +64,22 @@ class HyperP2PAutobaseIndexerBus extends EventEmitter {
|
|||||||
return this._queue.slice(0, n).map((e) => ({ ...e }))
|
return this._queue.slice(0, n).map((e) => ({ ...e }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
filterByType (type) {
|
||||||
|
assertNonEmpty(type, 'type')
|
||||||
|
return this._queue.filter((e) => e.type === type).map((e) => ({ ...e }))
|
||||||
|
}
|
||||||
|
|
||||||
|
lastEvent () {
|
||||||
|
const e = this._queue[this._queue.length - 1]
|
||||||
|
return e ? { ...e } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
clearEvents () {
|
||||||
|
const n = this._queue.length
|
||||||
|
this._queue = []
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
_onGossip (d) {
|
_onGossip (d) {
|
||||||
if (!d || d.type !== 'index-event' || !d.evt) return
|
if (!d || d.type !== 'index-event' || !d.evt) return
|
||||||
this._stats.gossipIn++
|
this._stats.gossipIn++
|
||||||
|
|||||||
@@ -37,3 +37,13 @@ test('getStats', async (t) => {
|
|||||||
t.is(m.getStats().protocol, 'autobase-indexer-bus/v1')
|
t.is(m.getStats().protocol, 'autobase-indexer-bus/v1')
|
||||||
await m.close()
|
await m.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('filterByType lastEvent clearEvents', async (t) => {
|
||||||
|
const m = new HyperP2PAutobaseIndexerBus()
|
||||||
|
m.publishIndexEvent('a', { n: 1 })
|
||||||
|
m.publishIndexEvent('b', { n: 2 })
|
||||||
|
t.is(m.filterByType('a').length, 1)
|
||||||
|
t.is(m.lastEvent().type, 'b')
|
||||||
|
t.is(m.clearEvents(), 2)
|
||||||
|
await m.close()
|
||||||
|
})
|
||||||
|
|||||||
@@ -67,6 +67,15 @@ class HyperP2PAutobaseViewSync extends EventEmitter {
|
|||||||
return Math.max(0, version - this._view.version)
|
return Math.max(0, version - this._view.version)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
viewHash () {
|
||||||
|
return this._view.hash
|
||||||
|
}
|
||||||
|
|
||||||
|
ageMs () {
|
||||||
|
if (!this._view.at) return 0
|
||||||
|
return Math.max(0, Date.now() - this._view.at)
|
||||||
|
}
|
||||||
|
|
||||||
_onGossip (d) {
|
_onGossip (d) {
|
||||||
if (!d || d.type !== 'view-sync' || !d.view) return
|
if (!d || d.type !== 'view-sync' || !d.view) return
|
||||||
this._stats.gossipIn++
|
this._stats.gossipIn++
|
||||||
|
|||||||
@@ -36,3 +36,13 @@ test('getStats', async (t) => {
|
|||||||
t.is(m.getStats().protocol, 'autobase-view-sync/v1')
|
t.is(m.getStats().protocol, 'autobase-view-sync/v1')
|
||||||
await m.close()
|
await m.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('viewHash ageMs behindBy', async (t) => {
|
||||||
|
const m = new HyperP2PAutobaseViewSync()
|
||||||
|
m.publishView(4)
|
||||||
|
t.ok(m.viewHash())
|
||||||
|
t.ok(m.ageMs() >= 0)
|
||||||
|
t.is(m.behindBy(10), 6)
|
||||||
|
t.ok(m.isAtLeast(4))
|
||||||
|
await m.close()
|
||||||
|
})
|
||||||
|
|||||||
@@ -61,6 +61,17 @@ class HyperP2PAutobaseWriterLease extends EventEmitter {
|
|||||||
return lease.holder === writerId
|
return lease.holder === writerId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
leaseRemainingMs (writerId) {
|
||||||
|
const lease = this._leases.get(writerId)
|
||||||
|
if (!lease) return 0
|
||||||
|
return Math.max(0, lease.expiresAt - Date.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
currentHolder (writerId) {
|
||||||
|
if (!this.hasLease(writerId)) return null
|
||||||
|
return this._leases.get(writerId).holder
|
||||||
|
}
|
||||||
|
|
||||||
listActiveLeases () {
|
listActiveLeases () {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const out = []
|
const out = []
|
||||||
|
|||||||
@@ -36,3 +36,11 @@ test('getStats', async (t) => {
|
|||||||
t.is(m.getStats().protocol, 'autobase-writer-lease/v1')
|
t.is(m.getStats().protocol, 'autobase-writer-lease/v1')
|
||||||
await m.close()
|
await m.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('leaseRemainingMs currentHolder', async (t) => {
|
||||||
|
const m = new HyperP2PAutobaseWriterLease()
|
||||||
|
m.acquireLease('w1', 60000)
|
||||||
|
t.ok(m.leaseRemainingMs('w1') > 0)
|
||||||
|
t.is(m.currentHolder('w1'), 'w1')
|
||||||
|
await m.close()
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user