diff --git a/modules/distribution/cmd/distribution-tools/main.go b/modules/distribution/cmd/distribution-tools/main.go index 529b70c3..2f0bd5a1 100644 --- a/modules/distribution/cmd/distribution-tools/main.go +++ b/modules/distribution/cmd/distribution-tools/main.go @@ -154,23 +154,62 @@ func learnPublishing(windows kept[holder.Window], caller string) { logf("node_haves names no build of %s: the direct-write window counts it at its next publish", caller) return } - pub := kept[holder.Publishing]{state: "windows", key: func(p holder.Publishing) string { return holder.PublishingKey }} - cur, _, err := pub.Get(holder.PublishingKey) + learnt, err := learnInto(stdio.State("windows"), module, a.Keys[caller].FP, learnAttempts) if err != nil { - logf("the publishing builds cannot be read: %v", err) + logf("the publishing build of %s is not kept: %v", caller, err) return } - cur.Name = holder.PublishingKey - if cur.Learn(module, a.Keys[caller].FP) { - if err := pub.Put(cur); err != nil { - logf("the publishing build of %s is not kept: %v", caller, err) - return - } + if learnt { logf("%s's build %s publishes through the seats", module, a.Keys[caller].FP) } learned.Store(caller, true) } +// learnAttempts bounds learnInto's tries against other writers of the key. +const learnAttempts = 8 + +// casState is the part of a kept state learnInto writes through: a read with its revision, a create, an update at a +// revision (stdio.KeptState). +type casState interface { + Get(key string) (*stdio.StateEntry, error) + Create(key string, value any) (uint64, error) + Update(key string, value any, revision uint64) (uint64, error) +} + +// learnInto adds fp for module to the publishing builds kept at holder.PublishingKey with compare-and-set on the key's +// revision: two first publishes at once each read, add and write only if nobody wrote meanwhile, else read again, so +// neither is lost. It answers whether fp was new. +func learnInto(state casState, module, fp string, attempts int) (bool, error) { + for i := 0; i < attempts; i++ { + e, err := state.Get(holder.PublishingKey) + if err != nil { + return false, err + } + var cur holder.Publishing + if e != nil { + if err := json.Unmarshal(e.Value, &cur); err != nil { + return false, fmt.Errorf("%s holds no publishing builds: %w", holder.PublishingKey, err) + } + } + cur.Name = holder.PublishingKey + if !cur.Learn(module, fp) { + return false, nil + } + if e == nil { + _, err = state.Create(holder.PublishingKey, cur) + } else { + _, err = state.Update(holder.PublishingKey, cur, e.Revision) + } + if err == nil { + return true, nil + } + if !errors.Is(err, stdio.ErrChanged) { + return false, err + } + } + return false, fmt.Errorf("%s was written by another writer first %d times: %w", holder.PublishingKey, attempts, stdio.ErrChanged) +} + func arg[T any](args map[string]any) (T, error) { var v T clean := map[string]any{} diff --git a/modules/distribution/cmd/distribution-tools/publishing_test.go b/modules/distribution/cmd/distribution-tools/publishing_test.go new file mode 100644 index 00000000..5a208579 --- /dev/null +++ b/modules/distribution/cmd/distribution-tools/publishing_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "encoding/json" + "reflect" + "testing" + + stdio "git.novox.be/novox/mesh-sdk/go" + + "distribution/internal/holder" +) + +// casMem is a kept state with revisions; before writes it lets another writer write first, once. +type casMem struct { + value []byte + rev uint64 + before func() +} + +func (m *casMem) Get(string) (*stdio.StateEntry, error) { + if m.value == nil { + return nil, nil + } + return &stdio.StateEntry{Value: m.value, Revision: m.rev}, nil +} +func (m *casMem) write(v any, at uint64) (uint64, error) { + if f := m.before; f != nil { + m.before = nil + f() + } + if m.rev != at { + return 0, stdio.ErrChanged + } + m.value, _ = json.Marshal(v) + m.rev++ + return m.rev, nil +} +func (m *casMem) Create(_ string, v any) (uint64, error) { return m.write(v, 0) } +func (m *casMem) Update(_ string, v any, rev uint64) (uint64, error) { return m.write(v, rev) } + +// TestTwoFirstPublishesAtOnceAreBothKept: the publishing builds are written with compare-and-set, so a writer that lost +// the race reads again and neither build is lost (A3f finding 2). +func TestTwoFirstPublishesAtOnceAreBothKept(t *testing.T) { + m := &casMem{} + m.before = func() { + // Another tools process learns mesh-build-go's build first. + m.value, _ = json.Marshal(holder.Publishing{Name: holder.PublishingKey, Modules: map[string][]string{"mesh-build-go": {"fp-go"}}}) + m.rev = 1 + } + if learnt, err := learnInto(m, "mesh-build-node", "fp-node", 3); err != nil || !learnt { + t.Fatalf("learnInto = %v, %v", learnt, err) + } + var got holder.Publishing + _ = json.Unmarshal(m.value, &got) + want := map[string][]string{"mesh-build-go": {"fp-go"}, "mesh-build-node": {"fp-node"}} + if !reflect.DeepEqual(got.Modules, want) { + t.Errorf("kept %v, want both builds %v", got.Modules, want) + } + if learnt, err := learnInto(m, "mesh-build-node", "fp-node", 3); err != nil || learnt { + t.Errorf("learning a kept build again = %v, %v, want not new and no write", learnt, err) + } + m.before = func() { m.rev++ } + if _, err := learnInto(m, "mesh-build-oci", "fp-oci", 1); err == nil { + t.Error("a write lost on every attempt answered no error") + } +}