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.
This commit is contained in:
2026-10-12 19:24:34 +02:00
parent 63602264ef
commit 2b15cb57f0
14 changed files with 195 additions and 56 deletions
+40 -29
View File
@@ -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 is the resource id of one access to it in the machine's declaration: what the take act names.
Access string `json:"access"` 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 are the access ids (each module's own) that place it, Modules the modules waiting on it, both sorted.
Accesses []string `json:"accesses"` Accesses []string `json:"accesses"`
Modules []string `json:"modules"` Modules []string `json:"modules"`
At time.Time `json:"at"` // 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. // takeVerb is the verb a take's answer binds: the controller's own, performed by itself on the warrant.
const takeVerb = askerName + ".take" const takeVerb = askerName + ".take"
// key is the folder on its machine: what one ask is kept for. // 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 // 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. // modules the operator was told.
func (t folderTake) digest() string { func (t folderTake) digest() string {
raw, _ := json.Marshal(struct { raw, _ := json.Marshal(struct {
Node, Path, Access string Node, Path, Access, Placed string
Modules []string Modules []string
}{t.Node, filepath.Clean(t.Path), t.Access, t.Modules}) }{t.Node, filepath.Clean(t.Path), t.Access, t.Placed, t.Modules})
sum := sha256.Sum256(raw) sum := sha256.Sum256(raw)
return "sha256:" + hex.EncodeToString(sum[:]) 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) 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, 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.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 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. // waitingFolder is one access wait excused for a folder not yet taken, as foldersWaiting gathers them.
type waitingFolder struct { type waitingFolder struct {
module, access string module, access string
// resource is the access's resource id on the machine, as the engine stated it. // resource is the access's resource id on the machine, as the engine stated it; placed the filesystem its folder
resource string // 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 // 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] = map[string][]waitingFolder{}
} }
out[machine][path] = append(out[machine][path], waitingFolder{module: r.Module, access: w.Access, 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 { func takesOf(waiting map[string]map[string][]waitingFolder) []folderTake {
var out []folderTake var out []folderTake
for machine, folders := range waiting { for machine, folders := range waiting {
for path, ws := range folders { for path, all := range folders {
t := folderTake{Node: machine, Path: path} // One take per filesystem the waits name: they agree but for a moment while a disk is swapped.
seenM, seenA := map[string]bool{}, map[string]bool{} byPlaced := map[string][]waitingFolder{}
var resources []string for _, w := range all {
for _, w := range ws { byPlaced[w.placed] = append(byPlaced[w.placed], w)
if !seenM[w.module] { }
seenM[w.module] = true for placed, ws := range byPlaced {
t.Modules = append(t.Modules, w.module) 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] { sort.Strings(t.Modules)
seenA[w.access] = true sort.Strings(t.Accesses)
t.Accesses = append(t.Accesses, w.access) sort.Strings(resources)
} t.Access = resources[0]
resources = append(resources, w.resource) 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() }) sort.Slice(out, func(i, j int) bool { return out[i].key() < out[j].key() })
+66 -3
View File
@@ -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} 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 { func diskWait(access string) inventory.Wait {
return inventory.Wait{Part: "radarr", Access: access, Disk: true, What: "mount the disk that holds " + access} 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 { func accessFacts(taken ...string) operatorWaitFacts {
kept := map[string]bool{} kept := map[string]string{}
for _, p := range taken { for _, p := range taken {
kept[p] = true kept[p] = "fs-000000000001"
} }
return operatorWaitFacts{ return operatorWaitFacts{
manifests: map[string]catalogue.Manifest{"radarr": radarrManifest, "plex": plexManifest}, manifests: map[string]catalogue.Manifest{"radarr": radarrManifest, "plex": plexManifest},
given: map[string]map[string]time.Time{"radarr@ace": {}, "plex@ace": {}}, 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}, placed: map[string]map[string]string{"radarr@ace": {"movies": moviesAt}, "plex@ace": {"films": moviesAt},
"radarr@novox": {}}, "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", "ace", diskWait("movies"), accessFacts(), ""},
{"its disk not there, the folder taken before", "ace", diskWait("movies"), accessFacts(moviesAt), ""}, {"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", "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"}, {"undeclared", "ace", keepWait("books"), accessFacts(), "does not declare"},
{"unplaced", "novox", keepWait("movies"), accessFacts(), "not placed on novox"}, {"unplaced", "novox", keepWait("movies"), accessFacts(), "not placed on novox"},
{"taken folders unread", "ace", keepWait("movies"), operatorWaitFacts{manifests: accessFacts().manifests, {"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) 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)
}
}
+15 -3
View File
@@ -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. // 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 { for _, w := range r.AccessWaits {
kept.Waits = append(kept.Waits, inventory.Wait{Part: w.Part, Access: w.Access, Disk: w.WaitsForDisk(), 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) resources = append(resources, kept)
} }
@@ -650,7 +650,7 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea
if waits && !f.groupsAdded[module] { if waits && !f.groupsAdded[module] {
waits = false waits = false
} }
var found, onWaiting []string var found, onWaiting, leftover []string
var forOperator []inventory.Wait var forOperator []inventory.Wait
for _, r := range resources { for _, r := range resources {
if r.Module != module { if r.Module != module {
@@ -673,6 +673,14 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea
found = append(found, r.Resource) found = append(found, r.Resource)
continue 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 { switch r.State {
case link.StateHealthy: case link.StateHealthy:
case link.StateStarting: case link.StateStarting:
@@ -696,7 +704,7 @@ func moduleHealthWord(module, machine string, since time.Time, f gateFacts) (hea
reasonAfter(r.Reason)) 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 said := onWaiting
if waits { if waits {
said = append(said, wait) 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 "+ 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 <directory>` on the control-node)", machine, module, strings.Join(found, ", "), machine)) "(`nox node hand-over %s <directory>` 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 healthPerson, strings.Join(said, "; ")
} }
return healthGood, "" return healthGood, ""
+11 -8
View File
@@ -45,10 +45,11 @@ type operatorWaitFacts struct {
manifests map[string]catalogue.Manifest manifests map[string]catalogue.Manifest
given map[string]map[string]time.Time given map[string]map[string]time.Time
// placed is, per "<module>@<machine>", where each access of the module is on that machine, by id (novox/hq ADR // placed is, per "<module>@<machine>", 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 // 0325 §7); taken is, per machine, every folder the operator let the mesh keep there, with the filesystem it was
// taken could not be read, and no access wait of it is excused. // 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 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. // 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", return fmt.Errorf("says it waits for the access %s, and which folders are taken on %s could not be read",
w.Access, machine) 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 "+ 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) "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{} f.placed = map[string]map[string]string{}
} }
if f.taken == nil { if f.taken == nil {
f.taken = map[string]map[string]bool{} f.taken = map[string]map[string]string{}
} }
if inv == nil { if inv == nil {
return return
@@ -330,9 +333,9 @@ func readWaitFacts(ctx context.Context, inv *inventory.Inventory, machine string
} }
if _, read := f.taken[machine]; !read { if _, read := f.taken[machine]; !read {
if taken, err := inv.KeptFolders(ctx, machine); err == nil { if taken, err := inv.KeptFolders(ctx, machine); err == nil {
kept := map[string]bool{} kept := map[string]string{}
for path := range taken { for path, t := range taken {
kept[path] = true kept[path] = t.Placed
} }
f.taken[machine] = kept f.taken[machine] = kept
} }
+15
View File
@@ -9,6 +9,7 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"path/filepath"
"slices" "slices"
"sort" "sort"
"strings" "strings"
@@ -1066,6 +1067,20 @@ func folderFacts(ctx context.Context, inv *inventory.Inventory, node string, pla
for path := range taken { for path := range taken {
kept[path] = true 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 return true, identities, kept, nil
} }
+1 -1
View File
@@ -35,4 +35,4 @@ require (
// committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and // 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 // 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. // `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
+2 -2
View File
@@ -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-20261012170302-d2d9fbbd7d1c h1:5k5y4bML7BQtFVPstHVHkQUdjKUpOQII4yTbu1MKMGY=
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/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 h1:l5onJwoIeH8yE/PjVlEeoPyXBsHp2NQiWLllz2fwX7s=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= 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= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
+3
View File
@@ -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). // 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"` Access string `json:"access,omitempty"`
Disk bool `json:"disk,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"` What string `json:"what"`
} }
+13 -6
View File
@@ -78,12 +78,14 @@ type TakenFolder struct {
Ask string Ask string
Warrant string Warrant string
Said 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. // 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) { func (i *Inventory) KeptFolders(ctx context.Context, nodeName string) (map[string]TakenFolder, error) {
rows, err := i.store.Pool().Query(ctx, 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) where n.name = $1`, nodeName)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -92,7 +94,7 @@ func (i *Inventory) KeptFolders(ctx context.Context, nodeName string) (map[strin
out := map[string]TakenFolder{} out := map[string]TakenFolder{}
for rows.Next() { for rows.Next() {
var t TakenFolder 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 return nil, err
} }
out[t.Path] = t out[t.Path] = t
@@ -100,8 +102,9 @@ func (i *Inventory) KeptFolders(ctx context.Context, nodeName string) (map[strin
return out, rows.Err() return out, rows.Err()
} }
// RecordTaken keeps that a folder was taken on a machine, under the operator's warrant. A take is recorded once: // RecordTaken keeps that a folder was taken on a machine, under the operator's warrant. A take is recorded once per
// a second for the same folder keeps the first. // 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 { func (i *Inventory) RecordTaken(ctx context.Context, nodeName string, t TakenFolder) error {
switch { switch {
case !strings.HasPrefix(t.Path, "/"): case !strings.HasPrefix(t.Path, "/"):
@@ -114,7 +117,11 @@ func (i *Inventory) RecordTaken(ctx context.Context, nodeName string, t TakenFol
return err return err
} }
_, err = i.store.Pool().Exec(ctx, _, err = i.store.Pool().Exec(ctx,
`insert into kept_folder (node, path, taken_by, ask, warrant, said) values ($1, $2, $3, $4, $5, $6) `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 nothing`, node.ID, filepath.Clean(t.Path), t.By, t.Ask, t.Warrant, t.Said) 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 return err
} }
+12
View File
@@ -96,6 +96,18 @@ func TestAFolderIsTakenOnceUnderAWarrant(t *testing.T) {
if !has || got.Ask != "c01" || got.By != "the operator" { 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) 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 — // novox/hq issue 575: a module registered without the access movies, whose newest build on its branch declares it —
@@ -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 -- 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 -- 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, -- 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 ( create table kept_folder (
node uuid not null references node (id) on delete cascade, node uuid not null references node (id) on delete cascade,
path text not null check (path like '/%'), path text not null check (path like '/%'),
@@ -36,5 +36,9 @@ create table kept_folder (
warrant text not null, warrant text not null,
-- What the node-engine answered when it started the take. -- What the node-engine answered when it started the take.
said text not null default '', 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) primary key (node, path)
); );
+9
View File
@@ -601,6 +601,10 @@ type AccessWait struct {
Access string `json:"access"` Access string `json:"access"`
Part string `json:"part"` Part string `json:"part"`
What string `json:"what"` 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 // 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") 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 (`<access id>~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 // 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. // runner's entry.
type KeptEntry struct { type KeptEntry struct {
+1 -1
View File
@@ -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. a second place to clean besides the catalogue itself.
mesh-catalog b9de001833b1b61b297182b8e3bcdae1cbdeace9 modules/*/module.json, modules/nats/Dockerfile 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 mesh-sdk 33917f91ac3cc213b879b32f3c70e2aa70395f98 conformance/events/module-event.json
To move them, from this repository's root, with the two repositories checked out beside it: To move them, from this repository's root, with the two repositories checked out beside it:
+2 -2
View File
@@ -78,7 +78,7 @@ github.com/nats-io/nkeys
# github.com/nats-io/nuid v1.0.1 # github.com/nats-io/nuid v1.0.1
## explicit ## explicit
github.com/nats-io/nuid 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 ## explicit; go 1.26.0
github.com/novox/mesh-host/internal/declaration github.com/novox/mesh-host/internal/declaration
github.com/novox/mesh-host/rootsearch github.com/novox/mesh-host/rootsearch
@@ -135,4 +135,4 @@ golang.org/x/text/width
# golang.org/x/time v0.15.0 # golang.org/x/time v0.15.0
## explicit; go 1.25.0 ## explicit; go 1.25.0
golang.org/x/time/rate 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