Skip to content
Closed
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
36 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
fe0f1f5
Merge remote-tracking branch 'origin/main' into add/network-wide-retr…
pyropy Apr 28, 2025
ad8a8e8
Lint fix
pyropy Apr 28, 2025
31019d0
Add ID to Provider
pyropy Apr 28, 2025
3710910
Filter out bitswap providers before picking random provider
pyropy Apr 28, 2025
c61a196
Update lib/ipni-client.js
pyropy Apr 29, 2025
d1f62fa
Update lib/spark.js
pyropy Apr 29, 2025
05bd1c2
Update lib/spark.js
pyropy Apr 29, 2025
3451eff
Rename random to alternative provider
pyropy Apr 29, 2025
8b8db36
Merge branch 'add/network-wide-retrieval-check' of github.com:Checker…
pyropy Apr 29, 2025
59d3d22
Simplify pseudo-rng
pyropy Apr 29, 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
Member 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) {
Copy link
Member

Choose a reason for hiding this comment

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

what is the motivation for this change?

Copy link
Member Author

Choose a reason for hiding this comment

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

Motivation behind that change was to supply randomness (used to pick tasks in the first place) alongside the task. Randomness could later on be supplied as a seed to a pseudo-RNG.

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
}
Copy link
Member

Choose a reason for hiding this comment

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

Can we prevent this case earlier, when we decide whether to run this function in the first place?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, It's possible to prevent it by filtering alternative providers by their protocol. If we only have alternative providers that are serving content via bitswap by filtering them out we can exit early.


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,
Copy link
Member

Choose a reason for hiding this comment

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

Shouldn't this also have byteLength, carChecksum and headStatusCode?

Copy link
Member

Choose a reason for hiding this comment

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

Or are we consciously omitting them? If so, could you please add a code comment?

Copy link
Member Author

Choose a reason for hiding this comment

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

I am not sure if we're supposed to have them; I think it wouldn't be a big deal to add those fields.

Copy link
Member

Choose a reason for hiding this comment

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

If we don't have them it means we could have a successful retrieval (using the alternative provider method) but not know the byte length, car checksum and head status code. @bajtos wdyt?

Copy link
Member

Choose a reason for hiding this comment

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

It depends on what do we want to use the alternative retrieval check measurement for.

As I understand it, we want to calculate network-wide RSR for retrievals that include alternative providers so that we can show this RSR on the leaderboard. I don't see how we need byteLength, carChecksum or headStatusCode for that.

I'd say YAGNI, exclude these fields for now, and wait until we need them.

}
}

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 advertisted in their ContextID.
* 2. Graphsync Providers with Piece Info advertisted 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
Member 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)]
Copy link
Member

Choose a reason for hiding this comment

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

IIUC, we are making exactly one rng.next() call per each randomness value. Using a pseudo-random generator for that feels like unnecessary complexity to me.

Can you treat the DRAND randomness as the random value instead?

Something along the following lines:

// Take the first 16 hex characters and parse them as an integer
const randomValue = BigInt("0x" + randomness.slice(16))
// 16 characters, each character represents one of 16 values
const max = 16n**16n
const ix = Number(BigInt(items.length) * randomValue / max)
return items[ix]

Copy link
Member

Choose a reason for hiding this comment

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

I overcomplicated the snippet above. I think the following should work:

const randomValue = BigInt("0x" + randomness)
const ix = Number(randomValue % BigInt(items.length))
return items[ix]

For example, when we have 10 items:

  • the random value is 523 => we pick the item at the index 3 (523 modulo 10 = 3).
  • the random value is 10 => we pick the first item (10 modulo 10 = 0).

Copy link
Member Author

Choose a reason for hiding this comment

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

Great suggestion, I like the simplicity of it.

At first I tried implementing my own psuedo-RNG but leaned towards using prando as it was somewhat popular implementation.

I agree that this is much simpler and does not come with overhead of prando.

}

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
Member 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