diff --git a/README.md b/README.md index d5866e7e..95320e03 100644 --- a/README.md +++ b/README.md @@ -106,24 +106,49 @@ metaURI: mongodb://localhost:28012 The verifier will now check to completion to make sure that there are no inconsistencies. The command you need to send the verifier here is `writesOff`. The command doesn’t block. This means that you will have to poll the verifier, or watch its logs, to see the status of the verification (see `progress`). ``` - curl -H "Content-Type: application/json" -X POST -d '{}' http://127.0.0.1:27020/api/v1/writesOff + curl -H "Content-Type: application/json" -d '{}' http://127.0.0.1:27020/api/v1/writesOff ``` -3. You can poll the status of the verification by hitting the `progress`endpoint. In particular, the `phase`should reveal whether the verifier is done verifying; once the `phase`is `idle`the verification has completed. When the `phase`has reached `idle`, the `error`field should be `null`and the `failedTasks`field should be `0`, if the verification was successful. A non-`null``error`field indicates that the verifier itself ran into an error. `failedTasks`being non-`0`indicates that there was an inconsistency. The logs printed by the verifier itself should have more information regarding what the inconsistencies are. - - ``` - curl -H "Content-Type: application/json" -X GET http://127.0.0.1:27020/api/v1/progress - - ``` - - - - - This is a sample output when inconsistencies are present: +3. You can poll the status of the verification by hitting the `progress` endpoint. In particular, the `phase` should reveal whether the verifier is done verifying. Once the `phase` is `idle`, the verification has completed. At that point the `error` field should be `null`, and the `failedTasks` field should be `0`, if the verification was successful. A non-`null` `error` field indicates that the verifier itself ran into an error. `failedTasks` being non-`0` indicates that there was an inconsistency. See below for how to investigate mismatches. +``` +curl http://127.0.0.1:27020/api/v1/progress +``` - `{"progress":{"phase":"idle","error":null,"verificationStatus":{"totalTasks":1,"addedTasks":0,"processingTasks":0,"failedTasks":1,"completedTasks":0,"metadataMismatchTasks":0,"recheckTasks":0}}}` +### `/progress` API Response Contents + +In the below a “timestamp” is an object with `T` and `I` unsigned integers. +These represent a logical time in MongoDB’s replication protocol. + +- `progress` + - `phase` (string): either `idle`, `check`, or `recheck` + - `generation` (unsigned integer) + - `generationStats` + - `timeElapsed` (string, [Go Duration format](https://pkg.go.dev/time#ParseDuration)) + - `activeWorkers` (unsigned integer) + - `docsCompared` (unsigned integer) + - `totalDocs` (unsigned integer) + - `srcBytesCompared` (unsigned integer) + - `totalSrcBytes` (unsigned integer, only present in `check` phase) + - `priorMismatches` (unsigned integer, optional, mismatches seen in prior generation) + - `mismatchesFound` (unsigned integer) + - `rechecksEnqueued` (unsigned integer) + - `srcChangeStats` + - `eventsPerSecond` (nonnegative float, optional) + - `currentTimes` (optional) + - `lastHandledTime` (timestamp) + - `lastClusterTime` (timestamp) + - `bufferSaturation` (nonnegative float) + - `dstChangeStats` (same fields as `srcChangeStats`) + - `error` (string, optional) + - `verificationStatus` (tasks for the current generation) + - `totalTasks` (unsigned integer) + - `addedTasks` (unsigned integer, unstarted tasks) + - `processingTasks` (unsigned integer, in-progress tasks) + - `failedTasks` (unsigned integer, tasks that found a document mismatch) + - `completedTasks` (unsigned integer, tasks that found no problems) + - `metadataMismatchTasks` (unsigned integer, tasks that found a collection metadata mismatch) # CLI Options diff --git a/agg/agg.go b/agg/agg.go index e9f8b885..5402902f 100644 --- a/agg/agg.go +++ b/agg/agg.go @@ -171,50 +171,3 @@ func (s Switch) D() bson.D { func (s Switch) MarshalBSON() ([]byte, error) { return bson.Marshal(s.D()) } - -// --------------------------------------------- - -type Map struct { - Input, As, In any -} - -var _ bson.Marshaler = Map{} - -func (m Map) D() bson.D { - return bson.D{ - {"$map", bson.D{ - {"input", m.Input}, - {"as", m.As}, - {"in", m.In}, - }}, - } -} - -func (m Map) MarshalBSON() ([]byte, error) { - return bson.Marshal(m.D()) -} - -// ------------------------------------------ - -type Filter struct { - Input, As, Cond, Limit any -} - -var _ bson.Marshaler = Filter{} - -func (f Filter) D() bson.D { - d := bson.D{ - {"input", f.Input}, - {"as", f.As}, - {"cond", f.Cond}, - } - - if f.Limit != nil { - d = append(d, bson.E{"limit", f.Limit}) - } - return bson.D{{"$filter", d}} -} - -func (f Filter) MarshalBSON() ([]byte, error) { - return bson.Marshal(f.D()) -} diff --git a/agg/array.go b/agg/array.go index e58b405a..100a6aa0 100644 --- a/agg/array.go +++ b/agg/array.go @@ -23,6 +23,8 @@ func (s Slice) MarshalBSON() ([]byte, error) { }) } +// --------------------------------------------- + type ArrayElemAt struct { Array any Index any @@ -40,3 +42,84 @@ func (a ArrayElemAt) D() bson.D { func (a ArrayElemAt) MarshalBSON() ([]byte, error) { return bson.Marshal(a.D()) } + +// --------------------------------------------- + +type Size [1]any + +var _ bson.Marshaler = Size{} + +func (s Size) MarshalBSON() ([]byte, error) { + return bson.Marshal(bson.D{{"$size", s[0]}}) +} + +// --------------------------------------------- + +type Map struct { + Input, As, In any +} + +var _ bson.Marshaler = Map{} + +func (m Map) D() bson.D { + return bson.D{ + {"$map", bson.D{ + {"input", m.Input}, + {"as", m.As}, + {"in", m.In}, + }}, + } +} + +func (m Map) MarshalBSON() ([]byte, error) { + return bson.Marshal(m.D()) +} + +// ------------------------------------------ + +type Filter struct { + Input, As, Cond, Limit any +} + +var _ bson.Marshaler = Filter{} + +func (f Filter) D() bson.D { + d := bson.D{ + {"input", f.Input}, + {"as", f.As}, + {"cond", f.Cond}, + } + + if f.Limit != nil { + d = append(d, bson.E{"limit", f.Limit}) + } + return bson.D{{"$filter", d}} +} + +func (f Filter) MarshalBSON() ([]byte, error) { + return bson.Marshal(f.D()) +} + +// ------------------------------------------ + +type Range struct { + Start any + End any + Step any // ignored if 0 +} + +var _ bson.Marshaler = Range{} + +func (r Range) MarshalBSON() ([]byte, error) { + if r.Start == nil { + r.Start = 0 + } + + args := bson.A{r.Start, r.End} + + if r.Step != nil { + args = append(args, r.Step) + } + + return bson.Marshal(bson.D{{"$range", args}}) +} diff --git a/agg/string.go b/agg/string.go new file mode 100644 index 00000000..1def24ab --- /dev/null +++ b/agg/string.go @@ -0,0 +1,11 @@ +package agg + +import "go.mongodb.org/mongo-driver/v2/bson" + +type ToString [1]any + +var _ bson.Marshaler = ToString{} + +func (ts ToString) MarshalBSON() ([]byte, error) { + return bson.Marshal(bson.D{{"$toString", ts[0]}}) +} diff --git a/internal/verifier/mismatches.go b/internal/verifier/mismatches.go index 114e23f0..a257173a 100644 --- a/internal/verifier/mismatches.go +++ b/internal/verifier/mismatches.go @@ -4,9 +4,11 @@ import ( "context" "encoding/binary" "fmt" + "time" "github.com/10gen/migration-verifier/agg" "github.com/10gen/migration-verifier/agg/accum" + "github.com/10gen/migration-verifier/agg/helpers" "github.com/10gen/migration-verifier/option" "github.com/pkg/errors" "github.com/samber/lo" @@ -92,6 +94,194 @@ func createMismatchesCollection(ctx context.Context, db *mongo.Database) error { return nil } +type recheckCounts struct { + // FromMismatch are rechecks in the given generation from mismatches + // in the prior generation. + FromMismatch int64 + + // FromChange are rechecks from changes seen in the prior generation. + FromChange int64 + + // Total adds up all of the given generation’s rechecks. This will be less + // than FromMismatch + FromChange by however many documents both changed + // and were seen to mismatch. + Total int64 + + // NewMismatches are mismatches seen thus far in the current generation + // that will be rechecked in the next generation. + NewMismatches int64 + + // MaxMismatchDuration indicates the longest-lived mismatch, among either + // the current or the prior generation. + MaxMismatchDuration option.Option[time.Duration] +} + +// NB: This is OK to call for generation==0. In this case it will only +// add up newly-seen document mismatches. +func countRechecksForGeneration( + ctx context.Context, + metaDB *mongo.Database, + generation int, +) (recheckCounts, error) { + + // The numbers we need are: + // - the given generation’s total # of docs to recheck + // - the # of mismatches found in the prior generation + cursor, err := metaDB.Collection(verificationTasksCollection).Aggregate( + ctx, + mongo.Pipeline{ + {{"$match", bson.D{ + {"generation", bson.D{{"$in", []any{generation, generation - 1}}}}, + {"type", verificationTaskVerifyDocuments}, + + // NB: We don’t filter by task status because we need to count + // all rechecks, including those in tasks that turned up no + // mismatches. + }}}, + {{"$lookup", bson.D{ + {"from", mismatchesCollectionName}, + {"localField", "_id"}, + {"foreignField", "task"}, + {"as", "mismatches"}, + {"pipeline", mongo.Pipeline{ + {{"$group", bson.D{ + {"_id", nil}, + + {"count", accum.Sum{1}}, + {"maxDurationMS", accum.Max{"$detail.mismatchHistory.durationMS"}}, + }}}, + }}, + }}}, + + {{"$addFields", bson.D{ + // We avoid $unwind here because that’ll erase any tasks that + // don’t match up to >=1 mismatch. + {"mismatches", agg.ArrayElemAt{ + Array: "$mismatches", + Index: 0, + }}, + {"_ids", agg.Cond{ + If: agg.Eq{0, "$generation"}, + Then: 0, + Else: agg.Size{"$_ids"}, + }}, + {"rechecksFromChange", agg.Cond{ + If: agg.Or{ + agg.Eq{0, "$generation"}, + agg.Eq{generation - 1, "$generation"}, + }, + Then: 0, + + // _ids is the array of document IDs to recheck. + // mismatch_first_seen_at maps indexes of that array to + // the document’s first mismatch time. It only contains + // entries for documents that mismatched without a change + // event. Thus, any _ids member whose index is *not* in + // mismatch_first_seen_at was enqueued from a change event. + Else: agg.Size{agg.Filter{ + // This gives us all the array indices. + Input: agg.Range{End: agg.Size{"$_ids"}}, + As: "idx", + Cond: agg.Not{helpers.Exists{ + agg.GetField{ + Input: "$mismatch_first_seen_at", + Field: agg.ToString{"$$idx"}, + }, + }}, + }}, + }}, + }}}, + {{"$group", bson.D{ + {"_id", nil}, + {"allRechecks", accum.Sum{ + agg.Cond{ + If: agg.Eq{"$generation", generation}, + Then: "$_ids", + Else: 0, + }, + }}, + {"rechecksFromMismatch", accum.Sum{ + agg.Cond{ + If: agg.Eq{"$generation", generation - 1}, + Then: "$mismatches.count", + Else: 0, + }, + }}, + {"rechecksFromChange", accum.Sum{ + agg.Cond{ + If: agg.Eq{"$generation", generation}, + Then: "$rechecksFromChange", + Else: 0, + }, + }}, + {"newMismatches", accum.Sum{ + agg.Cond{ + If: agg.Eq{"$generation", generation}, + Then: "$mismatches.count", + Else: 0, + }, + }}, + {"maxMismatchDurationMS", accum.Max{"$mismatches.maxDurationMS"}}, + }}}, + }, + ) + if err != nil { + return recheckCounts{}, errors.Wrap(err, "sending query to count last generation’s found mismatches") + } + + defer cursor.Close(ctx) + + if !cursor.Next(ctx) { + if cursor.Err() != nil { + return recheckCounts{}, errors.Wrap(err, "reading count of last generation’s found mismatches") + } + + // This happens if there were no failed or in-progress tasks in the queried generations. + return recheckCounts{}, nil + } + + result := struct { + AllRechecks int64 + RechecksFromMismatch int64 + RechecksFromChange int64 + NewMismatches int64 + MaxMismatchDurationMS option.Option[int64] + }{} + + err = cursor.Decode(&result) + if err != nil { + return recheckCounts{}, errors.Wrapf(err, "reading mismatches from result (%v)", cursor.Current) + } + + /* + if result.RechecksFromMismatch > result.AllRechecks { + // TODO: fix + slog.Warn( + fmt.Sprintf( + "Mismatches found in generation %d outnumber generation %d’s total docs to recheck. This should be rare.", + generation-1, + generation, + ), + "priorGenMismatches", result.RechecksFromMismatch, + "curGenRechecks", result.AllRechecks, + ) + } + */ + + return recheckCounts{ + Total: result.AllRechecks, + FromMismatch: result.RechecksFromMismatch, + FromChange: result.RechecksFromChange, + NewMismatches: result.NewMismatches, + MaxMismatchDuration: option.Map( + result.MaxMismatchDurationMS, + func(ms int64) time.Duration { + return time.Duration(ms) * time.Millisecond + }, + ), + }, nil +} + type mismatchCountsPerType struct { MissingOnDst int64 ExtraOnDst int64 diff --git a/internal/verifier/progress.go b/internal/verifier/progress.go index f383b50f..f9b26c1a 100644 --- a/internal/verifier/progress.go +++ b/internal/verifier/progress.go @@ -1,22 +1,163 @@ package verifier -import "context" +import ( + "context" + "time" + + "github.com/10gen/migration-verifier/contextplus" + "github.com/10gen/migration-verifier/internal/types" + "github.com/10gen/migration-verifier/option" + "github.com/pkg/errors" +) func (verifier *Verifier) GetProgress(ctx context.Context) (Progress, error) { verifier.mux.RLock() defer verifier.mux.RUnlock() + var vStatus *VerificationStatus + generation := verifier.generation + genStats := ProgressGenerationStats{} + + if !verifier.generationStartTime.IsZero() { + progressTime := time.Now() + genElapsed := progressTime.Sub(verifier.generationStartTime) + + genStats.TimeElapsed = option.Some(genElapsed.Round(time.Millisecond).String()) + } + + eg, egCtx := contextplus.ErrGroup(ctx) + eg.Go( + func() error { + var err error + vStatus, err = verifier.getVerificationStatusForGeneration(egCtx, generation) + + return errors.Wrapf(err, "fetching generation %d’s tasks’ status", generation) + }, + ) + + eg.Go( + func() error { + recheckStats, err := countRechecksForGeneration( + egCtx, + verifier.metaClient.Database(verifier.metaDBName), + generation, + ) + + if err != nil { + return errors.Wrapf(err, "counting mismatches seen during generation %d", generation) + } + + if generation > 0 { + genStats.CurrentGenerationRechecks = option.Some(ProgressRechecks{ + Changes: recheckStats.FromChange, + Mismatches: recheckStats.FromMismatch, + }) + } + + genStats.MismatchesFound = recheckStats.NewMismatches + genStats.MaxMismatchDuration = option.Map( + recheckStats.MaxMismatchDuration, + time.Duration.String, + ) + + return nil + }, + ) + + eg.Go( + func() error { + enqueuedRecheckCounts, err := verifier.countEnqueuedRechecksWhileLocked(ctx) + + if err != nil { + return errors.Wrap(err, "counting enqueued rechecks") + } + + genStats.NextGenerationRechecks = enqueuedRecheckCounts - status, err := verifier.getVerificationStatusForGeneration(ctx, generation) - if err != nil { + /* + genStats.NextGenerationRechecks = ProgressRechecks{ + Changes: enqueuedRecheckCounts.Changed, + Mismatches: enqueuedRecheckCounts.Mismatched, + Total: option.Some(enqueuedRecheckCounts.Changed + enqueuedRecheckCounts.Mismatched - enqueuedRecheckCounts.ChangedAndMismatched), + } + */ + + return nil + }, + ) + + eg.Go( + func() error { + var err error + nsStats, err := verifier.GetPersistedNamespaceStatisticsForGeneration(ctx, generation) + + if err != nil { + return errors.Wrapf(err, "fetching generation %d’s persisted namespace stats", generation) + } + + var totalDocs, comparedDocs types.DocumentCount + var totalBytes, comparedBytes types.ByteCount + var totalNss, completedNss types.NamespaceCount + + for _, result := range nsStats { + totalDocs += result.TotalDocs + comparedDocs += result.DocsCompared + totalBytes += result.TotalBytes + comparedBytes += result.BytesCompared + + totalNss++ + if result.PartitionsDone > 0 { + partitionsPending := result.PartitionsAdded + result.PartitionsProcessing + if partitionsPending == 0 { + completedNss++ + } + } + } + + var activeWorkers int + perNamespaceWorkerStats := verifier.getPerNamespaceWorkerStats() + for _, nsWorkerStats := range perNamespaceWorkerStats { + for _, workerStats := range nsWorkerStats { + activeWorkers++ + comparedDocs += workerStats.SrcDocCount + comparedBytes += workerStats.SrcByteCount + } + } + + genStats.DocsCompared = comparedDocs + genStats.TotalDocs = totalDocs + + genStats.SrcBytesCompared = comparedBytes + genStats.TotalSrcBytes = totalBytes + + genStats.ActiveWorkers = activeWorkers + + return nil + }, + ) + + if err := eg.Wait(); err != nil { return Progress{Error: err}, err } + return Progress{ - Phase: verifier.getPhaseWhileLocked(), - Generation: verifier.generation, - Status: status, + Phase: verifier.getPhaseWhileLocked(), + Generation: verifier.generation, + GenerationStats: genStats, + SrcChangeStats: ProgressChangeStats{ + EventsPerSecond: verifier.srcChangeReader.getEventsPerSecond(), + CurrentTimes: verifier.srcChangeReader.getCurrentTimes(), + BufferSaturation: verifier.srcChangeReader.getBufferSaturation(), + }, + DstChangeStats: ProgressChangeStats{ + EventsPerSecond: verifier.dstChangeReader.getEventsPerSecond(), + CurrentTimes: verifier.dstChangeReader.getCurrentTimes(), + BufferSaturation: verifier.dstChangeReader.getBufferSaturation(), + }, + Status: vStatus, }, nil + } func (verifier *Verifier) getPhaseWhileLocked() string { diff --git a/internal/verifier/recheck.go b/internal/verifier/recheck.go index f990b608..54259d0e 100644 --- a/internal/verifier/recheck.go +++ b/internal/verifier/recheck.go @@ -77,6 +77,92 @@ func (verifier *Verifier) InsertFailedCompareRecheckDocs( ) } +type enqueuedRecheckCounts struct { + Changed int64 + Mismatched int64 + ChangedAndMismatched int64 +} + +func (verifier *Verifier) countEnqueuedRechecksWhileLocked( + ctx context.Context, +) (int64, error) { + generation, _ := verifier.getGenerationWhileLocked() + + // We enqueue for the generation after the current one. + generation++ + + genCollection := verifier.getRecheckQueueCollection(generation) + + return genCollection.EstimatedDocumentCount(ctx) + /* + cursor, err := genCollection.Aggregate( + ctx, + mongo.Pipeline{ + {{"$group", bson.D{ + {"_id", bson.D{ + {"db", "$_id.db"}, + {"coll", "$_id.coll"}, + {"docID", "$_id.docID"}, + }}, + + {"fromChanges", accum.Sum{agg.Cond{ + If: agg.Not{helpers.Exists{"$mismatchTimes"}}, + Then: 1, + Else: 0, + }}}, + + {"fromMismatch", accum.Sum{agg.Cond{ + If: helpers.Exists{"$mismatchTimes"}, + Then: 1, + Else: 0, + }}}, + }}}, + {{"$group", bson.D{ + {"_id", nil}, + + {"changed", accum.Sum{agg.Cond{ + If: agg.Gt{"$fromChanges", 0}, + Then: 1, + Else: 0, + }}}, + + {"mismatched", accum.Sum{agg.Cond{ + If: agg.Gt{"$fromMismatch", 0}, + Then: 1, + Else: 0, + }}}, + + {"changedAndMismatched", accum.Sum{agg.Cond{ + If: agg.And{ + agg.Gt{"$fromChanges", 0}, + agg.Gt{"$fromMismatch", 0}, + }, + Then: 1, + Else: 0, + }}}, + }}}, + }, + ) + if err != nil { + return enqueuedRecheckCounts{}, errors.Wrap(err, "surveying enqueued rechecks") + } + + var results []enqueuedRecheckCounts + if err := cursor.All(ctx, &results); err != nil { + return enqueuedRecheckCounts{}, errors.Wrap(err, "reading survey of enqueued rechecks") + } + + switch len(results) { + case 0: + return enqueuedRecheckCounts{}, nil + case 1: + return results[0], nil + } + + panic(fmt.Sprintf("multiple group results: %+v", results)) + */ +} + func (verifier *Verifier) insertRecheckDocs( ctx context.Context, dbNames []string, diff --git a/internal/verifier/statistics.go b/internal/verifier/statistics.go index 73aa0725..29ad3038 100644 --- a/internal/verifier/statistics.go +++ b/internal/verifier/statistics.go @@ -179,6 +179,13 @@ var jsonTemplate *template.Template func (verifier *Verifier) GetPersistedNamespaceStatistics(ctx context.Context) ([]NamespaceStats, error) { generation, _ := verifier.getGeneration() + return verifier.GetPersistedNamespaceStatisticsForGeneration(ctx, generation) +} + +func (verifier *Verifier) GetPersistedNamespaceStatisticsForGeneration( + ctx context.Context, + generation int, +) ([]NamespaceStats, error) { templateOnce.Do(func() { tmpl, err := template.New("").Parse(perNsStatsPipelineTemplate) if err != nil { diff --git a/internal/verifier/webserver.go b/internal/verifier/webserver.go index 57c52624..190cfa27 100644 --- a/internal/verifier/webserver.go +++ b/internal/verifier/webserver.go @@ -11,7 +11,9 @@ import ( "github.com/10gen/migration-verifier/contextplus" "github.com/10gen/migration-verifier/internal/logger" + "github.com/10gen/migration-verifier/internal/types" "github.com/10gen/migration-verifier/internal/verifier/webserver" + "github.com/10gen/migration-verifier/option" "github.com/gin-contrib/pprof" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -240,12 +242,53 @@ func (server *WebServer) writesOffEndpoint(c *gin.Context) { successResponse(c) } +type ProgressRechecks struct { + // Mismatches counts the # of rechecks from a mismatch. + Mismatches int64 `json:"mismatches"` + + // Changes counts the # of rechecks from a change event. + Changes int64 `json:"changes"` + + // Total counts all rechecks. This needn’t equal Mismatches + Changes + // because a document can both change and be seen to mismatch in the + // same generation. (Mismatches + Changes - Total counts those.) + Total option.Option[int64] `json:"total,omitzero"` +} + +type ProgressGenerationStats struct { + TimeElapsed option.Option[string] `json:"timeElapsed"` + ActiveWorkers int `json:"activeWorkers"` + + DocsCompared types.DocumentCount `json:"docsCompared"` + TotalDocs types.DocumentCount `json:"totalDocs"` + + SrcBytesCompared types.ByteCount `json:"srcBytesCompared"` + TotalSrcBytes types.ByteCount `json:"totalSrcBytes,omitempty"` + + CurrentGenerationRechecks option.Option[ProgressRechecks] `json:"currentGenerationRechecks"` + NextGenerationRechecks int64 `json:"nextGenerationRechecks"` + MismatchesFound int64 `json:"mismatchesFound"` + MaxMismatchDuration option.Option[string] `json:"maxMismatchDuration"` +} + +type ProgressChangeStats struct { + EventsPerSecond option.Option[float64] `json:"eventsPerSecond"` + CurrentTimes option.Option[readerCurrentTimes] `json:"currentTimes"` + BufferSaturation float64 `json:"bufferSaturation"` +} + // Progress represents the structure of the JSON response from the Progress end point. type Progress struct { - Phase string `json:"phase"` - Generation int `json:"generation"` - Error error `json:"error"` - Status *VerificationStatus `json:"verificationStatus"` + Phase string `json:"phase"` + + Generation int `json:"generation"` + GenerationStats ProgressGenerationStats `json:"generationStats"` + + SrcChangeStats ProgressChangeStats `json:"srcChangeStats"` + DstChangeStats ProgressChangeStats `json:"dstChangeStats"` + + Error error `json:"error,omitempty"` + Status *VerificationStatus `json:"verificationStatus"` } // progressEndpoint implements the gin handle for the progress endpoint. diff --git a/option/option.go b/option/option.go index 588bab3c..99bade46 100644 --- a/option/option.go +++ b/option/option.go @@ -150,3 +150,21 @@ func (o Option[T]) IsNone() bool { func (o Option[T]) IsSome() bool { return o.val != nil } + +// Map returns None if the given Option is empty; otherwise it +// returns cb’s result. This is useful, e.g., to transform a +// non-empty value from one type to another. +// +// Example usage: +// +// var maybeStringer Option[stringerType] +// maybeString := option.Map(maybeStringer, stringerType.String) +func Map[T any, V any](in Option[T], cb func(T) V) Option[V] { + var ret Option[V] + + if val, has := in.Get(); has { + ret = Some(cb(val)) + } + + return ret +}