Give each asking of a merge ask its own id, and add the have phase removed (issues 1370, 1369)
mesh/merge-gate head ba684e54976b merged into trunk 5786ba190c69 — pass: group fix/r3e-before-the-gate composed: every machine composes with the change as …
mesh/repo-check head ba684e54976b merged into trunk 5786ba190c69 beside mesh-host#121@150fadfe — pass: its merge-check.sh passed, each part in its toolchai…
mesh/delivery delivered
mesh/delivery-group group fix/r3e-before-the-gate delivered: every member is delivered

The router drops an ask under an id it keeps as a redelivery, so a gate that writes the same group's ask again at unchanged heads was never heard. Ask gains Asking; AskingID gives the first asking the old id and later ones their own. PhaseRemoved lets the node-engine say a module left its node.
This commit is contained in:
2026-10-20 01:18:49 +00:00
parent 5786ba190c
commit ba684e5497
4 changed files with 107 additions and 4 deletions
+6 -2
View File
@@ -80,6 +80,10 @@ const (
PhaseChecking Phase = "checking" PhaseChecking Phase = "checking"
PhaseRunning Phase = "running" PhaseRunning Phase = "running"
PhaseFailed Phase = "failed" PhaseFailed Phase = "failed"
// PhaseRemoved is written by the node-engine, once, for a module its applied declaration no longer holds and
// for which it had written a have key; the key is never deleted (issue 1369, ADR 0408 §4), so a reader can tell
// that the module stopped.
PhaseRemoved Phase = "removed"
) )
// Health is what the module's own check says. // Health is what the module's own check says.
@@ -338,9 +342,9 @@ func decode(data []byte, into any) error {
func (h Have) check() error { func (h Have) check() error {
switch h.Phase { switch h.Phase {
case PhaseBackingUp, PhaseApplying, PhaseChecking, PhaseRunning, PhaseFailed: case PhaseBackingUp, PhaseApplying, PhaseChecking, PhaseRunning, PhaseFailed, PhaseRemoved:
default: default:
return fmt.Errorf("delivery: phase %q is not backing-up, applying, checking, running or failed", h.Phase) return fmt.Errorf("delivery: phase %q is not backing-up, applying, checking, running, failed or removed", h.Phase)
} }
switch h.Health { switch h.Health {
case HealthOK, HealthFailed, HealthWaits, HealthUnknown: case HealthOK, HealthFailed, HealthWaits, HealthUnknown:
+16
View File
@@ -292,3 +292,19 @@ func TestSame(t *testing.T) {
t.Error("different pulses same") t.Error("different pulses same")
} }
} }
func TestHaveRemoved(t *testing.T) { // 1370/AC3
in := Have{Build: "b1", Phase: PhaseRemoved, Health: HealthUnknown, Detail: "the declaration no longer holds it"}
b, err := EncodeHave(in)
if err != nil {
t.Fatal(err)
}
out, err := DecodeHave(b)
if err != nil {
t.Fatal(err)
}
in.V = Version
if out != in || out.Phase != "removed" {
t.Errorf("round trip: %+v", out)
}
}
+21 -2
View File
@@ -119,6 +119,18 @@ func AskID(askKey string) string {
return string(readable) + "-" + hex.EncodeToString(sum[:])[:12] return string(readable) + "-" + hex.EncodeToString(sum[:])[:12]
} }
// AskingID is the id of the asking-th writing of the ask at askKey. Asking 0 and 1 give AskID(askKey), so the first
// asking's id is unchanged; asking n of 2 or more gives the AskID form of askKey + "." + n, so each asking has an id
// of its own (at most 64 bytes, ending in the 12 hex digits of the hash of that whole string) and the router, which
// drops an ask under an id it keeps as a redelivery, takes each as new and an answer to an earlier asking is never
// taken for a later one.
func AskingID(askKey string, asking int) string {
if asking < 2 {
return AskID(askKey)
}
return AskID(askKey + "." + strconv.Itoa(asking))
}
func checkOwedAct(act string) (kind, context string, err error) { func checkOwedAct(act string) (kind, context string, err error) {
kind, context, hasContext := strings.Cut(act, "=") kind, context, hasContext := strings.Cut(act, "=")
switch kind { switch kind {
@@ -274,15 +286,22 @@ type Ask struct {
Group string `json:"group"` Group string `json:"group"`
Heads []Head `json:"heads"` Heads []Head `json:"heads"`
Ask asks.Ask `json:"ask"` Ask asks.Ask `json:"ask"`
// Asking counts the times the gate wrote this group's ask at these heads: 0 or 1 for the first, n for the n-th.
// The router drops an ask whose id it has already kept as a redelivery, so each asking has its own id
// (AskingID); the first asking's id is unchanged and the field is omitted for it.
Asking int `json:"asking,omitempty"`
} }
func (a Ask) check() error { func (a Ask) check() error {
if a.Asking < 0 {
return fmt.Errorf("delivery: the ask's asking %d is negative", a.Asking)
}
key, err := MergeAskKey(a.Group, a.Heads) key, err := MergeAskKey(a.Group, a.Heads)
if err != nil { if err != nil {
return fmt.Errorf("delivery: an ask needs its group and at least one pull request's head: %w", err) return fmt.Errorf("delivery: an ask needs its group and at least one pull request's head: %w", err)
} }
if want := AskID(key); a.Ask.ID != want { if want := AskingID(key, a.Asking); a.Ask.ID != want {
return fmt.Errorf("delivery: the ask's id %q is not %q, the id its group and heads give", a.Ask.ID, want) return fmt.Errorf("delivery: the ask's id %q is not %q, the id its group, heads and asking %d give", a.Ask.ID, want, a.Asking)
} }
if len(a.Ask.Options) == 0 { if len(a.Ask.Options) == 0 {
return fmt.Errorf("delivery: the ask has no option") return fmt.Errorf("delivery: the ask has no option")
+64
View File
@@ -508,3 +508,67 @@ func TestOwedCommentsHaveASequence(t *testing.T) { // 1322/AC3
} }
} }
} }
func TestAskingID(t *testing.T) { // 1370/AC1
k, _ := MergeAskKey("feat/x", gateHeads())
long, _ := MergeAskKey("feat/"+strings.Repeat("long-", 20), gateHeads())
for _, key := range []string{k, long} {
if AskingID(key, 1) != AskID(key) || AskingID(key, 0) != AskID(key) {
t.Errorf("the first asking's id is not AskID: %q %q %q", AskingID(key, 0), AskingID(key, 1), AskID(key))
}
seen := map[string]int{AskID(key): 1}
for n := 2; n <= 12; n++ {
id := AskingID(key, n)
if other, dup := seen[id]; dup {
t.Errorf("askings %d and %d give the id %q", n, other, id)
}
seen[id] = n
if len(id) > 64 || !asks.UsableID(id) {
t.Errorf("id %q of asking %d is not usable by the router", id, n)
}
if id != AskID(key+"."+strconv.Itoa(n)) {
t.Errorf("id of asking %d is not the AskID form of key.%d: %q", n, n, id)
}
}
}
}
func TestAskChecksItsAsking(t *testing.T) { // 1370/AC2
key, _ := MergeAskKey("feat/x", gateHeads())
a := sampleAsk()
if _, err := EncodeAsk(a); err != nil {
t.Fatalf("asking 0: %v", err)
}
a.Asking = 1
if _, err := EncodeAsk(a); err != nil {
t.Fatalf("asking 1 under the first id: %v", err)
}
a.Asking = 3
a.Ask.ID = AskingID(key, 3)
data, err := EncodeAsk(a)
if err != nil {
t.Fatalf("asking 3 under its id: %v", err)
}
a.V = Version
if got, err := DecodeAsk(data); err != nil || !SameAsk(got, a) || got.Asking != 3 {
t.Errorf("asking round trip: %+v %v", got, err)
}
a.Ask.ID = AskingID(key, 2)
if _, err := EncodeAsk(a); err == nil {
t.Error("asking 3 under asking 2's id was accepted")
}
a.Asking = 2
a.Ask.ID = AskID(key)
if _, err := EncodeAsk(a); err == nil {
t.Error("asking 2 under the first asking's id was accepted")
}
a.Asking = -1
if _, err := EncodeAsk(a); err == nil {
t.Error("a negative asking was accepted")
}
// the first asking is written without the field
data, _ = EncodeAsk(sampleAsk())
if strings.Contains(string(data), `"asking"`) {
t.Errorf("asking 0 is written: %s", data)
}
}