Skip to content

Commit af920e5

Browse files
authored
feat(shed): lotus-shed miner fees-inspect improvements (#13212)
- Proper CSV output - Add --output to write to a file - Add resume capability from last processed epoch when using --output - --tipset only required if not resuming, now means "start" - --count is optional, will default to chain head - Always walk from oldest to newest so keeping a file updated is easier - Nice progress indicator when writing to a file - Improve efficiency comment for inspectMiner function
1 parent 4e247d1 commit af920e5

File tree

1 file changed

+207
-21
lines changed

1 file changed

+207
-21
lines changed

cmd/lotus-shed/miner-fees.go

Lines changed: 207 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@ package main
22

33
import (
44
"bytes"
5+
"encoding/csv"
56
"fmt"
7+
"io"
8+
"os"
69
"sort"
10+
"strconv"
11+
"strings"
712

813
cbor "github.com/ipfs/go-ipld-cbor"
914
"github.com/urfave/cli/v2"
@@ -392,8 +397,8 @@ var minerFeesCmd = &cli.Command{
392397

393398
var minerFeesInspect = &cli.Command{
394399
Name: "fees-inspect",
395-
UsageText: "lotus-shed miner fees-inspect [--tipset <tipset>] [--count <count>]",
396-
Description: "Inspect miner fees in the given tipset and its parents. The output is a CSV with the following columns:\n" +
400+
UsageText: "lotus-shed miner fees-inspect [--tipset <start-epoch>] [--count <epochs>] [--output <file>]",
401+
Description: "Inspect miner fees starting from the given tipset and going forward. The output is a CSV with the following columns:\n" +
397402
"Epoch, Burn, Fees, Penalties, Expected, Miners ...\n" +
398403
"Where:\n" +
399404
" - Epoch: the epoch of the tipset\n" +
@@ -403,17 +408,32 @@ var minerFeesInspect = &cli.Command{
403408
" - Expected: whether the sum of fees and penalties equals the burn amount (✓ or ✗)\n" +
404409
" A discrepancy here likely results from burnt precommit deposits or miners who can't pay fees,\n" +
405410
" neither of which are currently calculated by this tool\n" +
406-
" - Miners: the list of miners that burned or were expected to burn in this tipset",
411+
" - Miners: the list of miners that burned or were expected to burn in this tipset\n\n" +
412+
"Output Options:\n" +
413+
" --output <file> : Write CSV to file and track inspection progress. If file exists, inspection will resume from last epoch.\n\n" +
414+
"Examples:\n" +
415+
" # Inspect 100 epochs starting from epoch 5100000\n" +
416+
" lotus-shed miner fees-inspect --tipset @5100000 --count 100\n\n" +
417+
" # Inspect from epoch 5100000 to current head\n" +
418+
" lotus-shed miner fees-inspect --tipset @5100000\n\n" +
419+
" # Save results to file, inspect 1000 epochs from epoch 5000000\n" +
420+
" lotus-shed miner fees-inspect --tipset @5000000 --count 1000 --output fees.csv\n\n" +
421+
" # Resume from last epoch in existing file (no --tipset needed)\n" +
422+
" lotus-shed miner fees-inspect --output fees.csv --count 100\n\n" +
423+
" # Resume and go to current head\n" +
424+
" lotus-shed miner fees-inspect --output fees.csv",
407425
Flags: []cli.Flag{
408426
&cli.StringFlag{
409427
Name: "tipset",
410-
Usage: "tipset or height (@X or @head for latest)",
411-
Value: "@head",
428+
Usage: "starting tipset or height (@X to specify an epoch) - required unless resuming from file",
412429
},
413430
&cli.IntFlag{
414431
Name: "count",
415-
Usage: "number of tipsets to inspect, working backwards from the --tipset",
416-
Value: 1,
432+
Usage: "number of epochs to inspect forward from --tipset (default: to current head)",
433+
},
434+
&cli.StringFlag{
435+
Name: "output",
436+
Usage: "CSV file to save or resume results",
417437
},
418438
},
419439
Action: func(cctx *cli.Context) error {
@@ -433,6 +453,75 @@ var minerFeesInspect = &cli.Command{
433453
bstore := blockstore.NewAPIBlockstore(api)
434454
adtStore := adt.WrapStore(ctx, cbor.NewCborStore(bstore))
435455

456+
outputFile := cctx.String("output")
457+
count := cctx.Int("count")
458+
459+
// Handle output options
460+
var csvWriter *csv.Writer
461+
var lastEpoch abi.ChainEpoch = 0
462+
463+
if outputFile != "" {
464+
// Check if file exists to resume from it
465+
if _, err := os.Stat(outputFile); err == nil {
466+
// Read the last epoch from existing file
467+
f, err := os.Open(outputFile)
468+
if err != nil {
469+
return xerrors.Errorf("opening output file: %w", err)
470+
}
471+
reader := csv.NewReader(f)
472+
var lastLine []string
473+
for {
474+
line, err := reader.Read()
475+
if err == io.EOF {
476+
break
477+
}
478+
if err != nil {
479+
_ = f.Close()
480+
return xerrors.Errorf("reading output file: %w", err)
481+
}
482+
if line[0] != "Epoch" { // Skip header
483+
lastLine = line
484+
}
485+
}
486+
_ = f.Close()
487+
488+
if len(lastLine) > 0 {
489+
epochStr := strings.TrimSpace(lastLine[0])
490+
epoch, err := strconv.ParseInt(epochStr, 10, 64)
491+
if err != nil {
492+
return xerrors.Errorf("parsing last epoch from output file: %w", err)
493+
}
494+
lastEpoch = abi.ChainEpoch(epoch)
495+
_, _ = fmt.Fprintf(cctx.App.ErrWriter, "Last processed epoch: %d\n", lastEpoch)
496+
}
497+
}
498+
499+
// Open file for appending or create new one
500+
var f *os.File
501+
if lastEpoch > 0 {
502+
f, err = os.OpenFile(outputFile, os.O_APPEND|os.O_WRONLY, 0644)
503+
if err != nil {
504+
return xerrors.Errorf("opening output file for append: %w", err)
505+
}
506+
} else {
507+
f, err = os.Create(outputFile)
508+
if err != nil {
509+
return xerrors.Errorf("creating output file: %w", err)
510+
}
511+
}
512+
defer func() { _ = f.Close() }()
513+
514+
csvWriter = csv.NewWriter(f)
515+
defer csvWriter.Flush()
516+
517+
// Write header if new file
518+
if lastEpoch == 0 {
519+
if err := csvWriter.Write([]string{"Epoch", "Burn", "Fees", "Penalties", "Expected", "Miners"}); err != nil {
520+
return xerrors.Errorf("writing output header: %w", err)
521+
}
522+
}
523+
}
524+
436525
inspectTipset := func(ts *types.TipSet) error {
437526
compute, err := api.StateCompute(ctx, ts.Height(), nil, ts.Key())
438527
if err != nil {
@@ -625,32 +714,129 @@ var minerFeesInspect = &cli.Command{
625714
// calculate that for each miner.
626715
expected = "✗"
627716
}
628-
_, _ = fmt.Fprintf(cctx.App.Writer, "%d, %v, %v, %v, %s", ts.Height(), totalBurn, totalFees, totalPenalties, expected)
717+
// Format output - unified CSV handling for both file and stdout
718+
row := []string{
719+
fmt.Sprintf("%d", ts.Height()),
720+
totalBurn.String(),
721+
totalFees.String(),
722+
totalPenalties.String(),
723+
expected,
724+
}
725+
726+
// Add miners - consistent ordering
629727
sort.Slice(miners, func(i, j int) bool {
630728
return must.One(address.IDFromAddress(miners[i])) < must.One(address.IDFromAddress(miners[j]))
631729
})
632-
for _, maddr := range miners {
633-
_, _ = fmt.Fprintf(cctx.App.Writer, ", %v", maddr)
730+
minersStr := ""
731+
for i, maddr := range miners {
732+
if i > 0 {
733+
minersStr += " "
734+
}
735+
minersStr += maddr.String()
736+
}
737+
row = append(row, minersStr)
738+
739+
if err := csvWriter.Write(row); err != nil {
740+
return xerrors.Errorf("writing output: %w", err)
634741
}
635-
_, _ = fmt.Fprintf(cctx.App.Writer, "\n")
742+
csvWriter.Flush()
636743

637744
return nil
638745
}
639746

640-
ts, err := lcli.LoadTipSet(ctx, cctx, api)
641-
if err != nil {
642-
return err
747+
// Determine starting point
748+
var startTs *types.TipSet
749+
if lastEpoch > 0 && outputFile != "" {
750+
// Resume from last epoch in file
751+
// Check if we're already at the head
752+
head, err := api.ChainHead(ctx)
753+
if err != nil {
754+
return xerrors.Errorf("getting chain head: %w", err)
755+
}
756+
757+
if lastEpoch >= head.Height() {
758+
_, _ = fmt.Fprintf(cctx.App.ErrWriter, "File is up to date (last epoch %d, current head %d)\n", lastEpoch, head.Height())
759+
return nil
760+
}
761+
762+
startTs, err = api.ChainGetTipSetByHeight(ctx, lastEpoch+1, types.EmptyTSK)
763+
if err != nil {
764+
return xerrors.Errorf("getting tipset at height %d: %w", lastEpoch+1, err)
765+
}
766+
_, _ = fmt.Fprintf(cctx.App.ErrWriter, "Resuming from epoch %d\n", lastEpoch+1)
767+
} else {
768+
// Start from specified tipset
769+
if !cctx.IsSet("tipset") {
770+
return xerrors.Errorf("--tipset is required when not resuming from an existing file")
771+
}
772+
startTs, err = lcli.LoadTipSet(ctx, cctx, api)
773+
if err != nil {
774+
return err
775+
}
643776
}
644777

645-
count := cctx.Int("count")
646-
_, _ = fmt.Fprintln(cctx.App.Writer, "Epoch, Burn, Fees, Penalties, Expected, Miners ...")
647-
for i := 0; i < count; i++ {
648-
if err := inspectTipset(ts); err != nil {
649-
return xerrors.Errorf("inspecting tipset %d: %w", ts.Height(), err)
778+
// Determine end point
779+
var endHeight abi.ChainEpoch
780+
if count > 0 {
781+
// Use specified count
782+
endHeight = startTs.Height() + abi.ChainEpoch(count) - 1
783+
} else {
784+
// Go to current head
785+
head, err := api.ChainHead(ctx)
786+
if err != nil {
787+
return xerrors.Errorf("getting chain head: %w", err)
650788
}
651-
if ts, err = api.ChainGetTipSet(ctx, ts.Parents()); err != nil {
652-
return xerrors.Errorf("getting parent tipset: %w", err)
789+
endHeight = head.Height()
790+
}
791+
792+
// Calculate actual count for the loop
793+
actualCount := int(endHeight - startTs.Height() + 1)
794+
if actualCount <= 0 {
795+
return xerrors.Errorf("invalid range: start epoch %d >= end epoch %d", startTs.Height(), endHeight)
796+
}
797+
798+
// Setup CSV writer for stdout if no output file
799+
if outputFile == "" {
800+
csvWriter = csv.NewWriter(cctx.App.Writer)
801+
defer csvWriter.Flush()
802+
// Write header for stdout
803+
if err := csvWriter.Write([]string{"Epoch", "Burn", "Fees", "Penalties", "Expected", "Miners"}); err != nil {
804+
return xerrors.Errorf("writing output header: %w", err)
805+
}
806+
}
807+
808+
// Process epochs forward from start to end
809+
currentTs := startTs
810+
811+
// Print starting info if outputting to file
812+
if outputFile != "" {
813+
_, _ = fmt.Fprintf(cctx.App.ErrWriter, "Processing %d epochs from %d to %d\n", actualCount, startTs.Height(), endHeight)
814+
}
815+
816+
for i := 0; i < actualCount; i++ {
817+
// Show progress if outputting to file
818+
if outputFile != "" {
819+
_, _ = fmt.Fprintf(cctx.App.ErrWriter, "\rProcessing epoch %d [%d/%d] (%d%%)...", currentTs.Height(), i+1, actualCount, (i+1)*100/actualCount)
820+
}
821+
822+
if err := inspectTipset(currentTs); err != nil {
823+
return xerrors.Errorf("inspecting tipset %d: %w", currentTs.Height(), err)
653824
}
825+
826+
// Move to next epoch
827+
if i < actualCount-1 {
828+
nextHeight := currentTs.Height() + 1
829+
currentTs, err = api.ChainGetTipSetByHeight(ctx, nextHeight, types.EmptyTSK)
830+
if err != nil {
831+
return xerrors.Errorf("getting tipset at height %d: %w", nextHeight, err)
832+
}
833+
}
834+
}
835+
836+
// Clear progress line if outputting to file
837+
if outputFile != "" {
838+
_, _ = fmt.Fprintf(cctx.App.ErrWriter, "\r \r")
839+
_, _ = fmt.Fprintf(cctx.App.ErrWriter, "Completed processing %d epochs\n", actualCount)
654840
}
655841
return nil
656842
},

0 commit comments

Comments
 (0)