CAT-D5: the publishing builds are written with compare-and-set on the key's revision, so two first publishes at once both count

A blind write after a read could lose the build of the publish that wrote first until a bundle restart learned it again.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-04 13:16:55 +01:00
parent 32699e35b8
commit e34fcbeda3
2 changed files with 114 additions and 9 deletions
@@ -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{}
@@ -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")
}
}