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
+31 -2
View File
@@ -26,8 +26,34 @@ const (
PlacementProposalsRead = AssignerModule + "." + PlacementProposalsState
// PlacementProposalsHistory is the revisions the bucket keeps per key.
PlacementProposalsHistory = 5
// PlacementPulsesState is the holder's state of bounds (§4): a key per node a proposal waits on, written with a time
// to live of the answer bound, and a key per raised ask, with its ask's; the server's delete marker when one ends is
// the event that fails a silent node or ends an unanswered ask, so the holder keeps no timer. Its bucket allows a
// time to live per key (catalogue.StateDeclaration.KeyTTL).
PlacementPulsesState = "pulses"
PlacementPulsesBucket = AssignerModule + "_" + PlacementPulsesState
)
// PulseAnswerKey is the key of the bound on one node's answer to a proposal: `answer.<proposal>.<node>`.
func PulseAnswerKey(proposal, node string) (string, error) {
if err := CheckProposalID(proposal); err != nil {
return "", err
}
if err := checkName("node", node); err != nil {
return "", err
}
return "answer." + proposal + "." + node, nil
}
// PulseAskKey is the key of the bound on a proposal's raised ask: `ask.<proposal>`.
func PulseAskKey(proposal string) (string, error) {
if err := CheckProposalID(proposal); err != nil {
return "", err
}
return "ask." + proposal, nil
}
// The operations a proposal's change names (ADR 0567 §2).
const (
OpPlace = "place" // place a module on a node, with its host ports and bindings
@@ -360,6 +386,9 @@ type PlacementProposal struct {
// Must is every node it must hear from (§3): each whose key it changes and each whose composition it changes
// through another's, sorted; set by the holder once it composed, before then the nodes of Base.
Must []string `json:"must,omitempty"`
// HandBack says it is the operator's hand-back (§7): every key of Base deleted on its warrant, with no change and no
// node's answer.
HandBack bool `json:"hand-back,omitempty"`
// State is ProposalAnswering, ProposalFailed or ProposalAsked; Verdict is the latest judgement, Ask the authorising
// ask raised on a passing one.
State string `json:"state"`
@@ -385,8 +414,8 @@ func (p PlacementProposal) check() error {
if strings.TrimSpace(p.By) == "" || strings.TrimSpace(p.Why) == "" {
return fmt.Errorf("delivery: the proposal %s names no proposer or no why", p.ID)
}
if len(p.Changes) == 0 || len(p.Base) == 0 {
return fmt.Errorf("delivery: the proposal %s changes nothing", p.ID)
if len(p.Base) == 0 || (len(p.Changes) == 0) != p.HandBack || (p.HandBack && p.Seed) {
return fmt.Errorf("delivery: the proposal %s changes nothing, or is a hand-back with changes", p.ID)
}
for i, c := range p.Changes {
if err := c.check(); err != nil {
+22 -10
View File
@@ -535,20 +535,32 @@ func TestPlacement(t *testing.T) {
if ExportDigest(current) != ExportDigest(map[string]Placement{"novox": novox, "g14": g14}) || ExportDigest(current) == ExportDigest(nil) {
t.Error("the export digest depends on order or not on the placements")
}
back := p
back.HandBack, back.Changes = true, nil
if _, err := EncodePlacementProposal(back); err != nil {
t.Errorf("a hand-back: %v", err)
}
if k, err := PulseAnswerKey(id, "g14"); err != nil || k != "answer."+id+".g14" {
t.Errorf("PulseAnswerKey = %q, %v", k, err)
}
if k, err := PulseAskKey(id); err != nil || k != "ask."+id || PlacementPulsesBucket != "mesh-delivery-assigner_pulses" {
t.Errorf("PulseAskKey = %q, %v", k, err)
}
if ChangesDigest(p) == ChangesDigest(seed) {
t.Error("two proposals' changes digest alike")
}
for name, mut := range map[string]func(*PlacementProposal){
"no id": func(p *PlacementProposal) { p.ID = "" },
"no proposer": func(p *PlacementProposal) { p.By = "" },
"no why": func(p *PlacementProposal) { p.Why = " " },
"no change": func(p *PlacementProposal) { p.Changes = nil },
"no base": func(p *PlacementProposal) { p.Base = map[string]uint64{"novox": 1} },
"no state": func(p *PlacementProposal) { p.State = "" },
"asked, no ask": func(p *PlacementProposal) { p.State = ProposalAsked },
"seed mixed": func(p *PlacementProposal) { p.Seed, p.ExportDigest = true, "d" },
"bad change": func(p *PlacementProposal) { p.Changes[0].Op = "swap" },
"seed, no digest": func(p *PlacementProposal) { *p = seed; p.ExportDigest = "" },
"no id": func(p *PlacementProposal) { p.ID = "" },
"no proposer": func(p *PlacementProposal) { p.By = "" },
"no why": func(p *PlacementProposal) { p.Why = " " },
"no change": func(p *PlacementProposal) { p.Changes = nil },
"no base": func(p *PlacementProposal) { p.Base = map[string]uint64{"novox": 1} },
"no state": func(p *PlacementProposal) { p.State = "" },
"asked, no ask": func(p *PlacementProposal) { p.State = ProposalAsked },
"seed mixed": func(p *PlacementProposal) { p.Seed, p.ExportDigest = true, "d" },
"bad change": func(p *PlacementProposal) { p.Changes[0].Op = "swap" },
"seed, no digest": func(p *PlacementProposal) { *p = seed; p.ExportDigest = "" },
"hand-back with changes": func(p *PlacementProposal) { p.HandBack = true },
} {
q := p
q.Changes = append([]PlacementProposalChange(nil), p.Changes...)