issue 975: the pull request's key written by compare-and-set, the ready flag on a ready mark's check, build-agent reads the key

This commit is contained in:
2026-10-17 13:48:54 +02:00
parent 27ca8bbcff
commit 2c8c1543ba
11 changed files with 311 additions and 4 deletions
+3
View File
@@ -12,6 +12,9 @@
"serves": ["current", "kill", "pause", "resume"]
}
],
"reads": [
"mesh-delivery.pulls"
],
"requires": [
"artifact-store",
{
@@ -286,6 +286,8 @@ func (r *runtime) answer(m rpcMessage) {
case "seat:" + ControllerSeat + ".delivery-plan":
return toolAnswer(map[string]any{"plan": DeliveryPlan{Repository: "novox/app", Moved: []string{"app"},
Summary: "builds app, sent to the node"}}), nil
case "seat:" + ControllerSeat + ".delivery-check":
return toolAnswer(map[string]any{"asked": "check-1"}), nil
case "gitea.gitea_open_pulls":
return toolAnswer(map[string]any{"pulls": pulls}), nil
case "gitea.gitea_note_append", "gitea.gitea_delivery_view", "gitea.gitea_commit_status", "gitea.gitea_merge_pull_request",
@@ -404,6 +406,7 @@ func TestAReadyMarkAndItsApproveAreMergedWithinSecondsOfTheirEventsOverARealBus(
// A pull request into main is announced, and the controller's verdict on its head passes.
const head = "1111111111111111111111111111111111111111"
announced := pr("novox/app", 7, head, "fix/x", "src/a.go")
announced.BaseSHA = "t1t1t1t1t1t1"
announced.Title = "Fix the apostrophe (issue 975)"
b.publish(t, "mesh.mod.gitea.event.pull.updated", announced)
b.publish(t, "mesh.seat."+ControllerSeat+".event.checked", judgedAt("novox/app", 7, head, "pass", "t1t1t1t1t1t1"))
@@ -421,6 +424,12 @@ func TestAReadyMarkAndItsApproveAreMergedWithinSecondsOfTheirEventsOverARealBus(
}
return false
})
// The pull request's key holds its head, the trunk commit it was asked against and the verdict.
rt.until("the pull request's key to hold head, trunk and verdict", 5*time.Second, func() bool {
e, err := b.kvs["pulls"].Get(context.Background(), "novox.app.7")
var k PullKey
return err == nil && json.Unmarshal(e.Value(), &k) == nil && k == PullKey{Head: head, Trunk: "t1t1t1t1t1t1", Verdict: "pass"}
})
reads := len(rt.asksOf("gitea.gitea_open_pulls"))
// The reviewer's label and comment: the forge shows the label alone at first, and the mark whole once the comment is
@@ -479,3 +488,29 @@ func TestAReadyMarkAndItsApproveAreMergedWithinSecondsOfTheirEventsOverARealBus(
t.Fatalf("%d asks of the mesh in a quiet second and a half after the merge: something polls", more)
}
}
// A ready mark on a pull request whose check nobody asked yet asks it at once, on the ready stream (the check's `ready`).
func TestAReadyMarkAsksTheCheckOfAPullRequestWithNoVerdictAsReady(t *testing.T) {
b := newTestBus(t)
rt := b.start(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
rt.deliverEvents(ctx)
rt.until("the module to subscribe", 20*time.Second, func() bool {
rt.mu.Lock()
defer rt.mu.Unlock()
return len(rt.subs) >= len(followed)+1
})
const head = "2222222222222222222222222222222222222222"
b.publish(t, "mesh.mod.gitea.event.pull.updated", pr("novox/app", 8, head, "fix/y", "src/a.go"))
rt.mu.Lock()
rt.openPulls = []OpenPull{markedPull("novox/app", 8, head, head)}
rt.mu.Unlock()
b.publish(t, "mesh.mod.gitea.event.forge.pull-request.commented", commentedReady(8, head))
rt.until("the check to be asked", 5*time.Second, func() bool { return len(rt.asksOf("seat:"+ControllerSeat+".delivery-check")) >= 1 })
body := rt.asksOf("seat:" + ControllerSeat + ".delivery-check")[0].Body
var members []Member
if err := json.Unmarshal([]byte(fmt.Sprint(body["members"])), &members); err != nil || len(members) != 1 || !members[0].Ready {
t.Fatalf("the check was asked for %v (%v); wanted one member with ready", body["members"], err)
}
}
@@ -108,6 +108,8 @@ type fakeController struct {
down bool
// walkReads counts the reads of the controller's walks (Walks).
walkReads int
// checkReady is the ready flag of each member of each check asked.
checkReady []bool
// refuseDeliver refuses every word, as a controller that will not take it does (review of novox/hq issue 753).
refuseDeliver bool
// onCheck runs as a check is asked, before it is answered: what the forge held at that moment.
@@ -178,6 +180,9 @@ func (c *fakeController) Check(group string, ms []Member) (string, error) {
heads = append(heads, m.ID)
}
c.checks = append(c.checks, group+":"+strings.Join(heads, ","))
for _, m := range ms {
c.checkReady = append(c.checkReady, m.Ready)
}
return id, nil
}
func (c *fakeController) Deliver(walk, why string) error {
@@ -29,6 +29,8 @@ type Holder struct {
Logf func(format string, a ...any)
// Operator is the operator channel, asked for the Approve of a merge into main (approval.go).
Operator Operator
// Pulls keeps the pull requests' keys on the bus (pullkey.go); nil keeps none.
Pulls PullKeys
// cancels are asks to take back, gathered under the lock and run with the other asks of the look.
cancels []func()
@@ -1705,7 +1707,7 @@ func (h *Holder) askCheck(d *Delivery, now time.Time) func() {
if d.State != Proposed || d.Check != nil || d.CheckAsk != nil || d.MergedAs != "" || d.Number == 0 {
return nil
}
if now.Sub(d.Since) < checkGrace {
if now.Sub(d.Since) < checkGrace && d.Ready == nil { // a ready mark makes the check worth asking at once
h.lookAt(d.Since.Add(checkGrace), "a proposed delivery's check, asked once the grace has passed with no verdict")
return nil
}
@@ -46,6 +46,7 @@ func main() {
Emit: func(event string, body any) error { return stdio.Emit(event, body) },
Logf: logf,
Operator: operator,
Pulls: kvPulls{},
}
h.init()
l := &listening{now: "not yet: starting"}
@@ -143,6 +144,7 @@ func take(h *Holder, e stdio.Envelope) error {
if h.PullUpdated(p) {
h.wantForge() // a head not seen before: the forge shows whether anyone checks it
}
h.KeepPull(p)
case "gitea.pull.merged":
h.PullMerged(p)
default:
@@ -176,6 +178,7 @@ func take(h *Holder, e stdio.Envelope) error {
return nil
}
h.Checked(c)
h.KeepVerdict(c)
case ControllerSeat + ".plan-moved":
var w Walk
if err := json.Unmarshal(e.Body, &w); err != nil {
@@ -57,7 +57,7 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
if !reflect.DeepEqual(m.Emits, []string{"transition", "group"}) {
t.Fatalf("emits %v", m.Emits)
}
if !reflect.DeepEqual(m.State, []string{"deliveries", "groups", "asked"}) {
if !reflect.DeepEqual(m.State, []string{"deliveries", "groups", "asked", "pulls"}) {
t.Fatalf("state %v", m.State)
}
// It calls exactly what its ports ask: the controller's six verbs and the forge's four tools.
@@ -32,6 +32,8 @@ type Member struct {
ModuleDirsSaid bool `json:"module_dirs_said,omitempty"`
CloneURL string `json:"clone_url,omitempty"`
After []string `json:"after,omitempty"`
// Ready is whether the pull request carries a ready mark: its check is published on the ready stream (ADR 0406 §3).
Ready bool `json:"ready,omitempty"`
// BaseSHA is the base branch's head the forge last announced the head with (novox/hq ADR 0323): the trunk commit
// the controller's verdict names when no build judges the head.
BaseSHA string `json:"base_sha,omitempty"`
@@ -39,7 +41,7 @@ type Member struct {
// MemberOf is a delivery as a member.
func MemberOf(d *Delivery) Member {
return Member{ID: d.ID, Repository: d.Repository, Base: d.Base, Head: d.Commit, Number: d.Number, Paths: d.Paths,
return Member{Ready: d.Ready != nil && d.Ready.Merged == nil, ID: d.ID, Repository: d.Repository, Base: d.Base, Head: d.Commit, Number: d.Number, Paths: d.Paths,
PathsTruncated: d.PathsTruncated, Removed: d.Removed, ModuleDirs: d.ModuleDirs, ModuleDirsSaid: d.ModuleDirsSaid,
CloneURL: d.CloneURL, After: d.After, BaseSHA: d.BaseSHA}
}
@@ -0,0 +1,115 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// The pull request's key (novox/hq ADR 0406 §4, issue 975): mesh-delivery keeps one key per pull request on the bus,
// holding its head, the trunk commit its current check is asked against and its verdict, written by compare-and-set.
// It writes a new head when the forge says one (`pull.updated`, which carries the head and the trunk commit it was
// announced against), and a new trunk commit only when the check is asked again against one — the merge tool's ask is
// that same announcement. A merge in a group's order, and a trunk move that leaves an inactive pull request unasked,
// are announced as nothing (the gitea module announces `pull.updated` only for a head and for the merge tool's ask), so
// they write nothing. A verdict for that head and trunk commit writes the verdict. The build seat reads the key.
// PullKey is what the bus keeps of one pull request.
type PullKey struct {
Head string `json:"head"`
Trunk string `json:"trunk,omitempty"`
Verdict string `json:"verdict,omitempty"`
}
// PullKeys keeps the pull requests' keys: Change reads the key, applies the change to what it read and writes the result
// by compare-and-set when the change says to write; a write another writer won reads the key again and applies again.
type PullKeys interface {
Change(key string, apply func(PullKey) (PullKey, bool)) error
}
// pullKeyName is a pull request's key: owner.repo.number, with any dot in a name made an underscore.
func pullKeyName(owner, repo string, number int) string {
clean := func(s string) string { return strings.NewReplacer(".", "_", "/", "_").Replace(s) }
return fmt.Sprintf("%s.%s.%d", clean(owner), clean(repo), number)
}
// KeepPull writes the key of a pull request whose head was announced, when the head or the trunk commit it was
// announced against is not what the key holds; a new head or trunk clears the verdict.
func (h *Holder) KeepPull(p PullEvent) {
if h.Pulls == nil || p.Number == 0 || p.HeadSHA == "" {
return
}
err := h.Pulls.Change(pullKeyName(p.Owner, p.Repo, p.Number), func(k PullKey) (PullKey, bool) {
if sameCommit(k.Head, p.HeadSHA) && k.Head != "" && (p.BaseSHA == "" || sameCommit(k.Trunk, p.BaseSHA)) {
return k, false
}
return PullKey{Head: p.HeadSHA, Trunk: p.BaseSHA}, true
})
if err != nil {
h.Logf("[mesh-delivery] the key of %s/%s#%d could not be written: %v", p.Owner, p.Repo, p.Number, err)
h.again("a pull request's key that could not be written")
}
}
// KeepVerdict writes a verdict into the key of its pull request, when the key holds that head and trunk commit.
func (h *Holder) KeepVerdict(c CheckedEvent) {
if h.Pulls == nil || c.Group != "" || c.Number == 0 || c.Commit == "" {
return
}
verdict := c.Verdict
if verdict == "" {
return
}
err := h.Pulls.Change(pullKeyName(c.Owner, c.Repo, c.Number), func(k PullKey) (PullKey, bool) {
if !sameCommit(k.Head, c.Commit) || (c.Trunk != "" && !sameCommit(k.Trunk, c.Trunk)) || k.Verdict == verdict {
return k, false
}
k.Verdict = verdict
return k, true
})
if err != nil {
h.Logf("[mesh-delivery] the verdict in the key of %s/%s#%d could not be written: %v", c.Owner, c.Repo, c.Number, err)
h.again("a pull request's key that could not be written")
}
}
// kvPulls keeps the keys in the module's own state `pulls`.
type kvPulls struct{}
// changeAttempts is how often a lost compare-and-set is read and applied again before it is said.
const changeAttempts = 10
func (kvPulls) Change(key string, apply func(PullKey) (PullKey, bool)) error {
state := stdio.State("pulls")
for range changeAttempts {
e, err := state.Get(key)
if err != nil {
return err
}
var k PullKey
if e != nil {
if err := json.Unmarshal(e.Value, &k); err != nil {
return err
}
}
next, write := apply(k)
if !write {
return nil
}
if e == nil {
_, err = state.Create(key, next)
} else {
_, err = state.Update(key, next, e.Revision)
}
if err == nil {
return nil
}
if !errors.Is(err, stdio.ErrChanged) {
return err
}
}
return fmt.Errorf("%s was written by another writer first %d times: %w", key, changeAttempts, stdio.ErrChanged)
}
@@ -0,0 +1,140 @@
package main
import (
"errors"
"sync"
"testing"
)
// **The pull request's key** (novox/hq ADR 0406 §4, issue 975): one key per pull request on the bus, holding its head,
// the trunk commit its current check is asked against and its verdict, written by compare-and-set. A head the forge
// announces writes head and trunk; a verdict for that head and trunk writes the verdict; nothing else writes.
type fakePulls struct {
mu sync.Mutex
keys map[string]PullKey
writes int
// lose makes the first n writes lose to another writer, which sets rival.
lose int
rival PullKey
}
func newFakePulls() *fakePulls { return &fakePulls{keys: map[string]PullKey{}} }
func (f *fakePulls) Change(key string, apply func(PullKey) (PullKey, bool)) error {
f.mu.Lock()
defer f.mu.Unlock()
for tries := 0; tries < 5; tries++ {
next, write := apply(f.keys[key])
if !write {
return nil
}
if f.lose > 0 { // another writer was first: read again and apply again
f.lose--
f.keys[key] = f.rival
continue
}
f.keys[key] = next
f.writes++
return nil
}
return errors.New("lost the race too often")
}
func (f *fakePulls) get(key string) PullKey {
f.mu.Lock()
defer f.mu.Unlock()
return f.keys[key]
}
const appKey = "novox.app.7"
func announcedAt(head, trunk string) PullEvent {
p := pr("novox/app", 7, head, "feat/x", "src/a.go")
p.BaseSHA = trunk
return p
}
func TestAnAnnouncedHeadWritesTheKeyAndTheSameAnnouncementAgainWritesNothing(t *testing.T) {
w := newWorld(t)
pulls := newFakePulls()
w.h.Pulls = pulls
p := announcedAt(readyHead, "t1t1t1t1t1t1")
w.h.PullUpdated(p)
w.h.KeepPull(p)
if got := pulls.get(appKey); got.Head != readyHead || got.Trunk != "t1t1t1t1t1t1" || got.Verdict != "" {
t.Fatalf("the key holds %+v", got)
}
w.h.KeepPull(p)
if pulls.writes != 1 {
t.Fatalf("the same announcement wrote %d times", pulls.writes)
}
}
func TestAVerdictForTheKeysHeadAndTrunkIsWrittenAndOneForAnotherIsNot(t *testing.T) {
w := newWorld(t)
pulls := newFakePulls()
w.h.Pulls = pulls
w.h.KeepPull(announcedAt(readyHead, "t1t1t1t1t1t1"))
old := judgedAt("novox/app", 7, pushed, "pass", "t1t1t1t1t1t1")
w.h.KeepVerdict(old)
if pulls.get(appKey).Verdict != "" {
t.Fatal("a verdict for another head was written")
}
other := judgedAt("novox/app", 7, readyHead, "pass", "t9t9t9t9t9t9")
w.h.KeepVerdict(other)
if pulls.get(appKey).Verdict != "" {
t.Fatal("a verdict for another trunk commit was written")
}
w.h.KeepVerdict(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
if got := pulls.get(appKey); got.Verdict != "pass" {
t.Fatalf("the key holds %+v", got)
}
}
func TestANewTrunkCommitClearsTheVerdictAndANewHeadReplacesTheKey(t *testing.T) {
w := newWorld(t)
pulls := newFakePulls()
w.h.Pulls = pulls
w.h.KeepPull(announcedAt(readyHead, "t1t1t1t1t1t1"))
w.h.KeepVerdict(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
w.h.KeepPull(announcedAt(readyHead, "t2t2t2t2t2t2"))
if got := pulls.get(appKey); got.Trunk != "t2t2t2t2t2t2" || got.Verdict != "" {
t.Fatalf("the check asked again against a new trunk left %+v", got)
}
w.h.KeepPull(announcedAt(pushed, "t2t2t2t2t2t2"))
if got := pulls.get(appKey); got.Head != pushed {
t.Fatalf("a new head left %+v", got)
}
}
func TestALostRaceReadsTheKeyAgainAndAppliesTheChangeToWhatItFound(t *testing.T) {
w := newWorld(t)
pulls := newFakePulls()
pulls.lose, pulls.rival = 1, PullKey{Head: readyHead, Trunk: "t1t1t1t1t1t1", Verdict: "pass"}
w.h.Pulls = pulls
// The rival writer already holds this very head and trunk with a verdict: the change finds nothing to write.
w.h.KeepPull(announcedAt(readyHead, "t1t1t1t1t1t1"))
if got := pulls.get(appKey); got.Verdict != "pass" || pulls.writes != 0 {
t.Fatalf("after the lost race the key holds %+v after %d writes", got, pulls.writes)
}
}
func TestAPullRequestMarkedReadyHasItsCheckAskedOnTheReadyStream(t *testing.T) {
w := newWorld(t)
w.h.PullUpdated(pr("novox/app", 7, readyHead, "feat/x", "src/a.go"))
w.forge.open = []OpenPull{markedPull("novox/app", 7, readyHead, readyHead)}
w.h.wantForge()
w.settleAll()
if len(w.ctl.checkReady) == 0 || !w.ctl.checkReady[0] {
t.Fatalf("the check of a marked pull request was asked with ready %v", w.ctl.checkReady)
}
// An unmarked one is asked as before.
w2 := newWorld(t)
w2.h.PullUpdated(pr("novox/app", 8, pushed, "feat/y", "src/a.go"))
w2.later(checkGrace + 1)
w2.settleAll()
if len(w2.ctl.checkReady) == 0 || w2.ctl.checkReady[0] {
t.Fatalf("the check of an unmarked pull request was asked with ready %v", w2.ctl.checkReady)
}
}
@@ -199,6 +199,7 @@ func (h *Holder) readyAsks(now time.Time) []func() {
}
if d.Ready == nil || d.Ready.Head != mark.Head || (d.Ready.Declined != nil && mark.At != d.Ready.MarkedAt) {
d.Ready = &ReadyRun{Head: mark.Head, By: mark.By, MarkedAt: mark.At, Seen: now}
h.Poke() // its check, if none is asked, is worth asking now (askCheck)
h.Logf("[mesh-delivery] %s is marked ready at %s by %s: merged once its check passes against its trunk's head",
d.ID, shortOf(mark.Head, 12), orNone(mark.By))
h.keep(d)
+2 -1
View File
@@ -64,7 +64,8 @@
"state": [
"deliveries",
"groups",
"asked"
"asked",
"pulls"
],
"tools": [
"delivery_status"