diff --git a/go/delivery/window.go b/go/delivery/window.go new file mode 100644 index 0000000..d5c7a00 --- /dev/null +++ b/go/delivery/window.go @@ -0,0 +1,64 @@ +package delivery + +import ( + "slices" + "sort" + "time" +) + +// WindowHeld is the part of a window every window keeper shares (design 51: distribution's old seat name and direct +// writes, the builder's old reference form): whether it is open, the nodes holding it and since when, and when it last +// closed and who held it then. A keeper embeds it in its own record, so its JSON is the record's. +type WindowHeld struct { + Open bool `json:"open"` + Holding []string `json:"holding,omitempty"` + Since map[string]time.Time `json:"since,omitempty"` + Closed time.Time `json:"closed,omitzero"` + // LastHolding are the nodes that held it last, said when it closes. + LastHolding []string `json:"last_holding,omitempty"` +} + +// StepWindow is the one rule every window keeper steps its window by (security-upgrade review F3, the second of a +// kind): named is every node the read named, dropped ones included, and holding those of them that hold the window at +// now. A read that names no node judges nothing, as a first view's empty answer at revision 0 is no empty state (ADR +// 0615): an empty node_haves or a placement not yet read never closes a window, and the window stays as it was. A read +// naming nodes, none holding, closes an open window (closed true, once); one naming a holder opens it, each holder's +// since kept from w. overdue are the holders whose since is at least findingAfter before now (the keeper's finding, +// cleared for every other node); a read naming no node keeps w's holders' overdue as they stand at now. +func StepWindow(w WindowHeld, named, holding []string, now time.Time, findingAfter time.Duration) (next WindowHeld, closed bool, overdue []string) { + now = now.UTC() + if len(named) == 0 { + next = w + for _, n := range w.Holding { + if since, ok := w.Since[n]; ok && now.Sub(since) >= findingAfter { + overdue = append(overdue, n) + } + } + return next, false, overdue + } + if len(holding) == 0 { + next = WindowHeld{Closed: w.Closed, LastHolding: w.LastHolding} + if w.Open { + next.Closed, next.LastHolding = now, slices.Clone(w.Holding) + if next.LastHolding == nil { + next.LastHolding = []string{} + } + return next, true, nil + } + return next, false, nil + } + held := slices.Clone(holding) + sort.Strings(held) + next = WindowHeld{Open: true, Holding: held, Since: map[string]time.Time{}, Closed: w.Closed, LastHolding: w.LastHolding} + for _, n := range held { + since, had := w.Since[n] + if !had { + since = now + } + next.Since[n] = since + if now.Sub(since) >= findingAfter { + overdue = append(overdue, n) + } + } + return next, false, overdue +} diff --git a/go/delivery/window_test.go b/go/delivery/window_test.go new file mode 100644 index 0000000..8fd1ade --- /dev/null +++ b/go/delivery/window_test.go @@ -0,0 +1,41 @@ +package delivery + +import ( + "slices" + "testing" + "time" +) + +// The one rule every window keeper steps by (design 51; security-upgrade review F3): a read naming no node judges +// nothing, so an empty node_haves or an unread placement never closes a window. +func TestStepWindow(t *testing.T) { + now := time.Date(2026, 11, 4, 17, 0, 0, 0, time.UTC) + week := 7 * 24 * time.Hour + long := now.Add(-8 * 24 * time.Hour) + open := WindowHeld{Open: true, Holding: []string{"n1"}, Since: map[string]time.Time{"n1": long}} + for _, c := range []struct { + name string + w WindowHeld + named, holding []string + open, closed bool + holds, overdue []string + }{ + {"a read naming no node leaves an open window open, its holder still overdue", open, nil, nil, true, false, []string{"n1"}, []string{"n1"}}, + {"a read naming no node leaves a closed window closed", WindowHeld{Closed: long}, nil, nil, false, false, nil, nil}, + {"a read naming nodes, none holding, closes an open window once", open, []string{"n1", "n2"}, nil, false, true, nil, nil}, + {"a closed window a read finds unheld stays closed, said no more", WindowHeld{Closed: long, LastHolding: []string{"n1"}}, []string{"n1"}, nil, false, false, nil, nil}, + {"a new holder opens it, since now, not overdue", WindowHeld{}, []string{"n1", "n2"}, []string{"n2"}, true, false, []string{"n2"}, nil}, + {"a holder keeps its since and is overdue past the bound", open, []string{"n1", "n2"}, []string{"n2", "n1"}, true, false, []string{"n1", "n2"}, []string{"n1"}}, + } { + next, closed, overdue := StepWindow(c.w, c.named, c.holding, now, week) + if next.Open != c.open || closed != c.closed || !slices.Equal(next.Holding, c.holds) || !slices.Equal(overdue, c.overdue) { + t.Errorf("%s: open %v closed %v holding %v overdue %v", c.name, next.Open, closed, next.Holding, overdue) + } + if closed && (!next.Closed.Equal(now) || !slices.Equal(next.LastHolding, c.w.Holding)) { + t.Errorf("%s: closed at %v, last held by %v", c.name, next.Closed, next.LastHolding) + } + if !c.open && !closed && !next.Closed.Equal(c.w.Closed) { + t.Errorf("%s: its last close moved to %v", c.name, next.Closed) + } + } +}