Write and take in a merge that leaves no module.json, so a module-less repository's reach is not held for ever and a removed last module is dropped (issue 1126)

The forge bridge wrote no merged message for such a merge, so taken.<repo> never moved
and the view held 'the indexer has not yet taken in <head>' (hq #490-#492). The bridge
now writes it with no trees; the indexer drops every module of the repository on it,
after the same order check as any merged message. The view needed no change: with the
taken key moving it reaches the merge, which its new row proves.

Needs mesh-sdk fix/1126-no-module-reach (a merged message may carry no trees).
This commit is contained in:
2026-11-03 16:45:06 +01:00
parent 9f49bc92ad
commit 6bc16d74db
6 changed files with 64 additions and 20 deletions
@@ -74,10 +74,6 @@ func handle(ctx context.Context, m delivered, pub publisher, trees bridge.Trees,
logger.Printf("%v; heard again in %s", err, retryDelay)
_ = m.NakWithDelay(retryDelay)
return
case errors.Is(err, bridge.ErrNoModules):
logger.Printf("%v; no merged message", err)
_ = m.Ack()
return
case errors.Is(err, bridge.ErrNotHandled):
logger.Printf("%v; nothing written", err)
_ = m.Ack()
@@ -29,9 +29,6 @@ var ErrNotHandled = errors.New("event not handled")
// heard again later.
var ErrRetry = errors.New("the forge could not say")
// ErrNoModules says a merge into the trunk left a repository with no module.json anywhere: no merged message.
var ErrNoModules = errors.New("no module.json in the merge")
// Trunk is the branch a merged message is written for.
const Trunk = "main"
@@ -100,7 +97,9 @@ func unreadable(subject, why string, args ...any) error {
// Convert turns one event of the forge module, by its bus subject and body, into a message. An event it cannot read
// is an ErrUnreadable error naming the subject and why; an event it writes nothing for is ErrNotHandled. A merge into
// the trunk asks trees once and becomes a merged message; a merge into any other branch is a closed pull message, as the
// pull request left its group. A forge that cannot say is ErrRetry; a merge with no module.json is ErrNoModules.
// pull request left its group. A forge that cannot say is ErrRetry. A merge that leaves no module.json anywhere is a merged message with no
// trees all the same (issue 1126): the indexer takes it in and drops the repository's modules, and the view reads the
// repository as reached at it.
func Convert(subject string, data []byte, trees Trees, files Files) (Message, error) {
return ConvertKept(subject, data, trees, files, nil)
}
@@ -294,9 +293,6 @@ func merged(subject string, p pull, trees Trees, files Files, kept Kept) (Messag
if err != nil {
return Message{}, fmt.Errorf("%w: %s: the trees of %s at the merge commit %s: %v", ErrRetry, subject, repository, p.MergeSHA, err)
}
if len(dirs) == 0 {
return Message{}, fmt.Errorf("%w: %s: %s at the merge commit %s", ErrNoModules, subject, repository, p.MergeSHA)
}
names := make([]string, 0, len(dirs))
for dir := range dirs {
names = append(names, dir)
@@ -339,7 +335,11 @@ func merged(subject string, p pull, trees Trees, files Files, kept Kept) (Messag
if err != nil {
return Message{}, unreadable(subject, "%v", err)
}
return Message{Event: event, Data: body}, nil
msg := Message{Event: event, Data: body}
if len(dirs) == 0 {
msg.Said = fmt.Sprintf("%s at the merge commit %s holds no module.json: a merged message with no module directories", repository, p.MergeSHA)
}
return msg, nil
}
// openPull is an entry of gitea_open_pulls's answer (mesh-catalog modules/gitea/client.ts:369-381 openPulls, with the
@@ -269,11 +269,19 @@ func TestTreesRefusedWritesNothingAndSaysWhy(t *testing.T) { // 1236/AC3 (the co
}
}
func TestNoModuleJSONAnywhereWritesNoMergedMessage(t *testing.T) { // 1236 (notes: a repository with no module.json)
// 1126 (note 94): a merge that leaves no module.json anywhere (hq, or a repository whose last module was removed) is a merged
// message with no trees, so the indexer takes it in and the repository's reach is not held for ever.
func TestNoModuleJSONAnywhereWritesAMergedMessageWithNoTrees(t *testing.T) {
var calls []call
msg, err := Convert(mergedSubject, mergedBody(nil), treesOf(&calls, "r0", map[string]string{}, nil), anyFiles)
if !errors.Is(err, ErrNoModules) || msg.Data != nil {
t.Errorf("err = %v data = %s", err, msg.Data)
if err != nil {
t.Fatal(err)
}
f, err := delivery.DecodeForge(msg.Data)
want := delivery.Forge{V: delivery.Version, Kind: delivery.ForgeMerged, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12,
Head: head, Merge: merge, Root: "r0"}
if err != nil || msg.Event != "forge.novox.mesh-delivery.merged" || !delivery.SameForge(f, want) || !strings.Contains(msg.Said, "no module.json") {
t.Errorf("event %q message %+v said %q, %v; want a merged message with no trees", msg.Event, f, msg.Said, err)
}
}
@@ -117,8 +117,10 @@ func (ix *Indexer) applyMoving(ctx context.Context, f delivery.Forge, drop bool,
overlay[key] = next
}
// A module of this repository the merge commit holds no module.json for is gone from the trunk, and so is its key
// (issue 1900). A message that carries no manifests (an older writer) or one that cannot be read whole says nothing.
if drop && len(f.Manifests) > 0 && every {
// (issue 1900). A message with no trees says the merge commit holds no module.json at all, so every module of the
// repository goes (issue 1126 note 94: a repository that removed its last module). A message with trees but no
// manifests (an older writer) or one that cannot be read whole says nothing.
if drop && (len(f.Manifests) > 0 || len(f.Trees) == 0) && every {
if err := ix.dropRemoved(ctx, f.Repository, held, func(key string, _ delivery.Module) bool { return named[key] }); err != nil {
return w, err
}
@@ -219,6 +219,32 @@ func TestAnUnchangedModuleWritesNothing(t *testing.T) { // 1239 (unchanged value
}
return ix.Apply(context.Background(), mergedMsg(repo, three))
}},
// Issue 1126 note 94: a merge that leaves no module.json anywhere (hq, or a repository whose last module was removed) is a
// merged message with no trees; it drops the repository's modules and moves its taken key, and an older one, heard late,
// goes through the same order check and drops nothing.
{name: "a merge that leaves no module.json drops every module and is taken in", writes: 3, commit: map[string]string{"a": "", "b": "", "c": ""}, taken: next,
said: "modules written: none",
then: func(ix *Indexer) error {
m := mergedMsg(repo, nil)
m.Merge = next
return ix.Apply(context.Background(), m)
}},
{name: "an earlier merge with no module.json heard after a later one drops nothing", writes: 0, commit: map[string]string{"a": merge, "b": merge, "c": merge}, taken: next,
said: "the merged message is older than taken.novox/mesh-delivery at cccccccccccc",
then: func(ix *Indexer) error {
const earlier = "dddddddddddddddddddddddddddddddddddddddd"
ix.Contains = func(_ context.Context, _, commit, in string) (bool, error) {
return commit == in || commit == earlier && in == next, nil
}
later := mergedMsg(repo, three)
later.Merge = next
if err := ix.Apply(context.Background(), later); err != nil {
return err
}
gone := mergedMsg(repo, nil)
gone.Merge = earlier
return ix.Apply(context.Background(), gone)
}},
// Review round 2: a 502 whose words carry "404" inside a sha is no not-found; the late message is left for redelivery.
{name: "a late message whose order the forge fails with a 502 writes nothing and is left for redelivery", writes: 1, commit: map[string]string{"a": next, "b": merge, "c": merge}, taken: next, err: true,
said: "502 Bad Gateway",
@@ -305,8 +331,8 @@ func TestAnUnchangedModuleWritesNothing(t *testing.T) { // 1239 (unchanged value
if got := mods.writes - writes; got != tc.writes {
t.Errorf("%d values written, want %d", got, tc.writes)
}
for module, want := range tc.commit {
if m, ok := moduleOf(t, mods, module); !ok || m.Commit != want {
for module, want := range tc.commit { // "" is a module the merge deleted
if m, ok := moduleOf(t, mods, module); ok != (want != "") || m.Commit != want {
t.Errorf("%s = %+v (%v), want commit %.8s", module, m, ok, want)
}
}
@@ -13,7 +13,7 @@ import (
func TestTheViewDerivesHowFarEachRepositoryReached(t *testing.T) {
const web, sdk = "novox/web", "novox/sdk"
commitA, commitB, commitC := strings.Repeat("a", 40), strings.Repeat("b", 40), strings.Repeat("c", 40)
commitS, commitX := strings.Repeat("5", 40), strings.Repeat("e", 40)
commitS, commitX, commitD := strings.Repeat("5", 40), strings.Repeat("e", 40), strings.Repeat("d", 40)
const fpMail = "src2:3333333333333333333333333333333333333333333333333333333333333333"
mod := func(module, fp, commit string, src uint64, history bool) Entry {
v, err := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: strings.Repeat("a", 64), Repository: web, Commit: commit, Src: src, Path: module})
@@ -84,6 +84,18 @@ func TestTheViewDerivesHowFarEachRepositoryReached(t *testing.T) {
then: []Entry{taken(web, commitB)}},
{name: "a change whose source revision is not known yet holds no head the source named", reached: commitC,
then: []Entry{mod("web", "src2:"+strings.Repeat("9", 64), commitX, 0, true), mod("web", "src2:"+strings.Repeat("9", 64), commitX, 0, false)}},
// 1126 note 94: a merge that removes the repository's last module.json moves nothing; once the indexer dropped its
// modules and took the merge in, the repository reaches it, held by nothing, as the library with no module does.
{name: "a merge that removed the last module is reached at the merge the indexer took in", reached: commitD,
then: func() []Entry {
head := src(13, commitD)
current := head
current.History = false
gone := func(module string, history bool) Entry {
return Entry{Bucket: delivery.ModulesBucket, Key: module, Deleted: true, At: t0, History: history}
}
return []Entry{head, current, gone("web", true), gone("mail", true), gone("web", false), gone("mail", false), taken(web, commitD)}
}()},
// 1126: the view started while the bus did not answer its writes; its reach and its event are made at the next change it
// takes in, though the derivation is the same, so the state reach never keeps an older have than node_haves says.
{name: "a write and an event refused at the start are made at the next change", reached: commitC, refused: true,