Skip to content

Add alternative provider retrieval check #132

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

Open
wants to merge 26 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
aedec80
Add network wide retrieval check
pyropy Apr 9, 2025
233cc1f
Use status code instead of boolean retrieval flag
pyropy Apr 9, 2025
83e7f31
Simplify name for network wide measurements
pyropy Apr 9, 2025
afe30dd
Refactor code for picking random provider
pyropy Apr 9, 2025
23ee203
Add network retrieval protocol field
pyropy Apr 9, 2025
4bc1076
Add basic test for testing network retrieval
pyropy Apr 9, 2025
63424ff
Refactor function for picking random providers
pyropy Apr 9, 2025
8a94f4e
Only return providers in case of no valid advert
pyropy Apr 9, 2025
c4350b6
Convert network stats to object inside stats obj
pyropy Apr 10, 2025
edfdef1
Format testNetworkRetrieval func
pyropy Apr 10, 2025
dbf0fd7
Refactor queryTheIndex function
pyropy Apr 10, 2025
d33f276
Handle case when no random provider is picked
pyropy Apr 10, 2025
97bee91
Test function for picking random providers
pyropy Apr 10, 2025
4b6d0bc
Rename network retrieval to alternative provider check
pyropy Apr 11, 2025
97fcc28
Update logging to reflect metric name change
pyropy Apr 11, 2025
5121a49
Update logging to reflect metric name change
pyropy Apr 11, 2025
4065784
Rename providers field to alternativeProviders
pyropy Apr 11, 2025
74f06e9
Rename testNetworkRetrieval to checkRetrievalFromAlternativeProvider
pyropy Apr 11, 2025
ea8cce4
Return retrieval stats from checkRetrievalFromAlternativeProvider
pyropy Apr 11, 2025
f9afe34
Update lib/spark.js
pyropy Apr 11, 2025
9959b50
Update lib/spark.js
pyropy Apr 11, 2025
a2da050
Rename functions to match new metric name
pyropy Apr 11, 2025
9759d80
Merge branch 'add/network-wide-retrieval-check' of github.com:filecoi…
pyropy Apr 11, 2025
820e8a3
Pick alternative provider using supplied randomness
pyropy Apr 15, 2025
5b13287
Replace custom rng implementation with Prando
pyropy Apr 15, 2025
3c14f84
Fix typos
pyropy Apr 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,6 @@ export {
export { assertOkResponse } from 'https://cdn.skypack.dev/[email protected]/?dts'
import pRetry from 'https://cdn.skypack.dev/[email protected]/?dts'
export { pRetry }

import Prando from 'https://cdn.jsdelivr.net/npm/[email protected]/+esm'
export { Prando }
Copy link
Author

Choose a reason for hiding this comment

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

I have opted for using package instead of the custom implementation for the pRNG. There's lack of good packages for pRNG so I have settled in the end for Prando. I also wanted to use Deno's random package but from what I realize they have added it to newer versions of the std package which we don't use yet.

This may be a good thing to update in the future.

33 changes: 24 additions & 9 deletions lib/ipni-client.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { decodeBase64, decodeVarint, pRetry, assertOkResponse } from '../vendor/deno-deps.js'

/** @typedef {{ address: string; protocol: string; contextId: string; }} Provider */

/**
*
* @param {string} cid
* @param {string} providerId
* @returns {Promise<{
* indexerResult: string;
* provider?: { address: string; protocol: string };
* provider?: Provider;
* alternativeProviders?: Provider[];
* }>}
*/
export async function queryTheIndex(cid, providerId) {
Expand All @@ -31,9 +34,8 @@ export async function queryTheIndex(cid, providerId) {
}

let graphsyncProvider
const alternativeProviders = []
for (const p of providerResults) {
if (p.Provider.ID !== providerId) continue

const [protocolCode] = decodeVarint(decodeBase64(p.Metadata))
const protocol = {
0x900: 'bitswap',
Expand All @@ -45,22 +47,31 @@ export async function queryTheIndex(cid, providerId) {
const address = p.Provider.Addrs[0]
if (!address) continue

const provider = {
address: formatProviderAddress(p.Provider.ID, address, protocol),
contextId: p.ContextID,
protocol,
}

if (p.Provider.ID !== providerId) {
alternativeProviders.push(provider)
continue
}

switch (protocol) {
case 'http':
return {
indexerResult: 'OK',
provider: { address, protocol },
provider,
}

case 'graphsync':
if (!graphsyncProvider) {
graphsyncProvider = {
address: `${address}/p2p/${p.Provider.ID}`,
protocol,
}
graphsyncProvider = provider
}
}
}

if (graphsyncProvider) {
console.log('HTTP protocol is not advertised, falling back to Graphsync.')
return {
Expand All @@ -70,7 +81,7 @@ export async function queryTheIndex(cid, providerId) {
}

console.log('All advertisements are from other miners or for unsupported protocols.')
return { indexerResult: 'NO_VALID_ADVERTISEMENT' }
return { indexerResult: 'NO_VALID_ADVERTISEMENT', alternativeProviders }
}

async function getRetrievalProviders(cid) {
Expand All @@ -81,3 +92,7 @@ async function getRetrievalProviders(cid) {
const result = await res.json()
return result.MultihashResults.flatMap((r) => r.ProviderResults)
}

function formatProviderAddress(id, address, protocol) {
return protocol === 'http' ? address : `${address}/p2p/${id}`
}
144 changes: 131 additions & 13 deletions lib/spark.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* global Zinnia */

/** @import { Provider } from './ipni-client.js' */
import { ActivityState } from './activity-state.js'
import {
SPARK_VERSION,
Expand All @@ -18,6 +19,7 @@ import {
CarBlockIterator,
encodeHex,
HashMismatchError,
Prando,
UnsupportedHashError,
validateBlock,
} from '../vendor/deno-deps.js'
Expand All @@ -41,16 +43,17 @@ export default class Spark {

async getRetrieval() {
const retrieval = await this.#tasker.next()
if (retrieval) {
if (retrieval.retrievalTask) {
console.log({ retrieval })
}

return retrieval
}

async executeRetrievalCheck(retrieval, stats) {
console.log(`Calling Filecoin JSON-RPC to get PeerId of miner ${retrieval.minerId}`)
async executeRetrievalCheck({ retrievalTask, stats, randomness }) {
console.log(`Calling Filecoin JSON-RPC to get PeerId of miner ${retrievalTask.minerId}`)
try {
const peerId = await this.#getIndexProviderPeerId(retrieval.minerId)
const peerId = await this.#getIndexProviderPeerId(retrievalTask.minerId)
console.log(`Found peer id: ${peerId}`)
stats.providerId = peerId
} catch (err) {
Expand All @@ -70,19 +73,39 @@ export default class Spark {
throw err
}

console.log(`Querying IPNI to find retrieval providers for ${retrieval.cid}`)
const { indexerResult, provider } = await queryTheIndex(retrieval.cid, stats.providerId)
console.log(`Querying IPNI to find retrieval providers for ${retrievalTask.cid}`)
const { indexerResult, provider, alternativeProviders } = await queryTheIndex(
retrievalTask.cid,
stats.providerId,
)
stats.indexerResult = indexerResult

const providerFound = indexerResult === 'OK' || indexerResult === 'HTTP_NOT_ADVERTISED'
if (!providerFound) return
const noValidAdvertisement = indexerResult === 'NO_VALID_ADVERTISEMENT'

// In case index lookup failed we will not perform any retrieval
if (!providerFound && !noValidAdvertisement) return

// In case we fail to find a valid advertisement for the provider
// we will try to perform network wide retrieval from other providers
if (noValidAdvertisement) {
console.log(
'No valid advertisement found. Trying to retrieve from an alternative provider...',
)
stats.alternativeProviderCheck = await this.checkRetrievalFromAlternativeProvider({
alternativeProviders,
randomness,
cid: retrievalTask.cid,
})
return
}

stats.protocol = provider.protocol
stats.providerAddress = provider.address

await this.fetchCAR(provider.protocol, provider.address, retrieval.cid, stats)
await this.fetchCAR(provider.protocol, provider.address, retrievalTask.cid, stats)
if (stats.protocol === 'http') {
await this.testHeadRequest(provider.address, retrieval.cid, stats)
await this.testHeadRequest(provider.address, retrievalTask.cid, stats)
}
}

Expand Down Expand Up @@ -202,6 +225,31 @@ export default class Spark {
}
}

async checkRetrievalFromAlternativeProvider({ alternativeProviders, randomness, cid }) {
if (!alternativeProviders.length) {
console.info('No alternative providers found for this CID.')
return
}

const randomProvider = pickRandomProvider(alternativeProviders, randomness)
if (!randomProvider) {
console.warn(
'No providers serving the content via HTTP or Graphsync found. Skipping network-wide retrieval check.',
)
return
}

const alternativeProviderRetrievalStats = newAlternativeProviderCheckStats()
await this.fetchCAR(
randomProvider.protocol,
randomProvider.address,
cid,
alternativeProviderRetrievalStats,
)

return alternativeProviderRetrievalStats
}

async submitMeasurement(task, stats) {
console.log('Submitting measurement...')
const payload = {
Expand All @@ -228,17 +276,17 @@ export default class Spark {
}

async nextRetrieval() {
const retrieval = await this.getRetrieval()
if (!retrieval) {
const { retrievalTask, randomness } = await this.getRetrieval()
if (!retrievalTask) {
console.log('Completed all tasks for the current round. Waiting for the next round to start.')
return
}

const stats = newStats()

await this.executeRetrievalCheck(retrieval, stats)
await this.executeRetrievalCheck({ retrievalTask, randomness, stats })

const measurementId = await this.submitMeasurement(retrieval, { ...stats })
const measurementId = await this.submitMeasurement(retrievalTask, { ...stats })
Zinnia.jobCompleted()
return measurementId
}
Expand Down Expand Up @@ -315,6 +363,17 @@ export function newStats() {
carChecksum: null,
statusCode: null,
headStatusCode: null,
alternativeProviderCheck: null,
}
}

function newAlternativeProviderCheckStats() {
return {
statusCode: null,
timeout: false,
endAt: null,
carTooLarge: false,
providerId: null,
}
}

Expand Down Expand Up @@ -395,3 +454,62 @@ function mapErrorToStatusCode(err) {
// Fallback code for unknown errors
return 600
}

/**
* Picks a random provider based on their priority and supplied randomness.
*
* Providers are prioritized in the following order:
*
* 1. HTTP Providers with Piece Info advertised in their ContextID.
* 2. Graphsync Providers with Piece Info advertised in their ContextID.
* 3. HTTP Providers.
* 4. Graphsync Providers.
*
* @param {Provider[]} providers
* @param {number} randomness
* @returns {Provider | undefined}
*/
export function pickRandomProvider(providers, randomness) {
Copy link
Author

Choose a reason for hiding this comment

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

pickRandomProvider now picks random provider based on the priority rather then weight and generated pseudo-random number.

const rng = new Prando(randomness)

const filterByProtocol = (items, protocol) =>
items.filter((provider) => provider.protocol === protocol)

const pickRandomItem = (items) => {
if (!items.length) return undefined
return items[Math.floor(rng.next() * items.length)]
}

const providersWithPieceInfoContextID = providers.filter(
(p) => p.contextId.startsWith('ghsA') && p.protocol !== 'bitswap',
)

// Priority 1: HTTP providers with ContextID containing PieceCID
const httpProvidersWithPieceInfoContextID = filterByProtocol(
providersWithPieceInfoContextID,
'http',
)
if (httpProvidersWithPieceInfoContextID.length) {
return pickRandomItem(httpProvidersWithPieceInfoContextID, randomness)
}

// Priority 2: Graphsync providers with ContextID containing PieceCID
const graphsyncProvidersWithPieceInfoContextID = filterByProtocol(
providersWithPieceInfoContextID,
'graphsync',
)
if (graphsyncProvidersWithPieceInfoContextID.length) {
return pickRandomItem(graphsyncProvidersWithPieceInfoContextID, randomness)
}

// Priority 3: HTTP providers
const httpProviders = filterByProtocol(providers, 'http')
if (httpProviders.length) return pickRandomItem(httpProviders, randomness)

// Priority 4: Graphsync providers
const graphsyncProviders = filterByProtocol(providers, 'graphsync')
if (graphsyncProviders.length) return pickRandomItem(graphsyncProviders, randomness)

// No valid providers found
return undefined
}
12 changes: 7 additions & 5 deletions lib/tasker.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export class Tasker {
#remainingRoundTasks
#fetch
#activity
#randomness

/**
* @param {object} args
Expand All @@ -35,11 +36,12 @@ export class Tasker {
}

/**
* @returns {Task | undefined}
* @returns {{retrievalTask?: RetrievalTask; randomness: number; }}
*/
async next() {
await this.#updateCurrentRound()
return this.#remainingRoundTasks.pop()
const retrievalTask = this.#remainingRoundTasks.pop()
return { retrievalTask, randomness: this.#randomness }
Copy link
Author

Choose a reason for hiding this comment

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

We somehow need to export the round randomness so I have opted for returning object with randomness attribute from the next function.

Maybe adding the randomness property to the retrieval task wouldn't be a bad thing either.

}

async #updateCurrentRound() {
Expand Down Expand Up @@ -72,13 +74,13 @@ export class Tasker {
console.log(' %s retrieval tasks', retrievalTasks.length)
this.maxTasksPerRound = maxTasksPerNode

const randomness = await getRandomnessForSparkRound(round.startEpoch)
console.log(' randomness: %s', randomness)
this.#randomness = await getRandomnessForSparkRound(round.startEpoch)
console.log(' randomness: %s', this.#randomness)

this.#remainingRoundTasks = await pickTasksForNode({
tasks: retrievalTasks,
maxTasksPerRound: this.maxTasksPerRound,
randomness,
randomness: this.#randomness,
stationId: Zinnia.stationId,
})

Expand Down
12 changes: 6 additions & 6 deletions manual-check.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import Spark, { getRetrievalUrl } from './lib/spark.js'
import { getIndexProviderPeerId as defaultGetIndexProvider } from './lib/miner-info.js'

// The task to check, replace with your own values
const task = {
const retrievalTask = {
cid: 'bafkreih25dih6ug3xtj73vswccw423b56ilrwmnos4cbwhrceudopdp5sq',
minerId: 'f0frisbii',
}
Expand All @@ -19,8 +19,8 @@ const getIndexProviderPeerId = (minerId) =>

// Run the check
const spark = new Spark({ getIndexProviderPeerId })
const stats = { ...task, indexerResult: null, statusCode: null, byteLength: 0 }
await spark.executeRetrievalCheck(task, stats)
const stats = { ...retrievalTask, indexerResult: null, statusCode: null, byteLength: 0 }
await spark.executeRetrievalCheck({ retrievalTask, stats })
console.log('Measurement: %o', stats)

if (stats.providerAddress && stats.statusCode !== 200) {
Expand All @@ -31,15 +31,15 @@ if (stats.providerAddress && stats.statusCode !== 200) {
console.log(
' lassie fetch -o /dev/null -vv --dag-scope block --protocols graphsync --providers %s %s',
JSON.stringify(stats.providerAddress),
task.cid,
retrievalTask.cid,
)
console.log(
'\nHow to install Lassie: https://github.com/filecoin-project/lassie?tab=readme-ov-file#installation',
)
break
case 'http':
try {
const url = getRetrievalUrl(stats.protocol, stats.providerAddress, task.cid)
const url = getRetrievalUrl(stats.protocol, stats.providerAddress, retrievalTask.cid)
console.log('You can get more details by requesting the following URL yourself:\n')
console.log(' %s', url)
console.log('\nE.g. using `curl`:')
Expand All @@ -48,7 +48,7 @@ if (stats.providerAddress && stats.statusCode !== 200) {
console.log(
' lassie fetch -o /dev/null -vv --dag-scope block --protocols http --providers %s %s',
JSON.stringify(stats.providerAddress),
task.cid,
retrievalTask.cid,
)
console.log(
'\nHow to install Lassie: https://github.com/filecoin-project/lassie?tab=readme-ov-file#installation',
Expand Down
Loading
Loading