Files
mesh-controller/cmd/mesh-controller/group_wait.go
T

229 lines
9.2 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// **A delivery group still assembles** (novox/hq ADR 0306 decision 2): a merge whose delivery group has a member
// not yet merged waits for the merge window, whether or not a batch is assembling, so the group's members walk in
// one batch, in their order (ADR 0276 decision 5). Which groups exist and where each member stands is
// mesh-delivery's record (ADR 0239): a group is the pull requests of two repositories or more sharing a head
// branch name, which is its id. The controller asks it, through its `groups` verb, when a merge from a head is
// heard; a member whose pull request is open (proposed, checked, ready or rejected) is still to merge. When
// mesh-delivery does not answer within groupAskWithin, or answers something unreadable, the merge waits: the safe
// error is to wait, and its batch says why.
// groupAskWithin is how long mesh-delivery is given to say a merge's group, while the merge is being heard.
var groupAskWithin = 3 * time.Second
// openPullStates are the states of a delivery whose pull request is open and not merged (mesh-delivery's table):
// a member in one of them is still to merge. Closed unmerged is stopped, final, and waits for nothing.
var openPullStates = map[string]bool{"proposed": true, "checked": true, "ready": true, "rejected": true}
// groupMember is one member of a delivery group as mesh-delivery's `groups` says it: `<owner/repo>@<commit> (state)`.
type groupMember struct {
Repository, State string
// Composed says the group's line says its gate is the composed check's (a mesh-delivery that merges a group in
// one pass): only such a group is held open for its last member, since an older mesh-delivery merges member two
// only after member one is delivered, and a held batch would wait for ever.
Composed bool
}
// groupsLine is one group as mesh-delivery's `groups` says it: its id (the head branch name), its members as text and,
// from a mesh-delivery that knows novox/hq ADR 0382, each member again as an object (`member_details`) naming its pull
// request's number, whole head, base branch and whether it is open.
type groupsLine struct {
ID string `json:"id"`
Members []string `json:"members"`
Details []memberLine `json:"member_details,omitempty"`
// ComposedGate says the group's gate is the composed check's (novox/hq ADR 0403): a member's own check runs the
// repository's check alone. Said by a mesh-delivery that merges a group in one pass; absent from an older one, whose
// members keep their own gate.
ComposedGate bool `json:"composed_gate,omitempty"`
}
// memberLine is one member of a group, as `groups` says it beside its text.
type memberLine struct {
ID string `json:"id"`
Repository string `json:"repository"`
State string `json:"state"`
Number int `json:"number"`
Head string `json:"head"`
Base string `json:"base"`
Open bool `json:"open"`
}
// groupsAsked is mesh-delivery's `groups`: every group it keeps, or an error when it cannot be asked or did not
// answer within groupAskWithin. A seam a test replaces.
var groupsAsked = func(ctx context.Context, held bool) ([]groupsLine, error) {
if !held {
return nil, fmt.Errorf("no holder of %s is on record to say it", catalogue.DeliverySeat)
}
// On the bus as this process is: the serving controller's own connection, lent, or — in a verb that runs as a command
// of its own, such as a recheck's `delivery check` — one dialled for the moment (onTheBus).
var raw json.RawMessage
err := onTheBus(func(conn *nats.Conn) error {
asking, cancel := context.WithTimeout(ctx, groupAskWithin)
defer cancel()
var err error
raw, err = askDeliveryOwner(asking, conn, "groups", map[string]any{})
return err
})
if err != nil {
if errors.Is(err, link.ErrNothingServes) {
return nil, fmt.Errorf("nothing serves %s.groups", catalogue.DeliverySeat)
}
return nil, err
}
var lines []groupsLine
if err := json.Unmarshal(raw, &lines); err != nil {
return nil, fmt.Errorf("%s.groups answered something unreadable: %w", catalogue.DeliverySeat, err)
}
return lines, nil
}
// deliveryGroupOf is what mesh-delivery says of the group a head names: its members, and whether the head names a
// group at all; an error when it cannot be asked or did not answer. A seam a test replaces.
var deliveryGroupOf = func(ctx context.Context, held bool, head string) ([]groupMember, bool, error) {
lines, err := groupsAsked(ctx, held)
if err != nil {
return nil, false, err
}
for _, l := range lines {
if l.ID != head {
continue
}
var members []groupMember
for _, s := range l.Members {
m := memberOf(s)
m.Composed = l.ComposedGate
members = append(members, m)
}
return members, true, nil
}
return nil, false, nil
}
// memberOf reads one member as `groups` says it: `novox/mesh-sdk@d41d255f28ad (held)`.
func memberOf(s string) groupMember {
id, state := strings.TrimSpace(s), ""
if i := strings.LastIndex(id, " ("); i >= 0 && strings.HasSuffix(id, ")") {
id, state = id[:i], id[i+2:len(id)-1]
}
repository, _, _ := strings.Cut(id, "@")
return groupMember{Repository: repository, State: state}
}
// groupWait says whether a merge heard now waits for the window for its delivery group, and why in words; nothing
// when it names no head, its head names no group, or every other member is merged or closed.
//
// A member of another repository whose merge this controller already keeps in a batch not yet cut, of either
// kind, is merged, whatever mesh-delivery says yet (it marks a member merged once its walk opened): the merge joins
// that batch by their head. **Kinds do not part a group** (novox/hq ADR 0418 clause 6): a controller member and a
// catalogue member share one batch.
//
// Asked before the plans are held: a slow answer holds up no walk.
func groupWait(ctx context.Context, inv *inventory.Inventory, m link.SourceMoved, held, own bool) (string, error) {
why, _, err := groupWaitHolding(ctx, inv, m, held)
return why, err
}
// groupWaitHolding is groupWait, and whether the wait is for a member still to merge: such a batch is held open
// however long it takes (ADR 0418 clause 3). A wait only because mesh-delivery did not say is no hold.
func groupWaitHolding(ctx context.Context, inv *inventory.Inventory, m link.SourceMoved, held bool) (string, bool, error) {
if m.Head == "" {
return "", false, nil
}
open, isGroup, composed, err := openMembersOf(ctx, inv, held, m.Head, m.Owner+"/"+m.Repo)
if err != nil {
return fmt.Sprintf("%s did not say whether group %s has a member still to merge (%v): it waits for the "+
"window, the safe side", catalogue.DeliverySeat, m.Head, err), false, nil
}
if !isGroup || len(open) == 0 {
return "", false, nil
}
return "group " + m.Head + " has a member still to merge: " + strings.Join(open, ", "), composed, nil
}
// openMembersOf are the members of the group a head names that are still to merge, in words: those whose pull
// request is open and whose merge no batch not yet cut keeps. self is a repository to leave out, the merge being heard.
func openMembersOf(ctx context.Context, inv *inventory.Inventory, held bool, head, self string) ([]string, bool, bool, error) {
kept, err := keptFromHead(ctx, inv, head)
if err != nil {
return nil, false, false, err
}
members, isGroup, err := deliveryGroupOf(ctx, held, head)
if err != nil || !isGroup {
return nil, isGroup, false, err
}
var open []string
composed := false
for _, g := range members {
composed = composed || g.Composed
if strings.EqualFold(g.Repository, self) || kept[strings.ToLower(g.Repository)] || !openPullStates[g.State] {
continue
}
open = append(open, g.Repository+" ("+g.State+")")
}
sort.Strings(open)
return open, true, composed, nil
}
// keptFromHead are the repositories (lower case) of the merges from a head that batches not yet cut hold, of
// either kind.
func keptFromHead(ctx context.Context, inv *inventory.Inventory, head string) (map[string]bool, error) {
batches, err := inv.Batches(ctx)
if err != nil {
return nil, err
}
kept := map[string]bool{}
for _, b := range batches {
merges, err := inv.MergesOf(ctx, b.ID)
if err != nil {
return nil, err
}
for _, k := range merges {
if announcedOf(k).Head == head {
kept[strings.ToLower(k.Repository)] = true
}
}
}
return kept, nil
}
// batchOfGroup is the batch not yet cut that holds a merge of another repository from the same head: the group's
// batch, whatever kind it is.
func batchOfGroup(ctx context.Context, inv *inventory.Inventory, head, repository string) (inventory.Plan, bool, error) {
if head == "" {
return inventory.Plan{}, false, nil
}
batches, err := inv.Batches(ctx)
if err != nil {
return inventory.Plan{}, false, err
}
for _, b := range batches {
merges, err := inv.MergesOf(ctx, b.ID)
if err != nil {
return inventory.Plan{}, false, err
}
for _, k := range merges {
if announcedOf(k).Head == head && !strings.EqualFold(k.Repository, repository) {
return b, true, nil
}
}
}
return inventory.Plan{}, false, nil
}