diff --git a/go/views/held_writer.go b/go/views/held_writer.go new file mode 100644 index 00000000..2ca92cfd --- /dev/null +++ b/go/views/held_writer.go @@ -0,0 +1,90 @@ +package views + +import ( + "context" + "encoding/json" + "errors" + "sync" +) + +// HeldWriter is the finding writer of a part whose findings are raised as they happen, not judged afresh (design 47 G A-29, +// ยง686): it holds the findings it has written, seeded from the keys of its state `findings` before it starts, under a lock of +// its own (never the part's: the writer calls Observe under its own lock while the part writes under its), and its Observe +// answers them, so a statement says exactly what the part holds. Write and Clear update the held set and then the writer's. +// It is a Writer[F, C, S, A] with that Observe, so no part writes the adapter. +type HeldWriter[F, C, S, A any] struct { + *Writer[F, C, S, A] + mu sync.Mutex + held map[string]F +} + +// NewHeldWriter makes the writer for cfg, whose Observe is none (a given Observe is refused): the findings that stand in +// cfg.State are read at once, each key's value as the writer kept it (a value that is no finding of F is left to the next +// statement, which deletes its key when it is of a kind the writer owns). +func NewHeldWriter[F, C, S, A any](cfg WriterConfig[F, C, S, A]) (*HeldWriter[F, C, S, A], error) { + if cfg.Observe != nil { + return nil, errors.New("a held finding writer observes what it holds: it takes no Observe") + } + if cfg.State == nil { + return nil, errors.New("a finding writer needs the state `findings`") + } + h := &HeldWriter[F, C, S, A]{held: map[string]F{}} + keys, err := cfg.State.Keys() + if err != nil { + return nil, err + } + for _, k := range keys { + e, err := cfg.State.Get(k) + if err != nil { + return nil, err + } + var f F + if e == nil || json.Unmarshal(e.Value, &f) != nil { + continue + } + if m, err := wireOf(&f); err == nil && keyOf(m) == k { + h.held[k] = f + } + } + cfg.Observe = func(context.Context) (Observed[F], error) { + h.mu.Lock() + defer h.mu.Unlock() + var o Observed[F] + for _, f := range h.held { + o.Findings = append(o.Findings, f) + } + return o, nil + } + w, err := NewWriter(cfg) + if err != nil { + return nil, err + } + h.Writer = w + return h, nil +} + +// Write holds the finding and writes it through the writer. +func (h *HeldWriter[F, C, S, A]) Write(ctx context.Context, finding F) error { + m, err := wireOf(&finding) + if err != nil { + return err + } + if err := CheckFinding(m); err != nil { + return err + } + if !h.Writer.owns(str(m, "kind")) { + return h.Writer.Write(ctx, finding) // refused by the writer, naming the kind, and held no more than it is + } + h.mu.Lock() + h.held[keyOf(m)] = finding + h.mu.Unlock() + return h.Writer.Write(ctx, finding) +} + +// Clear stops holding the finding of scope, id and kind and clears it through the writer. +func (h *HeldWriter[F, C, S, A]) Clear(ctx context.Context, scope, id, kind, why string) error { + h.mu.Lock() + delete(h.held, FindingKey(scope, id, kind)) + h.mu.Unlock() + return h.Writer.Clear(ctx, scope, id, kind, why) +} diff --git a/go/views/writer_test.go b/go/views/writer_test.go index e91af2e2..17685785 100644 --- a/go/views/writer_test.go +++ b/go/views/writer_test.go @@ -463,3 +463,48 @@ func TestWriterRefusesABodyWithoutAContractField(t *testing.T) { t.Fatalf("a writer with no subject for findings-asked: %v", err) } } + +// A part whose findings are raised as they happen holds them in a writer that observes from its own state (design 47 G A-29): +// the findings standing in the state are seeded before it starts, a statement says what is held, Write and Clear keep the held +// set, and an Observe of the part's own is refused. +func TestHeldWriterStatesWhatItHoldsFromItsOwnState(t *testing.T) { + state := &fakeState{m: map[string][]byte{}} + raw, _ := json.Marshal(finding("seeded", "stood")) + state.m["module.seeded.k"] = raw + state.m["module.junk.k"] = []byte(`"not a finding"`) + var sent []said + cfg := testConfig{Module: "m", State: state, Node: "n1", Asked: asked, + Publish: func(_ context.Context, subject string, data []byte, _ string) error { + sent = append(sent, said{subject, data}) + return nil + }, + Listen: func(context.Context, string, func([]byte)) (func(), error) { return func() {}, nil }} + cfg.Observe = func(context.Context) (testObserved, error) { return testObserved{}, nil } + if _, err := views.NewHeldWriter(cfg); err == nil { + t.Fatal("a held writer took an Observe of its own") + } + cfg.Observe = nil + h, err := views.NewHeldWriter(cfg) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + if err := h.Write(ctx, finding("raised", "now")); err != nil { + t.Fatal(err) + } + if err := h.Write(ctx, testFinding{Kind: "k", ID: "no-scope"}); err == nil { + t.Fatal("a finding that breaks a rule was held") + } + if err := h.Clear(ctx, "module", "seeded", "k", "no longer"); err != nil { + t.Fatal(err) + } + sent = nil + if err := h.State(ctx, "a1"); err != nil { + t.Fatal(err) + } + var stated testStated + if len(sent) != 1 || sent[0].subject != subjects.Stated || json.Unmarshal(sent[0].data, &stated) != nil || + len(stated.Findings) != 1 || stated.Findings[0].ID != "raised" || stated.Asked != "a1" { + t.Fatalf("the statement says %+v after %d events", stated, len(sent)) + } +}