Bound a proposal's answers and ask by pulse keys whose expiry the holder hears, and name a hand-back proposal, so no timer waits (ADR 0567 §4, §7, issue 1126)

This commit is contained in:
2026-10-28 18:43:39 +01:00
parent e987a8280b
commit a2dbcf670b
4 changed files with 87 additions and 41 deletions
+24 -19
View File
@@ -15,8 +15,9 @@ import (
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// AnswerBound is how long the holder waits for every answer after the newest candidate was written (ADR 0567 §4): a node
// asleep or away is named past it, never passed. A variable so a test on the bus can shorten it.
// AnswerBound is how long the holder waits for a node's answer after its candidate was written (ADR 0567 §4): the time to
// live of the node's pulse key (delivery.PulseAnswerKey), whose expiry names it silent, never passed. A variable so a test
// on the bus can shorten it.
var AnswerBound = 10 * time.Minute
// Intended is, by node, the modules whose composition the proposal changes, and the nodes that must answer it (§3).
@@ -113,12 +114,13 @@ func Intended(current, laidInput *compose.Input, currentPlaced map[string]delive
// JudgeAnswers is the verdict of a proposal's answers (§4). answers is by node its HaveNode.Candidates entry for the
// proposal (nil when it has none) and revisions by node the revision of its candidate key: an answer counts when it
// names the proposal and that revision. It fails on a refusal by a node that must answer, and, from the proposal's
// Written plus AnswerBound on, on every node of Must with no answer that counts, named; before that it waits for them.
// names the proposal and that revision. It fails on a refusal by a node that must answer, and on every node of Must with
// no answer that counts once its pulse key has ended (pulsing false: the bound's expiry, heard on the bus); while it
// stands it waits for them.
// A difference fails nothing: one Intended computed is listed under Intended, one it did not under Unintended, both
// shown in the ask's brief. The seed alone fails on any difference.
func JudgeAnswers(p delivery.PlacementProposal, intended map[string]map[string]bool, answers map[string]*delivery.CandidateAnswer,
revisions map[string]uint64, online map[string]bool, now time.Time) delivery.PlacementVerdict {
revisions map[string]uint64, online map[string]bool, pulsing map[string]bool, now time.Time) delivery.PlacementVerdict {
v := delivery.PlacementVerdict{At: now.UTC(), Intended: map[string][]string{}, Unintended: map[string][]string{},
Answers: map[string]delivery.CandidateAnswer{}}
for node, set := range intended {
@@ -175,27 +177,30 @@ func JudgeAnswers(p delivery.PlacementProposal, intended map[string]map[string]b
if len(v.Answers) == 0 {
v.Answers = nil
}
if len(v.Silent) > 0 {
v.Until = p.Written.Add(AnswerBound).UTC()
named := make([]string, len(v.Silent))
for i, node := range v.Silent {
named[i] = node
if online != nil && !online[node] {
named[i] = node + " (offline)"
}
var waiting, gone []string
for _, node := range v.Silent {
named := node
if online != nil && !online[node] {
named += " (offline)"
}
if now.Before(v.Until) {
v.Notes = append(v.Notes, fmt.Sprintf("waits for the answers of %s until %s", strings.Join(named, ", "),
v.Until.Format(time.RFC3339)))
if pulsing[node] {
waiting = append(waiting, named)
} else {
v.Fails = append(v.Fails, fmt.Sprintf("%s did not answer within %s of the newest candidate's write at %s; judged "+
"again when it answers", strings.Join(named, ", "), AnswerBound, p.Written.UTC().Format(time.RFC3339)))
gone = append(gone, named)
}
}
if len(waiting) > 0 {
v.Notes = append(v.Notes, fmt.Sprintf("waits for the answers of %s, each for %s from its candidate's write",
strings.Join(waiting, ", "), AnswerBound))
}
if len(gone) > 0 {
v.Fails = append(v.Fails, fmt.Sprintf("%s did not answer within %s of its candidate's write; judged again when it "+
"answers", strings.Join(gone, ", "), AnswerBound))
}
switch {
case len(v.Fails) > 0:
v.State = delivery.VerdictFail
case len(v.Silent) > 0:
case len(waiting) > 0:
v.State = delivery.VerdictWaits
default:
v.State = delivery.VerdictPass
+10 -10
View File
@@ -38,25 +38,25 @@ func TestJudge(t *testing.T) {
name string
seed bool
answers map[string]*delivery.CandidateAnswer
now time.Time
pulsing map[string]bool
state string
words string
marked string
}{
{"all the same", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": same(8)}, at, delivery.VerdictPass, "", ""},
{"all the same", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": same(8)}, nil, delivery.VerdictPass, "", ""},
{"a module added where intended", false, map[string]*delivery.CandidateAnswer{"anchor": {Proposal: "p-one", Revision: 7, Verdict: delivery.CandidateDiffers,
Added: []delivery.ModuleAdded{{Module: "web", Unissued: []string{"its own secret"}}}}, "laptop": same(8)}, at, delivery.VerdictPass, "", ""},
{"a difference not computed passes, marked", false, map[string]*delivery.CandidateAnswer{"anchor": differs(7, "mail"), "laptop": same(8)}, at, delivery.VerdictPass, "", "mail"},
{"the seed with a difference", true, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": differs(8, "web")}, at, delivery.VerdictFail, "laptop composes web differently", ""},
Added: []delivery.ModuleAdded{{Module: "web", Unissued: []string{"its own secret"}}}}, "laptop": same(8)}, nil, delivery.VerdictPass, "", ""},
{"a difference not computed passes, marked", false, map[string]*delivery.CandidateAnswer{"anchor": differs(7, "mail"), "laptop": same(8)}, nil, delivery.VerdictPass, "", "mail"},
{"the seed with a difference", true, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": differs(8, "web")}, nil, delivery.VerdictFail, "laptop composes web differently", ""},
{"a refusal", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8, Verdict: delivery.CandidateRefused,
Refusals: []delivery.ModuleRefused{{Module: "web", Why: "no own secret"}}}}, at, delivery.VerdictFail, "laptop refuses", ""},
{"a node not answering, within its bound", false, map[string]*delivery.CandidateAnswer{"anchor": same(7)}, at.Add(AnswerBound - time.Second), delivery.VerdictWaits, "laptop (offline)", ""},
{"an answer of an earlier revision, past its bound", false, map[string]*delivery.CandidateAnswer{"anchor": same(6), "laptop": same(8)}, at.Add(AnswerBound), delivery.VerdictFail, "anchor did not answer", ""},
{"its later answer", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": same(8)}, at.Add(time.Hour), delivery.VerdictPass, "", ""},
Refusals: []delivery.ModuleRefused{{Module: "web", Why: "no own secret"}}}}, nil, delivery.VerdictFail, "laptop refuses", ""},
{"a node not answering, within its bound", false, map[string]*delivery.CandidateAnswer{"anchor": same(7)}, map[string]bool{"laptop": true}, delivery.VerdictWaits, "laptop (offline)", ""},
{"an answer of an earlier revision, past its bound", false, map[string]*delivery.CandidateAnswer{"anchor": same(6), "laptop": same(8)}, nil, delivery.VerdictFail, "anchor did not answer", ""},
{"its later answer", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": same(8)}, nil, delivery.VerdictPass, "", ""},
} {
q := p
q.Seed = row.seed
v := JudgeAnswers(q, intended, row.answers, revisions, map[string]bool{"anchor": true}, row.now)
v := JudgeAnswers(q, intended, row.answers, revisions, map[string]bool{"anchor": true}, row.pulsing, at)
all := strings.Join(append(append([]string{}, v.Fails...), v.Notes...), "; ")
if v.State != row.state || !strings.Contains(all, row.words) {
t.Errorf("%s: %s, %q", row.name, v.State, all)