Skip to content

Commit 4d42d5f

Browse files
aparajonclaude
andcommitted
feat(github): store born-held checks while a preflighted apply changes the target
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 44166f4 commit 4d42d5f

3 files changed

Lines changed: 144 additions & 0 deletions

File tree

pkg/metrics/metrics.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1619,6 +1619,20 @@ func RecordCheckPreflightGateOutcome(ctx context.Context, database, environment,
16191619
)
16201620
}
16211621

1622+
// RecordMergeGatePlanTimeHold counts plan-time check writes stored born held:
1623+
// the plan's verdict would have passed, but a preflighted apply is changing
1624+
// the target, so storing a passing check would reopen the merge gate
1625+
// mid-apply. The apply's settle fan-out re-plans held checks when it settles.
1626+
// A sustained rate with no matching settle re-plans means holds are piling up
1627+
// on the target — check the merge gate processor's logs.
1628+
func RecordMergeGatePlanTimeHold(ctx context.Context, database, environment string) {
1629+
addCounter(ctx, "schemabot.merge_gate.plan_time_holds_total",
1630+
"Total plan-time check writes stored held because a preflighted apply was in flight on the target", "{hold}",
1631+
attribute.String("database", database),
1632+
EnvironmentAttribute(environment),
1633+
)
1634+
}
1635+
16221636
// RecordMergeGateTerminatedStuck counts merge gate requests terminated
16231637
// by the stuck-processing sweep: rows wedged past the attempt cap with an
16241638
// expired lease (a driver hard-killed on its final attempt). Each terminated

pkg/webhook/check_records.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,45 @@ func (h *Handler) upsertPlanCheckRecord(ctx context.Context, client *ghclient.In
209209
blockingReason = reviewTimeDeploymentDriftBlock.blockingReason
210210
}
211211

