From 2b15cb57f097a0e51bbb86f00f0d677fb6e2bf30 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 12 Oct 2026 19:24:34 +0200 Subject: [PATCH] Ask again for a folder that moved, and carry entries left while a disk was away (hq ADR 0325) The engine names the filesystem a waiting folder is on; a warrant is recorded for that filesystem, and a folder on another is asked about again and not taken there meanwhile. Entries the engine could not take out while their disk was away, stated before a send, do not hold that delivery's gate. mesh-host pinned to d2d9fbbd. --- cmd/mesh-controller/kept_folders.go | 69 +++++++++++-------- cmd/mesh-controller/kept_folders_test.go | 69 ++++++++++++++++++- cmd/mesh-controller/module_health.go | 18 ++++- cmd/mesh-controller/operator_wait.go | 19 ++--- cmd/mesh-controller/plan.go | 15 ++++ go.mod | 2 +- go.sum | 4 +- internal/inventory/health.go | 3 + internal/inventory/identities.go | 19 +++-- internal/inventory/identities_test.go | 12 ++++ ...iven-an-identity-and-a-folder-is-taken.sql | 6 +- internal/link/protocol.go | 9 +++ testdata/beside/CAPTURED | 2 +- vendor/modules.txt | 4 +- 14 files changed, 195 insertions(+), 56 deletions(-) diff --git a/cmd/mesh-controller/kept_folders.go b/cmd/mesh-controller/kept_folders.go index b13a7942..087cc599 100644 --- a/cmd/mesh-controller/kept_folders.go +++ b/cmd/mesh-controller/kept_folders.go @@ -48,24 +48,27 @@ type folderTake struct { // Access is the resource id of one access to it in the machine's declaration: what the take act names. Access string `json:"access"` // Accesses are the access ids (each module's own) that place it, Modules the modules waiting on it, both sorted. - Accesses []string `json:"accesses"` - Modules []string `json:"modules"` - At time.Time `json:"at"` + Accesses []string `json:"accesses"` + Modules []string `json:"modules"` + // Placed is the filesystem the folder is on, as the engine names it without a path: the warrant is for that one. + Placed string `json:"placed,omitempty"` + At time.Time `json:"at"` } // takeVerb is the verb a take's answer binds: the controller's own, performed by itself on the warrant. const takeVerb = askerName + ".take" // key is the folder on its machine: what one ask is kept for. -func (t folderTake) key() string { return t.Node + "\x00" + filepath.Clean(t.Path) } +// The filesystem is part of it: a folder that moved to another disk is asked about again. +func (t folderTake) key() string { return t.Node + "\x00" + filepath.Clean(t.Path) + "\x00" + t.Placed } // digest binds everything the take does and was said for: the machine, the folder, the access it names and the // modules the operator was told. func (t folderTake) digest() string { raw, _ := json.Marshal(struct { - Node, Path, Access string - Modules []string - }{t.Node, filepath.Clean(t.Path), t.Access, t.Modules}) + Node, Path, Access, Placed string + Modules []string + }{t.Node, filepath.Clean(t.Path), t.Access, t.Placed, t.Modules}) sum := sha256.Sum256(raw) return "sha256:" + hex.EncodeToString(sum[:]) } @@ -246,7 +249,7 @@ func takeFolderIn(open *stores, ask func(ctx context.Context, act link.AccessAct return "", fmt.Errorf("%s refused to take the folder: %s", t.Node, answer.Refused) } if err := open.inventory.RecordTaken(ctx, t.Node, inventory.TakenFolder{Path: t.Path, By: by, Ask: askID, - Warrant: askID, Said: answer.Said}); err != nil { + Warrant: askID, Said: answer.Said, Placed: t.Placed}); err != nil { return "", fmt.Errorf("%s started the take (%s), and it could NOT be recorded: %w", t.Node, answer.Said, err) } return fmt.Sprintf("%s takes the folder for %s: %s", t.Node, strings.Join(t.Modules, ", "), answer.Said), nil @@ -256,8 +259,9 @@ func takeFolderIn(open *stores, ask func(ctx context.Context, act link.AccessAct // waitingFolder is one access wait excused for a folder not yet taken, as foldersWaiting gathers them. type waitingFolder struct { module, access string - // resource is the access's resource id on the machine, as the engine stated it. - resource string + // resource is the access's resource id on the machine, as the engine stated it; placed the filesystem its folder + // is on, without its path. + resource, placed string } // foldersWaiting is every folder waiting to be kept across the mesh (ADR 0325 §7, §8): from each machine's newest @@ -302,7 +306,7 @@ func foldersWaiting(ctx context.Context, inv *inventory.Inventory, healths map[s out[machine] = map[string][]waitingFolder{} } out[machine][path] = append(out[machine][path], waitingFolder{module: r.Module, access: w.Access, - resource: r.Resource}) + resource: r.Resource, placed: w.Placed}) } } } @@ -327,26 +331,33 @@ func foldersIn(open *stores) func(ctx context.Context) ([]folderTake, error) { func takesOf(waiting map[string]map[string][]waitingFolder) []folderTake { var out []folderTake for machine, folders := range waiting { - for path, ws := range folders { - t := folderTake{Node: machine, Path: path} - seenM, seenA := map[string]bool{}, map[string]bool{} - var resources []string - for _, w := range ws { - if !seenM[w.module] { - seenM[w.module] = true - t.Modules = append(t.Modules, w.module) + for path, all := range folders { + // One take per filesystem the waits name: they agree but for a moment while a disk is swapped. + byPlaced := map[string][]waitingFolder{} + for _, w := range all { + byPlaced[w.placed] = append(byPlaced[w.placed], w) + } + for placed, ws := range byPlaced { + t := folderTake{Node: machine, Path: path, Placed: placed} + seenM, seenA := map[string]bool{}, map[string]bool{} + var resources []string + for _, w := range ws { + if !seenM[w.module] { + seenM[w.module] = true + t.Modules = append(t.Modules, w.module) + } + if !seenA[w.access] { + seenA[w.access] = true + t.Accesses = append(t.Accesses, w.access) + } + resources = append(resources, w.resource) } - if !seenA[w.access] { - seenA[w.access] = true - t.Accesses = append(t.Accesses, w.access) - } - resources = append(resources, w.resource) + sort.Strings(t.Modules) + sort.Strings(t.Accesses) + sort.Strings(resources) + t.Access = resources[0] + out = append(out, t) } - sort.Strings(t.Modules) - sort.Strings(t.Accesses) - sort.Strings(resources) - t.Access = resources[0] - out = append(out, t) } } sort.Slice(out, func(i, j int) bool { return out[i].key() < out[j].key() }) diff --git a/cmd/mesh-controller/kept_folders_test.go b/cmd/mesh-controller/kept_folders_test.go index 85f292f0..b4b80343 100644 --- a/cmd/mesh-controller/kept_folders_test.go +++ b/cmd/mesh-controller/kept_folders_test.go @@ -33,21 +33,28 @@ func keepWait(access string) inventory.Wait { return inventory.Wait{Part: "radarr", Access: access, What: "let the mesh keep the shared folder " + access} } +func placedWait(access, placed string) inventory.Wait { + w := keepWait(access) + w.Placed = placed + return w +} + func diskWait(access string) inventory.Wait { return inventory.Wait{Part: "radarr", Access: access, Disk: true, What: "mount the disk that holds " + access} } +// accessFacts is the facts with each folder given taken on the filesystem fs-000000000001. func accessFacts(taken ...string) operatorWaitFacts { - kept := map[string]bool{} + kept := map[string]string{} for _, p := range taken { - kept[p] = true + kept[p] = "fs-000000000001" } return operatorWaitFacts{ manifests: map[string]catalogue.Manifest{"radarr": radarrManifest, "plex": plexManifest}, given: map[string]map[string]time.Time{"radarr@ace": {}, "plex@ace": {}}, placed: map[string]map[string]string{"radarr@ace": {"movies": moviesAt}, "plex@ace": {"films": moviesAt}, "radarr@novox": {}}, - taken: map[string]map[string]bool{"ace": kept}, + taken: map[string]map[string]string{"ace": kept}, } } @@ -66,6 +73,10 @@ func TestAnAccessWaitIsExcusedOnlyForAFolderTheMeshMayNotKeepYet(t *testing.T) { {"its disk not there", "ace", diskWait("movies"), accessFacts(), ""}, {"its disk not there, the folder taken before", "ace", diskWait("movies"), accessFacts(moviesAt), ""}, {"already taken", "ace", keepWait("movies"), accessFacts(moviesAt), "taken on ace already"}, + {"already taken on the filesystem it is on", "ace", placedWait("movies", "fs-000000000001"), + accessFacts(moviesAt), "taken on ace already"}, + {"taken on another filesystem: it moved, and is asked about again", "ace", + placedWait("movies", "fs-0000000000ff"), accessFacts(moviesAt), ""}, {"undeclared", "ace", keepWait("books"), accessFacts(), "does not declare"}, {"unplaced", "novox", keepWait("movies"), accessFacts(), "not placed on novox"}, {"taken folders unread", "ace", keepWait("movies"), operatorWaitFacts{manifests: accessFacts().manifests, @@ -358,3 +369,55 @@ func TestTheDeclarationSaysItsIdentitiesOnlyWhenComposed(t *testing.T) { t.Fatalf("the contract the engine states for ADR 0325 is 5, and this controller reads %d", link.IdentityContract) } } + +// A folder that moved to another filesystem is asked about again: one ask per folder, machine and filesystem, and the +// take records the filesystem it was for. +func TestAFolderThatMovedIsAskedAboutAgain(t *testing.T) { + placed := "fs-000000000001" + r, taken := takeRig(t, func() map[string]map[string][]waitingFolder { + return waitingOn("ace", waitingFolder{module: "radarr", access: "movies", resource: "radarr.access-movies", + placed: placed}) + }) + ctx := context.Background() + if err := r.a.reconcile(ctx); err != nil { + t.Fatal(err) + } + placed = "fs-0000000000ff" + if err := r.a.reconcile(ctx); err != nil { + t.Fatal(err) + } + var open []asked + for _, a := range takeAsks(r) { + if a.State == askOpen { + open = append(open, a) + } + } + if len(open) != 1 || open[0].Take.Placed != "fs-0000000000ff" || len(takeAsks(r)) != 2 { + t.Fatalf("after the folder moved: %d asks, open %+v", len(takeAsks(r)), open) + } + answerWith(t, r, warrantOn(open[0], "let-the-mesh-keep-it", r.now)) + if len(*taken) != 1 { + t.Fatalf("taken %v", *taken) + } +} + +// The mesh's entries left while a disk was away (kind kept-entries), stated before the send, do not hold a delivery +// that did not bring them; stated since, they do — and they may name an access no longer declared. +func TestEntriesLeftWhileADiskWasAwayDoNotHoldADeliveryThatDidNotBringThem(t *testing.T) { + now := time.Now() + since := now.Add(-time.Minute) + left := inventory.ResourceHealth{Module: "radarr", Resource: "radarr.access-gone~entries", Kind: link.KindKeptEntries, + Target: moviesAt, State: link.StateUnhealthy, Reason: "the mesh's entries could not be taken out: its disk is away", + Since: since.Add(-time.Hour)} + f := gateFacts{now: now, waits: accessFacts(), groupsAdded: map[string]bool{"radarr": false}, + health: map[string]inventory.NodeHealth{"ace": {Node: "ace", HeardAt: now, + Resources: []inventory.ResourceHealth{left}}}} + if h, why := moduleHealthWord("radarr", "ace", since, f); h != healthPerson || !strings.Contains(why, "access-gone~entries") { + t.Fatalf("entries left before the send read %v %q; want carried in a passing verdict", h, why) + } + left.Since = now + f.health["ace"] = inventory.NodeHealth{Node: "ace", HeardAt: now, Resources: []inventory.ResourceHealth{left}} + if h, _ := moduleHealthWord("radarr", "ace", since, f); h != healthNotYet { + t.Fatalf("entries left by this send read %v; want judged unhealthy", h) + } +} diff --git a/cmd/mesh-controller/module_health.go b/cmd/mesh-controller/module_health.go index 571493ce..a3500630 100644 --- a/cmd/mesh-controller/module_health.go +++ b/cmd/mesh-controller/module_health.go @@ -81,7 +81,7 @@ func stateHealth(ctx context.Context, inv *inventory.Inventory, k *conditions.Ke // access by its module's id, and whether it waits for a disk rather than for the operator's word. for _, w := range r.AccessWaits { kept.Waits = append(kept.Waits, inventory.Wait{Part: w.Part, Access: w.Access, Disk: w.WaitsForDisk(), - What: w.What}) + Placed: w.Placed, What: w.What}) } resources = append(resources, kept) } @@ -650,7 +650,7 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea if waits && !f.groupsAdded[module] { waits = false } - var found, onWaiting []string + var found, onWaiting, leftover []string var forOperator []inventory.Wait for _, r := range resources { if r.Module != module { @@ -673,6 +673,14 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea found = append(found, r.Resource) continue } + // **The mesh's entries left on a folder whose disk was away, stated before this send, are a condition this + // build found** (novox/hq ADR 0325 §8): they may name an access no longer declared, they wait for the disk, + // and a delivery that did not bring them is not held for them — said in the verdict, as a directory used as + // found is. Stated since the send, the send brought them, and they are judged as unhealthy. + if r.Kind == link.KindKeptEntries && r.State == link.StateUnhealthy && r.Since.Before(since) { + leftover = append(leftover, r.Resource) + continue + } switch r.State { case link.StateHealthy: case link.StateStarting: @@ -696,7 +704,7 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea reasonAfter(r.Reason)) } } - if waits || len(found) > 0 || len(forOperator) > 0 || len(onWaiting) > 0 { + if waits || len(found) > 0 || len(forOperator) > 0 || len(onWaiting) > 0 || len(leftover) > 0 { said := onWaiting if waits { said = append(said, wait) @@ -708,6 +716,10 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea said = append(said, fmt.Sprintf("on %s, %s uses %s as found and waits for the operator to hand it over "+ "(`nox node hand-over %s ` on the control-node)", machine, module, strings.Join(found, ", "), machine)) } + if len(leftover) > 0 { + said = append(said, fmt.Sprintf("on %s, the mesh's entries %s could not be taken out while their disk was "+ + "away, stated before this send: they are taken out when it is back", machine, strings.Join(leftover, ", "))) + } return healthPerson, strings.Join(said, "; ") } return healthGood, "" diff --git a/cmd/mesh-controller/operator_wait.go b/cmd/mesh-controller/operator_wait.go index 85787028..d37d9ad9 100644 --- a/cmd/mesh-controller/operator_wait.go +++ b/cmd/mesh-controller/operator_wait.go @@ -45,10 +45,11 @@ type operatorWaitFacts struct { manifests map[string]catalogue.Manifest given map[string]map[string]time.Time // placed is, per "@", where each access of the module is on that machine, by id (novox/hq ADR - // 0325 §7); taken is, per machine, every folder the operator let the mesh keep there. A machine absent from - // taken could not be read, and no access wait of it is excused. + // 0325 §7); taken is, per machine, every folder the operator let the mesh keep there, with the filesystem it was + // taken on (empty when the engine named none). A machine absent from taken could not be read, and no access wait + // of it is excused. placed map[string]map[string]string - taken map[string]map[string]bool + taken map[string]map[string]string } // checkWait is nil when a wait is excused, and otherwise why not, in words. Pure. @@ -119,7 +120,9 @@ func checkAccessWait(module, machine string, m catalogue.Manifest, w inventory.W return fmt.Errorf("says it waits for the access %s, and which folders are taken on %s could not be read", w.Access, machine) } - if taken[path] { + // A folder taken on another filesystem than the one it is on now (it moved to another disk) is not taken by that + // warrant: the wait is excused, and the operator is asked again (ADR 0325 §8). + if on, was := taken[path]; was && (w.Placed == "" || on == w.Placed) { return fmt.Errorf("says it waits for the access %s, whose folder is taken on %s already: a module that gains "+ "an access to a taken folder is given its entries without a wait", w.Access, machine) } @@ -284,7 +287,7 @@ func readWaitFacts(ctx context.Context, inv *inventory.Inventory, machine string f.placed = map[string]map[string]string{} } if f.taken == nil { - f.taken = map[string]map[string]bool{} + f.taken = map[string]map[string]string{} } if inv == nil { return @@ -330,9 +333,9 @@ func readWaitFacts(ctx context.Context, inv *inventory.Inventory, machine string } if _, read := f.taken[machine]; !read { if taken, err := inv.KeptFolders(ctx, machine); err == nil { - kept := map[string]bool{} - for path := range taken { - kept[path] = true + kept := map[string]string{} + for path, t := range taken { + kept[path] = t.Placed } f.taken[machine] = kept } diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 32a656ef..0311bb4f 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "os" + "path/filepath" "slices" "sort" "strings" @@ -1066,6 +1067,20 @@ func folderFacts(ctx context.Context, inv *inventory.Inventory, node string, pla for path := range taken { kept[path] = true } + // **A folder that moved to another filesystem is not taken there until its new warrant** (ADR 0325 §8): the + // machine's newest statement names, without a path, the filesystem each folder waiting for the operator is on now. + if stated, had, err := inv.HealthOf(ctx, node); err != nil { + return false, nil, nil, err + } else if had { + for _, r := range stated.Resources { + for _, w := range r.Waits { + path := filepath.Clean(r.Target) + if t, was := taken[path]; was && w.Access != "" && !w.Disk && w.Placed != "" && w.Placed != t.Placed { + kept[path] = false + } + } + } + } return true, identities, kept, nil } diff --git a/go.mod b/go.mod index f7a73885..f78e5a50 100644 --- a/go.mod +++ b/go.mod @@ -35,4 +35,4 @@ require ( // committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and // fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without // `go mod vendor` fails loudly, at once, everywhere. -replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012164304-51e97a8c1405 +replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012170302-d2d9fbbd7d1c diff --git a/go.sum b/go.sum index 6647dedc..aefa245f 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-host v0.0.0-20261012164304-51e97a8c1405 h1:aC+TkJr9B6qYbeLNmzs4FtOm8/hIJw0ZrIpbU+bFWZE= -git.novox.be/novox/mesh-host v0.0.0-20261012164304-51e97a8c1405/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c= +git.novox.be/novox/mesh-host v0.0.0-20261012170302-d2d9fbbd7d1c h1:5k5y4bML7BQtFVPstHVHkQUdjKUpOQII4yTbu1MKMGY= +git.novox.be/novox/mesh-host v0.0.0-20261012170302-d2d9fbbd7d1c/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c h1:l5onJwoIeH8yE/PjVlEeoPyXBsHp2NQiWLllz2fwX7s= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= diff --git a/internal/inventory/health.go b/internal/inventory/health.go index d89a75ee..e79b5dee 100644 --- a/internal/inventory/health.go +++ b/internal/inventory/health.go @@ -48,6 +48,9 @@ type Wait struct { // waits for the disk that holds its folder rather than for the operator's word (novox/hq ADR 0325 §7, §9). Access string `json:"access,omitempty"` Disk bool `json:"disk,omitempty"` + // Placed is the filesystem the folder is on now, without its path (ADR 0325 §8): a warrant for another does not + // answer the wait. + Placed string `json:"placed,omitempty"` What string `json:"what"` } diff --git a/internal/inventory/identities.go b/internal/inventory/identities.go index f53a607a..3c971def 100644 --- a/internal/inventory/identities.go +++ b/internal/inventory/identities.go @@ -78,12 +78,14 @@ type TakenFolder struct { Ask string Warrant string Said string + // Placed is the filesystem the folder was on when taken, without its path (ADR 0325 §8). + Placed string } // KeptFolders is every folder taken on a machine, by its clean path. func (i *Inventory) KeptFolders(ctx context.Context, nodeName string) (map[string]TakenFolder, error) { rows, err := i.store.Pool().Query(ctx, - `select k.path, k.taken_by, k.ask, k.warrant, k.said from kept_folder k join node n on n.id = k.node + `select k.path, k.taken_by, k.ask, k.warrant, k.said, k.placed from kept_folder k join node n on n.id = k.node where n.name = $1`, nodeName) if err != nil { return nil, err @@ -92,7 +94,7 @@ func (i *Inventory) KeptFolders(ctx context.Context, nodeName string) (map[strin out := map[string]TakenFolder{} for rows.Next() { var t TakenFolder - if err := rows.Scan(&t.Path, &t.By, &t.Ask, &t.Warrant, &t.Said); err != nil { + if err := rows.Scan(&t.Path, &t.By, &t.Ask, &t.Warrant, &t.Said, &t.Placed); err != nil { return nil, err } out[t.Path] = t @@ -100,8 +102,9 @@ func (i *Inventory) KeptFolders(ctx context.Context, nodeName string) (map[strin return out, rows.Err() } -// RecordTaken keeps that a folder was taken on a machine, under the operator's warrant. A take is recorded once: -// a second for the same folder keeps the first. +// RecordTaken keeps that a folder was taken on a machine, under the operator's warrant. A take is recorded once per +// filesystem: a second for the same folder on the same filesystem keeps the first, and one for the folder on another +// filesystem (it moved, and the operator was asked again) replaces it. func (i *Inventory) RecordTaken(ctx context.Context, nodeName string, t TakenFolder) error { switch { case !strings.HasPrefix(t.Path, "/"): @@ -114,7 +117,11 @@ func (i *Inventory) RecordTaken(ctx context.Context, nodeName string, t TakenFol return err } _, err = i.store.Pool().Exec(ctx, - `insert into kept_folder (node, path, taken_by, ask, warrant, said) values ($1, $2, $3, $4, $5, $6) - on conflict (node, path) do nothing`, node.ID, filepath.Clean(t.Path), t.By, t.Ask, t.Warrant, t.Said) + `insert into kept_folder (node, path, taken_by, ask, warrant, said, placed) values ($1, $2, $3, $4, $5, $6, $7) + on conflict (node, path) do update + set taken_at = now(), taken_by = excluded.taken_by, ask = excluded.ask, warrant = excluded.warrant, + said = excluded.said, placed = excluded.placed + where kept_folder.placed is distinct from excluded.placed`, node.ID, filepath.Clean(t.Path), t.By, t.Ask, + t.Warrant, t.Said, t.Placed) return err } diff --git a/internal/inventory/identities_test.go b/internal/inventory/identities_test.go index a50c67af..1e6d303c 100644 --- a/internal/inventory/identities_test.go +++ b/internal/inventory/identities_test.go @@ -96,6 +96,18 @@ func TestAFolderIsTakenOnceUnderAWarrant(t *testing.T) { if !has || got.Ask != "c01" || got.By != "the operator" { t.Fatalf("the taken folder reads %+v (%v): the first take, by its clean path", got, has) } + // The folder moved to another filesystem and the operator was asked again: the new take replaces the old. + if err := inv.RecordTaken(ctx, "ace", TakenFolder{Path: "/storage/media/movies", By: "the operator", Ask: "c03", + Warrant: "c03", Placed: "fs-0000000000ff"}); err != nil { + t.Fatal(err) + } + kept, err = inv.KeptFolders(ctx, "ace") + if err != nil { + t.Fatal(err) + } + if got := kept["/storage/media/movies"]; got.Ask != "c03" || got.Placed != "fs-0000000000ff" { + t.Fatalf("after a take on another filesystem the folder reads %+v", got) + } } // novox/hq issue 575: a module registered without the access movies, whose newest build on its branch declares it — diff --git a/internal/inventory/migrations/0096-a-module-is-given-an-identity-and-a-folder-is-taken.sql b/internal/inventory/migrations/0096-a-module-is-given-an-identity-and-a-folder-is-taken.sql index 4117383a..34eb6024 100644 --- a/internal/inventory/migrations/0096-a-module-is-given-an-identity-and-a-folder-is-taken.sql +++ b/internal/inventory/migrations/0096-a-module-is-given-an-identity-and-a-folder-is-taken.sql @@ -25,7 +25,7 @@ create table module_identity ( -- everything there, and the folder is taken: kept from then on without asking, a module that gains the access -- added with no wait. The row is the record of that act — who approved it, under which warrant — and what tells a -- later module's access that it joins a taken folder (`taken` on the access). The path is the folder as placed, --- clean and absolute. +-- clean and absolute. A new warrant for the same folder on another filesystem replaces the row. create table kept_folder ( node uuid not null references node (id) on delete cascade, path text not null check (path like '/%'), @@ -36,5 +36,9 @@ create table kept_folder ( warrant text not null, -- What the node-engine answered when it started the take. said text not null default '', + -- The filesystem the folder was on when it was taken, as the engine names it without a path ("fs-<12 hex>"): + -- a wait naming another (the folder moved to another disk) is not answered by this take, and the operator is + -- asked again; until the new warrant the folder is not taken there. Empty from an engine that names none. + placed text not null default '', primary key (node, path) ); diff --git a/internal/link/protocol.go b/internal/link/protocol.go index c4b4bf89..961aa828 100644 --- a/internal/link/protocol.go +++ b/internal/link/protocol.go @@ -601,6 +601,10 @@ type AccessWait struct { Access string `json:"access"` Part string `json:"part"` What string `json:"what"` + // Placed names, with no path, the filesystem the folder is on now ("fs-<12 hex>"), on a wait for the operator's + // word: a warrant given for another filesystem (the folder moved) does not answer it, and the controller asks + // again (mesh-host internal/link AccessWait.Placed). + Placed string `json:"placed,omitempty"` } // WaitsForDisk says an access wait is for a disk that is not there (ADR 0325 §9) rather than for the operator's word @@ -609,6 +613,11 @@ func (w AccessWait) WaitsForDisk() bool { return strings.HasPrefix(strings.TrimSpace(w.What), "mount the disk") } +// KindKeptEntries is the mesh's entries on a shared folder that nobody wants and that could not be taken out yet +// because its disk was away (novox/hq ADR 0325 §8; mesh-host internal/link): unhealthy, said under the access that +// last named it (`~entries`), which may no longer be declared. The node-engine's own. +const KindKeptEntries = "kept-entries" + // KeptEntry is one user of a kept folder as the engine's listing says it (ADR 0325 §4), with a note beside the tool // runner's entry. type KeptEntry struct { diff --git a/testdata/beside/CAPTURED b/testdata/beside/CAPTURED index 695ae040..48d10391 100644 --- a/testdata/beside/CAPTURED +++ b/testdata/beside/CAPTURED @@ -8,7 +8,7 @@ private addresses. That is acceptable while mesh-controller is private; if it ev a second place to clean besides the catalogue itself. mesh-catalog b9de001833b1b61b297182b8e3bcdae1cbdeace9 modules/*/module.json, modules/nats/Dockerfile -mesh-host 51e97a8c14058fd58e108a045b5d94744f23a6ee examples/foundation-first-node-nats.lock +mesh-host d2d9fbbd7d1cb92efaf404ebe0bd8dd91896277f examples/foundation-first-node-nats.lock mesh-sdk 33917f91ac3cc213b879b32f3c70e2aa70395f98 conformance/events/module-event.json To move them, from this repository's root, with the two repositories checked out beside it: diff --git a/vendor/modules.txt b/vendor/modules.txt index 4231b52e..4cb2cf58 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -78,7 +78,7 @@ github.com/nats-io/nkeys # github.com/nats-io/nuid v1.0.1 ## explicit github.com/nats-io/nuid -# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261012164304-51e97a8c1405 +# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261012170302-d2d9fbbd7d1c ## explicit; go 1.26.0 github.com/novox/mesh-host/internal/declaration github.com/novox/mesh-host/rootsearch @@ -135,4 +135,4 @@ golang.org/x/text/width # golang.org/x/time v0.15.0 ## explicit; go 1.25.0 golang.org/x/time/rate -# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012164304-51e97a8c1405 +# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012170302-d2d9fbbd7d1c