Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions api/stovepipe/proto/stovepipe.proto
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,10 @@ message RequestSummary {
int64 accepted_at_ms = 7;
// Stable reason for the represented state. Empty when unavailable or inapplicable.
string outcome_reason = 8;
// Total recorded project results; zero when no project results have been recorded.
int32 project_count = 9;
// Recorded project results with degree above zero.
int32 broken_project_count = 10;
}

// ListRequest selects a page of retained request summaries for one queue.
Expand Down
29 changes: 25 additions & 4 deletions api/stovepipe/protopb/stovepipe.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

174 changes: 88 additions & 86 deletions api/stovepipe/protopb/stovepipe.pb.yarpc.go

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion doc/rfc/stovepipe/list-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,18 @@ This is the wire representation of the existing domain `RequestSummary`, not ano
| `state_updated_at_ms` | int64 | Timestamp of the represented state entry. |
| `accepted_at_ms` | int64 | Timestamp of the original retained accepted entry; `0` means unknown, otherwise positive and immutable. |
| `outcome_reason` | string | Reason for the represented state; empty when unavailable or inapplicable. |
| `project_count` | int32 | Total recorded project results; zero means none recorded. |
| `broken_project_count` | int32 | Recorded project results with degree above zero. |

The domain already defines a typed [RequestState](../../../stovepipe/entity/request.go). The wire field remains a string to match existing status/history APIs. States and reasons use the existing [public vocabulary](request-log.md#outcome-reasons); clients tolerate future values. Duplicate Ingest calls resolving to the same request produce one row.

## Data and Storage

All listed fields are materialized in [RequestSummary](../../../stovepipe/entity/request_summary.go), including acceptance time and outcome reason from retained logs. Acceptance time is acceptance-log time, not first RPC receipt time. No new producer signal or per-queue counter is needed.
All listed fields are materialized in [RequestSummary](../../../stovepipe/entity/request_summary.go), including acceptance time and outcome reason from retained logs. Acceptance time is acceptance-log time, not first RPC receipt time. The `project_facts_recorded` event supplies total and broken counts after all project facts and their indexes are durable. Counts follow persisted facts, including adopted immutable facts, rather than retry-time resolver answers. No per-queue counter is needed.

Counts default to zero before project results are recorded, for empty results, and for older summaries without counts. Clients can show the ratio when `project_count` is positive. Lifecycle updates preserve counts, and completion projection preserves lifecycle timestamps and outcome reasons.

Existing databases must add the defaulted `project_count` and `broken_project_count` summary columns before deploying readers or writers that use them. Defaults leave older counts at zero; historical count backfill is not automatic.

Listing uses an immutable mapping keyed by `(queue, accepted_at_ms, request_id)` for known positive acceptance times, then point-reads the corresponding summaries. This adds one small record per request and bounded extra reads, not another mutable status projection. Existing summary keys and public request IDs remain unchanged; no numeric-key migration is needed.

Expand Down
2 changes: 1 addition & 1 deletion doc/rfc/stovepipe/request-history-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Request-ID lookup validates the queue and ID, then reads that queue's `RequestLo

At least one retained log record defines the existence of a history. A summary alone does not establish a retained history, and the operational `Request` entity is not required for either read. A returned summary must match the selected queue and request ID; disagreement is an infrastructure consistency error.

The summary is the existing current lifecycle projection maintained by the request-log materializer. It includes request identity, queue, commit URI, baseline URI, current state, state timestamp, acceptance timestamp, and outcome reason. The mapper reuses the public summary representation exposed by List, omitting internal storage versions.
The summary is the existing current lifecycle projection maintained by the request-log materializer. It includes request identity, queue, commit URI, baseline URI, current state, state timestamp, acceptance timestamp, outcome reason, and recorded project outcome counts. The mapper reuses the public summary representation exposed by List, omitting internal storage versions.

History is read before its summary. The two reads are eventually consistent, not a transactional snapshot: the current summary can represent newer state than the retained events returned by the earlier read, or lag a newly appended state event. The API does not replay the returned events to derive a summary or fabricate missing occurrences from current state.

Expand Down
2 changes: 1 addition & 1 deletion doc/rfc/stovepipe/request-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ Immutable Request context such as URI, build strategy, base URI, and terminal bu
| `build_analysis_abandoned` | Build analysis could not complete; issue observations may be incomplete. This is not a validation verdict. | Build ID metadata and event retention time |
| `issue_tracking_abandoned` | Issue tracking stopped before completing a pass; existing issue changes remain, but reconciliation may be incomplete. This is not a validation verdict. | Build ID metadata and event retention time |

Build running and unchanged polls are not retained. Trigger and terminal result explain the request outcome without turning polling into an unbounded log. Individual project facts remain outside the vocabulary; one batch-completion event summarizes their durable recording.
Build running and unchanged polls are not retained. Trigger and terminal result explain the request outcome without turning polling into an unbounded log. Individual project facts remain outside the vocabulary; one batch-completion event summarizes their durable recording. Its `project_fact_count` and `broken_project_fact_count` metadata contain the total and the number with degree above zero, counted from persisted facts after their indexes are durable. Project completion projection requires both counts and assigns them together in `RequestSummary` from the retained event.

### Evolution

Expand Down
2 changes: 1 addition & 1 deletion doc/rfc/stovepipe/steps/record.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ Whole-repository degree depends on the outcome `buildsignal` stamped on the Requ
| `cancelled` | no fact |


`DegreePartial` is hard coded to `0.5` for now; it does not measure the fraction of projects failing. Each project fact preserves the degree returned by the resolver.
`DegreePartial` is hard coded to `0.5` for now; it does not measure the fraction of projects failing. Each project fact preserves the degree returned by the resolver, unless its immutable identity already exists. After persisting every project fact and its degree index, `record` retains total and broken project counts in `project_facts_recorded` for the request summary. Broken means a persisted degree above zero; retries count adopted facts rather than contradictory resolver results. Empty results record zero counts.

## Last-green advancement (Queue bookmark)

Expand Down
18 changes: 10 additions & 8 deletions service/stovepipe/server/mapper/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,15 @@ func ListResultToProto(result entity.ListResult) *pb.ListResponse {
// RequestSummaryToProto exposes public summary fields, omitting internal storage versions.
func RequestSummaryToProto(summary entity.RequestSummary) *pb.RequestSummary {
return &pb.RequestSummary{
RequestId: summary.RequestID,
Queue: summary.Queue,
ChangeUri: summary.URI,
BaseUri: summary.BaseURI,
RequestState: string(summary.State),
StateUpdatedAtMs: summary.StateTimestampMs,
AcceptedAtMs: summary.AcceptedAtMs,
OutcomeReason: string(summary.OutcomeReason),
RequestId: summary.RequestID,
Queue: summary.Queue,
ChangeUri: summary.URI,
BaseUri: summary.BaseURI,
RequestState: string(summary.State),
StateUpdatedAtMs: summary.StateTimestampMs,
AcceptedAtMs: summary.AcceptedAtMs,
OutcomeReason: string(summary.OutcomeReason),
ProjectCount: summary.ProjectCount,
BrokenProjectCount: summary.BrokenProjectCount,
}
}
19 changes: 19 additions & 0 deletions service/stovepipe/server/mapper/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,22 @@ func TestListResultToProto(t *testing.T) {
})
}
}

