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:
Raven Scott
2026-05-21 01:09:29 -04:00
co-authored by Cursor
parent b8b815fce6
commit 44b8a80907
36 changed files with 508 additions and 4 deletions
+2 -2
View File
@@ -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)
+7
View File
@@ -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()
})