diff --git a/platform/metrics/README.md b/platform/metrics/README.md index f3161ac6..55da95c6 100644 --- a/platform/metrics/README.md +++ b/platform/metrics/README.md @@ -57,7 +57,7 @@ h := metrics.NamedHistogram(c.scope, "process", "duration", metrics.FastLatencyB h.RecordDuration(elapsed) ``` -Do not emit gauges or timers. Represent operation latency and completion count with lifecycle histograms, and represent instantaneous quantities as sampled histogram values when needed. +Do not emit timers. Represent operation latency and completion count with lifecycle histograms. Use a gauge only for a periodically refreshed, current-state value whose latest observation is the query result; use a histogram for distributions of observations over time. ### Why histograms, not timers diff --git a/platform/metrics/metrics.go b/platform/metrics/metrics.go index e2bf0db9..f2cf50f4 100644 --- a/platform/metrics/metrics.go +++ b/platform/metrics/metrics.go @@ -106,6 +106,25 @@ var ( 2 * time.Hour, 4 * time.Hour, } + + // ChangeAgeBuckets suits age-based signals for source-control changes, + // including time to failure detection and last-known-green freshness. + ChangeAgeBuckets = tally.DurationBuckets{ + 1 * time.Minute, + 5 * time.Minute, + 15 * time.Minute, + 30 * time.Minute, + 1 * time.Hour, + 2 * time.Hour, + 4 * time.Hour, + 8 * time.Hour, + 12 * time.Hour, + 24 * time.Hour, + 48 * time.Hour, + 7 * 24 * time.Hour, + 14 * 24 * time.Hour, + 30 * 24 * time.Hour, + } ) // Op tracks the lifecycle of a named operation. It captures the start time on diff --git a/stovepipe/controller/buildsignal/BUILD.bazel b/stovepipe/controller/buildsignal/BUILD.bazel index d5b9c17c..8a0654d4 100644 --- a/stovepipe/controller/buildsignal/BUILD.bazel +++ b/stovepipe/controller/buildsignal/BUILD.bazel @@ -13,6 +13,7 @@ go_library( "//stovepipe/core/messagequeue:go_default_library", "//stovepipe/entity:go_default_library", "//stovepipe/extension/buildrunner:go_default_library", + "//stovepipe/extension/sourcecontrol:go_default_library", "//stovepipe/extension/storage:go_default_library", "@com_github_uber_go_tally//:go_default_library", "@org_uber_go_zap//:go_default_library", @@ -33,6 +34,8 @@ go_test( "//stovepipe/entity:go_default_library", "//stovepipe/extension/buildrunner:go_default_library", "//stovepipe/extension/buildrunner/mock:go_default_library", + "//stovepipe/extension/sourcecontrol:go_default_library", + "//stovepipe/extension/sourcecontrol/mock:go_default_library", "//stovepipe/extension/storage:go_default_library", "//stovepipe/extension/storage/mock:go_default_library", "@com_github_stretchr_testify//assert:go_default_library", diff --git a/stovepipe/controller/buildsignal/buildsignal.go b/stovepipe/controller/buildsignal/buildsignal.go index e58742c3..4051a6af 100644 --- a/stovepipe/controller/buildsignal/buildsignal.go +++ b/stovepipe/controller/buildsignal/buildsignal.go @@ -24,6 +24,7 @@ import ( "context" "errors" "fmt" + "time" "github.com/uber-go/tally" entityqueue "github.com/uber/submitqueue/platform/base/messagequeue" @@ -33,6 +34,7 @@ import ( stovepipemq "github.com/uber/submitqueue/stovepipe/core/messagequeue" "github.com/uber/submitqueue/stovepipe/entity" "github.com/uber/submitqueue/stovepipe/extension/buildrunner" + "github.com/uber/submitqueue/stovepipe/extension/sourcecontrol" "github.com/uber/submitqueue/stovepipe/extension/storage" "go.uber.org/zap" ) @@ -61,6 +63,7 @@ type Controller struct { metricsScope tally.Scope stores storage.Factory buildRunners buildrunner.Factory + sourceControl sourcecontrol.Factory registry consumer.TopicRegistry topicKey consumer.TopicKey consumerGroup string @@ -72,6 +75,16 @@ var _ consumer.Controller = (*Controller)(nil) // _opName is the metric operation name shared by every emit in this file. const _opName = "buildsignal" +// Option configures a Controller. +type Option func(*Controller) + +// WithSourceControl enables base-change-age metrics for failed builds. +func WithSourceControl(factory sourcecontrol.Factory) Option { + return func(c *Controller) { + c.sourceControl = factory + } +} + // NewController creates a new buildsignal controller. func NewController( logger *zap.SugaredLogger, @@ -81,8 +94,9 @@ func NewController( registry consumer.TopicRegistry, topicKey consumer.TopicKey, consumerGroup string, + options ...Option, ) *Controller { - return &Controller{ + controller := &Controller{ logger: logger.Named("buildsignal_controller"), metricsScope: scope.SubScope("buildsignal_controller"), stores: stores, @@ -91,6 +105,10 @@ func NewController( topicKey: topicKey, consumerGroup: consumerGroup, } + for _, option := range options { + option(controller) + } + return controller } // Process reloads the build referenced by the delivery, polls its runner for @@ -270,10 +288,52 @@ func (c *Controller) markOutcome(ctx context.Context, store storage.Storage, req metrics.NamedCounter(c.metricsScope, _opName, "outcomes", 1, metrics.NewTag("state", string(state)), ) + if state == entity.RequestStateFailed { + c.emitBaseChangeAge(ctx, request) + } return nil } } +func (c *Controller) emitBaseChangeAge(ctx context.Context, request *entity.Request) { + if c.sourceControl == nil || request.BaseURI == "" { + metrics.NamedCounter(c.metricsScope, "build_failure", "base_change_unavailable", 1, + metrics.NewTag("queue", request.Queue), + metrics.NewTag("strategy", string(request.BuildStrategy)), + ) + return + } + + control, err := c.sourceControl.For(sourcecontrol.Config{QueueName: request.Queue}) + if err != nil { + metrics.NamedCounter(c.metricsScope, "build_failure", "change_info_errors", 1, + metrics.NewTag("queue", request.Queue), + metrics.NewTag("stage", "resolve_source_control"), + ) + return + } + info, err := control.ChangeInfo(ctx, request.BaseURI) + if err != nil || info.CreatedAt.IsZero() { + metrics.NamedCounter(c.metricsScope, "build_failure", "change_info_errors", 1, + metrics.NewTag("queue", request.Queue), + metrics.NewTag("stage", "get_change_info"), + ) + return + } + age := time.Since(info.CreatedAt) + if age < 0 { + metrics.NamedCounter(c.metricsScope, "build_failure", "change_info_errors", 1, + metrics.NewTag("queue", request.Queue), + metrics.NewTag("stage", "future_change"), + ) + return + } + metrics.NamedHistogram(c.metricsScope, "build_failure", "time_to_detection", metrics.ChangeAgeBuckets, + metrics.NewTag("queue", request.Queue), + metrics.NewTag("strategy", string(request.BuildStrategy)), + ).RecordDuration(age) +} + // releaseBuildSlot CAS-decrements the queue's in_flight_count, reopening the process // concurrency gate now that this request's build is over. It decrements relatively // (preserving concurrent updates), clamps at zero, and retries on version conflicts. diff --git a/stovepipe/controller/buildsignal/buildsignal_test.go b/stovepipe/controller/buildsignal/buildsignal_test.go index 022ff927..c600e4f4 100644 --- a/stovepipe/controller/buildsignal/buildsignal_test.go +++ b/stovepipe/controller/buildsignal/buildsignal_test.go @@ -18,6 +18,7 @@ import ( "context" "errors" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -31,6 +32,8 @@ import ( "github.com/uber/submitqueue/stovepipe/entity" "github.com/uber/submitqueue/stovepipe/extension/buildrunner" buildrunnermock "github.com/uber/submitqueue/stovepipe/extension/buildrunner/mock" + "github.com/uber/submitqueue/stovepipe/extension/sourcecontrol" + sourcecontrolmock "github.com/uber/submitqueue/stovepipe/extension/sourcecontrol/mock" "github.com/uber/submitqueue/stovepipe/extension/storage" storagemock "github.com/uber/submitqueue/stovepipe/extension/storage/mock" "go.uber.org/mock/gomock" @@ -143,6 +146,38 @@ func expectFinish(m buildsignalMocks, state entity.RequestState) { m.reqStore.EXPECT().Update(gomock.Any(), requestWithState(state), int32(1), int32(2)).Return(nil) } +func TestEmitBaseChangeAge(t *testing.T) { + ctrl := gomock.NewController(t) + scope := tally.NewTestScope("test", nil) + sourceControls := sourcecontrolmock.NewMockFactory(ctrl) + source := sourcecontrolmock.NewMockSourceControl(ctrl) + baseURI := "git://github.com/uber-code/repo/refs%2Fheads%2Fmain/abc" + + sourceControls.EXPECT().For(sourcecontrol.Config{QueueName: testQueue}).Return(source, nil) + source.EXPECT().ChangeInfo(gomock.Any(), baseURI).Return(sourcecontrol.ChangeInfo{ + CreatedAt: time.Now().Add(-time.Hour), + }, nil) + + controller := NewController( + zap.NewNop().Sugar(), + scope, + nil, + nil, + consumer.TopicRegistry{}, + stovepipemq.TopicKeyBuildSignal, + "stovepipe-buildsignal", + WithSourceControl(sourceControls), + ) + controller.emitBaseChangeAge(context.Background(), &entity.Request{ + Queue: testQueue, + BaseURI: baseURI, + BuildStrategy: entity.BuildStrategyIncrementalSinceGreen, + }) + + _, ok := scope.Snapshot().Histograms()["test.buildsignal_controller.build_failure.time_to_detection+queue=monorepo/main,strategy=incremental_since_green"] + assert.True(t, ok) +} + func TestProcess(t *testing.T) { tests := []struct { name string diff --git a/stovepipe/controller/record/BUILD.bazel b/stovepipe/controller/record/BUILD.bazel index a66522e3..ad5f364c 100644 --- a/stovepipe/controller/record/BUILD.bazel +++ b/stovepipe/controller/record/BUILD.bazel @@ -11,6 +11,7 @@ go_library( "//stovepipe/core/loader:go_default_library", "//stovepipe/core/messagequeue:go_default_library", "//stovepipe/entity:go_default_library", + "//stovepipe/extension/observability:go_default_library", "//stovepipe/extension/storage:go_default_library", "@com_github_uber_go_tally//:go_default_library", "@org_uber_go_zap//:go_default_library", diff --git a/stovepipe/controller/record/record.go b/stovepipe/controller/record/record.go index fe009cab..cc8dc95e 100644 --- a/stovepipe/controller/record/record.go +++ b/stovepipe/controller/record/record.go @@ -33,6 +33,7 @@ import ( "github.com/uber/submitqueue/stovepipe/core/loader" stovepipemq "github.com/uber/submitqueue/stovepipe/core/messagequeue" "github.com/uber/submitqueue/stovepipe/entity" + "github.com/uber/submitqueue/stovepipe/extension/observability" "github.com/uber/submitqueue/stovepipe/extension/storage" "go.uber.org/zap" ) @@ -44,6 +45,7 @@ type Controller struct { logger *zap.SugaredLogger metricsScope tally.Scope stores storage.Factory + reporter observability.Reporter topicKey consumer.TopicKey consumerGroup string } @@ -59,6 +61,16 @@ const _opName = "record" // attribution that this stage does not do, so every fact it writes is whole-repository. const wholeRepositoryProject = "" +// Option configures a Controller. +type Option func(*Controller) + +// WithReporter configures best-effort queue observability reporting. +func WithReporter(reporter observability.Reporter) Option { + return func(c *Controller) { + c.reporter = reporter + } +} + // NewController creates a new record controller. func NewController( logger *zap.SugaredLogger, @@ -66,14 +78,19 @@ func NewController( stores storage.Factory, topicKey consumer.TopicKey, consumerGroup string, + options ...Option, ) *Controller { - return &Controller{ + controller := &Controller{ logger: logger.Named("record_controller"), metricsScope: scope.SubScope("record_controller"), stores: stores, topicKey: topicKey, consumerGroup: consumerGroup, } + for _, option := range options { + option(controller) + } + return controller } // Process loads the request referenced by the delivery and, when its build @@ -112,6 +129,9 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er metrics.NamedCounter(c.metricsScope, _opName, "queue_mismatch", 1) return fmt.Errorf("payload queue %q does not match queue %q of request %s", rec.GetQueueName(), request.Queue, request.ID) } + if c.reporter != nil { + defer c.reporter.Report(ctx, request.Queue) + } switch request.State { case entity.RequestStateSucceeded, entity.RequestStateFailed: diff --git a/stovepipe/extension/observability/BUILD.bazel b/stovepipe/extension/observability/BUILD.bazel new file mode 100644 index 00000000..f23b1520 --- /dev/null +++ b/stovepipe/extension/observability/BUILD.bazel @@ -0,0 +1,8 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "go_default_library", + srcs = ["observability.go"], + importpath = "github.com/uber/submitqueue/stovepipe/extension/observability", + visibility = ["//visibility:public"], +) diff --git a/stovepipe/extension/observability/lastgreen/BUILD.bazel b/stovepipe/extension/observability/lastgreen/BUILD.bazel new file mode 100644 index 00000000..813ef94b --- /dev/null +++ b/stovepipe/extension/observability/lastgreen/BUILD.bazel @@ -0,0 +1,31 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["lastgreen.go"], + importpath = "github.com/uber/submitqueue/stovepipe/extension/observability/lastgreen", + visibility = ["//visibility:public"], + deps = [ + "//stovepipe/extension/observability:go_default_library", + "//stovepipe/extension/sourcecontrol:go_default_library", + "//stovepipe/extension/storage:go_default_library", + "@com_github_uber_go_tally//:go_default_library", + ], +) + +go_test( + name = "go_default_test", + srcs = ["lastgreen_test.go"], + embed = [":go_default_library"], + deps = [ + "//stovepipe/entity:go_default_library", + "//stovepipe/extension/sourcecontrol:go_default_library", + "//stovepipe/extension/sourcecontrol/mock:go_default_library", + "//stovepipe/extension/storage:go_default_library", + "//stovepipe/extension/storage/mock:go_default_library", + "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + "@com_github_uber_go_tally//:go_default_library", + "@org_uber_go_mock//gomock:go_default_library", + ], +) diff --git a/stovepipe/extension/observability/lastgreen/lastgreen.go b/stovepipe/extension/observability/lastgreen/lastgreen.go new file mode 100644 index 00000000..73d3304c --- /dev/null +++ b/stovepipe/extension/observability/lastgreen/lastgreen.go @@ -0,0 +1,73 @@ +// Package lastgreen reports the age of Stovepipe's last-known-green changes. +package lastgreen + +import ( + "context" + "time" + + "github.com/uber-go/tally" + "github.com/uber/submitqueue/stovepipe/extension/observability" + "github.com/uber/submitqueue/stovepipe/extension/sourcecontrol" + "github.com/uber/submitqueue/stovepipe/extension/storage" +) + +type reporter struct { + scope tally.Scope + stores storage.Factory + sourceControl sourcecontrol.Factory +} + +var _ observability.Reporter = (*reporter)(nil) + +// New creates a Reporter for a queue's last-known-green age. +func New(scope tally.Scope, stores storage.Factory, sourceControl sourcecontrol.Factory) observability.Reporter { + return &reporter{ + scope: scope.SubScope("last_green"), + stores: stores, + sourceControl: sourceControl, + } +} + +// Report updates the queue's current last-known-green age gauge. +func (r *reporter) Report(ctx context.Context, queue string) { + tags := map[string]string{"queue": queue} + store, err := r.stores.For(storage.Config{QueueName: queue}) + if err != nil { + r.error(tags, "resolve_storage") + return + } + queueRow, err := store.GetQueueStore().Get(ctx, queue) + if err != nil { + r.error(tags, "get_queue") + return + } + if queueRow.LastGreenURI == "" { + r.scope.Tagged(tags).Counter("age_missing").Inc(1) + return + } + control, err := r.sourceControl.For(sourcecontrol.Config{QueueName: queue}) + if err != nil { + r.error(tags, "resolve_source_control") + return + } + info, err := control.ChangeInfo(ctx, queueRow.LastGreenURI) + if err != nil || info.CreatedAt.IsZero() { + r.error(tags, "get_change_info") + return + } + age := time.Since(info.CreatedAt) + if age < 0 { + r.error(tags, "future_change") + return + } + r.scope.Tagged(tags).Gauge("age_seconds").Update(age.Seconds()) +} + +func (r *reporter) error(tags map[string]string, stage string) { + tagsCopy := make(map[string]string, len(tags)+1) + for key, value := range tags { + tagsCopy[key] = value + } + tagsCopy["stage"] = stage + r.scope.Tagged(tagsCopy).Counter("age_errors").Inc(1) +} diff --git a/stovepipe/extension/observability/lastgreen/lastgreen_test.go b/stovepipe/extension/observability/lastgreen/lastgreen_test.go new file mode 100644 index 00000000..9f3686cc --- /dev/null +++ b/stovepipe/extension/observability/lastgreen/lastgreen_test.go @@ -0,0 +1,65 @@ +package lastgreen + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber-go/tally" + "github.com/uber/submitqueue/stovepipe/entity" + sourcecontrol "github.com/uber/submitqueue/stovepipe/extension/sourcecontrol" + sourcecontrolmock "github.com/uber/submitqueue/stovepipe/extension/sourcecontrol/mock" + "github.com/uber/submitqueue/stovepipe/extension/storage" + storagemock "github.com/uber/submitqueue/stovepipe/extension/storage/mock" + "go.uber.org/mock/gomock" +) + +const testQueue = "monorepo/main" + +func TestReport_EmitsLastGreenAge(t *testing.T) { + ctrl := gomock.NewController(t) + scope := tally.NewTestScope("stovepipe", nil) + stores := storagemock.NewMockFactory(ctrl) + store := storagemock.NewMockStorage(ctrl) + queueStore := storagemock.NewMockQueueStore(ctrl) + sourceControls := sourcecontrolmock.NewMockFactory(ctrl) + source := sourcecontrolmock.NewMockSourceControl(ctrl) + createdAt := time.Now().Add(-time.Hour) + + stores.EXPECT().For(storage.Config{QueueName: testQueue}).Return(store, nil) + store.EXPECT().GetQueueStore().Return(queueStore) + queueStore.EXPECT().Get(gomock.Any(), testQueue).Return(entity.Queue{ + Name: testQueue, + LastGreenURI: "git://github.com/uber-code/repo/refs%2Fheads%2Fmain/abc", + }, nil) + sourceControls.EXPECT().For(sourcecontrol.Config{QueueName: testQueue}).Return(source, nil) + source.EXPECT().ChangeInfo(gomock.Any(), gomock.Any()).Return(sourcecontrol.ChangeInfo{ + CreatedAt: createdAt, + }, nil) + + New(scope, stores, sourceControls).Report(context.Background(), testQueue) + + gauge, ok := scope.Snapshot().Gauges()["stovepipe.last_green.age_seconds+queue=monorepo/main"] + require.True(t, ok) + assert.InDelta(t, time.Since(createdAt).Seconds(), gauge.Value(), 1) +} + +func TestReport_RecordsMissingLastGreen(t *testing.T) { + ctrl := gomock.NewController(t) + scope := tally.NewTestScope("stovepipe", nil) + stores := storagemock.NewMockFactory(ctrl) + store := storagemock.NewMockStorage(ctrl) + queueStore := storagemock.NewMockQueueStore(ctrl) + + stores.EXPECT().For(storage.Config{QueueName: testQueue}).Return(store, nil) + store.EXPECT().GetQueueStore().Return(queueStore) + queueStore.EXPECT().Get(gomock.Any(), testQueue).Return(entity.Queue{Name: testQueue}, nil) + + New(scope, stores, sourcecontrolmock.NewMockFactory(ctrl)).Report(context.Background(), testQueue) + + counter, ok := scope.Snapshot().Counters()["stovepipe.last_green.age_missing+queue=monorepo/main"] + require.True(t, ok) + assert.EqualValues(t, 1, counter.Value()) +} diff --git a/stovepipe/extension/observability/observability.go b/stovepipe/extension/observability/observability.go new file mode 100644 index 00000000..b6063ea3 --- /dev/null +++ b/stovepipe/extension/observability/observability.go @@ -0,0 +1,9 @@ +// Package observability defines Stovepipe's metrics reporting boundary. +package observability + +import "context" + +// Reporter emits best-effort observability data for a queue. +type Reporter interface { + Report(context.Context, string) +} diff --git a/stovepipe/extension/sourcecontrol/fake/fake.go b/stovepipe/extension/sourcecontrol/fake/fake.go index ae57a8c1..2fd4ba53 100644 --- a/stovepipe/extension/sourcecontrol/fake/fake.go +++ b/stovepipe/extension/sourcecontrol/fake/fake.go @@ -21,6 +21,7 @@ package fake import ( "context" + "time" "github.com/uber/submitqueue/platform/base/page" "github.com/uber/submitqueue/stovepipe/extension/sourcecontrol" @@ -94,6 +95,14 @@ func (s sourceControlFake) History(_ context.Context, cursor string, limit int) return page.Page[string]{Items: uris, NextCursor: next}, nil } +// ChangeInfo returns deterministic metadata for a URI in the fake history. +func (s sourceControlFake) ChangeInfo(_ context.Context, uri string) (sourcecontrol.ChangeInfo, error) { + if s.indexOf(uri) < 0 { + return sourcecontrol.ChangeInfo{}, sourcecontrol.ErrNotFound + } + return sourcecontrol.ChangeInfo{CreatedAt: time.Unix(1, 0).UTC()}, nil +} + // indexOf returns the index of uri in the history, or -1 if absent. func (s sourceControlFake) indexOf(uri string) int { for i, u := range s.history { diff --git a/stovepipe/extension/sourcecontrol/mock/sourcecontrol_mock.go b/stovepipe/extension/sourcecontrol/mock/sourcecontrol_mock.go index 92d52526..a7a3c05f 100644 --- a/stovepipe/extension/sourcecontrol/mock/sourcecontrol_mock.go +++ b/stovepipe/extension/sourcecontrol/mock/sourcecontrol_mock.go @@ -42,6 +42,21 @@ func (m *MockSourceControl) EXPECT() *MockSourceControlMockRecorder { return m.recorder } +// ChangeInfo mocks base method. +func (m *MockSourceControl) ChangeInfo(ctx context.Context, uri string) (sourcecontrol.ChangeInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ChangeInfo", ctx, uri) + ret0, _ := ret[0].(sourcecontrol.ChangeInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ChangeInfo indicates an expected call of ChangeInfo. +func (mr *MockSourceControlMockRecorder) ChangeInfo(ctx, uri any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChangeInfo", reflect.TypeOf((*MockSourceControl)(nil).ChangeInfo), ctx, uri) +} + // History mocks base method. func (m *MockSourceControl) History(ctx context.Context, cursor string, limit int) (page.Page[string], error) { m.ctrl.T.Helper() diff --git a/stovepipe/extension/sourcecontrol/sourcecontrol.go b/stovepipe/extension/sourcecontrol/sourcecontrol.go index d61a1013..b7fd0331 100644 --- a/stovepipe/extension/sourcecontrol/sourcecontrol.go +++ b/stovepipe/extension/sourcecontrol/sourcecontrol.go @@ -28,10 +28,19 @@ import ( "context" "errors" "fmt" + "time" "github.com/uber/submitqueue/platform/base/page" ) +// ChangeInfo describes immutable metadata about the source-control change +// represented by a URI. +type ChangeInfo struct { + // CreatedAt is when the source-control provider recorded the immutable + // change. It does not represent ref-update or fetch time. + CreatedAt time.Time +} + // ErrNotFound is returned when a queue, ref, or URI cannot be resolved by the // implementation (for example an unknown queue, or an ancestry query referencing // a URI that is not on the ref). @@ -71,6 +80,10 @@ type SourceControl interface { // the greenness/status of each commit. Returns ErrNotFound if the cursor does // not refer to a position on the ref. History(ctx context.Context, cursor string, limit int) (page.Page[string], error) + + // ChangeInfo returns immutable metadata for uri. The returned CreatedAt must + // be non-zero. Returns ErrNotFound when uri cannot be resolved. + ChangeInfo(ctx context.Context, uri string) (ChangeInfo, error) } // Config carries the per-queue identity handed to a Factory. The system knows