Skip to content

Commit 0933c5f

Browse files
beck-8SgtPookiBigLep
authored
feat!: confirm indexing via Curio piece-status (#689)
* feat: confirm indexing via Curio piece-status instead of filecoinpin.contact - poll Curio's per-provider piece-status endpoint until synced, then a single bounded cid.contact check to confirm the advertisement is actually indexed - treat a Curio-synced-but-cid.contact-disagrees case as a distinct mismatch, not a plain timeout - checkIpniIndexer's ipniIndexerUrl is now required (previously defaulted to filecoinpin.contact, which is being retired) * fix indexing confirmation gaps found in review * fix indexer failure classification and provider validation * fix(ipni): make retry waits abort-aware * fix(ipni): fail fast on pre-v1.28.6 piece status * test(ipni): drop alias identity assertion * refactor(ipni)!: drop waitForIpniProviderResults alias * refactor(ipni): carry mismatch details on the error * chore: lint:fix * Update documentation/content-routing-faq.md Co-authored-by: Steve Loeppky <biglep@filoz.org> * Update documentation/content-routing-faq.md Co-authored-by: Steve Loeppky <biglep@filoz.org> * Update documentation/behind-the-scenes-of-adding-a-file.md Co-authored-by: Steve Loeppky <biglep@filoz.org> * chore: drop orphaned deprecated JSDoc --------- Co-authored-by: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com> Co-authored-by: Steve Loeppky <biglep@filoz.org>
1 parent 3e3e7d7 commit 0933c5f

13 files changed

Lines changed: 1241 additions & 123 deletions

documentation/behind-the-scenes-of-adding-a-file.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,9 @@ This is a function of the CAR size and the throughput between the client and the
113113

114114
*What/why:*
115115

116-
At some point after receiving the uploaded [CAR](glossary.md#car), an SP indexing task processes the CAR and creates a local mapping of CIDs to offsets within the CAR so it can serve IPFS style retrievals. Following that, an SP [IPNI](glossary.md#ipni) tasks picks up the local index, makes and IPNI advertisement chain, and then announces the advertisement chain to IPNI indexers like filecoinpin.contact and cid.contact so they know to come and get the advertisement chain to build up their own index.
116+
At some point after receiving the uploaded [CAR](glossary.md#car), an SP indexing task processes the CAR and creates a local mapping of CIDs to offsets within the CAR so it can serve IPFS style retrievals. Following that, an SP [IPNI](glossary.md#ipni) tasks picks up the local index, makes and IPNI advertisement chain, and then announces the advertisement chain to IPNI indexers like cid.contact so they know to come and get the advertisement chain to build up their own index.
117117

118-
Filecoin Pin validates the IPNI advertisement process by polling `https://filecoinpin.contact/cid/$cid` (NOT cid.contact due to [negative caching issues discussed below](#how-long-does-an-ipni-indexer-cache-results)).
118+
Filecoin Pin validates the IPNI advertisement process in two steps: first it polls the SP's `GET /pdp/piece/{pieceCid}/status` endpoint until it reports `synced: true`. (Behind the scenes, the SP checks the IPNI instance's own sync-status endpoint on Filecoin Pin's behalf. This was added in [curio#1450](https://github.com/filecoin-project/curio/pull/1450)). Then, once synced, Filecoin Pin makes a single confirming query to `https://cid.contact/cid/$cid` to verify the expected provider actually shows up. Querying cid.contact directly before that confirmation is avoided due to [negative caching issues](content-routing-faq.md#how-long-does-an-ipni-indexer-cache-results).
119119

120120
*Outputs:*
121121

documentation/content-routing-faq.md

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,7 @@ In the event that an SP wipes their existing index state, the previously announc
2323

2424
## How long does an IPNI indexer cache results?
2525

26-
This depends on both the [IPNI](glossary.md#ipni) indexer instance (e.g., cid.contact, filecoinpin.contact) and whether there is a cache hit or cache miss.
26+
[cid.contact](http://cid.contact) tends to cache hits for multiple hours and cache misses (negative cache) for minutes. (cid.contact caching teraform is [here](https://github.com/ipni/storetheindex/blob/main/deploy/infrastructure/prod/us-east-2/cloudfront.tf).) As a result of this, there are "gotchas" we have to be careful to avoid or can unavoidably fall into.
2727

28-
[cid.contact](http://cid.contact) for example tends to cache hits for multiple hours and cache misses (negative cache) for minutes. As a result of this, there are "gotchas" we have to be careful to avoid or can unavoidably fall into.
29-
30-
- [cid.contact](http://cid.contact) cache miss "gotcha" - Because cid.contact caches misses (i.e., negative cache), it's important for Filecoin Pin to not poll cid.contact after an advertisement has been announced. The act of polling could cause the empty result set to get cached for minutes. Instead, Filecoin Pin polls [filecoinpin.contact](http://filecoinpin.contact) which doesn't have negative caching. Once Filecoin Pin sees the expected results from filecoinpin.contact it then proceeds to give IPFS Mainnet retrieval URLs since it should be safe to invoke a request path that hits cid.contact because cid.contact should now not get a non-empty result.
31-
- [cid.contact](http://cid.contact) cache hit "gotcha" - If cid.contact has a provider record(s) for CID X, but CID X is not currently from any of those provider(s), then cid.contact could be caching non-retrievable result for hours even though filecoinpin.contact has a provider that makes CID X retrievable. We currently don't have a workaround for this…
32-
33-
## Why is there filecoinpin.contact and cid.contact?
34-
35-
[filecoinpin.contact](http://filecoinpin.contact) serves two purposes currently:
36-
37-
1. Serve as a fallback in case [cid.contact](http://cid.contact) has issues keeping its global index updated. To help with availability, cid.contact has the ability to delegate requests to other [IPNI](glossary.md#ipni) indexers like [filecoinpin.contact](http://filecoinpin.contact) in case they have results.
38-
2. Validate IPNI announcing/advertising independently of [cid.contact](http://cid.contact). Per the "[cid.contact](http://cid.contact) cache miss gotcha" above, the act of polling cid.contact can actually delay how long it takes before cid.contact returns a non-empty result for a given CID. [filecoinpin.contact](http://filecoinpin.contact) has different caching configuration so that polling can be done safely.
28+
- cid.contact cache miss "gotcha" - Because cid.contact caches misses (i.e., negative cache), it's important for Filecoin Pin to not poll `GET /cid/{cid}` before the advertisement has actually been processed. The act of polling could cause the empty result set to get cached for minutes. Instead, Filecoin Pin first polls the storage provider's `GET /pdp/piece/{pieceCid}/status` until it reports `synced: true`. The SP checks the IPNI instance's own sync-status endpoint on Filecoin Pin's behalf, which isn't a CID lookup and isn't subject to the same negative caching, so it's safe for the SP to do that patiently. Only once the SP confirms sync does Filecoin Pin make a single confirming `GET /cid/{cid}` query, to confirm the expected provider's record actually shows up. If a provider never reports `synced: true`, Filecoin Pin reports failure without ever falling back to `GET /cid/{cid}`. The SP already did the patient, safe check on its end, so a further `/cid/{cid}` fallback would be redundant and risk the exact negative-cache problem this whole flow exists to avoid.
29+
- cid.contact cache hit "gotcha" - If cid.contact has a provider record(s) for CID X, but CID X is not currently retrievable from any of those provider(s), then cid.contact could be caching a non-retrievable result for hours. We currently don't have a workaround for this…

documentation/glossary.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ As a "trustless" protocol, retrieval of IPFS data using this mechanism provides
124124

125125
See https://docs.ipfs.tech/concepts/glossary/#ipni.
126126

127-
IPNI is the content routing system that [Filecoin Pin](#filecoin-pin) relies upon for retrieval to work for [standard IPFS tooling](#standard-ipfs-tooling). [Service Providers](#service-provider) announce their advertisement changes to IPNI indexer like [filecoinpin.contact](http://filecoinpin.contact) and cid.contact, and the advertised CIDs become discoverable for IPFS Standard tooling.
127+
IPNI is the content routing system that [Filecoin Pin](#filecoin-pin) relies upon for retrieval to work for [standard IPFS tooling](#standard-ipfs-tooling). [Service Providers](#service-provider) announce their advertisement changes to IPNI indexers like cid.contact, and the advertised CIDs become discoverable for IPFS Standard tooling.
128128

129129
## Metadata
130130

@@ -196,7 +196,7 @@ See [Retrieving Your Data](retrieval.md) for how to use each CID to fetch your c
196196

197197
[IPNI](#ipni) advertisements include a `ContextID` that encodes the [Piece CID](#piece-cid). You can use this to reverse-map an IPFS CID back to the Piece it lives in.
198198

199-
1. Look up the IPFS CID in an IPNI indexer, e.g. `https://cid.contact/cid/<ipfs-cid>` (or use [filecoinpin.contact](https://filecoinpin.contact) for data stored via Filecoin Pin).
199+
1. Look up the IPFS CID in an IPNI indexer, e.g. `https://cid.contact/cid/<ipfs-cid>`.
200200
2. Find the `ContextID` field in one of the provider records. It is base64-encoded.
201201
3. Decode the base64, drop the first byte (a version prefix), and treat the remaining bytes as a CID:
202202

src/common/upload-flow.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,22 @@ export async function performUpload(
594594
return `Checking for IPNI provider records (${overallPart}${cidPart})`
595595
}
596596

597+
function pieceSyncOpId(providerIndex: number): string {
598+
return `piece-sync-${providerIndex}`
599+
}
600+
601+
// Only shown when there's more than one provider — keeps the common single-provider
602+
// case identical to the original single-op wording.
603+
function providerPrefix(providerIndex: number, providerCount: number): string {
604+
return providerCount > 1 ? `[${providerIndex}/${providerCount}] ` : ''
605+
}
606+
607+
function discardPieceSyncOps(providerCount: number): void {
608+
for (let i = 1; i <= providerCount; i++) {
609+
flow.discardOperation(pieceSyncOpId(i))
610+
}
611+
}
612+
597613
const network = getNetworkSlug(synapse.chain)
598614

599615
const uploadResult = await executeUpload(synapse, carData, rootCid, {
@@ -683,6 +699,56 @@ export async function performUpload(
683699
break
684700
}
685701

702+
case 'pieceSyncStatus:retryUpdate': {
703+
const { serviceURL, providerIndex, providerCount, providerAttempt, providerMaxAttempts } = event.data
704+
const suffix = providerCount > 1 ? ` on ${serviceURL}` : ''
705+
flow.addOperation(
706+
pieceSyncOpId(providerIndex),
707+
`${providerPrefix(providerIndex, providerCount)}Waiting for advertisement to be indexed${suffix} (attempt ${providerAttempt}/${providerMaxAttempts})`
708+
)
709+
break
710+
}
711+
case 'pieceSyncStatus:providerSynced': {
712+
const { serviceURL, providerIndex, providerCount } = event.data
713+
// Single-provider case: leave the op open — pieceSyncStatus:complete closes it below.
714+
if (providerCount > 1) {
715+
flow.completeOperation(
716+
pieceSyncOpId(providerIndex),
717+
`${providerPrefix(providerIndex, providerCount)}Advertisement confirmed indexed on ${serviceURL}`,
718+
{ type: 'success' }
719+
)
720+
}
721+
break
722+
}
723+
case 'pieceSyncStatus:complete': {
724+
if (event.data.providerCount > 1) {
725+
discardPieceSyncOps(event.data.providerCount)
726+
flow.printSection(
727+
pc.green(`✓ Advertisement confirmed indexed on all ${event.data.providerCount} providers`),
728+
[]
729+
)
730+
} else {
731+
flow.completeOperation(pieceSyncOpId(1), 'Advertisement confirmed indexed', { type: 'success' })
732+
}
733+
break
734+
}
735+
case 'pieceSyncStatus:failed': {
736+
if (event.data.providerCount > 1) {
737+
discardPieceSyncOps(event.data.providerCount)
738+
flow.printSection(pc.yellow('⚠ Advertisement not confirmed indexed in time.'), [
739+
pc.gray(event.data.error.message),
740+
])
741+
} else {
742+
flow.completeOperation(pieceSyncOpId(1), 'Advertisement not confirmed indexed in time.', {
743+
type: 'warning',
744+
details: {
745+
title: 'Reason',
746+
content: [pc.gray(event.data.error.message)],
747+
},
748+
})
749+
}
750+
break
751+
}
686752
case 'ipniProviderResults:retryUpdate': {
687753
const attempt = event.data.attempt ?? (event.data.retryCount === 0 ? 1 : event.data.retryCount + 1)
688754
flow.addOperation(
@@ -718,6 +784,16 @@ export async function performUpload(
718784
})
719785
break
720786
}
787+
case 'indexingConfirmation:mismatch': {
788+
// The underlying ipniProviderResults:failed is suppressed for this call, so
789+
// 'ipni' never gets completed on its own — discard it before reporting here.
790+
flow.discardOperation('ipni')
791+
flow.printSection(
792+
pc.yellow('⚠ Storage provider reported sync, but a direct indexer lookup still disagrees'),
793+
[pc.gray(event.data.error.message)]
794+
)
795+
break
796+
}
721797
default: {
722798
break
723799
}

src/core/upload/index.ts

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { FilecoinChain, PDPProvider, Synapse } from '@filoz/synapse-sdk'
1+
import type { FilecoinChain, PDPProvider, PieceCID, Synapse } from '@filoz/synapse-sdk'
22
import { calibration, mainnet } from '@filoz/synapse-sdk'
33
import type { StorageContext } from '@filoz/synapse-sdk/storage'
44
import type { CID } from 'multiformats/cid'
@@ -19,9 +19,9 @@ import { isSessionKeyMode } from '../synapse/index.js'
1919
import { recordUploadResult } from '../telemetry/index.js'
2020
import type { ProgressEvent, ProgressEventHandler } from '../utils/types.js'
2121
import {
22-
type ValidateIPNIProgressEvents,
23-
type WaitForIpniProviderResultsOptions,
24-
waitForIpniProviderResults,
22+
type IndexingConfirmationProgressEvents,
23+
type WaitForIndexingConfirmationOptions,
24+
waitForIndexingConfirmation,
2525
} from '../utils/validate-ipni-advertisement.js'
2626
import {
2727
type SynapseUploadData,
@@ -232,7 +232,7 @@ export interface UploadExecutionOptions {
232232
/** Optional identifier to help correlate logs. */
233233
contextId?: string
234234
/** Optional umbrella onProgress receiving child progress events. */
235-
onProgress?: ProgressEventHandler<(UploadProgressEvents | ValidateIPNIProgressEvents) & {}>
235+
onProgress?: ProgressEventHandler<(UploadProgressEvents | IndexingConfirmationProgressEvents) & {}>
236236
/** Optional metadata to associate with the upload (per-piece). */
237237
pieceMetadata?: Record<string, string>
238238
/**
@@ -250,7 +250,7 @@ export interface UploadExecutionOptions {
250250
* @default: true
251251
*/
252252
enabled?: boolean
253-
} & Omit<WaitForIpniProviderResultsOptions, 'onProgress'>
253+
} & Omit<WaitForIndexingConfirmationOptions, 'onProgress'>
254254

255255
/** Number of storage copies to create (default determined by SDK). */
256256
copies?: number
@@ -306,7 +306,8 @@ export interface UploadExecutionResult extends SynapseUploadResult {
306306
/** Active network derived from the Synapse instance. */
307307
network: string
308308
/**
309-
* True if the IPFS Root CID was observed on filecoinpin.contact (IPNI).
309+
* True if the IPNI indexer confirmed the piece's advertisement(s) as indexed and
310+
* the IPFS Root CID was confirmed present on the indexer.
310311
*
311312
* You should block any displaying, or attempting to access, of IPFS
312313
* download URLs unless the IPNI validation is successful.
@@ -349,27 +350,37 @@ export async function executeUpload(
349350
)
350351
}
351352

352-
// Collect providers from `providerSelected` events for IPNI validation
353-
const selectedProviders: PDPProvider[] = []
353+
// pieceCid is identical across providers for the same upload, so any `stored` event works.
354+
// contexts carry their provider directly — no providerSelected event fires for them.
355+
const selectedProviders: PDPProvider[] = options.contexts?.map((context) => context.provider) ?? []
356+
let pieceCidForValidation: PieceCID | undefined
354357
let ipniValidationPromise: Promise<boolean> | undefined
355358

356-
const emitProgress: ProgressEventHandler<UploadProgressEvents | ValidateIPNIProgressEvents> = (event) => {
359+
const emitProgress: ProgressEventHandler<UploadProgressEvents | IndexingConfirmationProgressEvents> = (event) => {
357360
switch (event.type) {
358361
case 'providerSelected': {
359362
selectedProviders.push(event.data.provider)
360363
break
361364
}
365+
case 'stored': {
366+
pieceCidForValidation ??= event.data.pieceCid
367+
break
368+
}
362369
case 'piecesAdded': {
363-
// Begin IPNI validation on the first piecesAdded event
370+
// Begin indexing confirmation on the first piecesAdded event
364371
if (options.ipniValidation?.enabled !== false && ipniValidationPromise == null) {
372+
if (pieceCidForValidation == null) {
373+
logger.warn('piecesAdded fired before stored; skipping indexing confirmation (no pieceCid to poll)')
374+
break
375+
}
365376
const {
366377
enabled: _enabled,
367378
expectedProviders,
368379
signal: ipniSignal,
369380
...restOptions
370381
} = options.ipniValidation ?? {}
371382

372-
const validationOptions: WaitForIpniProviderResultsOptions = {
383+
const validationOptions: WaitForIndexingConfirmationOptions = {
373384
...restOptions,
374385
logger,
375386
signal: ipniSignal ?? options.signal,
@@ -379,18 +390,20 @@ export async function executeUpload(
379390
validationOptions.onProgress = options.onProgress
380391
}
381392

382-
// Use providers collected from selection events for IPNI validation
393+
// Use providers collected from selection events for indexing confirmation
383394
if (expectedProviders != null) {
384395
validationOptions.expectedProviders = expectedProviders
385396
} else if (selectedProviders.length > 0) {
386397
validationOptions.expectedProviders = selectedProviders
387398
}
388399

389-
ipniValidationPromise = waitForIpniProviderResults(rootCid, validationOptions).catch((error) => {
390-
validationOptions.signal?.throwIfAborted()
391-
logger.warn({ error }, 'IPNI provider results check was rejected')
392-
return false
393-
})
400+
ipniValidationPromise = waitForIndexingConfirmation(rootCid, pieceCidForValidation, validationOptions).catch(
401+
(error) => {
402+
validationOptions.signal?.throwIfAborted()
403+
logger.warn({ error }, 'Indexing confirmation check was rejected')
404+
return false
405+
}
406+
)
394407
}
395408
break
396409
}
@@ -447,7 +460,7 @@ export async function executeUpload(
447460
ipniValidated = await ipniValidationPromise
448461
} catch (error) {
449462
options.signal?.throwIfAborted()
450-
logger.error({ error }, 'Could not validate IPNI provider records')
463+
logger.error({ error }, 'Could not confirm indexing')
451464
ipniValidated = false
452465
}
453466
}

0 commit comments

Comments
 (0)