@@ -10,6 +10,7 @@ import { fromSecp256k1 } from '@filoz/synapse-core/session-key'
1010import { addPieces , createDataSet , waitForAddPieces , waitForCreateDataSet } from '@filoz/synapse-core/sp'
1111import { getDataSet } from '@filoz/synapse-core/warm-storage'
1212import { execa } from 'execa'
13+ import pMap from 'p-map'
1314import { createPublicClient , http } from 'viem'
1415import { getAddress } from 'viem/utils'
1516import { z } from 'zod'
@@ -20,6 +21,7 @@ import { openTrackingDb } from '../lib/tracking-db.mjs'
2021
2122const PARKING_BATCH_SIZE = 50
2223const COMMIT_BATCH_SIZE = 20
24+ const PARKING_RECOVERY_BATCH_SIZE = 1_000
2325const DEFAULT_COMMIT_CONCURRENCY = 4
2426const EMPTY_POLL_INTERVAL_MS = 1_000
2527
@@ -167,31 +169,57 @@ const parkingResultSchema = z
167169 message : 'count must match pieces length' ,
168170 } )
169171
172+ /**
173+ * Curio may emit log lines before the final JSON result.
174+ *
175+ * @param {string } stdout
176+ */
177+ function extractParkingJson ( stdout ) {
178+ const trimmed = stdout . trim ( )
179+ if ( ! trimmed ) {
180+ throw new Error ( 'commit: parking command returned empty stdout' )
181+ }
182+
183+ const objectStart = trimmed . lastIndexOf ( '\n{' )
184+ const start = objectStart >= 0 ? objectStart + 1 : trimmed . indexOf ( '{' )
185+ if ( start < 0 ) {
186+ throw new Error ( 'commit: parking command returned no JSON result in stdout' )
187+ }
188+
189+ return trimmed . slice ( start )
190+ }
191+
170192/**
171193 * @param {string } dir
172194 * @param {string } target
173195 * @returns {Promise<ParkingResult> }
174196 */
175197async function runParkingBinary ( dir , target ) {
176- const result = await execa ( {
177- env : {
178- LANG : 'en_US.UTF-8' ,
179- } ,
180- } ) ( 'curio' , [
181- 'toolbox' ,
182- 'import-pieces' ,
183- '--source' ,
184- shardsDir ( dir ) ,
185- '--target' ,
186- target ,
187- '--batch-size' ,
188- String ( PARKING_BATCH_SIZE ) ,
189- ] )
198+ let result
199+ try {
200+ result = await execa ( {
201+ env : {
202+ LANG : 'en_US.UTF-8' ,
203+ GOLOG_LOG_LEVEL : 'error' ,
204+ } ,
205+ } ) ( 'curio' , [
206+ 'toolbox' ,
207+ 'import-pieces' ,
208+ '--source' ,
209+ shardsDir ( dir ) ,
210+ '--target' ,
211+ target ,
212+ '--batch-size' ,
213+ String ( PARKING_BATCH_SIZE ) ,
214+ ] )
215+ } catch ( err ) {
216+ const message = err ?. stderr || err ?. stdout || err ?. message || String ( err )
217+ throw new Error ( `commit: parking command failed: ${ message } ` )
218+ }
190219
191220 let parsed
192221 try {
193- console . log ( `Parking binary output: ${ result . stdout } ` )
194- parsed = JSON . parse ( result . stdout || '{}' )
222+ parsed = JSON . parse ( extractParkingJson ( result . stdout || '' ) )
195223 } catch ( err ) {
196224 throw new Error ( `commit: parking command returned invalid JSON: ${ err ?. message || err } ` )
197225 }
@@ -209,6 +237,63 @@ function buildCommitPieces(rows) {
209237 } ) )
210238}
211239
240+ /**
241+ * @param {string } serviceUrl
242+ * @param {string } pieceCid
243+ */
244+ async function isPieceAvailable ( serviceUrl , pieceCid ) {
245+ const baseUrl = serviceUrl . endsWith ( '/' ) ? serviceUrl : `${ serviceUrl } /`
246+ const url = new URL ( `piece/${ pieceCid } ` , baseUrl )
247+ const response = await fetch ( url , { method : 'HEAD' } )
248+
249+ if ( response . status === 200 ) return true
250+ if ( response . status === 404 ) return false
251+ if ( ! response . ok ) {
252+ throw new Error ( `commit: parking recovery HEAD ${ url . toString ( ) } returned ${ response . status } ` )
253+ }
254+
255+ return true
256+ }
257+
258+ /**
259+ * Last-resort recovery for the crash window where Curio already parked files
260+ * but the DB was not updated before the process failed.
261+ *
262+ * @param {object } args
263+ * @param {TrackingDb } args.tracking
264+ * @param {string } args.serviceUrl
265+ * @param {number } args.concurrency
266+ */
267+ async function recoverParkedPieces ( { tracking, serviceUrl, concurrency } ) {
268+ let afterPieceCid = ''
269+ let recoveredCount = 0
270+
271+ while ( true ) {
272+ const pendingPieceCids = tracking . listPendingCommitPieceCids ( PARKING_RECOVERY_BATCH_SIZE , afterPieceCid )
273+ if ( pendingPieceCids . length === 0 ) return recoveredCount
274+
275+ const recovered = await pMap (
276+ pendingPieceCids ,
277+ async ( pieceCid ) => {
278+ try {
279+ return ( await isPieceAvailable ( serviceUrl , pieceCid ) ) ? pieceCid : null
280+ } catch ( err ) {
281+ const message = `parking recovery probe failed: ${ err ?. message || err } `
282+ console . error ( `commit: ${ message } for piece ${ pieceCid } ` )
283+ tracking . markPendingCommitPieceError ( pieceCid , message )
284+ return null
285+ }
286+ } ,
287+ { concurrency } ,
288+ )
289+
290+ const recoveredPieceCids = recovered . filter ( ( pieceCid ) => pieceCid != null )
291+ tracking . markParkedByPieceCids ( recoveredPieceCids )
292+ recoveredCount += recoveredPieceCids . length
293+ afterPieceCid = pendingPieceCids [ pendingPieceCids . length - 1 ]
294+ }
295+ }
296+
212297/**
213298 * @param {object } args
214299 * @param {ReturnType<typeof fromSecp256k1> } args.sessionKey
@@ -291,7 +376,18 @@ export async function runCommit({
291376 try {
292377 while ( true ) {
293378 const parkingResult = await runParkingBinary ( dir , target )
294- if ( parkingResult . count === 0 ) return
379+ if ( parkingResult . count === 0 ) {
380+ const recovered = await recoverParkedPieces ( {
381+ tracking,
382+ serviceUrl,
383+ concurrency : commitConcurrency ,
384+ } )
385+ if ( recovered === 0 ) return
386+
387+ renderCommitProgress ( tracking . getCommitStats ( ) )
388+ continue
389+ }
390+
295391 tracking . markParkedByPieceCids ( parkingResult . pieces )
296392 renderCommitProgress ( tracking . getCommitStats ( ) )
297393 }
0 commit comments