@filoz/repair-cli 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/package.json +2 -2
  2. package/dist/src/commands/datasets.d.ts +2 -2
  3. package/dist/src/commands/datasets.js +9 -2
  4. package/dist/src/commands/datasets.js.map +1 -1
  5. package/dist/src/commands/providers.d.ts +2 -2
  6. package/dist/src/commands/repair.d.ts +2 -2
  7. package/dist/src/commands/repair.js +5 -1
  8. package/dist/src/commands/repair.js.map +1 -1
  9. package/dist/src/commands/replicate.d.ts +2 -2
  10. package/dist/src/commands/replicate.js +5 -1
  11. package/dist/src/commands/replicate.js.map +1 -1
  12. package/dist/src/commands/second-copy.d.ts +2 -2
  13. package/dist/src/commands/second-copy.js +5 -1
  14. package/dist/src/commands/second-copy.js.map +1 -1
  15. package/dist/src/commands/session-key.d.ts +2 -2
  16. package/dist/src/commands/wallet.d.ts +2 -2
  17. package/dist/src/middleware.d.ts +5 -5
  18. package/dist/src/middleware.d.ts.map +1 -1
  19. package/dist/src/middleware.js.map +1 -1
  20. package/dist/src/pipeline/add-pieces.d.ts +1 -1
  21. package/dist/src/pipeline/add-pieces.d.ts.map +1 -1
  22. package/dist/src/pipeline/add-pieces.js +57 -43
  23. package/dist/src/pipeline/add-pieces.js.map +1 -1
  24. package/dist/src/types.d.ts +2 -2
  25. package/dist/src/types.d.ts.map +1 -1
  26. package/dist/src/utils.d.ts +38 -27
  27. package/dist/src/utils.d.ts.map +1 -1
  28. package/dist/src/utils.js +46 -0
  29. package/dist/src/utils.js.map +1 -1
  30. package/package.json +2 -2
  31. package/readme.md +9 -9
  32. package/src/commands/datasets.ts +9 -2
  33. package/src/commands/repair.ts +5 -1
  34. package/src/commands/replicate.ts +5 -1
  35. package/src/commands/second-copy.ts +5 -1
  36. package/src/middleware.ts +3 -3
  37. package/src/pipeline/add-pieces.ts +92 -46
  38. package/src/types.ts +2 -2
  39. package/src/utils.ts +69 -3
@@ -1,5 +1,4 @@
1
1
  import { taskLog } from '@clack/prompts'
2
- import * as Piece from '@filoz/synapse-core/piece'
3
2
  import * as SP from '@filoz/synapse-core/sp'
4
3
  import { and, asc, eq, gt, inArray } from 'drizzle-orm'
5
4
  import PQueue from 'p-queue'
@@ -10,14 +9,35 @@ import { repairUpdate } from '../db/repair-update.ts'
10
9
  import { upsertOperations } from '../db/upsert-operations.ts'
11
10
  import type { OperationSelect, RepairSelect } from '../local-schema.ts'
12
11
  import type { IndexerApi, LocalDatabase, WalletClient } from '../types.ts'
13
- import { completeConfirmedOperations, excludeOperationsByCid, hashLink, operationsToPullPieces } from '../utils.ts'
12
+ import {
13
+ completeConfirmedOperations,
14
+ excludeOperationsByCid,
15
+ fitAddPiecesBatch,
16
+ hashLink,
17
+ operationsToAddPieces,
18
+ operationsToPullPieces,
19
+ } from '../utils.ts'
20
+
21
+ /** Operations read per batch before fitting; above the ~400 metadata-free pieces one addPieces message holds. */
22
+ const MAX_BATCH_CANDIDATES = 500
23
+
24
+ /**
25
+ * Curio admits at most 108 unique pieces per pull request and 108 pending pulled pieces per client.
26
+ *
27
+ * @see https://github.com/filecoin-project/curio/blob/main/pdp/pull_types.go
28
+ */
29
+ const MAX_PULL_PIECES = 108
30
+
31
+ /** Curio answers 429 with Retry-After (1-5 minutes) while its pull queue is full; wait it out instead of failing. */
32
+ const PULL_RETRY_COUNT = 10
14
33
 
15
34
  export type RunPullPiecesPhaseOptions = {
16
35
  localDb: LocalDatabase
17
36
  api: IndexerApi
18
37
  repair: RepairSelect
19
38
  concurrency: number
20
- batchSize: number
39
+ /** Max pieces per batch. Defaults to as many as fit in one addPieces message. */
40
+ batchSize?: number
21
41
  client: WalletClient
22
42
  }
23
43
 