func TestListResultProjectCounts(t *testing.T) {
for _, tt := range []struct {
name string
count, broken int32
}{
{name: "no recorded project facts"},
{name: "all green", count: 3},
{name: "mixed outcomes", count: 3, broken: 1},
} {
t.Run(tt.name, func(t *testing.T) {
response := ListResultToProto(entity.ListResult{Requests: []entity.RequestSummary{{
RequestID: "7", ProjectCount: tt.count, BrokenProjectCount: tt.broken,
}}})
require.Len(t, response.Requests, 1)
assert.Equal(t, &pb.RequestSummary{RequestId: "7", ProjectCount: tt.count, BrokenProjectCount: tt.broken}, response.Requests[0])
})
}
}
2 changes: 2 additions & 0 deletions service/stovepipe/server/mapper/request_history_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ func TestRequestHistorySummaryMapping(t *testing.T) {
RequestSummary: entity.RequestSummary{
RequestID: "7", Queue: "queue", URI: "uri", BaseURI: "base", State: "future-state",
StateTimestampMs: 200, AcceptedAtMs: 100, OutcomeReason: "future-reason",
ProjectCount: 3, BrokenProjectCount: 1,
},
}
byID := RequestHistoryByIDToProto(history)
Expand All @@ -188,6 +189,7 @@ func TestRequestHistorySummaryMapping(t *testing.T) {
want := &pb.RequestSummary{
RequestId: "7", Queue: "queue", ChangeUri: "uri", BaseUri: "base", RequestState: "future-state",
StateUpdatedAtMs: 200, AcceptedAtMs: 100, OutcomeReason: "future-reason",
ProjectCount: 3, BrokenProjectCount: 1,
}
assert.Equal(t, want, byID.RequestSummary)
assert.Equal(t, want, byURI.RequestSummary)
Expand Down
13 changes: 10 additions & 3 deletions stovepipe/controller/record/record.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ func (c *Controller) persistProjectFacts(ctx context.Context, store storage.Stor
op := metrics.Begin(c.metricsScope, "persist_project_facts", metrics.LongLatencyBuckets, metrics.TagsFromContext(ctx)...)
defer func() { op.Complete(retErr) }()

brokenCount := 0
for _, result := range results {
fact, _, err := recordValidationFact(ctx, c.logger, c.metricsScope, store.GetValidationFactStore(), entity.ValidationFact{
URI: request.URI,
Expand All @@ -220,6 +221,9 @@ func (c *Controller) persistProjectFacts(ctx context.Context, store storage.Stor
if err != nil {
return err
}
if !fact.IsGreen() {
brokenCount++
}

entry, err := entity.NewProjectFactByDegree(fact)
if err != nil {
Expand All @@ -230,7 +234,7 @@ func (c *Controller) persistProjectFacts(ctx context.Context, store storage.Stor
return fmt.Errorf("failed to index project fact for request %q and project %q: %w", request.ID, fact.Project, err)
}
}
return c.persistProjectFactsRecordedLog(ctx, store, request, len(results))
return c.persistProjectFactsRecordedLog(ctx, store, request, len(results), brokenCount)
}

func validateProjectStatusResults(request entity.Request, results []projectstatus.Result) error {
Expand All @@ -254,13 +258,16 @@ func (c *Controller) persistProjectFactsRecordedLog(
ctx context.Context,
store storage.Storage,
request entity.Request,
count int,
count, brokenCount int,
) error {
log := requestlog.NewRequestEventLog(
request,
entity.RequestEventProjectFactsRecorded,
"all",
map[string]string{requestlog.MetadataKeyProjectFactCount: strconv.Itoa(count)},
map[string]string{
requestlog.MetadataKeyProjectFactCount: strconv.Itoa(count),
requestlog.MetadataKeyBrokenProjectFactCount: strconv.Itoa(brokenCount),
},
)
if err := c.materializer.PersistLog(ctx, store, log); err != nil {
return fmt.Errorf("failed to record project facts for request %s: %w", request.ID, err)
Expand Down
44 changes: 43 additions & 1 deletion stovepipe/controller/record/record_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ func TestProcess_RecordsNamedProjectStatusResults(t *testing.T) {
gomock.Any(), requestWithState(entity.RequestStateFailed), "bk-1",
).Return([]projectstatus.Result{
{Project: "project-a", Degree: entity.DegreeBroken},
{Project: "project-b", Degree: entity.DegreeBroken},
{Project: "project-b", Degree: entity.DegreeGreen},
}, nil)
m.reqStore.EXPECT().Get(gomock.Any(), testID).Return(requestWithState(entity.RequestStateFailed), nil)

Expand Down Expand Up @@ -379,6 +379,8 @@ func TestProcess_RecordsNamedProjectStatusResults(t *testing.T) {
assert.Equal(t, testID, fact.RequestID)
if fact.Project == "" {
assert.Equal(t, entity.DegreePartial, fact.Degree)
} else if fact.Project == "project-b" {
assert.Equal(t, entity.DegreeGreen, fact.Degree)
} else {
assert.Equal(t, entity.DegreeBroken, fact.Degree)
}
Expand All @@ -390,6 +392,7 @@ func TestProcess_RecordsNamedProjectStatusResults(t *testing.T) {
assert.Equal(t, "2", logs[1].Metadata[requestlog.MetadataKeyProjectFactCount])
assertRecordOperationMetrics(t, m.metricsScope, "resolve_project_results", "success")
assertRecordOperationMetrics(t, m.metricsScope, "persist_project_facts", "success")
assert.Equal(t, "1", logs[1].Metadata[requestlog.MetadataKeyBrokenProjectFactCount])
}

func TestRecordProjectFacts_PassesEmptyTerminalBuild(t *testing.T) {
Expand Down Expand Up @@ -608,6 +611,7 @@ func TestProcessRecordsEmptyProjectCompletion(t *testing.T) {
func(_ context.Context, _ storage.Storage, log entity.RequestLog) error {
assert.Equal(t, "event/project_facts_recorded/all", log.ID)
assert.Equal(t, "0", log.Metadata[requestlog.MetadataKeyProjectFactCount])
assert.Equal(t, "0", log.Metadata[requestlog.MetadataKeyBrokenProjectFactCount])
return tt.writeErr
}),
)
Expand Down Expand Up @@ -1498,3 +1502,41 @@ func TestProcessRetriesProjectPersistenceWithImmutablePartialFact(t *testing.T)
require.NoError(t, c.Process(queueContext(), delivery(t, ctrl, recordPayload(t, testID))))
assert.Len(t, m.hooks.events, 1)
}

func TestPersistProjectFactsCountsPersistedOutcomes(t *testing.T) {
for _, tt := range []struct {
name string
storedDegree, resolvedDegree float64
broken string
}{
{name: "new green", storedDegree: entity.DegreeGreen, resolvedDegree: entity.DegreeGreen, broken: "0"},
{name: "new partially broken", storedDegree: 0.25, resolvedDegree: 0.25, broken: "1"},
{name: "new fully broken", storedDegree: entity.DegreeBroken, resolvedDegree: entity.DegreeBroken, broken: "1"},
{name: "adopts existing green", storedDegree: entity.DegreeGreen, resolvedDegree: entity.DegreeBroken, broken: "0"},
{name: "adopts existing broken", storedDegree: entity.DegreeBroken, resolvedDegree: entity.DegreeGreen, broken: "1"},
} {
t.Run(tt.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
c, m := newController(t, ctrl)
request := requestWithState(entity.RequestStateFailed)
stored := entity.ValidationFact{URI: request.URI, Project: "p", Degree: tt.storedDegree, RequestID: request.ID, CreatedAt: 10}
if tt.storedDegree != tt.resolvedDegree {
m.factStore.EXPECT().Create(gomock.Any(), gomock.Any()).Return(storage.ErrAlreadyExists)
m.factStore.EXPECT().Get(gomock.Any(), request.URI, "p").Return(stored, nil)
} else {
m.factStore.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil)
}
m.indexStore.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil)
materializer := requestlogmock.NewMockMaterializer(ctrl)
c.materializer = materializer
materializer.EXPECT().PersistLog(gomock.Any(), m.store, gomock.Any()).DoAndReturn(func(_ context.Context, _ storage.Storage, log entity.RequestLog) error {
assert.Equal(t, map[string]string{
requestlog.MetadataKeyProjectFactCount: "1",
requestlog.MetadataKeyBrokenProjectFactCount: tt.broken,
}, log.Metadata)
return nil
})
require.NoError(t, c.persistProjectFacts(queueContext(), m.store, request, []projectstatus.Result{{Project: "p", Degree: tt.resolvedDegree}}))
})
}
}
2 changes: 2 additions & 0 deletions stovepipe/core/requestlog/materializer.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ const (
MetadataKeyFactDegree = "fact_degree"
// MetadataKeyProjectFactCount records how many project facts a completed batch established.
MetadataKeyProjectFactCount = "project_fact_count"
// MetadataKeyBrokenProjectFactCount records how many persisted project facts have a nonzero degree.
MetadataKeyBrokenProjectFactCount = "broken_project_fact_count"
// MetadataKeyBaseURI records the selected build baseline; an empty value means a full build.
MetadataKeyBaseURI = "base_uri"
)
Expand Down
Loading
Loading