filecoin-pin
Version:
Bridge IPFS content to Filecoin Onchain Cloud using familiar tools
299 lines (266 loc) • 9.37 kB
text/typescript
import type { Synapse } from '@filoz/synapse-sdk'
import PQueue from 'p-queue'
import type { Logger } from 'pino'
import { createStorageContextFromDataSetId } from '../synapse/storage-context-helper.js'
import type { ProgressEvent, ProgressEventHandler, Warning } from '../utils/types.js'
import { getDataSetPieces } from './get-data-set-pieces.js'
import type { DataSetSummary } from './types.js'
export interface ActualStorageResult {
/** Total storage in bytes across all active data sets */
totalBytes: bigint
/** Number of active data sets found */
dataSetCount: number
/** Number of data sets successfully processed */
dataSetsProcessed: number
/** Total number of pieces across all data sets */
pieceCount: number
/** Whether the calculation timed out */
timedOut?: boolean
/** Non-fatal warnings encountered during calculation */
warnings: Warning[]
}
export type ActualStorageProgressEvents = ProgressEvent<
'actual-storage:progress',
{ dataSetsProcessed: number; dataSetCount: number; pieceCount: number; totalBytes: bigint }
>
/**
* Get a unique Provider-scoped key for a data set
* @param dataSet - The data set to get the key for
* @returns The unique Provider-scoped key for the data set
*/
const getProviderKey = ({ providerId, serviceProvider, dataSetId }: DataSetSummary): string | number => {
if (providerId !== undefined) {
return providerId
}
if (serviceProvider) {
return serviceProvider
}
return `unknown-${dataSetId}`
}
/**
* Calculate actual storage from all active data sets for an address
*
* This function queries all active/live data sets and sums up the actual piece sizes.
* It's more accurate than deriving storage from billing rates, but can be slow for
* users with many pieces.
*
* The calculation respects abort signals - if aborted, it will return partial results
* with a timedOut flag set to true.
*
* Example usage:
* ```typescript
* const result = await calculateActualStorage(synapse, {
* address: '0x1234...',
* signal: AbortSignal.timeout(30000), // 30 second timeout
* logger: myLogger
* })
*
* console.log(`Total storage: ${result.totalBytes} bytes`)
* console.log(`Across ${result.dataSetsProcessed}/${result.dataSetCount} data sets`)
* console.log(`Total pieces: ${result.pieceCount}`)
*
* if (result.timedOut) {
* console.warn('Calculation was aborted, results may be incomplete')
* }
*
* if (result.warnings.length > 0) {
* console.warn('Encountered warnings:', result.warnings)
* }
* ```
*
* @param synapse - Initialized Synapse instance
* @param options - Configuration options
* @returns Actual storage calculation result
*/
export async function calculateActualStorage(
synapse: Synapse,
dataSets: DataSetSummary[],
options?: {
/** Address to calculate storage for (defaults to synapse client address) */
address?: string
/** Abort signal for cancellation/timeout (optional) */
signal?: AbortSignal
/** Logger for debugging (optional) */
logger?: Logger
/** Max number of providers to query in parallel (defaults to 10) */
maxParallelProviders?: number
/** Max concurrent datasets per provider (defaults to 10) */
maxParallelPerProvider?: number
/** Progress callback for UI updates */
onProgress?: ProgressEventHandler<ActualStorageProgressEvents>
}
): Promise<ActualStorageResult> {
const logger = options?.logger
const address = options?.address ?? (await synapse.getClient().getAddress())
const signal = options?.signal
const maxParallelProviders = Math.max(1, options?.maxParallelProviders ?? 10)
const maxParallelPerProvider = Math.max(1, options?.maxParallelPerProvider ?? 10)
const onProgress = options?.onProgress
const warnings: Warning[] = []
let totalBytes = 0n
let pieceCount = 0
let dataSetsProcessed = 0
let dataSetCount = 0
// Process data sets with provider-scoped concurrency (one at a time per provider)
const globalQueue = new PQueue({ concurrency: maxParallelProviders })
const providerQueues = new Map<string | number, PQueue>()
if (signal) {
signal.addEventListener(
'abort',
() => {
logger?.warn({ reason: signal.reason }, 'Abort signal received during storage calculation')
globalQueue.clear()
providerQueues.forEach((queue) => {
queue.clear()
})
},
{ once: true }
)
}
try {
dataSetCount = dataSets.length
if (dataSetCount === 0) {
return {
totalBytes: 0n,
dataSetCount,
dataSetsProcessed: 0,
pieceCount: 0,
warnings,
}
}
logger?.info({ dataSetCount: dataSets.length, address }, 'Calculating actual storage across data sets')
const processDataSet = async (dataSet: (typeof dataSets)[number]): Promise<void> => {
signal?.throwIfAborted()
try {
const { storage: storageContext } = await createStorageContextFromDataSetId(synapse, dataSet.dataSetId)
signal?.throwIfAborted()
const getPiecesOptions: { logger?: Logger; signal?: AbortSignal } = {}
if (logger) {
getPiecesOptions.logger = logger
}
if (signal) {
getPiecesOptions.signal = signal
}
const result = await getDataSetPieces(synapse, storageContext, getPiecesOptions)
if (result.totalSizeBytes) {
totalBytes += result.totalSizeBytes
}
pieceCount += result.pieces.length
dataSetsProcessed++
if (result.warnings && result.warnings.length > 0) {
warnings.push(...result.warnings)
}
onProgress?.({
type: 'actual-storage:progress',
data: {
dataSetsProcessed,
dataSetCount,
pieceCount,
totalBytes,
},
})
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
logger?.warn('Piece retrieval aborted')
throw error // Re-throw AbortError to propagate cancellation
}
const errorMessage = error instanceof Error ? error.message : String(error)
logger?.warn({ dataSetId: dataSet.dataSetId, error: errorMessage }, 'Failed to get pieces for data set')
warnings.push({
code: 'DATA_SET_QUERY_FAILED',
message: `Failed to query pieces for data set ${dataSet.dataSetId}`,
context: {
dataSetId: dataSet.dataSetId,
error: errorMessage,
},
})
}
}
const scheduledPromises = dataSets.map((dataSet) => {
const providerKey = getProviderKey(dataSet)
let providerQueue = providerQueues.get(providerKey)
if (!providerQueue) {
providerQueue = new PQueue({ concurrency: maxParallelPerProvider })
providerQueues.set(providerKey, providerQueue)
}
const jobOptions: { signal?: AbortSignal } = signal ? { signal } : {}
return globalQueue.add(() => providerQueue.add(() => processDataSet(dataSet), jobOptions), jobOptions)
})
await (signal
? Promise.race([Promise.allSettled(scheduledPromises), waitForAbort(signal)])
: Promise.allSettled(scheduledPromises))
// Derive timedOut from signal state
const timedOut = signal?.aborted ?? false
if (timedOut) {
logger?.warn({ dataSetsProcessed, totalDataSets: dataSets.length }, 'Calculation aborted')
warnings.push({
code: 'CALCULATION_ABORTED',
message: `Calculation aborted after processing ${dataSetsProcessed}/${dataSetCount} data sets`,
context: {
dataSetsProcessed,
totalDataSets: dataSetCount,
},
})
}
logger?.info(
{
totalBytes: totalBytes.toString(),
dataSetCount,
dataSetsProcessed,
pieceCount,
timedOut,
},
'Actual storage calculation complete'
)
return {
totalBytes,
dataSetCount,
dataSetsProcessed,
pieceCount,
timedOut,
warnings,
}
} catch (error) {
// Check if this was an abort
const isAborted = signal?.aborted || (error instanceof Error && error.name === 'AbortError')
if (isAborted) {
logger?.warn({ error }, 'Storage calculation aborted; returning partial results')
if (!warnings.some((w) => w.code === 'CALCULATION_ABORTED')) {
warnings.push({
code: 'CALCULATION_ABORTED',
message: `Calculation aborted after processing ${dataSetsProcessed}/${dataSetCount} data sets`,
context: {
dataSetsProcessed,
totalDataSets: dataSetCount,
},
})
}
return {
totalBytes,
dataSetCount,
dataSetsProcessed,
pieceCount,
timedOut: true,
warnings,
}
}
const errorMessage = error instanceof Error ? error.message : String(error)
logger?.error({ error: errorMessage }, 'Failed to calculate actual storage')
throw new Error(`Failed to calculate actual storage: ${errorMessage}`)
}
}
function waitForAbort(signal: AbortSignal): Promise<'aborted'> {
return new Promise((resolve) => {
if (signal.aborted) {
resolve('aborted')
return
}
signal.addEventListener(
'abort',
() => {
resolve('aborted')
},
{ once: true }
)
})
}