@@ -25,10 +45,11 @@ type CreateAddPiecesWorkerOptions = {
25
45
  localDb: LocalDatabase
26
46
  /** CIDs already in the target dataset; repairs only. Updated as batches are added. */
27
47
  targetCids?: Set<string>
48
+ /** Max unique pieces per pull request. */
49
+ pullChunkSize: number
28
50
  repair: RepairSelect
29
51
  client: WalletClient
30
52
  state: {
31
- totalBatches: number
32
53
  totalOperations: number
33
54
  completedOperations: number
34
55
  failedOperations: number
@@ -44,13 +65,21 @@ type AddPiecesJobOptions = {
44
65
  /**
45
66
  * Create a worker function that adds pieces to the target repair dataset.
46
67
  */
47
- function createAddPiecesWorker({ localDb, targetCids, repair, client, state, log }: CreateAddPiecesWorkerOptions) {
68
+ function createAddPiecesWorker({
69
+ localDb,
70
+ targetCids,
71
+ pullChunkSize,
72
+ repair,
73
+ client,
74
+ state,
75
+ log,
76
+ }: CreateAddPiecesWorkerOptions) {
48
77
  return async (options: AddPiecesJobOptions) => {
49
78
  let completedOps = 0
50
79
  let failedOps = 0
51
80
  let operations: OperationSelect[] = options.operations
52
81
 
53
- const group = log.group(`Batch ${options.batchNumber}/${state.totalBatches}`)
82
+ const group = log.group(`Batch ${options.batchNumber} (${options.operations.length} pieces)`)
54
83
 
55
84
  try {
56
85
  const dataset = await getTargetDataset({ localDb, repairId: repair.id, client })
@@ -69,36 +98,41 @@ function createAddPiecesWorker({ localDb, targetCids, repair, client, state, log
69
98
  }
70
99
 
71
100
  group.message(`Pulling ${operations.length} pieces...`)
72
- // pull pieces
101
+ // pull pieces in chunks that Curio admits, one chunk at a time per worker
73
102
  if (operations.length > 0) {
74
- const pullResult = await SP.waitForPullPieces(client, {
75
- serviceURL: repair.targetProviderUrl,
76
- dataSetId: dataset.dataSetId,
77
- clientDataSetId: dataset.clientDataSetId,
78
- pieces: operationsToPullPieces(operations),
79
- timeout: 1000 * 60 * 30, // 30 minutes
80
- onStatus: (status) => {
81
- const completed = status.pieces.filter((piece) => piece.status === 'complete').length
82
- const failed = status.pieces.filter((piece) => piece.status === 'failed').length
83
- group.message(`Pull ${completed} completed, ${failed} failed`)
84
- },
85
- })
103
+ const pullPieces = operationsToPullPieces(operations)
104
+ const pullChunks = Math.ceil(pullPieces.length / pullChunkSize)
105
+ for (let offset = 0; offset < pullPieces.length; offset += pullChunkSize) {
106
+ const chunk = offset / pullChunkSize + 1
107
+ const pullResult = await SP.waitForPullPieces(client, {
108
+ serviceURL: repair.targetProviderUrl,
109
+ dataSetId: dataset.dataSetId,
110
+ clientDataSetId: dataset.clientDataSetId,
111
+ pieces: pullPieces.slice(offset, offset + pullChunkSize),
112
+ retryCount: PULL_RETRY_COUNT,
113
+ onStatus: (status) => {
114
+ const completed = status.pieces.filter((piece) => piece.status === 'complete').length
115
+ const failed = status.pieces.filter((piece) => piece.status === 'failed').length
116
+ group.message(`Pull ${chunk}/${pullChunks}: ${completed} completed, ${failed} failed`)
117
+ },
118
+ })
86
119
 
87
- for (const { pieceCid, status } of pullResult.pieces) {
88
- const cid = pieceCid.toString()
89
- if (status !== 'complete') {
90
- state.failedOperations++
91
- failedOps++
92
- const { operationToFailed, operationToCommit } = excludeOperationsByCid(operations, cid)
93
- operations = operationToCommit
94
- await upsertOperations({
95
- localDb,
96
- operations: operationToFailed.map((operation) => ({
97
- ...operation,
98
- status: 'failed',
99
- error: `pull failed with status ${status}`,
100
- })),
101
- })
120
+ for (const { pieceCid, status } of pullResult.pieces) {
121
+ const cid = pieceCid.toString()
122
+ if (status !== 'complete') {
123
+ state.failedOperations++
124
+ failedOps++
125
+ const { operationToFailed, operationToCommit } = excludeOperationsByCid(operations, cid)
126
+ operations = operationToCommit
127
+ await upsertOperations({
128
+ localDb,
129
+ operations: operationToFailed.map((operation) => ({
130
+ ...operation,
131
+ status: 'failed',
132
+ error: `pull failed with status ${status}`,
133
+ })),
134
+ })
135
+ }
102
136
  }
103
137
  }
104
138
  }
@@ -110,10 +144,7 @@ function createAddPiecesWorker({ localDb, targetCids, repair, client, state, log
110
144
  serviceURL: repair.targetProviderUrl,
111
145
  dataSetId: dataset.dataSetId,
112
146
  clientDataSetId: dataset.clientDataSetId,
113
- pieces: operations.map((operation) => ({
114
- pieceCid: Piece.from(operation.cid),
115
- metadata: targetCids ? undefined : operation.metadata,
116
- })),
147
+ pieces: operationsToAddPieces(operations, !targetCids),
117
148
  })
118
149
  await upsertOperations({
119
150
  localDb,
@@ -125,6 +156,8 @@ function createAddPiecesWorker({ localDb, targetCids, repair, client, state, log
125
156
 
126
157
  group.message(`Waiting for add pieces ${hashLink(addPiecesResult.txHash, client.chain)}...`)
127
158
  const waitForAddPieces = await SP.waitForAddPieces(addPiecesResult)
159
+ // Curio may replace the message by fee; record the hash that landed on chain.
160
+ const confirmedTxHash = waitForAddPieces.confirmedTxHash ?? waitForAddPieces.txHash
128
161
  state.completedOperations += operations.length
129
162
  completedOps += operations.length
130
163
  for (const operation of operations) targetCids?.add(operation.cid)
@@ -134,7 +167,7 @@ function createAddPiecesWorker({ localDb, targetCids, repair, client, state, log
134
167
  ...operation,
135
168
  status: 'completed',
136
169
  error: null,
137
- txHash: waitForAddPieces.txHash,
170
+ txHash: confirmedTxHash,
138
171
  })),
139
172
  })
140
173
  }
@@ -179,7 +212,6 @@ export async function runAddPieces({
179
212
  )
180
213
  let batchNumber = 0
181
214
  const state = {
182
- totalBatches: Math.ceil(totalOperations / batchSize),
183
215
  totalOperations,
184
216
  completedOperations: 0,
185
217
  failedOperations: 0,
@@ -190,16 +222,20 @@ export async function runAddPieces({
190
222
  limit: 1,
191
223
  })
192
224
 
225
+ const dataset = totalOperations > 0 ? await getTargetDataset({ localDb, repairId: repair.id, client }) : undefined
226
+
193
227
  // Repairs and second copies dedupe into one shared dataset; read its CIDs once instead of per batch.
194
228
  let targetCids: Set<string> | undefined
195
- if (repair.repairDataSetId == null && totalOperations > 0) {
196
- const dataset = await getTargetDataset({ localDb, repairId: repair.id, client })
229
+ if (repair.repairDataSetId == null && dataset) {
197
230
  const pieces = await listDataSetPieces(api, dataset.dataSetId, { removed: false })
198
231
  targetCids = new Set(pieces.flatMap((piece) => (piece.cid == null ? [] : [piece.cid])))
199
232
  }
200
233
 
201
234
  async function getNextBatch(): Promise<OperationSelect[] | null> {
202
- const operations = await localDb.query.operations.findMany({
235
+ if (!dataset) {
236
+ return null
237
+ }
238
+ const candidates = await localDb.query.operations.findMany({
203
239
  where: and(
204
240
  eq(localSchema.operations.repairId, repair.id),
205
241
  eq(localSchema.operations.type, 'add_piece'),
@@ -207,17 +243,27 @@ export async function runAddPieces({
207
243
  gt(localSchema.operations.id, cursor)
208
244
  ),
209
245
  orderBy: [asc(localSchema.operations.id)],
210
- limit: batchSize,
246
+ limit: batchSize ?? MAX_BATCH_CANDIDATES,
211
247
  })
212
- if (operations.length === 0) {
248
+ if (candidates.length === 0) {
213
249
  return null
214
250
  }
215
251
 
252
+ // Size the batch by what fits in one addPieces message rather than a fixed piece count.
253
+ const operations = fitAddPiecesBatch({
254
+ operations: candidates,
255
+ dataSetId: dataset.dataSetId,
256
+ chain: client.chain,
257
+ withMetadata: !targetCids,
258
+ })
259
+
216
260
  cursor = operations.at(-1)?.id ?? cursor
217
261
  return operations
218
262
  }
219
263
 
220
- const addPiecesJob = createAddPiecesWorker({ localDb, targetCids, repair, client, state, log })
264
+ // Keep pieces pulling across all workers within Curio's per-client limit.
265
+ const pullChunkSize = Math.max(1, Math.floor(MAX_PULL_PIECES / concurrency))
266
+ const addPiecesJob = createAddPiecesWorker({ localDb, targetCids, pullChunkSize, repair, client, state, log })
221
267
  const queue = new PQueue({ concurrency })
222
268
 
223
269
  while (true) {
package/src/types.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ApiClient } from '@filecoin-project/api-client'
2
- import type { Chain } from '@filoz/synapse-core/chains'
2
+ import type { FilecoinChain } from '@filoz/synapse-core/chains'
3
3
  import type { Client } from '@libsql/client'
4
4
  import type { LibSQLDatabase } from 'drizzle-orm/libsql'
5
5
  import type { z } from 'incur'
@@ -26,7 +26,7 @@ export interface Config {
26
26
  dbPath: string
27
27
  }
28
28
 
29
- export type WalletClient = ViemClient<Transport, Chain, Account>
29
+ export type WalletClient = ViemClient<Transport, FilecoinChain, Account>
30
30
 
31
31
  export type Context = z.infer<typeof contextSchema>
32
32
 
package/src/utils.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { type Chain, getChain } from '@filoz/synapse-core/chains'
1
+ import { type FilecoinChain, getChain } from '@filoz/synapse-core/chains'
2
2
  import * as Piece from '@filoz/synapse-core/piece'
3
- import type * as SP from '@filoz/synapse-core/sp'
3
+ import * as SP from '@filoz/synapse-core/sp'
4
4
  import { getTableColumns, inArray, type SQL, sql } from 'drizzle-orm'
5
5
  import { drizzle } from 'drizzle-orm/libsql'
6
6
  import type { SQLiteTable } from 'drizzle-orm/sqlite-core'
@@ -166,7 +166,7 @@ export async function migrateLocalDatabase(db: LocalDatabase) {
166
166
  * @param chain - The chain to use
167
167
  * @returns The link
168
168
  */
169
- export function hashLink(hash: string, chain: Chain) {
169
+ export function hashLink(hash: string, chain: FilecoinChain) {
170
170
  const link = terminalLink(hash, `${chain.blockExplorers?.default?.url}/tx/${hash}`)
171
171
  return link
172
172
  }
@@ -327,3 +327,69 @@ export function operationsToPullPieces(operations: OperationSelect[]) {
327
327
  }
328
328
  return pieces
329
329
  }
330
+
331
+ /**
332
+ * Convert operations to addPieces inputs. Repairs and second copies add pieces without metadata.
333
+ */
334
+ export function operationsToAddPieces(operations: OperationSelect[], withMetadata: boolean) {
335
+ return operations.map((operation) => ({
336
+ pieceCid: Piece.from(operation.cid),
337
+ metadata: withMetadata ? operation.metadata : undefined,
338
+ }))
339
+ }
340
+
341
+ /**
342
+ * Take the longest prefix of operations that fits in one addPieces message.
343
+ * Always returns at least one operation so an oversized or invalid piece fails in the worker instead of stalling the queue.
344
+ */
345
+ export function fitAddPiecesBatch({
346
+ operations,
347
+ dataSetId,
348
+ chain,
349
+ withMetadata,
350
+ }: {
351
+ operations: OperationSelect[]
352
+ dataSetId: bigint
353
+ chain: FilecoinChain
354
+ withMetadata: boolean
355
+ }): OperationSelect[] {
356
+ // Malformed CIDs throw; stop before the first one so it fails alone in the worker.
357
+ const pieces: SP.LimiterPiece[] = []
358
+ for (const operation of operations) {
359
+ try {
360
+ pieces.push(...operationsToAddPieces([operation], withMetadata))
361
+ } catch {
362
+ break
363
+ }
364
+ }
365
+ if (pieces.length === 0) {
366
+ return operations.slice(0, 1)
367
+ }
368
+
369
+ const fits = (count: number) => {
370
+ try {
371
+ return SP.addPiecesFits({
372
+ kind: 'addPieces',
373
+ dataSetId,
374
+ legacyPieceStorageIdLimit: chain.legacyPieceStorageIdLimit,
375
+ pieces: pieces.slice(0, count),
376
+ })
377
+ } catch {
378
+ // Invalid metadata throws; isolate that piece so addPieces fails it in the worker.
379
+ return false
380
+ }
381
+ }
382
+
383
+ // Calldata only grows as pieces are added, so binary search the largest prefix that fits.
384
+ let low = 1
385
+ let high = pieces.length
386
+ while (low < high) {
387
+ const mid = Math.ceil((low + high) / 2)
388
+ if (fits(mid)) {
389
+ low = mid
390
+ } else {
391
+ high = mid - 1
392
+ }
393
+ }
394
+ return operations.slice(0, low)
395
+ }