Skip to content

Commit c8d8085

Browse files
committed
fix(backup-helper): make commit parking retriable
1 parent a09026c commit c8d8085

2 files changed

Lines changed: 149 additions & 17 deletions

File tree

scripts/backup-helper/commands/commit.mjs

Lines changed: 113 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { fromSecp256k1 } from '@filoz/synapse-core/session-key'
1010
import { addPieces, createDataSet, waitForAddPieces, waitForCreateDataSet } from '@filoz/synapse-core/sp'
1111
import { getDataSet } from '@filoz/synapse-core/warm-storage'
1212
import { execa } from 'execa'
13+
import pMap from 'p-map'
1314
import { createPublicClient, http } from 'viem'
1415
import { getAddress } from 'viem/utils'
1516
import { z } from 'zod'
@@ -20,6 +21,7 @@ import { openTrackingDb } from '../lib/tracking-db.mjs'
2021

2122
const PARKING_BATCH_SIZE = 50
2223
const COMMIT_BATCH_SIZE = 20
24+
const PARKING_RECOVERY_BATCH_SIZE = 1_000
2325
const DEFAULT_COMMIT_CONCURRENCY = 4
2426
const 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
*/
175197
async 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
}

scripts/backup-helper/lib/tracking-db.mjs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,11 +354,30 @@ export function openTrackingDb(dir) {
354354
const markParkedByPieceCidStmt = db.prepare(`
355355
UPDATE root_shards
356356
SET commit_status = '${COMMIT_STATUS.parked}',
357+
last_commit_error = NULL,
358+
updated_at = ?
359+
WHERE piece_cid = ?
360+
AND commit_status = '${COMMIT_STATUS.pending}'
361+
`)
362+
363+
const markPendingCommitPieceErrorStmt = db.prepare(`
364+
UPDATE root_shards
365+
SET last_commit_error = ?,
357366
updated_at = ?
358367
WHERE piece_cid = ?
359368
AND commit_status = '${COMMIT_STATUS.pending}'
360369
`)
361370

371+
const pendingCommitPieceCidsStmt = db.prepare(`
372+
SELECT DISTINCT piece_cid
373+
FROM root_shards
374+
WHERE commit_status = '${COMMIT_STATUS.pending}'
375+
AND piece_cid IS NOT NULL
376+
AND piece_cid > ?
377+
ORDER BY piece_cid
378+
LIMIT ?
379+
`)
380+
362381
const claimCommitCandidatesStmt = db.prepare(`
363382
SELECT root_cid, shard_cid, piece_cid
364383
FROM root_shards
@@ -777,6 +796,23 @@ export function openTrackingDb(dir) {
777796
return changed
778797
},
779798

799+
/**
800+
* @param {string} pieceCid
801+
* @param {string} error
802+
*/
803+
markPendingCommitPieceError(pieceCid, error) {
804+
return Number(markPendingCommitPieceErrorStmt.run(error, now(), pieceCid).changes || 0)
805+
},
806+
807+
/**
808+
* @param {number} limit
809+
* @param {string} afterPieceCid
810+
* @returns {string[]}
811+
*/
812+
listPendingCommitPieceCids(limit, afterPieceCid = '') {
813+
return pendingCommitPieceCidsStmt.all(afterPieceCid, limit).map((row) => row.piece_cid?.toString())
814+
},
815+
780816
/**
781817
* @param {number} limit
782818
* @returns {CommitCandidate[]}

0 commit comments

Comments
 (0)