-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathexport.go
More file actions
287 lines (251 loc) · 9.32 KB
/
Copy pathexport.go
File metadata and controls
287 lines (251 loc) · 9.32 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
package cmd
import (
"context"
"errors"
"fmt"
"io"
"path/filepath"
"sync"
"time"
"github.com/ethereum/go-ethereum/core/types"
ethclient "github.com/ethersphere/batch-export/pkg/ethclientwrapper"
"github.com/ethersphere/batch-export/pkg/eventfetcher"
"github.com/ethersphere/batch-export/pkg/filestore"
"github.com/ethersphere/batch-export/pkg/gzipstore"
"github.com/ethersphere/batch-export/pkg/resume"
"github.com/ethersphere/bee/v2/pkg/config"
"github.com/ethersphere/bee/v2/pkg/util/abiutil"
"github.com/spf13/cobra"
)
func (c *command) initExportCmd() (err error) {
var (
startBlock uint64
endBlock uint64
rpcEndpoint string
maxRequest int
blockRangeLimit uint32
outputFile string
compress bool
resumeFile string
slim bool
retryMax int
retryDelay time.Duration
)
cmd := &cobra.Command{
Use: "export",
Short: "Export Swarm Postage Stamp contract event logs within a block range.",
Long: `Exports event logs for the Swarm Postage Stamp contract from a specified Ethereum RPC endpoint
within a given block range (--start to --end). It handles large ranges by querying in chunks (--block-range-limit)
and respects RPC rate limits (--max-request). Requests failing with a transient network error are retried
with an exponential backoff (--retry-max, --retry-delay).
The retrieved logs are saved to the specified output file (default: 'export.ndjson') in NDJSON format.
The process can be interrupted at any time (Ctrl+C), and it will attempt to save already retrieved logs before exiting.`,
RunE: func(cmd *cobra.Command, args []string) (err error) {
ctx, cancel := context.WithCancel(cmd.Context())
defer cancel()
var cursor *resume.Cursor
if resumeFile != "" {
cursor, err = resume.Read(resumeFile)
if err != nil {
return fmt.Errorf("failed to read resume file %q: %w", resumeFile, err)
}
if cmd.Flags().Changed("start") {
c.log.Warning("--start is ignored when --resume is set", "resumeFile", resumeFile)
}
if compress {
c.log.Warning("--compress is ignored when resuming; resume a compressed file to get a compressed result", "resumeFile", resumeFile)
compress = false
}
// An unset --output means in-place; so does naming the input.
if !cmd.Flags().Changed("output") || filepath.Clean(outputFile) == filepath.Clean(resumeFile) {
outputFile = resumeFile
}
startBlock = cursor.BlockNumber
c.log.Info("Resuming export",
"resumeFile", resumeFile,
"outputFile", outputFile,
"startBlock", startBlock,
"lastLogIndex", cursor.LogIndex,
"compressed", cursor.Compressed,
)
}
if retryMax < 0 {
return fmt.Errorf("invalid --retry-max %d: must not be negative", retryMax)
}
if retryDelay <= 0 {
return fmt.Errorf("invalid --retry-delay %s: must be greater than zero", retryDelay)
}
ec, err := ethclient.NewClient(ctx, rpcEndpoint,
ethclient.WithRateLimit(maxRequest),
ethclient.WithLogger(c.log),
ethclient.WithRetry(retryMax, retryDelay),
)
if err != nil {
return fmt.Errorf("failed to connect to the Ethereum client: %w", err)
}
defer ec.Close()
chainID, err := ec.ChainID(ctx)
if err != nil {
return fmt.Errorf("failed to get chainID: %w", err)
}
chainCfg, found := config.GetByChainID(chainID.Int64())
if !found {
return fmt.Errorf("chain config not found for chain ID %d", chainID.Int64())
}
postageStampContractABI := abiutil.MustParseABI(chainCfg.PostageStampABI)
client := eventfetcher.NewClient(ec, postageStampContractABI, blockRangeLimit, c.log)
if startBlock == 0 {
startBlock = chainCfg.PostageStampStartBlock
}
if cursor != nil {
discarded, err := resume.PrepareOutput(cursor, resumeFile, outputFile)
if err != nil {
return err
}
if discarded > 0 {
c.log.Warning("previous export ends with an interrupted write, leaving it out",
"resumeFile", resumeFile,
"offset", cursor.CleanSize,
"discardedBytes", discarded,
)
}
}
// Opened before the first log is fetched: from inside the saving
// goroutine, a failure here would leave the fetcher pushing into a
// channel nobody drains.
w, err := openOutput(outputFile, cursor)
if err != nil {
return fmt.Errorf("failed to open output file: %w", err)
}
c.log.Info("Retrieving logs", "startBlock", startBlock, "endBlock", endBlock)
logChan, errorChan := client.GetLogs(ctx, &eventfetcher.Request{
Address: chainCfg.PostageStampAddress,
StartBlock: startBlock,
EndBlock: endBlock,
})
var wg sync.WaitGroup
wg.Add(1)
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
var saveErr error
go func() {
defer wg.Done()
if err := saveLogs(ctx, logChan, w, cursor, slim); err != nil {
if solelyCanceled(err) {
c.log.Error(err, "context canceled while saving logs")
return
}
c.log.Error(err, "error saving logs")
// Stop the fetcher too: with the saver gone, logChan
// would fill and block it forever.
saveErr = err
cancel()
return
}
c.log.Info("all logs have been saved", "outputFile", outputFile)
}()
compressFunc := func() error {
if compress {
if err := gzipstore.CompressFile(outputFile, outputFile+".gzip"); err != nil {
return fmt.Errorf("error compressing file: %w", err)
}
c.log.Info("File compressed", "outputFile", outputFile+".gzip")
}
return nil
}
for {
select {
case err, ok := <-errorChan:
if !ok {
errorChan = nil
} else {
wg.Wait()
if saveErr != nil && errors.Is(err, context.Canceled) {
return saveErr
}
return errors.Join(fmt.Errorf("error retrieving logs: %w", err), saveErr)
}
case <-ticker.C:
c.log.Info("still retrieving logs...")
case <-ctx.Done():
c.log.Info("context canceled, waiting for logs to be saved...")
wg.Wait()
if saveErr != nil {
return saveErr
}
if err := compressFunc(); err != nil {
return errors.Join(fmt.Errorf("error compressing file: %w", err), ctx.Err())
}
return ctx.Err()
}
if errorChan == nil {
break
}
}
wg.Wait()
if saveErr != nil {
return saveErr
}
if err := compressFunc(); err != nil {
return fmt.Errorf("error compressing file: %w", err)
}
return nil
},
}
cmd.Flags().Uint64VarP(&startBlock, "start", "", 31306381, "Start block (optional, uses contract start block if 0)")
cmd.Flags().Uint64VarP(&endBlock, "end", "", 0, "End block (optional, uses latest finalized block if 0)")
cmd.Flags().StringVarP(&rpcEndpoint, "endpoint", "e", "https://rpc.gnosis.gateway.fm", "Ethereum based RPC endpoint URL")
cmd.Flags().IntVarP(&maxRequest, "max-request", "m", 15, "Max RPC requests/sec")
cmd.Flags().Uint32VarP(&blockRangeLimit, "block-range-limit", "b", 5, "Max blocks per log query")
cmd.Flags().StringVarP(&outputFile, "output", "o", "export.ndjson", "Output file path (NDJSON)")
cmd.Flags().BoolVarP(&compress, "compress", "c", false, "Compress to GZIP")
cmd.Flags().StringVarP(&resumeFile, "resume", "r", "", "Continue a previous export file (.ndjson, .gz or .gzip); combine with --output to write a new snapshot instead of appending in place")
cmd.Flags().BoolVar(&slim, "slim", true, "Emit only the types.Log fields Bee consumes (address, topics, data, blockNumber, transactionHash) plus logIndex to keep exports resumable; pass --slim=false for the full geth types.Log JSON shape")
cmd.Flags().IntVarP(&retryMax, "retry-max", "", 5, "Max retries per RPC request on transient network errors (0 disables retrying)")
cmd.Flags().DurationVarP(&retryDelay, "retry-delay", "", ethclient.DefaultRetryDelay, "Delay before the first retry, doubling per retry up to 30s")
c.root.AddCommand(cmd)
return nil
}
// openOutput opens the destination for a run's logs: a fresh file when cursor
// is nil, or a writer that appends to the file the cursor came from.
func openOutput(outputFile string, cursor *resume.Cursor) (io.WriteCloser, error) {
switch {
case cursor == nil:
return filestore.CreateWriter(outputFile)
case cursor.Compressed:
return gzipstore.AppendWriter(outputFile)
default:
return filestore.AppendWriter(outputFile)
}
}
// saveLogs writes logs to w, dropping any entry a resumed export already holds.
// A nil cursor means the destination starts empty, so every log is kept.
func saveLogs(ctx context.Context, logChan <-chan types.Log, w io.WriteCloser, cursor *resume.Cursor, slim bool) error {
var skip func(types.Log) bool
if cursor != nil {
skip = cursor.Skip
}
return filestore.AppendLogsAsync(ctx, logChan, w, skip, slim)
}
// solelyCanceled reports whether err contains nothing beyond context
// cancellation, unwrapping joined and wrapped errors along the way. It is
// stricter than errors.Is(err, context.Canceled): AppendLogsAsync joins the
// save error with the destination's Close error, and a SIGINT racing a
// failing gzip-member flush must not be reported as pure cancellation.
func solelyCanceled(err error) bool {
if err == nil {
return false
}
if u, ok := err.(interface{ Unwrap() []error }); ok {
for _, e := range u.Unwrap() {
if !solelyCanceled(e) {
return false
}
}
return true
}
if u := errors.Unwrap(err); u != nil {
return solelyCanceled(u)
}
return errors.Is(err, context.Canceled)
}