Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,43 @@ The result contains:
- **`copies`** - array of successful copies, each with `providerId`, `dataSetId`, `pieceId`, `role` (`'primary'` or `'secondary'`), `retrievalUrl`, and `isNewDataSet`
- **`failedAttempts`** - providers that were tried but did not produce a copy. The SDK retries failed secondaries with alternate providers, so a non-empty array often just means a provider was swapped out. These are diagnostic, check `complete` for the actual outcome.

### Uploading Multiple Files

Piece batching is enabled by default. Compatible uploads that run concurrently can share an on-chain transaction when they use the same provider and data set. Each provider maintains its own batch.

Start the uploads together to give them an opportunity to join the same batch:

```ts
const results = await Promise.all(
files.map((file) => synapse.storage.upload(file))
)
```

Sequential uploads cannot share a batch because each call waits for its own on-chain confirmation before the next call starts:

```ts
for (const file of files) {
await synapse.storage.upload(file)
}
```

By default, the SDK submits a batch after a zero-delay window. You can instead hold compatible pieces until the transaction size limit is reached or you explicitly flush them:

```ts
const synapse = Synapse.create({
account: privateKeyToAccount("0x..."),
pieceBatching: { wait: { kind: "limiter" } },
})

const uploads = files.map((file) => synapse.storage.upload(file))
await synapse.storage.flush()
const results = await Promise.all(uploads)
```

`flush()` waits for accepted uploads and pulls to finish parking, then submits their pending batch windows. It does not report whether every upload was submitted or confirmed successfully; always await the individual upload promises for results and errors.

