DLV-B9: the old reference form's window over sdk/forms, kept in old-form, read and dropped by builder_old_form
The window opens while a counted node-engine reads, of some kind, none of the newest form sdk/forms knows, and closes when none does: said once as version.old-form-closed, and a node holding it seven days is old-form-held about it. It steps on a statement's change, the placement, a hand drop and each build key written, never on a timer. A retired node dropped by hand leaves the count of the forms a build key keeps (design 51). internal/versions' own Forms goes: which forms a key keeps is sdk/forms'. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
@@ -34,7 +34,7 @@ type queueAsker func(key string, value []byte) (json.RawMessage, error)
|
||||
// the builder's read, buildAgain writes a reasks value of mode build and waits for the builder's answer.
|
||||
// tags writes a tag-asks value and waits for the builder's answer; ta reads tag-asks.
|
||||
func Tools(r build.StuckReader, askAgain func(key string, value []byte) error, queue, buildAgain queueAsker, since build.LogSince, now func() time.Time, vs versions.Reader,
|
||||
tags queueAsker, ta, fw versions.Reader) []stdio.Tool {
|
||||
tags queueAsker, ta, fw, of versions.Reader) []stdio.Tool {
|
||||
str := func(args map[string]any, k string) string { s, _ := args[k].(string); return strings.TrimSpace(s) }
|
||||
// handAct writes a reasks value of a versions hand act and answers the builder's answer, or its refusal as an error.
|
||||
handAct := func(key string, value []byte, err error) (any, error) {
|
||||
@@ -104,6 +104,16 @@ func Tools(r build.StuckReader, askAgain func(key string, value []byte) error, q
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"repository": map[string]any{"type": "string", "description": "only this repository's asks, <owner>/<name>"}}},
|
||||
Run: func(args map[string]any) (any, error) { return versions.AnswerTagAsks(ta, str(args, "repository")) }},
|
||||
{Name: versions.OldFormTool, Description: versions.OldFormDescription,
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"drop": map[string]any{"type": "string", "description": "a retired node to drop from the count by hand"},
|
||||
"why": map[string]any{"type": "string", "description": "with drop: why, in plain words"}}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
if node := str(args, "drop"); node != "" {
|
||||
return handAct(build.NewOldFormDrop(node, str(args, "why"), stdio.CallerOf(args), now()))
|
||||
}
|
||||
return versions.AnswerOldForm(of)
|
||||
}},
|
||||
{Name: versions.FloorWaitsTool, Description: versions.FloorWaitsDescription,
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{}},
|
||||
Run: func(map[string]any) (any, error) { return versions.AnswerFloorWaits(fw) }},
|
||||
@@ -378,7 +388,15 @@ func main() {
|
||||
}
|
||||
return e.Value, true, nil
|
||||
}}
|
||||
tools := Tools(r, put, askQueue(stdio.State(build.QueuesState), 25*time.Second), buildAgain, stdio.Log(delivery.BuilderLog).Since, time.Now, vr, tags, tr, fr)
|
||||
ofs := stdio.State(versions.OldFormState)
|
||||
ofr := versions.Reader{Keys: ofs.Keys, Get: func(key string) (json.RawMessage, bool, error) {
|
||||
e, err := ofs.Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return e.Value, true, nil
|
||||
}}
|
||||
tools := Tools(r, put, askQueue(stdio.State(build.QueuesState), 25*time.Second), buildAgain, stdio.Log(delivery.BuilderLog).Since, time.Now, vr, tags, tr, fr, ofr)
|
||||
// What each counted node's engine reads, as the builder keeps it (ADR 0618 §2, ADR 0624 §1): listed, one node asked
|
||||
// again, or one node's key dropped.
|
||||
tools = append(tools, enginereads.Tool(EngineReadsTool, "the builder", enginereads.KeptState(stdio.State("engine-reads")), true, time.Now))
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/forms"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
@@ -207,8 +208,29 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
// What each counted node's engine reads (ADR 0618 §2, ADR 0624 §1): asked through the node-engine seat's verb as the
|
||||
// builder's own account, for every node the placement names, and kept in the state engine-reads once it is bound.
|
||||
reads := newEngineReads(calls.CallOn, gate, logger.Printf)
|
||||
// The old reference form's window over them (DLV-B9), kept in the state old-form once it is bound; builder_old_form
|
||||
// reads it and drops a retired node by hand.
|
||||
reads.ctx = context.WithoutCancel(ctx)
|
||||
reads.window = &oldForm{forms: forms.Current, findings: found, logf: logger.Printf, now: time.Now,
|
||||
said: func(body versions.OldFormClosedBody) {
|
||||
if err := emitEvent(context.WithoutCancel(ctx), js, cred, versions.OldFormClosedEvent, "old-form-closed-"+body.At.Format(time.RFC3339Nano), body); err != nil {
|
||||
logger.Printf("saying %s: %v", versions.OldFormClosedEvent, err)
|
||||
}
|
||||
}}
|
||||
acts.DropOldForm = func(ctx context.Context, node, by, why string, at time.Time) (string, error) {
|
||||
answer, err := reads.window.drop(ctx, node, by, why, at)
|
||||
if err == nil {
|
||||
reads.stepWindow()
|
||||
}
|
||||
return answer, err
|
||||
}
|
||||
process.AwaitBucket(ctx, js, conn, versions.OldFormBucket, "the old form's window is kept in memory only, and builder_old_form shows none", logger.Printf).
|
||||
Ready(func(kv jetstream.KeyValue) {
|
||||
reads.window.bind(context.WithoutCancel(ctx), kv)
|
||||
reads.stepWindow()
|
||||
})
|
||||
gate.OnPlaced = reads.placed
|
||||
b.Forms = reads.forms
|
||||
b.Forms = reads.built
|
||||
placementErrs, err := buildrun.FollowPlacement(ctx, js, gate, b.NodesChanged, logger.Printf)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/forms"
|
||||
|
||||
"mesh-delivery-builder/internal/versions"
|
||||
)
|
||||
|
||||
// oldForm keeps the old reference form's window (design 51 *Every build keeps the old reference form*, DLV-B9) in the
|
||||
// state old-form: stepped on every change of what the counted nodes state, of the placement, on a hand drop and at each
|
||||
// build key written, never on a timer. A node holding it seven days is the finding old-form-held; its close is said once
|
||||
// as version.old-form-closed and in the builder's log.
|
||||
type oldForm struct {
|
||||
forms forms.ArtifactForms
|
||||
findings *findings
|
||||
// said emits version.old-form-closed.
|
||||
said func(versions.OldFormClosedBody)
|
||||
logf func(string, ...any)
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
w versions.OldFormWindow
|
||||
kv jetstream.KeyValue
|
||||
// overdue are the nodes the finding is raised for now.
|
||||
overdue []string
|
||||
}
|
||||
|
||||
// bind takes the kept window in, so a restarted builder keeps each node's since and the nodes dropped by hand, and keeps
|
||||
// every later step in kv.
|
||||
func (o *oldForm) bind(ctx context.Context, kv jetstream.KeyValue) {
|
||||
e, err := kv.Get(ctx, versions.OldFormKey)
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
switch {
|
||||
case errors.Is(err, jetstream.ErrKeyNotFound):
|
||||
case err != nil:
|
||||
o.logf("%s.%s cannot be read, so the window starts again: %v", versions.OldFormState, versions.OldFormKey, err)
|
||||
default:
|
||||
w, err := versions.DecodeOldFormWindow(e.Value())
|
||||
if err != nil {
|
||||
o.logf("%v; the window starts again", err)
|
||||
break
|
||||
}
|
||||
// Taken in under what was stepped before the state was bound: the kept since and drops are the older truth.
|
||||
for n, s := range o.w.Since {
|
||||
if _, ok := w.Since[n]; !ok && w.Open && slices.Contains(w.Holding, n) {
|
||||
w.Since[n] = s
|
||||
}
|
||||
}
|
||||
for _, d := range o.w.Dropped {
|
||||
w, _ = w.Drop(d.Node, d.By, d.Why, d.At)
|
||||
}
|
||||
o.w = w
|
||||
}
|
||||
o.kv = kv
|
||||
o.keep(ctx)
|
||||
}
|
||||
|
||||
// dropped is the nodes dropped by hand.
|
||||
func (o *oldForm) dropped() map[string]bool {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
return o.w.DroppedSet()
|
||||
}
|
||||
|
||||
// step moves the window to what counted states.
|
||||
func (o *oldForm) step(ctx context.Context, counted map[string][]string) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
before := o.w
|
||||
next, closed, overdue := o.w.Step(versions.Holding(o.forms, counted, o.w.DroppedSet()), o.now())
|
||||
o.w = next
|
||||
if !slices.Equal(before.Holding, next.Holding) || before.Open != next.Open || closed {
|
||||
o.keep(ctx)
|
||||
}
|
||||
for _, n := range o.overdue {
|
||||
if !slices.Contains(overdue, n) {
|
||||
o.findings.clear(ctx, versions.OldFormHeld, health.ScopeMachine, n)
|
||||
}
|
||||
}
|
||||
for _, n := range overdue {
|
||||
o.findings.raise(ctx, finding{kind: versions.OldFormHeld, scope: health.ScopeMachine, id: n,
|
||||
summary: fmt.Sprintf("the node-engine of %s has read only an older reference form since %s, so every build key still carries it",
|
||||
n, next.Since[n].Format(time.RFC3339)),
|
||||
headline: "A node holds the old reference form",
|
||||
explanation: "The builder writes an older reference form into every build key while a counted node's engine reads none of the newest (ADR 0624 §1); this node has held it seven days.",
|
||||
resolved: "The node's engine states the newest form, the node leaves the placement, or it is dropped as retired.",
|
||||
needs: "builder_old_form shows the window; a retired node is dropped there by hand; builder_engine_reads asks the node again."})
|
||||
}
|
||||
o.overdue = overdue
|
||||
if closed {
|
||||
body := next.Closing()
|
||||
o.logf("the old reference form's window closed at %s: no counted node-engine reads only an older form (last held by %s; dropped by hand: %d); new build keys leave it out",
|
||||
body.At.Format(time.RFC3339), strings.Join(body.LastHolding, ", "), len(body.Dropped))
|
||||
if o.said != nil {
|
||||
o.said(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drop is builder_old_form's drop: the node leaves the count, the window is stepped by the caller.
|
||||
func (o *oldForm) drop(ctx context.Context, node, by, why string, at time.Time) (string, error) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
w, err := o.w.Drop(node, by, why, at)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
o.w = w
|
||||
o.keep(ctx)
|
||||
return node + " dropped from the old form's count by hand; the window is stepped now", nil
|
||||
}
|
||||
|
||||
// keep writes the window, once the state is bound; until then it is kept here only. Called with mu held.
|
||||
func (o *oldForm) keep(ctx context.Context) {
|
||||
if o.kv == nil {
|
||||
return
|
||||
}
|
||||
raw, err := json.Marshal(o.w)
|
||||
if err == nil {
|
||||
_, err = o.kv.Put(ctx, versions.OldFormKey, raw)
|
||||
}
|
||||
if err != nil {
|
||||
o.logf("%s.%s cannot be written: %v", versions.OldFormState, versions.OldFormKey, err)
|
||||
}
|
||||
}
|
||||
@@ -30,11 +30,30 @@ type engineReads struct {
|
||||
gate *buildrun.ToolchainGate
|
||||
mu sync.Mutex
|
||||
counted []string
|
||||
// window is the old reference form's window over these statements (DLV-B9); nil keeps none.
|
||||
window *oldForm
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// newEngineReads asks through call (process.Conn.CallOn) and keeps each answer in the state once it is bound.
|
||||
func newEngineReads(call enginereads.Caller, gate *buildrun.ToolchainGate, logf func(string, ...any)) *engineReads {
|
||||
return &engineReads{gate: gate, keeper: &enginereads.Keeper{Call: call, Logf: logf}}
|
||||
r := &engineReads{gate: gate, keeper: &enginereads.Keeper{Call: call, Logf: logf}}
|
||||
r.keeper.Changed = r.stepWindow
|
||||
return r
|
||||
}
|
||||
|
||||
// stepWindow moves the old form's window to what the counted nodes state now (design 51 *Every build keeps the old
|
||||
// reference form*): on a statement's change, the placement's and a hand drop, and at each build key written.
|
||||
func (r *engineReads) stepWindow() {
|
||||
if r.window == nil {
|
||||
return
|
||||
}
|
||||
ctx := r.ctx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
_, counted := r.forms()
|
||||
r.window.step(ctx, counted)
|
||||
}
|
||||
|
||||
// bind keeps the statements in kv and hears it: every node's last statement at the start, a person's ask again and drop
|
||||
@@ -78,6 +97,7 @@ func (r *engineReads) placed() {
|
||||
r.keeper.Forget(node)
|
||||
}
|
||||
}
|
||||
r.stepWindow()
|
||||
}
|
||||
|
||||
// heard takes an event of the builder's consumer on EVENTS: manifest-reads-changed asks its node again, and answers true.
|
||||
@@ -96,10 +116,29 @@ func (r *engineReads) heard(m jetstream.Msg, logf func(string, ...any)) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// forms is build.Builder.Forms: the counted nodes' statements, and those of the nodes whose engine answered.
|
||||
// forms is the counted nodes' statements, and those of the nodes whose engine answered; a retired node dropped by hand
|
||||
// (builder_old_form) counts no more.
|
||||
func (r *engineReads) forms() (present, counted map[string][]string) {
|
||||
r.mu.Lock()
|
||||
nodes := slices.Clone(r.counted)
|
||||
r.mu.Unlock()
|
||||
if r.window != nil {
|
||||
dropped := r.window.dropped()
|
||||
nodes = slices.DeleteFunc(nodes, func(n string) bool { return dropped[n] })
|
||||
}
|
||||
return r.keeper.Present(nodes), r.keeper.Reads(nodes)
|
||||
}
|
||||
|
||||
// built is build.Builder.Forms: forms, and the window stepped with them, so a node's seven days are seen at the next
|
||||
// build key at the latest.
|
||||
func (r *engineReads) built() (present, counted map[string][]string) {
|
||||
present, counted = r.forms()
|
||||
if r.window != nil {
|
||||
ctx := r.ctx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
r.window.step(ctx, counted)
|
||||
}
|
||||
return present, counted
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func reAsked(ctx context.Context, b askAgainer, acts versions.Acts, kv jetstream
|
||||
announcedAgain(ctx, b, kv, e, r, logf)
|
||||
return
|
||||
}
|
||||
if r.Mode == build.ModePublishAgain || r.Mode == build.ModeFloorAgain {
|
||||
if r.Mode == build.ModePublishAgain || r.Mode == build.ModeFloorAgain || r.Mode == build.ModeOldFormDrop {
|
||||
// A version's hand act (design 51 *Tools of the builder*) may build again, so it is answered beside the watch.
|
||||
go versionActed(ctx, acts, kv, e, r, logf)
|
||||
return
|
||||
@@ -156,13 +156,19 @@ func appendLog(ctx context.Context, js jetstream.JetStream, cred process.Account
|
||||
return nil
|
||||
}
|
||||
|
||||
// versionActed answers a reasks value of builder_publish_again or builder_floor_again, a hand act said in the log.
|
||||
// versionActed answers a reasks value of builder_publish_again, builder_floor_again or builder_old_form's drop, a hand act said in the log.
|
||||
func versionActed(ctx context.Context, acts versions.Acts, kv jetstream.KeyValue, e jetstream.KeyValueEntry, r build.ReAsk, logf func(string, ...any)) {
|
||||
var err error
|
||||
if r.Mode == build.ModePublishAgain {
|
||||
var ag versions.Again
|
||||
ag, r.Answer, err = acts.PublishAgain(ctx, r.Name, r.FP, r.Why, r.By, r.At)
|
||||
r.Do, r.Version = ag.Do, ag.Entry.Version
|
||||
} else if r.Mode == build.ModeOldFormDrop {
|
||||
if acts.DropOldForm == nil {
|
||||
err = errors.New("this builder keeps no old-form window")
|
||||
} else {
|
||||
r.Answer, err = acts.DropOldForm(ctx, r.Name, r.By, r.Why, r.At)
|
||||
}
|
||||
} else {
|
||||
r.Answer, err = acts.FloorAgain(ctx, r.Name)
|
||||
}
|
||||
|
||||
@@ -18,9 +18,10 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/enginereads"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/forms"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"mesh-delivery-builder/internal/build"
|
||||
@@ -150,7 +151,7 @@ func (m *memReads) Get(key string) ([]byte, uint64, bool, error) {
|
||||
return v, 1, ok, nil
|
||||
}
|
||||
func (m *memReads) Put(key string, value []byte) (uint64, error) { m.v[key] = value; return 2, nil }
|
||||
func (m *memReads) Delete(key string) error { delete(m.v, key); return nil }
|
||||
func (m *memReads) Delete(key string) error { delete(m.v, key); return nil }
|
||||
func (m *memReads) Await(string, uint64, func([]byte) bool, time.Duration) ([]byte, error) {
|
||||
return nil, errors.New("no part answers here")
|
||||
}
|
||||
@@ -327,6 +328,39 @@ func aFailedTrunkBuildIsAFinding(t *testing.T) {
|
||||
if _, ok := st.vals[floorKey]; ok {
|
||||
t.Error("a known floor leaves floor-silent")
|
||||
}
|
||||
// DLV-B9, the old form's window: one counted node reading only the first form holds it; seven days on it is
|
||||
// old-form-held about that node; the node stating the newest form closes it, said once, the finding cleared; a
|
||||
// retired node dropped by hand leaves the count.
|
||||
now := time.Unix(0, 0)
|
||||
fs := forms.ArtifactForms{{Name: "registry", First: true, Kinds: []string{"image"}}, {Name: "oci", Kinds: []string{"image"}}}
|
||||
var closings []versions.OldFormClosedBody
|
||||
window := &oldForm{forms: fs, findings: f, logf: t.Logf, now: func() time.Time { return now },
|
||||
said: func(b versions.OldFormClosedBody) { closings = append(closings, b) }}
|
||||
reads := &engineReads{keeper: &enginereads.Keeper{}, window: window, counted: []string{"a", "b"}}
|
||||
newest := []string{forms.FormNeed("oci")}
|
||||
counted := map[string][]string{"a": newest, "b": nil}
|
||||
window.step(context.Background(), counted)
|
||||
heldKey := health.FindingKey(health.ScopeMachine, "b", versions.OldFormHeld)
|
||||
if !window.w.Open || !slices.Equal(window.w.Holding, []string{"b"}) || st.vals[heldKey] != nil {
|
||||
t.Fatalf("one node lacking the newest form: %+v", window.w)
|
||||
}
|
||||
now = now.Add(versions.FindingAfter)
|
||||
window.step(context.Background(), counted)
|
||||
if got, err := health.DecodeFinding(st.vals[heldKey]); err != nil || !strings.Contains(got.Summary, "the node-engine of b") {
|
||||
t.Fatalf("seven days held: the finding = %+v, %v", got, err)
|
||||
}
|
||||
counted["b"] = newest
|
||||
window.step(context.Background(), counted)
|
||||
window.step(context.Background(), counted)
|
||||
if window.w.Open || len(closings) != 1 || !slices.Equal(closings[0].LastHolding, []string{"b"}) || st.vals[heldKey] != nil {
|
||||
t.Errorf("the node back with the newest form: %+v, closings %+v, finding left %v", window.w, closings, st.vals[heldKey] != nil)
|
||||
}
|
||||
if _, err := window.drop(context.Background(), "b", "op@shanks", "retired", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, c := reads.forms(); len(c) != 1 || c["b"] != nil {
|
||||
t.Errorf("a node dropped by hand is still counted: %v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// memFindings is a findings bucket in memory.
|
||||
|
||||
@@ -101,6 +101,24 @@ func NewFloorAgain(name, why, by string, at time.Time) (string, []byte, error) {
|
||||
return "floor." + versions.EscapeToken(name), data, err
|
||||
}
|
||||
|
||||
// ModeOldFormDrop is the Mode of a reasks value of builder_old_form's drop (design 51 *Every build keeps the old
|
||||
// reference form*): a retired node dropped from the count by hand; Name is the node.
|
||||
const ModeOldFormDrop = "old-form-drop"
|
||||
|
||||
// NewOldFormDrop is the value builder_old_form writes to drop a retired node: the key `old-form.<node>`, never a build
|
||||
// key, and the value.
|
||||
func NewOldFormDrop(node, why, by string, at time.Time) (string, []byte, error) {
|
||||
node = strings.TrimSpace(node)
|
||||
if node == "" || strings.ContainsAny(node, ". *>") {
|
||||
return "", nil, fmt.Errorf("node %q is not a node's name", node)
|
||||
}
|
||||
if strings.TrimSpace(why) == "" {
|
||||
return "", nil, errors.New("say why the node leaves the count (why)")
|
||||
}
|
||||
data, err := json.Marshal(ReAsk{V: 1, Why: strings.TrimSpace(why), By: by, At: at.UTC(), Mode: ModeOldFormDrop, Name: node})
|
||||
return "old-form." + node, data, err
|
||||
}
|
||||
|
||||
// ModeBuild is the Mode of a reasks value that asks the key to be built again, not its waiting asks to be asked again.
|
||||
const ModeBuild = "build"
|
||||
|
||||
|
||||
@@ -213,6 +213,8 @@ type Acts struct {
|
||||
Reconsider func(ctx context.Context)
|
||||
// Remove calls a seat's verb remove for one tagged version, reason tag-gone.
|
||||
Remove func(ctx context.Context, seat string, e Entry) error
|
||||
// DropOldForm drops a retired node from the old form's count by hand (builder_old_form): its answer, or why not.
|
||||
DropOldForm func(ctx context.Context, node, by, why string, at time.Time) (string, error)
|
||||
}
|
||||
|
||||
// TagAsked answers one unanswered value of tag-asks. An untag calls remove on each of the branch's tagged versions'
|
||||
|
||||
@@ -1,108 +1,67 @@
|
||||
package versions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/forms"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// The old reference form's window (design 51 *Every build keeps the old reference form*, ADR 0624, ADR 0618 §2-§4,
|
||||
// DLV-B9). The builder writes into every build key each reference form a counted node-engine states, the oldest of them
|
||||
// into the key's single reference and the others beside it; the new form only once every present node-engine states it.
|
||||
// The window closes when no counted node-engine states the old form alone. What each node states is read through the
|
||||
// node-engine seat's verb `manifest-reads` at the builder's start and again on the seat's event
|
||||
// `manifest-reads-changed`, never by a watch of the node-engine's state and never on a timer.
|
||||
// The old reference form's window (design 51 *Every build keeps the old reference form*, ADR 0624 §1, ADR 0618 §2-§4,
|
||||
// DLV-B9). Which forms a build key keeps is mesh-delivery's sdk/forms (Kept, Choose), the counted nodes' statements the
|
||||
// builder's state engine-reads; this is only the window over them: open while a counted node-engine, present or away,
|
||||
// reads of some artifact kind none of the newest form the builder knows for it, so an older form stays in new build keys
|
||||
// for it; closed, said once with `old-form-closed` and a log line, when none does. It moves on what moves a statement
|
||||
// (the node-engine seat's verb `manifest-reads` and its event `manifest-reads-changed`), the placement, a hand drop and
|
||||
// each build key written; never on a timer.
|
||||
|
||||
// NodeReads is one counted node: what its node-engine states of the reference forms, and whether it is present. Stated
|
||||
// nil is a node-engine that states nothing, or does not serve `manifest-reads` yet: read as the baseline's forms.
|
||||
type NodeReads struct {
|
||||
Node string `json:"node"`
|
||||
Present bool `json:"present"`
|
||||
Stated []string `json:"stated,omitempty"`
|
||||
// Serves is false while the node-engine does not serve `manifest-reads`: it counts as stating the baseline.
|
||||
Serves bool `json:"serves"`
|
||||
}
|
||||
// OldFormState is the builder's state of the window: one key, OldFormKey, an OldFormWindow. builder_old_form reads it.
|
||||
const (
|
||||
OldFormState = "old-form"
|
||||
OldFormKey = "window"
|
||||
)
|
||||
|
||||
// FormsAnswer is what the builder writes into a build key, and why.
|
||||
type FormsAnswer struct {
|
||||
// Single is the form written into the key's single reference: the oldest a counted engine states.
|
||||
Single string `json:"single"`
|
||||
// Beside are the other forms written beside it, oldest first.
|
||||
Beside []string `json:"beside,omitempty"`
|
||||
// Open is whether the old form's window is open: a counted engine states the oldest form alone.
|
||||
Open bool `json:"open"`
|
||||
// Holding are the counted nodes whose engine states the oldest form alone (present or away), sorted.
|
||||
Holding []string `json:"holding,omitempty"`
|
||||
}
|
||||
// OldFormBucket is that state's bucket.
|
||||
const OldFormBucket = delivery.BuilderModule + "_" + OldFormState
|
||||
|
||||
// Forms decides the reference forms of new build keys. forms are every reference form the builder can write, oldest
|
||||
// first; baseline what an engine stating nothing reads; dropped the nodes a person dropped from the count by hand
|
||||
// (builder_old_form, a hand act), which then count no more. A node leaves the count when it is retired (by hand here),
|
||||
// never because it is away (ADR 0624 §1).
|
||||
func Forms(forms, baseline []string, nodes []NodeReads, dropped map[string]bool) FormsAnswer {
|
||||
if len(forms) == 0 {
|
||||
return FormsAnswer{}
|
||||
// OldFormHeld is the finding of a node holding the window open for FindingAfter, about that node, cleared when the
|
||||
// window closes or the node no longer holds it.
|
||||
const OldFormHeld = "old-form-held"
|
||||
|
||||
// Holding are the counted nodes, those dropped by hand left out, whose engine reads, of some artifact kind fs knows, not
|
||||
// the newest form of that kind: the nodes for which an older form stays in new build keys (forms.ArtifactForms.Kept). A
|
||||
// node stating nothing reads the first forms (fs.Reads), sorted.
|
||||
func Holding(fs forms.ArtifactForms, counted map[string][]string, dropped map[string]bool) []string {
|
||||
newest := map[string]string{} // kind -> newest form
|
||||
var kinds []string
|
||||
for _, f := range fs {
|
||||
for _, k := range f.Kinds {
|
||||
if _, ok := newest[k]; !ok {
|
||||
kinds = append(kinds, k)
|
||||
}
|
||||
newest[k] = f.Name
|
||||
}
|
||||
}
|
||||
oldest, newest := forms[0], forms[len(forms)-1]
|
||||
type counted struct {
|
||||
node string
|
||||
reads []string
|
||||
}
|
||||
var cs []counted
|
||||
everyPresentNewest := true
|
||||
var holding []string
|
||||
for _, n := range nodes {
|
||||
if dropped[n.Node] {
|
||||
var out []string
|
||||
for node, stated := range counted {
|
||||
if dropped[node] {
|
||||
continue
|
||||
}
|
||||
stated := n.Stated
|
||||
if !n.Serves || len(stated) == 0 {
|
||||
stated = baseline
|
||||
}
|
||||
var reads []string
|
||||
for _, f := range forms {
|
||||
if slices.Contains(stated, f) {
|
||||
reads = append(reads, f)
|
||||
}
|
||||
}
|
||||
if n.Present && !slices.Contains(reads, newest) {
|
||||
everyPresentNewest = false
|
||||
}
|
||||
if len(reads) == 1 && reads[0] == oldest {
|
||||
holding = append(holding, n.Node)
|
||||
}
|
||||
cs = append(cs, counted{n.Node, reads})
|
||||
}
|
||||
// The new form is written once every present engine states it (ADR 0618 §2); each counted engine then gets the
|
||||
// newest form it reads among those written, so a form leaves once no counted engine needs it (ADR 0624 §1). An
|
||||
// engine reading none of them is held by the assigner (ADR 0624 §2), not served by an older form here.
|
||||
keep := map[string]bool{}
|
||||
if everyPresentNewest {
|
||||
keep[newest] = true
|
||||
}
|
||||
for _, c := range cs {
|
||||
for i := len(c.reads) - 1; i >= 0; i-- {
|
||||
if f := c.reads[i]; f != newest || everyPresentNewest {
|
||||
keep[f] = true
|
||||
for _, k := range kinds {
|
||||
if !fs.Reads(stated, newest[k]) {
|
||||
out = append(out, node)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
var kept []string
|
||||
for _, f := range forms {
|
||||
if keep[f] {
|
||||
kept = append(kept, f)
|
||||
}
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
kept = []string{oldest} // nothing counted: the oldest stays, so no engine is cut off
|
||||
}
|
||||
sort.Strings(holding)
|
||||
var beside []string
|
||||
if len(kept) > 1 {
|
||||
beside = kept[1:]
|
||||
}
|
||||
return FormsAnswer{Single: kept[0], Beside: beside, Open: len(holding) > 0, Holding: holding}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// OldFormWindow is the window's record: whether it is open, who holds it and since when, and the nodes dropped by
|
||||
@@ -112,12 +71,12 @@ type OldFormWindow struct {
|
||||
Holding []string `json:"holding,omitempty"`
|
||||
Since map[string]time.Time `json:"since,omitempty"`
|
||||
Dropped []Dropped `json:"dropped,omitempty"`
|
||||
Closed time.Time `json:"closed,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"`
|
||||
}
|
||||
|
||||
// Dropped is one node dropped from the count by hand: a hand act with who and why.
|
||||
// Dropped is one retired node dropped from the count by hand (builder_old_form): a hand act with who and why.
|
||||
type Dropped struct {
|
||||
Node string `json:"node"`
|
||||
By string `json:"by"`
|
||||
@@ -125,27 +84,35 @@ type Dropped struct {
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// OldFormClosedBody is the body of OldFormClosedEvent (design 51): when, the nodes that held it last and the nodes dropped
|
||||
// by hand.
|
||||
type OldFormClosedBody struct {
|
||||
At time.Time `json:"at"`
|
||||
LastHolding []string `json:"last_holding,omitempty"`
|
||||
Dropped []Dropped `json:"dropped,omitempty"`
|
||||
}
|
||||
|
||||
// FindingAfter is how long a node holds the window open before it is the builder's finding naming the node.
|
||||
const FindingAfter = 7 * 24 * time.Hour
|
||||
|
||||
// Step moves the window to what Forms answered at now: it answers the new window, whether it closed in this step (so
|
||||
// the builder says `old-form-closed` and its build-log line once), and the nodes holding it past FindingAfter (the
|
||||
// finding, cleared when the window closes).
|
||||
func (w OldFormWindow) Step(a FormsAnswer, now time.Time) (next OldFormWindow, closed bool, overdue []string) {
|
||||
next = OldFormWindow{Dropped: w.Dropped, Since: map[string]time.Time{}}
|
||||
if !a.Open {
|
||||
// Step moves the window to the nodes holding it at now: it answers the new window, whether it closed in this step (so
|
||||
// the builder says `old-form-closed` and its log line once), and the nodes holding it past FindingAfter (the finding,
|
||||
// cleared for every other node).
|
||||
func (w OldFormWindow) Step(holding []string, now time.Time) (next OldFormWindow, closed bool, overdue []string) {
|
||||
next = OldFormWindow{Dropped: w.Dropped}
|
||||
if len(holding) == 0 {
|
||||
next.Closed, next.LastHolding = w.Closed, w.LastHolding
|
||||
if w.Open {
|
||||
next.Closed, next.LastHolding = now, w.Holding
|
||||
next.Closed, next.LastHolding = now.UTC(), w.Holding
|
||||
return next, true, nil
|
||||
}
|
||||
next.Closed, next.LastHolding = w.Closed, w.LastHolding
|
||||
return next, false, nil
|
||||
}
|
||||
next.Open, next.Holding = true, a.Holding
|
||||
for _, n := range a.Holding {
|
||||
next.Open, next.Holding, next.Since = true, slices.Clone(holding), map[string]time.Time{}
|
||||
for _, n := range holding {
|
||||
since, had := w.Since[n]
|
||||
if !had {
|
||||
since = now
|
||||
since = now.UTC()
|
||||
}
|
||||
next.Since[n] = since
|
||||
if now.Sub(since) >= FindingAfter {
|
||||
@@ -155,7 +122,20 @@ func (w OldFormWindow) Step(a FormsAnswer, now time.Time) (next OldFormWindow, c
|
||||
return next, false, overdue
|
||||
}
|
||||
|
||||
// DroppedSet is the dropped nodes as Forms takes them.
|
||||
// Drop drops a retired node from the count by hand; dropping it again changes nothing.
|
||||
func (w OldFormWindow) Drop(node, by, why string, at time.Time) (OldFormWindow, error) {
|
||||
node, why = strings.TrimSpace(node), strings.TrimSpace(why)
|
||||
if node == "" || why == "" {
|
||||
return w, errors.New("name the retired node (node) and say why it leaves the count (why)")
|
||||
}
|
||||
if w.DroppedSet()[node] {
|
||||
return w, nil
|
||||
}
|
||||
w.Dropped = append(slices.Clone(w.Dropped), Dropped{Node: node, By: by, Why: why, At: at.UTC()})
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// DroppedSet is the dropped nodes, as Holding takes them.
|
||||
func (w OldFormWindow) DroppedSet() map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, d := range w.Dropped {
|
||||
@@ -163,3 +143,36 @@ func (w OldFormWindow) DroppedSet() map[string]bool {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Closing is the event's body of a window that closed in a Step.
|
||||
func (w OldFormWindow) Closing() OldFormClosedBody {
|
||||
return OldFormClosedBody{At: w.Closed, LastHolding: w.LastHolding, Dropped: w.Dropped}
|
||||
}
|
||||
|
||||
// DecodeOldFormWindow reads the state's value; none is a closed window no node holds.
|
||||
func DecodeOldFormWindow(raw []byte) (OldFormWindow, error) {
|
||||
var w OldFormWindow
|
||||
if len(raw) == 0 {
|
||||
return w, nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, &w); err != nil {
|
||||
return OldFormWindow{}, fmt.Errorf("the old-form window is not readable: %w", err)
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// OldFormDescription is builder_old_form's description.
|
||||
const OldFormDescription = "The old reference form's window (design 51 *Every build keeps the old reference form*, ADR 0624): whether it is open, " +
|
||||
"the counted nodes, present or away, whose node-engine reads an older reference form alone and since when (a node holding it seven days " +
|
||||
"is the builder's finding " + OldFormHeld + "), when it last closed and who held it then, and the retired nodes dropped from the count by " +
|
||||
"hand. With drop (a node) and why, drops that retired node from the count by hand, a hand act the builder answers; a node still placed " +
|
||||
"leaves the count by itself only when the placement no longer names it. builder_engine_reads shows what each node's engine states."
|
||||
|
||||
// AnswerOldForm is builder_old_form's read: the window as the state holds it.
|
||||
func AnswerOldForm(r Reader) (OldFormWindow, error) {
|
||||
raw, ok, err := r.Get(OldFormKey)
|
||||
if err != nil || !ok {
|
||||
return OldFormWindow{}, err
|
||||
}
|
||||
return DecodeOldFormWindow(raw)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/forms"
|
||||
)
|
||||
|
||||
// mem is a Store in memory; before runs once before the next Create, to stage a race.
|
||||
@@ -306,49 +308,56 @@ func TestTheBuildersVersions(t *testing.T) {
|
||||
}
|
||||
})
|
||||
t.Run("old form", func(t *testing.T) {
|
||||
forms := []string{"ref:artifact-store", "ref:registry"}
|
||||
base := []string{"ref:artifact-store"}
|
||||
both := []string{"ref:artifact-store", "ref:registry"}
|
||||
// One kind, a first form and a newer one (the builder's own forms are all first today, so no window opens).
|
||||
fs := forms.ArtifactForms{{Name: "registry", First: true, Kinds: []string{"image"}}, {Name: "oci", Kinds: []string{"image"}}}
|
||||
both := []string{forms.FormNeed("oci")}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
nodes []NodeReads
|
||||
counted map[string][]string
|
||||
dropped map[string]bool
|
||||
want FormsAnswer
|
||||
want []string
|
||||
}{
|
||||
{"one present node lacking it keeps the old form alone",
|
||||
[]NodeReads{{Node: "a", Present: true, Serves: true, Stated: both}, {Node: "b", Present: true, Serves: true, Stated: base}}, nil,
|
||||
FormsAnswer{Single: "ref:artifact-store", Open: true, Holding: []string{"b"}}},
|
||||
{"a node not serving manifest-reads counts as the baseline",
|
||||
[]NodeReads{{Node: "a", Present: true, Serves: true, Stated: both}, {Node: "b", Present: true, Stated: both}}, nil,
|
||||
FormsAnswer{Single: "ref:artifact-store", Open: true, Holding: []string{"b"}}},
|
||||
{"every present node states it: the new form beside the old, an away old node keeping the old in the single reference",
|
||||
[]NodeReads{{Node: "a", Present: true, Serves: true, Stated: both}, {Node: "c", Serves: true, Stated: base}}, nil,
|
||||
FormsAnswer{Single: "ref:artifact-store", Beside: []string{"ref:registry"}, Open: true, Holding: []string{"c"}}},
|
||||
{"the away node back with the new engine closes the window",
|
||||
[]NodeReads{{Node: "a", Present: true, Serves: true, Stated: both}, {Node: "c", Present: true, Serves: true, Stated: []string{"ref:registry"}}}, nil,
|
||||
FormsAnswer{Single: "ref:registry"}},
|
||||
{"a retired node dropped by hand counts no more",
|
||||
[]NodeReads{{Node: "a", Present: true, Serves: true, Stated: []string{"ref:registry"}}, {Node: "c", Serves: true, Stated: base}}, map[string]bool{"c": true},
|
||||
FormsAnswer{Single: "ref:registry"}},
|
||||
{"one node lacking the new form holds the old", map[string][]string{"a": both, "b": nil}, nil, []string{"b"}},
|
||||
{"an away node counts, stating its last", map[string][]string{"a": both, "c": {"bus-state"}}, nil, []string{"c"}},
|
||||
{"every counted node reads the newest: closed", map[string][]string{"a": both, "c": both}, nil, nil},
|
||||
{"a retired node dropped by hand counts no more", map[string][]string{"a": both, "c": nil}, map[string]bool{"c": true}, nil},
|
||||
{"the builder's own forms: no window", nil, nil, nil},
|
||||
} {
|
||||
if got := Forms(forms, base, c.nodes, c.dropped); !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("%s: Forms = %+v, want %+v", c.name, got, c.want)
|
||||
if got := Holding(fs, c.counted, c.dropped); !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("%s: Holding = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
if got := Holding(forms.Current, map[string][]string{"a": nil}, nil); got != nil {
|
||||
t.Errorf("forms.Current, a node stating nothing: Holding = %v, want none", got)
|
||||
}
|
||||
t0 := time.Unix(0, 0)
|
||||
w, closed, overdue := OldFormWindow{}.Step(FormsAnswer{Open: true, Holding: []string{"c"}}, t0)
|
||||
w, closed, overdue := OldFormWindow{}.Step([]string{"c"}, t0)
|
||||
if closed || len(overdue) != 0 || !w.Open {
|
||||
t.Fatalf("Step = %+v %v %v", w, closed, overdue)
|
||||
}
|
||||
w, _, overdue = w.Step(FormsAnswer{Open: true, Holding: []string{"c"}}, t0.Add(FindingAfter))
|
||||
w, _, overdue = w.Step([]string{"c"}, t0.Add(FindingAfter))
|
||||
if !reflect.DeepEqual(overdue, []string{"c"}) {
|
||||
t.Errorf("seven days held: overdue %v, want c", overdue)
|
||||
}
|
||||
w, closed, _ = w.Step(FormsAnswer{}, t0.Add(FindingAfter+time.Hour))
|
||||
if !closed || w.Open || !reflect.DeepEqual(w.LastHolding, []string{"c"}) {
|
||||
w, err := w.Drop("d", "op@shanks", "retired", t0)
|
||||
if err != nil || !w.DroppedSet()["d"] {
|
||||
t.Fatalf("Drop = %+v, %v", w, err)
|
||||
}
|
||||
if again, _ := w.Drop("d", "op@shanks", "again", t0); len(again.Dropped) != 1 {
|
||||
t.Errorf("a node dropped twice is kept twice: %+v", again.Dropped)
|
||||
}
|
||||
if _, err := w.Drop("e", "op@shanks", "", t0); err == nil {
|
||||
t.Error("a drop with no why is taken")
|
||||
}
|
||||
w, closed, _ = w.Step(nil, t0.Add(FindingAfter+time.Hour))
|
||||
if !closed || w.Open || !reflect.DeepEqual(w.LastHolding, []string{"c"}) || w.Closing().Dropped[0].Node != "d" {
|
||||
t.Errorf("closing: %+v closed=%v", w, closed)
|
||||
}
|
||||
if _, again, _ := w.Step(FormsAnswer{}, t0.Add(FindingAfter+2*time.Hour)); again {
|
||||
raw, _ := json.Marshal(w)
|
||||
if back, err := DecodeOldFormWindow(raw); err != nil || !back.Closed.Equal(w.Closed) {
|
||||
t.Errorf("the window read back = %+v, %v", back, err)
|
||||
}
|
||||
if _, again, _ := w.Step(nil, t0.Add(FindingAfter+2*time.Hour)); again {
|
||||
t.Error("a closed window closes again: old-form-closed would be said twice")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -93,7 +93,7 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1
|
||||
if m.Module != "mesh-delivery-builder" {
|
||||
t.Errorf("module = %q", m.Module)
|
||||
}
|
||||
if want := []state{{Name: "builds", History: 5}, {Name: "engine-reads", History: 1}, {Name: "findings", History: 5}, {Name: "floor-waits", History: 1}, {Name: "hearing", History: 1}, {Name: "instances", History: 1}, {Name: "queues", History: 1}, {Name: "reasks", History: 1}, {Name: "tag-asks", History: 1}, {Name: "versions", History: 1}}; !reflect.DeepEqual(m.State, want) { // delivery.BuilderBuildsHistory
|
||||
if want := []state{{Name: "builds", History: 5}, {Name: "engine-reads", History: 1}, {Name: "findings", History: 5}, {Name: "floor-waits", History: 1}, {Name: "hearing", History: 1}, {Name: "instances", History: 1}, {Name: "old-form", History: 1}, {Name: "queues", History: 1}, {Name: "reasks", History: 1}, {Name: "tag-asks", History: 1}, {Name: "versions", History: 1}}; !reflect.DeepEqual(m.State, want) { // delivery.BuilderBuildsHistory
|
||||
t.Errorf("state = %+v, want %+v", m.State, want)
|
||||
}
|
||||
// 1887/A4c: the builder chooses no node, so it reads neither presence nor have, invokes no build module's tool and
|
||||
@@ -135,7 +135,7 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1
|
||||
}
|
||||
// 1945: the builder's tools (builder_seat_queue: note 62), served by its tools bundle, and the event it says when it asks an ask again.
|
||||
if want := []string{"builder_announce_again", "builder_ask_again", "builder_build_again", "builder_builds_since", "builder_engine_reads", "builder_floor_again", "builder_floor_waits",
|
||||
"builder_publish_again", "builder_seat_queue", "builder_stuck", "builder_tag", "builder_tag_asks", "builder_untag", "builder_versions"}; !reflect.DeepEqual(sorted(m.Tools), want) {
|
||||
"builder_old_form", "builder_publish_again", "builder_seat_queue", "builder_stuck", "builder_tag", "builder_tag_asks", "builder_untag", "builder_versions"}; !reflect.DeepEqual(sorted(m.Tools), want) {
|
||||
t.Errorf("tools = %v, want %v", m.Tools, want)
|
||||
}
|
||||
// Design 51 *Events of the builder*: numbered and old-form-closed on the family version, apart from the builds' ends.
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"builder_engine_reads",
|
||||
"builder_floor_again",
|
||||
"builder_floor_waits",
|
||||
"builder_old_form",
|
||||
"builder_publish_again",
|
||||
"builder_seat_queue",
|
||||
"builder_stuck",
|
||||
@@ -66,6 +67,7 @@
|
||||
{ "name": "floor-waits", "history": 1 },
|
||||
{ "name": "hearing", "history": 1 },
|
||||
{ "name": "instances", "history": 1 },
|
||||
{ "name": "old-form", "history": 1 },
|
||||
{ "name": "queues", "history": 1 },
|
||||
{ "name": "reasks", "history": 1 },
|
||||
{ "name": "tag-asks", "history": 1 },
|
||||
|
||||
Reference in New Issue
Block a user