Skip to content

Commit d4ed8ca

Browse files
authored
feat(stovepipe): add request log materializer (#657)
## Summary Intent: - Provide one direct, idempotent materialization path for durable request state logs. - Keep the initial rollout limited to state entries while preserving a boundary for future request summaries and other projections. Changes: - Add `Materializer.PersistLog` over the queue-scoped storage aggregate. - Compose readable stable occurrence IDs with `publish.IntentID`. - Deduplicate retained occurrences because Stovepipe writes directly; SubmitQueue instead deduplicates the message carrying a log to its materializer. - Preserve caller-supplied timestamps, assign missing timestamps, and reconcile duplicate writes by retained semantic content. - Emit bounded metrics with context-derived tags. - Document how future projections can be added within the materializer without changing callers. ## Test Plan - `./tool/bazel test //stovepipe/core/requestlog:go_default_test --cache_test_results=no` - `make check-gazelle` - `make lint` ## Revert Plan - Revert this PR. No production call site depends on the materializer until the next PR in the stack lands. ## Issues ## Stack 1. @ #657 1. #658
1 parent 8c16e6a commit d4ed8ca

9 files changed

Lines changed: 498 additions & 14 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -579,7 +579,7 @@ local-stovepipe-stop: ## Stop the Stovepipe service
579579

580580
mocks: ## Generate mock files using mockgen
581581
@echo "Generating mocks..."
582-
@$(BAZEL) run @rules_go//go -- generate ./submitqueue/extension/storage/... ./submitqueue/extension/buildrunner/... ./submitqueue/extension/changeprovider/... ./platform/extension/counter/... ./platform/extension/consumergate/... ./platform/extension/hook/... ./platform/extension/messagequeue/... ./submitqueue/extension/queueconfig/... ./submitqueue/extension/mergechecker/... ./submitqueue/extension/conflict/... ./submitqueue/extension/speculation/... ./submitqueue/extension/validator/... ./platform/consumer/... ./stovepipe/extension/storage/... ./stovepipe/extension/sourcecontrol/...
582+
@$(BAZEL) run @rules_go//go -- generate ./submitqueue/extension/storage/... ./submitqueue/extension/buildrunner/... ./submitqueue/extension/changeprovider/... ./platform/extension/counter/... ./platform/extension/consumergate/... ./platform/extension/hook/... ./platform/extension/messagequeue/... ./submitqueue/extension/queueconfig/... ./submitqueue/extension/mergechecker/... ./submitqueue/extension/conflict/... ./submitqueue/extension/speculation/... ./submitqueue/extension/validator/... ./platform/consumer/... ./stovepipe/core/requestlog/... ./stovepipe/extension/storage/... ./stovepipe/extension/sourcecontrol/...
583583
@echo "Mocks generated successfully!"
584584

585585
proto: ## Generate protobuf files from .proto definitions

doc/rfc/stovepipe/request-history-api.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,9 @@ Event rows remain in SubmitQueue history but never participate in current-status
107107

108108
Stovepipe has no equivalent ownership gap. The same service owns the queue-scoped `Request`, `ValidationFact`, request-URI mapping, and request-log store. Operational reads use their owning entities, while request history reads retained log records directly.
109109

110-
Stovepipe therefore does not add `RequestSummary`, replay history to determine current state, or materialize another history table. The controller performs only an in-memory wire projection from stored log records to protobuf messages. This avoids a second winner-selection algorithm competing with Request CAS state.
110+
Stovepipe calls `requestlog.Materializer.PersistLog` directly, without an intermediate topic. Its initial materializer appends only the request log: it does not add `RequestSummary`, replay history to determine current state, or materialize another history table. The controller performs only an in-memory wire projection from stored log records to protobuf messages. This avoids a second winner-selection algorithm competing with Request CAS state.
111+
112+
`PersistLog` receives the whole queue-scoped storage aggregate so a future current-status or queue-list API can add SubmitQueue-style summary and index projections behind the same write boundary without changing request-log producers. Such projections remain deferred until a concrete read path needs them; `Request` remains authoritative in the initial implementation.
111113

112114
## Ordering and Consistency
113115

doc/rfc/stovepipe/request-log.md

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Summary
44

5-
Stovepipe retains an append-only request log for each validation request. Its internal `RequestLog` is the counterpart of SubmitQueue's `RequestLog`: both retain request status changes and explanatory lifecycle events, while Stovepipe persists records directly instead of sending them through a cross-service log topic and materializer. The public API presents these records as request history. The log records every durable `Request.State` transition plus three asynchronous milestones needed to explain those transitions and the public verdict:
5+
Stovepipe retains an append-only request log for each validation request. Its internal `RequestLog` is the counterpart of SubmitQueue's `RequestLog`: both retain request status changes and explanatory lifecycle events, while Stovepipe calls its materializer directly instead of sending records through a cross-service log topic. The public API presents these records as request history. The log records every durable `Request.State` transition plus three asynchronous milestones needed to explain those transitions and the public verdict:
66

77
- `build_triggered`;
88
- `build_finished`;
@@ -73,13 +73,13 @@ The retained unit is `entity.RequestLog`:
7373

7474
```go
7575
type RequestLog struct {
76-
// ID is the stable identity of one logical occurrence within the request. It is opaque, stable across redelivery, and never derived from time or randomness.
76+
// ID is the stable identity of one logical occurrence within the request. It is stable across redelivery and never derived from time or randomness.
7777
ID string
7878
// Queue is the logical queue containing the request and scopes RequestID.
7979
Queue string
8080
// RequestID identifies the request whose log contains this record.
8181
RequestID string
82-
// TimestampMs is the durable occurrence time in Unix milliseconds.
82+
// TimestampMs is when the occurrence was first retained, in Unix milliseconds.
8383
TimestampMs int64
8484
// State is the durable request state recorded by a state entry. It is unset on an event entry.
8585
State RequestState
@@ -145,7 +145,9 @@ Terminal entries retain domain reasons rather than transport mechanisms. Initial
145145

146146
## Stable IDs and Idempotency
147147

148-
`stovepipe/core/requestlog.Recorder` constructs opaque IDs from durable identities:
148+
`stovepipe/core/requestlog` composes readable IDs from durable identities with the same `publish.IntentID` convention used by SubmitQueue before passing each record to `Materializer.PersistLog`. The surrounding `(queue, request_id)` storage key scopes the ID to one request, so a request state entry uses `state/<request-version>` without repeating the request identity:
149+
150+
SubmitQueue applies that identity to the message carrying a log to its materializer, while its log store remains append-only and may retain a duplicate after a later materialization step fails. Stovepipe has no intermediate log topic, so it applies the identity to the retained row itself: retrying the direct call reloads the existing occurrence and succeeds only when its semantic content is compatible.
149151

150152
| Entry | Stable identity inputs |
151153
|---|---|
@@ -154,7 +156,7 @@ Terminal entries retain domain reasons rather than transport mechanisms. Initial
154156
| Build finished | Request ID, event kind, and build ID |
155157
| Validation fact recorded | Request ID, event kind, and whole-repository fact identity |
156158

157-
The recorder calls `RequestLogStore.Create`. If the ID already exists, it loads the stored record and compares every semantic field. Identical content is idempotent success; conflicting content is an internal consistency error, and the stored record is never overwritten.
159+
The controller passes the materializer the same queue-scoped storage aggregate used for the source write. The materializer preserves a supplied occurrence time or assigns the current time immediately before the first insertion attempt, then calls `RequestLogStore.Create`. If the ID already exists, it loads the stored record and compares the explicitly designated stable semantic fields. The first successfully retained timestamp is authoritative and is not compared with a later retry's newly sampled time. Metadata keys emitted by both records must agree, while a key present on only one record remains compatible so an additive metadata rollout does not turn retries of older occurrences into conflicts. Compatible content is idempotent success; conflicting content is an internal consistency error, and the stored record is never overwritten or enriched.
158160

159161
## Storage Contract
160162

@@ -188,11 +190,11 @@ Request-log durability is part of completing a pipeline transition. The source w
188190

189191
For a Request transition, the controller:
190192

191-
1. builds an immutable updated copy with transition context and `StateChangedAtMs`;
193+
1. builds an immutable updated copy for the state transition;
192194
2. computes `newVersion = oldVersion + 1`;
193195
3. calls `RequestStore.Update(updated, oldVersion, newVersion)`;
194196
4. assigns the in-memory version only after the store succeeds;
195-
5. asks the recorder to create the log record from durable Request data;
197+
5. constructs the log record from the durable Request and bounded context still owned by that stage, then calls `Materializer.PersistLog`;
196198
6. publishes the downstream handoff.
197199

198200
Request creation, Build changes, and fact creation use the same source-write, log-write, dependent-publish ordering. A request-log outage can leave a source update visible, but it cannot allow dependent processing to move past an unrecorded transition.
@@ -224,7 +226,7 @@ This is rollout work, not deferred cleanup: a mandatory request log without a du
224226

225227
Rollout therefore:
226228

227-
1. deploys source timestamp and provenance fields, request-log storage, recorder, and readers;
229+
1. deploys request-log storage, the materializer, and readers;
228230
2. enables writers and verifies every repair path stage by stage;
229231
3. enables the public API after every writer and repair path is active.
230232

@@ -246,11 +248,11 @@ Identifiers, outcome reasons, and reasonable per-request build counts have expli
246248

247249
Contract tests cover required-field validation, stable IDs, idempotent create/reload, conflict detection, queue binding, deterministic ordering, equal-timestamp tie breaking, and empty histories.
248250

249-
Writer tests cover source success followed by log failure, redelivery with the log record absent or present, CAS loss, conflicting terminal writers, downstream publish failure, stable timestamps, and controller-owned version arithmetic. End-to-end tests cover successful, failed, cancelled, superseded, and fail-closed paths plus idempotent redelivery.
251+
Writer tests cover source success followed by log failure, redelivery with the log record absent or present, CAS loss, conflicting terminal writers, downstream publish failure, first-insert timestamp reuse, and controller-owned version arithmetic. End-to-end tests cover successful, failed, cancelled, superseded, and fail-closed paths plus idempotent redelivery.
250252

251253
Tests reconstruct the latest Request state from state entries by request version and compare it with `RequestStore.Get`. They separately verify that only a durable fact produces green or broken.
252254

253-
The recorder reports create, identical-existing, conflict, validation failure, and storage failure counters tagged only by bounded state or event. IDs, URIs, and errors remain structured log fields rather than metric tags. Alerts cover sustained repair gaps and content conflicts.
255+
The materializer reports create, identical-existing, conflict, validation failure, and storage failure counters tagged only by bounded state or event plus tags carried by the context. IDs, URIs, and errors remain structured log fields rather than metric tags. Alerts cover sustained repair gaps and content conflicts.
254256

255257
## Alternatives Considered
256258

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "go_default_library",
5+
srcs = ["materializer.go"],
6+
importpath = "github.com/uber/submitqueue/stovepipe/core/requestlog",
7+
visibility = ["//visibility:public"],
8+
deps = [
9+
"//platform/metrics:go_default_library",
10+
"//platform/publish:go_default_library",
11+
"//stovepipe/entity:go_default_library",
12+
"//stovepipe/extension/storage:go_default_library",
13+
"@com_github_uber_go_tally//:go_default_library",
14+
],
15+
)
16+
17+
go_test(
18+
name = "go_default_test",
19+
srcs = ["materializer_test.go"],
20+
embed = [":go_default_library"],
21+
deps = [
22+
"//platform/metrics:go_default_library",
23+
"//stovepipe/entity:go_default_library",
24+
"//stovepipe/extension/storage:go_default_library",
25+
"//stovepipe/extension/storage/mock:go_default_library",
26+
"@com_github_stretchr_testify//assert:go_default_library",
27+
"@com_github_stretchr_testify//require:go_default_library",
28+
"@com_github_uber_go_tally//:go_default_library",
29+
"@org_uber_go_mock//gomock:go_default_library",
30+
],
31+
)
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright (c) 2025 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Package requestlog retains the request occurrences exposed by Stovepipe's history API.
16+
package requestlog
17+
18+
//go:generate mockgen -source=materializer.go -destination=mock/materializer_mock.go -package=mock
19+
20+
import (
21+
"context"
22+
"errors"
23+
"fmt"
24+
"strconv"
25+
"time"
26+
27+
"github.com/uber-go/tally"
28+
29+
"github.com/uber/submitqueue/platform/metrics"
30+
"github.com/uber/submitqueue/platform/publish"
31+
"github.com/uber/submitqueue/stovepipe/entity"
32+
"github.com/uber/submitqueue/stovepipe/extension/storage"
33+
)
34+
35+
const (
36+
_occurrenceKindState = "state"
37+
)
38+
39+
// Materializer persists request-log occurrences into their queue-scoped read model.
40+
type Materializer interface {
41+
// PersistLog retains one request-log occurrence idempotently.
42+
PersistLog(context.Context, storage.Storage, entity.RequestLog) error
43+
}
44+
45+
type materializer struct {
46+
scope tally.Scope
47+
now func() time.Time
48+
}
49+
50+
var _ Materializer = (*materializer)(nil)
51+
52+
// NewMaterializer creates a request-log materializer.
53+
func NewMaterializer(scope tally.Scope) Materializer {
54+
return &materializer{
55+
scope: scope.SubScope("request_log_materializer"),
56+
now: time.Now,
57+
}
58+
}
59+
60+
// NewRequestStateLog constructs the occurrence representing the request's current durable state.
61+
func NewRequestStateLog(request entity.Request, outcomeReason entity.RequestOutcomeReason) entity.RequestLog {
62+
return entity.RequestLog{
63+
ID: publish.IntentID(_occurrenceKindState, strconv.FormatInt(int64(request.Version), 10)),
64+
Queue: request.Queue,
65+
RequestID: request.ID,
66+
State: request.State,
67+
RequestVersion: request.Version,
68+
OutcomeReason: outcomeReason,
69+
}
70+
}
71+
72+
func (m *materializer) PersistLog(ctx context.Context, stores storage.Storage, log entity.RequestLog) error {
73+
if log.TimestampMs == 0 {
74+
log.TimestampMs = m.now().UnixMilli()
75+
}
76+
77+
if err := log.Validate(); err != nil {
78+
m.count(ctx, "validation_failure")
79+
return fmt.Errorf("invalid request log occurrence: %w", err)
80+
}
81+
82+
store := stores.GetRequestLogStore()
83+
// SubmitQueue deduplicates the message that hands a log to its materializer. Stovepipe has no
84+
// log topic, so the retained occurrence ID is the retry boundary instead.
85+
if err := store.Create(ctx, log); err == nil {
86+
m.count(ctx, "created")
87+
return nil
88+
} else if !errors.Is(err, storage.ErrAlreadyExists) {
89+
m.count(ctx, "storage_failure")
90+
return fmt.Errorf("failed to create request log request_id=%q log_id=%q: %w", log.RequestID, log.ID, err)
91+
}
92+
93+
stored, err := store.Get(ctx, log.RequestID, log.ID)
94+
if err != nil {
95+
m.count(ctx, "storage_failure")
96+
return fmt.Errorf("failed to reconcile request log request_id=%q log_id=%q: %w", log.RequestID, log.ID, err)
97+
}
98+
if !sameSemanticOccurrence(stored, log) {
99+
m.count(ctx, "conflict")
100+
return fmt.Errorf("request log conflicts with retained occurrence request_id=%q log_id=%q", log.RequestID, log.ID)
101+
}
102+
103+
m.count(ctx, "identical_existing")
104+
return nil
105+
}
106+
107+
func (m *materializer) count(ctx context.Context, counter string) {
108+
metrics.NamedCounter(m.scope, "persist", counter, 1, metrics.TagsFromContext(ctx)...)
109+
}
110+
111+
func sameSemanticOccurrence(stored, candidate entity.RequestLog) bool {
112+
// This list is the compatibility boundary for duplicate reconciliation. New entity fields do not
113+
// become conflict-sensitive until they are deliberately added here.
114+
return stored.ID == candidate.ID &&
115+
stored.Queue == candidate.Queue &&
116+
stored.RequestID == candidate.RequestID &&
117+
stored.State == candidate.State &&
118+
stored.Event == candidate.Event &&
119+
stored.RequestVersion == candidate.RequestVersion &&
120+
stored.OutcomeReason == candidate.OutcomeReason &&
121+
metadataCompatible(stored.Metadata, candidate.Metadata)
122+
}
123+
124+
func metadataCompatible(stored, candidate map[string]string) bool {
125+
// One-sided keys permit additive metadata rollout without making a retry conflict with an older
126+
// immutable row. Values emitted by both versions must still agree.
127+
for key, storedValue := range stored {
128+
if candidateValue, ok := candidate[key]; ok && candidateValue != storedValue {
129+
return false
130+
}
131+
}
132+
return true
133+
}

0 commit comments

Comments
 (0)