UNPKG

ipfs-bitswap

Version:

JavaScript implementation of the Bitswap data exchange protocol used by IPFS

488 lines (415 loc) 14.2 kB
import { trackedMap } from '@libp2p/utils/tracked-map' import { base58btc } from 'multiformats/bases/base58' import { CID } from 'multiformats/cid' import { BitswapMessage as Message } from '../message/index.js' import { logger } from '../utils/index.js' import { Wantlist } from '../wantlist/index.js' import { Ledger } from './ledger.js' import { RequestQueue } from './req-queue.js' import { DefaultTaskMerger } from './task-merger.js' import type { BitswapMessageEntry } from '../message/entry.js' import type { Message as PBMessage } from '../message/message.js' import type { Network } from '../network.js' import type { Stats } from '../stats/index.js' import type { WantListEntry } from '../wantlist/entry.js' import type { Libp2p, PeerId } from '@libp2p/interface' import type { Logger } from '@libp2p/logger' import type { Blockstore } from 'interface-blockstore' export interface TaskMerger { /** * Given the existing tasks with the same topic, does the task add some new * information? Used to decide whether to merge the task or ignore it. */ hasNewInfo(task: Task, tasksWithTopic: Task[]): boolean /** * Merge the information from the task into the existing pending task. */ merge(newTask: Task, existingTask: Task): void } export interface Task { /** * A name for the Task (like an id but not necessarily unique) */ topic: string /** * Priority for the Task (tasks are ordered by priority per peer). */ priority: number /** * The size of the task, e.g. the number of bytes in a block. */ size: number data: TaskData } export interface TaskData { /** * The size of the block, if known (if we don't have the block this is zero) */ blockSize: number /** * Indicates if the request is for a block or for a HAVE. */ isWantBlock: boolean /** * Indicates if we have the block. */ haveBlock: boolean /** * Indicates whether to send a DONT_HAVE response if we don't have the block. * If this is `false` and we don't have the block, we just ignore the * want-block request (useful for discovery where we query lots of peers but * don't want a response unless the peer has the block). */ sendDontHave: boolean } const WantType = Message.WantType // The ideal size of the batched payload. We try to pop this much data off the // request queue, but // - if there isn't any more data in the queue we send whatever we have // - if there are several small items in the queue (eg HAVE response) followed // by one big item (eg a block) that would exceed this target size, we // include the big item in the message const TARGET_MESSAGE_SIZE = 16 * 1024 // If the client sends a want-have, and the engine has the corresponding block, // we check the size of the block and if it's small enough we send the block // itself, rather than sending a HAVE. // This constant defines the maximum size up to which we replace a HAVE with // a block. const MAX_SIZE_REPLACE_HAS_WITH_BLOCK = 1024 export interface DecisionEngineOptions { targetMessageSize?: number maxSizeReplaceHasWithBlock?: number } export interface PeerLedger { peer: PeerId value: number sent: number recv: number exchanged: number } export class DecisionEngine { private readonly _log: Logger public blockstore: Blockstore public network: Network private readonly _stats: Stats private readonly _opts: Required<DecisionEngineOptions> public ledgerMap: Map<string, Ledger> private _running: boolean public _requestQueue: RequestQueue constructor (peerId: PeerId, blockstore: Blockstore, network: Network, stats: Stats, libp2p: Libp2p, opts: DecisionEngineOptions = {}) { this._log = logger(peerId, 'engine') this.blockstore = blockstore this.network = network this._stats = stats this._opts = this._processOpts(opts) // A list of of ledgers by their partner id this.ledgerMap = trackedMap({ name: 'ipfs_bitswap_ledger_map', metrics: libp2p.metrics }) this._running = false // Queue of want-have / want-block per peer this._requestQueue = new RequestQueue(DefaultTaskMerger) } _processOpts (opts: DecisionEngineOptions): Required<DecisionEngineOptions> { return { maxSizeReplaceHasWithBlock: MAX_SIZE_REPLACE_HAS_WITH_BLOCK, targetMessageSize: TARGET_MESSAGE_SIZE, ...opts } } _scheduleProcessTasks (): void { setTimeout(() => { this._processTasks().catch(err => { this._log.error('error processing stats', err) }) }) } /** * Pull tasks off the request queue and send a message to the corresponding * peer */ async _processTasks (): Promise<void> { if (!this._running) { return } const { peerId, tasks, pendingSize } = this._requestQueue.popTasks(this._opts.targetMessageSize) if (tasks.length === 0) { return } // Create a new message const msg = new Message(false) // Amount of data in the request queue still waiting to be popped msg.setPendingBytes(pendingSize) // Split out want-blocks, want-haves and DONT_HAVEs const blockCids = [] const blockTasks = new Map<string, TaskData>() for (const task of tasks) { const cid = CID.parse(task.topic) if (task.data.haveBlock) { if (task.data.isWantBlock) { blockCids.push(cid) blockTasks.set(task.topic, task.data) } else { // Add HAVES to the message msg.addHave(cid) } } else { // Add DONT_HAVEs to the message msg.addDontHave(cid) } } const blocks = await this._getBlocks(blockCids) for (const [topic, taskData] of blockTasks) { const cid = CID.parse(topic) const blk = blocks.get(topic) // If the block was found (it has not been removed) if (blk != null) { // Add the block to the message msg.addBlock(cid, blk) } else { // The block was not found. If the client requested DONT_HAVE, // add DONT_HAVE to the message. if (taskData.sendDontHave) { msg.addDontHave(cid) } } } // If there's nothing in the message, bail out if (msg.empty) { (peerId != null) && this._requestQueue.tasksDone(peerId, tasks) // Trigger the next round of task processing this._scheduleProcessTasks() return } try { // Send the message (peerId != null) && await this.network.sendMessage(peerId, msg) // Peform sent message accounting for (const [cidStr, block] of blocks.entries()) { (peerId != null) && this.messageSent(peerId, CID.parse(cidStr), block) } } catch (err) { this._log.error(err) } // Free the tasks up from the request queue (peerId != null) && this._requestQueue.tasksDone(peerId, tasks) // Trigger the next round of task processing this._scheduleProcessTasks() } wantlistForPeer (peerId: PeerId): Map<string, WantListEntry> { const peerIdStr = peerId.toString() const ledger = this.ledgerMap.get(peerIdStr) return (ledger != null) ? ledger.wantlist.sortedEntries() : new Map() } ledgerForPeer (peerId: PeerId): PeerLedger | undefined { const peerIdStr = peerId.toString() const ledger = this.ledgerMap.get(peerIdStr) if (ledger == null) { return undefined } return { peer: ledger.partner, value: ledger.debtRatio(), sent: ledger.accounting.bytesSent, recv: ledger.accounting.bytesRecv, exchanged: ledger.exchangeCount } } peers (): PeerId[] { return Array.from(this.ledgerMap.values()).map((l) => l.partner) } /** * Receive blocks either from an incoming message from the network, or from * blocks being added by the client on the localhost (eg IPFS add) */ receivedBlocks (blocks: Array<{ cid: CID, block: Uint8Array }>): void { if (blocks.length === 0) { return } // For each connected peer, check if it wants the block we received for (const ledger of this.ledgerMap.values()) { for (const { cid, block } of blocks) { // Filter out blocks that we don't want const want = ledger.wantlistContains(cid) if (want == null) { continue } // If the block is small enough, just send the block, even if the // client asked for a HAVE const blockSize = block.length const isWantBlock = this._sendAsBlock(want.wantType, blockSize) let entrySize = blockSize if (!isWantBlock) { entrySize = Message.blockPresenceSize(want.cid) } this._requestQueue.pushTasks(ledger.partner, [{ topic: want.cid.toString(base58btc), priority: want.priority, size: entrySize, data: { blockSize, isWantBlock, haveBlock: true, sendDontHave: false } }]) } } this._scheduleProcessTasks() } /** * Handle incoming messages */ async messageReceived (peerId: PeerId, msg: Message): Promise<void> { const ledger = this._findOrCreate(peerId) if (msg.empty) { return } // If the message has a full wantlist, clear the current wantlist if (msg.full) { ledger.wantlist = new Wantlist() } // Record the amount of block data received this._updateBlockAccounting(msg.blocks, ledger) if (msg.wantlist.size === 0) { this._scheduleProcessTasks() return } // Clear cancelled wants and add new wants to the ledger const cancels: CID[] = [] const wants: BitswapMessageEntry[] = [] msg.wantlist.forEach((entry) => { if (entry.cancel) { ledger.cancelWant(entry.cid) cancels.push(entry.cid) } else { ledger.wants(entry.cid, entry.priority, entry.wantType) wants.push(entry) } }) this._cancelWants(peerId, cancels) await this._addWants(peerId, wants) this._scheduleProcessTasks() } _cancelWants (peerId: PeerId, cids: CID[]): void { for (const c of cids) { this._requestQueue.remove(c.toString(base58btc), peerId) } } async _addWants (peerId: PeerId, wants: BitswapMessageEntry[]): Promise<void> { // Get the size of each wanted block const blockSizes = await this._getBlockSizes(wants.map(w => w.cid)) const tasks = [] for (const want of wants) { const id = want.cid.toString(base58btc) const blockSize = blockSizes.get(id) // If the block was not found if (blockSize == null) { // Only add the task to the queue if the requester wants a DONT_HAVE if (want.sendDontHave) { tasks.push({ topic: id, priority: want.priority, size: Message.blockPresenceSize(want.cid), data: { isWantBlock: want.wantType === WantType.Block, blockSize: 0, haveBlock: false, sendDontHave: want.sendDontHave } }) } } else { // The block was found, add it to the queue // If the block is small enough, just send the block, even if the // client asked for a HAVE const isWantBlock = this._sendAsBlock(want.wantType, blockSize) // entrySize is the amount of space the entry takes up in the // message we send to the recipient. If we're sending a block, the // entrySize is the size of the block. Otherwise it's the size of // a block presence entry. let entrySize = blockSize if (!isWantBlock) { entrySize = Message.blockPresenceSize(want.cid) } tasks.push({ topic: id, priority: want.priority, size: entrySize, data: { isWantBlock, blockSize, haveBlock: true, sendDontHave: want.sendDontHave } }) } this._requestQueue.pushTasks(peerId, tasks) } } _sendAsBlock (wantType: PBMessage.Wantlist.WantType, blockSize: number): boolean { return wantType === WantType.Block || blockSize <= this._opts.maxSizeReplaceHasWithBlock } async _getBlockSizes (cids: CID[]): Promise<Map<string, number>> { const blocks = await this._getBlocks(cids) return new Map([...blocks].map(([k, v]) => [k, v.length])) } async _getBlocks (cids: CID[]): Promise<Map<string, Uint8Array>> { const res = new Map() await Promise.all(cids.map(async (cid) => { try { const block = await this.blockstore.get(cid) res.set(cid.toString(base58btc), block) } catch (err: any) { if (err.code !== 'ERR_NOT_FOUND') { this._log.error('failed to query blockstore for %s: %s', cid, err) } } })) return res } _updateBlockAccounting (blocksMap: Map<string, Uint8Array>, ledger: Ledger): void { for (const block of blocksMap.values()) { this._log('got block (%s bytes)', block.length) ledger.receivedBytes(block.length) } } /** * Clear up all accounting things after message was sent */ messageSent (peerId: PeerId, cid: CID, block: Uint8Array): void { const ledger = this._findOrCreate(peerId) ledger.sentBytes(block.length) ledger.wantlist.remove(cid) } numBytesSentTo (peerId: PeerId): number { return this._findOrCreate(peerId).accounting.bytesSent } numBytesReceivedFrom (peerId: PeerId): number { return this._findOrCreate(peerId).accounting.bytesRecv } peerDisconnected (peerId: PeerId): void { this.ledgerMap.delete(peerId.toString()) } _findOrCreate (peerId: PeerId): Ledger { const peerIdStr = peerId.toString() const ledger = this.ledgerMap.get(peerIdStr) if (ledger != null) { return ledger } const l = new Ledger(peerId) this.ledgerMap.set(peerIdStr, l) if (this._stats != null) { this._stats.push(peerIdStr, 'peerCount', 1) } return l } start (): void { this._running = true } stop (): void { this._running = false } }