212+
// A preflighted apply changing this target right now means the schema this
213+
// plan was computed against is mid-change, so a passing verdict cannot
214+
// prove the schema the merge would land on. Storing it would reopen the
215+
// merge gate the preflight fan-out closed: the check is born held instead,
216+
// and the apply's settle fan-out re-plans it against the settled schema,
217+
// releasing the hold. Verdicts that already block (changes, plan errors,
218+
// drift) keep their more specific reason. A storage failure fails closed
219+
// by failing the write — never by assuming the target is quiet.
220+
errorMessage := ""
221+
if conclusion == checkConclusionSuccess {
222+
activeHold, err := h.service.Storage().MergeGateRequests().HasActivePreflightedApplyOnTarget(ctx, environment, schema.Type, schema.Database)
223+
if err != nil {
224+
metrics.RecordStatusCheckOperation(ctx, metrics.StatusCheckOperation{
225+
Operation: "plan_check_recorded",
226+
Repository: repo,
227+
Database: schema.Database,
228+
DatabaseType: schema.Type,
229+
Environment: environment,
230+
Status: "error",
231+
})
232+
return headSHA, nil, fmt.Errorf("check for an active preflighted apply before storing plan check state repo %s pr %d environment %s database_type %s database %s: %w",
233+
repo, pr, environment, schema.Type, schema.Database, err)
234+
}
235+
if activeHold {
236+
conclusion = checkConclusionActionRequired
237+
blockingReason = applyInFlightBlock.blockingReason
238+
errorMessage = applyInFlightBlock.message
239+
changeSummary = clampDriftSummary(fmt.Sprintf("held: an apply in flight is changing %s in %s", schema.Database, environment))
240+
h.logger.Info("plan check born held: an active preflighted apply is changing the target; the apply's settle fan-out will re-plan this check when it settles",
241+
"repo", repo,
242+
"pr", pr,
243+
"head_sha", headSHA,
244+
"environment", environment,
245+
"database_type", schema.Type,
246+
"database", schema.Database)
247+
metrics.RecordMergeGatePlanTimeHold(ctx, schema.Database, environment)
248+
}
249+
}
250+
212251
check := &storage.Check{
213252
Repository: repo,
214253
PullRequest: pr,
@@ -220,6 +259,7 @@ func (h *Handler) upsertPlanCheckRecord(ctx context.Context, client *ghclient.In
220259
Status: checkStatusCompleted,
221260
Conclusion: conclusion,
222261
BlockingReason: blockingReason,
262+
ErrorMessage: errorMessage,
223263
ChangeSummary: changeSummary,
224264
}
225265
if err := h.service.Storage().Checks().UpsertPlanResult(ctx, check, drift.planDriftState()); err != nil {

pkg/webhook/merge_gate_integration_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,12 @@ import (
2222
"database/sql"
2323
"encoding/json"
2424
"fmt"
25+
"log/slog"
2526
"net/http"
2627
"net/http/httptest"
2728
"net/url"
29+
"os"
30+
"strings"
2831
"sync"
2932
"testing"
3033
"time"
@@ -34,6 +37,7 @@ import (
3437
"github.com/stretchr/testify/require"
3538

3639
"github.com/block/schemabot/pkg/api"
40+
ghclient "github.com/block/schemabot/pkg/github"
3741
"github.com/block/schemabot/pkg/state"
3842
"github.com/block/schemabot/pkg/storage"
3943
)
@@ -884,3 +888,89 @@ func TestE2ECheckSettleDefersToActivePreflightedApply(t *testing.T) {
884888
assert.Equal(t, applyInFlightBlock.blockingReason, stillHeld.BlockingReason,
885889
"the active apply's holds must survive an earlier apply's settle")
886890
}
891+
892+
// TestE2ECheckPlanBornHeldDuringActivePreflightedApply verifies plan-time
893+
// merge gate awareness: while a preflighted apply is changing a target, a
894+
// sibling change that plans against that target must not mint a fresh
895+
// passing check — the preflight fan-out already held the checks that existed
896+
// when the apply started, and a new plan (a pushed commit or a manual plan
897+
// command) would otherwise sidestep those holds. A plan whose verdict would
898+
// pass is stored born held with the apply-in-flight blocking reason instead,
899+
// and the apply's settle fan-out re-plans it like any other held check. The
900+
// plan comment still posts normally: the hold changes the stored verdict,
901+
// not the plan UX.
902+
func TestE2ECheckPlanBornHeldDuringActivePreflightedApply(t *testing.T) {
903+
clearMergeGateRequests(t)
904+
dbName := "webhook_mergegate_bornheld"
905+
svc := setupE2EService(t, dbName)
906+
907+
// The target already matches the PR schema, so the plan finds no changes
908+
// and its verdict would pass.
909+
ctx := t.Context()
910+
appDSN := strings.Replace(e2eTargetDSN, "/target_test", "/"+dbName, 1) + "&multiStatements=true"
911+
db, err := sql.Open("mysql", appDSN)
912+
require.NoError(t, err)
913+
_, err = db.ExecContext(ctx, "CREATE TABLE `users` (\n `id` bigint unsigned NOT NULL AUTO_INCREMENT,\n `name` varchar(255) NOT NULL,\n PRIMARY KEY (`id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci")
914+
require.NoError(t, err)
915+
_ = db.Close()
916+
917+
// Another change's apply is mid-flight on the target with a recorded
918+
// preflight, meaning its holds are (or are about to be) in force.
919+
apply := seedApplyWithLock(t, svc, dbName, state.Apply.Running, 2)
920+
recordRefreshRequest(t, svc, &storage.MergeGateRequest{
921+
ApplyID: apply.ID,
922+
Kind: storage.MergeGateKindPreflight,
923+
ApplyIdentifier: apply.ApplyIdentifier,
924+
Environment: "staging",
925+
DatabaseType: "mysql",
926+
DatabaseName: dbName,
927+
Repository: "octocat/hello-world",
928+
ChangeKey: "2",
929+
RequestedBy: apply.Caller,
930+
})
931+
932+
mux := http.NewServeMux()
933+
server := httptest.NewServer(mux)
934+
t.Cleanup(server.Close)
935+
936+
client := gh.NewClient(nil)
937+
client.BaseURL, _ = url.Parse(server.URL + "/")
938+
939+
schemabotConfig := fmt.Sprintf("database: %s\ntype: mysql\n", dbName)
940+
schemaFiles := map[string]string{
941+
"users.sql": "CREATE TABLE `users` (\n `id` bigint unsigned NOT NULL AUTO_INCREMENT,\n `name` varchar(255) NOT NULL,\n PRIMARY KEY (`id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;",
942+
}
943+
result := setupFakeGitHubForPlan(t, mux, schemaFiles, schemabotConfig, dbName)
944+
945+
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
946+
installClient := ghclient.NewInstallationClient(client, logger)
947+
factory := &fakeClientFactory{client: installClient}
948+
h := NewHandler(svc, factory, nil, logger)
949+
950+
req := buildWebhookRequest(t, webhookPayloadOpts{
951+
comment: "schemabot plan -e staging",
952+
isPR: true,
953+
}, nil)
954+
955+
rr := httptest.NewRecorder()
956+
h.ServeHTTP(rr, req)
957+
require.Equal(t, http.StatusOK, rr.Code)
958+
959+
// The plan comment posts as usual.
960+
select {
961+
case body := <-result.comments:
962+
assert.Contains(t, body, "No schema changes detected")
963+
case <-time.After(10 * time.Second):
964+
t.Fatal("timed out waiting for plan comment")
965+
}
966+
967+
// The stored check is born held, not passing.
968+
check, err := svc.Storage().Checks().Get(ctx, "octocat/hello-world", 1, "staging", "mysql", dbName)
969+
require.NoError(t, err)
970+
require.NotNil(t, check, "expected a stored check record for the plan")
971+
assert.False(t, check.HasChanges)
972+
assert.Equal(t, checkStatusCompleted, check.Status)
973+
assert.Equal(t, checkConclusionActionRequired, check.Conclusion)
974+
assert.Equal(t, applyInFlightBlock.blockingReason, check.BlockingReason)
975+
assert.Contains(t, check.ChangeSummary, "held: an apply in flight is changing "+dbName+" in staging")
976+
}

0 commit comments

Comments
 (0)