package slasher import ( "bytes" "context" "fmt" "github.com/pkg/errors" slashertypes "github.com/prysmaticlabs/prysm/v5/beacon-chain/slasher/types" fieldparams "github.com/prysmaticlabs/prysm/v5/config/fieldparams" "github.com/prysmaticlabs/prysm/v5/consensus-types/primitives" ethpb "github.com/prysmaticlabs/prysm/v5/proto/prysm/v1alpha1" "go.opencensus.io/trace" "golang.org/x/exp/maps" ) // Takes in a list of indexed attestation wrappers and returns any // found attester slashings to the caller. func (s *Service) checkSlashableAttestations( ctx context.Context, currentEpoch primitives.Epoch, atts []*slashertypes.IndexedAttestationWrapper, ) (map[[fieldparams.RootLength]byte]*ethpb.AttesterSlashing, error) { slashings := map[[fieldparams.RootLength]byte]*ethpb.AttesterSlashing{} // Double votes doubleVoteSlashings, err := s.checkDoubleVotes(ctx, atts) if err != nil { return nil, errors.Wrap(err, "could not check slashable double votes") } for root, slashing := range doubleVoteSlashings { slashings[root] = slashing } // Save the attestation records to our database. // If multiple attestations are provided for the same validator index + target epoch combination, // then the first (validator index + target epoch) => signing root) link is kept into the database. if err := s.serviceCfg.Database.SaveAttestationRecordsForValidators(ctx, atts); err != nil { return nil, errors.Wrap(err, couldNotSaveAttRecord) } // Surrounding / surrounded votes surroundSlashings, err := s.checkSurroundVotes(ctx, atts, currentEpoch) if err != nil { return nil, errors.Wrap(err, "could not check slashable surround votes") } for root, slashing := range surroundSlashings { slashings[root] = slashing } return slashings, nil } // Check for surrounding and surrounded votes in our database given a list of incoming attestations. func (s *Service) checkSurroundVotes( ctx context.Context, attWrappers []*slashertypes.IndexedAttestationWrapper, currentEpoch primitives.Epoch, ) (map[[fieldparams.RootLength]byte]*ethpb.AttesterSlashing, error) { // With 256 validators and 16 epochs per chunk, there is 4096 `uint16` elements per chunk. // 4096 `uint16` elements = 8192 bytes = 8KB // 25_600 chunks * 8KB = 200MB const maxChunkBeforeFlush = 25_600 slashings := map[[fieldparams.RootLength]byte]*ethpb.AttesterSlashing{} // Group attestation wrappers by validator chunk index. attWrappersByValidatorChunkIndex := s.groupByValidatorChunkIndex(attWrappers) attWrappersByValidatorChunkIndexCount := len(attWrappersByValidatorChunkIndex) minChunkByChunkIndexByValidatorChunkIndex := make(map[uint64]map[uint64]Chunker, attWrappersByValidatorChunkIndexCount) maxChunkByChunkIndexByValidatorChunkIndex := make(map[uint64]map[uint64]Chunker, attWrappersByValidatorChunkIndexCount) chunksCounts := 0 for validatorChunkIndex, attWrappers := range attWrappersByValidatorChunkIndex { minChunkByChunkIndex, err := s.updatedChunkByChunkIndex(ctx, slashertypes.MinSpan, currentEpoch, validatorChunkIndex) if err != nil { return nil, errors.Wrap(err, "could not update updatedMinChunks") } maxChunkByChunkIndex, err := s.updatedChunkByChunkIndex(ctx, slashertypes.MaxSpan, currentEpoch, validatorChunkIndex) if err != nil { return nil, errors.Wrap(err, "could not update updatedMaxChunks") } chunksCounts += len(minChunkByChunkIndex) + len(maxChunkByChunkIndex) // Group (already grouped by validator chunk index) attestation wrappers by chunk index. attWrappersByChunkIndex := s.groupByChunkIndex(attWrappers) // Check for surrounding votes. surroundingSlashings, err := s.updateSpans(ctx, minChunkByChunkIndex, attWrappersByChunkIndex, slashertypes.MinSpan, validatorChunkIndex, currentEpoch) if err != nil { return nil, errors.Wrapf(err, "could not update min attestation spans for validator chunk index %d", validatorChunkIndex) } for root, slashing := range surroundingSlashings { slashings[root] = slashing } // Check for surrounded votes. surroundedSlashings, err := s.updateSpans(ctx, maxChunkByChunkIndex, attWrappersByChunkIndex, slashertypes.MaxSpan, validatorChunkIndex, currentEpoch) if err != nil { return nil, errors.Wrapf(err, "could not update max attestation spans for validator chunk index %d", validatorChunkIndex) } for root, slashing := range surroundedSlashings { slashings[root] = slashing } // Memoize the updated chunks for the current validator chunk index. minChunkByChunkIndexByValidatorChunkIndex[validatorChunkIndex] = minChunkByChunkIndex maxChunkByChunkIndexByValidatorChunkIndex[validatorChunkIndex] = maxChunkByChunkIndex if chunksCounts >= maxChunkBeforeFlush { // Save the updated chunks to disk if we have reached the maximum number of chunks to store in memory. if err := s.saveChunksToDisk(ctx, slashertypes.MinSpan, minChunkByChunkIndexByValidatorChunkIndex); err != nil { return nil, errors.Wrap(err, "could not save updated min chunks to disk") } if err := s.saveChunksToDisk(ctx, slashertypes.MaxSpan, maxChunkByChunkIndexByValidatorChunkIndex); err != nil { return nil, errors.Wrap(err, "could not save updated max chunks to disk") } // Reset the chunks counts. chunksCounts = 0 // Reset memoized chunks. minChunkByChunkIndexByValidatorChunkIndex = make(map[uint64]map[uint64]Chunker, attWrappersByValidatorChunkIndexCount) maxChunkByChunkIndexByValidatorChunkIndex = make(map[uint64]map[uint64]Chunker, attWrappersByValidatorChunkIndexCount) } // Update the latest updated epoch for all validators involved to the current chunk. indexes := s.params.ValidatorIndexesInChunk(validatorChunkIndex) for _, index := range indexes { s.latestEpochUpdatedForValidator[index] = currentEpoch } } // Save the updated chunks to disk. if err := s.saveChunksToDisk(ctx, slashertypes.MinSpan, minChunkByChunkIndexByValidatorChunkIndex); err != nil { return nil, errors.Wrap(err, "could not save updated min chunks to disk") } if err := s.saveChunksToDisk(ctx, slashertypes.MaxSpan, maxChunkByChunkIndexByValidatorChunkIndex); err != nil { return nil, errors.Wrap(err, "could not save updated max chunks to disk") } return slashings, nil } // Check for double votes in our database given a list of incoming attestations. func (s *Service) checkDoubleVotes( ctx context.Context, incomingAttWrappers []*slashertypes.IndexedAttestationWrapper, ) (map[[fieldparams.RootLength]byte]*ethpb.AttesterSlashing, error) { ctx, span := trace.StartSpan(ctx, "Slasher.checkDoubleVotesOnDisk") defer span.End() type attestationInfo struct { validatorIndex uint64 epoch primitives.Epoch } slashings := map[[fieldparams.RootLength]byte]*ethpb.AttesterSlashing{} // Check each incoming attestation for double votes against other incoming attestations. existingAttWrappers := make(map[attestationInfo]*slashertypes.IndexedAttestationWrapper) for _, incomingAttWrapper := range incomingAttWrappers { targetEpoch := incomingAttWrapper.IndexedAttestation.Data.Target.Epoch for _, validatorIndex := range incomingAttWrapper.IndexedAttestation.AttestingIndices { info := attestationInfo{ validatorIndex: validatorIndex, epoch: targetEpoch, } existingAttWrapper, ok := existingAttWrappers[info] if !ok { // This is the first attestation for this `validator index x epoch` combination. // There is no double vote. This attestation is memoized for future checks. existingAttWrappers[info] = incomingAttWrapper continue } if existingAttWrapper.DataRoot == incomingAttWrapper.DataRoot { // Both attestations are the same, this is not a double vote. continue } // There is two different attestations for the same `validator index x epoch` combination. // This is a double vote. doubleVotesTotal.Inc() slashing := ðpb.AttesterSlashing{ Attestation_1: existingAttWrapper.IndexedAttestation, Attestation_2: incomingAttWrapper.IndexedAttestation, } // Ensure the attestation with the lower data root is the first attestation. // It will be useful for comparing with other double votes. if bytes.Compare(existingAttWrapper.DataRoot[:], incomingAttWrapper.DataRoot[:]) > 0 { slashing = ðpb.AttesterSlashing{ Attestation_1: incomingAttWrapper.IndexedAttestation, Attestation_2: existingAttWrapper.IndexedAttestation, } } root, err := slashing.HashTreeRoot() if err != nil { return nil, errors.Wrap(err, "could not hash tree root for attester slashing") } slashings[root] = slashing } } // Check each incoming attestation for double votes against the database. doubleVotes, err := s.serviceCfg.Database.CheckAttesterDoubleVotes(ctx, incomingAttWrappers) if err != nil { return nil, errors.Wrap(err, "could not retrieve potential double votes from disk") } for _, doubleVote := range doubleVotes { doubleVotesTotal.Inc() wrapper_1 := doubleVote.Wrapper_1 wrapper_2 := doubleVote.Wrapper_2 slashing := ðpb.AttesterSlashing{ Attestation_1: wrapper_1.IndexedAttestation, Attestation_2: wrapper_2.IndexedAttestation, } // Ensure the attestation with the lower data root is the first attestation. if bytes.Compare(wrapper_1.DataRoot[:], wrapper_2.DataRoot[:]) > 0 { slashing = ðpb.AttesterSlashing{ Attestation_1: wrapper_2.IndexedAttestation, Attestation_2: wrapper_1.IndexedAttestation, } } root, err := slashing.HashTreeRoot() if err != nil { return nil, errors.Wrap(err, "could not hash tree root for attester slashing") } slashings[root] = slashing } return slashings, nil } // updatedChunkByChunkIndex loads the chunks from the database for validators corresponding to // the `validatorChunkIndex`. // It then updates the chunks with the neutral element for corresponding validators from // the epoch just after the latest updated epoch to the current epoch. // A mapping between chunk index and chunk is returned to the caller. func (s *Service) updatedChunkByChunkIndex( ctx context.Context, chunkKind slashertypes.ChunkKind, currentEpoch primitives.Epoch, validatorChunkIndex uint64, ) (map[uint64]Chunker, error) { // Every validator may have a first epoch to update. // For a given validator, // - If it has no latest updated epoch, then the first epoch to update is set to 0. // - If the latest updated epoch is the current epoch, then there is no epoch to update. // Thus, then there is no first epoch to update. // - In all other cases, the first epoch to update is the latest updated epoch + 1. // minFirstEpochToUpdate is set to the smallest first epoch to update for all validators in the chunk // corresponding to the `validatorChunkIndex`. var ( minFirstEpochToUpdate *primitives.Epoch neededChunkIndexesMap map[uint64]bool err error ) validatorIndexes := s.params.ValidatorIndexesInChunk(validatorChunkIndex) if neededChunkIndexesMap, err = s.findNeededChunkIndexes(validatorIndexes, currentEpoch, minFirstEpochToUpdate); err != nil { return nil, errors.Wrap(err, "could not find the needed chunk indexed") } // Transform the map of needed chunk indexes to a slice. neededChunkIndexes := maps.Keys(neededChunkIndexesMap) // Retrieve needed chunks from the database. chunkByChunkIndex, err := s.loadChunksFromDisk(ctx, validatorChunkIndex, chunkKind, neededChunkIndexes) if err != nil { return nil, errors.Wrap(err, "could not load chunks from disk") } for _, validatorIndex := range validatorIndexes { // Retrieve the first epoch to write for the validator index. isAnEpochToUpdate, firstEpochToUpdate, err := s.firstEpochToUpdate(validatorIndex, currentEpoch) if err != nil { return nil, errors.Wrapf(err, "could not get first epoch to write for validator index %d with current epoch %d", validatorIndex, currentEpoch) } if !isAnEpochToUpdate { // If there is no epoch to write, skip. continue } epochToUpdate := firstEpochToUpdate for epochToUpdate <= currentEpoch { // Get the chunk index for the epoch to write. chunkIndex := s.params.chunkIndex(epochToUpdate) // Get the chunk corresponding to the chunk index from the `chunkByChunkIndex` map. currentChunk, ok := chunkByChunkIndex[chunkIndex] if !ok { return nil, errors.Errorf("chunk at index %d does not exist", chunkIndex) } // Update the current chunk with the neutral element for the validator index for the epoch to write. for s.params.chunkIndex(epochToUpdate) == chunkIndex && epochToUpdate <= currentEpoch { if err := setChunkRawDistance( s.params, currentChunk.Chunk(), validatorIndex, epochToUpdate, currentChunk.NeutralElement(), ); err != nil { return nil, err } epochToUpdate++ } chunkByChunkIndex[chunkIndex] = currentChunk } } return chunkByChunkIndex, nil } // findNeededChunkIndexes returns a map of chunk indexes // it loops over the validator indexes and finds the first epoch to update for each validator index. func (s *Service) findNeededChunkIndexes( validatorIndexes []primitives.ValidatorIndex, currentEpoch primitives.Epoch, minFirstEpochToUpdate *primitives.Epoch, ) (map[uint64]bool, error) { neededChunkIndexesMap := map[uint64]bool{} for _, validatorIndex := range validatorIndexes { // Retrieve the first epoch to write for the validator index. isAnEpochToUpdate, firstEpochToUpdate, err := s.firstEpochToUpdate(validatorIndex, currentEpoch) if err != nil { return nil, errors.Wrapf(err, "could not get first epoch to write for validator index %d with current epoch %d", validatorIndex, currentEpoch) } if !isAnEpochToUpdate { // If there is no epoch to write, skip. continue } // If, for this validator index, the chunk corresponding to the first epoch to write // (and all following epochs until the current epoch) are already flagged as needed, // skip. if minFirstEpochToUpdate != nil && *minFirstEpochToUpdate <= firstEpochToUpdate { continue } minFirstEpochToUpdate = &firstEpochToUpdate // Add new needed chunk indexes to the map. for i := firstEpochToUpdate; i <= currentEpoch; i++ { chunkIndex := s.params.chunkIndex(i) neededChunkIndexesMap[chunkIndex] = true } } return neededChunkIndexesMap, nil } // firstEpochToUpdate, given a validator index and the current epoch, returns a boolean indicating // if there is an epoch to write. If it is the case, it returns the first epoch to write. func (s *Service) firstEpochToUpdate(validatorIndex primitives.ValidatorIndex, currentEpoch primitives.Epoch) (bool, primitives.Epoch, error) { latestEpochUpdated, ok := s.latestEpochUpdatedForValidator[validatorIndex] // Start from the epoch just after the latest updated epoch. epochToUpdate, err := latestEpochUpdated.SafeAdd(1) if err != nil { return false, primitives.Epoch(0), errors.Wrap(err, "could not add 1 to latest updated epoch") } if !ok { epochToUpdate = 0 } if latestEpochUpdated == currentEpoch { // If the latest updated epoch is the current epoch, we do not need to update anything. return false, primitives.Epoch(0), nil } // Latest updated epoch should not be greater than the current epoch. if latestEpochUpdated > currentEpoch { return false, primitives.Epoch(0), errors.Errorf("epoch to write `%d` should not be greater than the current epoch `%d`", epochToUpdate, currentEpoch) } // It is useless to update more than `historyLength` epochs, since // the chunks are circular and we will be overwritten at least one. if currentEpoch-epochToUpdate >= s.params.historyLength { epochToUpdate = currentEpoch + 1 - s.params.historyLength } return true, epochToUpdate, nil } // Updates spans and detects any slashable attester offenses along the way. // 1. Determine the chunks we need to use for updating for the validator indices // in a validator chunk index, then retrieve those chunks from the database. // 2. Using the chunks from step (1): // for every attestation by chunk index: // for each validator in the attestation's attesting indices: // - Check if the attestation is slashable, if so return a slashing object. // 3. Save the updated chunks to disk. func (s *Service) updateSpans( ctx context.Context, updatedChunks map[uint64]Chunker, attWrapperByChunkIdx map[uint64][]*slashertypes.IndexedAttestationWrapper, kind slashertypes.ChunkKind, validatorChunkIndex uint64, currentEpoch primitives.Epoch, ) (map[[fieldparams.RootLength]byte]*ethpb.AttesterSlashing, error) { ctx, span := trace.StartSpan(ctx, "Slasher.updateSpans") defer span.End() // Apply the attestations to the related chunks and find any // slashings along the way. slashings := map[[fieldparams.RootLength]byte]*ethpb.AttesterSlashing{} for _, attWrappers := range attWrapperByChunkIdx { for _, attWrapper := range attWrappers { for _, validatorIdx := range attWrapper.IndexedAttestation.AttestingIndices { validatorIndex := primitives.ValidatorIndex(validatorIdx) computedValidatorChunkIdx := s.params.validatorChunkIndex(validatorIndex) // Every validator chunk index represents a range of validators. // It is possible that the validator index in this loop iteration is // not part of the validator chunk index we are updating chunks for. // // For example, if there are 4 validators per validator chunk index, // then validator chunk index 0 contains validator indices [0, 1, 2, 3] // If we see an attestation with attesting indices [3, 4, 5] and we are updating // chunks for validator chunk index 0, only validator index 3 should make // it past this line. if validatorChunkIndex != computedValidatorChunkIdx { continue } slashing, err := s.applyAttestationForValidator( ctx, updatedChunks, attWrapper, kind, validatorChunkIndex, validatorIndex, currentEpoch, ) if err != nil { return nil, errors.Wrapf(err, "could not apply attestation for validator index %d", validatorIndex) } if slashing == nil { continue } root, err := slashing.HashTreeRoot() if err != nil { return nil, errors.Wrap(err, "could not hash tree root for attester slashing") } slashings[root] = slashing } } } // Write the updated chunks to disk. return slashings, nil } // Checks if an incoming attestation is slashable based on the validator chunk it // corresponds to. If a slashable offense is found, we return it to the caller. // If not, then update every single chunk the attestation covers, starting from its // source epoch up to its target. func (s *Service) applyAttestationForValidator( ctx context.Context, chunksByChunkIdx map[uint64]Chunker, attestation *slashertypes.IndexedAttestationWrapper, chunkKind slashertypes.ChunkKind, validatorChunkIndex uint64, validatorIndex primitives.ValidatorIndex, currentEpoch primitives.Epoch, ) (*ethpb.AttesterSlashing, error) { ctx, span := trace.StartSpan(ctx, "Slasher.applyAttestationForValidator") defer span.End() var err error sourceEpoch := attestation.IndexedAttestation.Data.Source.Epoch targetEpoch := attestation.IndexedAttestation.Data.Target.Epoch attestationDistance.Observe(float64(targetEpoch) - float64(sourceEpoch)) chunkIndex := s.params.chunkIndex(sourceEpoch) chunk, ok := chunksByChunkIdx[chunkIndex] if !ok { chunk, err = s.getChunkFromDatabase(ctx, chunkKind, validatorChunkIndex, chunkIndex) if err != nil { return nil, errors.Wrapf(err, "could not get chunk at index %d", chunkIndex) } } // Check slashable, if so, return the slashing. slashing, err := chunk.CheckSlashable( ctx, s.serviceCfg.Database, validatorIndex, attestation, ) if err != nil { return nil, errors.Wrapf( err, "could not check if attestation for validator index %d is slashable", validatorIndex, ) } if slashing != nil { return slashing, nil } // Get the first start epoch for the chunk. If it does not exist or // is not possible based on the input arguments, do not continue with the update. startEpoch, exists := chunk.StartEpoch(sourceEpoch, currentEpoch) if !exists { return nil, nil } // Given a single attestation could span across multiple chunks // for a validator min or max span, we attempt to update the current chunk // for the source epoch of the attestation. If the update function tells // us we need to proceed to the next chunk, we continue by determining // the start epoch of the next chunk. We exit once no longer need to // keep updating chunks. for { chunkIndex = s.params.chunkIndex(startEpoch) chunk, ok := chunksByChunkIdx[chunkIndex] if !ok { chunk, err = s.getChunkFromDatabase(ctx, chunkKind, validatorChunkIndex, chunkIndex) if err != nil { return nil, errors.Wrapf(err, "could not get chunk at index %d", chunkIndex) } } keepGoing, err := chunk.Update( chunkIndex, currentEpoch, validatorIndex, startEpoch, targetEpoch, ) if err != nil { return nil, errors.Wrapf( err, "could not update chunk at chunk index %d for validator index %d and current epoch %d", chunkIndex, validatorIndex, currentEpoch, ) } // We update the chunksByChunkIdx map with the chunk we just updated. chunksByChunkIdx[chunkIndex] = chunk if !keepGoing { break } // Move to first epoch of next chunk if needed. startEpoch = chunk.NextChunkStartEpoch(startEpoch) } return nil, nil } // Retrieve a chunk from database. func (s *Service) getChunkFromDatabase( ctx context.Context, chunkKind slashertypes.ChunkKind, validatorChunkIndex uint64, chunkIndex uint64, ) (Chunker, error) { // We can ensure we load the appropriate chunk we need by fetching from the DB. diskChunks, err := s.loadChunksFromDisk(ctx, validatorChunkIndex, chunkKind, []uint64{chunkIndex}) if err != nil { return nil, errors.Wrapf(err, "could not load chunk at index %d", chunkIndex) } if chunk, ok := diskChunks[chunkIndex]; ok { return chunk, nil } return nil, fmt.Errorf("could not retrieve chunk at chunk index %d from disk", chunkIndex) } // Load chunks for a specified list of chunk indices. We attempt to load it from the database. // If the data exists, then we initialize a chunk of a specified kind. Otherwise, we create // an empty chunk, add it to our map, and then return it to the caller. func (s *Service) loadChunksFromDisk( ctx context.Context, validatorChunkIndex uint64, chunkKind slashertypes.ChunkKind, chunkIndexes []uint64, ) (map[uint64]Chunker, error) { ctx, span := trace.StartSpan(ctx, "Slasher.loadChunks") defer span.End() chunksCount := len(chunkIndexes) if chunksCount == 0 { return map[uint64]Chunker{}, nil } // Build chunk keys. chunkKeys := make([][]byte, 0, chunksCount) for _, chunkIndex := range chunkIndexes { chunkKey := s.params.flatSliceID(validatorChunkIndex, chunkIndex) chunkKeys = append(chunkKeys, chunkKey) } // Load the chunks from the database. rawChunks, chunksExist, err := s.serviceCfg.Database.LoadSlasherChunks(ctx, chunkKind, chunkKeys) if err != nil { return nil, errors.Wrapf(err, "could not load slasher chunk index") } // Perform basic checks. if len(rawChunks) != chunksCount { return nil, errors.Errorf("expected %d chunks, got %d", chunksCount, len(rawChunks)) } if len(chunksExist) != chunksCount { return nil, errors.Errorf("expected %d chunks exist, got %d", chunksCount, len(chunksExist)) } // Initialize the chunks. chunksByChunkIdx := make(map[uint64]Chunker, chunksCount) for i := 0; i < len(rawChunks); i++ { // If the chunk exists in the database, we initialize it from the raw bytes data. // If it does not exist, we initialize an empty chunk. var ( chunk Chunker err error ) chunkExists := chunksExist[i] switch chunkKind { case slashertypes.MinSpan: if chunkExists { chunk, err = MinChunkSpansSliceFrom(s.params, rawChunks[i]) break } chunk = EmptyMinSpanChunksSlice(s.params) case slashertypes.MaxSpan: if chunkExists { chunk, err = MaxChunkSpansSliceFrom(s.params, rawChunks[i]) break } chunk = EmptyMaxSpanChunksSlice(s.params) } if err != nil { return nil, errors.Wrap(err, "could not initialize chunk") } chunksByChunkIdx[chunkIndexes[i]] = chunk } return chunksByChunkIdx, nil } func (s *Service) saveChunksToDisk( ctx context.Context, chunkKind slashertypes.ChunkKind, chunkByChunkIndexByValidatorChunkIndex map[uint64]map[uint64]Chunker, ) error { ctx, span := trace.StartSpan(ctx, "Slasher.saveChunksToDisk") defer span.End() // Compute the total number of chunks to save. chunksCount := 0 for _, chunkByChunkIndex := range chunkByChunkIndexByValidatorChunkIndex { chunksCount += len(chunkByChunkIndex) } // Create needed arrays. chunkKeys := make([][]byte, 0, chunksCount) chunks := make([][]uint16, 0, chunksCount) // Fill the arrays. for validatorChunkIndex, chunkByChunkIndex := range chunkByChunkIndexByValidatorChunkIndex { for chunkIndex, chunk := range chunkByChunkIndex { chunkKeys = append(chunkKeys, s.params.flatSliceID(validatorChunkIndex, chunkIndex)) chunks = append(chunks, chunk.Chunk()) } } // Update prometheus metrics. chunksSavedTotal.Add(float64(chunksCount)) // Save the chunks to disk. return s.serviceCfg.Database.SaveSlasherChunks(ctx, chunkKind, chunkKeys, chunks) }