This is a Substreams Sink library. You can use to build any sink application that consumes Substreams in Golang
What you get by using this library:
- Handles connection and reconnections
- Throughput Logging (block rates, etc)
- Best Practices error handling
The library provides a Sinker class that can be used to connect to the Substreams API. The Sinker class is a wrapper around the substreams library, which is a low-level library that provides a convenient way to connect to the Substreams API.
The user's primary responsibility when creating a custom sink is to implement handlers for processing Substreams data. The sink library provides two main patterns for creating handlers:
For simple use cases, you can use NewSinkerHandlers which requires only the essential handlers:
import (
pbsubstreamsrpc "github.com/streamingfast/substreams/pb/sf/substreams/rpc/v2"
)
handlers := sink.NewSinkerHandlers(
handleBlockScopedData func(ctx context.Context, data *pbsubstreamsrpc.BlockScopedData, isLive *bool, cursor *Cursor) error,
handleBlockUndoSignal func(ctx context.Context, undoSignal *pbsubstreamsrpc.BlockUndoSignal, cursor *Cursor) error,
)For advanced use cases that need session initialization, progress tracking, and debug capabilities, use NewSinkerFullHandlers:
handlers := sink.NewSinkerFullHandlers(
handleBlockScopedData func(ctx context.Context, data *pbsubstreamsrpc.BlockScopedData, isLive *bool, cursor *Cursor) error,
handleBlockUndoSignal func(ctx context.Context, undoSignal *pbsubstreamsrpc.BlockUndoSignal, cursor *Cursor) error,
handleSessionInit func(ctx context.Context, req *pbsubstreamsrpc.Request, session *pbsubstreamsrpc.SessionInit) error,
handleProgress func(ctx context.Context, progress *pbsubstreamsrpc.ModulesProgress),
handleInitialSnapshotData func(ctx context.Context, debug *pbsubstreamsrpc.InitialSnapshotData) error,
handleInitialSnapshotComplete func(ctx context.Context, complete *pbsubstreamsrpc.InitialSnapshotComplete) error,
handleError func(ctx context.Context, error *pbsubstreamsrpc.Error),
)We invite you to take a look at our:
- Basic Example (and its accompanying README.md)
- Advanced Example (and its accompanying README.md)
Note
We highly recommend to use the Advanced Example example for any serious sink implementation!
ctx context.Contextis thesink.Sinkeractualcontext.Context.data *pbsubstreamsrpc.BlockScopedDatacontains the data that was received from the Substreams API, refer to it's definition for proper usage.isLive *boolwill be non-nil if a LivenessChecker has been configured on the Sinker instance. When non-nil,*isLiveindicates whether the current block being processed is considered "live" (recently produced) or historical.cursor *Cursoris the cursor at the given block, this cursor should be saved regularly as a checkpoint in case the process is interrupted.
ctx context.Contextis thesink.Sinkeractualcontext.Context.undoSignal *pbsubstreamsrpc.BlockUndoSignalcontains the last valid block that is still valid, any data saved after this last saved block should be discarded.cursor *Cursoris the cursor to use after the undo, this cursor should be saved regularly as a checkpoint in case the process is interrupted.
Called when a new session is initialized with the Substreams server. This is useful for logging session information or performing setup tasks.
ctx context.Contextis thesink.Sinkeractualcontext.Context.req *pbsubstreamsrpc.Requestis the request that was sent to the Substreams API.session *pbsubstreamsrpc.SessionInitcontains the session initialization data including trace ID, resolved start block, and linear handoff information.
Called periodically to report processing progress. Useful for monitoring and displaying progress information.
ctx context.Contextis thesink.Sinkeractualcontext.Context.progress *pbsubstreamsrpc.ModulesProgresscontains progress information for each module being processed, including processed block ranges and execution statistics.
Called when initial snapshot data is available for store modules. Only called in development mode when debug snapshots are enabled.
ctx context.Contextis thesink.Sinkeractualcontext.Context.snapshotData *pbsubstreamsrpc.InitialSnapshotDatacontains the initial state data for store modules, useful for debugging.
Called when all initial snapshot data has been delivered. Signals that the snapshot phase is complete.
ctx context.Contextis thesink.Sinkeractualcontext.Context.complete *pbsubstreamsrpc.InitialSnapshotCompleteindicates completion of the initial snapshot delivery.
Called when errors are received from the Substreams server. Useful for custom error logging or handling.
ctx context.Contextis thesink.Sinkeractualcontext.Context.error *pbsubstreamsrpc.Errorcontains error information from the Substreams API.
Instead of using function-based handlers, you can implement the handler interfaces directly. This approach provides better type safety and organization for complex sinks:
// Implement the required interface
type MySink struct {
// your sink fields
}
func (s *MySink) HandleBlockScopedData(ctx context.Context, data *pbsubstreamsrpc.BlockScopedData, isLive *bool, cursor *Cursor) error {
// your implementation
}
func (s *MySink) HandleBlockUndoSignal(ctx context.Context, undoSignal *pbsubstreamsrpc.BlockUndoSignal, cursor *Cursor) error {
// your implementation
}
// Optional interfaces you can implement
func (s *MySink) HandleProgress(ctx context.Context, progress *pbsubstreamsrpc.ModulesProgress) {
// your implementation
}
func (s *MySink) HandleSessionInit(ctx context.Context, req *pbsubstreamsrpc.Request, session *pbsubstreamsrpc.SessionInit) error {
// your implementation
}
func (s *MySink) HandleInitialSnapshotData(ctx context.Context, snapshotData *pbsubstreamsrpc.InitialSnapshotData) error {
// your implementation
}
func (s *MySink) HandleInitialSnapshotComplete(ctx context.Context, complete *pbsubstreamsrpc.InitialSnapshotComplete) error {
// your implementation
}
func (s *MySink) HandleError(ctx context.Context, err error) {
// your implementation
}
func (s *MySink) HandleBlockRangeCompletion(ctx context.Context, cursor *Cursor) error {
// Called when the sinker finishes processing the requested block range
// Only called when streaming has a defined end block (not in live mode)
// your implementation
}
// Pass your sink directly to the sinker
mySink := &MySink{}
sinker.Run(ctx, cursor, mySink)The sink library automatically detects which interfaces your handler implements and calls the appropriate methods.
The basic pattern for using the Sinker is as follows:
- Setup Phase: Create your data layer responsible for decoding Substreams data and saving it to desired storage
- Handler Implementation: Implement the required handlers (either using functions or interfaces)
- Sinker Creation: Create a
Sinkerobject usingsink.Newwith your configuration - Handler Registration: Pass your handlers to the sinker using
sinker.Run(ctx, cursor, handlers)
When the sinker is running, handlers are called in the following order:
HandleSessionInit(if implemented) - Called once when the session startsHandleInitialSnapshotData(if implemented) - Called for each initial snapshot in development modeHandleInitialSnapshotComplete(if implemented) - Called when all snapshots are deliveredHandleProgress(if implemented) - Called periodically during processingHandleBlockScopedData(required) - Called for each block's dataHandleBlockUndoSignal(required) - Called when blockchain reorganizations occurHandleError(if implemented) - Called when errors are received from the serverHandleBlockRangeCompletion(if implemented) - Called when the specified block range is fully processed
The HandleBlockScopedData handler is called for each block scoped data message received from the Substreams API. It contains all the data output for the given Substreams module. In your handler, you are responsible for:
- Decoding and processing the data
- Saving the data to your storage system
- Persisting the cursor for checkpoint recovery
- Returning an error if there's a problem (which will trigger retries or termination based on configuration)
The HandleBlockUndoSignal handler is called when a blockchain reorganization (fork) occurs. The message contains:
LastValidBlock: Points to the last block that should be assumed to be part of the canonical chainLastValidCursor: The cursor that should be used as the current active position
Important: You must treat every piece of data from blocks where BlockScopedData.Clock.Number > LastValidBlock.Number as invalid and remove it from your storage. For example, if your entities include block numbers, you should delete all entities where blockNumber > LastValidBlock.Number.
The sink library provides robust error handling:
- Retryable Errors: Wrap errors in
derr.NewRetryableError(err)to indicate they should be retried - Fatal Errors: Return unwrapped errors to indicate fatal conditions that should stop processing
- Backoff Strategy: The library uses exponential backoff for retryable errors
- Max Retries: Can be configured for production deployments (0=no retries, -1=infinite, >0=specific count)
See examples from the 'examples' folder
Our sink(s) are all using Viper/Cobra and a set of public StreamingFast libraries to deal with flags, logging, environment variables, etc. If you use the same structure as us, you can benefit from sink.AddFlagsToSet and sink.ConfigFromViper which both perform most of the boilerplate to bootstrap a sinker from flags.
The SinkerConfig struct provides extensive configuration options:
config := &sink.SinkerConfig{
// Required: Substreams package and output module
Pkg: pkg, // *pbsubstreams.Package
OutputModule: outputModule, // *pbsubstreams.Module
// Connection settings
ClientConfig: clientConfig, // *client.SubstreamsClientConfig
// Block range
StartBlock: 0, // int64
StopBlock: 0, // uint64 (0 means live streaming)
// Processing mode
Mode: sink.SubstreamsModeDevelopment, // or SubstreamsModeProduction
// Error handling
MaxRetries: -1, // int - maximum retries (0=no retries, -1=infinite, >0=specific count)
// Performance tuning
FinalBlocksOnly: false, // bool - disable undo handling for faster processing
UndoBufferSize: 12, // int - number of blocks to buffer for undo handling
// Development features
DevOutputModules: []string{}, // Debug specific modules
DevOutputSnapshots: []string{}, // Get initial snapshots for stores
// Monitoring
LivenessChecker: livenessChecker, // *sink.LivenessChecker (optional)
// Logging
Logger: logger, // *zap.Logger
Tracer: tracer, // *otellib.Tracer
}The sinker can be launched by calling the Run method on the Sinker object. The Run method will block until the sinker is stopped or encounters an error.
ctx := context.Background()
cursor, err := sink.NewCursor("") // Start from beginning, or load from checkpoint
if err != nil {
return fmt.Errorf("creating cursor: %w", err)
}
// Run the sinker with your handlers
err = sinker.Run(ctx, cursor, handlers)
if err != nil {
return fmt.Errorf("sinker failed: %w", err)
}The sinker implements the shutter interface which can be used to handle all shutdown logic (e.g., flushing any remaining data to storage, stopping the sink in case of database disconnection, etc.).
You can implement graceful shutdown using context cancellation:
ctx, cancel := context.WithCancel(context.Background())
// Handle shutdown signals
go func() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
fmt.Println("Shutting down gracefully...")
cancel()
}()
err := sinker.Run(ctx, cursor, handlers)
if err != nil && err != context.Canceled {
log.Fatalf("Sinker error: %v", err)
}The sinker provides built-in statistics that can be accessed after completion:
sinker.Run(ctx, cursor, handlers)
// Print statistics
fmt.Printf("Total Processed Bytes: %d\n", sink.ProgressMessageProcessedBytes.Get())
fmt.Printf("Total Processed Blocks: %d\n", sink.ProgressMessageTotalProcessedBlocks.Get())
fmt.Printf("Total Received Bytes: %d\n", sink.DataMessageSizeBytes.Get())The following sinks are included with the substreams binary:
- example - A simple example
- webhook - Webhook sink for HTTP endpoints
- noop - Not exactly a sink -> used to force the substreams server to prepare its cache, with minimal data egress.
- protojson - Sink to JSONL files (using ProtoJSON encoding)
The following repositories are production examples of how the sink library can be used:
- substreams-sink-mongodb - MongoDB sink implementation
- substreams-sink-postgres - PostgreSQL sink implementation