Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c86e00b
Squashed branch 'create-contexts' (https://github.com/FilOzone/synaps…
wjmelements Oct 29, 2025
1b75a25
mockServiceProviderRegistry: getProvidersByProductType
wjmelements Oct 30, 2025
53e4374
update tests: create data set is now delayed until first add piece
wjmelements Oct 30, 2025
20d92b9
Merge remote-tracking branch 'origin/master' into wjmelements/create-…
wjmelements Oct 30, 2025
7411bd0
use smartSelectProvider for the remaining providers
wjmelements Oct 30, 2025
178073d
add broken test
wjmelements Oct 30, 2025
55c324d
fix the test by converting bigint to number
wjmelements Oct 30, 2025
9511a10
Revert "fix the test by converting bigint to number"
wjmelements Oct 30, 2025
6afaa65
fix the test by fixing the types
wjmelements Oct 30, 2025
21bb003
it can attempt to create numerous contexts, returning fewer
wjmelements Oct 30, 2025
c821d6f
min doc and check Error type
wjmelements Oct 30, 2025
4163bd6
add failing test
wjmelements Oct 30, 2025
44f0488
dedupe with Set
wjmelements Oct 30, 2025
cd2e364
add failing test
wjmelements Oct 30, 2025
ca3b3f3
fix test with excludeDataSetIds
wjmelements Oct 30, 2025
4d20f3a
doc & test createContext won't return multiple contexts for the same …
wjmelements Oct 30, 2025
b0e4b03
document the priority of the createContext options
wjmelements Oct 30, 2025
ecdc24e
cleanup createDataSet mocking no longer needed
wjmelements Oct 30, 2025
b6e9064
skipProviderIds sooner
wjmelements Oct 30, 2025
06eb886
skipProviderIds even sooner
wjmelements Oct 30, 2025
fe71415
test default params
wjmelements Oct 30, 2025
2adac93
rm providerAddresses
wjmelements Nov 3, 2025
a4f631a
Merge remote-tracking branch 'origin/master' into wjmelements/create-…
wjmelements Nov 3, 2025
95a69ce
fix imports
wjmelements Nov 3, 2025
6515fb0
Merge remote-tracking branch 'origin/master' into wjmelements/create-…
wjmelements Nov 3, 2025
21aa5e2
async selection where possible
wjmelements Nov 3, 2025
85299bf
only check each provider once
wjmelements Nov 3, 2025
2e65e15
nonnull these args earlier
wjmelements Nov 3, 2025
e36fca4
filter by dev and ipni before selectProviderWithPing async generator
wjmelements Nov 3, 2025
fb23990
rewrite doc for synapse.storage.createContexts
wjmelements Nov 4, 2025
877f4ed
Update packages/synapse-sdk/src/storage/context.ts
wjmelements Nov 4, 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
185 changes: 140 additions & 45 deletions packages/synapse-sdk/src/storage/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { SPRegistryService } from '../sp-registry/index.ts'
import type { ProviderInfo } from '../sp-registry/types.ts'
import type { Synapse } from '../synapse.ts'
import type {
CreateContextsOptions,
DownloadOptions,
EnhancedDataSetInfo,
MetadataEntry,
Expand All @@ -58,6 +59,8 @@ import {
import { combineMetadata, metadataMatches, objectToEntries, validatePieceMetadata } from '../utils/metadata.ts'
import type { WarmStorageService } from '../warm-storage/index.ts'

const NO_REMAINING_PROVIDERS_ERROR_MESSAGE = 'No approved service providers available'

export class StorageContext {
private readonly _synapse: Synapse
private readonly _provider: ProviderInfo
Expand Down Expand Up @@ -175,6 +178,95 @@ export class StorageContext {
this._pdpServer = new PDPServer(authHelper, provider.products.PDP.data.serviceURL)
}

/**
* Creates new storage contexts with specified options
* Each context corresponds to a different data set
*/
static async createContexts(
synapse: Synapse,
warmStorageService: WarmStorageService,
options: CreateContextsOptions
): Promise<StorageContext[]> {
const count = options?.count ?? 2
const resolutions: ProviderSelectionResult[] = []
const clientAddress = await synapse.getClient().getAddress()
const registryAddress = warmStorageService.getServiceProviderRegistryAddress()
const spRegistry = new SPRegistryService(synapse.getProvider(), registryAddress)
if (options.dataSetIds) {
const selections = []
for (const dataSetId of new Set(options.dataSetIds)) {
selections.push(
StorageContext.resolveByDataSetId(dataSetId, warmStorageService, spRegistry, clientAddress, {
withCDN: options.withCDN,
withIpni: options.withIpni,
dev: options.dev,
metadata: options.metadata,
})
)
if (selections.length >= count) {
break
}
}
resolutions.push(...(await Promise.all(selections)))
}
const resolvedProviderIds = resolutions.map((resolution) => resolution.provider.id)
if (resolutions.length < count) {
if (options.providerIds) {
const selections = []
// NOTE: Set.difference is unavailable in some targets
for (const providerId of [...new Set(options.providerIds)].filter(
(providerId) => !resolvedProviderIds.includes(providerId)
)) {
selections.push(
StorageContext.resolveByProviderId(
clientAddress,
providerId,
options.metadata ?? {},
warmStorageService,
spRegistry,
options.forceCreateDataSets
)
)
resolvedProviderIds.push(providerId)
if (selections.length + resolutions.length >= count) {
break
}
}
resolutions.push(...(await Promise.all(selections)))
}
}
if (resolutions.length < count) {
const excludeProviderIds = [...(options.excludeProviderIds ?? []), ...resolvedProviderIds]
for (let i = resolutions.length; i < count; i++) {
try {
const resolution = await StorageContext.smartSelectProvider(
clientAddress,
options.metadata ?? {},
warmStorageService,
spRegistry,
excludeProviderIds,
options.forceCreateDataSets ?? false,
options.withIpni ?? false,
options.dev ?? false
)
excludeProviderIds.push(resolution.provider.id)
resolutions.push(resolution)
} catch (error) {
if (error instanceof Error && error.message.includes(NO_REMAINING_PROVIDERS_ERROR_MESSAGE)) {
break
}
throw error
}
}
}
return await Promise.all(
resolutions.map(
async (resolution) =>
await StorageContext.createWithSelectedProvider(resolution, synapse, warmStorageService, options)
)
)
}

/**
* Static factory method to create a StorageContext
* Handles provider selection and data set selection/creation
Expand All @@ -191,6 +283,15 @@ export class StorageContext {
// Resolve provider and data set based on options
const resolution = await StorageContext.resolveProviderAndDataSet(synapse, warmStorageService, spRegistry, options)

return await StorageContext.createWithSelectedProvider(resolution, synapse, warmStorageService, options)
}

private static async createWithSelectedProvider(
resolution: ProviderSelectionResult,
synapse: Synapse,
warmStorageService: WarmStorageService,
options: StorageServiceOptions = {}
): Promise<StorageContext> {
// Notify callback about provider selection
try {
options.callbacks?.onProviderSelected?.(resolution.provider)
Expand Down Expand Up @@ -227,8 +328,7 @@ export class StorageContext {
spRegistry: SPRegistryService,
options: StorageServiceOptions
): Promise<ProviderSelectionResult> {
const client = synapse.getClient()
const clientAddress = await client.getAddress()
const clientAddress = await synapse.getClient().getAddress()

// Handle explicit data set ID selection (highest priority)
if (options.dataSetId != null && options.forceCreateDataSet !== true) {
Expand Down Expand Up @@ -274,10 +374,10 @@ export class StorageContext {
requestedMetadata,
warmStorageService,
spRegistry,
options.excludeProviderIds,
options.forceCreateDataSet,
options.withIpni,
options.dev
options.excludeProviderIds ?? [],
options.forceCreateDataSet ?? false,
options.withIpni ?? false,
options.dev ?? false
)
}

Expand Down Expand Up @@ -491,10 +591,10 @@ export class StorageContext {
requestedMetadata: Record<string, string>,
warmStorageService: WarmStorageService,
spRegistry: SPRegistryService,
excludeProviderIds?: number[],
forceCreateDataSet?: boolean,
withIpni?: boolean,
dev?: boolean
excludeProviderIds: number[],
forceCreateDataSet: boolean,
withIpni: boolean,
dev: boolean
): Promise<ProviderSelectionResult> {
// Strategy:
// 1. Try to find existing data sets first
Expand All @@ -503,9 +603,15 @@ export class StorageContext {
// Get client's data sets
const dataSets = await warmStorageService.getClientDataSetsWithDetails(signerAddress)

const skipProviderIds = new Set<number>(excludeProviderIds)
// Filter for managed data sets with matching metadata
const managedDataSets = dataSets.filter(
(ps) => ps.isLive && ps.isManaged && ps.pdpEndEpoch === 0 && metadataMatches(ps.metadata, requestedMetadata)
(ps) =>
ps.isLive &&
ps.isManaged &&
ps.pdpEndEpoch === 0 &&
metadataMatches(ps.metadata, requestedMetadata) &&
!skipProviderIds.has(ps.providerId)
)

if (managedDataSets.length > 0 && !forceCreateDataSet) {
Expand All @@ -518,10 +624,12 @@ export class StorageContext {

// Create async generator that yields providers lazily
async function* generateProviders(): AsyncGenerator<ProviderInfo> {
const skipProviderIds = new Set<number>(excludeProviderIds)

// 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) {
Expand All @@ -530,18 +638,21 @@ export class StorageContext {
)
continue
}
if (!skipProviderIds.has(provider.id)) {
skipProviderIds.add(provider.id)
yield provider

if (withIpni && provider.products.PDP?.data.ipniIpfs === false) {
continue
}

if (!dev && provider.products.PDP?.capabilities?.dev != null) {
continue
}

yield provider
}
}

try {
const selectedProvider = await StorageContext.selectProviderWithPing(generateProviders(), {
dev,
withIpni,
})
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)
Expand Down Expand Up @@ -575,15 +686,18 @@ export class StorageContext {
const approvedIds = await warmStorageService.getApprovedProviderIds()
const approvedProviders = await spRegistry.getProviders(approvedIds)
const allProviders = approvedProviders.filter(
(provider: ProviderInfo) => excludeProviderIds?.includes(provider.id) !== true
(provider: ProviderInfo) =>
(!withIpni || provider.products.PDP?.data.ipniIpfs === true) &&
(dev || provider.products.PDP?.capabilities?.dev == null) &&
!excludeProviderIds.includes(provider.id)
)

if (allProviders.length === 0) {
throw createError('StorageContext', 'smartSelectProvider', 'No approved service providers available')
throw createError('StorageContext', 'smartSelectProvider', NO_REMAINING_PROVIDERS_ERROR_MESSAGE)
}

// Random selection from all providers
const provider = await StorageContext.selectRandomProvider(allProviders, withIpni, dev)
const provider = await StorageContext.selectRandomProvider(allProviders)

return {
provider,
Expand All @@ -600,11 +714,7 @@ export class StorageContext {
* @param dev - Include dev providers
* @returns Selected provider
*/
private static async selectRandomProvider(
providers: ProviderInfo[],
withIpni?: boolean,
dev?: boolean
): Promise<ProviderInfo> {
private static async selectRandomProvider(providers: ProviderInfo[]): Promise<ProviderInfo> {
if (providers.length === 0) {
throw createError('StorageContext', 'selectRandomProvider', 'No providers available')
}
Expand All @@ -620,10 +730,7 @@ export class StorageContext {
}
}

return await StorageContext.selectProviderWithPing(generateRandomProviders(), {
withIpni,
dev,
})
return await StorageContext.selectProviderWithPing(generateRandomProviders())
}

/**
Expand All @@ -633,23 +740,11 @@ export class StorageContext {
* @returns The first provider that responds
* @throws If all providers fail
*/
private static async selectProviderWithPing(
providers: AsyncIterable<ProviderInfo>,
options?: { withIpni?: boolean; dev?: boolean }
): Promise<ProviderInfo> {
private static async selectProviderWithPing(providers: AsyncIterable<ProviderInfo>): Promise<ProviderInfo> {
let providerCount = 0
const { withIpni, dev } = options ?? {}

// Try providers in order until we find one that responds to ping
for await (const provider of providers) {
if (withIpni && provider?.products.PDP?.data.ipniIpfs === false) {
continue
}

if (!dev && provider?.products.PDP?.capabilities?.dev != null) {
continue
}

providerCount++
try {
// Create a temporary PDPServer for this specific provider's endpoint
Expand Down
35 changes: 35 additions & 0 deletions packages/synapse-sdk/src/storage/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { asPieceCID, downloadAndValidate } from '../piece/index.ts'
import { SPRegistryService } from '../sp-registry/index.ts'
import type { Synapse } from '../synapse.ts'
import type {
CreateContextsOptions,
DownloadOptions,
EnhancedDataSetInfo,
PieceCID,
Expand Down Expand Up @@ -230,6 +231,40 @@ export class StorageManager {
return await StorageContext.performPreflightCheck(this._warmStorageService, this._synapse.payments, size, withCDN)
}

/**
* Creates storage contexts for multi-provider storage deals and other operations.
*
* By storing data with multiple independent providers, you reduce dependency on any
* single provider and improve overall data availability. Use contexts together as a group.
*
* Contexts are selected by priority:
* 1. Specified datasets (`dataSetIds`) - uses their existing providers
* 2. Specified providers (`providerIds` or `providerAddresses`) - finds or creates matching datasets
* 3. Automatically selected from remaining approved providers
*
* For automatic selection, existing datasets matching the `metadata` are reused unless
* `forceCreateDataSets` is true. Providers are randomly chosen to distribute across the network.
*
* @param synapse - Synapse instance
* @param warmStorageService - Warm storage service instance
* @param options - Configuration options
* @param options.count - Maximum number of contexts to create (default: 2)
* @param options.dataSetIds - Specific dataset IDs to include
* @param options.providerIds - Specific provider IDs to use
* @param options.metadata - Metadata to match when finding/creating datasets
* @param options.forceCreateDataSets - Always create new datasets instead of reusing existing ones
* @param options.excludeProviderIds - Provider IDs to skip during selection
* @returns Promise resolving to array of storage contexts
*/
async createContexts(options?: CreateContextsOptions): Promise<StorageContext[]> {
return await StorageContext.createContexts(this._synapse, this._warmStorageService, {
...options,
withCDN: options?.withCDN ?? this._withCDN,
withIpni: options?.withIpni ?? this._withIpni,
dev: options?.dev ?? this._dev,
})
}

/**
* Create a new storage context with specified options
*/
Expand Down
Loading