Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 0 additions & 15 deletions packages/synapse-core/src/utils/rand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,3 @@ export function randU256(): bigint {
export function fallbackRandIndex(length: number): number {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't delete fallbackRandIndex because it is still used by fallbackRandU256.

return Math.floor(Math.random() * length)
}

/**
* Provides a random index into an array of supplied length (0 <= index < length)
* @param length - exclusive upper boundary
* @returns a valid index
*/
export function randIndex(length: number): number {
if (crypto?.getRandomValues != null) {
const randomBytes = new Uint32Array(1)
crypto.getRandomValues(randomBytes)
return randomBytes[0] % length
} else {
return fallbackRandIndex(length)
}
}
161 changes: 63 additions & 98 deletions packages/synapse-sdk/src/storage/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,15 @@
*/

import * as SP from '@filoz/synapse-core/sp'
import { randIndex, randU256 } from '@filoz/synapse-core/utils'
import { randU256 } from '@filoz/synapse-core/utils'
import type { ethers } from 'ethers'
import type { Hex } from 'viem'
import type { PaymentsService } from '../payments/index.ts'
import { PDPAuthHelper, PDPServer } from '../pdp/index.ts'
import { PDPVerifier } from '../pdp/verifier.ts'
import { asPieceCID } from '../piece/index.ts'
import { SPRegistryService } from '../sp-registry/index.ts'
import type { ProviderInfo } from '../sp-registry/types.ts'
import type { ProviderInfo, ServiceProduct } from '../sp-registry/types.ts'
import type { Synapse } from '../synapse.ts'
import type {
CreateContextsOptions,
Expand Down Expand Up @@ -605,7 +605,7 @@ export class StorageContext {

const skipProviderIds = new Set<number>(excludeProviderIds)
// Filter for managed data sets with matching metadata
const managedDataSets = dataSets.filter(
const managedDataSets: EnhancedDataSetInfo[] = dataSets.filter(
(ps) =>
ps.isLive &&
ps.isManaged &&
Expand All @@ -615,48 +615,38 @@ export class StorageContext {
)

if (managedDataSets.length > 0 && !forceCreateDataSet) {
// Prefer data sets with pieces, sort by ID (older first)
const sorted = managedDataSets.sort((a, b) => {
if (a.currentPieceCount > 0 && b.currentPieceCount === 0) return -1
if (b.currentPieceCount > 0 && a.currentPieceCount === 0) return 1
return a.pdpVerifierDataSetId - b.pdpVerifierDataSetId
Copy link
Contributor Author

Choose a reason for hiding this comment

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

But they're not quite preserved, currently you'll always be pulled back to data sets with the most pieces in it

No, the current tie breaker is dataset ID

})

// Create async generator that yields providers lazily
async function* generateProviders(): AsyncGenerator<ProviderInfo> {
// First, yield providers from existing data sets (in sorted order)
for (const dataSet of sorted) {
if (skipProviderIds.has(dataSet.providerId)) {
continue
}
skipProviderIds.add(dataSet.providerId)
const provider = await spRegistry.getProvider(dataSet.providerId)

if (provider == null) {
console.warn(
`Provider ID ${dataSet.providerId} for data set ${dataSet.pdpVerifierDataSetId} is not currently approved`
)
continue
}
// Prefer data sets with pieces
const [hasNoPieces, hasPieces] = managedDataSets
.reduce<[Set<EnhancedDataSetInfo>, Set<EnhancedDataSetInfo>]>(
(results: [Set<EnhancedDataSetInfo>, Set<EnhancedDataSetInfo>], managedDataSet: EnhancedDataSetInfo) => {
results[managedDataSet.currentPieceCount > 0 ? 1 : 0].add(managedDataSet)
return results
},
[new Set(), new Set()]
)
.map((deduped) => [...deduped])
Copy link
Collaborator

Choose a reason for hiding this comment

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

does createContexts land us with duplicates at this point now? why are we worried about deduping with Sets?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

These are the client data sets, and while we don't expect there to be multiple data sets with the same provider, there could be many, and we want to dedupe because the subsequent code does assume the providerId are unique. It will also be important to dedupe when changing this to a method that returns multiple providers; otherwise it might pick the same provider multiple times.

We currently dedupe these in the iterative code with the skipProviderIds.


if (withIpni && provider.products.PDP?.data.ipniIpfs === false) {
continue
}
for (const managedDataSets of [hasPieces, hasNoPieces]) {
const providers: ProviderInfo[] = (
await Promise.all(
managedDataSets.map((dataSet: EnhancedDataSetInfo) => spRegistry.getProvider(dataSet.providerId))
Comment on lines +629 to +632
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
for (const managedDataSets of [hasPieces, hasNoPieces]) {
const providers: ProviderInfo[] = (
await Promise.all(
managedDataSets.map((dataSet: EnhancedDataSetInfo) => spRegistry.getProvider(dataSet.providerId))
for (const dataSets of [hasPieces, hasNoPieces]) {
const providers: ProviderInfo[] = (
await Promise.all(
dataSets.map((dataSet: EnhancedDataSetInfo) => spRegistry.getProvider(dataSet.providerId))

shadowing managedDataSets here makes it confusing

Copy link
Contributor Author

Choose a reason for hiding this comment

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

dataSets is also already used in this function

)
).filter<ProviderInfo>(
(provider: ProviderInfo | null): provider is ProviderInfo =>
provider !== null &&
(!withIpni || provider.products.PDP?.data.ipniIpfs !== false) &&
(dev || provider.products.PDP?.capabilities?.dev == null)
Copy link
Collaborator

Choose a reason for hiding this comment

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

this will conflict with #376, let's pull that one in first (@rjan90) and make sure we account for it here

)

if (!dev && provider.products.PDP?.capabilities?.dev != null) {
continue
}
const selectedProvider = await StorageContext.selectProviderWithPing(providers)

yield provider
if (selectedProvider == null) {
continue
}
}

try {
const selectedProvider = await StorageContext.selectProviderWithPing(generateProviders())

// Find the first matching data set ID for this provider
// Match by provider ID (stable identifier in the registry)
const matchingDataSet = sorted.find((ps) => ps.providerId === selectedProvider.id)
const matchingDataSet = managedDataSets.find((ps: EnhancedDataSetInfo) => ps.providerId === selectedProvider.id)

if (matchingDataSet == null) {
console.warn(
Expand All @@ -675,10 +665,8 @@ export class StorageContext {
dataSetMetadata,
}
}
} catch (_error) {
console.warn('All providers from existing data sets failed health check. Falling back to all providers.')
// Fall through to select from all approved providers below
}
console.warn('All providers from existing data sets failed health check. Falling back to all providers.')
}

// No existing data sets - select from all approved providers. First we get approved IDs from
Expand All @@ -696,8 +684,16 @@ export class StorageContext {
throw createError('StorageContext', 'smartSelectProvider', NO_REMAINING_PROVIDERS_ERROR_MESSAGE)
}

// Random selection from all providers
const provider = await StorageContext.selectRandomProvider(allProviders)
// Select from all providers
const provider = await StorageContext.selectProviderWithPing(allProviders)

if (provider == null) {
throw createError(
'StorageContext',
'selectProviderWithPing',
`All ${allProviders.length} providers failed health check. Storage may be temporarily unavailable.`
)
}

return {
provider,
Expand All @@ -708,72 +704,41 @@ export class StorageContext {
}

/**
* Select a random provider from a list with ping validation
* @param providers - Array of providers to select from
* @param withIpni - Filter for IPNI support
* @param dev - Include dev providers
* @returns Selected provider
* Select a provider with ping validation.
* @param providers - providers to try
* @returns The first provider that responds, or null if none do
*/
private static async selectRandomProvider(providers: ProviderInfo[]): Promise<ProviderInfo> {
if (providers.length === 0) {
throw createError('StorageContext', 'selectRandomProvider', 'No providers available')
}

// Create async generator that yields providers in random order
async function* generateRandomProviders(): AsyncGenerator<ProviderInfo> {
const remaining = [...providers]

while (remaining.length > 0) {
// Remove and yield the selected provider
const selected = remaining.splice(randIndex(remaining.length), 1)[0]
yield selected
private static async selectProviderWithPing(providers: ProviderInfo[]): Promise<ProviderInfo | null> {
type ProviderWithPDP = ProviderInfo & {
products: {
PDP: ServiceProduct
}
}

return await StorageContext.selectProviderWithPing(generateRandomProviders())
}

/**
* Select a provider from an async iterator with ping validation.
* This is shared logic used by both smart selection and random selection.
* @param providers - Async iterable of providers to try
* @returns The first provider that responds
* @throws If all providers fail
*/
private static async selectProviderWithPing(providers: AsyncIterable<ProviderInfo>): Promise<ProviderInfo> {
let providerCount = 0

// Try providers in order until we find one that responds to ping
for await (const provider of providers) {
providerCount++
function hasPDP(provider: ProviderInfo): provider is ProviderWithPDP {
return provider.products.PDP != null
}
// Ping all providers
const pings = providers.filter(hasPDP).map((provider, index) =>
new PDPServer(null, provider.products.PDP.data.serviceURL).ping().then(
() => Promise.resolve(provider),
(error) => Promise.reject({ error, index, provider })
)
)
Comment on lines +722 to +727
Copy link
Collaborator

Choose a reason for hiding this comment

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

this construct feels unnecessarily complex, can't we just const pdpProviders = providers.filter(hasPDP) then map them into a plain ping() promise, then you should be able to use the index of the promise that you use to tell you which provider it is and avoid the complexity of this then nested promise

Copy link
Contributor Author

Choose a reason for hiding this comment

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

then does simplify this code. The only difference from making pdpProviders a local would be the ability to recalculate the provider from the index. Both resolve and reject need the provider though, so the code is simpler if you nest it like this.

let remaining = pings.length
while (remaining-- > 0) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I like to write this like while (remaining --> 0) but lint disagrees

try {
// Create a temporary PDPServer for this specific provider's endpoint
if (!provider.products.PDP?.data.serviceURL) {
// Skip providers without PDP products
continue
}
const providerPdpServer = new PDPServer(null, provider.products.PDP.data.serviceURL)
await providerPdpServer.ping()
return provider
} catch (error) {
return await Promise.race(pings)
Copy link
Collaborator

Choose a reason for hiding this comment

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

This whole loop could just be replaced with a Promise.any I think; the problem you're battling is that race will return the first settled promise regardless of whether it's a resolve or reject, Promise.any returns the first resolved promise or it rejects if they all reject. There's an example of this in packages/synapse-sdk/src/retriever/utils.ts.

const { response, index: winnerIndex } = await Promise.any(providerAttempts)

then use index to pick out of your original list, and you don't need that custom then block.

Also, see in the retriever code how AbortController is used, we should be doing the same thing down in to ping() so we can abort everything else once we get one succeeding. Although, in the retrieval case we care about not aborting the winning promise, in this case we can abort everything because the winning promise has properly completed (i.e. in that case the controller is passed to the fetch Response which we don't want to abort, but here we complete the response before the promise resolves). So we could just one AbortController for this whole thing.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Promise.any would be good. Would have to move the failure logging into the .then() reject block, but could eliminate index and remaining.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Moving the logging into the reject block would actually be noisy if we abort though.

Copy link
Collaborator

Choose a reason for hiding this comment

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

The rule is something like: Promise.race is almost never what you want.

} catch (err: any) {
const { error, index, provider } = err
console.warn(
`Provider ${provider.serviceProvider} failed ping test:`,
error instanceof Error ? error.message : String(error)
)
// Continue to next provider
pings[index] = new Promise(() => undefined)
}
}

// All providers failed ping test
if (providerCount === 0) {
throw createError('StorageContext', 'selectProviderWithPing', 'No providers available to select from')
}

throw createError(
'StorageContext',
'selectProviderWithPing',
`All ${providerCount} providers failed health check. Storage may be temporarily unavailable.`
)
return null
}

/**
Expand Down
85 changes: 41 additions & 44 deletions packages/synapse-sdk/src/test/rand.test.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,49 @@
/* globals describe it */

import { fallbackRandIndex, fallbackRandU256, randIndex, randU256 } from '@filoz/synapse-core/utils'
import { fallbackRandIndex, fallbackRandU256, randU256 } from '@filoz/synapse-core/utils'
import { assert } from 'chai'

const randIndexMethods = [randIndex, fallbackRandIndex]
randIndexMethods.forEach((randIndexMethod) => {
describe(randIndexMethod.name, () => {
it('should return 0 for length 1', () => {
for (let i = 0; i < 32; i++) {
assert.equal(0, randIndexMethod(1))
}
})
it('returns both 0 and 1 for length 2', () => {
const counts = [0, 0]
for (let i = 0; i < 32; i++) {
counts[randIndexMethod(counts.length)]++
}
// this test can fail probabilistically but the probability is low
// each bit should be independent with 50% likelihood
// the probability of getting the same index N times is 2**(1-N)
// so if this test fails, the 50% assumption is likely wrong
assert.isAtLeast(counts[0], 1)
assert.isAtLeast(counts[1], 1)
})
it('has at least 10 random bits', () => {
const counts = []
for (let i = 0; i < 10; i++) {
counts.push([0, 0])
}
for (let i = 0; i < 32; i++) {
let index = randIndexMethod(1024)
assert.isAtLeast(index, 0)
assert.isAtMost(index, 1023)
for (let j = 0; j < 10; j++) {
counts[j][index & 1]++
index >>= 1
}
assert.equal(index, 0)
}
// this test can fail probabilistically but the probability is low
// each bit should be independent with 50% likelihood
// the probability of getting the same bitvalue N times is 2**(1-N)
// so if this test fails, the 50% assumption is likely wrong
for (let i = 0; i < 10; i++) {
assert.isAtLeast(counts[i][0], 1)
assert.isAtLeast(counts[i][1], 1)
describe('fallbackRandIndex', () => {
it('should return 0 for length 1', () => {
for (let i = 0; i < 32; i++) {
assert.equal(0, fallbackRandIndex(1))
}
})
it('returns both 0 and 1 for length 2', () => {
const counts = [0, 0]
for (let i = 0; i < 32; i++) {
counts[fallbackRandIndex(counts.length)]++
}
// this test can fail probabilistically but the probability is low
// each bit should be independent with 50% likelihood
// the probability of getting the same index N times is 2**(1-N)
// so if this test fails, the 50% assumption is likely wrong
assert.isAtLeast(counts[0], 1)
assert.isAtLeast(counts[1], 1)
})
it('has at least 10 random bits', () => {
const counts = []
for (let i = 0; i < 10; i++) {
counts.push([0, 0])
}
for (let i = 0; i < 32; i++) {
let index = fallbackRandIndex(1024)
assert.isAtLeast(index, 0)
assert.isAtMost(index, 1023)
for (let j = 0; j < 10; j++) {
counts[j][index & 1]++
index >>= 1
}
})
assert.equal(index, 0)
}
// this test can fail probabilistically but the probability is low
// each bit should be independent with 50% likelihood
// the probability of getting the same bitvalue N times is 2**(1-N)
// so if this test fails, the 50% assumption is likely wrong
for (let i = 0; i < 10; i++) {
assert.isAtLeast(counts[i][0], 1)
assert.isAtLeast(counts[i][1], 1)
}
})
})

Expand Down
13 changes: 4 additions & 9 deletions packages/synapse-sdk/src/test/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2392,7 +2392,7 @@ describe('StorageService', () => {
})

describe('Provider Ping Validation', () => {
describe('selectRandomProvider with ping validation', () => {
describe('selectProviderWithPing', () => {
it('should select first provider that responds to ping', async () => {
const testProviders: ProviderInfo[] = [
createSimpleProvider({
Expand Down Expand Up @@ -2428,7 +2428,7 @@ describe('StorageService', () => {
}

try {
const result = await (StorageContext as any).selectRandomProvider(testProviders)
const result = await (StorageContext as any).selectProviderWithPing(testProviders)

// Should have selected the second provider (first one failed ping)
assert.equal(result.serviceProvider, testProviders[1].serviceProvider)
Expand All @@ -2438,8 +2438,6 @@ describe('StorageService', () => {
}
})

// Test removed: selectRandomProvider no longer supports exclusion functionality

it('should throw error when all providers fail ping', async () => {
const testProviders: ProviderInfo[] = [
createSimpleProvider({
Expand All @@ -2463,11 +2461,8 @@ describe('StorageService', () => {
}

try {
await (StorageContext as any).selectRandomProvider(testProviders)
assert.fail('Should have thrown error')
} catch (error: any) {
assert.include(error.message, 'StorageContext selectProviderWithPing failed')
assert.include(error.message, 'All 2 providers failed health check')
const provider = await (StorageContext as any).selectProviderWithPing(testProviders)
assert.isNull(provider)
} finally {
global.fetch = originalFetch
}
Expand Down