Skip to content

Commit 6ba60ea

Browse files
aparajonclaude
andcommitted
refactor(api): carry code-host neutral change identity on merge gate requests
The drive tail records the originating change as a provider-scoped change_key string via ChangeKeyForPullRequest, replacing the pull_request integer, and component prose follows the merge gate rename. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 934878b commit 6ba60ea

6 files changed

Lines changed: 108 additions & 98 deletions

File tree

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -22,38 +22,38 @@ func (s *staticGetApplyStore) Get(context.Context, int64) (*storage.Apply, error
2222
return s.apply, nil
2323
}
2424

25-
type capturingCheckRefreshStore struct {
26-
storage.CheckRefreshRequestStore
27-
recorded []*storage.CheckRefreshRequest
25+
type capturingMergeGateStore struct {
26+
storage.MergeGateRequestStore
27+
recorded []*storage.MergeGateRequest
2828
}
2929

30-
func (s *capturingCheckRefreshStore) Record(_ context.Context, req *storage.CheckRefreshRequest) (bool, error) {
30+
func (s *capturingMergeGateStore) Record(_ context.Context, req *storage.MergeGateRequest) (bool, error) {
3131
s.recorded = append(s.recorded, req)
3232
return true, nil
3333
}
3434

35-
type mockStorageWithCheckRefresh struct {
35+
type mockStorageWithMergeGate struct {
3636
mockStorage
37-
applies storage.ApplyStore
38-
checkRefresh storage.CheckRefreshRequestStore
37+
applies storage.ApplyStore
38+
mergeGate storage.MergeGateRequestStore
3939
}
4040

41-
func (m *mockStorageWithCheckRefresh) Applies() storage.ApplyStore { return m.applies }
42-
func (m *mockStorageWithCheckRefresh) CheckRefreshRequests() storage.CheckRefreshRequestStore {
43-
return m.checkRefresh
41+
func (m *mockStorageWithMergeGate) Applies() storage.ApplyStore { return m.applies }
42+
func (m *mockStorageWithMergeGate) MergeGateRequests() storage.MergeGateRequestStore {
43+
return m.mergeGate
4444
}
4545

46-
// TestRecordCheckRefreshGatedOnConsumer verifies the drive tail records a
47-
// check refresh request only when a refresh consumer is registered. A server
46+
// TestRecordMergeGateGatedOnConsumer verifies the drive tail records a
47+
// merge gate request only when a merge gate consumer is registered. A server
4848
// with no GitHub runtime — a gRPC/CLI-only deployment — has no PR check state
4949
// to refresh and no processor to drain requests, so a recorded row would sit
5050
// pending forever; the drive tail must skip recording entirely there. With a
5151
// consumer registered, the request is recorded with the apply's target and
5252
// attribution and the consumer is woken.
53-
func TestRecordCheckRefreshGatedOnConsumer(t *testing.T) {
54-
newService := func() (*Service, *capturingCheckRefreshStore) {
55-
refreshStore := &capturingCheckRefreshStore{}
56-
st := &mockStorageWithCheckRefresh{
53+
func TestRecordMergeGateGatedOnConsumer(t *testing.T) {
54+
newService := func() (*Service, *capturingMergeGateStore) {
55+
gateStore := &capturingMergeGateStore{}
56+
st := &mockStorageWithMergeGate{
5757
applies: &staticGetApplyStore{apply: &storage.Apply{
5858
ID: 7,
5959
ApplyIdentifier: "apply-gate-test",
@@ -65,36 +65,36 @@ func TestRecordCheckRefreshGatedOnConsumer(t *testing.T) {
6565
Caller: "cli:tester@host",
6666
State: state.Apply.Completed,
6767
}},
68-
checkRefresh: refreshStore,
68+
mergeGate: gateStore,
6969
}
7070
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
71-
return New(st, testServerConfig(), nil, logger), refreshStore
71+
return New(st, testServerConfig(), nil, logger), gateStore
7272
}
7373

7474
t.Run("no consumer registered skips recording", func(t *testing.T) {
75-
svc, refreshStore := newService()
75+
svc, gateStore := newService()
7676

77-
svc.recordCheckRefreshIfApplyResolved(t.Context(), 0, 7)
77+
svc.recordMergeGateIfApplyResolved(t.Context(), 0, 7)
7878

79-
assert.Empty(t, refreshStore.recorded,
80-
"a server without a refresh consumer must not record requests nothing will drain")
79+
assert.Empty(t, gateStore.recorded,
80+
"a server without a merge gate consumer must not record requests nothing will drain")
8181
})
8282

8383
t.Run("registered consumer records and is woken", func(t *testing.T) {
84-
svc, refreshStore := newService()
84+
svc, gateStore := newService()
8585
woken := 0
86-
svc.OnCheckRefreshRecorded = func() { woken++ }
86+
svc.OnMergeGateRecorded = func() { woken++ }
8787

88-
svc.recordCheckRefreshIfApplyResolved(t.Context(), 0, 7)
88+
svc.recordMergeGateIfApplyResolved(t.Context(), 0, 7)
8989

90-
require.Len(t, refreshStore.recorded, 1)
91-
recorded := refreshStore.recorded[0]
90+
require.Len(t, gateStore.recorded, 1)
91+
recorded := gateStore.recorded[0]
9292
assert.Equal(t, "apply-gate-test", recorded.ApplyIdentifier)
9393
assert.Equal(t, "gate_db", recorded.DatabaseName)
9494
assert.Equal(t, "mysql", recorded.DatabaseType)
9595
assert.Equal(t, "staging", recorded.Environment)
9696
assert.Equal(t, "octocat/hello-world", recorded.Repository)
97-
assert.Equal(t, 1, recorded.PullRequest)
97+
assert.Equal(t, "1", recorded.ChangeKey)
9898
assert.Equal(t, "cli:tester@host", recorded.RequestedBy)
9999
assert.Equal(t, 1, woken, "the drive tail wakes the consumer exactly once per recording")
100100
})

pkg/api/operator.go

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -556,10 +556,10 @@ func (s *Service) recoverSingleApplyOperation(ctx context.Context, driverID int,
556556
return
557557
}
558558

559-
// The check refresh request depends only on the apply settling to terminal
559+
// The merge gate request depends only on the apply settling to terminal
560560
// success; record it before control-request cleanup so a cleanup error
561561
// cannot suppress it.
562-
s.recordCheckRefreshIfApplyResolved(applyLeaseCtx, driverID, finalApply.ID)
562+
s.recordMergeGateIfApplyResolved(applyLeaseCtx, driverID, finalApply.ID)
563563

564564
// If the derived state above settled the apply terminally and a stop or
565565
// cancel is still pending, complete it now so the request does not linger
@@ -738,10 +738,10 @@ func (s *Service) driveClaimedMultiOperation(ctx context.Context, driverID int,
738738
// error must not suppress it.
739739
s.publishTerminalSummaryIfWon(operationLeaseCtx, driverID, finalApply, result)
740740

741-
// Like the terminal summary, the check refresh request depends only on the
741+
// Like the terminal summary, the merge gate request depends only on the
742742
// apply settling to terminal success; record it before control-request
743743
// cleanup so a cleanup error cannot suppress it.
744-
s.recordCheckRefreshIfApplyResolved(operationLeaseCtx, driverID, finalApply.ID)
744+
s.recordMergeGateIfApplyResolved(operationLeaseCtx, driverID, finalApply.ID)
745745

746746
if err := s.completePendingControlRequestsIfApplyResolved(operationLeaseCtx, driverID, finalApply.ID); err != nil {
747747
s.logger.Error("operator: failed to complete pending control requests for resolved apply",
@@ -914,11 +914,11 @@ func (s *Service) recoverApplyPendingStop(ctx context.Context, driverID int, own
914914
// summary; publish it if this projection won the terminal swap.
915915
s.publishTerminalSummaryIfWon(applyLeaseCtx, driverID, finalApply, result)
916916

917-
// Like the terminal summary, the check refresh request depends only on the
917+
// Like the terminal summary, the merge gate request depends only on the
918918
// apply settling to terminal success (the data-plane apply can complete
919919
// while a stop was requested); record it before control-request cleanup so
920920
// a cleanup error cannot suppress it.
921-
s.recordCheckRefreshIfApplyResolved(applyLeaseCtx, driverID, finalApply.ID)
921+
s.recordMergeGateIfApplyResolved(applyLeaseCtx, driverID, finalApply.ID)
922922

923923
if err := s.completePendingControlRequestsIfApplyResolved(applyLeaseCtx, driverID, finalApply.ID); err != nil {
924924
s.logger.Error("operator: failed to complete pending control requests after stop reconciliation",
@@ -1019,83 +1019,83 @@ func (s *Service) completePendingRequestForResolvedApply(ctx context.Context, dr
10191019
return nil
10201020
}
10211021

1022-
// hasCheckRefreshConsumer reports whether a check refresh consumer — the
1023-
// webhook handler's refresh processor — exists on this server. The handler
1024-
// registers OnCheckRefreshRecorded at construction, so a nil callback means
1022+
// hasMergeGateConsumer reports whether a merge gate consumer — the
1023+
// webhook handler's merge gate processor — exists on this server. The handler
1024+
// registers OnMergeGateRecorded at construction, so a nil callback means
10251025
// no GitHub runtime is configured: no PR check state to refresh and no
10261026
// processor to drain requests.
1027-
func (s *Service) hasCheckRefreshConsumer() bool {
1028-
return s.OnCheckRefreshRecorded != nil
1027+
func (s *Service) hasMergeGateConsumer() bool {
1028+
return s.OnMergeGateRecorded != nil
10291029
}
10301030

1031-
// recordCheckRefreshIfApplyResolved records a durable check refresh request
1031+
// recordMergeGateIfApplyResolved records a durable merge gate request
10321032
// once the apply has settled to terminal success. A completed apply —
10331033
// including a completed rollback — changes the live schema of its
10341034
// (environment, database type, database) target, which stales the stored plan
10351035
// check state of every other open PR planning against that target. The check
1036-
// refresh processor consumes the durable request to re-plan those PRs. The
1036+
// merge gate processor consumes the durable request to re-plan those PRs. The
10371037
// apply is reloaded because the derived-state write operates on a copy and
10381038
// does not mutate the caller's row. Recording is idempotent (one request per
10391039
// apply) and never fails the drive tail: errors are logged and counted, and
10401040
// the backstop sweep over recently completed applies re-records anything
1041-
// missed here. No-op on a server with no check refresh consumer (no GitHub
1041+
// missed here. No-op on a server with no merge gate consumer (no GitHub
10421042
// runtime configured) and for every settled state other than terminal
10431043
// success — only terminal success mutates the target schema.
1044-
func (s *Service) recordCheckRefreshIfApplyResolved(ctx context.Context, driverID int, applyID int64) {
1045-
if !s.hasCheckRefreshConsumer() {
1044+
func (s *Service) recordMergeGateIfApplyResolved(ctx context.Context, driverID int, applyID int64) {
1045+
if !s.hasMergeGateConsumer() {
10461046
// Without a GitHub webhook runtime this server has no PR check state
10471047
// to refresh and no processor to drain requests, so a recorded row
10481048
// would sit pending forever.
1049-
s.logger.Debug("operator: no check refresh consumer registered (GitHub is not configured on this server); skipping check refresh recording",
1049+
s.logger.Debug("operator: no merge gate consumer registered (GitHub is not configured on this server); skipping merge gate recording",
10501050
"driver", driverID)
10511051
return
10521052
}
10531053
apply, err := s.storage.Applies().Get(ctx, applyID)
10541054
if err != nil {
1055-
s.logger.Error("operator: failed to reload apply before recording check refresh request; the backstop sweep will record it",
1055+
s.logger.Error("operator: failed to reload apply before recording merge gate request; the backstop sweep will record it",
10561056
"driver", driverID, "error", fmt.Errorf("reload apply %d: %w", applyID, err))
10571057
return
10581058
}
10591059
if apply == nil {
1060-
s.logger.Error("operator: apply not found while recording check refresh request; no refresh will be recorded",
1060+
s.logger.Error("operator: apply not found while recording merge gate request; sibling checks will not be re-planned",
10611061
"driver", driverID, "error", fmt.Errorf("reload apply %d: %w", applyID, storage.ErrApplyNotFound))
10621062
return
10631063
}
10641064
if !state.IsState(apply.State, state.Apply.Completed) {
10651065
// Only terminal success mutates the target schema; every other outcome
10661066
// (still running, stopped, cancelled, failed, reverted) leaves sibling
10671067
// plan checks accurate.
1068-
s.logger.Debug("operator: apply did not settle to terminal success; no check refresh recorded",
1068+
s.logger.Debug("operator: apply did not settle to terminal success; no merge gate recorded",
10691069
append(apply.LogAttrs(), "driver", driverID)...)
10701070
return
10711071
}
10721072

1073-
recorded, err := s.storage.CheckRefreshRequests().Record(ctx, &storage.CheckRefreshRequest{
1073+
recorded, err := s.storage.MergeGateRequests().Record(ctx, &storage.MergeGateRequest{
10741074
ApplyID: apply.ID,
10751075
ApplyIdentifier: apply.ApplyIdentifier,
10761076
Environment: apply.Environment,
10771077
DatabaseType: apply.DatabaseType,
10781078
DatabaseName: apply.Database,
10791079
Repository: apply.Repository,
1080-
PullRequest: apply.PullRequest,
1080+
ChangeKey: storage.ChangeKeyForPullRequest(apply.PullRequest),
10811081
RequestedBy: apply.Caller,
10821082
})
10831083
if err != nil {
1084-
s.logger.Error("operator: failed to record check refresh request for completed apply; sibling PR checks stay stale until the backstop sweep records it",
1084+
s.logger.Error("operator: failed to record merge gate request for completed apply; sibling PR checks stay stale until the backstop sweep records it",
10851085
append(apply.LogAttrs(), "driver", driverID, "error", err)...)
1086-
metrics.RecordCheckRefreshRecordFailure(ctx, apply.Database, apply.Environment)
1086+
metrics.RecordMergeGateRecordFailure(ctx, apply.Database, apply.Environment)
10871087
return
10881088
}
10891089
if !recorded {
1090-
s.logger.Debug("operator: check refresh request already recorded for completed apply",
1090+
s.logger.Debug("operator: merge gate request already recorded for completed apply",
10911091
append(apply.LogAttrs(), "driver", driverID)...)
10921092
return
10931093
}
1094-
s.logger.Info("operator: recorded check refresh request for completed apply; sibling PR checks against the target will be re-planned",
1094+
s.logger.Info("operator: recorded merge gate request for completed apply; sibling PR checks against the target will be re-planned",
10951095
append(apply.LogAttrs(), "driver", driverID)...)
1096-
metrics.RecordCheckRefreshRecorded(ctx, apply.Database, apply.Environment, metrics.CheckRefreshSourceDriveTail)
1096+
metrics.RecordMergeGateRecorded(ctx, apply.Database, apply.Environment, metrics.MergeGateSourceDriveTail)
10971097
// Non-nil by the consumer gate above; wake the processor to drain now.
1098-
s.OnCheckRefreshRecorded()
1098+
s.OnMergeGateRecorded()
10991099
}
11001100

11011101
// reconcileUnclaimableParent handles a claimed operation whose parent apply

pkg/api/service.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -178,18 +178,18 @@ type Service struct {
178178
// against any still-live per-driver observer.
179179
OnApplyTerminalSummary ApplyTerminalSummaryCallback
180180

181-
// OnCheckRefreshRecorded is called after a drive tail durably records a
182-
// check refresh request. Set by the webhook handler to wake its refresh
181+
// OnMergeGateRecorded is called after a drive tail durably records a
182+
// merge gate request. Set by the webhook handler to wake its merge gate
183183
// processor immediately instead of waiting for the next poll tick; the
184184
// durable request row stays the source of truth, so a lost wake-up only
185-
// costs poll latency, never the refresh.
185+
// costs poll latency, never the fan-out.
186186
//
187187
// Registration doubles as the drive tails' consumer signal: when nil, no
188188
// GitHub webhook runtime exists on this server — there is no PR check
189189
// state to refresh and no processor to drain requests — so drive tails
190190
// skip recording entirely. Implementations must be non-blocking and safe
191191
// for concurrent drivers.
192-
OnCheckRefreshRecorded func()
192+
OnMergeGateRecorded func()
193193

194194
pendingObserverMu sync.Mutex
195195
pendingObservers map[pendingObserverKey]tern.ProgressObserver

pkg/metrics/metrics.go

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1512,37 +1512,37 @@ func RecordPendingDropsCleanupError(ctx context.Context, database, environment,
15121512
)
15131513
}
15141514

1515-
// Check refresh recording sources for RecordCheckRefreshRecorded.
1515+
// Merge gate recording sources for RecordMergeGateRecorded.
15161516
const (
1517-
// CheckRefreshSourceDriveTail marks a request recorded inline by the
1517+
// MergeGateSourceDriveTail marks a request recorded inline by the
15181518
// operator drive tail that settled the apply.
1519-
CheckRefreshSourceDriveTail = "drive_tail"
1520-
// CheckRefreshSourceSweep marks a request recorded by the backstop sweep
1519+
MergeGateSourceDriveTail = "drive_tail"
1520+
// MergeGateSourceSweep marks a request recorded by the backstop sweep
15211521
// over recently completed applies.
1522-
CheckRefreshSourceSweep = "sweep"
1522+
MergeGateSourceSweep = "sweep"
15231523
)
15241524

1525-
// RecordCheckRefreshRecorded counts durable check refresh requests recorded
1525+
// RecordMergeGateRecorded counts durable merge gate requests recorded
15261526
// when an apply settles to terminal success. A sustained "sweep" rate means
15271527
// drive tails are failing to record — check the operator logs for the
15281528
// recording error.
1529-
func RecordCheckRefreshRecorded(ctx context.Context, database, environment, source string) {
1530-
addCounter(ctx, "schemabot.check_refresh.requests_recorded_total",
1531-
"Total durable check refresh requests recorded for applies that settled to terminal success", "{request}",
1529+
func RecordMergeGateRecorded(ctx context.Context, database, environment, source string) {
1530+
addCounter(ctx, "schemabot.merge_gate.requests_recorded_total",
1531+
"Total durable merge gate requests recorded for applies that settled to terminal success", "{request}",
15321532
attribute.String("database", database),
15331533
EnvironmentAttribute(environment),
15341534
attribute.String("source", source),
15351535
)
15361536
}
15371537

1538-
// RecordCheckRefreshRecordFailure counts failures to record a durable check
1539-
// refresh request for a completed apply. The backstop sweep retries the
1538+
// RecordMergeGateRecordFailure counts failures to record a durable merge
1539+
// gate request for a completed apply. The backstop sweep retries the
15401540
// recording on its next pass, so a transient blip self-heals; a sustained rate
15411541
// means storage writes are failing and sibling PR checks are going stale —
15421542
// check the operator logs for the storage error.
1543-
func RecordCheckRefreshRecordFailure(ctx context.Context, database, environment string) {
1544-
addCounter(ctx, "schemabot.check_refresh.record_failures_total",
1545-
"Total failures to record a durable check refresh request for a completed apply", "{failure}",
1543+
func RecordMergeGateRecordFailure(ctx context.Context, database, environment string) {
1544+
addCounter(ctx, "schemabot.merge_gate.record_failures_total",
1545+
"Total failures to record a durable merge gate request for a completed apply", "{failure}",
15461546
attribute.String("database", database),
15471547
EnvironmentAttribute(environment),
15481548
)

pkg/storage/types.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1419,6 +1419,16 @@ type MergeGateRequest struct {
14191419
UpdatedAt time.Time
14201420
}
14211421

1422+
// ChangeKeyForPullRequest renders a GitHub pull request number as a merge
1423+
// gate change key. Zero (no originating PR) renders as the empty key, which
1424+
// the fan-out treats as "exclude nothing".
1425+
func ChangeKeyForPullRequest(pr int) string {
1426+
if pr <= 0 {
1427+
return ""
1428+
}
1429+
return strconv.Itoa(pr)
1430+
}
1431+
14221432
// WebhookEvent is a durable inbox row for one SCM/webhook delivery.
14231433
type WebhookEvent struct {
14241434
ID int64

0 commit comments

Comments
 (0)