Set `pieceBatching: false` when creating `Synapse` to disable batching. Use the [split operations](#split-operations) when you need manual control over provider selection, signing, or each store, pull, and commit phase.

### Upload with Metadata

Attach metadata to organize uploads. The SDK reuses existing data sets when metadata matches, avoiding duplicate payment rails:
Expand Down
97 changes: 76 additions & 21 deletions packages/synapse-core/src/sp/create-piece-batcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,18 @@ import { findPiece } from './find-piece.ts'
import { waitForPullPieces } from './pull-pieces.ts'
import { type UploadPieceStreamingData, uploadPieceStreaming } from './upload-streaming.ts'

export type EnqueuePiece = LimiterPiece
export type EnqueuePiece = LimiterPiece & {
/** Raw byte size when the piece was parked by `upload()`. */
size?: number
}

export type FlushResult = {
txHash: Hex
confirmedTxHash?: Hex
statusUrl: string
dataSetId: bigint
clientDataSetId: bigint
isNewDataSet: boolean
pieces: EnqueuePiece[]
}

Expand All @@ -37,6 +44,10 @@ export type PieceResult = FlushResult & {
batchIndex: number
}

export type UploadResult = PieceResult & {
size: number
}

/**
* When to flush a tumbling addPieces window (limiter overflow, `flush()`, and
* `close()` always flush regardless).
Expand All @@ -56,7 +67,7 @@ export type PieceBatcherWait = { kind: 'delay'; ms: number } | { kind: 'limiter'
export type OnParked = (piece: EnqueuePiece) => void | Promise<void>

export type UploadInput = {
data: File | Uint8Array | ReadableStream<Uint8Array>
data: File | UploadPieceStreamingData
/** Known length for a stream (`File` uses `.size`). */
size?: number
metadata?: MetadataObject
Expand All @@ -71,6 +82,8 @@ export type PullInput = {
sourceUrl: string
metadata?: MetadataObject
onParked?: OnParked
onStatus?: (response: waitForPullPieces.ReturnType) => void
signal?: AbortSignal
}

type Slot = {
Expand All @@ -81,7 +94,7 @@ type Slot = {

export type PieceBatcher = {
/** Stream onto this SP, then join the addPieces window. */
upload: (input: UploadInput) => Promise<PieceResult>
upload: (input: UploadInput) => Promise<UploadResult>
/** Pull onto this SP (own extraData), then join the addPieces window. */
pull: (input: PullInput) => Promise<PieceResult>
/** Already on this SP. Join the addPieces window only. */
Expand Down Expand Up @@ -236,19 +249,36 @@ export function createPieceBatcher(
const pieces = batch.map((slot) => slot.piece)

try {
const submitted =
dataSet == null
? await flushCreate(pieces)
: await addPieces(client, {
serviceURL: serviceURL(),
dataSetId: dataSet.dataSetId,
clientDataSetId: dataSet.clientDataSetId,
pieces,
})
const isNewDataSet = dataSet == null
let submitted: {
txHash: Hex
confirmedTxHash?: Hex
statusUrl: string
dataSetId: bigint
clientDataSetId: bigint
}
if (dataSet == null) {
submitted = await flushCreate(pieces)
} else {
submitted = {
...(await addPieces(client, {
serviceURL: serviceURL(),
dataSetId: dataSet.dataSetId,
clientDataSetId: dataSet.clientDataSetId,
pieces,
})),
dataSetId: dataSet.dataSetId,
clientDataSetId: dataSet.clientDataSetId,
}
}

const result: FlushResult = {
txHash: submitted.txHash,
...(submitted.confirmedTxHash == null ? {} : { confirmedTxHash: submitted.confirmedTxHash }),
statusUrl: submitted.statusUrl,
dataSetId: submitted.dataSetId,
clientDataSetId: submitted.clientDataSetId,
isNewDataSet,
pieces,
}
for (const [batchIndex, slot] of batch.entries()) {
Expand All @@ -275,7 +305,13 @@ export function createPieceBatcher(
}
}

async function flushCreate(pieces: EnqueuePiece[]): Promise<{ txHash: Hex; statusUrl: string }> {
async function flushCreate(pieces: EnqueuePiece[]): Promise<{
txHash: Hex
confirmedTxHash?: Hex
statusUrl: string
dataSetId: bigint
clientDataSetId: bigint
}> {
if (payee == null) {
throw new ValidationError('`payee` is required when dataSet is undefined.')
}
Expand All @@ -294,7 +330,16 @@ export function createPieceBatcher(
throw new DataSetNotFoundError(created.dataSetId)
}
dataSet = resolved
return submitted
return {
txHash: submitted.txHash,
...(created.confirmedTxHash == null ? {} : { confirmedTxHash: created.confirmedTxHash }),
statusUrl: new URL(
`/pdp/data-sets/${created.dataSetId}/pieces/added/${submitted.txHash}`,
serviceURL()
).toString(),
dataSetId: created.dataSetId,
clientDataSetId: createClientDataSetId,
}
}

function cancelTimer(): void {
Expand Down Expand Up @@ -368,7 +413,7 @@ export function createPieceBatcher(
return internalEnqueue(piece)
}

async function upload(input: UploadInput): Promise<PieceResult> {
async function upload(input: UploadInput): Promise<UploadResult> {
let data: UploadPieceStreamingData
let size = input.size
if (isUint8Array(input.data)) {
Expand All @@ -380,7 +425,8 @@ export function createPieceBatcher(
} else {
data = input.data
}
return parkAndEnqueue(async () => {
let uploadedSize: number | undefined
const result = await parkAndEnqueue(async () => {
if (input.pieceCid != null) {
assertPieceCidSize(input.pieceCid)
}
Expand All @@ -392,14 +438,19 @@ export function createPieceBatcher(
onProgress: input.onProgress,
signal: input.signal,
})
uploadedSize = uploaded.size
await findPiece({
serviceURL: serviceURL(),
pieceCid: uploaded.pieceCid,
poll: true,
signal: input.signal,
})
return { pieceCid: uploaded.pieceCid, metadata: input.metadata }
return { pieceCid: uploaded.pieceCid, metadata: input.metadata, size: uploaded.size }
}, input.onParked)
if (uploadedSize == null) {
throw new ValidationError('Piece upload completed without a size.')
}
return { ...result, size: uploadedSize }
}

async function pull(input: PullInput): Promise<PieceResult> {
Expand Down Expand Up @@ -430,13 +481,17 @@ export function createPieceBatcher(
payer,
cdn,
metadata: datasetMetadata,
signal: input.signal,
onStatus: input.onStatus,
})
: await waitForPullPieces(client, {
serviceURL: serviceURL(),
pieces: [pullPiece],
extraData,
dataSetId: dataSet.dataSetId,
clientDataSetId: dataSet.clientDataSetId,
signal: input.signal,
onStatus: input.onStatus,
})
if (pullResult.status === 'failed') {
throw new PullError('Pull failed.')
Expand All @@ -453,15 +508,15 @@ export function createPieceBatcher(
}

async function flush(): Promise<FlushResult | undefined> {
await Promise.allSettled([...inFlight])
await Promise.resolve()
await scheduledIdle
return lock(() => flushWindowInternal())
}

async function close(): Promise<void> {
closed = true
await Promise.allSettled([...inFlight])
await Promise.resolve()
await scheduledIdle
await lock(() => flushWindowInternal())
await flush()
}

return {
Expand Down
72 changes: 72 additions & 0 deletions packages/synapse-sdk/src/storage/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
} from '@filoz/synapse-core/utils'
import {
fetchProviderSelectionInput,
getPdpDataSet,
metadataMatches,
type ResolvedLocation,
selectProviders,
Expand Down Expand Up @@ -80,6 +81,7 @@ import type {
import { createError, SIZE_CONSTANTS } from '../utils/index.ts'
import { combineMetadata } from '../utils/metadata.ts'
import type { WarmStorageService } from '../warm-storage/index.ts'
import { type BatchedUploadResult, getPieceBatchingService } from './piece-batching.ts'
import { terminateServiceFlow } from './terminate.ts'

const NO_REMAINING_PROVIDERS_ERROR_MESSAGE = 'No approved service providers available'
Expand Down Expand Up @@ -144,6 +146,25 @@ export class StorageContext {
return this._dataSetId
}

/** @internal Synchronize state after a shared piece batcher creates or resolves a data set. */
syncBatcherDataSet(dataSetId: bigint, clientDataSetId: bigint): void {
this._dataSetId = dataSetId
this._clientDataSetId = clientDataSetId
}

/** @internal Resolve the minimal data-set state required by a piece batcher. */
async getBatcherDataSet(): Promise<SP.PieceBatcher['dataSet']> {
if (this._dataSetId == null) {
return undefined
}
const dataSet = await getPdpDataSet(this._readClient, { dataSetId: this._dataSetId })
if (dataSet == null) {
throw createError('StorageContext', 'getBatcherDataSet', 'Data set not found')
}
this._clientDataSetId = dataSet.clientDataSetId
return dataSet
}

private assertPiecesFitMessage(pieces: Array<{ pieceCid: PieceCID; metadata?: MetadataObject }>): void {
SP.assertAddPiecesFit(
this._dataSetId
Expand Down Expand Up @@ -1021,6 +1042,57 @@ export class StorageContext {
* @returns Upload result with pieceCid, size, and a single-element copies array
*/
async upload(data: UploadPieceStreamingData, options?: UploadOptions): Promise<UploadResult> {
const batching = getPieceBatchingService(this._synapse)
if (batching != null) {
let parked = false
const task = batching.upload(this, {
data,
pieceCid: options?.pieceCid,
metadata: options?.pieceMetadata,
signal: options?.signal,
onProgress: options?.onProgress,
onParked: (piece) => {
parked = true
options?.onStored?.(this._provider.id, piece.pieceCid)
},
onSubmitted: (submitted) =>
options?.onPiecesAdded?.(submitted.txHash, this._provider.id, [{ pieceCid: submitted.pieceCid }]),
})

let result: BatchedUploadResult
try {
result = await task.committed
} catch (error) {
throw createError(
'StorageContext',
parked ? 'commit' : 'store',
parked ? 'Failed to commit pieces on-chain' : 'Failed to store piece on service provider',
error
)
}

options?.onPiecesConfirmed?.(result.dataSetId, this._provider.id, [
{ pieceId: result.pieceId, pieceCid: result.pieceCid },
])
return {
pieceCid: result.pieceCid,
size: result.size,
requestedCopies: 1,
complete: true,
copies: [
{
providerId: this._provider.id,
dataSetId: result.dataSetId,
pieceId: result.pieceId,
role: 'primary',
retrievalUrl: this.getPieceUrl(result.pieceCid),
isNewDataSet: result.isNewDataSet,
},
],
failedAttempts: [],
}
}

// Store phase
const storeResult = await this.store(data, {
pieceCid: options?.pieceCid,
Expand Down
Loading