feat(broker): add atomic host store migration
- commit
- 75fae3a98302d58e3829935103ec057fc5390b83
- author
- brice · · 2026-09-16 12:04 UTC
- parent
- a3b975e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
22 files · +3159 -143
A internal/adapters/brokerstore/README.md +89 -0
| @@ -0,0 +1,89 @@ | |||
| 1 | + | # Offline broker store (Plan 001 P2.3) | |
| 2 | + | ||
| 3 | + | `OpenOffline` is deliberately not wired into application composition. Supply a | |
| 4 | + | private destination directory, immutable copies of any legacy inputs, and an | |
| 5 | + | explicit policy for each imported endpoint. An empty input path means no input; | |
| 6 | + | a named missing, empty, corrupt, or unsupported file fails closed. The lifetime | |
| 7 | + | lock is `hosts.json.lock`, the exact lock file the legacy host writer takes, so | |
| 8 | + | a store opened on a directory that live writer also uses excludes it for the | |
| 9 | + | lifetime of the store. The legacy cache source has no such lock — its writer | |
| 10 | + | takes none — so a cache source must be immutable or quiesced before migration. | |
| 11 | + | Never point migration at a cache file that a writer can still mutate. | |
| 12 | + | ||
| 13 | + | Each source's recovery manifest entry records its generic role label | |
| 14 | + | (`membership` for the host registry, `observations` for the catalogue cache), | |
| 15 | + | whether the source was supplied at all, and the SHA-256 of its bytes. Presence | |
| 16 | + | is separate from the digest because an absent source and an empty one can share | |
| 17 | + | a digest; a record that claims a source was absent while retaining its bytes is | |
| 18 | + | rejected. | |
| 19 | + | ||
| 20 | + | ## Transaction and recovery | |
| 21 | + | ||
| 22 | + | - Hold `hosts.json.lock` with Linux nonblocking exclusive `flock` until `Close`. | |
| 23 | + | The name matches the legacy host writer's lock, so the store and that writer | |
| 24 | + | exclude each other; the legacy catalog cache writer takes no lock at all. | |
| 25 | + | - Validate bounded legacy hosts v1/v2/v3 and cache v2/v3/v4 without calling their | |
| 26 | + | existing writer adapters. Cache decoding freezes the existing exact legacy | |
| 27 | + | DTOs here; count-only v2 inventories are deliberately unsupported. | |
| 28 | + | - Write `recovery.json`: original bytes, SHA-256 migration manifest with source | |
| 29 | + | presence, policies, generated registration identities, and initial unified | |
| 30 | + | state. Write, fsync, rename, then fsync the directory before attempting state | |
| 31 | + | publication. | |
| 32 | + | - Write `state.json` with the same atomic sequence. Hosts, policies, snapshot, | |
| 33 | + | and manifest share one rename commit point. Only then return a ready store. | |
| 34 | + | - If state is absent on restart, validate and finish the recovery record without | |
| 35 | + | resampling identities or rereading changed sources. If state exists but is | |
| 36 | + | corrupt, fail closed; never silently roll authority back. | |
| 37 | + | - Keep recovery and original inputs indefinitely. Rollback is an explicit | |
| 38 | + | offline operator action using the original bytes, not automatic fallback. | |
| 39 | + | P2.3 does not retire or delete production files. | |
| 40 | + | ||
| 41 | + | A failed mutation poisons the handle because rename may already have happened. | |
| 42 | + | Close and reopen to resolve that outcome; do not blindly retry. Fault tests cover | |
| 43 | + | both files at write/fsync/rename/directory-fsync boundaries. They simulate errors | |
| 44 | + | and process reopening, not hardware power-loss behavior of a particular disk. | |
| 45 | + | ||
| 46 | + | The file budget is 16 MiB per JSON file (including the base64 rollback inputs in | |
| 47 | + | the recovery file). Inputs are bounded before decoding, must be regular files, | |
| 48 | + | and cannot be symlinks. JSON rejects duplicate/unknown fields, trailing content, | |
| 49 | + | invalid UTF-8, excessive nesting, and invalid domain/catalogue values. | |
| 50 | + | ||
| 51 | + | ## Authority | |
| 52 | + | ||
| 53 | + | `BrokerHostStore` separates durable membership from advisory observations. | |
| 54 | + | Host replacement uses a store revision CAS. Policy changes require a changed | |
| 55 | + | registration identity/generation; generations cannot regress within an | |
| 56 | + | incarnation. Full remove/re-add callers must supply a fresh incarnation. | |
| 57 | + | Snapshots cannot add hosts, overwrite retired registrations, regress revisions, | |
| 58 | + | or switch epochs after the first successful publication on an open handle. | |
| 59 | + | `NewRegistry` requires `BrokerHostStore` explicitly: durable membership is not | |
| 60 | + | an optional capability, and the registry never runs on a store that cannot | |
| 61 | + | answer `LoadHosts`. Startup intersects cached observations with authoritative | |
| 62 | + | membership, and creates unknown projections for registrations without | |
| 63 | + | observations. An observation is adopted only when it satisfies the same durable | |
| 64 | + | projection rules the store enforces (`ports.ValidateDurableHostProjection`): | |
| 65 | + | availability and failure kind inside their closed ranges, and a catalogue-valid | |
| 66 | + | session inventory for the exact incarnation and success time. Anything else is | |
| 67 | + | typed as an invalid response, so a published snapshot is never one the store | |
| 68 | + | would reject and silently never persist. | |
| 69 | + | ||
| 70 | + | Store failures are classified with `internal/ports` sentinels: | |
| 71 | + | `ErrBrokerHostConflict` (refresh the revision and retry), | |
| 72 | + | `ErrBrokerStoreLocked` (another owner is live), and | |
| 73 | + | `ErrBrokerStoreInvalidState` (resolve offline from the recovery record; state is | |
| 74 | + | never silently replaced or rolled back). Malformed caller membership passed to | |
| 75 | + | `ReplaceHosts` is validated before the CAS and returned as a plain caller error, | |
| 76 | + | never as `ErrBrokerStoreInvalidState`; the durable state is unchanged. | |
| 77 | + | ||
| 78 | + | Unbound cache v2/v3 records remain recoverable but cannot seed registration | |
| 79 | + | identity. A bound cache entry seeds only the exact surviving host incarnation; | |
| 80 | + | legacy cache schemas do not carry a registration generation. The imported host | |
| 81 | + | record remains authoritative for generation. Transient checking/error objects | |
| 82 | + | are never persisted. Retirement tombstones are process-local fencing state and | |
| 83 | + | are never durable: the registry's persistable copy and the store both clear | |
| 84 | + | `Snapshot.Removed` before writing, so reopening sees membership without stale | |
| 85 | + | tombstones. No environment, launch, or trust policy is guessed. | |
| 86 | + | ||
| 87 | + | Production cutover, lifecycle supervision, and IPC composition remain later | |
| 88 | + | phases. Close ordering must drain registry snapshot writers before releasing the | |
| 89 | + | store; the store does not own those workers. | |
| @@ -0,0 +1,89 @@ | |||||
| 1 | + | # Offline broker store (Plan 001 P2.3) | |||
| 2 | + | ||||
| 3 | + | `OpenOffline` is deliberately not wired into application composition. Supply a | |||
| 4 | + | private destination directory, immutable copies of any legacy inputs, and an | |||
| 5 | + | explicit policy for each imported endpoint. An empty input path means no input; | |||
| 6 | + | a named missing, empty, corrupt, or unsupported file fails closed. The lifetime | |||
| 7 | + | lock is `hosts.json.lock`, the exact lock file the legacy host writer takes, so | |||
| 8 | + | a store opened on a directory that live writer also uses excludes it for the | |||
| 9 | + | lifetime of the store. The legacy cache source has no such lock — its writer | |||
| 10 | + | takes none — so a cache source must be immutable or quiesced before migration. | |||
| 11 | + | Never point migration at a cache file that a writer can still mutate. | |||
| 12 | + | ||||
| 13 | + | Each source's recovery manifest entry records its generic role label | |||
| 14 | + | (`membership` for the host registry, `observations` for the catalogue cache), | |||
| 15 | + | whether the source was supplied at all, and the SHA-256 of its bytes. Presence | |||
| 16 | + | is separate from the digest because an absent source and an empty one can share | |||
| 17 | + | a digest; a record that claims a source was absent while retaining its bytes is | |||
| 18 | + | rejected. | |||
| 19 | + | ||||
| 20 | + | ## Transaction and recovery | |||
| 21 | + | ||||
| 22 | + | - Hold `hosts.json.lock` with Linux nonblocking exclusive `flock` until `Close`. | |||
| 23 | + | The name matches the legacy host writer's lock, so the store and that writer | |||
| 24 | + | exclude each other; the legacy catalog cache writer takes no lock at all. | |||
| 25 | + | - Validate bounded legacy hosts v1/v2/v3 and cache v2/v3/v4 without calling their | |||
| 26 | + | existing writer adapters. Cache decoding freezes the existing exact legacy | |||
| 27 | + | DTOs here; count-only v2 inventories are deliberately unsupported. | |||
| 28 | + | - Write `recovery.json`: original bytes, SHA-256 migration manifest with source | |||
| 29 | + | presence, policies, generated registration identities, and initial unified | |||
| 30 | + | state. Write, fsync, rename, then fsync the directory before attempting state | |||
| 31 | + | publication. | |||
| 32 | + | - Write `state.json` with the same atomic sequence. Hosts, policies, snapshot, | |||
| 33 | + | and manifest share one rename commit point. Only then return a ready store. | |||
| 34 | + | - If state is absent on restart, validate and finish the recovery record without | |||
| 35 | + | resampling identities or rereading changed sources. If state exists but is | |||
| 36 | + | corrupt, fail closed; never silently roll authority back. | |||
| 37 | + | - Keep recovery and original inputs indefinitely. Rollback is an explicit | |||
| 38 | + | offline operator action using the original bytes, not automatic fallback. | |||
| 39 | + | P2.3 does not retire or delete production files. | |||
| 40 | + | ||||
| 41 | + | A failed mutation poisons the handle because rename may already have happened. | |||
| 42 | + | Close and reopen to resolve that outcome; do not blindly retry. Fault tests cover | |||
| 43 | + | both files at write/fsync/rename/directory-fsync boundaries. They simulate errors | |||
| 44 | + | and process reopening, not hardware power-loss behavior of a particular disk. | |||
| 45 | + | ||||
| 46 | + | The file budget is 16 MiB per JSON file (including the base64 rollback inputs in | |||
| 47 | + | the recovery file). Inputs are bounded before decoding, must be regular files, | |||
| 48 | + | and cannot be symlinks. JSON rejects duplicate/unknown fields, trailing content, | |||
| 49 | + | invalid UTF-8, excessive nesting, and invalid domain/catalogue values. | |||
| 50 | + | ||||
| 51 | + | ## Authority | |||
| 52 | + | ||||
| 53 | + | `BrokerHostStore` separates durable membership from advisory observations. | |||
| 54 | + | Host replacement uses a store revision CAS. Policy changes require a changed | |||
| 55 | + | registration identity/generation; generations cannot regress within an | |||
| 56 | + | incarnation. Full remove/re-add callers must supply a fresh incarnation. | |||
| 57 | + | Snapshots cannot add hosts, overwrite retired registrations, regress revisions, | |||
| 58 | + | or switch epochs after the first successful publication on an open handle. | |||
| 59 | + | `NewRegistry` requires `BrokerHostStore` explicitly: durable membership is not | |||
| 60 | + | an optional capability, and the registry never runs on a store that cannot | |||
| 61 | + | answer `LoadHosts`. Startup intersects cached observations with authoritative | |||
| 62 | + | membership, and creates unknown projections for registrations without | |||
| 63 | + | observations. An observation is adopted only when it satisfies the same durable | |||
| 64 | + | projection rules the store enforces (`ports.ValidateDurableHostProjection`): | |||
| 65 | + | availability and failure kind inside their closed ranges, and a catalogue-valid | |||
| 66 | + | session inventory for the exact incarnation and success time. Anything else is | |||
| 67 | + | typed as an invalid response, so a published snapshot is never one the store | |||
| 68 | + | would reject and silently never persist. | |||
| 69 | + | ||||
| 70 | + | Store failures are classified with `internal/ports` sentinels: | |||
| 71 | + | `ErrBrokerHostConflict` (refresh the revision and retry), | |||
| 72 | + | `ErrBrokerStoreLocked` (another owner is live), and | |||
| 73 | + | `ErrBrokerStoreInvalidState` (resolve offline from the recovery record; state is | |||
| 74 | + | never silently replaced or rolled back). Malformed caller membership passed to | |||
| 75 | + | `ReplaceHosts` is validated before the CAS and returned as a plain caller error, | |||
| 76 | + | never as `ErrBrokerStoreInvalidState`; the durable state is unchanged. | |||
| 77 | + | ||||
| 78 | + | Unbound cache v2/v3 records remain recoverable but cannot seed registration | |||
| 79 | + | identity. A bound cache entry seeds only the exact surviving host incarnation; | |||
| 80 | + | legacy cache schemas do not carry a registration generation. The imported host | |||
| 81 | + | record remains authoritative for generation. Transient checking/error objects | |||
| 82 | + | are never persisted. Retirement tombstones are process-local fencing state and | |||
| 83 | + | are never durable: the registry's persistable copy and the store both clear | |||
| 84 | + | `Snapshot.Removed` before writing, so reopening sees membership without stale | |||
| 85 | + | tombstones. No environment, launch, or trust policy is guessed. | |||
| 86 | + | ||||
| 87 | + | Production cutover, lifecycle supervision, and IPC composition remain later | |||
| 88 | + | phases. Close ordering must drain registry snapshot writers before releasing the | |||
| 89 | + | store; the store does not own those workers. | |||
A internal/adapters/brokerstore/legacy.go +268 -0
| @@ -0,0 +1,268 @@ | |||
| 1 | + | package brokerstore | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "crypto/rand" | |
| 6 | + | "encoding/hex" | |
| 7 | + | "encoding/json" | |
| 8 | + | "errors" | |
| 9 | + | "fmt" | |
| 10 | + | "io" | |
| 11 | + | "os" | |
| 12 | + | "syscall" | |
| 13 | + | "unicode/utf8" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/vev/internal/domain" | |
| 16 | + | "github.com/bnema/vev/internal/ports" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | func readBounded(path string) ([]byte, error) { | |
| 20 | + | fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) | |
| 21 | + | if err != nil { | |
| 22 | + | return nil, err | |
| 23 | + | } | |
| 24 | + | f := os.NewFile(uintptr(fd), path) | |
| 25 | + | defer f.Close() | |
| 26 | + | st, err := f.Stat() | |
| 27 | + | if err != nil { | |
| 28 | + | return nil, err | |
| 29 | + | } | |
| 30 | + | if !st.Mode().IsRegular() || st.Size() > MaxFileBytes { | |
| 31 | + | return nil, errors.New("brokerstore: invalid file type or size") | |
| 32 | + | } | |
| 33 | + | b, err := io.ReadAll(io.LimitReader(f, MaxFileBytes+1)) | |
| 34 | + | if len(b) > MaxFileBytes { | |
| 35 | + | return nil, errors.New("brokerstore: oversized input") | |
| 36 | + | } | |
| 37 | + | return b, err | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // strict rejects duplicate keys as well as unknown fields, trailing JSON, | |
| 41 | + | // invalid UTF-8 and excessive nesting. Byte bounds apply before decoding. | |
| 42 | + | func strict(raw []byte, dst any) error { | |
| 43 | + | if len(raw) > MaxFileBytes || !utf8.Valid(raw) { | |
| 44 | + | return errors.New("brokerstore: invalid JSON size or encoding") | |
| 45 | + | } | |
| 46 | + | scan := json.NewDecoder(bytes.NewReader(raw)) | |
| 47 | + | if err := scanValue(scan, 0); err != nil { | |
| 48 | + | return err | |
| 49 | + | } | |
| 50 | + | if _, err := scan.Token(); err != io.EOF { | |
| 51 | + | return errors.New("brokerstore: trailing JSON") | |
| 52 | + | } | |
| 53 | + | dec := json.NewDecoder(bytes.NewReader(raw)) | |
| 54 | + | dec.DisallowUnknownFields() | |
| 55 | + | return dec.Decode(dst) | |
| 56 | + | } | |
| 57 | + | func scanValue(d *json.Decoder, depth int) error { | |
| 58 | + | if depth > 64 { | |
| 59 | + | return errors.New("brokerstore: JSON nesting limit") | |
| 60 | + | } | |
| 61 | + | t, err := d.Token() | |
| 62 | + | if err != nil { | |
| 63 | + | return err | |
| 64 | + | } | |
| 65 | + | delim, ok := t.(json.Delim) | |
| 66 | + | if !ok { | |
| 67 | + | return nil | |
| 68 | + | } | |
| 69 | + | switch delim { | |
| 70 | + | case '{': | |
| 71 | + | seen := map[string]bool{} | |
| 72 | + | for d.More() { | |
| 73 | + | key, err := d.Token() | |
| 74 | + | if err != nil { | |
| 75 | + | return err | |
| 76 | + | } | |
| 77 | + | k, ok := key.(string) | |
| 78 | + | if !ok || seen[k] { | |
| 79 | + | return errors.New("brokerstore: duplicate JSON key") | |
| 80 | + | } | |
| 81 | + | seen[k] = true | |
| 82 | + | if err := scanValue(d, depth+1); err != nil { | |
| 83 | + | return err | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | case '[': | |
| 87 | + | for d.More() { | |
| 88 | + | if err := scanValue(d, depth+1); err != nil { | |
| 89 | + | return err | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | default: | |
| 93 | + | return errors.New("brokerstore: unexpected delimiter") | |
| 94 | + | } | |
| 95 | + | _, err = d.Token() | |
| 96 | + | return err | |
| 97 | + | } | |
| 98 | + | func source(path string) ([]byte, error) { | |
| 99 | + | if path == "" { | |
| 100 | + | return nil, nil | |
| 101 | + | } | |
| 102 | + | return readBounded(path) | |
| 103 | + | } | |
| 104 | + | func migrate(o Options) (recovery, error) { | |
| 105 | + | var r recovery | |
| 106 | + | var err error | |
| 107 | + | if r.Hosts, err = source(o.LegacyHosts); err != nil { | |
| 108 | + | return r, err | |
| 109 | + | } | |
| 110 | + | if r.Cache, err = source(o.LegacyCache); err != nil { | |
| 111 | + | return r, err | |
| 112 | + | } | |
| 113 | + | r.State.Manifest = manifest{Version: 1, Sources: []manifestSource{ | |
| 114 | + | {Label: manifestMembership, Present: o.LegacyHosts != "", SHA256: digest(r.Hosts)}, | |
| 115 | + | {Label: manifestObservations, Present: o.LegacyCache != "", SHA256: digest(r.Cache)}, | |
| 116 | + | }} | |
| 117 | + | r.State.Hosts = ports.BrokerHosts{Revision: 1} | |
| 118 | + | if o.LegacyHosts != "" { | |
| 119 | + | r.State.Hosts.Hosts, err = decodeHosts(r.Hosts, o.Policies) | |
| 120 | + | if err != nil { | |
| 121 | + | return r, err | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | if o.LegacyCache != "" { | |
| 125 | + | entries, err := decodeCache(r.Cache) | |
| 126 | + | if err != nil { | |
| 127 | + | return r, err | |
| 128 | + | } | |
| 129 | + | for _, e := range entries { | |
| 130 | + | for _, h := range r.State.Hosts.Hosts { | |
| 131 | + | // Unbound v2/v3 cache data is validated and backed up, never promoted | |
| 132 | + | // to authority. v4 can seed only its exact surviving incarnation. | |
| 133 | + | if e.Host == h.Registration.Endpoint && e.Incarnation == h.Registration.Incarnation { | |
| 134 | + | r.State.Snapshot.Hosts = append(r.State.Snapshot.Hosts, ports.RemoteHostSnapshot{Endpoint: e.Host, Registration: h.Registration, Availability: domain.RemoteAvailabilityUnknown, InventoryKnown: true, LastSuccess: e.FetchedAt, Sessions: e.Sessions}) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | if len(r.State.Snapshot.Hosts) > 0 { | |
| 140 | + | var epoch [8]byte | |
| 141 | + | if _, err = rand.Read(epoch[:]); err != nil { | |
| 142 | + | return r, err | |
| 143 | + | } | |
| 144 | + | var n uint64 | |
| 145 | + | for _, b := range epoch { | |
| 146 | + | n = n<<8 | uint64(b) | |
| 147 | + | } | |
| 148 | + | if n == 0 { | |
| 149 | + | n = 1 | |
| 150 | + | } | |
| 151 | + | r.State.Snapshot.Epoch = ports.BrokerEpoch(n) | |
| 152 | + | r.State.Snapshot.Revision = 1 | |
| 153 | + | } | |
| 154 | + | return r, validate(r.State) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | type legacyRecord struct { | |
| 158 | + | Endpoint string `json:"endpoint"` | |
| 159 | + | Incarnation string `json:"incarnation"` | |
| 160 | + | Generation uint64 `json:"generation"` | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func decodeHosts(raw []byte, policies map[string]ports.BrokerPolicy) ([]ports.BrokerHostRecord, error) { | |
| 164 | + | var header struct { | |
| 165 | + | Version int `json:"version"` | |
| 166 | + | } | |
| 167 | + | if err := json.Unmarshal(raw, &header); err != nil { | |
| 168 | + | return nil, err | |
| 169 | + | } | |
| 170 | + | var pinned, learned []legacyRecord | |
| 171 | + | switch header.Version { | |
| 172 | + | case 1: | |
| 173 | + | var f struct { | |
| 174 | + | Version int `json:"version"` | |
| 175 | + | Hosts []string `json:"hosts"` | |
| 176 | + | } | |
| 177 | + | if err := strict(raw, &f); err != nil { | |
| 178 | + | return nil, err | |
| 179 | + | } | |
| 180 | + | if f.Hosts == nil { | |
| 181 | + | return nil, errors.New("brokerstore: missing hosts") | |
| 182 | + | } | |
| 183 | + | for _, e := range f.Hosts { | |
| 184 | + | learned = append(learned, legacyRecord{Endpoint: e}) | |
| 185 | + | } | |
| 186 | + | case 2: | |
| 187 | + | var f struct { | |
| 188 | + | Version int `json:"version"` | |
| 189 | + | Pinned []string `json:"pinned"` | |
| 190 | + | Learned []string `json:"learned"` | |
| 191 | + | } | |
| 192 | + | if err := strict(raw, &f); err != nil { | |
| 193 | + | return nil, err | |
| 194 | + | } | |
| 195 | + | if f.Pinned == nil || f.Learned == nil { | |
| 196 | + | return nil, errors.New("brokerstore: missing memberships") | |
| 197 | + | } | |
| 198 | + | for _, e := range f.Pinned { | |
| 199 | + | pinned = append(pinned, legacyRecord{Endpoint: e}) | |
| 200 | + | } | |
| 201 | + | for _, e := range f.Learned { | |
| 202 | + | learned = append(learned, legacyRecord{Endpoint: e}) | |
| 203 | + | } | |
| 204 | + | case 3: | |
| 205 | + | var f struct { | |
| 206 | + | Version int `json:"version"` | |
| 207 | + | Pinned []legacyRecord `json:"pinned"` | |
| 208 | + | Learned []legacyRecord `json:"learned"` | |
| 209 | + | } | |
| 210 | + | if err := strict(raw, &f); err != nil { | |
| 211 | + | return nil, err | |
| 212 | + | } | |
| 213 | + | if f.Pinned == nil || f.Learned == nil { | |
| 214 | + | return nil, errors.New("brokerstore: missing memberships") | |
| 215 | + | } | |
| 216 | + | pinned, learned = f.Pinned, f.Learned | |
| 217 | + | default: | |
| 218 | + | return nil, fmt.Errorf("brokerstore: unsupported hosts version %d", header.Version) | |
| 219 | + | } | |
| 220 | + | if len(pinned) > ports.BrokerMaxHosts || len(learned) > ports.BrokerMaxHosts { | |
| 221 | + | return nil, errors.New("brokerstore: too many hosts") | |
| 222 | + | } | |
| 223 | + | var out []ports.BrokerHostRecord | |
| 224 | + | indices := map[string]int{} | |
| 225 | + | for group, records := range [][]legacyRecord{pinned, learned} { | |
| 226 | + | seen := map[string]bool{} | |
| 227 | + | for _, record := range records { | |
| 228 | + | if seen[record.Endpoint] { | |
| 229 | + | return nil, errors.New("brokerstore: duplicate membership") | |
| 230 | + | } | |
| 231 | + | seen[record.Endpoint] = true | |
| 232 | + | reg := domain.RemoteRegistration{Endpoint: record.Endpoint, Generation: domain.RemoteGeneration(record.Generation)} | |
| 233 | + | if header.Version == 3 { | |
| 234 | + | b, err := hex.DecodeString(record.Incarnation) | |
| 235 | + | if err != nil || len(b) != 16 { | |
| 236 | + | return nil, errors.New("brokerstore: invalid incarnation") | |
| 237 | + | } | |
| 238 | + | copy(reg.Incarnation[:], b) | |
| 239 | + | } | |
| 240 | + | if index, ok := indices[record.Endpoint]; ok { | |
| 241 | + | if header.Version == 3 && !reg.Equal(out[index].Registration) { | |
| 242 | + | return nil, errors.New("brokerstore: conflicting registration") | |
| 243 | + | } | |
| 244 | + | out[index].Learned = true | |
| 245 | + | continue | |
| 246 | + | } | |
| 247 | + | if header.Version != 3 { | |
| 248 | + | if _, err := rand.Read(reg.Incarnation[:]); err != nil { | |
| 249 | + | return nil, err | |
| 250 | + | } | |
| 251 | + | reg.Generation = 1 | |
| 252 | + | } | |
| 253 | + | if err := reg.Validate(); err != nil { | |
| 254 | + | return nil, err | |
| 255 | + | } | |
| 256 | + | policy, ok := policies[record.Endpoint] | |
| 257 | + | if !ok { | |
| 258 | + | return nil, fmt.Errorf("brokerstore: explicit policy required for %q", record.Endpoint) | |
| 259 | + | } | |
| 260 | + | if err := policy.Validate(); err != nil { | |
| 261 | + | return nil, err | |
| 262 | + | } | |
| 263 | + | indices[record.Endpoint] = len(out) | |
| 264 | + | out = append(out, ports.BrokerHostRecord{Registration: reg, Pinned: group == 0, Learned: group == 1, Policy: policy}) | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | return out, nil | |
| 268 | + | } | |
| @@ -0,0 +1,268 @@ | |||||
| 1 | + | package brokerstore | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "crypto/rand" | |||
| 6 | + | "encoding/hex" | |||
| 7 | + | "encoding/json" | |||
| 8 | + | "errors" | |||
| 9 | + | "fmt" | |||
| 10 | + | "io" | |||
| 11 | + | "os" | |||
| 12 | + | "syscall" | |||
| 13 | + | "unicode/utf8" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/vev/internal/domain" | |||
| 16 | + | "github.com/bnema/vev/internal/ports" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func readBounded(path string) ([]byte, error) { | |||
| 20 | + | fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) | |||
| 21 | + | if err != nil { | |||
| 22 | + | return nil, err | |||
| 23 | + | } | |||
| 24 | + | f := os.NewFile(uintptr(fd), path) | |||
| 25 | + | defer f.Close() | |||
| 26 | + | st, err := f.Stat() | |||
| 27 | + | if err != nil { | |||
| 28 | + | return nil, err | |||
| 29 | + | } | |||
| 30 | + | if !st.Mode().IsRegular() || st.Size() > MaxFileBytes { | |||
| 31 | + | return nil, errors.New("brokerstore: invalid file type or size") | |||
| 32 | + | } | |||
| 33 | + | b, err := io.ReadAll(io.LimitReader(f, MaxFileBytes+1)) | |||
| 34 | + | if len(b) > MaxFileBytes { | |||
| 35 | + | return nil, errors.New("brokerstore: oversized input") | |||
| 36 | + | } | |||
| 37 | + | return b, err | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // strict rejects duplicate keys as well as unknown fields, trailing JSON, | |||
| 41 | + | // invalid UTF-8 and excessive nesting. Byte bounds apply before decoding. | |||
| 42 | + | func strict(raw []byte, dst any) error { | |||
| 43 | + | if len(raw) > MaxFileBytes || !utf8.Valid(raw) { | |||
| 44 | + | return errors.New("brokerstore: invalid JSON size or encoding") | |||
| 45 | + | } | |||
| 46 | + | scan := json.NewDecoder(bytes.NewReader(raw)) | |||
| 47 | + | if err := scanValue(scan, 0); err != nil { | |||
| 48 | + | return err | |||
| 49 | + | } | |||
| 50 | + | if _, err := scan.Token(); err != io.EOF { | |||
| 51 | + | return errors.New("brokerstore: trailing JSON") | |||
| 52 | + | } | |||
| 53 | + | dec := json.NewDecoder(bytes.NewReader(raw)) | |||
| 54 | + | dec.DisallowUnknownFields() | |||
| 55 | + | return dec.Decode(dst) | |||
| 56 | + | } | |||
| 57 | + | func scanValue(d *json.Decoder, depth int) error { | |||
| 58 | + | if depth > 64 { | |||
| 59 | + | return errors.New("brokerstore: JSON nesting limit") | |||
| 60 | + | } | |||
| 61 | + | t, err := d.Token() | |||
| 62 | + | if err != nil { | |||
| 63 | + | return err | |||
| 64 | + | } | |||
| 65 | + | delim, ok := t.(json.Delim) | |||
| 66 | + | if !ok { | |||
| 67 | + | return nil | |||
| 68 | + | } | |||
| 69 | + | switch delim { | |||
| 70 | + | case '{': | |||
| 71 | + | seen := map[string]bool{} | |||
| 72 | + | for d.More() { | |||
| 73 | + | key, err := d.Token() | |||
| 74 | + | if err != nil { | |||
| 75 | + | return err | |||
| 76 | + | } | |||
| 77 | + | k, ok := key.(string) | |||
| 78 | + | if !ok || seen[k] { | |||
| 79 | + | return errors.New("brokerstore: duplicate JSON key") | |||
| 80 | + | } | |||
| 81 | + | seen[k] = true | |||
| 82 | + | if err := scanValue(d, depth+1); err != nil { | |||
| 83 | + | return err | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | case '[': | |||
| 87 | + | for d.More() { | |||
| 88 | + | if err := scanValue(d, depth+1); err != nil { | |||
| 89 | + | return err | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | default: | |||
| 93 | + | return errors.New("brokerstore: unexpected delimiter") | |||
| 94 | + | } | |||
| 95 | + | _, err = d.Token() | |||
| 96 | + | return err | |||
| 97 | + | } | |||
| 98 | + | func source(path string) ([]byte, error) { | |||
| 99 | + | if path == "" { | |||
| 100 | + | return nil, nil | |||
| 101 | + | } | |||
| 102 | + | return readBounded(path) | |||
| 103 | + | } | |||
| 104 | + | func migrate(o Options) (recovery, error) { | |||
| 105 | + | var r recovery | |||
| 106 | + | var err error | |||
| 107 | + | if r.Hosts, err = source(o.LegacyHosts); err != nil { | |||
| 108 | + | return r, err | |||
| 109 | + | } | |||
| 110 | + | if r.Cache, err = source(o.LegacyCache); err != nil { | |||
| 111 | + | return r, err | |||
| 112 | + | } | |||
| 113 | + | r.State.Manifest = manifest{Version: 1, Sources: []manifestSource{ | |||
| 114 | + | {Label: manifestMembership, Present: o.LegacyHosts != "", SHA256: digest(r.Hosts)}, | |||
| 115 | + | {Label: manifestObservations, Present: o.LegacyCache != "", SHA256: digest(r.Cache)}, | |||
| 116 | + | }} | |||
| 117 | + | r.State.Hosts = ports.BrokerHosts{Revision: 1} | |||
| 118 | + | if o.LegacyHosts != "" { | |||
| 119 | + | r.State.Hosts.Hosts, err = decodeHosts(r.Hosts, o.Policies) | |||
| 120 | + | if err != nil { | |||
| 121 | + | return r, err | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | if o.LegacyCache != "" { | |||
| 125 | + | entries, err := decodeCache(r.Cache) | |||
| 126 | + | if err != nil { | |||
| 127 | + | return r, err | |||
| 128 | + | } | |||
| 129 | + | for _, e := range entries { | |||
| 130 | + | for _, h := range r.State.Hosts.Hosts { | |||
| 131 | + | // Unbound v2/v3 cache data is validated and backed up, never promoted | |||
| 132 | + | // to authority. v4 can seed only its exact surviving incarnation. | |||
| 133 | + | if e.Host == h.Registration.Endpoint && e.Incarnation == h.Registration.Incarnation { | |||
| 134 | + | r.State.Snapshot.Hosts = append(r.State.Snapshot.Hosts, ports.RemoteHostSnapshot{Endpoint: e.Host, Registration: h.Registration, Availability: domain.RemoteAvailabilityUnknown, InventoryKnown: true, LastSuccess: e.FetchedAt, Sessions: e.Sessions}) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | if len(r.State.Snapshot.Hosts) > 0 { | |||
| 140 | + | var epoch [8]byte | |||
| 141 | + | if _, err = rand.Read(epoch[:]); err != nil { | |||
| 142 | + | return r, err | |||
| 143 | + | } | |||
| 144 | + | var n uint64 | |||
| 145 | + | for _, b := range epoch { | |||
| 146 | + | n = n<<8 | uint64(b) | |||
| 147 | + | } | |||
| 148 | + | if n == 0 { | |||
| 149 | + | n = 1 | |||
| 150 | + | } | |||
| 151 | + | r.State.Snapshot.Epoch = ports.BrokerEpoch(n) | |||
| 152 | + | r.State.Snapshot.Revision = 1 | |||
| 153 | + | } | |||
| 154 | + | return r, validate(r.State) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | type legacyRecord struct { | |||
| 158 | + | Endpoint string `json:"endpoint"` | |||
| 159 | + | Incarnation string `json:"incarnation"` | |||
| 160 | + | Generation uint64 `json:"generation"` | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func decodeHosts(raw []byte, policies map[string]ports.BrokerPolicy) ([]ports.BrokerHostRecord, error) { | |||
| 164 | + | var header struct { | |||
| 165 | + | Version int `json:"version"` | |||
| 166 | + | } | |||
| 167 | + | if err := json.Unmarshal(raw, &header); err != nil { | |||
| 168 | + | return nil, err | |||
| 169 | + | } | |||
| 170 | + | var pinned, learned []legacyRecord | |||
| 171 | + | switch header.Version { | |||
| 172 | + | case 1: | |||
| 173 | + | var f struct { | |||
| 174 | + | Version int `json:"version"` | |||
| 175 | + | Hosts []string `json:"hosts"` | |||
| 176 | + | } | |||
| 177 | + | if err := strict(raw, &f); err != nil { | |||
| 178 | + | return nil, err | |||
| 179 | + | } | |||
| 180 | + | if f.Hosts == nil { | |||
| 181 | + | return nil, errors.New("brokerstore: missing hosts") | |||
| 182 | + | } | |||
| 183 | + | for _, e := range f.Hosts { | |||
| 184 | + | learned = append(learned, legacyRecord{Endpoint: e}) | |||
| 185 | + | } | |||
| 186 | + | case 2: | |||
| 187 | + | var f struct { | |||
| 188 | + | Version int `json:"version"` | |||
| 189 | + | Pinned []string `json:"pinned"` | |||
| 190 | + | Learned []string `json:"learned"` | |||
| 191 | + | } | |||
| 192 | + | if err := strict(raw, &f); err != nil { | |||
| 193 | + | return nil, err | |||
| 194 | + | } | |||
| 195 | + | if f.Pinned == nil || f.Learned == nil { | |||
| 196 | + | return nil, errors.New("brokerstore: missing memberships") | |||
| 197 | + | } | |||
| 198 | + | for _, e := range f.Pinned { | |||
| 199 | + | pinned = append(pinned, legacyRecord{Endpoint: e}) | |||
| 200 | + | } | |||
| 201 | + | for _, e := range f.Learned { | |||
| 202 | + | learned = append(learned, legacyRecord{Endpoint: e}) | |||
| 203 | + | } | |||
| 204 | + | case 3: | |||
| 205 | + | var f struct { | |||
| 206 | + | Version int `json:"version"` | |||
| 207 | + | Pinned []legacyRecord `json:"pinned"` | |||
| 208 | + | Learned []legacyRecord `json:"learned"` | |||
| 209 | + | } | |||
| 210 | + | if err := strict(raw, &f); err != nil { | |||
| 211 | + | return nil, err | |||
| 212 | + | } | |||
| 213 | + | if f.Pinned == nil || f.Learned == nil { | |||
| 214 | + | return nil, errors.New("brokerstore: missing memberships") | |||
| 215 | + | } | |||
| 216 | + | pinned, learned = f.Pinned, f.Learned | |||
| 217 | + | default: | |||
| 218 | + | return nil, fmt.Errorf("brokerstore: unsupported hosts version %d", header.Version) | |||
| 219 | + | } | |||
| 220 | + | if len(pinned) > ports.BrokerMaxHosts || len(learned) > ports.BrokerMaxHosts { | |||
| 221 | + | return nil, errors.New("brokerstore: too many hosts") | |||
| 222 | + | } | |||
| 223 | + | var out []ports.BrokerHostRecord | |||
| 224 | + | indices := map[string]int{} | |||
| 225 | + | for group, records := range [][]legacyRecord{pinned, learned} { | |||
| 226 | + | seen := map[string]bool{} | |||
| 227 | + | for _, record := range records { | |||
| 228 | + | if seen[record.Endpoint] { | |||
| 229 | + | return nil, errors.New("brokerstore: duplicate membership") | |||
| 230 | + | } | |||
| 231 | + | seen[record.Endpoint] = true | |||
| 232 | + | reg := domain.RemoteRegistration{Endpoint: record.Endpoint, Generation: domain.RemoteGeneration(record.Generation)} | |||
| 233 | + | if header.Version == 3 { | |||
| 234 | + | b, err := hex.DecodeString(record.Incarnation) | |||
| 235 | + | if err != nil || len(b) != 16 { | |||
| 236 | + | return nil, errors.New("brokerstore: invalid incarnation") | |||
| 237 | + | } | |||
| 238 | + | copy(reg.Incarnation[:], b) | |||
| 239 | + | } | |||
| 240 | + | if index, ok := indices[record.Endpoint]; ok { | |||
| 241 | + | if header.Version == 3 && !reg.Equal(out[index].Registration) { | |||
| 242 | + | return nil, errors.New("brokerstore: conflicting registration") | |||
| 243 | + | } | |||
| 244 | + | out[index].Learned = true | |||
| 245 | + | continue | |||
| 246 | + | } | |||
| 247 | + | if header.Version != 3 { | |||
| 248 | + | if _, err := rand.Read(reg.Incarnation[:]); err != nil { | |||
| 249 | + | return nil, err | |||
| 250 | + | } | |||
| 251 | + | reg.Generation = 1 | |||
| 252 | + | } | |||
| 253 | + | if err := reg.Validate(); err != nil { | |||
| 254 | + | return nil, err | |||
| 255 | + | } | |||
| 256 | + | policy, ok := policies[record.Endpoint] | |||
| 257 | + | if !ok { | |||
| 258 | + | return nil, fmt.Errorf("brokerstore: explicit policy required for %q", record.Endpoint) | |||
| 259 | + | } | |||
| 260 | + | if err := policy.Validate(); err != nil { | |||
| 261 | + | return nil, err | |||
| 262 | + | } | |||
| 263 | + | indices[record.Endpoint] = len(out) | |||
| 264 | + | out = append(out, ports.BrokerHostRecord{Registration: reg, Pinned: group == 0, Learned: group == 1, Policy: policy}) | |||
| 265 | + | } | |||
| 266 | + | } | |||
| 267 | + | return out, nil | |||
| 268 | + | } | |||
A internal/adapters/brokerstore/legacy_cache.go +258 -0
| @@ -0,0 +1,258 @@ | |||
| 1 | + | package brokerstore | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/hex" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "sort" | |
| 8 | + | "time" | |
| 9 | + | "unicode/utf8" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/domain" | |
| 12 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | const ( | |
| 16 | + | catalogCacheFileVersion = 4 | |
| 17 | + | catalogCacheLegacyFileVersion = 3 | |
| 18 | + | catalogCacheExactFileVersion = 2 | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | type catalogCacheFile struct { | |
| 22 | + | Version *int `json:"version"` | |
| 23 | + | Hosts *[]catalogCacheHost `json:"hosts"` | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | type catalogCacheVersion struct { | |
| 27 | + | Version *int `json:"version"` | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | type catalogCacheHost struct { | |
| 31 | + | Target *string `json:"target"` | |
| 32 | + | FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"` | |
| 33 | + | Incarnation *string `json:"incarnation,omitempty"` | |
| 34 | + | Sessions *[]catalogCacheSession `json:"sessions"` | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | type catalogCacheSession struct { | |
| 38 | + | LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id"` | |
| 39 | + | Name *string `json:"name"` | |
| 40 | + | State *catalogue.RemoteCatalogSessionState `json:"state"` | |
| 41 | + | Ephemeral *bool `json:"ephemeral"` | |
| 42 | + | LastUsedSeq *uint64 `json:"last_used_seq,omitempty"` | |
| 43 | + | Tabs *[]catalogue.RemoteCatalogTab `json:"tabs"` | |
| 44 | + | ActiveTabID *string `json:"active_tab_id,omitempty"` | |
| 45 | + | Attached *bool `json:"attached"` | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | type catalogCacheFileV2 struct { | |
| 49 | + | Version *int `json:"version"` | |
| 50 | + | Hosts *[]catalogCacheHostV2 `json:"hosts"` | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | type catalogCacheHostV2 struct { | |
| 54 | + | Target *string `json:"target"` | |
| 55 | + | FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"` | |
| 56 | + | Sessions *[]catalogCacheSessionV2 `json:"sessions"` | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | type catalogCacheSessionV2 struct { | |
| 60 | + | LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"` | |
| 61 | + | Name *string `json:"name"` | |
| 62 | + | State *catalogue.RemoteCatalogSessionState `json:"state"` | |
| 63 | + | Ephemeral *bool `json:"ephemeral"` | |
| 64 | + | LastUsedSeq *uint64 `json:"last_used_seq,omitempty"` | |
| 65 | + | Tabs *uint16 `json:"tabs,omitempty"` | |
| 66 | + | TabList *[]catalogue.RemoteCatalogTab `json:"tab_list,omitempty"` | |
| 67 | + | ActiveTabID *string `json:"active_tab_id,omitempty"` | |
| 68 | + | Attached *bool `json:"attached"` | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func decodeCache(raw []byte) ([]catalogue.RemoteCatalogCacheEntry, error) { | |
| 72 | + | var version catalogCacheVersion | |
| 73 | + | if err := json.Unmarshal(raw, &version); err != nil { | |
| 74 | + | return nil, err | |
| 75 | + | } | |
| 76 | + | if version.Version == nil { | |
| 77 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing version") | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | var file catalogCacheFile | |
| 81 | + | requireIncarnation := false | |
| 82 | + | switch *version.Version { | |
| 83 | + | case catalogCacheFileVersion: | |
| 84 | + | requireIncarnation = true | |
| 85 | + | if err := strict(raw, &file); err != nil { | |
| 86 | + | return nil, err | |
| 87 | + | } | |
| 88 | + | case catalogCacheLegacyFileVersion: | |
| 89 | + | if err := strict(raw, &file); err != nil { | |
| 90 | + | return nil, err | |
| 91 | + | } | |
| 92 | + | case catalogCacheExactFileVersion: | |
| 93 | + | var legacy catalogCacheFileV2 | |
| 94 | + | if err := strict(raw, &legacy); err != nil { | |
| 95 | + | return nil, err | |
| 96 | + | } | |
| 97 | + | migrated, err := migrateExactCatalogCacheV2(legacy) | |
| 98 | + | if err != nil { | |
| 99 | + | return nil, err | |
| 100 | + | } | |
| 101 | + | file = migrated | |
| 102 | + | default: | |
| 103 | + | return nil, fmt.Errorf("remote catalog cache: unsupported cache file version %d", *version.Version) | |
| 104 | + | } | |
| 105 | + | if file.Hosts == nil { | |
| 106 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing hosts") | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | entries := make([]catalogue.RemoteCatalogCacheEntry, 0, len(*file.Hosts)) | |
| 110 | + | for _, host := range *file.Hosts { | |
| 111 | + | if host.Target == nil || host.FetchedAtUnixNano == nil || host.Sessions == nil { | |
| 112 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing host fields") | |
| 113 | + | } | |
| 114 | + | if !utf8.ValidString(*host.Target) { | |
| 115 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8") | |
| 116 | + | } | |
| 117 | + | if *host.FetchedAtUnixNano <= 0 { | |
| 118 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: non-positive fetched time") | |
| 119 | + | } | |
| 120 | + | entry := catalogue.RemoteCatalogCacheEntry{ | |
| 121 | + | Host: *host.Target, | |
| 122 | + | FetchedAt: time.Unix(0, *host.FetchedAtUnixNano), | |
| 123 | + | Sessions: make([]catalogue.RemoteCatalogSession, 0, len(*host.Sessions)), | |
| 124 | + | } | |
| 125 | + | if host.Incarnation != nil { | |
| 126 | + | raw, err := hex.DecodeString(*host.Incarnation) | |
| 127 | + | if err != nil || len(raw) != 16 { | |
| 128 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid incarnation for %q", *host.Target) | |
| 129 | + | } | |
| 130 | + | copy(entry.Incarnation[:], raw) | |
| 131 | + | } else if requireIncarnation { | |
| 132 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing incarnation for %q", *host.Target) | |
| 133 | + | } | |
| 134 | + | // A zero incarnation is unbound advisory data: it loads but must | |
| 135 | + | // never seed a registration. | |
| 136 | + | for _, session := range *host.Sessions { | |
| 137 | + | if session.LifecycleID == nil || session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Attached == nil || session.Tabs == nil { | |
| 138 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing session fields") | |
| 139 | + | } | |
| 140 | + | if !utf8.ValidString(*session.Name) || !utf8.ValidString(string(*session.State)) { | |
| 141 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8") | |
| 142 | + | } | |
| 143 | + | tabs := make([]catalogue.RemoteCatalogTab, len(*session.Tabs)) | |
| 144 | + | copy(tabs, *session.Tabs) | |
| 145 | + | decoded := catalogue.RemoteCatalogSession{ | |
| 146 | + | LifecycleID: *session.LifecycleID, | |
| 147 | + | Name: *session.Name, | |
| 148 | + | State: *session.State, | |
| 149 | + | Ephemeral: *session.Ephemeral, | |
| 150 | + | Tabs: tabs, | |
| 151 | + | Attached: *session.Attached, | |
| 152 | + | } | |
| 153 | + | if session.LastUsedSeq != nil { | |
| 154 | + | decoded.LastUsedSeq = *session.LastUsedSeq | |
| 155 | + | } | |
| 156 | + | if session.ActiveTabID != nil { | |
| 157 | + | decoded.ActiveTabID = *session.ActiveTabID | |
| 158 | + | } | |
| 159 | + | entry.Sessions = append(entry.Sessions, decoded) | |
| 160 | + | } | |
| 161 | + | entries = append(entries, entry) | |
| 162 | + | } | |
| 163 | + | normalized, err := normalizeCatalogCacheEntries(entries) | |
| 164 | + | if err != nil { | |
| 165 | + | return nil, err | |
| 166 | + | } | |
| 167 | + | return normalized, nil | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func migrateExactCatalogCacheV2(legacy catalogCacheFileV2) (catalogCacheFile, error) { | |
| 171 | + | if legacy.Version == nil || *legacy.Version != catalogCacheExactFileVersion || legacy.Hosts == nil { | |
| 172 | + | return catalogCacheFile{}, fmt.Errorf("remote catalog cache: malformed version 2 cache file") | |
| 173 | + | } | |
| 174 | + | hosts := make([]catalogCacheHost, 0, len(*legacy.Hosts)) | |
| 175 | + | for _, host := range *legacy.Hosts { | |
| 176 | + | if host.Sessions == nil { | |
| 177 | + | return catalogCacheFile{}, fmt.Errorf("remote catalog cache: malformed cache file: missing host fields") | |
| 178 | + | } | |
| 179 | + | sessions := make([]catalogCacheSession, 0, len(*host.Sessions)) | |
| 180 | + | for _, session := range *host.Sessions { | |
| 181 | + | if session.Tabs != nil || session.TabList == nil { | |
| 182 | + | return catalogCacheFile{}, fmt.Errorf("remote catalog cache: unsupported ambiguous version 2 tab count") | |
| 183 | + | } | |
| 184 | + | sessions = append(sessions, catalogCacheSession{ | |
| 185 | + | LifecycleID: session.LifecycleID, | |
| 186 | + | Name: session.Name, | |
| 187 | + | State: session.State, | |
| 188 | + | Ephemeral: session.Ephemeral, | |
| 189 | + | LastUsedSeq: session.LastUsedSeq, | |
| 190 | + | Tabs: session.TabList, | |
| 191 | + | ActiveTabID: session.ActiveTabID, | |
| 192 | + | Attached: session.Attached, | |
| 193 | + | }) | |
| 194 | + | } | |
| 195 | + | hosts = append(hosts, catalogCacheHost{ | |
| 196 | + | Target: host.Target, FetchedAtUnixNano: host.FetchedAtUnixNano, Sessions: &sessions, | |
| 197 | + | }) | |
| 198 | + | } | |
| 199 | + | version := catalogCacheFileVersion | |
| 200 | + | return catalogCacheFile{Version: &version, Hosts: &hosts}, nil | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func normalizeCatalogCacheEntries(entries []catalogue.RemoteCatalogCacheEntry) ([]catalogue.RemoteCatalogCacheEntry, error) { | |
| 204 | + | if err := catalogue.ValidateRemoteCatalogCacheEntries(entries); err != nil { | |
| 205 | + | return nil, err | |
| 206 | + | } | |
| 207 | + | normalized := make([]catalogue.RemoteCatalogCacheEntry, 0, len(entries)) | |
| 208 | + | hosts := make(map[string]struct{}, len(entries)) | |
| 209 | + | for _, entry := range entries { | |
| 210 | + | if err := domain.ValidateRemoteHostTarget(entry.Host); err != nil { | |
| 211 | + | return nil, fmt.Errorf("remote catalog cache: invalid host %q: %w", entry.Host, err) | |
| 212 | + | } | |
| 213 | + | if entry.FetchedAt.UnixNano() <= 0 { | |
| 214 | + | return nil, fmt.Errorf("remote catalog cache: host %q has non-positive fetched time", entry.Host) | |
| 215 | + | } | |
| 216 | + | if _, duplicate := hosts[entry.Host]; duplicate { | |
| 217 | + | return nil, fmt.Errorf("remote catalog cache: duplicate host %q", entry.Host) | |
| 218 | + | } | |
| 219 | + | hosts[entry.Host] = struct{}{} | |
| 220 | + | ||
| 221 | + | copyEntry := catalogue.RemoteCatalogCacheEntry{ | |
| 222 | + | Host: entry.Host, | |
| 223 | + | FetchedAt: entry.FetchedAt, | |
| 224 | + | Incarnation: entry.Incarnation, | |
| 225 | + | Sessions: make([]catalogue.RemoteCatalogSession, 0, len(entry.Sessions)), | |
| 226 | + | } | |
| 227 | + | sessions := make(map[string]struct{}, len(entry.Sessions)) | |
| 228 | + | for _, session := range entry.Sessions { | |
| 229 | + | if !utf8.ValidString(session.Name) || !utf8.ValidString(string(session.State)) { | |
| 230 | + | return nil, fmt.Errorf("remote catalog cache: session is not valid UTF-8") | |
| 231 | + | } | |
| 232 | + | if err := domain.ValidateSessionName(session.Name); err != nil { | |
| 233 | + | return nil, fmt.Errorf("remote catalog cache: invalid session %q: %w", session.Name, err) | |
| 234 | + | } | |
| 235 | + | if !session.State.Valid() { | |
| 236 | + | return nil, fmt.Errorf("remote catalog cache: invalid session state %q", session.State) | |
| 237 | + | } | |
| 238 | + | if _, duplicate := sessions[session.Name]; duplicate { | |
| 239 | + | return nil, fmt.Errorf("remote catalog cache: duplicate session %q for host %q", session.Name, entry.Host) | |
| 240 | + | } | |
| 241 | + | sessions[session.Name] = struct{}{} | |
| 242 | + | copySession := session | |
| 243 | + | copySession.Tabs = make([]catalogue.RemoteCatalogTab, len(session.Tabs)) | |
| 244 | + | for i, tab := range session.Tabs { | |
| 245 | + | copySession.Tabs[i] = catalogue.RemoteCatalogTab{ID: tab.ID, Index: tab.Index, Name: tab.Name} | |
| 246 | + | } | |
| 247 | + | copyEntry.Sessions = append(copyEntry.Sessions, copySession) | |
| 248 | + | } | |
| 249 | + | sort.Slice(copyEntry.Sessions, func(i, j int) bool { | |
| 250 | + | return copyEntry.Sessions[i].Name < copyEntry.Sessions[j].Name | |
| 251 | + | }) | |
| 252 | + | normalized = append(normalized, copyEntry) | |
| 253 | + | } | |
| 254 | + | sort.Slice(normalized, func(i, j int) bool { | |
| 255 | + | return normalized[i].Host < normalized[j].Host | |
| 256 | + | }) | |
| 257 | + | return normalized, nil | |
| 258 | + | } | |
| @@ -0,0 +1,258 @@ | |||||
| 1 | + | package brokerstore | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/hex" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | "sort" | |||
| 8 | + | "time" | |||
| 9 | + | "unicode/utf8" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/vev/internal/domain" | |||
| 12 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | const ( | |||
| 16 | + | catalogCacheFileVersion = 4 | |||
| 17 | + | catalogCacheLegacyFileVersion = 3 | |||
| 18 | + | catalogCacheExactFileVersion = 2 | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | type catalogCacheFile struct { | |||
| 22 | + | Version *int `json:"version"` | |||
| 23 | + | Hosts *[]catalogCacheHost `json:"hosts"` | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | type catalogCacheVersion struct { | |||
| 27 | + | Version *int `json:"version"` | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | type catalogCacheHost struct { | |||
| 31 | + | Target *string `json:"target"` | |||
| 32 | + | FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"` | |||
| 33 | + | Incarnation *string `json:"incarnation,omitempty"` | |||
| 34 | + | Sessions *[]catalogCacheSession `json:"sessions"` | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | type catalogCacheSession struct { | |||
| 38 | + | LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id"` | |||
| 39 | + | Name *string `json:"name"` | |||
| 40 | + | State *catalogue.RemoteCatalogSessionState `json:"state"` | |||
| 41 | + | Ephemeral *bool `json:"ephemeral"` | |||
| 42 | + | LastUsedSeq *uint64 `json:"last_used_seq,omitempty"` | |||
| 43 | + | Tabs *[]catalogue.RemoteCatalogTab `json:"tabs"` | |||
| 44 | + | ActiveTabID *string `json:"active_tab_id,omitempty"` | |||
| 45 | + | Attached *bool `json:"attached"` | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | type catalogCacheFileV2 struct { | |||
| 49 | + | Version *int `json:"version"` | |||
| 50 | + | Hosts *[]catalogCacheHostV2 `json:"hosts"` | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | type catalogCacheHostV2 struct { | |||
| 54 | + | Target *string `json:"target"` | |||
| 55 | + | FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"` | |||
| 56 | + | Sessions *[]catalogCacheSessionV2 `json:"sessions"` | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | type catalogCacheSessionV2 struct { | |||
| 60 | + | LifecycleID *domain.SessionLifecycleID `json:"lifecycle_id,omitempty"` | |||
| 61 | + | Name *string `json:"name"` | |||
| 62 | + | State *catalogue.RemoteCatalogSessionState `json:"state"` | |||
| 63 | + | Ephemeral *bool `json:"ephemeral"` | |||
| 64 | + | LastUsedSeq *uint64 `json:"last_used_seq,omitempty"` | |||
| 65 | + | Tabs *uint16 `json:"tabs,omitempty"` | |||
| 66 | + | TabList *[]catalogue.RemoteCatalogTab `json:"tab_list,omitempty"` | |||
| 67 | + | ActiveTabID *string `json:"active_tab_id,omitempty"` | |||
| 68 | + | Attached *bool `json:"attached"` | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func decodeCache(raw []byte) ([]catalogue.RemoteCatalogCacheEntry, error) { | |||
| 72 | + | var version catalogCacheVersion | |||
| 73 | + | if err := json.Unmarshal(raw, &version); err != nil { | |||
| 74 | + | return nil, err | |||
| 75 | + | } | |||
| 76 | + | if version.Version == nil { | |||
| 77 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing version") | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | var file catalogCacheFile | |||
| 81 | + | requireIncarnation := false | |||
| 82 | + | switch *version.Version { | |||
| 83 | + | case catalogCacheFileVersion: | |||
| 84 | + | requireIncarnation = true | |||
| 85 | + | if err := strict(raw, &file); err != nil { | |||
| 86 | + | return nil, err | |||
| 87 | + | } | |||
| 88 | + | case catalogCacheLegacyFileVersion: | |||
| 89 | + | if err := strict(raw, &file); err != nil { | |||
| 90 | + | return nil, err | |||
| 91 | + | } | |||
| 92 | + | case catalogCacheExactFileVersion: | |||
| 93 | + | var legacy catalogCacheFileV2 | |||
| 94 | + | if err := strict(raw, &legacy); err != nil { | |||
| 95 | + | return nil, err | |||
| 96 | + | } | |||
| 97 | + | migrated, err := migrateExactCatalogCacheV2(legacy) | |||
| 98 | + | if err != nil { | |||
| 99 | + | return nil, err | |||
| 100 | + | } | |||
| 101 | + | file = migrated | |||
| 102 | + | default: | |||
| 103 | + | return nil, fmt.Errorf("remote catalog cache: unsupported cache file version %d", *version.Version) | |||
| 104 | + | } | |||
| 105 | + | if file.Hosts == nil { | |||
| 106 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing hosts") | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | entries := make([]catalogue.RemoteCatalogCacheEntry, 0, len(*file.Hosts)) | |||
| 110 | + | for _, host := range *file.Hosts { | |||
| 111 | + | if host.Target == nil || host.FetchedAtUnixNano == nil || host.Sessions == nil { | |||
| 112 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing host fields") | |||
| 113 | + | } | |||
| 114 | + | if !utf8.ValidString(*host.Target) { | |||
| 115 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8") | |||
| 116 | + | } | |||
| 117 | + | if *host.FetchedAtUnixNano <= 0 { | |||
| 118 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: non-positive fetched time") | |||
| 119 | + | } | |||
| 120 | + | entry := catalogue.RemoteCatalogCacheEntry{ | |||
| 121 | + | Host: *host.Target, | |||
| 122 | + | FetchedAt: time.Unix(0, *host.FetchedAtUnixNano), | |||
| 123 | + | Sessions: make([]catalogue.RemoteCatalogSession, 0, len(*host.Sessions)), | |||
| 124 | + | } | |||
| 125 | + | if host.Incarnation != nil { | |||
| 126 | + | raw, err := hex.DecodeString(*host.Incarnation) | |||
| 127 | + | if err != nil || len(raw) != 16 { | |||
| 128 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid incarnation for %q", *host.Target) | |||
| 129 | + | } | |||
| 130 | + | copy(entry.Incarnation[:], raw) | |||
| 131 | + | } else if requireIncarnation { | |||
| 132 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing incarnation for %q", *host.Target) | |||
| 133 | + | } | |||
| 134 | + | // A zero incarnation is unbound advisory data: it loads but must | |||
| 135 | + | // never seed a registration. | |||
| 136 | + | for _, session := range *host.Sessions { | |||
| 137 | + | if session.LifecycleID == nil || session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Attached == nil || session.Tabs == nil { | |||
| 138 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing session fields") | |||
| 139 | + | } | |||
| 140 | + | if !utf8.ValidString(*session.Name) || !utf8.ValidString(string(*session.State)) { | |||
| 141 | + | return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid UTF-8") | |||
| 142 | + | } | |||
| 143 | + | tabs := make([]catalogue.RemoteCatalogTab, len(*session.Tabs)) | |||
| 144 | + | copy(tabs, *session.Tabs) | |||
| 145 | + | decoded := catalogue.RemoteCatalogSession{ | |||
| 146 | + | LifecycleID: *session.LifecycleID, | |||
| 147 | + | Name: *session.Name, | |||
| 148 | + | State: *session.State, | |||
| 149 | + | Ephemeral: *session.Ephemeral, | |||
| 150 | + | Tabs: tabs, | |||
| 151 | + | Attached: *session.Attached, | |||
| 152 | + | } | |||
| 153 | + | if session.LastUsedSeq != nil { | |||
| 154 | + | decoded.LastUsedSeq = *session.LastUsedSeq | |||
| 155 | + | } | |||
| 156 | + | if session.ActiveTabID != nil { | |||
| 157 | + | decoded.ActiveTabID = *session.ActiveTabID | |||
| 158 | + | } | |||
| 159 | + | entry.Sessions = append(entry.Sessions, decoded) | |||
| 160 | + | } | |||
| 161 | + | entries = append(entries, entry) | |||
| 162 | + | } | |||
| 163 | + | normalized, err := normalizeCatalogCacheEntries(entries) | |||
| 164 | + | if err != nil { | |||
| 165 | + | return nil, err | |||
| 166 | + | } | |||
| 167 | + | return normalized, nil | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func migrateExactCatalogCacheV2(legacy catalogCacheFileV2) (catalogCacheFile, error) { | |||
| 171 | + | if legacy.Version == nil || *legacy.Version != catalogCacheExactFileVersion || legacy.Hosts == nil { | |||
| 172 | + | return catalogCacheFile{}, fmt.Errorf("remote catalog cache: malformed version 2 cache file") | |||
| 173 | + | } | |||
| 174 | + | hosts := make([]catalogCacheHost, 0, len(*legacy.Hosts)) | |||
| 175 | + | for _, host := range *legacy.Hosts { | |||
| 176 | + | if host.Sessions == nil { | |||
| 177 | + | return catalogCacheFile{}, fmt.Errorf("remote catalog cache: malformed cache file: missing host fields") | |||
| 178 | + | } | |||
| 179 | + | sessions := make([]catalogCacheSession, 0, len(*host.Sessions)) | |||
| 180 | + | for _, session := range *host.Sessions { | |||
| 181 | + | if session.Tabs != nil || session.TabList == nil { | |||
| 182 | + | return catalogCacheFile{}, fmt.Errorf("remote catalog cache: unsupported ambiguous version 2 tab count") | |||
| 183 | + | } | |||
| 184 | + | sessions = append(sessions, catalogCacheSession{ | |||
| 185 | + | LifecycleID: session.LifecycleID, | |||
| 186 | + | Name: session.Name, | |||
| 187 | + | State: session.State, | |||
| 188 | + | Ephemeral: session.Ephemeral, | |||
| 189 | + | LastUsedSeq: session.LastUsedSeq, | |||
| 190 | + | Tabs: session.TabList, | |||
| 191 | + | ActiveTabID: session.ActiveTabID, | |||
| 192 | + | Attached: session.Attached, | |||
| 193 | + | }) | |||
| 194 | + | } | |||
| 195 | + | hosts = append(hosts, catalogCacheHost{ | |||
| 196 | + | Target: host.Target, FetchedAtUnixNano: host.FetchedAtUnixNano, Sessions: &sessions, | |||
| 197 | + | }) | |||
| 198 | + | } | |||
| 199 | + | version := catalogCacheFileVersion | |||
| 200 | + | return catalogCacheFile{Version: &version, Hosts: &hosts}, nil | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | func normalizeCatalogCacheEntries(entries []catalogue.RemoteCatalogCacheEntry) ([]catalogue.RemoteCatalogCacheEntry, error) { | |||
| 204 | + | if err := catalogue.ValidateRemoteCatalogCacheEntries(entries); err != nil { | |||
| 205 | + | return nil, err | |||
| 206 | + | } | |||
| 207 | + | normalized := make([]catalogue.RemoteCatalogCacheEntry, 0, len(entries)) | |||
| 208 | + | hosts := make(map[string]struct{}, len(entries)) | |||
| 209 | + | for _, entry := range entries { | |||
| 210 | + | if err := domain.ValidateRemoteHostTarget(entry.Host); err != nil { | |||
| 211 | + | return nil, fmt.Errorf("remote catalog cache: invalid host %q: %w", entry.Host, err) | |||
| 212 | + | } | |||
| 213 | + | if entry.FetchedAt.UnixNano() <= 0 { | |||
| 214 | + | return nil, fmt.Errorf("remote catalog cache: host %q has non-positive fetched time", entry.Host) | |||
| 215 | + | } | |||
| 216 | + | if _, duplicate := hosts[entry.Host]; duplicate { | |||
| 217 | + | return nil, fmt.Errorf("remote catalog cache: duplicate host %q", entry.Host) | |||
| 218 | + | } | |||
| 219 | + | hosts[entry.Host] = struct{}{} | |||
| 220 | + | ||||
| 221 | + | copyEntry := catalogue.RemoteCatalogCacheEntry{ | |||
| 222 | + | Host: entry.Host, | |||
| 223 | + | FetchedAt: entry.FetchedAt, | |||
| 224 | + | Incarnation: entry.Incarnation, | |||
| 225 | + | Sessions: make([]catalogue.RemoteCatalogSession, 0, len(entry.Sessions)), | |||
| 226 | + | } | |||
| 227 | + | sessions := make(map[string]struct{}, len(entry.Sessions)) | |||
| 228 | + | for _, session := range entry.Sessions { | |||
| 229 | + | if !utf8.ValidString(session.Name) || !utf8.ValidString(string(session.State)) { | |||
| 230 | + | return nil, fmt.Errorf("remote catalog cache: session is not valid UTF-8") | |||
| 231 | + | } | |||
| 232 | + | if err := domain.ValidateSessionName(session.Name); err != nil { | |||
| 233 | + | return nil, fmt.Errorf("remote catalog cache: invalid session %q: %w", session.Name, err) | |||
| 234 | + | } | |||
| 235 | + | if !session.State.Valid() { | |||
| 236 | + | return nil, fmt.Errorf("remote catalog cache: invalid session state %q", session.State) | |||
| 237 | + | } | |||
| 238 | + | if _, duplicate := sessions[session.Name]; duplicate { | |||
| 239 | + | return nil, fmt.Errorf("remote catalog cache: duplicate session %q for host %q", session.Name, entry.Host) | |||
| 240 | + | } | |||
| 241 | + | sessions[session.Name] = struct{}{} | |||
| 242 | + | copySession := session | |||
| 243 | + | copySession.Tabs = make([]catalogue.RemoteCatalogTab, len(session.Tabs)) | |||
| 244 | + | for i, tab := range session.Tabs { | |||
| 245 | + | copySession.Tabs[i] = catalogue.RemoteCatalogTab{ID: tab.ID, Index: tab.Index, Name: tab.Name} | |||
| 246 | + | } | |||
| 247 | + | copyEntry.Sessions = append(copyEntry.Sessions, copySession) | |||
| 248 | + | } | |||
| 249 | + | sort.Slice(copyEntry.Sessions, func(i, j int) bool { | |||
| 250 | + | return copyEntry.Sessions[i].Name < copyEntry.Sessions[j].Name | |||
| 251 | + | }) | |||
| 252 | + | normalized = append(normalized, copyEntry) | |||
| 253 | + | } | |||
| 254 | + | sort.Slice(normalized, func(i, j int) bool { | |||
| 255 | + | return normalized[i].Host < normalized[j].Host | |||
| 256 | + | }) | |||
| 257 | + | return normalized, nil | |||
| 258 | + | } | |||
A internal/adapters/brokerstore/legacy_test.go +84 -0
| @@ -0,0 +1,84 @@ | |||
| 1 | + | package brokerstore | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestCacheStrictFailures(t *testing.T) { | |
| 13 | + | raw, err := os.ReadFile("testdata/cache-v4.json") | |
| 14 | + | require.NoError(t, err) | |
| 15 | + | for name, data := range map[string]string{ | |
| 16 | + | "unknown": strings.Replace(string(raw), `"target":`, `"surprise":1,"target":`, 1), | |
| 17 | + | "duplicate": strings.Replace(string(raw), `"version": 4`, `"version":4,"version":4`, 1), | |
| 18 | + | "trailing": string(raw) + `{}`, | |
| 19 | + | "missing incarnation": strings.Replace(string(raw), `, "incarnation": "01000000000000000000000000000000"`, "", 1), | |
| 20 | + | "bad lifecycle": strings.Replace(string(raw), "02000000000000000000000000000000", "00000000000000000000000000000000", 1), | |
| 21 | + | "bad time": strings.Replace(string(raw), "1780000000000000000", "0", 1), | |
| 22 | + | "ambiguous v2": `{"version":2,"hosts":[{"target":"user@arch","fetched_at_unix_nano":1,"sessions":[{"lifecycle_id":"02000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"tabs":1,"attached":false}]}]}`, | |
| 23 | + | "too many hosts": `{"version":4,"hosts":[` + strings.Repeat(`{"target":"user@arch","fetched_at_unix_nano":1,"incarnation":"01000000000000000000000000000000","sessions":[]},`, 64) + `{"target":"user@arch","fetched_at_unix_nano":1,"incarnation":"01000000000000000000000000000000","sessions":[]}]}`, | |
| 24 | + | } { | |
| 25 | + | t.Run(name, func(t *testing.T) { | |
| 26 | + | o := options(t) | |
| 27 | + | o.LegacyCache = filepath.Join(t.TempDir(), "cache") | |
| 28 | + | require.NoError(t, os.WriteFile(o.LegacyCache, []byte(data), 0600)) | |
| 29 | + | _, err := OpenOffline(o) | |
| 30 | + | require.Error(t, err) | |
| 31 | + | }) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestRecoveryCorruption(t *testing.T) { | |
| 36 | + | o := options(t) | |
| 37 | + | s, err := OpenOffline(o) | |
| 38 | + | require.NoError(t, err) | |
| 39 | + | require.NoError(t, s.Close()) | |
| 40 | + | require.NoError(t, os.Remove(filepath.Join(o.Dir, "state.json"))) | |
| 41 | + | raw, err := readBounded(filepath.Join(o.Dir, "recovery.json")) | |
| 42 | + | require.NoError(t, err) | |
| 43 | + | var r recovery | |
| 44 | + | require.NoError(t, strict(raw, &r)) | |
| 45 | + | r.Hosts = []byte("changed") | |
| 46 | + | // Use the production atomic writer without changing its digest metadata. | |
| 47 | + | writer := Store{dir: o.Dir} | |
| 48 | + | require.NoError(t, writer.write("recovery.json", r)) | |
| 49 | + | _, err = OpenOffline(o) | |
| 50 | + | require.ErrorContains(t, err, "digest mismatch") | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestDefensiveCopiesAndConcurrentAccess(t *testing.T) { | |
| 54 | + | o := options(t) | |
| 55 | + | s, err := OpenOffline(o) | |
| 56 | + | require.NoError(t, err) | |
| 57 | + | defer s.Close() | |
| 58 | + | h, err := s.LoadHosts() | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | h.Hosts[0].Policy.Trust = "changed" | |
| 61 | + | snap, err := s.Load() | |
| 62 | + | require.NoError(t, err) | |
| 63 | + | snap.Hosts[0].Sessions[0].Tabs[0].Name = "changed" | |
| 64 | + | again, err := s.Load() | |
| 65 | + | require.NoError(t, err) | |
| 66 | + | require.Equal(t, "shell", again.Hosts[0].Sessions[0].Tabs[0].Name) | |
| 67 | + | hosts, err := s.LoadHosts() | |
| 68 | + | require.NoError(t, err) | |
| 69 | + | require.Equal(t, "known-hosts", hosts.Hosts[0].Policy.Trust) | |
| 70 | + | done := make(chan struct{}) | |
| 71 | + | go func() { | |
| 72 | + | defer close(done) | |
| 73 | + | for i := 0; i < 30; i++ { | |
| 74 | + | _, _ = s.Load() | |
| 75 | + | _, _ = s.LoadHosts() | |
| 76 | + | } | |
| 77 | + | }() | |
| 78 | + | for i := 0; i < 30; i++ { | |
| 79 | + | hosts, err = s.LoadHosts() | |
| 80 | + | require.NoError(t, err) | |
| 81 | + | require.NoError(t, s.ReplaceHosts(hosts.Revision, hosts.Hosts)) | |
| 82 | + | } | |
| 83 | + | <-done | |
| 84 | + | } | |
| @@ -0,0 +1,84 @@ | |||||
| 1 | + | package brokerstore | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestCacheStrictFailures(t *testing.T) { | |||
| 13 | + | raw, err := os.ReadFile("testdata/cache-v4.json") | |||
| 14 | + | require.NoError(t, err) | |||
| 15 | + | for name, data := range map[string]string{ | |||
| 16 | + | "unknown": strings.Replace(string(raw), `"target":`, `"surprise":1,"target":`, 1), | |||
| 17 | + | "duplicate": strings.Replace(string(raw), `"version": 4`, `"version":4,"version":4`, 1), | |||
| 18 | + | "trailing": string(raw) + `{}`, | |||
| 19 | + | "missing incarnation": strings.Replace(string(raw), `, "incarnation": "01000000000000000000000000000000"`, "", 1), | |||
| 20 | + | "bad lifecycle": strings.Replace(string(raw), "02000000000000000000000000000000", "00000000000000000000000000000000", 1), | |||
| 21 | + | "bad time": strings.Replace(string(raw), "1780000000000000000", "0", 1), | |||
| 22 | + | "ambiguous v2": `{"version":2,"hosts":[{"target":"user@arch","fetched_at_unix_nano":1,"sessions":[{"lifecycle_id":"02000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"tabs":1,"attached":false}]}]}`, | |||
| 23 | + | "too many hosts": `{"version":4,"hosts":[` + strings.Repeat(`{"target":"user@arch","fetched_at_unix_nano":1,"incarnation":"01000000000000000000000000000000","sessions":[]},`, 64) + `{"target":"user@arch","fetched_at_unix_nano":1,"incarnation":"01000000000000000000000000000000","sessions":[]}]}`, | |||
| 24 | + | } { | |||
| 25 | + | t.Run(name, func(t *testing.T) { | |||
| 26 | + | o := options(t) | |||
| 27 | + | o.LegacyCache = filepath.Join(t.TempDir(), "cache") | |||
| 28 | + | require.NoError(t, os.WriteFile(o.LegacyCache, []byte(data), 0600)) | |||
| 29 | + | _, err := OpenOffline(o) | |||
| 30 | + | require.Error(t, err) | |||
| 31 | + | }) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestRecoveryCorruption(t *testing.T) { | |||
| 36 | + | o := options(t) | |||
| 37 | + | s, err := OpenOffline(o) | |||
| 38 | + | require.NoError(t, err) | |||
| 39 | + | require.NoError(t, s.Close()) | |||
| 40 | + | require.NoError(t, os.Remove(filepath.Join(o.Dir, "state.json"))) | |||
| 41 | + | raw, err := readBounded(filepath.Join(o.Dir, "recovery.json")) | |||
| 42 | + | require.NoError(t, err) | |||
| 43 | + | var r recovery | |||
| 44 | + | require.NoError(t, strict(raw, &r)) | |||
| 45 | + | r.Hosts = []byte("changed") | |||
| 46 | + | // Use the production atomic writer without changing its digest metadata. | |||
| 47 | + | writer := Store{dir: o.Dir} | |||
| 48 | + | require.NoError(t, writer.write("recovery.json", r)) | |||
| 49 | + | _, err = OpenOffline(o) | |||
| 50 | + | require.ErrorContains(t, err, "digest mismatch") | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestDefensiveCopiesAndConcurrentAccess(t *testing.T) { | |||
| 54 | + | o := options(t) | |||
| 55 | + | s, err := OpenOffline(o) | |||
| 56 | + | require.NoError(t, err) | |||
| 57 | + | defer s.Close() | |||
| 58 | + | h, err := s.LoadHosts() | |||
| 59 | + | require.NoError(t, err) | |||
| 60 | + | h.Hosts[0].Policy.Trust = "changed" | |||
| 61 | + | snap, err := s.Load() | |||
| 62 | + | require.NoError(t, err) | |||
| 63 | + | snap.Hosts[0].Sessions[0].Tabs[0].Name = "changed" | |||
| 64 | + | again, err := s.Load() | |||
| 65 | + | require.NoError(t, err) | |||
| 66 | + | require.Equal(t, "shell", again.Hosts[0].Sessions[0].Tabs[0].Name) | |||
| 67 | + | hosts, err := s.LoadHosts() | |||
| 68 | + | require.NoError(t, err) | |||
| 69 | + | require.Equal(t, "known-hosts", hosts.Hosts[0].Policy.Trust) | |||
| 70 | + | done := make(chan struct{}) | |||
| 71 | + | go func() { | |||
| 72 | + | defer close(done) | |||
| 73 | + | for i := 0; i < 30; i++ { | |||
| 74 | + | _, _ = s.Load() | |||
| 75 | + | _, _ = s.LoadHosts() | |||
| 76 | + | } | |||
| 77 | + | }() | |||
| 78 | + | for i := 0; i < 30; i++ { | |||
| 79 | + | hosts, err = s.LoadHosts() | |||
| 80 | + | require.NoError(t, err) | |||
| 81 | + | require.NoError(t, s.ReplaceHosts(hosts.Revision, hosts.Hosts)) | |||
| 82 | + | } | |||
| 83 | + | <-done | |||
| 84 | + | } | |||
A internal/adapters/brokerstore/store.go +455 -0
| @@ -0,0 +1,455 @@ | |||
| 1 | + | // Package brokerstore provides offline-only broker persistence. OpenOffline is | |
| 2 | + | // never composed into application wiring. Legacy sources are immutable inputs: | |
| 3 | + | // the lifetime lock is the same hosts.json.lock the legacy host writer takes, | |
| 4 | + | // so a live host writer is excluded, but the legacy catalog cache source has no | |
| 5 | + | // lock at all and must be immutable or quiesced. No environment policy is | |
| 6 | + | // inferred. | |
| 7 | + | package brokerstore | |
| 8 | + | ||
| 9 | + | import ( | |
| 10 | + | "crypto/sha256" | |
| 11 | + | "encoding/hex" | |
| 12 | + | "encoding/json" | |
| 13 | + | "errors" | |
| 14 | + | "fmt" | |
| 15 | + | "os" | |
| 16 | + | "path/filepath" | |
| 17 | + | "sync" | |
| 18 | + | "syscall" | |
| 19 | + | ||
| 20 | + | "github.com/bnema/vev/internal/ports" | |
| 21 | + | "github.com/bnema/vev/pkg/safedir" | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | const MaxFileBytes = 16 << 20 | |
| 25 | + | ||
| 26 | + | // lockFileName is the lifetime lock of a durable broker store. The name is | |
| 27 | + | // deliberately the one the legacy host writer takes beside hosts.json, so a | |
| 28 | + | // store opened on a directory that writer also uses excludes it for the | |
| 29 | + | // store's lifetime. The legacy catalog cache source is written without any | |
| 30 | + | // lock, so that source must be immutable or quiesced before migration. | |
| 31 | + | const lockFileName = "hosts.json.lock" | |
| 32 | + | ||
| 33 | + | // ErrLocked reports that another owner already holds the lifetime lock. | |
| 34 | + | var ErrLocked = fmt.Errorf("brokerstore: already owned: %w", ports.ErrBrokerStoreLocked) | |
| 35 | + | ||
| 36 | + | // ErrStale reports that the caller's authority token no longer matches durable | |
| 37 | + | // state, or that the supplied membership would regress or re-trust an existing | |
| 38 | + | // registration without a fresh identity. | |
| 39 | + | var ErrStale = fmt.Errorf("brokerstore: stale authority: %w", ports.ErrBrokerHostConflict) | |
| 40 | + | ||
| 41 | + | // ErrInvalidState reports durable state that fails validation. The caller must | |
| 42 | + | // resolve it offline from the recovery record; the store never replaces it. | |
| 43 | + | var ErrInvalidState = fmt.Errorf("brokerstore: invalid state: %w", ports.ErrBrokerStoreInvalidState) | |
| 44 | + | ||
| 45 | + | // invalid wraps one durable-state validation failure. | |
| 46 | + | func invalid(format string, args ...any) error { | |
| 47 | + | return fmt.Errorf("%w: %s", ErrInvalidState, fmt.Sprintf(format, args...)) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // manifestSourceLabel names one immutable recovery input by its generic role: | |
| 51 | + | // the membership source that seeds authority, and the observation source that | |
| 52 | + | // only seeds advisory inventory. A label never names a legacy file or schema. | |
| 53 | + | type manifestSourceLabel string | |
| 54 | + | ||
| 55 | + | const ( | |
| 56 | + | manifestMembership manifestSourceLabel = "membership" | |
| 57 | + | manifestObservations manifestSourceLabel = "observations" | |
| 58 | + | ) | |
| 59 | + | ||
| 60 | + | // manifestSource records one recovery input. Present distinguishes a source | |
| 61 | + | // that was supplied but carried no records from a source that was never | |
| 62 | + | // supplied: absence and emptiness can share a digest, so only presence makes | |
| 63 | + | // the recovery record an exact description of the migration input. | |
| 64 | + | type manifestSource struct { | |
| 65 | + | Label manifestSourceLabel | |
| 66 | + | Present bool | |
| 67 | + | SHA256 string | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | type manifest struct { | |
| 71 | + | Version int | |
| 72 | + | Sources []manifestSource | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // source returns the manifest entry for one generic role label. | |
| 76 | + | func (m manifest) source(label manifestSourceLabel) (manifestSource, bool) { | |
| 77 | + | for _, source := range m.Sources { | |
| 78 | + | if source.Label == label { | |
| 79 | + | return source, true | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | return manifestSource{}, false | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Options names a private offline destination and immutable legacy inputs. | |
| 86 | + | // Policies must explicitly cover every imported endpoint. Fault is a test seam | |
| 87 | + | // called after each durable-write boundary; an error poisons this open handle. | |
| 88 | + | type Options struct { | |
| 89 | + | Dir, LegacyHosts, LegacyCache string | |
| 90 | + | Policies map[string]ports.BrokerPolicy | |
| 91 | + | Fault func(string) error | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | type state struct { | |
| 95 | + | Manifest manifest | |
| 96 | + | Hosts ports.BrokerHosts | |
| 97 | + | Snapshot ports.BrokerSnapshot | |
| 98 | + | } | |
| 99 | + | type recovery struct { | |
| 100 | + | State state | |
| 101 | + | Hosts, Cache []byte | |
| 102 | + | } | |
| 103 | + | type Store struct { | |
| 104 | + | mu sync.Mutex | |
| 105 | + | dir string | |
| 106 | + | lock *os.File | |
| 107 | + | state state | |
| 108 | + | fault func(string) error | |
| 109 | + | failed bool | |
| 110 | + | writeEpoch ports.BrokerEpoch // first publication binds this lifetime handle | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | var _ ports.BrokerHostStore = (*Store)(nil) | |
| 114 | + | ||
| 115 | + | // OpenOffline returns only after validated unified state is durable. The | |
| 116 | + | // recovery record is written first and retained forever as the rollback copy; | |
| 117 | + | // restart reuses its generated identities instead of migrating a second time. | |
| 118 | + | // An existing corrupt state is an error, never silently replaced by a backup. | |
| 119 | + | func OpenOffline(o Options) (*Store, error) { | |
| 120 | + | if err := safedir.EnsurePrivate(o.Dir); err != nil { | |
| 121 | + | return nil, err | |
| 122 | + | } | |
| 123 | + | fd, err := syscall.Open(filepath.Join(o.Dir, lockFileName), syscall.O_CREAT|syscall.O_RDWR|syscall.O_CLOEXEC|syscall.O_NOFOLLOW, 0600) | |
| 124 | + | if err != nil { | |
| 125 | + | return nil, err | |
| 126 | + | } | |
| 127 | + | lock := os.NewFile(uintptr(fd), lockFileName) | |
| 128 | + | info, err := lock.Stat() | |
| 129 | + | if err != nil { | |
| 130 | + | lock.Close() | |
| 131 | + | return nil, err | |
| 132 | + | } | |
| 133 | + | if !info.Mode().IsRegular() || info.Mode().Perm() != 0600 { | |
| 134 | + | lock.Close() | |
| 135 | + | return nil, invalid("unsafe owner lock") | |
| 136 | + | } | |
| 137 | + | if err = syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB); err != nil { | |
| 138 | + | lock.Close() | |
| 139 | + | return nil, fmt.Errorf("%w: %v", ErrLocked, err) | |
| 140 | + | } | |
| 141 | + | s := &Store{dir: o.Dir, lock: lock, fault: o.Fault} | |
| 142 | + | ok := false | |
| 143 | + | defer func() { | |
| 144 | + | if !ok { | |
| 145 | + | s.Close() | |
| 146 | + | } | |
| 147 | + | }() | |
| 148 | + | raw, err := readBounded(filepath.Join(o.Dir, "state.json")) | |
| 149 | + | if err == nil { | |
| 150 | + | if err = strict(raw, &s.state); err != nil { | |
| 151 | + | return nil, invalid("state.json: %v", err) | |
| 152 | + | } | |
| 153 | + | } else if errors.Is(err, os.ErrNotExist) { | |
| 154 | + | var r recovery | |
| 155 | + | raw, err = readBounded(filepath.Join(o.Dir, "recovery.json")) | |
| 156 | + | if err == nil { | |
| 157 | + | if err = strict(raw, &r); err != nil { | |
| 158 | + | return nil, invalid("recovery.json: %v", err) | |
| 159 | + | } | |
| 160 | + | if err = r.verify(); err != nil { | |
| 161 | + | return nil, err | |
| 162 | + | } | |
| 163 | + | } else if errors.Is(err, os.ErrNotExist) { | |
| 164 | + | r, err = migrate(o) | |
| 165 | + | if err == nil { | |
| 166 | + | err = s.write("recovery.json", r) | |
| 167 | + | } | |
| 168 | + | } else { | |
| 169 | + | return nil, invalid("recovery.json: %v", err) | |
| 170 | + | } | |
| 171 | + | if err == nil { | |
| 172 | + | err = validate(r.State) | |
| 173 | + | } | |
| 174 | + | if err == nil { | |
| 175 | + | err = s.write("state.json", r.State) | |
| 176 | + | } | |
| 177 | + | s.state = r.State | |
| 178 | + | } else { | |
| 179 | + | return nil, invalid("state.json: %v", err) | |
| 180 | + | } | |
| 181 | + | if err != nil { | |
| 182 | + | return nil, err | |
| 183 | + | } | |
| 184 | + | if err = validate(s.state); err != nil { | |
| 185 | + | return nil, err | |
| 186 | + | } | |
| 187 | + | ok = true | |
| 188 | + | return s, nil | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // verify reports whether the recovery record's retained bytes match their | |
| 192 | + | // manifest entries, including each source's recorded presence: an absent | |
| 193 | + | // source must retain no bytes, and a supplied source keeps its exact digest. | |
| 194 | + | func (r recovery) verify() error { | |
| 195 | + | membership, ok := r.State.Manifest.source(manifestMembership) | |
| 196 | + | if !ok || digest(r.Hosts) != membership.SHA256 { | |
| 197 | + | return invalid("recovery digest mismatch") | |
| 198 | + | } | |
| 199 | + | observations, ok := r.State.Manifest.source(manifestObservations) | |
| 200 | + | if !ok || digest(r.Cache) != observations.SHA256 { | |
| 201 | + | return invalid("recovery digest mismatch") | |
| 202 | + | } | |
| 203 | + | for _, source := range []struct { | |
| 204 | + | label manifestSourceLabel | |
| 205 | + | present bool | |
| 206 | + | bytes int | |
| 207 | + | }{{manifestMembership, membership.Present, len(r.Hosts)}, {manifestObservations, observations.Present, len(r.Cache)}} { | |
| 208 | + | if !source.present && source.bytes != 0 { | |
| 209 | + | return invalid("recovery records an absent %s source with retained bytes", source.label) | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | return nil | |
| 213 | + | } | |
| 214 | + | func digest(b []byte) string { sum := sha256.Sum256(b); return hex.EncodeToString(sum[:]) } | |
| 215 | + | func (s *Store) ready() error { | |
| 216 | + | if s.lock == nil || s.failed { | |
| 217 | + | return errors.New("brokerstore: closed or outcome unknown; reopen required") | |
| 218 | + | } | |
| 219 | + | return nil | |
| 220 | + | } | |
| 221 | + | func (s *Store) Close() error { | |
| 222 | + | s.mu.Lock() | |
| 223 | + | defer s.mu.Unlock() | |
| 224 | + | if s.lock == nil { | |
| 225 | + | return nil | |
| 226 | + | } | |
| 227 | + | err := s.lock.Close() | |
| 228 | + | s.lock = nil | |
| 229 | + | return err | |
| 230 | + | } | |
| 231 | + | func (s *Store) LoadHosts() (ports.BrokerHosts, error) { | |
| 232 | + | s.mu.Lock() | |
| 233 | + | defer s.mu.Unlock() | |
| 234 | + | if err := s.ready(); err != nil { | |
| 235 | + | return ports.BrokerHosts{}, err | |
| 236 | + | } | |
| 237 | + | h := s.state.Hosts | |
| 238 | + | h.Hosts = append([]ports.BrokerHostRecord(nil), h.Hosts...) | |
| 239 | + | return h, nil | |
| 240 | + | } | |
| 241 | + | func (s *Store) Load() (ports.BrokerSnapshot, error) { | |
| 242 | + | s.mu.Lock() | |
| 243 | + | defer s.mu.Unlock() | |
| 244 | + | if err := s.ready(); err != nil { | |
| 245 | + | return ports.BrokerSnapshot{}, err | |
| 246 | + | } | |
| 247 | + | return s.state.Snapshot.Clone(), nil | |
| 248 | + | } | |
| 249 | + | func (s *Store) ReplaceHosts(expected uint64, hosts []ports.BrokerHostRecord) error { | |
| 250 | + | s.mu.Lock() | |
| 251 | + | defer s.mu.Unlock() | |
| 252 | + | if err := s.ready(); err != nil { | |
| 253 | + | return err | |
| 254 | + | } | |
| 255 | + | // Validate caller membership before the CAS: malformed registration, | |
| 256 | + | // policy, anchoring, bound, or duplicate-endpoint input is a caller error, | |
| 257 | + | // not corrupt durable state, so it never surfaces as ErrInvalidState. | |
| 258 | + | if err := ports.ValidateBrokerHostRecords(hosts); err != nil { | |
| 259 | + | return err | |
| 260 | + | } | |
| 261 | + | if expected != s.state.Hosts.Revision || expected == ^uint64(0) { | |
| 262 | + | return ErrStale | |
| 263 | + | } | |
| 264 | + | next := s.state | |
| 265 | + | next.Hosts = ports.BrokerHosts{Revision: expected + 1, Hosts: append([]ports.BrokerHostRecord(nil), hosts...)} | |
| 266 | + | // Policy changes require a new registration generation as well; otherwise | |
| 267 | + | // an already queued observation could be accepted under changed trust. | |
| 268 | + | for _, h := range hosts { | |
| 269 | + | for _, old := range s.state.Hosts.Hosts { | |
| 270 | + | if h.Registration.Endpoint == old.Registration.Endpoint && h.Policy != old.Policy && h.Registration.Equal(old.Registration) { | |
| 271 | + | return ErrStale | |
| 272 | + | } | |
| 273 | + | if h.Registration.Incarnation == old.Registration.Incarnation && h.Registration.Endpoint == old.Registration.Endpoint && h.Registration.Generation < old.Registration.Generation { | |
| 274 | + | return ErrStale | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | next.Snapshot = filter(next.Snapshot, next.Hosts) | |
| 279 | + | return s.commit(next) | |
| 280 | + | } | |
| 281 | + | func (s *Store) Store(snapshot ports.BrokerSnapshot) error { | |
| 282 | + | s.mu.Lock() | |
| 283 | + | defer s.mu.Unlock() | |
| 284 | + | if err := s.ready(); err != nil { | |
| 285 | + | return err | |
| 286 | + | } | |
| 287 | + | // Removed tombstones and in-flight checking/failure detail are process-local | |
| 288 | + | // fencing and observation state; the durable snapshot never carries them, so | |
| 289 | + | // sanitize the caller's copy before validating the durable shape. | |
| 290 | + | snapshot = snapshot.Clone() | |
| 291 | + | snapshot.Removed = nil | |
| 292 | + | for i := range snapshot.Hosts { | |
| 293 | + | snapshot.Hosts[i].Checking = false | |
| 294 | + | snapshot.Hosts[i].LastFailure.Err = nil | |
| 295 | + | } | |
| 296 | + | if err := snapshot.Validate(); err != nil { | |
| 297 | + | return err | |
| 298 | + | } | |
| 299 | + | if s.writeEpoch != 0 && s.writeEpoch != snapshot.Epoch { | |
| 300 | + | return ErrStale | |
| 301 | + | } | |
| 302 | + | old := s.state.Snapshot | |
| 303 | + | if old.Epoch == snapshot.Epoch && old.Revision >= snapshot.Revision { | |
| 304 | + | return ErrStale | |
| 305 | + | } | |
| 306 | + | // Reject rather than partially adopt a queued publication from retired hosts. | |
| 307 | + | for _, h := range snapshot.Hosts { | |
| 308 | + | found := false | |
| 309 | + | for _, a := range s.state.Hosts.Hosts { | |
| 310 | + | if h.Registration.Equal(a.Registration) { | |
| 311 | + | found = true | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | if !found { | |
| 315 | + | return ErrStale | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | next := s.state | |
| 319 | + | next.Snapshot = snapshot.Clone() | |
| 320 | + | if err := s.commit(next); err != nil { | |
| 321 | + | return err | |
| 322 | + | } | |
| 323 | + | s.writeEpoch = snapshot.Epoch | |
| 324 | + | return nil | |
| 325 | + | } | |
| 326 | + | func filter(snapshot ports.BrokerSnapshot, hosts ports.BrokerHosts) ports.BrokerSnapshot { | |
| 327 | + | out := snapshot.Clone() | |
| 328 | + | out.Hosts = nil | |
| 329 | + | out.Removed = nil | |
| 330 | + | for _, h := range snapshot.Hosts { | |
| 331 | + | for _, a := range hosts.Hosts { | |
| 332 | + | if h.Registration.Equal(a.Registration) { | |
| 333 | + | out.Hosts = append(out.Hosts, h.Clone()) | |
| 334 | + | } | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | return out | |
| 338 | + | } | |
| 339 | + | func validate(st state) error { | |
| 340 | + | if st.Manifest.Version != 1 { | |
| 341 | + | return invalid("unsupported manifest") | |
| 342 | + | } | |
| 343 | + | labels := make(map[manifestSourceLabel]bool, len(st.Manifest.Sources)) | |
| 344 | + | for _, source := range st.Manifest.Sources { | |
| 345 | + | if labels[source.Label] { | |
| 346 | + | return invalid("duplicate manifest source") | |
| 347 | + | } | |
| 348 | + | labels[source.Label] = true | |
| 349 | + | b, err := hex.DecodeString(source.SHA256) | |
| 350 | + | if err != nil || len(b) != 32 { | |
| 351 | + | return invalid("invalid digest") | |
| 352 | + | } | |
| 353 | + | } | |
| 354 | + | for _, label := range []manifestSourceLabel{manifestMembership, manifestObservations} { | |
| 355 | + | if !labels[label] { | |
| 356 | + | return invalid("missing manifest source") | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | if err := st.Hosts.Validate(); err != nil { | |
| 360 | + | return invalid("%v", err) | |
| 361 | + | } | |
| 362 | + | if st.Snapshot.Epoch == 0 { | |
| 363 | + | if st.Snapshot.Revision != 0 || len(st.Snapshot.Hosts) != 0 || len(st.Snapshot.Removed) != 0 { | |
| 364 | + | return invalid("invalid empty snapshot") | |
| 365 | + | } | |
| 366 | + | return nil | |
| 367 | + | } | |
| 368 | + | if err := st.Snapshot.Validate(); err != nil { | |
| 369 | + | return invalid("%v", err) | |
| 370 | + | } | |
| 371 | + | for _, h := range st.Snapshot.Hosts { | |
| 372 | + | found := false | |
| 373 | + | for _, a := range st.Hosts.Hosts { | |
| 374 | + | if h.Registration.Equal(a.Registration) { | |
| 375 | + | found = true | |
| 376 | + | } | |
| 377 | + | } | |
| 378 | + | if !found || h.Checking || h.LastFailure.Err != nil { | |
| 379 | + | return invalid("non-authoritative snapshot") | |
| 380 | + | } | |
| 381 | + | // The store enforces the same durable projection rules the registry | |
| 382 | + | // checks before publishing, so an accepted publication always persists. | |
| 383 | + | if err := ports.ValidateDurableHostProjection(h); err != nil { | |
| 384 | + | return invalid("%v", err) | |
| 385 | + | } | |
| 386 | + | } | |
| 387 | + | return nil | |
| 388 | + | } | |
| 389 | + | func (s *Store) commit(next state) error { | |
| 390 | + | if err := validate(next); err != nil { | |
| 391 | + | return err | |
| 392 | + | } | |
| 393 | + | if err := s.write("state.json", next); err != nil { | |
| 394 | + | s.failed = true | |
| 395 | + | return err | |
| 396 | + | } | |
| 397 | + | s.state = next | |
| 398 | + | return nil | |
| 399 | + | } | |
| 400 | + | func (s *Store) boundary(name string) error { | |
| 401 | + | if s.fault != nil { | |
| 402 | + | return s.fault(name) | |
| 403 | + | } | |
| 404 | + | return nil | |
| 405 | + | } | |
| 406 | + | func (s *Store) write(name string, value any) error { | |
| 407 | + | data, err := json.Marshal(value) | |
| 408 | + | if err != nil { | |
| 409 | + | return err | |
| 410 | + | } | |
| 411 | + | if len(data) > MaxFileBytes { | |
| 412 | + | return errors.New("brokerstore: file too large") | |
| 413 | + | } | |
| 414 | + | f, err := os.CreateTemp(s.dir, ".pending-") | |
| 415 | + | if err != nil { | |
| 416 | + | return err | |
| 417 | + | } | |
| 418 | + | defer os.Remove(f.Name()) | |
| 419 | + | defer f.Close() | |
| 420 | + | // The staged state carries rollback inputs and registration identities, so | |
| 421 | + | // its permissions are set explicitly instead of relying on the umask. | |
| 422 | + | if err = f.Chmod(0o600); err != nil { | |
| 423 | + | return err | |
| 424 | + | } | |
| 425 | + | if _, err = f.Write(data); err != nil { | |
| 426 | + | return err | |
| 427 | + | } | |
| 428 | + | if err = s.boundary(name + ":write"); err != nil { | |
| 429 | + | return err | |
| 430 | + | } | |
| 431 | + | if err = f.Sync(); err != nil { | |
| 432 | + | return err | |
| 433 | + | } | |
| 434 | + | if err = s.boundary(name + ":sync"); err != nil { | |
| 435 | + | return err | |
| 436 | + | } | |
| 437 | + | if err = f.Close(); err != nil { | |
| 438 | + | return err | |
| 439 | + | } | |
| 440 | + | if err = os.Rename(f.Name(), filepath.Join(s.dir, name)); err != nil { | |
| 441 | + | return err | |
| 442 | + | } | |
| 443 | + | if err = s.boundary(name + ":rename"); err != nil { | |
| 444 | + | return err | |
| 445 | + | } | |
| 446 | + | d, err := os.Open(s.dir) | |
| 447 | + | if err != nil { | |
| 448 | + | return err | |
| 449 | + | } | |
| 450 | + | defer d.Close() | |
| 451 | + | if err = d.Sync(); err != nil { | |
| 452 | + | return err | |
| 453 | + | } | |
| 454 | + | return s.boundary(name + ":dirsync") | |
| 455 | + | } | |
| @@ -0,0 +1,455 @@ | |||||
| 1 | + | // Package brokerstore provides offline-only broker persistence. OpenOffline is | |||
| 2 | + | // never composed into application wiring. Legacy sources are immutable inputs: | |||
| 3 | + | // the lifetime lock is the same hosts.json.lock the legacy host writer takes, | |||
| 4 | + | // so a live host writer is excluded, but the legacy catalog cache source has no | |||
| 5 | + | // lock at all and must be immutable or quiesced. No environment policy is | |||
| 6 | + | // inferred. | |||
| 7 | + | package brokerstore | |||
| 8 | + | ||||
| 9 | + | import ( | |||
| 10 | + | "crypto/sha256" | |||
| 11 | + | "encoding/hex" | |||
| 12 | + | "encoding/json" | |||
| 13 | + | "errors" | |||
| 14 | + | "fmt" | |||
| 15 | + | "os" | |||
| 16 | + | "path/filepath" | |||
| 17 | + | "sync" | |||
| 18 | + | "syscall" | |||
| 19 | + | ||||
| 20 | + | "github.com/bnema/vev/internal/ports" | |||
| 21 | + | "github.com/bnema/vev/pkg/safedir" | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | const MaxFileBytes = 16 << 20 | |||
| 25 | + | ||||
| 26 | + | // lockFileName is the lifetime lock of a durable broker store. The name is | |||
| 27 | + | // deliberately the one the legacy host writer takes beside hosts.json, so a | |||
| 28 | + | // store opened on a directory that writer also uses excludes it for the | |||
| 29 | + | // store's lifetime. The legacy catalog cache source is written without any | |||
| 30 | + | // lock, so that source must be immutable or quiesced before migration. | |||
| 31 | + | const lockFileName = "hosts.json.lock" | |||
| 32 | + | ||||
| 33 | + | // ErrLocked reports that another owner already holds the lifetime lock. | |||
| 34 | + | var ErrLocked = fmt.Errorf("brokerstore: already owned: %w", ports.ErrBrokerStoreLocked) | |||
| 35 | + | ||||
| 36 | + | // ErrStale reports that the caller's authority token no longer matches durable | |||
| 37 | + | // state, or that the supplied membership would regress or re-trust an existing | |||
| 38 | + | // registration without a fresh identity. | |||
| 39 | + | var ErrStale = fmt.Errorf("brokerstore: stale authority: %w", ports.ErrBrokerHostConflict) | |||
| 40 | + | ||||
| 41 | + | // ErrInvalidState reports durable state that fails validation. The caller must | |||
| 42 | + | // resolve it offline from the recovery record; the store never replaces it. | |||
| 43 | + | var ErrInvalidState = fmt.Errorf("brokerstore: invalid state: %w", ports.ErrBrokerStoreInvalidState) | |||
| 44 | + | ||||
| 45 | + | // invalid wraps one durable-state validation failure. | |||
| 46 | + | func invalid(format string, args ...any) error { | |||
| 47 | + | return fmt.Errorf("%w: %s", ErrInvalidState, fmt.Sprintf(format, args...)) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // manifestSourceLabel names one immutable recovery input by its generic role: | |||
| 51 | + | // the membership source that seeds authority, and the observation source that | |||
| 52 | + | // only seeds advisory inventory. A label never names a legacy file or schema. | |||
| 53 | + | type manifestSourceLabel string | |||
| 54 | + | ||||
| 55 | + | const ( | |||
| 56 | + | manifestMembership manifestSourceLabel = "membership" | |||
| 57 | + | manifestObservations manifestSourceLabel = "observations" | |||
| 58 | + | ) | |||
| 59 | + | ||||
| 60 | + | // manifestSource records one recovery input. Present distinguishes a source | |||
| 61 | + | // that was supplied but carried no records from a source that was never | |||
| 62 | + | // supplied: absence and emptiness can share a digest, so only presence makes | |||
| 63 | + | // the recovery record an exact description of the migration input. | |||
| 64 | + | type manifestSource struct { | |||
| 65 | + | Label manifestSourceLabel | |||
| 66 | + | Present bool | |||
| 67 | + | SHA256 string | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | type manifest struct { | |||
| 71 | + | Version int | |||
| 72 | + | Sources []manifestSource | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // source returns the manifest entry for one generic role label. | |||
| 76 | + | func (m manifest) source(label manifestSourceLabel) (manifestSource, bool) { | |||
| 77 | + | for _, source := range m.Sources { | |||
| 78 | + | if source.Label == label { | |||
| 79 | + | return source, true | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | return manifestSource{}, false | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // Options names a private offline destination and immutable legacy inputs. | |||
| 86 | + | // Policies must explicitly cover every imported endpoint. Fault is a test seam | |||
| 87 | + | // called after each durable-write boundary; an error poisons this open handle. | |||
| 88 | + | type Options struct { | |||
| 89 | + | Dir, LegacyHosts, LegacyCache string | |||
| 90 | + | Policies map[string]ports.BrokerPolicy | |||
| 91 | + | Fault func(string) error | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | type state struct { | |||
| 95 | + | Manifest manifest | |||
| 96 | + | Hosts ports.BrokerHosts | |||
| 97 | + | Snapshot ports.BrokerSnapshot | |||
| 98 | + | } | |||
| 99 | + | type recovery struct { | |||
| 100 | + | State state | |||
| 101 | + | Hosts, Cache []byte | |||
| 102 | + | } | |||
| 103 | + | type Store struct { | |||
| 104 | + | mu sync.Mutex | |||
| 105 | + | dir string | |||
| 106 | + | lock *os.File | |||
| 107 | + | state state | |||
| 108 | + | fault func(string) error | |||
| 109 | + | failed bool | |||
| 110 | + | writeEpoch ports.BrokerEpoch // first publication binds this lifetime handle | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | var _ ports.BrokerHostStore = (*Store)(nil) | |||
| 114 | + | ||||
| 115 | + | // OpenOffline returns only after validated unified state is durable. The | |||
| 116 | + | // recovery record is written first and retained forever as the rollback copy; | |||
| 117 | + | // restart reuses its generated identities instead of migrating a second time. | |||
| 118 | + | // An existing corrupt state is an error, never silently replaced by a backup. | |||
| 119 | + | func OpenOffline(o Options) (*Store, error) { | |||
| 120 | + | if err := safedir.EnsurePrivate(o.Dir); err != nil { | |||
| 121 | + | return nil, err | |||
| 122 | + | } | |||
| 123 | + | fd, err := syscall.Open(filepath.Join(o.Dir, lockFileName), syscall.O_CREAT|syscall.O_RDWR|syscall.O_CLOEXEC|syscall.O_NOFOLLOW, 0600) | |||
| 124 | + | if err != nil { | |||
| 125 | + | return nil, err | |||
| 126 | + | } | |||
| 127 | + | lock := os.NewFile(uintptr(fd), lockFileName) | |||
| 128 | + | info, err := lock.Stat() | |||
| 129 | + | if err != nil { | |||
| 130 | + | lock.Close() | |||
| 131 | + | return nil, err | |||
| 132 | + | } | |||
| 133 | + | if !info.Mode().IsRegular() || info.Mode().Perm() != 0600 { | |||
| 134 | + | lock.Close() | |||
| 135 | + | return nil, invalid("unsafe owner lock") | |||
| 136 | + | } | |||
| 137 | + | if err = syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB); err != nil { | |||
| 138 | + | lock.Close() | |||
| 139 | + | return nil, fmt.Errorf("%w: %v", ErrLocked, err) | |||
| 140 | + | } | |||
| 141 | + | s := &Store{dir: o.Dir, lock: lock, fault: o.Fault} | |||
| 142 | + | ok := false | |||
| 143 | + | defer func() { | |||
| 144 | + | if !ok { | |||
| 145 | + | s.Close() | |||
| 146 | + | } | |||
| 147 | + | }() | |||
| 148 | + | raw, err := readBounded(filepath.Join(o.Dir, "state.json")) | |||
| 149 | + | if err == nil { | |||
| 150 | + | if err = strict(raw, &s.state); err != nil { | |||
| 151 | + | return nil, invalid("state.json: %v", err) | |||
| 152 | + | } | |||
| 153 | + | } else if errors.Is(err, os.ErrNotExist) { | |||
| 154 | + | var r recovery | |||
| 155 | + | raw, err = readBounded(filepath.Join(o.Dir, "recovery.json")) | |||
| 156 | + | if err == nil { | |||
| 157 | + | if err = strict(raw, &r); err != nil { | |||
| 158 | + | return nil, invalid("recovery.json: %v", err) | |||
| 159 | + | } | |||
| 160 | + | if err = r.verify(); err != nil { | |||
| 161 | + | return nil, err | |||
| 162 | + | } | |||
| 163 | + | } else if errors.Is(err, os.ErrNotExist) { | |||
| 164 | + | r, err = migrate(o) | |||
| 165 | + | if err == nil { | |||
| 166 | + | err = s.write("recovery.json", r) | |||
| 167 | + | } | |||
| 168 | + | } else { | |||
| 169 | + | return nil, invalid("recovery.json: %v", err) | |||
| 170 | + | } | |||
| 171 | + | if err == nil { | |||
| 172 | + | err = validate(r.State) | |||
| 173 | + | } | |||
| 174 | + | if err == nil { | |||
| 175 | + | err = s.write("state.json", r.State) | |||
| 176 | + | } | |||
| 177 | + | s.state = r.State | |||
| 178 | + | } else { | |||
| 179 | + | return nil, invalid("state.json: %v", err) | |||
| 180 | + | } | |||
| 181 | + | if err != nil { | |||
| 182 | + | return nil, err | |||
| 183 | + | } | |||
| 184 | + | if err = validate(s.state); err != nil { | |||
| 185 | + | return nil, err | |||
| 186 | + | } | |||
| 187 | + | ok = true | |||
| 188 | + | return s, nil | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | // verify reports whether the recovery record's retained bytes match their | |||
| 192 | + | // manifest entries, including each source's recorded presence: an absent | |||
| 193 | + | // source must retain no bytes, and a supplied source keeps its exact digest. | |||
| 194 | + | func (r recovery) verify() error { | |||
| 195 | + | membership, ok := r.State.Manifest.source(manifestMembership) | |||
| 196 | + | if !ok || digest(r.Hosts) != membership.SHA256 { | |||
| 197 | + | return invalid("recovery digest mismatch") | |||
| 198 | + | } | |||
| 199 | + | observations, ok := r.State.Manifest.source(manifestObservations) | |||
| 200 | + | if !ok || digest(r.Cache) != observations.SHA256 { | |||
| 201 | + | return invalid("recovery digest mismatch") | |||
| 202 | + | } | |||
| 203 | + | for _, source := range []struct { | |||
| 204 | + | label manifestSourceLabel | |||
| 205 | + | present bool | |||
| 206 | + | bytes int | |||
| 207 | + | }{{manifestMembership, membership.Present, len(r.Hosts)}, {manifestObservations, observations.Present, len(r.Cache)}} { | |||
| 208 | + | if !source.present && source.bytes != 0 { | |||
| 209 | + | return invalid("recovery records an absent %s source with retained bytes", source.label) | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | return nil | |||
| 213 | + | } | |||
| 214 | + | func digest(b []byte) string { sum := sha256.Sum256(b); return hex.EncodeToString(sum[:]) } | |||
| 215 | + | func (s *Store) ready() error { | |||
| 216 | + | if s.lock == nil || s.failed { | |||
| 217 | + | return errors.New("brokerstore: closed or outcome unknown; reopen required") | |||
| 218 | + | } | |||
| 219 | + | return nil | |||
| 220 | + | } | |||
| 221 | + | func (s *Store) Close() error { | |||
| 222 | + | s.mu.Lock() | |||
| 223 | + | defer s.mu.Unlock() | |||
| 224 | + | if s.lock == nil { | |||
| 225 | + | return nil | |||
| 226 | + | } | |||
| 227 | + | err := s.lock.Close() | |||
| 228 | + | s.lock = nil | |||
| 229 | + | return err | |||
| 230 | + | } | |||
| 231 | + | func (s *Store) LoadHosts() (ports.BrokerHosts, error) { | |||
| 232 | + | s.mu.Lock() | |||
| 233 | + | defer s.mu.Unlock() | |||
| 234 | + | if err := s.ready(); err != nil { | |||
| 235 | + | return ports.BrokerHosts{}, err | |||
| 236 | + | } | |||
| 237 | + | h := s.state.Hosts | |||
| 238 | + | h.Hosts = append([]ports.BrokerHostRecord(nil), h.Hosts...) | |||
| 239 | + | return h, nil | |||
| 240 | + | } | |||
| 241 | + | func (s *Store) Load() (ports.BrokerSnapshot, error) { | |||
| 242 | + | s.mu.Lock() | |||
| 243 | + | defer s.mu.Unlock() | |||
| 244 | + | if err := s.ready(); err != nil { | |||
| 245 | + | return ports.BrokerSnapshot{}, err | |||
| 246 | + | } | |||
| 247 | + | return s.state.Snapshot.Clone(), nil | |||
| 248 | + | } | |||
| 249 | + | func (s *Store) ReplaceHosts(expected uint64, hosts []ports.BrokerHostRecord) error { | |||
| 250 | + | s.mu.Lock() | |||
| 251 | + | defer s.mu.Unlock() | |||
| 252 | + | if err := s.ready(); err != nil { | |||
| 253 | + | return err | |||
| 254 | + | } | |||
| 255 | + | // Validate caller membership before the CAS: malformed registration, | |||
| 256 | + | // policy, anchoring, bound, or duplicate-endpoint input is a caller error, | |||
| 257 | + | // not corrupt durable state, so it never surfaces as ErrInvalidState. | |||
| 258 | + | if err := ports.ValidateBrokerHostRecords(hosts); err != nil { | |||
| 259 | + | return err | |||
| 260 | + | } | |||
| 261 | + | if expected != s.state.Hosts.Revision || expected == ^uint64(0) { | |||
| 262 | + | return ErrStale | |||
| 263 | + | } | |||
| 264 | + | next := s.state | |||
| 265 | + | next.Hosts = ports.BrokerHosts{Revision: expected + 1, Hosts: append([]ports.BrokerHostRecord(nil), hosts...)} | |||
| 266 | + | // Policy changes require a new registration generation as well; otherwise | |||
| 267 | + | // an already queued observation could be accepted under changed trust. | |||
| 268 | + | for _, h := range hosts { | |||
| 269 | + | for _, old := range s.state.Hosts.Hosts { | |||
| 270 | + | if h.Registration.Endpoint == old.Registration.Endpoint && h.Policy != old.Policy && h.Registration.Equal(old.Registration) { | |||
| 271 | + | return ErrStale | |||
| 272 | + | } | |||
| 273 | + | if h.Registration.Incarnation == old.Registration.Incarnation && h.Registration.Endpoint == old.Registration.Endpoint && h.Registration.Generation < old.Registration.Generation { | |||
| 274 | + | return ErrStale | |||
| 275 | + | } | |||
| 276 | + | } | |||
| 277 | + | } | |||
| 278 | + | next.Snapshot = filter(next.Snapshot, next.Hosts) | |||
| 279 | + | return s.commit(next) | |||
| 280 | + | } | |||
| 281 | + | func (s *Store) Store(snapshot ports.BrokerSnapshot) error { | |||
| 282 | + | s.mu.Lock() | |||
| 283 | + | defer s.mu.Unlock() | |||
| 284 | + | if err := s.ready(); err != nil { | |||
| 285 | + | return err | |||
| 286 | + | } | |||
| 287 | + | // Removed tombstones and in-flight checking/failure detail are process-local | |||
| 288 | + | // fencing and observation state; the durable snapshot never carries them, so | |||
| 289 | + | // sanitize the caller's copy before validating the durable shape. | |||
| 290 | + | snapshot = snapshot.Clone() | |||
| 291 | + | snapshot.Removed = nil | |||
| 292 | + | for i := range snapshot.Hosts { | |||
| 293 | + | snapshot.Hosts[i].Checking = false | |||
| 294 | + | snapshot.Hosts[i].LastFailure.Err = nil | |||
| 295 | + | } | |||
| 296 | + | if err := snapshot.Validate(); err != nil { | |||
| 297 | + | return err | |||
| 298 | + | } | |||
| 299 | + | if s.writeEpoch != 0 && s.writeEpoch != snapshot.Epoch { | |||
| 300 | + | return ErrStale | |||
| 301 | + | } | |||
| 302 | + | old := s.state.Snapshot | |||
| 303 | + | if old.Epoch == snapshot.Epoch && old.Revision >= snapshot.Revision { | |||
| 304 | + | return ErrStale | |||
| 305 | + | } | |||
| 306 | + | // Reject rather than partially adopt a queued publication from retired hosts. | |||
| 307 | + | for _, h := range snapshot.Hosts { | |||
| 308 | + | found := false | |||
| 309 | + | for _, a := range s.state.Hosts.Hosts { | |||
| 310 | + | if h.Registration.Equal(a.Registration) { | |||
| 311 | + | found = true | |||
| 312 | + | } | |||
| 313 | + | } | |||
| 314 | + | if !found { | |||
| 315 | + | return ErrStale | |||
| 316 | + | } | |||
| 317 | + | } | |||
| 318 | + | next := s.state | |||
| 319 | + | next.Snapshot = snapshot.Clone() | |||
| 320 | + | if err := s.commit(next); err != nil { | |||
| 321 | + | return err | |||
| 322 | + | } | |||
| 323 | + | s.writeEpoch = snapshot.Epoch | |||
| 324 | + | return nil | |||
| 325 | + | } | |||
| 326 | + | func filter(snapshot ports.BrokerSnapshot, hosts ports.BrokerHosts) ports.BrokerSnapshot { | |||
| 327 | + | out := snapshot.Clone() | |||
| 328 | + | out.Hosts = nil | |||
| 329 | + | out.Removed = nil | |||
| 330 | + | for _, h := range snapshot.Hosts { | |||
| 331 | + | for _, a := range hosts.Hosts { | |||
| 332 | + | if h.Registration.Equal(a.Registration) { | |||
| 333 | + | out.Hosts = append(out.Hosts, h.Clone()) | |||
| 334 | + | } | |||
| 335 | + | } | |||
| 336 | + | } | |||
| 337 | + | return out | |||
| 338 | + | } | |||
| 339 | + | func validate(st state) error { | |||
| 340 | + | if st.Manifest.Version != 1 { | |||
| 341 | + | return invalid("unsupported manifest") | |||
| 342 | + | } | |||
| 343 | + | labels := make(map[manifestSourceLabel]bool, len(st.Manifest.Sources)) | |||
| 344 | + | for _, source := range st.Manifest.Sources { | |||
| 345 | + | if labels[source.Label] { | |||
| 346 | + | return invalid("duplicate manifest source") | |||
| 347 | + | } | |||
| 348 | + | labels[source.Label] = true | |||
| 349 | + | b, err := hex.DecodeString(source.SHA256) | |||
| 350 | + | if err != nil || len(b) != 32 { | |||
| 351 | + | return invalid("invalid digest") | |||
| 352 | + | } | |||
| 353 | + | } | |||
| 354 | + | for _, label := range []manifestSourceLabel{manifestMembership, manifestObservations} { | |||
| 355 | + | if !labels[label] { | |||
| 356 | + | return invalid("missing manifest source") | |||
| 357 | + | } | |||
| 358 | + | } | |||
| 359 | + | if err := st.Hosts.Validate(); err != nil { | |||
| 360 | + | return invalid("%v", err) | |||
| 361 | + | } | |||
| 362 | + | if st.Snapshot.Epoch == 0 { | |||
| 363 | + | if st.Snapshot.Revision != 0 || len(st.Snapshot.Hosts) != 0 || len(st.Snapshot.Removed) != 0 { | |||
| 364 | + | return invalid("invalid empty snapshot") | |||
| 365 | + | } | |||
| 366 | + | return nil | |||
| 367 | + | } | |||
| 368 | + | if err := st.Snapshot.Validate(); err != nil { | |||
| 369 | + | return invalid("%v", err) | |||
| 370 | + | } | |||
| 371 | + | for _, h := range st.Snapshot.Hosts { | |||
| 372 | + | found := false | |||
| 373 | + | for _, a := range st.Hosts.Hosts { | |||
| 374 | + | if h.Registration.Equal(a.Registration) { | |||
| 375 | + | found = true | |||
| 376 | + | } | |||
| 377 | + | } | |||
| 378 | + | if !found || h.Checking || h.LastFailure.Err != nil { | |||
| 379 | + | return invalid("non-authoritative snapshot") | |||
| 380 | + | } | |||
| 381 | + | // The store enforces the same durable projection rules the registry | |||
| 382 | + | // checks before publishing, so an accepted publication always persists. | |||
| 383 | + | if err := ports.ValidateDurableHostProjection(h); err != nil { | |||
| 384 | + | return invalid("%v", err) | |||
| 385 | + | } | |||
| 386 | + | } | |||
| 387 | + | return nil | |||
| 388 | + | } | |||
| 389 | + | func (s *Store) commit(next state) error { | |||
| 390 | + | if err := validate(next); err != nil { | |||
| 391 | + | return err | |||
| 392 | + | } | |||
| 393 | + | if err := s.write("state.json", next); err != nil { | |||
| 394 | + | s.failed = true | |||
| 395 | + | return err | |||
| 396 | + | } | |||
| 397 | + | s.state = next | |||
| 398 | + | return nil | |||
| 399 | + | } | |||
| 400 | + | func (s *Store) boundary(name string) error { | |||
| 401 | + | if s.fault != nil { | |||
| 402 | + | return s.fault(name) | |||
| 403 | + | } | |||
| 404 | + | return nil | |||
| 405 | + | } | |||
| 406 | + | func (s *Store) write(name string, value any) error { | |||
| 407 | + | data, err := json.Marshal(value) | |||
| 408 | + | if err != nil { | |||
| 409 | + | return err | |||
| 410 | + | } | |||
| 411 | + | if len(data) > MaxFileBytes { | |||
| 412 | + | return errors.New("brokerstore: file too large") | |||
| 413 | + | } | |||
| 414 | + | f, err := os.CreateTemp(s.dir, ".pending-") | |||
| 415 | + | if err != nil { | |||
| 416 | + | return err | |||
| 417 | + | } | |||
| 418 | + | defer os.Remove(f.Name()) | |||
| 419 | + | defer f.Close() | |||
| 420 | + | // The staged state carries rollback inputs and registration identities, so | |||
| 421 | + | // its permissions are set explicitly instead of relying on the umask. | |||
| 422 | + | if err = f.Chmod(0o600); err != nil { | |||
| 423 | + | return err | |||
| 424 | + | } | |||
| 425 | + | if _, err = f.Write(data); err != nil { | |||
| 426 | + | return err | |||
| 427 | + | } | |||
| 428 | + | if err = s.boundary(name + ":write"); err != nil { | |||
| 429 | + | return err | |||
| 430 | + | } | |||
| 431 | + | if err = f.Sync(); err != nil { | |||
| 432 | + | return err | |||
| 433 | + | } | |||
| 434 | + | if err = s.boundary(name + ":sync"); err != nil { | |||
| 435 | + | return err | |||
| 436 | + | } | |||
| 437 | + | if err = f.Close(); err != nil { | |||
| 438 | + | return err | |||
| 439 | + | } | |||
| 440 | + | if err = os.Rename(f.Name(), filepath.Join(s.dir, name)); err != nil { | |||
| 441 | + | return err | |||
| 442 | + | } | |||
| 443 | + | if err = s.boundary(name + ":rename"); err != nil { | |||
| 444 | + | return err | |||
| 445 | + | } | |||
| 446 | + | d, err := os.Open(s.dir) | |||
| 447 | + | if err != nil { | |||
| 448 | + | return err | |||
| 449 | + | } | |||
| 450 | + | defer d.Close() | |||
| 451 | + | if err = d.Sync(); err != nil { | |||
| 452 | + | return err | |||
| 453 | + | } | |||
| 454 | + | return s.boundary(name + ":dirsync") | |||
| 455 | + | } | |||
A internal/adapters/brokerstore/store_test.go +628 -0
| @@ -0,0 +1,628 @@ | |||
| 1 | + | package brokerstore | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "os/exec" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | "sync" | |
| 11 | + | "syscall" | |
| 12 | + | "testing" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/vev/internal/domain" | |
| 15 | + | "github.com/bnema/vev/internal/ports" | |
| 16 | + | "github.com/bnema/vev/internal/protocol" | |
| 17 | + | "github.com/stretchr/testify/require" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func options(t *testing.T) Options { | |
| 21 | + | t.Helper() | |
| 22 | + | return Options{Dir: filepath.Join(t.TempDir(), "broker"), LegacyHosts: "testdata/hosts-v3.json", LegacyCache: "testdata/cache-v4.json", Policies: map[string]ports.BrokerPolicy{"user@arch": {ProtocolVersion: 1, CatalogSchemaVersion: 1, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, Transport: "quic", Trust: "known-hosts", Launch: "explicit", Isolation: "user"}}} | |
| 23 | + | } | |
| 24 | + | func TestPriorFixtures(t *testing.T) { | |
| 25 | + | for h := 1; h <= 3; h++ { | |
| 26 | + | for c := 2; c <= 4; c++ { | |
| 27 | + | t.Run(fmt.Sprintf("hosts%d-cache%d", h, c), func(t *testing.T) { | |
| 28 | + | o := options(t) | |
| 29 | + | o.LegacyHosts = fmt.Sprintf("testdata/hosts-v%d.json", h) | |
| 30 | + | o.LegacyCache = fmt.Sprintf("testdata/cache-v%d.json", c) | |
| 31 | + | beforeH, err := os.ReadFile(o.LegacyHosts) | |
| 32 | + | require.NoError(t, err) | |
| 33 | + | beforeC, err := os.ReadFile(o.LegacyCache) | |
| 34 | + | require.NoError(t, err) | |
| 35 | + | s, err := OpenOffline(o) | |
| 36 | + | require.NoError(t, err) | |
| 37 | + | hosts, err := s.LoadHosts() | |
| 38 | + | require.NoError(t, err) | |
| 39 | + | require.Len(t, hosts.Hosts, 1) | |
| 40 | + | require.True(t, hosts.Hosts[0].Learned) | |
| 41 | + | require.Equal(t, h != 1, hosts.Hosts[0].Pinned) | |
| 42 | + | require.Equal(t, o.Policies["user@arch"], hosts.Hosts[0].Policy) | |
| 43 | + | if h == 3 { | |
| 44 | + | require.Equal(t, domain.RemoteGeneration(7), hosts.Hosts[0].Registration.Generation) | |
| 45 | + | } | |
| 46 | + | snap, err := s.Load() | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | if h == 3 && c == 4 { | |
| 49 | + | require.Len(t, snap.Hosts, 1) | |
| 50 | + | require.Len(t, snap.Hosts[0].Sessions, 1) | |
| 51 | + | require.Equal(t, "t_work", snap.Hosts[0].Sessions[0].Tabs[0].ID) | |
| 52 | + | } else { | |
| 53 | + | require.Empty(t, snap.Hosts) | |
| 54 | + | } | |
| 55 | + | require.NoError(t, s.Close()) | |
| 56 | + | o.LegacyHosts = "missing" | |
| 57 | + | o.LegacyCache = "missing" | |
| 58 | + | o.Policies = nil | |
| 59 | + | s, err = OpenOffline(o) | |
| 60 | + | require.NoError(t, err) | |
| 61 | + | defer s.Close() | |
| 62 | + | again, err := s.LoadHosts() | |
| 63 | + | require.NoError(t, err) | |
| 64 | + | require.Equal(t, hosts, again) | |
| 65 | + | raw, err := readBounded(filepath.Join(o.Dir, "recovery.json")) | |
| 66 | + | require.NoError(t, err) | |
| 67 | + | var r recovery | |
| 68 | + | require.NoError(t, strict(raw, &r)) | |
| 69 | + | require.Equal(t, beforeH, r.Hosts) | |
| 70 | + | require.Equal(t, beforeC, r.Cache) | |
| 71 | + | for _, name := range []string{"state.json", "recovery.json", lockFileName} { | |
| 72 | + | st, err := os.Stat(filepath.Join(o.Dir, name)) | |
| 73 | + | require.NoError(t, err) | |
| 74 | + | require.Equal(t, os.FileMode(0600), st.Mode().Perm()) | |
| 75 | + | } | |
| 76 | + | }) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | func TestMigrationFaultRestart(t *testing.T) { | |
| 81 | + | for _, file := range []string{"recovery.json", "state.json"} { | |
| 82 | + | for _, point := range []string{"write", "sync", "rename", "dirsync"} { | |
| 83 | + | t.Run(file+point, func(t *testing.T) { | |
| 84 | + | o := options(t) | |
| 85 | + | o.LegacyHosts = "testdata/hosts-v2.json" | |
| 86 | + | fault := errors.New("power loss") | |
| 87 | + | o.Fault = func(p string) error { | |
| 88 | + | if p == file+":"+point { | |
| 89 | + | return fault | |
| 90 | + | } | |
| 91 | + | return nil | |
| 92 | + | } | |
| 93 | + | s, err := OpenOffline(o) | |
| 94 | + | require.ErrorIs(t, err, fault) | |
| 95 | + | require.Nil(t, s) | |
| 96 | + | var saved recovery | |
| 97 | + | raw, e := readBounded(filepath.Join(o.Dir, "recovery.json")) | |
| 98 | + | if e == nil { | |
| 99 | + | require.NoError(t, strict(raw, &saved)) | |
| 100 | + | } | |
| 101 | + | o.Fault = nil | |
| 102 | + | s, err = OpenOffline(o) | |
| 103 | + | require.NoError(t, err) | |
| 104 | + | defer s.Close() | |
| 105 | + | hosts, err := s.LoadHosts() | |
| 106 | + | require.NoError(t, err) | |
| 107 | + | if e == nil { | |
| 108 | + | require.Equal(t, saved.State.Hosts, hosts) | |
| 109 | + | } | |
| 110 | + | }) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | func TestLockLifetime(t *testing.T) { | |
| 115 | + | if dir := os.Getenv("VEV_BROKERSTORE_LOCK_TEST"); dir != "" { | |
| 116 | + | s, err := OpenOffline(Options{Dir: dir}) | |
| 117 | + | if s != nil { | |
| 118 | + | s.Close() | |
| 119 | + | } | |
| 120 | + | if !errors.Is(err, ErrLocked) { | |
| 121 | + | os.Exit(2) | |
| 122 | + | } | |
| 123 | + | return | |
| 124 | + | } | |
| 125 | + | o := options(t) | |
| 126 | + | s, err := OpenOffline(o) | |
| 127 | + | require.NoError(t, err) | |
| 128 | + | // The lifetime lock carries the legacy host writer's exact file name, so a | |
| 129 | + | // store opened beside a live hosts.json excludes that writer too. | |
| 130 | + | _, err = os.Stat(filepath.Join(o.Dir, "hosts.json.lock")) | |
| 131 | + | require.NoError(t, err) | |
| 132 | + | cmd := exec.Command(os.Args[0], "-test.run=^TestLockLifetime$") | |
| 133 | + | cmd.Env = append(os.Environ(), "VEV_BROKERSTORE_LOCK_TEST="+o.Dir) | |
| 134 | + | require.NoError(t, cmd.Run()) | |
| 135 | + | // Assertions never run inside the competing goroutines: t.FailNow from a | |
| 136 | + | // non-test goroutine is invalid, so each worker records its outcome and the | |
| 137 | + | // test body checks them after the join. | |
| 138 | + | var wg sync.WaitGroup | |
| 139 | + | outcomes := make([]error, 8) | |
| 140 | + | for i := 0; i < len(outcomes); i++ { | |
| 141 | + | wg.Add(1) | |
| 142 | + | go func(i int) { | |
| 143 | + | defer wg.Done() | |
| 144 | + | other, e := OpenOffline(o) | |
| 145 | + | if other != nil { | |
| 146 | + | _ = other.Close() | |
| 147 | + | outcomes[i] = fmt.Errorf("concurrent OpenOffline returned a store: %w", ports.ErrBrokerStoreLocked) | |
| 148 | + | return | |
| 149 | + | } | |
| 150 | + | if !errors.Is(e, ErrLocked) || !errors.Is(e, ports.ErrBrokerStoreLocked) { | |
| 151 | + | outcomes[i] = fmt.Errorf("concurrent OpenOffline error = %v, want ErrLocked", e) | |
| 152 | + | } | |
| 153 | + | }(i) | |
| 154 | + | } | |
| 155 | + | wg.Wait() | |
| 156 | + | for i, outcome := range outcomes { | |
| 157 | + | require.NoError(t, outcome, "worker %d", i) | |
| 158 | + | } | |
| 159 | + | require.NoError(t, s.Close()) | |
| 160 | + | _, err = s.Load() | |
| 161 | + | require.Error(t, err) | |
| 162 | + | s, err = OpenOffline(o) | |
| 163 | + | require.NoError(t, err) | |
| 164 | + | require.NoError(t, s.Close()) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // TestLockExcludesLegacyHostWriter holds the lock the way the legacy host | |
| 168 | + | // writer does and proves the store refuses to own the same directory. | |
| 169 | + | func TestLockExcludesLegacyHostWriter(t *testing.T) { | |
| 170 | + | o := options(t) | |
| 171 | + | s, err := OpenOffline(o) | |
| 172 | + | require.NoError(t, err) | |
| 173 | + | require.NoError(t, s.Close()) | |
| 174 | + | ||
| 175 | + | legacyLock, err := os.OpenFile(filepath.Join(o.Dir, "hosts.json.lock"), os.O_CREATE|os.O_RDWR, 0600) | |
| 176 | + | require.NoError(t, err) | |
| 177 | + | defer legacyLock.Close() | |
| 178 | + | require.NoError(t, syscall.Flock(int(legacyLock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)) | |
| 179 | + | ||
| 180 | + | _, err = OpenOffline(o) | |
| 181 | + | require.ErrorIs(t, err, ErrLocked) | |
| 182 | + | require.ErrorIs(t, err, ports.ErrBrokerStoreLocked) | |
| 183 | + | ||
| 184 | + | require.NoError(t, syscall.Flock(int(legacyLock.Fd()), syscall.LOCK_UN)) | |
| 185 | + | s, err = OpenOffline(o) | |
| 186 | + | require.NoError(t, err) | |
| 187 | + | require.NoError(t, s.Close()) | |
| 188 | + | } | |
| 189 | + | func TestStrictCorruption(t *testing.T) { | |
| 190 | + | for _, raw := range []string{`{"version":1,"hosts":[]}`, `{"version":3,"hosts":[]}`} { | |
| 191 | + | var v any | |
| 192 | + | require.NoError(t, strict([]byte(raw), &v)) | |
| 193 | + | } | |
| 194 | + | for name, raw := range map[string]string{"duplicate": `{"version":1,"version":1,"hosts":[]}`, "unknown": `{"version":1,"hosts":[],"extra":0}`, "trailing": `{"version":1,"hosts":[]} {}`, "missing": `{"version":2,"pinned":[]}`, "future": `{"version":99,"hosts":[]}`, "utf8": string([]byte{255}), "oversize": strings.Repeat(" ", MaxFileBytes+1), "empty": ""} { | |
| 195 | + | t.Run(name, func(t *testing.T) { | |
| 196 | + | o := options(t) | |
| 197 | + | o.LegacyHosts = filepath.Join(t.TempDir(), "hosts") | |
| 198 | + | require.NoError(t, os.WriteFile(o.LegacyHosts, []byte(raw), 0600)) | |
| 199 | + | s, err := OpenOffline(o) | |
| 200 | + | require.Error(t, err) | |
| 201 | + | require.Nil(t, s) | |
| 202 | + | _, err = os.Stat(filepath.Join(o.Dir, "state.json")) | |
| 203 | + | require.True(t, os.IsNotExist(err)) | |
| 204 | + | }) | |
| 205 | + | } | |
| 206 | + | t.Run("no implicit policy", func(t *testing.T) { o := options(t); o.Policies = nil; _, err := OpenOffline(o); require.Error(t, err) }) | |
| 207 | + | t.Run("state never falls back", func(t *testing.T) { | |
| 208 | + | o := options(t) | |
| 209 | + | s, err := OpenOffline(o) | |
| 210 | + | require.NoError(t, err) | |
| 211 | + | require.NoError(t, s.Close()) | |
| 212 | + | require.NoError(t, os.WriteFile(filepath.Join(o.Dir, "state.json"), []byte(`{`), 0600)) | |
| 213 | + | _, err = OpenOffline(o) | |
| 214 | + | require.Error(t, err) | |
| 215 | + | }) | |
| 216 | + | t.Run("source symlink", func(t *testing.T) { | |
| 217 | + | o := options(t) | |
| 218 | + | p := filepath.Join(t.TempDir(), "link") | |
| 219 | + | require.NoError(t, os.Symlink(o.LegacyHosts, p)) | |
| 220 | + | o.LegacyHosts = p | |
| 221 | + | _, err := OpenOffline(o) | |
| 222 | + | require.Error(t, err) | |
| 223 | + | }) | |
| 224 | + | } | |
| 225 | + | func TestDurableStateSentinelMapping(t *testing.T) { | |
| 226 | + | o := options(t) | |
| 227 | + | s, err := OpenOffline(o) | |
| 228 | + | require.NoError(t, err) | |
| 229 | + | ||
| 230 | + | // A conflicting authority token is a host conflict, not a generic failure. | |
| 231 | + | require.ErrorIs(t, s.ReplaceHosts(0, nil), ErrStale) | |
| 232 | + | require.ErrorIs(t, s.ReplaceHosts(0, nil), ports.ErrBrokerHostConflict) | |
| 233 | + | ||
| 234 | + | // A locked, corrupt, or closed store is classified without importing the | |
| 235 | + | // adapter: callers retry, resolve state offline, or reopen respectively. | |
| 236 | + | require.ErrorIs(t, ErrLocked, ports.ErrBrokerStoreLocked) | |
| 237 | + | require.ErrorIs(t, ErrInvalidState, ports.ErrBrokerStoreInvalidState) | |
| 238 | + | ||
| 239 | + | require.NoError(t, s.Close()) | |
| 240 | + | // Closing releases ownership without discarding the durable files. | |
| 241 | + | require.Error(t, s.ReplaceHosts(1, nil)) | |
| 242 | + | require.NoError(t, os.WriteFile(filepath.Join(o.Dir, "state.json"), []byte(`{`), 0600)) | |
| 243 | + | _, err = OpenOffline(o) | |
| 244 | + | require.ErrorIs(t, err, ErrInvalidState) | |
| 245 | + | require.ErrorIs(t, err, ports.ErrBrokerStoreInvalidState) | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | // TestNonRegularInputsAreRejected covers inputs that are neither regular files | |
| 249 | + | // nor absent: a directory and a FIFO must both fail closed before decoding. | |
| 250 | + | func TestNonRegularInputsAreRejected(t *testing.T) { | |
| 251 | + | for _, name := range []string{"directory", "fifo"} { | |
| 252 | + | t.Run(name, func(t *testing.T) { | |
| 253 | + | o := options(t) | |
| 254 | + | path := filepath.Join(t.TempDir(), name) | |
| 255 | + | if name == "directory" { | |
| 256 | + | require.NoError(t, os.Mkdir(path, 0700)) | |
| 257 | + | } else { | |
| 258 | + | require.NoError(t, syscall.Mkfifo(path, 0600)) | |
| 259 | + | } | |
| 260 | + | o.LegacyHosts = path | |
| 261 | + | s, err := OpenOffline(o) | |
| 262 | + | require.Error(t, err) | |
| 263 | + | require.Nil(t, s) | |
| 264 | + | _, err = os.Stat(filepath.Join(o.Dir, "state.json")) | |
| 265 | + | require.True(t, os.IsNotExist(err)) | |
| 266 | + | }) | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | // TestCommittedStateAndRecoveryAreNeverFollowed replaces the committed state and | |
| 271 | + | // the recovery record with symlinks to valid bytes: the store reads both with | |
| 272 | + | // O_NOFOLLOW, so a symlink fails closed instead of redirecting a rename or | |
| 273 | + | // silently adopting attacker-chosen state. | |
| 274 | + | func TestCommittedStateAndRecoveryAreNeverFollowed(t *testing.T) { | |
| 275 | + | t.Run("state", func(t *testing.T) { | |
| 276 | + | o := options(t) | |
| 277 | + | s, err := OpenOffline(o) | |
| 278 | + | require.NoError(t, err) | |
| 279 | + | require.NoError(t, s.Close()) | |
| 280 | + | ||
| 281 | + | state := filepath.Join(o.Dir, "state.json") | |
| 282 | + | raw, err := os.ReadFile(state) | |
| 283 | + | require.NoError(t, err) | |
| 284 | + | target := filepath.Join(o.Dir, "elsewhere.json") | |
| 285 | + | require.NoError(t, os.WriteFile(target, raw, 0600)) | |
| 286 | + | require.NoError(t, os.Remove(state)) | |
| 287 | + | require.NoError(t, os.Symlink(target, state)) | |
| 288 | + | ||
| 289 | + | _, err = OpenOffline(o) | |
| 290 | + | require.ErrorIs(t, err, ErrInvalidState) | |
| 291 | + | require.ErrorIs(t, err, ports.ErrBrokerStoreInvalidState) | |
| 292 | + | after, err := os.ReadFile(target) | |
| 293 | + | require.NoError(t, err) | |
| 294 | + | require.Equal(t, raw, after) | |
| 295 | + | }) | |
| 296 | + | t.Run("recovery", func(t *testing.T) { | |
| 297 | + | o := options(t) | |
| 298 | + | s, err := OpenOffline(o) | |
| 299 | + | require.NoError(t, err) | |
| 300 | + | require.NoError(t, s.Close()) | |
| 301 | + | ||
| 302 | + | recoveryPath := filepath.Join(o.Dir, "recovery.json") | |
| 303 | + | raw, err := os.ReadFile(recoveryPath) | |
| 304 | + | require.NoError(t, err) | |
| 305 | + | target := filepath.Join(o.Dir, "elsewhere.json") | |
| 306 | + | require.NoError(t, os.WriteFile(target, raw, 0600)) | |
| 307 | + | require.NoError(t, os.Remove(recoveryPath)) | |
| 308 | + | require.NoError(t, os.Remove(filepath.Join(o.Dir, "state.json"))) | |
| 309 | + | require.NoError(t, os.Symlink(target, recoveryPath)) | |
| 310 | + | ||
| 311 | + | _, err = OpenOffline(o) | |
| 312 | + | require.ErrorIs(t, err, ErrInvalidState) | |
| 313 | + | require.ErrorIs(t, err, ports.ErrBrokerStoreInvalidState) | |
| 314 | + | _, err = os.Stat(filepath.Join(o.Dir, "state.json")) | |
| 315 | + | require.True(t, os.IsNotExist(err)) | |
| 316 | + | }) | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | // TestManifestDistinguishesAbsentFromEmptySources pins the recovery manifest: an | |
| 320 | + | // absent source and a supplied source that carries no records can produce the | |
| 321 | + | // same membership, so presence, keyed by generic role label, is what makes the | |
| 322 | + | // recovery record an exact description of the migration input. | |
| 323 | + | func TestManifestDistinguishesAbsentFromEmptySources(t *testing.T) { | |
| 324 | + | readManifest := func(t *testing.T, dir string) (manifest, string) { | |
| 325 | + | t.Helper() | |
| 326 | + | raw, err := readBounded(filepath.Join(dir, "recovery.json")) | |
| 327 | + | require.NoError(t, err) | |
| 328 | + | var r recovery | |
| 329 | + | require.NoError(t, strict(raw, &r)) | |
| 330 | + | return r.State.Manifest, digest(nil) | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | absent := options(t) | |
| 334 | + | absent.LegacyHosts = "" | |
| 335 | + | absent.LegacyCache = "" | |
| 336 | + | absent.Policies = nil | |
| 337 | + | absentStore, err := OpenOffline(absent) | |
| 338 | + | require.NoError(t, err) | |
| 339 | + | defer absentStore.Close() | |
| 340 | + | hosts, err := absentStore.LoadHosts() | |
| 341 | + | require.NoError(t, err) | |
| 342 | + | require.Empty(t, hosts.Hosts) | |
| 343 | + | absentManifest, emptyDigest := readManifest(t, absent.Dir) | |
| 344 | + | for _, label := range []manifestSourceLabel{manifestMembership, manifestObservations} { | |
| 345 | + | source, ok := absentManifest.source(label) | |
| 346 | + | require.True(t, ok, label) | |
| 347 | + | require.False(t, source.Present, label) | |
| 348 | + | require.Equal(t, emptyDigest, source.SHA256, label) | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | empty := options(t) | |
| 352 | + | empty.LegacyCache = "" | |
| 353 | + | empty.LegacyHosts = filepath.Join(t.TempDir(), "hosts") | |
| 354 | + | require.NoError(t, os.WriteFile(empty.LegacyHosts, []byte(`{"version":1,"hosts":[]}`), 0600)) | |
| 355 | + | emptyStore, err := OpenOffline(empty) | |
| 356 | + | require.NoError(t, err) | |
| 357 | + | defer emptyStore.Close() | |
| 358 | + | stillEmpty, err := emptyStore.LoadHosts() | |
| 359 | + | require.NoError(t, err) | |
| 360 | + | // The same empty membership results, but the manifest records that the | |
| 361 | + | // membership source was supplied and what it contained. | |
| 362 | + | require.Equal(t, hosts, stillEmpty) | |
| 363 | + | emptyManifest, _ := readManifest(t, empty.Dir) | |
| 364 | + | membership, ok := emptyManifest.source(manifestMembership) | |
| 365 | + | require.True(t, ok) | |
| 366 | + | require.True(t, membership.Present) | |
| 367 | + | require.NotEqual(t, emptyDigest, membership.SHA256) | |
| 368 | + | observations, ok := emptyManifest.source(manifestObservations) | |
| 369 | + | require.True(t, ok) | |
| 370 | + | require.False(t, observations.Present) | |
| 371 | + | ||
| 372 | + | // A record that claims the source was absent while retaining its bytes is | |
| 373 | + | // rejected: presence is verified, not merely recorded. | |
| 374 | + | raw, err := readBounded(filepath.Join(empty.Dir, "recovery.json")) | |
| 375 | + | require.NoError(t, err) | |
| 376 | + | var r recovery | |
| 377 | + | require.NoError(t, strict(raw, &r)) | |
| 378 | + | for i := range r.State.Manifest.Sources { | |
| 379 | + | if r.State.Manifest.Sources[i].Label == manifestMembership { | |
| 380 | + | r.State.Manifest.Sources[i].Present = false | |
| 381 | + | } | |
| 382 | + | } | |
| 383 | + | writer := Store{dir: empty.Dir} | |
| 384 | + | require.NoError(t, writer.write("recovery.json", r)) | |
| 385 | + | require.NoError(t, os.Remove(filepath.Join(empty.Dir, "state.json"))) | |
| 386 | + | require.NoError(t, emptyStore.Close()) | |
| 387 | + | _, err = OpenOffline(Options{Dir: empty.Dir}) | |
| 388 | + | require.ErrorIs(t, err, ErrInvalidState) | |
| 389 | + | require.ErrorContains(t, err, "absent membership source") | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | // TestRevisionOverflowIsRefused keeps the store from ever minting revision zero | |
| 393 | + | // or accepting an exhausted authority token. | |
| 394 | + | func TestRevisionOverflowIsRefused(t *testing.T) { | |
| 395 | + | o := options(t) | |
| 396 | + | s, err := OpenOffline(o) | |
| 397 | + | require.NoError(t, err) | |
| 398 | + | defer s.Close() | |
| 399 | + | h, err := s.LoadHosts() | |
| 400 | + | require.NoError(t, err) | |
| 401 | + | for _, hosts := range [][]ports.BrokerHostRecord{h.Hosts, nil} { | |
| 402 | + | require.ErrorIs(t, s.ReplaceHosts(^uint64(0), hosts), ErrStale) | |
| 403 | + | } | |
| 404 | + | after, err := s.LoadHosts() | |
| 405 | + | require.NoError(t, err) | |
| 406 | + | require.Equal(t, h, after) | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | // TestAuthorityGenerationPolicyAndRemoveReAdd exercises the durable authority | |
| 410 | + | // rules: policy changes need a fresh generation, generations never regress, a | |
| 411 | + | // removed incarnation's observation never returns, and a re-add carries a new | |
| 412 | + | // incarnation. | |
| 413 | + | func TestAuthorityGenerationPolicyAndRemoveReAdd(t *testing.T) { | |
| 414 | + | o := options(t) | |
| 415 | + | s, err := OpenOffline(o) | |
| 416 | + | require.NoError(t, err) | |
| 417 | + | defer s.Close() | |
| 418 | + | h, err := s.LoadHosts() | |
| 419 | + | require.NoError(t, err) | |
| 420 | + | require.Len(t, h.Hosts, 1) | |
| 421 | + | base := h.Hosts[0] | |
| 422 | + | ||
| 423 | + | // A policy change with an unchanged registration identity is refused: an | |
| 424 | + | // already queued observation must never be accepted under changed trust. | |
| 425 | + | changed := append([]ports.BrokerHostRecord(nil), h.Hosts...) | |
| 426 | + | changed[0].Policy.Trust = "changed" | |
| 427 | + | require.ErrorIs(t, s.ReplaceHosts(h.Revision, changed), ErrStale) | |
| 428 | + | ||
| 429 | + | // Re-supplying identical membership is allowed and still advances the | |
| 430 | + | // authority revision. | |
| 431 | + | require.NoError(t, s.ReplaceHosts(h.Revision, h.Hosts)) | |
| 432 | + | h2, err := s.LoadHosts() | |
| 433 | + | require.NoError(t, err) | |
| 434 | + | require.Equal(t, h.Revision+1, h2.Revision) | |
| 435 | + | ||
| 436 | + | // The same policy change with a bumped generation is accepted and durable. | |
| 437 | + | changed[0].Registration.Generation++ | |
| 438 | + | require.NoError(t, s.ReplaceHosts(h2.Revision, changed)) | |
| 439 | + | h3, err := s.LoadHosts() | |
| 440 | + | require.NoError(t, err) | |
| 441 | + | require.Equal(t, "changed", h3.Hosts[0].Policy.Trust) | |
| 442 | + | ||
| 443 | + | // A regressed generation on the same incarnation is refused. | |
| 444 | + | regressed := append([]ports.BrokerHostRecord(nil), h3.Hosts...) | |
| 445 | + | regressed[0].Registration.Generation = base.Registration.Generation | |
| 446 | + | require.ErrorIs(t, s.ReplaceHosts(h3.Revision, regressed), ErrStale) | |
| 447 | + | ||
| 448 | + | // Full removal drops the observation with the registration. | |
| 449 | + | require.NoError(t, s.ReplaceHosts(h3.Revision, nil)) | |
| 450 | + | removed, err := s.LoadHosts() | |
| 451 | + | require.NoError(t, err) | |
| 452 | + | require.Empty(t, removed.Hosts) | |
| 453 | + | ||
| 454 | + | // A queued publication from the retired incarnation is refused even though | |
| 455 | + | // its revision is newer. | |
| 456 | + | require.ErrorIs(t, s.Store(ports.BrokerSnapshot{Epoch: 9, Revision: 1, Hosts: []ports.RemoteHostSnapshot{{Endpoint: base.Registration.Endpoint, Registration: base.Registration, Availability: domain.RemoteAvailabilityReachable}}}), ErrStale) | |
| 457 | + | ||
| 458 | + | // Re-adding the endpoint needs a fresh incarnation; the retired | |
| 459 | + | // observation never comes back for it. | |
| 460 | + | var incarnation [16]byte | |
| 461 | + | incarnation[0] = 9 | |
| 462 | + | registration, err := domain.NewRemoteRegistration(base.Registration.Endpoint, incarnation) | |
| 463 | + | require.NoError(t, err) | |
| 464 | + | reAdd := append([]ports.BrokerHostRecord(nil), base) | |
| 465 | + | reAdd[0].Registration = registration | |
| 466 | + | require.NoError(t, s.ReplaceHosts(removed.Revision, reAdd)) | |
| 467 | + | readded, err := s.LoadHosts() | |
| 468 | + | require.NoError(t, err) | |
| 469 | + | require.True(t, readded.Hosts[0].Registration.Equal(registration)) | |
| 470 | + | snapshot, err := s.Load() | |
| 471 | + | require.NoError(t, err) | |
| 472 | + | _, ok := snapshot.Find(base.Registration.Endpoint) | |
| 473 | + | require.False(t, ok, "a removed incarnation's observation must not survive a re-add") | |
| 474 | + | } | |
| 475 | + | ||
| 476 | + | // TestReplaceHostsRejectsCallerInputBeforeCAS proves malformed caller | |
| 477 | + | // membership is rejected as a caller error before the compare-and-swap, never | |
| 478 | + | // as ErrBrokerStoreInvalidState: the durable state is a victim, not the cause. | |
| 479 | + | func TestReplaceHostsRejectsCallerInputBeforeCAS(t *testing.T) { | |
| 480 | + | o := options(t) | |
| 481 | + | s, err := OpenOffline(o) | |
| 482 | + | require.NoError(t, err) | |
| 483 | + | defer s.Close() | |
| 484 | + | h, err := s.LoadHosts() | |
| 485 | + | require.NoError(t, err) | |
| 486 | + | require.Len(t, h.Hosts, 1) | |
| 487 | + | base := h.Hosts[0] | |
| 488 | + | ||
| 489 | + | invalid := map[string][]ports.BrokerHostRecord{ | |
| 490 | + | "zero registration": {{Pinned: true, Policy: base.Policy}}, | |
| 491 | + | "zero policy": {{Registration: base.Registration, Pinned: true}}, | |
| 492 | + | "unanchored": {{Registration: base.Registration, Policy: base.Policy}}, | |
| 493 | + | "duplicate endpoint": { | |
| 494 | + | {Registration: base.Registration, Pinned: true, Policy: base.Policy}, | |
| 495 | + | {Registration: base.Registration, Learned: true, Policy: base.Policy}, | |
| 496 | + | }, | |
| 497 | + | } | |
| 498 | + | for name, records := range invalid { | |
| 499 | + | t.Run(name, func(t *testing.T) { | |
| 500 | + | // The CAS token matches, so a CAS-first store would have accepted the | |
| 501 | + | // malformed set and failed later as invalid durable state. | |
| 502 | + | err := s.ReplaceHosts(h.Revision, records) | |
| 503 | + | require.Error(t, err) | |
| 504 | + | require.NotErrorIs(t, err, ErrStale) | |
| 505 | + | require.NotErrorIs(t, err, ErrInvalidState) | |
| 506 | + | require.NotErrorIs(t, err, ports.ErrBrokerStoreInvalidState) | |
| 507 | + | }) | |
| 508 | + | } | |
| 509 | + | t.Run("over bound", func(t *testing.T) { | |
| 510 | + | records := make([]ports.BrokerHostRecord, 0, ports.BrokerMaxHosts+1) | |
| 511 | + | for index := 0; index <= ports.BrokerMaxHosts; index++ { | |
| 512 | + | registration, err := domain.NewRemoteRegistration(fmt.Sprintf("bound-%d.test", index), [16]byte{byte(index + 1)}) | |
| 513 | + | require.NoError(t, err) | |
| 514 | + | records = append(records, ports.BrokerHostRecord{Registration: registration, Pinned: true, Policy: base.Policy}) | |
| 515 | + | } | |
| 516 | + | err := s.ReplaceHosts(h.Revision, records) | |
| 517 | + | require.Error(t, err) | |
| 518 | + | require.NotErrorIs(t, err, ports.ErrBrokerStoreInvalidState) | |
| 519 | + | }) | |
| 520 | + | ||
| 521 | + | // Rejected input never commits: the authority revision and membership are | |
| 522 | + | // exactly as before. | |
| 523 | + | after, err := s.LoadHosts() | |
| 524 | + | require.NoError(t, err) | |
| 525 | + | require.Equal(t, h, after) | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | // TestDurableSnapshotDropsProcessLocalTombstones proves a snapshot's | |
| 529 | + | // process-local tombstone set is sanitized before persistence: storing a | |
| 530 | + | // snapshot whose tombstone names a live host is accepted and reopened without | |
| 531 | + | // Removed, instead of being rejected as durable-state corruption. | |
| 532 | + | func TestDurableSnapshotDropsProcessLocalTombstones(t *testing.T) { | |
| 533 | + | o := options(t) | |
| 534 | + | s, err := OpenOffline(o) | |
| 535 | + | require.NoError(t, err) | |
| 536 | + | h, err := s.LoadHosts() | |
| 537 | + | require.NoError(t, err) | |
| 538 | + | require.Len(t, h.Hosts, 1) | |
| 539 | + | reg := h.Hosts[0].Registration | |
| 540 | + | ||
| 541 | + | snap, err := s.Load() | |
| 542 | + | require.NoError(t, err) | |
| 543 | + | snap.Epoch = 5 | |
| 544 | + | snap.Revision = 7 | |
| 545 | + | snap.Removed = []ports.BrokerHostTombstone{{Endpoint: reg.Endpoint, Registration: reg, RetiredRevision: 6}} | |
| 546 | + | require.NoError(t, s.Store(snap)) | |
| 547 | + | ||
| 548 | + | stored, err := s.Load() | |
| 549 | + | require.NoError(t, err) | |
| 550 | + | require.Empty(t, stored.Removed) | |
| 551 | + | require.NoError(t, s.Close()) | |
| 552 | + | ||
| 553 | + | s, err = OpenOffline(o) | |
| 554 | + | require.NoError(t, err) | |
| 555 | + | defer s.Close() | |
| 556 | + | reopened, err := s.Load() | |
| 557 | + | require.NoError(t, err) | |
| 558 | + | require.Empty(t, reopened.Removed) | |
| 559 | + | } | |
| 560 | + | ||
| 561 | + | func TestFencingAndCommitFaults(t *testing.T) { | |
| 562 | + | for _, point := range []string{"write", "sync", "rename", "dirsync"} { | |
| 563 | + | t.Run(point, func(t *testing.T) { | |
| 564 | + | o := options(t) | |
| 565 | + | s, err := OpenOffline(o) | |
| 566 | + | require.NoError(t, err) | |
| 567 | + | h, err := s.LoadHosts() | |
| 568 | + | require.NoError(t, err) | |
| 569 | + | snap, err := s.Load() | |
| 570 | + | require.NoError(t, err) | |
| 571 | + | snap.Epoch = 20 | |
| 572 | + | snap.Revision = 1 | |
| 573 | + | require.NoError(t, s.Store(snap)) | |
| 574 | + | require.ErrorIs(t, s.Store(snap), ErrStale) | |
| 575 | + | other := snap | |
| 576 | + | other.Epoch = 21 | |
| 577 | + | require.ErrorIs(t, s.Store(other), ErrStale) | |
| 578 | + | require.ErrorIs(t, s.ReplaceHosts(0, nil), ErrStale) | |
| 579 | + | changed := append([]ports.BrokerHostRecord(nil), h.Hosts...) | |
| 580 | + | changed[0].Policy.Trust = "new" | |
| 581 | + | require.ErrorIs(t, s.ReplaceHosts(h.Revision, changed), ErrStale) | |
| 582 | + | s.fault = func(p string) error { | |
| 583 | + | if p == "state.json:"+point { | |
| 584 | + | return errors.New("disk fault") | |
| 585 | + | } | |
| 586 | + | return nil | |
| 587 | + | } | |
| 588 | + | require.Error(t, s.ReplaceHosts(h.Revision, nil)) | |
| 589 | + | _, err = s.LoadHosts() | |
| 590 | + | require.Error(t, err) | |
| 591 | + | require.NoError(t, s.Close()) | |
| 592 | + | s, err = OpenOffline(o) | |
| 593 | + | require.NoError(t, err) | |
| 594 | + | defer s.Close() | |
| 595 | + | h, err = s.LoadHosts() | |
| 596 | + | require.NoError(t, err) | |
| 597 | + | if point == "rename" || point == "dirsync" { | |
| 598 | + | require.Empty(t, h.Hosts) | |
| 599 | + | snap.Revision++ | |
| 600 | + | require.ErrorIs(t, s.Store(snap), ErrStale) | |
| 601 | + | } else { | |
| 602 | + | require.Len(t, h.Hosts, 1) | |
| 603 | + | } | |
| 604 | + | }) | |
| 605 | + | } | |
| 606 | + | t.Run("generation and restart", func(t *testing.T) { | |
| 607 | + | o := options(t) | |
| 608 | + | s, err := OpenOffline(o) | |
| 609 | + | require.NoError(t, err) | |
| 610 | + | h, err := s.LoadHosts() | |
| 611 | + | require.NoError(t, err) | |
| 612 | + | snap, err := s.Load() | |
| 613 | + | require.NoError(t, err) | |
| 614 | + | h.Hosts[0].Registration.Generation++ | |
| 615 | + | require.NoError(t, s.ReplaceHosts(h.Revision, h.Hosts)) | |
| 616 | + | require.ErrorIs(t, s.Store(snap), ErrStale) | |
| 617 | + | require.NoError(t, s.Close()) | |
| 618 | + | s, err = OpenOffline(o) | |
| 619 | + | require.NoError(t, err) | |
| 620 | + | defer s.Close() | |
| 621 | + | got, err := s.Load() | |
| 622 | + | require.NoError(t, err) | |
| 623 | + | require.Empty(t, got.Hosts) | |
| 624 | + | gotH, err := s.LoadHosts() | |
| 625 | + | require.NoError(t, err) | |
| 626 | + | require.Equal(t, h.Hosts, gotH.Hosts) | |
| 627 | + | }) | |
| 628 | + | } | |
| @@ -0,0 +1,628 @@ | |||||
| 1 | + | package brokerstore | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "os/exec" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "strings" | |||
| 10 | + | "sync" | |||
| 11 | + | "syscall" | |||
| 12 | + | "testing" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/vev/internal/domain" | |||
| 15 | + | "github.com/bnema/vev/internal/ports" | |||
| 16 | + | "github.com/bnema/vev/internal/protocol" | |||
| 17 | + | "github.com/stretchr/testify/require" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | func options(t *testing.T) Options { | |||
| 21 | + | t.Helper() | |||
| 22 | + | return Options{Dir: filepath.Join(t.TempDir(), "broker"), LegacyHosts: "testdata/hosts-v3.json", LegacyCache: "testdata/cache-v4.json", Policies: map[string]ports.BrokerPolicy{"user@arch": {ProtocolVersion: 1, CatalogSchemaVersion: 1, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, Transport: "quic", Trust: "known-hosts", Launch: "explicit", Isolation: "user"}}} | |||
| 23 | + | } | |||
| 24 | + | func TestPriorFixtures(t *testing.T) { | |||
| 25 | + | for h := 1; h <= 3; h++ { | |||
| 26 | + | for c := 2; c <= 4; c++ { | |||
| 27 | + | t.Run(fmt.Sprintf("hosts%d-cache%d", h, c), func(t *testing.T) { | |||
| 28 | + | o := options(t) | |||
| 29 | + | o.LegacyHosts = fmt.Sprintf("testdata/hosts-v%d.json", h) | |||
| 30 | + | o.LegacyCache = fmt.Sprintf("testdata/cache-v%d.json", c) | |||
| 31 | + | beforeH, err := os.ReadFile(o.LegacyHosts) | |||
| 32 | + | require.NoError(t, err) | |||
| 33 | + | beforeC, err := os.ReadFile(o.LegacyCache) | |||
| 34 | + | require.NoError(t, err) | |||
| 35 | + | s, err := OpenOffline(o) | |||
| 36 | + | require.NoError(t, err) | |||
| 37 | + | hosts, err := s.LoadHosts() | |||
| 38 | + | require.NoError(t, err) | |||
| 39 | + | require.Len(t, hosts.Hosts, 1) | |||
| 40 | + | require.True(t, hosts.Hosts[0].Learned) | |||
| 41 | + | require.Equal(t, h != 1, hosts.Hosts[0].Pinned) | |||
| 42 | + | require.Equal(t, o.Policies["user@arch"], hosts.Hosts[0].Policy) | |||
| 43 | + | if h == 3 { | |||
| 44 | + | require.Equal(t, domain.RemoteGeneration(7), hosts.Hosts[0].Registration.Generation) | |||
| 45 | + | } | |||
| 46 | + | snap, err := s.Load() | |||
| 47 | + | require.NoError(t, err) | |||
| 48 | + | if h == 3 && c == 4 { | |||
| 49 | + | require.Len(t, snap.Hosts, 1) | |||
| 50 | + | require.Len(t, snap.Hosts[0].Sessions, 1) | |||
| 51 | + | require.Equal(t, "t_work", snap.Hosts[0].Sessions[0].Tabs[0].ID) | |||
| 52 | + | } else { | |||
| 53 | + | require.Empty(t, snap.Hosts) | |||
| 54 | + | } | |||
| 55 | + | require.NoError(t, s.Close()) | |||
| 56 | + | o.LegacyHosts = "missing" | |||
| 57 | + | o.LegacyCache = "missing" | |||
| 58 | + | o.Policies = nil | |||
| 59 | + | s, err = OpenOffline(o) | |||
| 60 | + | require.NoError(t, err) | |||
| 61 | + | defer s.Close() | |||
| 62 | + | again, err := s.LoadHosts() | |||
| 63 | + | require.NoError(t, err) | |||
| 64 | + | require.Equal(t, hosts, again) | |||
| 65 | + | raw, err := readBounded(filepath.Join(o.Dir, "recovery.json")) | |||
| 66 | + | require.NoError(t, err) | |||
| 67 | + | var r recovery | |||
| 68 | + | require.NoError(t, strict(raw, &r)) | |||
| 69 | + | require.Equal(t, beforeH, r.Hosts) | |||
| 70 | + | require.Equal(t, beforeC, r.Cache) | |||
| 71 | + | for _, name := range []string{"state.json", "recovery.json", lockFileName} { | |||
| 72 | + | st, err := os.Stat(filepath.Join(o.Dir, name)) | |||
| 73 | + | require.NoError(t, err) | |||
| 74 | + | require.Equal(t, os.FileMode(0600), st.Mode().Perm()) | |||
| 75 | + | } | |||
| 76 | + | }) | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | func TestMigrationFaultRestart(t *testing.T) { | |||
| 81 | + | for _, file := range []string{"recovery.json", "state.json"} { | |||
| 82 | + | for _, point := range []string{"write", "sync", "rename", "dirsync"} { | |||
| 83 | + | t.Run(file+point, func(t *testing.T) { | |||
| 84 | + | o := options(t) | |||
| 85 | + | o.LegacyHosts = "testdata/hosts-v2.json" | |||
| 86 | + | fault := errors.New("power loss") | |||
| 87 | + | o.Fault = func(p string) error { | |||
| 88 | + | if p == file+":"+point { | |||
| 89 | + | return fault | |||
| 90 | + | } | |||
| 91 | + | return nil | |||
| 92 | + | } | |||
| 93 | + | s, err := OpenOffline(o) | |||
| 94 | + | require.ErrorIs(t, err, fault) | |||
| 95 | + | require.Nil(t, s) | |||
| 96 | + | var saved recovery | |||
| 97 | + | raw, e := readBounded(filepath.Join(o.Dir, "recovery.json")) | |||
| 98 | + | if e == nil { | |||
| 99 | + | require.NoError(t, strict(raw, &saved)) | |||
| 100 | + | } | |||
| 101 | + | o.Fault = nil | |||
| 102 | + | s, err = OpenOffline(o) | |||
| 103 | + | require.NoError(t, err) | |||
| 104 | + | defer s.Close() | |||
| 105 | + | hosts, err := s.LoadHosts() | |||
| 106 | + | require.NoError(t, err) | |||
| 107 | + | if e == nil { | |||
| 108 | + | require.Equal(t, saved.State.Hosts, hosts) | |||
| 109 | + | } | |||
| 110 | + | }) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | func TestLockLifetime(t *testing.T) { | |||
| 115 | + | if dir := os.Getenv("VEV_BROKERSTORE_LOCK_TEST"); dir != "" { | |||
| 116 | + | s, err := OpenOffline(Options{Dir: dir}) | |||
| 117 | + | if s != nil { | |||
| 118 | + | s.Close() | |||
| 119 | + | } | |||
| 120 | + | if !errors.Is(err, ErrLocked) { | |||
| 121 | + | os.Exit(2) | |||
| 122 | + | } | |||
| 123 | + | return | |||
| 124 | + | } | |||
| 125 | + | o := options(t) | |||
| 126 | + | s, err := OpenOffline(o) | |||
| 127 | + | require.NoError(t, err) | |||
| 128 | + | // The lifetime lock carries the legacy host writer's exact file name, so a | |||
| 129 | + | // store opened beside a live hosts.json excludes that writer too. | |||
| 130 | + | _, err = os.Stat(filepath.Join(o.Dir, "hosts.json.lock")) | |||
| 131 | + | require.NoError(t, err) | |||
| 132 | + | cmd := exec.Command(os.Args[0], "-test.run=^TestLockLifetime$") | |||
| 133 | + | cmd.Env = append(os.Environ(), "VEV_BROKERSTORE_LOCK_TEST="+o.Dir) | |||
| 134 | + | require.NoError(t, cmd.Run()) | |||
| 135 | + | // Assertions never run inside the competing goroutines: t.FailNow from a | |||
| 136 | + | // non-test goroutine is invalid, so each worker records its outcome and the | |||
| 137 | + | // test body checks them after the join. | |||
| 138 | + | var wg sync.WaitGroup | |||
| 139 | + | outcomes := make([]error, 8) | |||
| 140 | + | for i := 0; i < len(outcomes); i++ { | |||
| 141 | + | wg.Add(1) | |||
| 142 | + | go func(i int) { | |||
| 143 | + | defer wg.Done() | |||
| 144 | + | other, e := OpenOffline(o) | |||
| 145 | + | if other != nil { | |||
| 146 | + | _ = other.Close() | |||
| 147 | + | outcomes[i] = fmt.Errorf("concurrent OpenOffline returned a store: %w", ports.ErrBrokerStoreLocked) | |||
| 148 | + | return | |||
| 149 | + | } | |||
| 150 | + | if !errors.Is(e, ErrLocked) || !errors.Is(e, ports.ErrBrokerStoreLocked) { | |||
| 151 | + | outcomes[i] = fmt.Errorf("concurrent OpenOffline error = %v, want ErrLocked", e) | |||
| 152 | + | } | |||
| 153 | + | }(i) | |||
| 154 | + | } | |||
| 155 | + | wg.Wait() | |||
| 156 | + | for i, outcome := range outcomes { | |||
| 157 | + | require.NoError(t, outcome, "worker %d", i) | |||
| 158 | + | } | |||
| 159 | + | require.NoError(t, s.Close()) | |||
| 160 | + | _, err = s.Load() | |||
| 161 | + | require.Error(t, err) | |||
| 162 | + | s, err = OpenOffline(o) | |||
| 163 | + | require.NoError(t, err) | |||
| 164 | + | require.NoError(t, s.Close()) | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | // TestLockExcludesLegacyHostWriter holds the lock the way the legacy host | |||
| 168 | + | // writer does and proves the store refuses to own the same directory. | |||
| 169 | + | func TestLockExcludesLegacyHostWriter(t *testing.T) { | |||
| 170 | + | o := options(t) | |||
| 171 | + | s, err := OpenOffline(o) | |||
| 172 | + | require.NoError(t, err) | |||
| 173 | + | require.NoError(t, s.Close()) | |||
| 174 | + | ||||
| 175 | + | legacyLock, err := os.OpenFile(filepath.Join(o.Dir, "hosts.json.lock"), os.O_CREATE|os.O_RDWR, 0600) | |||
| 176 | + | require.NoError(t, err) | |||
| 177 | + | defer legacyLock.Close() | |||
| 178 | + | require.NoError(t, syscall.Flock(int(legacyLock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)) | |||
| 179 | + | ||||
| 180 | + | _, err = OpenOffline(o) | |||
| 181 | + | require.ErrorIs(t, err, ErrLocked) | |||
| 182 | + | require.ErrorIs(t, err, ports.ErrBrokerStoreLocked) | |||
| 183 | + | ||||
| 184 | + | require.NoError(t, syscall.Flock(int(legacyLock.Fd()), syscall.LOCK_UN)) | |||
| 185 | + | s, err = OpenOffline(o) | |||
| 186 | + | require.NoError(t, err) | |||
| 187 | + | require.NoError(t, s.Close()) | |||
| 188 | + | } | |||
| 189 | + | func TestStrictCorruption(t *testing.T) { | |||
| 190 | + | for _, raw := range []string{`{"version":1,"hosts":[]}`, `{"version":3,"hosts":[]}`} { | |||
| 191 | + | var v any | |||
| 192 | + | require.NoError(t, strict([]byte(raw), &v)) | |||
| 193 | + | } | |||
| 194 | + | for name, raw := range map[string]string{"duplicate": `{"version":1,"version":1,"hosts":[]}`, "unknown": `{"version":1,"hosts":[],"extra":0}`, "trailing": `{"version":1,"hosts":[]} {}`, "missing": `{"version":2,"pinned":[]}`, "future": `{"version":99,"hosts":[]}`, "utf8": string([]byte{255}), "oversize": strings.Repeat(" ", MaxFileBytes+1), "empty": ""} { | |||
| 195 | + | t.Run(name, func(t *testing.T) { | |||
| 196 | + | o := options(t) | |||
| 197 | + | o.LegacyHosts = filepath.Join(t.TempDir(), "hosts") | |||
| 198 | + | require.NoError(t, os.WriteFile(o.LegacyHosts, []byte(raw), 0600)) | |||
| 199 | + | s, err := OpenOffline(o) | |||
| 200 | + | require.Error(t, err) | |||
| 201 | + | require.Nil(t, s) | |||
| 202 | + | _, err = os.Stat(filepath.Join(o.Dir, "state.json")) | |||
| 203 | + | require.True(t, os.IsNotExist(err)) | |||
| 204 | + | }) | |||
| 205 | + | } | |||
| 206 | + | t.Run("no implicit policy", func(t *testing.T) { o := options(t); o.Policies = nil; _, err := OpenOffline(o); require.Error(t, err) }) | |||
| 207 | + | t.Run("state never falls back", func(t *testing.T) { | |||
| 208 | + | o := options(t) | |||
| 209 | + | s, err := OpenOffline(o) | |||
| 210 | + | require.NoError(t, err) | |||
| 211 | + | require.NoError(t, s.Close()) | |||
| 212 | + | require.NoError(t, os.WriteFile(filepath.Join(o.Dir, "state.json"), []byte(`{`), 0600)) | |||
| 213 | + | _, err = OpenOffline(o) | |||
| 214 | + | require.Error(t, err) | |||
| 215 | + | }) | |||
| 216 | + | t.Run("source symlink", func(t *testing.T) { | |||
| 217 | + | o := options(t) | |||
| 218 | + | p := filepath.Join(t.TempDir(), "link") | |||
| 219 | + | require.NoError(t, os.Symlink(o.LegacyHosts, p)) | |||
| 220 | + | o.LegacyHosts = p | |||
| 221 | + | _, err := OpenOffline(o) | |||
| 222 | + | require.Error(t, err) | |||
| 223 | + | }) | |||
| 224 | + | } | |||
| 225 | + | func TestDurableStateSentinelMapping(t *testing.T) { | |||
| 226 | + | o := options(t) | |||
| 227 | + | s, err := OpenOffline(o) | |||
| 228 | + | require.NoError(t, err) | |||
| 229 | + | ||||
| 230 | + | // A conflicting authority token is a host conflict, not a generic failure. | |||
| 231 | + | require.ErrorIs(t, s.ReplaceHosts(0, nil), ErrStale) | |||
| 232 | + | require.ErrorIs(t, s.ReplaceHosts(0, nil), ports.ErrBrokerHostConflict) | |||
| 233 | + | ||||
| 234 | + | // A locked, corrupt, or closed store is classified without importing the | |||
| 235 | + | // adapter: callers retry, resolve state offline, or reopen respectively. | |||
| 236 | + | require.ErrorIs(t, ErrLocked, ports.ErrBrokerStoreLocked) | |||
| 237 | + | require.ErrorIs(t, ErrInvalidState, ports.ErrBrokerStoreInvalidState) | |||
| 238 | + | ||||
| 239 | + | require.NoError(t, s.Close()) | |||
| 240 | + | // Closing releases ownership without discarding the durable files. | |||
| 241 | + | require.Error(t, s.ReplaceHosts(1, nil)) | |||
| 242 | + | require.NoError(t, os.WriteFile(filepath.Join(o.Dir, "state.json"), []byte(`{`), 0600)) | |||
| 243 | + | _, err = OpenOffline(o) | |||
| 244 | + | require.ErrorIs(t, err, ErrInvalidState) | |||
| 245 | + | require.ErrorIs(t, err, ports.ErrBrokerStoreInvalidState) | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | // TestNonRegularInputsAreRejected covers inputs that are neither regular files | |||
| 249 | + | // nor absent: a directory and a FIFO must both fail closed before decoding. | |||
| 250 | + | func TestNonRegularInputsAreRejected(t *testing.T) { | |||
| 251 | + | for _, name := range []string{"directory", "fifo"} { | |||
| 252 | + | t.Run(name, func(t *testing.T) { | |||
| 253 | + | o := options(t) | |||
| 254 | + | path := filepath.Join(t.TempDir(), name) | |||
| 255 | + | if name == "directory" { | |||
| 256 | + | require.NoError(t, os.Mkdir(path, 0700)) | |||
| 257 | + | } else { | |||
| 258 | + | require.NoError(t, syscall.Mkfifo(path, 0600)) | |||
| 259 | + | } | |||
| 260 | + | o.LegacyHosts = path | |||
| 261 | + | s, err := OpenOffline(o) | |||
| 262 | + | require.Error(t, err) | |||
| 263 | + | require.Nil(t, s) | |||
| 264 | + | _, err = os.Stat(filepath.Join(o.Dir, "state.json")) | |||
| 265 | + | require.True(t, os.IsNotExist(err)) | |||
| 266 | + | }) | |||
| 267 | + | } | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | // TestCommittedStateAndRecoveryAreNeverFollowed replaces the committed state and | |||
| 271 | + | // the recovery record with symlinks to valid bytes: the store reads both with | |||
| 272 | + | // O_NOFOLLOW, so a symlink fails closed instead of redirecting a rename or | |||
| 273 | + | // silently adopting attacker-chosen state. | |||
| 274 | + | func TestCommittedStateAndRecoveryAreNeverFollowed(t *testing.T) { | |||
| 275 | + | t.Run("state", func(t *testing.T) { | |||
| 276 | + | o := options(t) | |||
| 277 | + | s, err := OpenOffline(o) | |||
| 278 | + | require.NoError(t, err) | |||
| 279 | + | require.NoError(t, s.Close()) | |||
| 280 | + | ||||
| 281 | + | state := filepath.Join(o.Dir, "state.json") | |||
| 282 | + | raw, err := os.ReadFile(state) | |||
| 283 | + | require.NoError(t, err) | |||
| 284 | + | target := filepath.Join(o.Dir, "elsewhere.json") | |||
| 285 | + | require.NoError(t, os.WriteFile(target, raw, 0600)) | |||
| 286 | + | require.NoError(t, os.Remove(state)) | |||
| 287 | + | require.NoError(t, os.Symlink(target, state)) | |||
| 288 | + | ||||
| 289 | + | _, err = OpenOffline(o) | |||
| 290 | + | require.ErrorIs(t, err, ErrInvalidState) | |||
| 291 | + | require.ErrorIs(t, err, ports.ErrBrokerStoreInvalidState) | |||
| 292 | + | after, err := os.ReadFile(target) | |||
| 293 | + | require.NoError(t, err) | |||
| 294 | + | require.Equal(t, raw, after) | |||
| 295 | + | }) | |||
| 296 | + | t.Run("recovery", func(t *testing.T) { | |||
| 297 | + | o := options(t) | |||
| 298 | + | s, err := OpenOffline(o) | |||
| 299 | + | require.NoError(t, err) | |||
| 300 | + | require.NoError(t, s.Close()) | |||
| 301 | + | ||||
| 302 | + | recoveryPath := filepath.Join(o.Dir, "recovery.json") | |||
| 303 | + | raw, err := os.ReadFile(recoveryPath) | |||
| 304 | + | require.NoError(t, err) | |||
| 305 | + | target := filepath.Join(o.Dir, "elsewhere.json") | |||
| 306 | + | require.NoError(t, os.WriteFile(target, raw, 0600)) | |||
| 307 | + | require.NoError(t, os.Remove(recoveryPath)) | |||
| 308 | + | require.NoError(t, os.Remove(filepath.Join(o.Dir, "state.json"))) | |||
| 309 | + | require.NoError(t, os.Symlink(target, recoveryPath)) | |||
| 310 | + | ||||
| 311 | + | _, err = OpenOffline(o) | |||
| 312 | + | require.ErrorIs(t, err, ErrInvalidState) | |||
| 313 | + | require.ErrorIs(t, err, ports.ErrBrokerStoreInvalidState) | |||
| 314 | + | _, err = os.Stat(filepath.Join(o.Dir, "state.json")) | |||
| 315 | + | require.True(t, os.IsNotExist(err)) | |||
| 316 | + | }) | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | // TestManifestDistinguishesAbsentFromEmptySources pins the recovery manifest: an | |||
| 320 | + | // absent source and a supplied source that carries no records can produce the | |||
| 321 | + | // same membership, so presence, keyed by generic role label, is what makes the | |||
| 322 | + | // recovery record an exact description of the migration input. | |||
| 323 | + | func TestManifestDistinguishesAbsentFromEmptySources(t *testing.T) { | |||
| 324 | + | readManifest := func(t *testing.T, dir string) (manifest, string) { | |||
| 325 | + | t.Helper() | |||
| 326 | + | raw, err := readBounded(filepath.Join(dir, "recovery.json")) | |||
| 327 | + | require.NoError(t, err) | |||
| 328 | + | var r recovery | |||
| 329 | + | require.NoError(t, strict(raw, &r)) | |||
| 330 | + | return r.State.Manifest, digest(nil) | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | absent := options(t) | |||
| 334 | + | absent.LegacyHosts = "" | |||
| 335 | + | absent.LegacyCache = "" | |||
| 336 | + | absent.Policies = nil | |||
| 337 | + | absentStore, err := OpenOffline(absent) | |||
| 338 | + | require.NoError(t, err) | |||
| 339 | + | defer absentStore.Close() | |||
| 340 | + | hosts, err := absentStore.LoadHosts() | |||
| 341 | + | require.NoError(t, err) | |||
| 342 | + | require.Empty(t, hosts.Hosts) | |||
| 343 | + | absentManifest, emptyDigest := readManifest(t, absent.Dir) | |||
| 344 | + | for _, label := range []manifestSourceLabel{manifestMembership, manifestObservations} { | |||
| 345 | + | source, ok := absentManifest.source(label) | |||
| 346 | + | require.True(t, ok, label) | |||
| 347 | + | require.False(t, source.Present, label) | |||
| 348 | + | require.Equal(t, emptyDigest, source.SHA256, label) | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | empty := options(t) | |||
| 352 | + | empty.LegacyCache = "" | |||
| 353 | + | empty.LegacyHosts = filepath.Join(t.TempDir(), "hosts") | |||
| 354 | + | require.NoError(t, os.WriteFile(empty.LegacyHosts, []byte(`{"version":1,"hosts":[]}`), 0600)) | |||
| 355 | + | emptyStore, err := OpenOffline(empty) | |||
| 356 | + | require.NoError(t, err) | |||
| 357 | + | defer emptyStore.Close() | |||
| 358 | + | stillEmpty, err := emptyStore.LoadHosts() | |||
| 359 | + | require.NoError(t, err) | |||
| 360 | + | // The same empty membership results, but the manifest records that the | |||
| 361 | + | // membership source was supplied and what it contained. | |||
| 362 | + | require.Equal(t, hosts, stillEmpty) | |||
| 363 | + | emptyManifest, _ := readManifest(t, empty.Dir) | |||
| 364 | + | membership, ok := emptyManifest.source(manifestMembership) | |||
| 365 | + | require.True(t, ok) | |||
| 366 | + | require.True(t, membership.Present) | |||
| 367 | + | require.NotEqual(t, emptyDigest, membership.SHA256) | |||
| 368 | + | observations, ok := emptyManifest.source(manifestObservations) | |||
| 369 | + | require.True(t, ok) | |||
| 370 | + | require.False(t, observations.Present) | |||
| 371 | + | ||||
| 372 | + | // A record that claims the source was absent while retaining its bytes is | |||
| 373 | + | // rejected: presence is verified, not merely recorded. | |||
| 374 | + | raw, err := readBounded(filepath.Join(empty.Dir, "recovery.json")) | |||
| 375 | + | require.NoError(t, err) | |||
| 376 | + | var r recovery | |||
| 377 | + | require.NoError(t, strict(raw, &r)) | |||
| 378 | + | for i := range r.State.Manifest.Sources { | |||
| 379 | + | if r.State.Manifest.Sources[i].Label == manifestMembership { | |||
| 380 | + | r.State.Manifest.Sources[i].Present = false | |||
| 381 | + | } | |||
| 382 | + | } | |||
| 383 | + | writer := Store{dir: empty.Dir} | |||
| 384 | + | require.NoError(t, writer.write("recovery.json", r)) | |||
| 385 | + | require.NoError(t, os.Remove(filepath.Join(empty.Dir, "state.json"))) | |||
| 386 | + | require.NoError(t, emptyStore.Close()) | |||
| 387 | + | _, err = OpenOffline(Options{Dir: empty.Dir}) | |||
| 388 | + | require.ErrorIs(t, err, ErrInvalidState) | |||
| 389 | + | require.ErrorContains(t, err, "absent membership source") | |||
| 390 | + | } | |||
| 391 | + | ||||
| 392 | + | // TestRevisionOverflowIsRefused keeps the store from ever minting revision zero | |||
| 393 | + | // or accepting an exhausted authority token. | |||
| 394 | + | func TestRevisionOverflowIsRefused(t *testing.T) { | |||
| 395 | + | o := options(t) | |||
| 396 | + | s, err := OpenOffline(o) | |||
| 397 | + | require.NoError(t, err) | |||
| 398 | + | defer s.Close() | |||
| 399 | + | h, err := s.LoadHosts() | |||
| 400 | + | require.NoError(t, err) | |||
| 401 | + | for _, hosts := range [][]ports.BrokerHostRecord{h.Hosts, nil} { | |||
| 402 | + | require.ErrorIs(t, s.ReplaceHosts(^uint64(0), hosts), ErrStale) | |||
| 403 | + | } | |||
| 404 | + | after, err := s.LoadHosts() | |||
| 405 | + | require.NoError(t, err) | |||
| 406 | + | require.Equal(t, h, after) | |||
| 407 | + | } | |||
| 408 | + | ||||
| 409 | + | // TestAuthorityGenerationPolicyAndRemoveReAdd exercises the durable authority | |||
| 410 | + | // rules: policy changes need a fresh generation, generations never regress, a | |||
| 411 | + | // removed incarnation's observation never returns, and a re-add carries a new | |||
| 412 | + | // incarnation. | |||
| 413 | + | func TestAuthorityGenerationPolicyAndRemoveReAdd(t *testing.T) { | |||
| 414 | + | o := options(t) | |||
| 415 | + | s, err := OpenOffline(o) | |||
| 416 | + | require.NoError(t, err) | |||
| 417 | + | defer s.Close() | |||
| 418 | + | h, err := s.LoadHosts() | |||
| 419 | + | require.NoError(t, err) | |||
| 420 | + | require.Len(t, h.Hosts, 1) | |||
| 421 | + | base := h.Hosts[0] | |||
| 422 | + | ||||
| 423 | + | // A policy change with an unchanged registration identity is refused: an | |||
| 424 | + | // already queued observation must never be accepted under changed trust. | |||
| 425 | + | changed := append([]ports.BrokerHostRecord(nil), h.Hosts...) | |||
| 426 | + | changed[0].Policy.Trust = "changed" | |||
| 427 | + | require.ErrorIs(t, s.ReplaceHosts(h.Revision, changed), ErrStale) | |||
| 428 | + | ||||
| 429 | + | // Re-supplying identical membership is allowed and still advances the | |||
| 430 | + | // authority revision. | |||
| 431 | + | require.NoError(t, s.ReplaceHosts(h.Revision, h.Hosts)) | |||
| 432 | + | h2, err := s.LoadHosts() | |||
| 433 | + | require.NoError(t, err) | |||
| 434 | + | require.Equal(t, h.Revision+1, h2.Revision) | |||
| 435 | + | ||||
| 436 | + | // The same policy change with a bumped generation is accepted and durable. | |||
| 437 | + | changed[0].Registration.Generation++ | |||
| 438 | + | require.NoError(t, s.ReplaceHosts(h2.Revision, changed)) | |||
| 439 | + | h3, err := s.LoadHosts() | |||
| 440 | + | require.NoError(t, err) | |||
| 441 | + | require.Equal(t, "changed", h3.Hosts[0].Policy.Trust) | |||
| 442 | + | ||||
| 443 | + | // A regressed generation on the same incarnation is refused. | |||
| 444 | + | regressed := append([]ports.BrokerHostRecord(nil), h3.Hosts...) | |||
| 445 | + | regressed[0].Registration.Generation = base.Registration.Generation | |||
| 446 | + | require.ErrorIs(t, s.ReplaceHosts(h3.Revision, regressed), ErrStale) | |||
| 447 | + | ||||
| 448 | + | // Full removal drops the observation with the registration. | |||
| 449 | + | require.NoError(t, s.ReplaceHosts(h3.Revision, nil)) | |||
| 450 | + | removed, err := s.LoadHosts() | |||
| 451 | + | require.NoError(t, err) | |||
| 452 | + | require.Empty(t, removed.Hosts) | |||
| 453 | + | ||||
| 454 | + | // A queued publication from the retired incarnation is refused even though | |||
| 455 | + | // its revision is newer. | |||
| 456 | + | require.ErrorIs(t, s.Store(ports.BrokerSnapshot{Epoch: 9, Revision: 1, Hosts: []ports.RemoteHostSnapshot{{Endpoint: base.Registration.Endpoint, Registration: base.Registration, Availability: domain.RemoteAvailabilityReachable}}}), ErrStale) | |||
| 457 | + | ||||
| 458 | + | // Re-adding the endpoint needs a fresh incarnation; the retired | |||
| 459 | + | // observation never comes back for it. | |||
| 460 | + | var incarnation [16]byte | |||
| 461 | + | incarnation[0] = 9 | |||
| 462 | + | registration, err := domain.NewRemoteRegistration(base.Registration.Endpoint, incarnation) | |||
| 463 | + | require.NoError(t, err) | |||
| 464 | + | reAdd := append([]ports.BrokerHostRecord(nil), base) | |||
| 465 | + | reAdd[0].Registration = registration | |||
| 466 | + | require.NoError(t, s.ReplaceHosts(removed.Revision, reAdd)) | |||
| 467 | + | readded, err := s.LoadHosts() | |||
| 468 | + | require.NoError(t, err) | |||
| 469 | + | require.True(t, readded.Hosts[0].Registration.Equal(registration)) | |||
| 470 | + | snapshot, err := s.Load() | |||
| 471 | + | require.NoError(t, err) | |||
| 472 | + | _, ok := snapshot.Find(base.Registration.Endpoint) | |||
| 473 | + | require.False(t, ok, "a removed incarnation's observation must not survive a re-add") | |||
| 474 | + | } | |||
| 475 | + | ||||
| 476 | + | // TestReplaceHostsRejectsCallerInputBeforeCAS proves malformed caller | |||
| 477 | + | // membership is rejected as a caller error before the compare-and-swap, never | |||
| 478 | + | // as ErrBrokerStoreInvalidState: the durable state is a victim, not the cause. | |||
| 479 | + | func TestReplaceHostsRejectsCallerInputBeforeCAS(t *testing.T) { | |||
| 480 | + | o := options(t) | |||
| 481 | + | s, err := OpenOffline(o) | |||
| 482 | + | require.NoError(t, err) | |||
| 483 | + | defer s.Close() | |||
| 484 | + | h, err := s.LoadHosts() | |||
| 485 | + | require.NoError(t, err) | |||
| 486 | + | require.Len(t, h.Hosts, 1) | |||
| 487 | + | base := h.Hosts[0] | |||
| 488 | + | ||||
| 489 | + | invalid := map[string][]ports.BrokerHostRecord{ | |||
| 490 | + | "zero registration": {{Pinned: true, Policy: base.Policy}}, | |||
| 491 | + | "zero policy": {{Registration: base.Registration, Pinned: true}}, | |||
| 492 | + | "unanchored": {{Registration: base.Registration, Policy: base.Policy}}, | |||
| 493 | + | "duplicate endpoint": { | |||
| 494 | + | {Registration: base.Registration, Pinned: true, Policy: base.Policy}, | |||
| 495 | + | {Registration: base.Registration, Learned: true, Policy: base.Policy}, | |||
| 496 | + | }, | |||
| 497 | + | } | |||
| 498 | + | for name, records := range invalid { | |||
| 499 | + | t.Run(name, func(t *testing.T) { | |||
| 500 | + | // The CAS token matches, so a CAS-first store would have accepted the | |||
| 501 | + | // malformed set and failed later as invalid durable state. | |||
| 502 | + | err := s.ReplaceHosts(h.Revision, records) | |||
| 503 | + | require.Error(t, err) | |||
| 504 | + | require.NotErrorIs(t, err, ErrStale) | |||
| 505 | + | require.NotErrorIs(t, err, ErrInvalidState) | |||
| 506 | + | require.NotErrorIs(t, err, ports.ErrBrokerStoreInvalidState) | |||
| 507 | + | }) | |||
| 508 | + | } | |||
| 509 | + | t.Run("over bound", func(t *testing.T) { | |||
| 510 | + | records := make([]ports.BrokerHostRecord, 0, ports.BrokerMaxHosts+1) | |||
| 511 | + | for index := 0; index <= ports.BrokerMaxHosts; index++ { | |||
| 512 | + | registration, err := domain.NewRemoteRegistration(fmt.Sprintf("bound-%d.test", index), [16]byte{byte(index + 1)}) | |||
| 513 | + | require.NoError(t, err) | |||
| 514 | + | records = append(records, ports.BrokerHostRecord{Registration: registration, Pinned: true, Policy: base.Policy}) | |||
| 515 | + | } | |||
| 516 | + | err := s.ReplaceHosts(h.Revision, records) | |||
| 517 | + | require.Error(t, err) | |||
| 518 | + | require.NotErrorIs(t, err, ports.ErrBrokerStoreInvalidState) | |||
| 519 | + | }) | |||
| 520 | + | ||||
| 521 | + | // Rejected input never commits: the authority revision and membership are | |||
| 522 | + | // exactly as before. | |||
| 523 | + | after, err := s.LoadHosts() | |||
| 524 | + | require.NoError(t, err) | |||
| 525 | + | require.Equal(t, h, after) | |||
| 526 | + | } | |||
| 527 | + | ||||
| 528 | + | // TestDurableSnapshotDropsProcessLocalTombstones proves a snapshot's | |||
| 529 | + | // process-local tombstone set is sanitized before persistence: storing a | |||
| 530 | + | // snapshot whose tombstone names a live host is accepted and reopened without | |||
| 531 | + | // Removed, instead of being rejected as durable-state corruption. | |||
| 532 | + | func TestDurableSnapshotDropsProcessLocalTombstones(t *testing.T) { | |||
| 533 | + | o := options(t) | |||
| 534 | + | s, err := OpenOffline(o) | |||
| 535 | + | require.NoError(t, err) | |||
| 536 | + | h, err := s.LoadHosts() | |||
| 537 | + | require.NoError(t, err) | |||
| 538 | + | require.Len(t, h.Hosts, 1) | |||
| 539 | + | reg := h.Hosts[0].Registration | |||
| 540 | + | ||||
| 541 | + | snap, err := s.Load() | |||
| 542 | + | require.NoError(t, err) | |||
| 543 | + | snap.Epoch = 5 | |||
| 544 | + | snap.Revision = 7 | |||
| 545 | + | snap.Removed = []ports.BrokerHostTombstone{{Endpoint: reg.Endpoint, Registration: reg, RetiredRevision: 6}} | |||
| 546 | + | require.NoError(t, s.Store(snap)) | |||
| 547 | + | ||||
| 548 | + | stored, err := s.Load() | |||
| 549 | + | require.NoError(t, err) | |||
| 550 | + | require.Empty(t, stored.Removed) | |||
| 551 | + | require.NoError(t, s.Close()) | |||
| 552 | + | ||||
| 553 | + | s, err = OpenOffline(o) | |||
| 554 | + | require.NoError(t, err) | |||
| 555 | + | defer s.Close() | |||
| 556 | + | reopened, err := s.Load() | |||
| 557 | + | require.NoError(t, err) | |||
| 558 | + | require.Empty(t, reopened.Removed) | |||
| 559 | + | } | |||
| 560 | + | ||||
| 561 | + | func TestFencingAndCommitFaults(t *testing.T) { | |||
| 562 | + | for _, point := range []string{"write", "sync", "rename", "dirsync"} { | |||
| 563 | + | t.Run(point, func(t *testing.T) { | |||
| 564 | + | o := options(t) | |||
| 565 | + | s, err := OpenOffline(o) | |||
| 566 | + | require.NoError(t, err) | |||
| 567 | + | h, err := s.LoadHosts() | |||
| 568 | + | require.NoError(t, err) | |||
| 569 | + | snap, err := s.Load() | |||
| 570 | + | require.NoError(t, err) | |||
| 571 | + | snap.Epoch = 20 | |||
| 572 | + | snap.Revision = 1 | |||
| 573 | + | require.NoError(t, s.Store(snap)) | |||
| 574 | + | require.ErrorIs(t, s.Store(snap), ErrStale) | |||
| 575 | + | other := snap | |||
| 576 | + | other.Epoch = 21 | |||
| 577 | + | require.ErrorIs(t, s.Store(other), ErrStale) | |||
| 578 | + | require.ErrorIs(t, s.ReplaceHosts(0, nil), ErrStale) | |||
| 579 | + | changed := append([]ports.BrokerHostRecord(nil), h.Hosts...) | |||
| 580 | + | changed[0].Policy.Trust = "new" | |||
| 581 | + | require.ErrorIs(t, s.ReplaceHosts(h.Revision, changed), ErrStale) | |||
| 582 | + | s.fault = func(p string) error { | |||
| 583 | + | if p == "state.json:"+point { | |||
| 584 | + | return errors.New("disk fault") | |||
| 585 | + | } | |||
| 586 | + | return nil | |||
| 587 | + | } | |||
| 588 | + | require.Error(t, s.ReplaceHosts(h.Revision, nil)) | |||
| 589 | + | _, err = s.LoadHosts() | |||
| 590 | + | require.Error(t, err) | |||
| 591 | + | require.NoError(t, s.Close()) | |||
| 592 | + | s, err = OpenOffline(o) | |||
| 593 | + | require.NoError(t, err) | |||
| 594 | + | defer s.Close() | |||
| 595 | + | h, err = s.LoadHosts() | |||
| 596 | + | require.NoError(t, err) | |||
| 597 | + | if point == "rename" || point == "dirsync" { | |||
| 598 | + | require.Empty(t, h.Hosts) | |||
| 599 | + | snap.Revision++ | |||
| 600 | + | require.ErrorIs(t, s.Store(snap), ErrStale) | |||
| 601 | + | } else { | |||
| 602 | + | require.Len(t, h.Hosts, 1) | |||
| 603 | + | } | |||
| 604 | + | }) | |||
| 605 | + | } | |||
| 606 | + | t.Run("generation and restart", func(t *testing.T) { | |||
| 607 | + | o := options(t) | |||
| 608 | + | s, err := OpenOffline(o) | |||
| 609 | + | require.NoError(t, err) | |||
| 610 | + | h, err := s.LoadHosts() | |||
| 611 | + | require.NoError(t, err) | |||
| 612 | + | snap, err := s.Load() | |||
| 613 | + | require.NoError(t, err) | |||
| 614 | + | h.Hosts[0].Registration.Generation++ | |||
| 615 | + | require.NoError(t, s.ReplaceHosts(h.Revision, h.Hosts)) | |||
| 616 | + | require.ErrorIs(t, s.Store(snap), ErrStale) | |||
| 617 | + | require.NoError(t, s.Close()) | |||
| 618 | + | s, err = OpenOffline(o) | |||
| 619 | + | require.NoError(t, err) | |||
| 620 | + | defer s.Close() | |||
| 621 | + | got, err := s.Load() | |||
| 622 | + | require.NoError(t, err) | |||
| 623 | + | require.Empty(t, got.Hosts) | |||
| 624 | + | gotH, err := s.LoadHosts() | |||
| 625 | + | require.NoError(t, err) | |||
| 626 | + | require.Equal(t, h.Hosts, gotH.Hosts) | |||
| 627 | + | }) | |||
| 628 | + | } | |||
A internal/adapters/brokerstore/testdata/cache-v2.json +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | {"version": 2, "hosts": [{"target": "user@arch", "fetched_at_unix_nano": 1780000000000000000, "sessions": [{"lifecycle_id": "02000000000000000000000000000000", "name": "work", "state": "up", "ephemeral": false, "last_used_seq": 42, "active_tab_id": "t_work", "attached": false, "tab_list": [{"id": "t_work", "index": 0, "name": "shell"}]}]}]} | |
| @@ -0,0 +1,1 @@ | |||||
| 1 | + | {"version": 2, "hosts": [{"target": "user@arch", "fetched_at_unix_nano": 1780000000000000000, "sessions": [{"lifecycle_id": "02000000000000000000000000000000", "name": "work", "state": "up", "ephemeral": false, "last_used_seq": 42, "active_tab_id": "t_work", "attached": false, "tab_list": [{"id": "t_work", "index": 0, "name": "shell"}]}]}]} | |||
A internal/adapters/brokerstore/testdata/cache-v3.json +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | {"version": 3, "hosts": [{"target": "user@arch", "fetched_at_unix_nano": 1780000000000000000, "sessions": [{"lifecycle_id": "02000000000000000000000000000000", "name": "work", "state": "up", "ephemeral": false, "last_used_seq": 42, "tabs": [{"id": "t_work", "index": 0, "name": "shell"}], "active_tab_id": "t_work", "attached": false}]}]} | |
| @@ -0,0 +1,1 @@ | |||||
| 1 | + | {"version": 3, "hosts": [{"target": "user@arch", "fetched_at_unix_nano": 1780000000000000000, "sessions": [{"lifecycle_id": "02000000000000000000000000000000", "name": "work", "state": "up", "ephemeral": false, "last_used_seq": 42, "tabs": [{"id": "t_work", "index": 0, "name": "shell"}], "active_tab_id": "t_work", "attached": false}]}]} | |||
A internal/adapters/brokerstore/testdata/cache-v4.json +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | {"version": 4, "hosts": [{"target": "user@arch", "fetched_at_unix_nano": 1780000000000000000, "sessions": [{"lifecycle_id": "02000000000000000000000000000000", "name": "work", "state": "up", "ephemeral": false, "last_used_seq": 42, "tabs": [{"id": "t_work", "index": 0, "name": "shell"}], "active_tab_id": "t_work", "attached": false}], "incarnation": "01000000000000000000000000000000"}]} | |
| @@ -0,0 +1,1 @@ | |||||
| 1 | + | {"version": 4, "hosts": [{"target": "user@arch", "fetched_at_unix_nano": 1780000000000000000, "sessions": [{"lifecycle_id": "02000000000000000000000000000000", "name": "work", "state": "up", "ephemeral": false, "last_used_seq": 42, "tabs": [{"id": "t_work", "index": 0, "name": "shell"}], "active_tab_id": "t_work", "attached": false}], "incarnation": "01000000000000000000000000000000"}]} | |||
A internal/adapters/brokerstore/testdata/hosts-v1.json +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | {"version": 1, "hosts": ["user@arch"]} | |
| @@ -0,0 +1,1 @@ | |||||
| 1 | + | {"version": 1, "hosts": ["user@arch"]} | |||
A internal/adapters/brokerstore/testdata/hosts-v2.json +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | {"version": 2, "pinned": ["user@arch"], "learned": ["user@arch"]} | |
| @@ -0,0 +1,1 @@ | |||||
| 1 | + | {"version": 2, "pinned": ["user@arch"], "learned": ["user@arch"]} | |||
A internal/adapters/brokerstore/testdata/hosts-v3.json +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | {"version": 3, "pinned": [{"endpoint": "user@arch", "incarnation": "01000000000000000000000000000000", "generation": 7}], "learned": [{"endpoint": "user@arch", "incarnation": "01000000000000000000000000000000", "generation": 7}]} | |
| @@ -0,0 +1,1 @@ | |||||
| 1 | + | {"version": 3, "pinned": [{"endpoint": "user@arch", "incarnation": "01000000000000000000000000000000", "generation": 7}], "learned": [{"endpoint": "user@arch", "incarnation": "01000000000000000000000000000000", "generation": 7}]} | |||
M internal/ports/broker.go +28 -11
| @@ -69,6 +69,22 @@ const ( | |||
| 69 | 69 | BrokerMaxPolicyTokenBytes = 256 | |
| 70 | 70 | ) | |
| 71 | 71 | ||
| 72 | + | // Broker registry lifecycle sentinels. They are typed so a caller can classify | |
| 73 | + | // why the registry refused a membership mutation without importing the use | |
| 74 | + | // case or matching on message text. | |
| 75 | + | var ( | |
| 76 | + | // ErrBrokerRegistryClosed reports that the registry run has settled: its | |
| 77 | + | // durable writer is flushed, in-flight attempts are retired, and no further | |
| 78 | + | // membership mutation is acknowledged. | |
| 79 | + | ErrBrokerRegistryClosed = errors.New("ports: broker registry is closed") | |
| 80 | + | // ErrBrokerRevisionExhausted reports that the broker epoch has consumed its | |
| 81 | + | // entire revision series. The registry fails closed: it keeps the last valid | |
| 82 | + | // snapshot published and refuses further mutations instead of wrapping to | |
| 83 | + | // zero or publishing a revision below the newest one, either of which would | |
| 84 | + | // silently freeze durable persistence. | |
| 85 | + | ErrBrokerRevisionExhausted = errors.New("ports: broker revision series exhausted") | |
| 86 | + | ) | |
| 87 | + | ||
| 72 | 88 | // BrokerEpoch identifies one broker process incarnation. Every broker | |
| 73 | 89 | // process samples a fresh non-zero epoch at startup; every publication | |
| 74 | 90 | // carries a revision within that epoch. Clients discard all broker state | |
| @@ -220,7 +236,9 @@ func (t BrokerHostTombstone) Fences(candidate domain.RemoteRegistration) bool { | |||
| 220 | 236 | // BrokerSnapshot is an immutable, fully defensive broker publication. Hosts | |
| 221 | 237 | // reuse the ordered RemoteHostSnapshot projection; Removed carries the | |
| 222 | 238 | // bounded tombstone set that fences stale authority. Nested slices are | |
| 223 | - | // never mutated after publication. | |
| 239 | + | // never mutated after publication. Removed is process-local fencing state and | |
| 240 | + | // is never durable: a BrokerSnapshotStore persists and reloads hosts without | |
| 241 | + | // tombstones. | |
| 224 | 242 | type BrokerSnapshot struct { | |
| 225 | 243 | Epoch BrokerEpoch | |
| 226 | 244 | Revision BrokerRevision | |
| @@ -618,17 +636,16 @@ type BrokerSnapshotStore interface { | |||
| 618 | 636 | } | |
| 619 | 637 | ||
| 620 | 638 | // BrokerHostProbe observes one exact host registration without creating an | |
| 621 | - | // attachment. The returned projection must describe the same registration, | |
| 622 | - | // must not exceed BrokerMaxSessionsPerHost sessions (an over-bound projection | |
| 623 | - | // is classified as an invalid response and never published), and every | |
| 624 | - | // returned session and its catalogue values must already be fully validated | |
| 625 | - | // and within the catalogue bounds: the registry re-checks only the session | |
| 626 | - | // count, so malformed or over-bound session names, tab identities, states, | |
| 627 | - | // and detail text would be published verbatim. Implementations should | |
| 639 | + | // attachment. The returned projection must describe the same registration and | |
| 640 | + | // must satisfy the durable projection rules (see | |
| 641 | + | // ValidateDurableHostProjection): the registry re-checks exactly those rules, | |
| 642 | + | // so an over-bound or catalogue-invalid session inventory is classified as an | |
| 643 | + | // invalid response and never published. Implementations should therefore | |
| 628 | 644 | // validate the projection with catalogue.ValidateRemoteCatalog against the | |
| 629 | - | // RemoteCatalogMax* bounds before returning it. Probe must return promptly | |
| 630 | - | // once ctx is cancelled: the registry cancels the attempt context when a | |
| 631 | - | // registration is replaced or removed, and when the run settles. A probe that | |
| 645 | + | // RemoteCatalogMax* bounds before returning it rather than relying on that | |
| 646 | + | // rejection. Probe must return promptly once ctx is cancelled: the registry | |
| 647 | + | // cancels the attempt context when a registration is replaced or removed, and | |
| 648 | + | // when the run settles. A probe that | |
| 632 | 649 | // ignores cancellation delays that retirement and can overlap a replacement | |
| 633 | 650 | // attempt for the same endpoint. Stale completions that arrive anyway are | |
| 634 | 651 | // fenced by epoch and registration identity before publication. | |
| @@ -69,6 +69,22 @@ const ( | |||||
| 69 | BrokerMaxPolicyTokenBytes = 256 | 69 | BrokerMaxPolicyTokenBytes = 256 | ||
| 70 | ) | 70 | ) | ||
| 71 | 71 | ||||
| 72 | + | // Broker registry lifecycle sentinels. They are typed so a caller can classify | |||
| 73 | + | // why the registry refused a membership mutation without importing the use | |||
| 74 | + | // case or matching on message text. | |||
| 75 | + | var ( | |||
| 76 | + | // ErrBrokerRegistryClosed reports that the registry run has settled: its | |||
| 77 | + | // durable writer is flushed, in-flight attempts are retired, and no further | |||
| 78 | + | // membership mutation is acknowledged. | |||
| 79 | + | ErrBrokerRegistryClosed = errors.New("ports: broker registry is closed") | |||
| 80 | + | // ErrBrokerRevisionExhausted reports that the broker epoch has consumed its | |||
| 81 | + | // entire revision series. The registry fails closed: it keeps the last valid | |||
| 82 | + | // snapshot published and refuses further mutations instead of wrapping to | |||
| 83 | + | // zero or publishing a revision below the newest one, either of which would | |||
| 84 | + | // silently freeze durable persistence. | |||
| 85 | + | ErrBrokerRevisionExhausted = errors.New("ports: broker revision series exhausted") | |||
| 86 | + | ) | |||
| 87 | + | ||||
| 72 | // BrokerEpoch identifies one broker process incarnation. Every broker | 88 | // BrokerEpoch identifies one broker process incarnation. Every broker | ||
| 73 | // process samples a fresh non-zero epoch at startup; every publication | 89 | // process samples a fresh non-zero epoch at startup; every publication | ||
| 74 | // carries a revision within that epoch. Clients discard all broker state | 90 | // carries a revision within that epoch. Clients discard all broker state | ||
| @@ -220,7 +236,9 @@ func (t BrokerHostTombstone) Fences(candidate domain.RemoteRegistration) bool { | |||||
| 220 | // BrokerSnapshot is an immutable, fully defensive broker publication. Hosts | 236 | // BrokerSnapshot is an immutable, fully defensive broker publication. Hosts | ||
| 221 | // reuse the ordered RemoteHostSnapshot projection; Removed carries the | 237 | // reuse the ordered RemoteHostSnapshot projection; Removed carries the | ||
| 222 | // bounded tombstone set that fences stale authority. Nested slices are | 238 | // bounded tombstone set that fences stale authority. Nested slices are | ||
| 223 | - | // never mutated after publication. | 239 | + | // never mutated after publication. Removed is process-local fencing state and |
| 240 | + | // is never durable: a BrokerSnapshotStore persists and reloads hosts without | |||
| 241 | + | // tombstones. | |||
| 224 | type BrokerSnapshot struct { | 242 | type BrokerSnapshot struct { | ||
| 225 | Epoch BrokerEpoch | 243 | Epoch BrokerEpoch | ||
| 226 | Revision BrokerRevision | 244 | Revision BrokerRevision | ||
| @@ -618,17 +636,16 @@ type BrokerSnapshotStore interface { | |||||
| 618 | } | 636 | } | ||
| 619 | 637 | ||||
| 620 | // BrokerHostProbe observes one exact host registration without creating an | 638 | // BrokerHostProbe observes one exact host registration without creating an | ||
| 621 | - | // attachment. The returned projection must describe the same registration, | 639 | + | // attachment. The returned projection must describe the same registration and |
| 622 | - | // must not exceed BrokerMaxSessionsPerHost sessions (an over-bound projection | 640 | + | // must satisfy the durable projection rules (see |
| 623 | - | // is classified as an invalid response and never published), and every | 641 | + | // ValidateDurableHostProjection): the registry re-checks exactly those rules, |
| 624 | - | // returned session and its catalogue values must already be fully validated | 642 | + | // so an over-bound or catalogue-invalid session inventory is classified as an |
| 625 | - | // and within the catalogue bounds: the registry re-checks only the session | 643 | + | // invalid response and never published. Implementations should therefore |
| 626 | - | // count, so malformed or over-bound session names, tab identities, states, | |||
| 627 | - | // and detail text would be published verbatim. Implementations should | |||
| 628 | // validate the projection with catalogue.ValidateRemoteCatalog against the | 644 | // validate the projection with catalogue.ValidateRemoteCatalog against the | ||
| 629 | - | // RemoteCatalogMax* bounds before returning it. Probe must return promptly | 645 | + | // RemoteCatalogMax* bounds before returning it rather than relying on that |
| 630 | - | // once ctx is cancelled: the registry cancels the attempt context when a | 646 | + | // rejection. Probe must return promptly once ctx is cancelled: the registry |
| 631 | - | // registration is replaced or removed, and when the run settles. A probe that | 647 | + | // cancels the attempt context when a registration is replaced or removed, and |
| 648 | + | // when the run settles. A probe that | |||
| 632 | // ignores cancellation delays that retirement and can overlap a replacement | 649 | // ignores cancellation delays that retirement and can overlap a replacement | ||
| 633 | // attempt for the same endpoint. Stale completions that arrive anyway are | 650 | // attempt for the same endpoint. Stale completions that arrive anyway are | ||
| 634 | // fenced by epoch and registration identity before publication. | 651 | // fenced by epoch and registration identity before publication. | ||
A internal/ports/broker_store.go +121 -0
| @@ -0,0 +1,121 @@ | |||
| 1 | + | package ports | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // Durable broker store sentinels. Adapters wrap these so a caller can classify | |
| 11 | + | // a store failure without importing the adapter: conflicting authority means | |
| 12 | + | // refresh the token and retry, a locked store means another owner is live, and | |
| 13 | + | // invalid state is resolved offline from the recovery record. None of them is | |
| 14 | + | // ever retried blindly inside the store. | |
| 15 | + | var ( | |
| 16 | + | // ErrBrokerHostConflict reports that durable host authority changed under | |
| 17 | + | // the caller's expected revision, or that the supplied membership would | |
| 18 | + | // regress or re-trust an existing registration without a fresh identity. | |
| 19 | + | ErrBrokerHostConflict = errors.New("ports: broker durable host authority conflict") | |
| 20 | + | // ErrBrokerStoreLocked reports that another owner already holds the durable | |
| 21 | + | // store's lifetime lock. | |
| 22 | + | ErrBrokerStoreLocked = errors.New("ports: broker durable store is owned elsewhere") | |
| 23 | + | // ErrBrokerStoreInvalidState reports durable state that fails validation. | |
| 24 | + | // Such state is never silently replaced or rolled back by the store. | |
| 25 | + | ErrBrokerStoreInvalidState = errors.New("ports: broker durable store state is invalid") | |
| 26 | + | ) | |
| 27 | + | ||
| 28 | + | // BrokerHostRecord is durable registration authority. Membership and policy | |
| 29 | + | // are never inferred from an observation or from the broker environment. | |
| 30 | + | type BrokerHostRecord struct { | |
| 31 | + | Registration domain.RemoteRegistration | |
| 32 | + | Pinned bool | |
| 33 | + | Learned bool | |
| 34 | + | Policy BrokerPolicy | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // BrokerHosts is an optimistic-concurrency token and the ordered authority. | |
| 38 | + | // Revision is store-local and independent of broker publication epochs. | |
| 39 | + | type BrokerHosts struct { | |
| 40 | + | Revision uint64 | |
| 41 | + | Hosts []BrokerHostRecord | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (h BrokerHosts) Validate() error { | |
| 45 | + | if h.Revision == 0 { | |
| 46 | + | return errors.New("ports: invalid broker host state") | |
| 47 | + | } | |
| 48 | + | return ValidateBrokerHostRecords(h.Hosts) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // ValidateBrokerHostRecords reports whether caller-supplied membership is | |
| 52 | + | // internally valid, independently of any revision token: every registration | |
| 53 | + | // and policy is valid, every record is anchored as pinned or learned, no | |
| 54 | + | // endpoint repeats, and the set is within the host bound. A durable store | |
| 55 | + | // applies this rule to caller input before its CAS, so malformed membership is | |
| 56 | + | // a caller error rather than durable-state corruption. | |
| 57 | + | func ValidateBrokerHostRecords(hosts []BrokerHostRecord) error { | |
| 58 | + | if len(hosts) > BrokerMaxHosts { | |
| 59 | + | return errors.New("ports: invalid broker host state") | |
| 60 | + | } | |
| 61 | + | seen := make(map[string]bool, len(hosts)) | |
| 62 | + | for _, host := range hosts { | |
| 63 | + | if err := host.Registration.Validate(); err != nil { | |
| 64 | + | return err | |
| 65 | + | } | |
| 66 | + | if err := host.Policy.Validate(); err != nil { | |
| 67 | + | return err | |
| 68 | + | } | |
| 69 | + | if (!host.Pinned && !host.Learned) || seen[host.Registration.Endpoint] { | |
| 70 | + | return errors.New("ports: invalid broker membership") | |
| 71 | + | } | |
| 72 | + | seen[host.Registration.Endpoint] = true | |
| 73 | + | } | |
| 74 | + | return nil | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // BrokerHostStore owns durable membership separately from advisory snapshots. | |
| 78 | + | // ReplaceHosts compares expected with the current host revision, increments it, | |
| 79 | + | // and atomically discards observations for removed/replaced registrations or | |
| 80 | + | // changed policies. Callers supply fresh incarnations on remove/re-add. | |
| 81 | + | // ReplaceHosts validates the supplied membership (ValidateBrokerHostRecords) | |
| 82 | + | // before its compare-and-swap, so malformed caller input is reported as a | |
| 83 | + | // caller error and never as invalid durable state. Close releases exclusive | |
| 84 | + | // ownership; no operation is valid after Close. | |
| 85 | + | type BrokerHostStore interface { | |
| 86 | + | BrokerSnapshotStore | |
| 87 | + | LoadHosts() (BrokerHosts, error) | |
| 88 | + | ReplaceHosts(expected uint64, hosts []BrokerHostRecord) error | |
| 89 | + | Close() error | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // BrokerHostStore is a BrokerSnapshotStore: the exclusive owner of durable | |
| 93 | + | // membership also owns the advisory snapshot, so the registry writes through | |
| 94 | + | // one seam. This guard keeps that composition compile-checked. | |
| 95 | + | var _ BrokerSnapshotStore = (BrokerHostStore)(nil) | |
| 96 | + | ||
| 97 | + | // ValidateDurableHostProjection reports whether one host projection satisfies | |
| 98 | + | // the semantic rules of durable broker state: a published availability and | |
| 99 | + | // failure kind inside their closed ranges, and a session inventory the | |
| 100 | + | // catalogue accepts for the host's exact incarnation and success time. The | |
| 101 | + | // offline store and the registry both apply this rule, so a projection the | |
| 102 | + | // registry publishes is never rejected (and therefore silently never | |
| 103 | + | // persisted) by the store. Transient state (Checking and the live failure | |
| 104 | + | // cause) is deliberately not part of the durable rule. | |
| 105 | + | func ValidateDurableHostProjection(host RemoteHostSnapshot) error { | |
| 106 | + | if host.Availability < domain.RemoteAvailabilityUnknown || host.Availability > domain.RemoteAvailabilityInvalidResponse { | |
| 107 | + | return errors.New("ports: broker host availability is out of range") | |
| 108 | + | } | |
| 109 | + | if host.LastFailure.Kind > domain.RemoteFailureInvalidResponse { | |
| 110 | + | return errors.New("ports: broker host failure kind is out of range") | |
| 111 | + | } | |
| 112 | + | if len(host.Sessions) == 0 { | |
| 113 | + | return nil | |
| 114 | + | } | |
| 115 | + | return catalogue.ValidateRemoteCatalogCacheEntries([]catalogue.RemoteCatalogCacheEntry{{ | |
| 116 | + | Host: host.Endpoint, | |
| 117 | + | Incarnation: host.Registration.Incarnation, | |
| 118 | + | FetchedAt: host.LastSuccess, | |
| 119 | + | Sessions: host.Sessions, | |
| 120 | + | }}) | |
| 121 | + | } | |
| @@ -0,0 +1,121 @@ | |||||
| 1 | + | package ports | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // Durable broker store sentinels. Adapters wrap these so a caller can classify | |||
| 11 | + | // a store failure without importing the adapter: conflicting authority means | |||
| 12 | + | // refresh the token and retry, a locked store means another owner is live, and | |||
| 13 | + | // invalid state is resolved offline from the recovery record. None of them is | |||
| 14 | + | // ever retried blindly inside the store. | |||
| 15 | + | var ( | |||
| 16 | + | // ErrBrokerHostConflict reports that durable host authority changed under | |||
| 17 | + | // the caller's expected revision, or that the supplied membership would | |||
| 18 | + | // regress or re-trust an existing registration without a fresh identity. | |||
| 19 | + | ErrBrokerHostConflict = errors.New("ports: broker durable host authority conflict") | |||
| 20 | + | // ErrBrokerStoreLocked reports that another owner already holds the durable | |||
| 21 | + | // store's lifetime lock. | |||
| 22 | + | ErrBrokerStoreLocked = errors.New("ports: broker durable store is owned elsewhere") | |||
| 23 | + | // ErrBrokerStoreInvalidState reports durable state that fails validation. | |||
| 24 | + | // Such state is never silently replaced or rolled back by the store. | |||
| 25 | + | ErrBrokerStoreInvalidState = errors.New("ports: broker durable store state is invalid") | |||
| 26 | + | ) | |||
| 27 | + | ||||
| 28 | + | // BrokerHostRecord is durable registration authority. Membership and policy | |||
| 29 | + | // are never inferred from an observation or from the broker environment. | |||
| 30 | + | type BrokerHostRecord struct { | |||
| 31 | + | Registration domain.RemoteRegistration | |||
| 32 | + | Pinned bool | |||
| 33 | + | Learned bool | |||
| 34 | + | Policy BrokerPolicy | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // BrokerHosts is an optimistic-concurrency token and the ordered authority. | |||
| 38 | + | // Revision is store-local and independent of broker publication epochs. | |||
| 39 | + | type BrokerHosts struct { | |||
| 40 | + | Revision uint64 | |||
| 41 | + | Hosts []BrokerHostRecord | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func (h BrokerHosts) Validate() error { | |||
| 45 | + | if h.Revision == 0 { | |||
| 46 | + | return errors.New("ports: invalid broker host state") | |||
| 47 | + | } | |||
| 48 | + | return ValidateBrokerHostRecords(h.Hosts) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // ValidateBrokerHostRecords reports whether caller-supplied membership is | |||
| 52 | + | // internally valid, independently of any revision token: every registration | |||
| 53 | + | // and policy is valid, every record is anchored as pinned or learned, no | |||
| 54 | + | // endpoint repeats, and the set is within the host bound. A durable store | |||
| 55 | + | // applies this rule to caller input before its CAS, so malformed membership is | |||
| 56 | + | // a caller error rather than durable-state corruption. | |||
| 57 | + | func ValidateBrokerHostRecords(hosts []BrokerHostRecord) error { | |||
| 58 | + | if len(hosts) > BrokerMaxHosts { | |||
| 59 | + | return errors.New("ports: invalid broker host state") | |||
| 60 | + | } | |||
| 61 | + | seen := make(map[string]bool, len(hosts)) | |||
| 62 | + | for _, host := range hosts { | |||
| 63 | + | if err := host.Registration.Validate(); err != nil { | |||
| 64 | + | return err | |||
| 65 | + | } | |||
| 66 | + | if err := host.Policy.Validate(); err != nil { | |||
| 67 | + | return err | |||
| 68 | + | } | |||
| 69 | + | if (!host.Pinned && !host.Learned) || seen[host.Registration.Endpoint] { | |||
| 70 | + | return errors.New("ports: invalid broker membership") | |||
| 71 | + | } | |||
| 72 | + | seen[host.Registration.Endpoint] = true | |||
| 73 | + | } | |||
| 74 | + | return nil | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // BrokerHostStore owns durable membership separately from advisory snapshots. | |||
| 78 | + | // ReplaceHosts compares expected with the current host revision, increments it, | |||
| 79 | + | // and atomically discards observations for removed/replaced registrations or | |||
| 80 | + | // changed policies. Callers supply fresh incarnations on remove/re-add. | |||
| 81 | + | // ReplaceHosts validates the supplied membership (ValidateBrokerHostRecords) | |||
| 82 | + | // before its compare-and-swap, so malformed caller input is reported as a | |||
| 83 | + | // caller error and never as invalid durable state. Close releases exclusive | |||
| 84 | + | // ownership; no operation is valid after Close. | |||
| 85 | + | type BrokerHostStore interface { | |||
| 86 | + | BrokerSnapshotStore | |||
| 87 | + | LoadHosts() (BrokerHosts, error) | |||
| 88 | + | ReplaceHosts(expected uint64, hosts []BrokerHostRecord) error | |||
| 89 | + | Close() error | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // BrokerHostStore is a BrokerSnapshotStore: the exclusive owner of durable | |||
| 93 | + | // membership also owns the advisory snapshot, so the registry writes through | |||
| 94 | + | // one seam. This guard keeps that composition compile-checked. | |||
| 95 | + | var _ BrokerSnapshotStore = (BrokerHostStore)(nil) | |||
| 96 | + | ||||
| 97 | + | // ValidateDurableHostProjection reports whether one host projection satisfies | |||
| 98 | + | // the semantic rules of durable broker state: a published availability and | |||
| 99 | + | // failure kind inside their closed ranges, and a session inventory the | |||
| 100 | + | // catalogue accepts for the host's exact incarnation and success time. The | |||
| 101 | + | // offline store and the registry both apply this rule, so a projection the | |||
| 102 | + | // registry publishes is never rejected (and therefore silently never | |||
| 103 | + | // persisted) by the store. Transient state (Checking and the live failure | |||
| 104 | + | // cause) is deliberately not part of the durable rule. | |||
| 105 | + | func ValidateDurableHostProjection(host RemoteHostSnapshot) error { | |||
| 106 | + | if host.Availability < domain.RemoteAvailabilityUnknown || host.Availability > domain.RemoteAvailabilityInvalidResponse { | |||
| 107 | + | return errors.New("ports: broker host availability is out of range") | |||
| 108 | + | } | |||
| 109 | + | if host.LastFailure.Kind > domain.RemoteFailureInvalidResponse { | |||
| 110 | + | return errors.New("ports: broker host failure kind is out of range") | |||
| 111 | + | } | |||
| 112 | + | if len(host.Sessions) == 0 { | |||
| 113 | + | return nil | |||
| 114 | + | } | |||
| 115 | + | return catalogue.ValidateRemoteCatalogCacheEntries([]catalogue.RemoteCatalogCacheEntry{{ | |||
| 116 | + | Host: host.Endpoint, | |||
| 117 | + | Incarnation: host.Registration.Incarnation, | |||
| 118 | + | FetchedAt: host.LastSuccess, | |||
| 119 | + | Sessions: host.Sessions, | |||
| 120 | + | }}) | |||
| 121 | + | } | |||
M internal/ports/broker_test.go +59 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "reflect" | |
| 5 | 5 | "strings" | |
| 6 | 6 | "testing" | |
| 7 | + | "time" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| 9 | 10 | "github.com/bnema/vev/internal/protocol" | |
| @@ -53,6 +54,64 @@ func validBrokerSnapshot() BrokerSnapshot { | |||
| 53 | 54 | } | |
| 54 | 55 | } | |
| 55 | 56 | ||
| 57 | + | // TestBrokerHostStoreIsASnapshotStore pins the interface guard: the exclusive | |
| 58 | + | // owner of durable membership also owns the advisory snapshot, so a | |
| 59 | + | // BrokerHostStore must stay usable wherever a BrokerSnapshotStore is required. | |
| 60 | + | func TestBrokerHostStoreIsASnapshotStore(t *testing.T) { | |
| 61 | + | host := reflect.TypeOf((*BrokerHostStore)(nil)).Elem() | |
| 62 | + | snapshot := reflect.TypeOf((*BrokerSnapshotStore)(nil)).Elem() | |
| 63 | + | if !host.Implements(snapshot) { | |
| 64 | + | t.Fatal("BrokerHostStore must implement BrokerSnapshotStore") | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // TestValidateDurableHostProjection matches the durable rules the offline store | |
| 69 | + | // enforces and the registry re-checks before adopting an observation. | |
| 70 | + | func TestValidateDurableHostProjection(t *testing.T) { | |
| 71 | + | session := catalogue.RemoteCatalogSession{ | |
| 72 | + | LifecycleID: testBrokerLifecycle(), | |
| 73 | + | Name: "work", | |
| 74 | + | State: catalogue.RemoteCatalogSessionUp, | |
| 75 | + | Tabs: []catalogue.RemoteCatalogTab{}, | |
| 76 | + | } | |
| 77 | + | tests := []struct { | |
| 78 | + | name string | |
| 79 | + | mutate func(*RemoteHostSnapshot) | |
| 80 | + | wantErr bool | |
| 81 | + | }{ | |
| 82 | + | {name: "empty inventory", mutate: func(*RemoteHostSnapshot) {}}, | |
| 83 | + | {name: "valid inventory", mutate: func(h *RemoteHostSnapshot) { h.Sessions = []catalogue.RemoteCatalogSession{session} }}, | |
| 84 | + | {name: "zero availability", mutate: func(h *RemoteHostSnapshot) { h.Availability = 0 }, wantErr: true}, | |
| 85 | + | {name: "unknown availability", mutate: func(h *RemoteHostSnapshot) { h.Availability = domain.RemoteAvailabilityUnknown }}, | |
| 86 | + | {name: "availability past the closed range", mutate: func(h *RemoteHostSnapshot) { h.Availability = domain.RemoteAvailabilityInvalidResponse + 1 }, wantErr: true}, | |
| 87 | + | {name: "failure kind past the closed range", mutate: func(h *RemoteHostSnapshot) { h.LastFailure.Kind = domain.RemoteFailureInvalidResponse + 1 }, wantErr: true}, | |
| 88 | + | {name: "missing lifecycle identity", mutate: func(h *RemoteHostSnapshot) { | |
| 89 | + | h.Sessions = []catalogue.RemoteCatalogSession{{Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: []catalogue.RemoteCatalogTab{}}} | |
| 90 | + | }, wantErr: true}, | |
| 91 | + | {name: "absent tab list", mutate: func(h *RemoteHostSnapshot) { | |
| 92 | + | broken := session | |
| 93 | + | broken.Tabs = nil | |
| 94 | + | h.Sessions = []catalogue.RemoteCatalogSession{broken} | |
| 95 | + | }, wantErr: true}, | |
| 96 | + | {name: "sessions past the catalogue bound", mutate: func(h *RemoteHostSnapshot) { | |
| 97 | + | h.Sessions = make([]catalogue.RemoteCatalogSession, catalogue.RemoteCatalogMaxSessions+1) | |
| 98 | + | for i := range h.Sessions { | |
| 99 | + | h.Sessions[i] = session | |
| 100 | + | } | |
| 101 | + | }, wantErr: true}, | |
| 102 | + | } | |
| 103 | + | for _, tc := range tests { | |
| 104 | + | t.Run(tc.name, func(t *testing.T) { | |
| 105 | + | host := testBrokerHost("user@arch") | |
| 106 | + | host.LastSuccess = time.Unix(50, 0) | |
| 107 | + | tc.mutate(&host) | |
| 108 | + | if gotErr := ValidateDurableHostProjection(host); (gotErr != nil) != tc.wantErr { | |
| 109 | + | t.Fatalf("ValidateDurableHostProjection() = %v, wantErr %t", gotErr, tc.wantErr) | |
| 110 | + | } | |
| 111 | + | }) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 56 | 115 | func TestBrokerSnapshotValidateBounds(t *testing.T) { | |
| 57 | 116 | tests := []struct { | |
| 58 | 117 | name string | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "reflect" | 4 | "reflect" | ||
| 5 | "strings" | 5 | "strings" | ||
| 6 | "testing" | 6 | "testing" | ||
| 7 | + | "time" | |||
| 7 | 8 | ||||
| 8 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 9 | "github.com/bnema/vev/internal/protocol" | 10 | "github.com/bnema/vev/internal/protocol" | ||
| @@ -53,6 +54,64 @@ func validBrokerSnapshot() BrokerSnapshot { | |||||
| 53 | } | 54 | } | ||
| 54 | } | 55 | } | ||
| 55 | 56 | ||||
| 57 | + | // TestBrokerHostStoreIsASnapshotStore pins the interface guard: the exclusive | |||
| 58 | + | // owner of durable membership also owns the advisory snapshot, so a | |||
| 59 | + | // BrokerHostStore must stay usable wherever a BrokerSnapshotStore is required. | |||
| 60 | + | func TestBrokerHostStoreIsASnapshotStore(t *testing.T) { | |||
| 61 | + | host := reflect.TypeOf((*BrokerHostStore)(nil)).Elem() | |||
| 62 | + | snapshot := reflect.TypeOf((*BrokerSnapshotStore)(nil)).Elem() | |||
| 63 | + | if !host.Implements(snapshot) { | |||
| 64 | + | t.Fatal("BrokerHostStore must implement BrokerSnapshotStore") | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // TestValidateDurableHostProjection matches the durable rules the offline store | |||
| 69 | + | // enforces and the registry re-checks before adopting an observation. | |||
| 70 | + | func TestValidateDurableHostProjection(t *testing.T) { | |||
| 71 | + | session := catalogue.RemoteCatalogSession{ | |||
| 72 | + | LifecycleID: testBrokerLifecycle(), | |||
| 73 | + | Name: "work", | |||
| 74 | + | State: catalogue.RemoteCatalogSessionUp, | |||
| 75 | + | Tabs: []catalogue.RemoteCatalogTab{}, | |||
| 76 | + | } | |||
| 77 | + | tests := []struct { | |||
| 78 | + | name string | |||
| 79 | + | mutate func(*RemoteHostSnapshot) | |||
| 80 | + | wantErr bool | |||
| 81 | + | }{ | |||
| 82 | + | {name: "empty inventory", mutate: func(*RemoteHostSnapshot) {}}, | |||
| 83 | + | {name: "valid inventory", mutate: func(h *RemoteHostSnapshot) { h.Sessions = []catalogue.RemoteCatalogSession{session} }}, | |||
| 84 | + | {name: "zero availability", mutate: func(h *RemoteHostSnapshot) { h.Availability = 0 }, wantErr: true}, | |||
| 85 | + | {name: "unknown availability", mutate: func(h *RemoteHostSnapshot) { h.Availability = domain.RemoteAvailabilityUnknown }}, | |||
| 86 | + | {name: "availability past the closed range", mutate: func(h *RemoteHostSnapshot) { h.Availability = domain.RemoteAvailabilityInvalidResponse + 1 }, wantErr: true}, | |||
| 87 | + | {name: "failure kind past the closed range", mutate: func(h *RemoteHostSnapshot) { h.LastFailure.Kind = domain.RemoteFailureInvalidResponse + 1 }, wantErr: true}, | |||
| 88 | + | {name: "missing lifecycle identity", mutate: func(h *RemoteHostSnapshot) { | |||
| 89 | + | h.Sessions = []catalogue.RemoteCatalogSession{{Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: []catalogue.RemoteCatalogTab{}}} | |||
| 90 | + | }, wantErr: true}, | |||
| 91 | + | {name: "absent tab list", mutate: func(h *RemoteHostSnapshot) { | |||
| 92 | + | broken := session | |||
| 93 | + | broken.Tabs = nil | |||
| 94 | + | h.Sessions = []catalogue.RemoteCatalogSession{broken} | |||
| 95 | + | }, wantErr: true}, | |||
| 96 | + | {name: "sessions past the catalogue bound", mutate: func(h *RemoteHostSnapshot) { | |||
| 97 | + | h.Sessions = make([]catalogue.RemoteCatalogSession, catalogue.RemoteCatalogMaxSessions+1) | |||
| 98 | + | for i := range h.Sessions { | |||
| 99 | + | h.Sessions[i] = session | |||
| 100 | + | } | |||
| 101 | + | }, wantErr: true}, | |||
| 102 | + | } | |||
| 103 | + | for _, tc := range tests { | |||
| 104 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 105 | + | host := testBrokerHost("user@arch") | |||
| 106 | + | host.LastSuccess = time.Unix(50, 0) | |||
| 107 | + | tc.mutate(&host) | |||
| 108 | + | if gotErr := ValidateDurableHostProjection(host); (gotErr != nil) != tc.wantErr { | |||
| 109 | + | t.Fatalf("ValidateDurableHostProjection() = %v, wantErr %t", gotErr, tc.wantErr) | |||
| 110 | + | } | |||
| 111 | + | }) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 56 | func TestBrokerSnapshotValidateBounds(t *testing.T) { | 115 | func TestBrokerSnapshotValidateBounds(t *testing.T) { | ||
| 57 | tests := []struct { | 116 | tests := []struct { | ||
| 58 | name string | 117 | name string | ||
A internal/ports/mocks/broker_store_mock.go +295 -0
| @@ -0,0 +1,295 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package portsmocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | + | mock "github.com/stretchr/testify/mock" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // NewMockBrokerHostStore creates a new instance of MockBrokerHostStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 13 | + | // The first argument is typically a *testing.T value. | |
| 14 | + | func NewMockBrokerHostStore(t interface { | |
| 15 | + | mock.TestingT | |
| 16 | + | Cleanup(func()) | |
| 17 | + | }) *MockBrokerHostStore { | |
| 18 | + | mock := &MockBrokerHostStore{} | |
| 19 | + | mock.Mock.Test(t) | |
| 20 | + | ||
| 21 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 22 | + | ||
| 23 | + | return mock | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // MockBrokerHostStore is an autogenerated mock type for the BrokerHostStore type | |
| 27 | + | type MockBrokerHostStore struct { | |
| 28 | + | mock.Mock | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | type MockBrokerHostStore_Expecter struct { | |
| 32 | + | mock *mock.Mock | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (_m *MockBrokerHostStore) EXPECT() *MockBrokerHostStore_Expecter { | |
| 36 | + | return &MockBrokerHostStore_Expecter{mock: &_m.Mock} | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // Close provides a mock function for the type MockBrokerHostStore | |
| 40 | + | func (_mock *MockBrokerHostStore) Close() error { | |
| 41 | + | ret := _mock.Called() | |
| 42 | + | ||
| 43 | + | if len(ret) == 0 { | |
| 44 | + | panic("no return value specified for Close") | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | var r0 error | |
| 48 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |
| 49 | + | r0 = returnFunc() | |
| 50 | + | } else { | |
| 51 | + | r0 = ret.Error(0) | |
| 52 | + | } | |
| 53 | + | return r0 | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // MockBrokerHostStore_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close' | |
| 57 | + | type MockBrokerHostStore_Close_Call struct { | |
| 58 | + | *mock.Call | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // Close is a helper method to define mock.On call | |
| 62 | + | func (_e *MockBrokerHostStore_Expecter) Close() *MockBrokerHostStore_Close_Call { | |
| 63 | + | return &MockBrokerHostStore_Close_Call{Call: _e.mock.On("Close")} | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func (_c *MockBrokerHostStore_Close_Call) Run(run func()) *MockBrokerHostStore_Close_Call { | |
| 67 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 68 | + | run() | |
| 69 | + | }) | |
| 70 | + | return _c | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func (_c *MockBrokerHostStore_Close_Call) Return(err error) *MockBrokerHostStore_Close_Call { | |
| 74 | + | _c.Call.Return(err) | |
| 75 | + | return _c | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func (_c *MockBrokerHostStore_Close_Call) RunAndReturn(run func() error) *MockBrokerHostStore_Close_Call { | |
| 79 | + | _c.Call.Return(run) | |
| 80 | + | return _c | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // Load provides a mock function for the type MockBrokerHostStore | |
| 84 | + | func (_mock *MockBrokerHostStore) Load() (ports.BrokerSnapshot, error) { | |
| 85 | + | ret := _mock.Called() | |
| 86 | + | ||
| 87 | + | if len(ret) == 0 { | |
| 88 | + | panic("no return value specified for Load") | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | var r0 ports.BrokerSnapshot | |
| 92 | + | var r1 error | |
| 93 | + | if returnFunc, ok := ret.Get(0).(func() (ports.BrokerSnapshot, error)); ok { | |
| 94 | + | return returnFunc() | |
| 95 | + | } | |
| 96 | + | if returnFunc, ok := ret.Get(0).(func() ports.BrokerSnapshot); ok { | |
| 97 | + | r0 = returnFunc() | |
| 98 | + | } else { | |
| 99 | + | r0 = ret.Get(0).(ports.BrokerSnapshot) | |
| 100 | + | } | |
| 101 | + | if returnFunc, ok := ret.Get(1).(func() error); ok { | |
| 102 | + | r1 = returnFunc() | |
| 103 | + | } else { | |
| 104 | + | r1 = ret.Error(1) | |
| 105 | + | } | |
| 106 | + | return r0, r1 | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // MockBrokerHostStore_Load_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Load' | |
| 110 | + | type MockBrokerHostStore_Load_Call struct { | |
| 111 | + | *mock.Call | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // Load is a helper method to define mock.On call | |
| 115 | + | func (_e *MockBrokerHostStore_Expecter) Load() *MockBrokerHostStore_Load_Call { | |
| 116 | + | return &MockBrokerHostStore_Load_Call{Call: _e.mock.On("Load")} | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func (_c *MockBrokerHostStore_Load_Call) Run(run func()) *MockBrokerHostStore_Load_Call { | |
| 120 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 121 | + | run() | |
| 122 | + | }) | |
| 123 | + | return _c | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func (_c *MockBrokerHostStore_Load_Call) Return(brokerSnapshot ports.BrokerSnapshot, err error) *MockBrokerHostStore_Load_Call { | |
| 127 | + | _c.Call.Return(brokerSnapshot, err) | |
| 128 | + | return _c | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func (_c *MockBrokerHostStore_Load_Call) RunAndReturn(run func() (ports.BrokerSnapshot, error)) *MockBrokerHostStore_Load_Call { | |
| 132 | + | _c.Call.Return(run) | |
| 133 | + | return _c | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // LoadHosts provides a mock function for the type MockBrokerHostStore | |
| 137 | + | func (_mock *MockBrokerHostStore) LoadHosts() (ports.BrokerHosts, error) { | |
| 138 | + | ret := _mock.Called() | |
| 139 | + | ||
| 140 | + | if len(ret) == 0 { | |
| 141 | + | panic("no return value specified for LoadHosts") | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | var r0 ports.BrokerHosts | |
| 145 | + | var r1 error | |
| 146 | + | if returnFunc, ok := ret.Get(0).(func() (ports.BrokerHosts, error)); ok { | |
| 147 | + | return returnFunc() | |
| 148 | + | } | |
| 149 | + | if returnFunc, ok := ret.Get(0).(func() ports.BrokerHosts); ok { | |
| 150 | + | r0 = returnFunc() | |
| 151 | + | } else { | |
| 152 | + | r0 = ret.Get(0).(ports.BrokerHosts) | |
| 153 | + | } | |
| 154 | + | if returnFunc, ok := ret.Get(1).(func() error); ok { | |
| 155 | + | r1 = returnFunc() | |
| 156 | + | } else { | |
| 157 | + | r1 = ret.Error(1) | |
| 158 | + | } | |
| 159 | + | return r0, r1 | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // MockBrokerHostStore_LoadHosts_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadHosts' | |
| 163 | + | type MockBrokerHostStore_LoadHosts_Call struct { | |
| 164 | + | *mock.Call | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // LoadHosts is a helper method to define mock.On call | |
| 168 | + | func (_e *MockBrokerHostStore_Expecter) LoadHosts() *MockBrokerHostStore_LoadHosts_Call { | |
| 169 | + | return &MockBrokerHostStore_LoadHosts_Call{Call: _e.mock.On("LoadHosts")} | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func (_c *MockBrokerHostStore_LoadHosts_Call) Run(run func()) *MockBrokerHostStore_LoadHosts_Call { | |
| 173 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 174 | + | run() | |
| 175 | + | }) | |
| 176 | + | return _c | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func (_c *MockBrokerHostStore_LoadHosts_Call) Return(brokerHosts ports.BrokerHosts, err error) *MockBrokerHostStore_LoadHosts_Call { | |
| 180 | + | _c.Call.Return(brokerHosts, err) | |
| 181 | + | return _c | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func (_c *MockBrokerHostStore_LoadHosts_Call) RunAndReturn(run func() (ports.BrokerHosts, error)) *MockBrokerHostStore_LoadHosts_Call { | |
| 185 | + | _c.Call.Return(run) | |
| 186 | + | return _c | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // ReplaceHosts provides a mock function for the type MockBrokerHostStore | |
| 190 | + | func (_mock *MockBrokerHostStore) ReplaceHosts(expected uint64, hosts []ports.BrokerHostRecord) error { | |
| 191 | + | ret := _mock.Called(expected, hosts) | |
| 192 | + | ||
| 193 | + | if len(ret) == 0 { | |
| 194 | + | panic("no return value specified for ReplaceHosts") | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | var r0 error | |
| 198 | + | if returnFunc, ok := ret.Get(0).(func(uint64, []ports.BrokerHostRecord) error); ok { | |
| 199 | + | r0 = returnFunc(expected, hosts) | |
| 200 | + | } else { | |
| 201 | + | r0 = ret.Error(0) | |
| 202 | + | } | |
| 203 | + | return r0 | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // MockBrokerHostStore_ReplaceHosts_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReplaceHosts' | |
| 207 | + | type MockBrokerHostStore_ReplaceHosts_Call struct { | |
| 208 | + | *mock.Call | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // ReplaceHosts is a helper method to define mock.On call | |
| 212 | + | // - expected uint64 | |
| 213 | + | // - hosts []ports.BrokerHostRecord | |
| 214 | + | func (_e *MockBrokerHostStore_Expecter) ReplaceHosts(expected any, hosts any) *MockBrokerHostStore_ReplaceHosts_Call { | |
| 215 | + | return &MockBrokerHostStore_ReplaceHosts_Call{Call: _e.mock.On("ReplaceHosts", expected, hosts)} | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | func (_c *MockBrokerHostStore_ReplaceHosts_Call) Run(run func(expected uint64, hosts []ports.BrokerHostRecord)) *MockBrokerHostStore_ReplaceHosts_Call { | |
| 219 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 220 | + | var arg0 uint64 | |
| 221 | + | if args[0] != nil { | |
| 222 | + | arg0 = args[0].(uint64) | |
| 223 | + | } | |
| 224 | + | var arg1 []ports.BrokerHostRecord | |
| 225 | + | if args[1] != nil { | |
| 226 | + | arg1 = args[1].([]ports.BrokerHostRecord) | |
| 227 | + | } | |
| 228 | + | run( | |
| 229 | + | arg0, | |
| 230 | + | arg1, | |
| 231 | + | ) | |
| 232 | + | }) | |
| 233 | + | return _c | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func (_c *MockBrokerHostStore_ReplaceHosts_Call) Return(err error) *MockBrokerHostStore_ReplaceHosts_Call { | |
| 237 | + | _c.Call.Return(err) | |
| 238 | + | return _c | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | func (_c *MockBrokerHostStore_ReplaceHosts_Call) RunAndReturn(run func(expected uint64, hosts []ports.BrokerHostRecord) error) *MockBrokerHostStore_ReplaceHosts_Call { | |
| 242 | + | _c.Call.Return(run) | |
| 243 | + | return _c | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | // Store provides a mock function for the type MockBrokerHostStore | |
| 247 | + | func (_mock *MockBrokerHostStore) Store(brokerSnapshot ports.BrokerSnapshot) error { | |
| 248 | + | ret := _mock.Called(brokerSnapshot) | |
| 249 | + | ||
| 250 | + | if len(ret) == 0 { | |
| 251 | + | panic("no return value specified for Store") | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | var r0 error | |
| 255 | + | if returnFunc, ok := ret.Get(0).(func(ports.BrokerSnapshot) error); ok { | |
| 256 | + | r0 = returnFunc(brokerSnapshot) | |
| 257 | + | } else { | |
| 258 | + | r0 = ret.Error(0) | |
| 259 | + | } | |
| 260 | + | return r0 | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | // MockBrokerHostStore_Store_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Store' | |
| 264 | + | type MockBrokerHostStore_Store_Call struct { | |
| 265 | + | *mock.Call | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | // Store is a helper method to define mock.On call | |
| 269 | + | // - brokerSnapshot ports.BrokerSnapshot | |
| 270 | + | func (_e *MockBrokerHostStore_Expecter) Store(brokerSnapshot any) *MockBrokerHostStore_Store_Call { | |
| 271 | + | return &MockBrokerHostStore_Store_Call{Call: _e.mock.On("Store", brokerSnapshot)} | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | func (_c *MockBrokerHostStore_Store_Call) Run(run func(brokerSnapshot ports.BrokerSnapshot)) *MockBrokerHostStore_Store_Call { | |
| 275 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 276 | + | var arg0 ports.BrokerSnapshot | |
| 277 | + | if args[0] != nil { | |
| 278 | + | arg0 = args[0].(ports.BrokerSnapshot) | |
| 279 | + | } | |
| 280 | + | run( | |
| 281 | + | arg0, | |
| 282 | + | ) | |
| 283 | + | }) | |
| 284 | + | return _c | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | func (_c *MockBrokerHostStore_Store_Call) Return(err error) *MockBrokerHostStore_Store_Call { | |
| 288 | + | _c.Call.Return(err) | |
| 289 | + | return _c | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | func (_c *MockBrokerHostStore_Store_Call) RunAndReturn(run func(brokerSnapshot ports.BrokerSnapshot) error) *MockBrokerHostStore_Store_Call { | |
| 293 | + | _c.Call.Return(run) | |
| 294 | + | return _c | |
| 295 | + | } | |
| @@ -0,0 +1,295 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package portsmocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "github.com/bnema/vev/internal/ports" | |||
| 9 | + | mock "github.com/stretchr/testify/mock" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // NewMockBrokerHostStore creates a new instance of MockBrokerHostStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 13 | + | // The first argument is typically a *testing.T value. | |||
| 14 | + | func NewMockBrokerHostStore(t interface { | |||
| 15 | + | mock.TestingT | |||
| 16 | + | Cleanup(func()) | |||
| 17 | + | }) *MockBrokerHostStore { | |||
| 18 | + | mock := &MockBrokerHostStore{} | |||
| 19 | + | mock.Mock.Test(t) | |||
| 20 | + | ||||
| 21 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 22 | + | ||||
| 23 | + | return mock | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // MockBrokerHostStore is an autogenerated mock type for the BrokerHostStore type | |||
| 27 | + | type MockBrokerHostStore struct { | |||
| 28 | + | mock.Mock | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | type MockBrokerHostStore_Expecter struct { | |||
| 32 | + | mock *mock.Mock | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func (_m *MockBrokerHostStore) EXPECT() *MockBrokerHostStore_Expecter { | |||
| 36 | + | return &MockBrokerHostStore_Expecter{mock: &_m.Mock} | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // Close provides a mock function for the type MockBrokerHostStore | |||
| 40 | + | func (_mock *MockBrokerHostStore) Close() error { | |||
| 41 | + | ret := _mock.Called() | |||
| 42 | + | ||||
| 43 | + | if len(ret) == 0 { | |||
| 44 | + | panic("no return value specified for Close") | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | var r0 error | |||
| 48 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |||
| 49 | + | r0 = returnFunc() | |||
| 50 | + | } else { | |||
| 51 | + | r0 = ret.Error(0) | |||
| 52 | + | } | |||
| 53 | + | return r0 | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // MockBrokerHostStore_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close' | |||
| 57 | + | type MockBrokerHostStore_Close_Call struct { | |||
| 58 | + | *mock.Call | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // Close is a helper method to define mock.On call | |||
| 62 | + | func (_e *MockBrokerHostStore_Expecter) Close() *MockBrokerHostStore_Close_Call { | |||
| 63 | + | return &MockBrokerHostStore_Close_Call{Call: _e.mock.On("Close")} | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func (_c *MockBrokerHostStore_Close_Call) Run(run func()) *MockBrokerHostStore_Close_Call { | |||
| 67 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 68 | + | run() | |||
| 69 | + | }) | |||
| 70 | + | return _c | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func (_c *MockBrokerHostStore_Close_Call) Return(err error) *MockBrokerHostStore_Close_Call { | |||
| 74 | + | _c.Call.Return(err) | |||
| 75 | + | return _c | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func (_c *MockBrokerHostStore_Close_Call) RunAndReturn(run func() error) *MockBrokerHostStore_Close_Call { | |||
| 79 | + | _c.Call.Return(run) | |||
| 80 | + | return _c | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // Load provides a mock function for the type MockBrokerHostStore | |||
| 84 | + | func (_mock *MockBrokerHostStore) Load() (ports.BrokerSnapshot, error) { | |||
| 85 | + | ret := _mock.Called() | |||
| 86 | + | ||||
| 87 | + | if len(ret) == 0 { | |||
| 88 | + | panic("no return value specified for Load") | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | var r0 ports.BrokerSnapshot | |||
| 92 | + | var r1 error | |||
| 93 | + | if returnFunc, ok := ret.Get(0).(func() (ports.BrokerSnapshot, error)); ok { | |||
| 94 | + | return returnFunc() | |||
| 95 | + | } | |||
| 96 | + | if returnFunc, ok := ret.Get(0).(func() ports.BrokerSnapshot); ok { | |||
| 97 | + | r0 = returnFunc() | |||
| 98 | + | } else { | |||
| 99 | + | r0 = ret.Get(0).(ports.BrokerSnapshot) | |||
| 100 | + | } | |||
| 101 | + | if returnFunc, ok := ret.Get(1).(func() error); ok { | |||
| 102 | + | r1 = returnFunc() | |||
| 103 | + | } else { | |||
| 104 | + | r1 = ret.Error(1) | |||
| 105 | + | } | |||
| 106 | + | return r0, r1 | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // MockBrokerHostStore_Load_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Load' | |||
| 110 | + | type MockBrokerHostStore_Load_Call struct { | |||
| 111 | + | *mock.Call | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // Load is a helper method to define mock.On call | |||
| 115 | + | func (_e *MockBrokerHostStore_Expecter) Load() *MockBrokerHostStore_Load_Call { | |||
| 116 | + | return &MockBrokerHostStore_Load_Call{Call: _e.mock.On("Load")} | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func (_c *MockBrokerHostStore_Load_Call) Run(run func()) *MockBrokerHostStore_Load_Call { | |||
| 120 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 121 | + | run() | |||
| 122 | + | }) | |||
| 123 | + | return _c | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func (_c *MockBrokerHostStore_Load_Call) Return(brokerSnapshot ports.BrokerSnapshot, err error) *MockBrokerHostStore_Load_Call { | |||
| 127 | + | _c.Call.Return(brokerSnapshot, err) | |||
| 128 | + | return _c | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | func (_c *MockBrokerHostStore_Load_Call) RunAndReturn(run func() (ports.BrokerSnapshot, error)) *MockBrokerHostStore_Load_Call { | |||
| 132 | + | _c.Call.Return(run) | |||
| 133 | + | return _c | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // LoadHosts provides a mock function for the type MockBrokerHostStore | |||
| 137 | + | func (_mock *MockBrokerHostStore) LoadHosts() (ports.BrokerHosts, error) { | |||
| 138 | + | ret := _mock.Called() | |||
| 139 | + | ||||
| 140 | + | if len(ret) == 0 { | |||
| 141 | + | panic("no return value specified for LoadHosts") | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | var r0 ports.BrokerHosts | |||
| 145 | + | var r1 error | |||
| 146 | + | if returnFunc, ok := ret.Get(0).(func() (ports.BrokerHosts, error)); ok { | |||
| 147 | + | return returnFunc() | |||
| 148 | + | } | |||
| 149 | + | if returnFunc, ok := ret.Get(0).(func() ports.BrokerHosts); ok { | |||
| 150 | + | r0 = returnFunc() | |||
| 151 | + | } else { | |||
| 152 | + | r0 = ret.Get(0).(ports.BrokerHosts) | |||
| 153 | + | } | |||
| 154 | + | if returnFunc, ok := ret.Get(1).(func() error); ok { | |||
| 155 | + | r1 = returnFunc() | |||
| 156 | + | } else { | |||
| 157 | + | r1 = ret.Error(1) | |||
| 158 | + | } | |||
| 159 | + | return r0, r1 | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // MockBrokerHostStore_LoadHosts_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadHosts' | |||
| 163 | + | type MockBrokerHostStore_LoadHosts_Call struct { | |||
| 164 | + | *mock.Call | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | // LoadHosts is a helper method to define mock.On call | |||
| 168 | + | func (_e *MockBrokerHostStore_Expecter) LoadHosts() *MockBrokerHostStore_LoadHosts_Call { | |||
| 169 | + | return &MockBrokerHostStore_LoadHosts_Call{Call: _e.mock.On("LoadHosts")} | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func (_c *MockBrokerHostStore_LoadHosts_Call) Run(run func()) *MockBrokerHostStore_LoadHosts_Call { | |||
| 173 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 174 | + | run() | |||
| 175 | + | }) | |||
| 176 | + | return _c | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func (_c *MockBrokerHostStore_LoadHosts_Call) Return(brokerHosts ports.BrokerHosts, err error) *MockBrokerHostStore_LoadHosts_Call { | |||
| 180 | + | _c.Call.Return(brokerHosts, err) | |||
| 181 | + | return _c | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func (_c *MockBrokerHostStore_LoadHosts_Call) RunAndReturn(run func() (ports.BrokerHosts, error)) *MockBrokerHostStore_LoadHosts_Call { | |||
| 185 | + | _c.Call.Return(run) | |||
| 186 | + | return _c | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | // ReplaceHosts provides a mock function for the type MockBrokerHostStore | |||
| 190 | + | func (_mock *MockBrokerHostStore) ReplaceHosts(expected uint64, hosts []ports.BrokerHostRecord) error { | |||
| 191 | + | ret := _mock.Called(expected, hosts) | |||
| 192 | + | ||||
| 193 | + | if len(ret) == 0 { | |||
| 194 | + | panic("no return value specified for ReplaceHosts") | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | var r0 error | |||
| 198 | + | if returnFunc, ok := ret.Get(0).(func(uint64, []ports.BrokerHostRecord) error); ok { | |||
| 199 | + | r0 = returnFunc(expected, hosts) | |||
| 200 | + | } else { | |||
| 201 | + | r0 = ret.Error(0) | |||
| 202 | + | } | |||
| 203 | + | return r0 | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // MockBrokerHostStore_ReplaceHosts_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReplaceHosts' | |||
| 207 | + | type MockBrokerHostStore_ReplaceHosts_Call struct { | |||
| 208 | + | *mock.Call | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | // ReplaceHosts is a helper method to define mock.On call | |||
| 212 | + | // - expected uint64 | |||
| 213 | + | // - hosts []ports.BrokerHostRecord | |||
| 214 | + | func (_e *MockBrokerHostStore_Expecter) ReplaceHosts(expected any, hosts any) *MockBrokerHostStore_ReplaceHosts_Call { | |||
| 215 | + | return &MockBrokerHostStore_ReplaceHosts_Call{Call: _e.mock.On("ReplaceHosts", expected, hosts)} | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | func (_c *MockBrokerHostStore_ReplaceHosts_Call) Run(run func(expected uint64, hosts []ports.BrokerHostRecord)) *MockBrokerHostStore_ReplaceHosts_Call { | |||
| 219 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 220 | + | var arg0 uint64 | |||
| 221 | + | if args[0] != nil { | |||
| 222 | + | arg0 = args[0].(uint64) | |||
| 223 | + | } | |||
| 224 | + | var arg1 []ports.BrokerHostRecord | |||
| 225 | + | if args[1] != nil { | |||
| 226 | + | arg1 = args[1].([]ports.BrokerHostRecord) | |||
| 227 | + | } | |||
| 228 | + | run( | |||
| 229 | + | arg0, | |||
| 230 | + | arg1, | |||
| 231 | + | ) | |||
| 232 | + | }) | |||
| 233 | + | return _c | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | func (_c *MockBrokerHostStore_ReplaceHosts_Call) Return(err error) *MockBrokerHostStore_ReplaceHosts_Call { | |||
| 237 | + | _c.Call.Return(err) | |||
| 238 | + | return _c | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | func (_c *MockBrokerHostStore_ReplaceHosts_Call) RunAndReturn(run func(expected uint64, hosts []ports.BrokerHostRecord) error) *MockBrokerHostStore_ReplaceHosts_Call { | |||
| 242 | + | _c.Call.Return(run) | |||
| 243 | + | return _c | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | // Store provides a mock function for the type MockBrokerHostStore | |||
| 247 | + | func (_mock *MockBrokerHostStore) Store(brokerSnapshot ports.BrokerSnapshot) error { | |||
| 248 | + | ret := _mock.Called(brokerSnapshot) | |||
| 249 | + | ||||
| 250 | + | if len(ret) == 0 { | |||
| 251 | + | panic("no return value specified for Store") | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | var r0 error | |||
| 255 | + | if returnFunc, ok := ret.Get(0).(func(ports.BrokerSnapshot) error); ok { | |||
| 256 | + | r0 = returnFunc(brokerSnapshot) | |||
| 257 | + | } else { | |||
| 258 | + | r0 = ret.Error(0) | |||
| 259 | + | } | |||
| 260 | + | return r0 | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | // MockBrokerHostStore_Store_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Store' | |||
| 264 | + | type MockBrokerHostStore_Store_Call struct { | |||
| 265 | + | *mock.Call | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | // Store is a helper method to define mock.On call | |||
| 269 | + | // - brokerSnapshot ports.BrokerSnapshot | |||
| 270 | + | func (_e *MockBrokerHostStore_Expecter) Store(brokerSnapshot any) *MockBrokerHostStore_Store_Call { | |||
| 271 | + | return &MockBrokerHostStore_Store_Call{Call: _e.mock.On("Store", brokerSnapshot)} | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | func (_c *MockBrokerHostStore_Store_Call) Run(run func(brokerSnapshot ports.BrokerSnapshot)) *MockBrokerHostStore_Store_Call { | |||
| 275 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 276 | + | var arg0 ports.BrokerSnapshot | |||
| 277 | + | if args[0] != nil { | |||
| 278 | + | arg0 = args[0].(ports.BrokerSnapshot) | |||
| 279 | + | } | |||
| 280 | + | run( | |||
| 281 | + | arg0, | |||
| 282 | + | ) | |||
| 283 | + | }) | |||
| 284 | + | return _c | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | func (_c *MockBrokerHostStore_Store_Call) Return(err error) *MockBrokerHostStore_Store_Call { | |||
| 288 | + | _c.Call.Return(err) | |||
| 289 | + | return _c | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | func (_c *MockBrokerHostStore_Store_Call) RunAndReturn(run func(brokerSnapshot ports.BrokerSnapshot) error) *MockBrokerHostStore_Store_Call { | |||
| 293 | + | _c.Call.Return(run) | |||
| 294 | + | return _c | |||
| 295 | + | } | |||
M internal/ports/remote_state.go +7 -2
| @@ -28,12 +28,17 @@ type RemoteHostSnapshot struct { | |||
| 28 | 28 | Sessions []catalogue.RemoteCatalogSession | |
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | - | // Clone returns a defensive copy with an independent session slice. | |
| 31 | + | // Clone returns a defensive copy with an independent session slice. Each | |
| 32 | + | // session keeps the exact nil-ness of its tab list: an empty but present list | |
| 33 | + | // is catalogue-valid and must not become absent through a copy. | |
| 32 | 34 | func (s RemoteHostSnapshot) Clone() RemoteHostSnapshot { | |
| 33 | 35 | out := s | |
| 34 | 36 | out.Sessions = append([]catalogue.RemoteCatalogSession(nil), s.Sessions...) | |
| 35 | 37 | for i := range out.Sessions { | |
| 36 | - | out.Sessions[i].Tabs = append([]catalogue.RemoteCatalogTab(nil), out.Sessions[i].Tabs...) | |
| 38 | + | if s.Sessions[i].Tabs == nil { | |
| 39 | + | continue | |
| 40 | + | } | |
| 41 | + | out.Sessions[i].Tabs = append(make([]catalogue.RemoteCatalogTab, 0, len(s.Sessions[i].Tabs)), s.Sessions[i].Tabs...) | |
| 37 | 42 | } | |
| 38 | 43 | return out | |
| 39 | 44 | } | |
| @@ -28,12 +28,17 @@ type RemoteHostSnapshot struct { | |||||
| 28 | Sessions []catalogue.RemoteCatalogSession | 28 | Sessions []catalogue.RemoteCatalogSession | ||
| 29 | } | 29 | } | ||
| 30 | 30 | ||||
| 31 | - | // Clone returns a defensive copy with an independent session slice. | 31 | + | // Clone returns a defensive copy with an independent session slice. Each |
| 32 | + | // session keeps the exact nil-ness of its tab list: an empty but present list | |||
| 33 | + | // is catalogue-valid and must not become absent through a copy. | |||
| 32 | func (s RemoteHostSnapshot) Clone() RemoteHostSnapshot { | 34 | func (s RemoteHostSnapshot) Clone() RemoteHostSnapshot { | ||
| 33 | out := s | 35 | out := s | ||
| 34 | out.Sessions = append([]catalogue.RemoteCatalogSession(nil), s.Sessions...) | 36 | out.Sessions = append([]catalogue.RemoteCatalogSession(nil), s.Sessions...) | ||
| 35 | for i := range out.Sessions { | 37 | for i := range out.Sessions { | ||
| 36 | - | out.Sessions[i].Tabs = append([]catalogue.RemoteCatalogTab(nil), out.Sessions[i].Tabs...) | 38 | + | if s.Sessions[i].Tabs == nil { |
| 39 | + | continue | |||
| 40 | + | } | |||
| 41 | + | out.Sessions[i].Tabs = append(make([]catalogue.RemoteCatalogTab, 0, len(s.Sessions[i].Tabs)), s.Sessions[i].Tabs...) | |||
| 37 | } | 42 | } | ||
| 38 | return out | 43 | return out | ||
| 39 | } | 44 | } | ||
M internal/ports/remote_state_test.go +8 -1
| @@ -72,7 +72,7 @@ func TestRemoteDirectorySnapshotCloneIsDefensive(t *testing.T) { | |||
| 72 | 72 | func TestRemoteHostSnapshotCloneIsDefensive(t *testing.T) { | |
| 73 | 73 | original := RemoteHostSnapshot{ | |
| 74 | 74 | Endpoint: "user@arch", | |
| 75 | - | Sessions: []catalogue.RemoteCatalogSession{{Name: "work"}}, | |
| 75 | + | Sessions: []catalogue.RemoteCatalogSession{{Name: "work"}, {Name: "empty", Tabs: []catalogue.RemoteCatalogTab{}}}, | |
| 76 | 76 | } | |
| 77 | 77 | cloned := original.Clone() | |
| 78 | 78 | cloned.Sessions[0].Name = "mutated" | |
| @@ -82,4 +82,11 @@ func TestRemoteHostSnapshotCloneIsDefensive(t *testing.T) { | |||
| 82 | 82 | if (RemoteHostSnapshot{}).Clone().Sessions != nil { | |
| 83 | 83 | t.Fatal("Clone() of a session-less host must stay nil") | |
| 84 | 84 | } | |
| 85 | + | if cloned.Sessions[1].Tabs == nil { | |
| 86 | + | t.Fatal("Clone() must keep an empty but present tab list") | |
| 87 | + | } | |
| 88 | + | cloned.Sessions[1].Tabs = append(cloned.Sessions[1].Tabs, catalogue.RemoteCatalogTab{ID: "tab-1"}) | |
| 89 | + | if original.Sessions[1].Tabs == nil || len(original.Sessions[1].Tabs) != 0 { | |
| 90 | + | t.Fatalf("Clone() shares tab memory with the original: %+v", original.Sessions[1].Tabs) | |
| 91 | + | } | |
| 85 | 92 | } | |
| @@ -72,7 +72,7 @@ func TestRemoteDirectorySnapshotCloneIsDefensive(t *testing.T) { | |||||
| 72 | func TestRemoteHostSnapshotCloneIsDefensive(t *testing.T) { | 72 | func TestRemoteHostSnapshotCloneIsDefensive(t *testing.T) { | ||
| 73 | original := RemoteHostSnapshot{ | 73 | original := RemoteHostSnapshot{ | ||
| 74 | Endpoint: "user@arch", | 74 | Endpoint: "user@arch", | ||
| 75 | - | Sessions: []catalogue.RemoteCatalogSession{{Name: "work"}}, | 75 | + | Sessions: []catalogue.RemoteCatalogSession{{Name: "work"}, {Name: "empty", Tabs: []catalogue.RemoteCatalogTab{}}}, |
| 76 | } | 76 | } | ||
| 77 | cloned := original.Clone() | 77 | cloned := original.Clone() | ||
| 78 | cloned.Sessions[0].Name = "mutated" | 78 | cloned.Sessions[0].Name = "mutated" | ||
| @@ -82,4 +82,11 @@ func TestRemoteHostSnapshotCloneIsDefensive(t *testing.T) { | |||||
| 82 | if (RemoteHostSnapshot{}).Clone().Sessions != nil { | 82 | if (RemoteHostSnapshot{}).Clone().Sessions != nil { | ||
| 83 | t.Fatal("Clone() of a session-less host must stay nil") | 83 | t.Fatal("Clone() of a session-less host must stay nil") | ||
| 84 | } | 84 | } | ||
| 85 | + | if cloned.Sessions[1].Tabs == nil { | |||
| 86 | + | t.Fatal("Clone() must keep an empty but present tab list") | |||
| 87 | + | } | |||
| 88 | + | cloned.Sessions[1].Tabs = append(cloned.Sessions[1].Tabs, catalogue.RemoteCatalogTab{ID: "tab-1"}) | |||
| 89 | + | if original.Sessions[1].Tabs == nil || len(original.Sessions[1].Tabs) != 0 { | |||
| 90 | + | t.Fatalf("Clone() shares tab memory with the original: %+v", original.Sessions[1].Tabs) | |||
| 91 | + | } | |||
| 85 | } | 92 | } | ||
M internal/usecase/broker/registry.go +184 -42
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "fmt" | |
| 8 | 8 | "hash/fnv" | |
| 9 | 9 | "log/slog" | |
| 10 | + | "reflect" | |
| 10 | 11 | "sort" | |
| 11 | 12 | "sync" | |
| 12 | 13 | "sync/atomic" | |
| @@ -44,9 +45,20 @@ type Registry struct { | |||
| 44 | 45 | log *slog.Logger | |
| 45 | 46 | store *snapshotWriter | |
| 46 | 47 | ||
| 48 | + | // hostStore and authority are the synchronous membership owner. Only this | |
| 49 | + | // registry may mutate the store while it is live; the caller owns Close. | |
| 50 | + | hostStore ports.BrokerHostStore | |
| 51 | + | authority ports.BrokerHosts // guarded by mu; independent of publication revision | |
| 52 | + | ||
| 47 | 53 | current atomic.Pointer[ports.BrokerSnapshot] | |
| 48 | 54 | mu sync.Mutex | |
| 49 | 55 | revision ports.BrokerRevision | |
| 56 | + | // revisionExhausted records that this epoch consumed its entire revision | |
| 57 | + | // series. The registry then fails closed: the last valid snapshot stays | |
| 58 | + | // published, nothing is written to the store, and every mutation path | |
| 59 | + | // reports ports.ErrBrokerRevisionExhausted instead of wrapping the series to | |
| 60 | + | // zero or below the newest revision. | |
| 61 | + | revisionExhausted bool | |
| 50 | 62 | // attempts is the registry-wide monotonic attempt token source. Every | |
| 51 | 63 | // started observation claims one token, and only the token currently | |
| 52 | 64 | // recorded as in flight may complete. Tokens are never reused, so a stale | |
| @@ -73,8 +85,16 @@ type Registry struct { | |||
| 73 | 85 | } | |
| 74 | 86 | ||
| 75 | 87 | // NewRegistry restores a validated durable snapshot under a fresh broker epoch. | |
| 76 | - | func NewRegistry(epoch ports.BrokerEpoch, store ports.BrokerSnapshotStore, probe ports.BrokerHostProbe, clock ports.Clock, log *slog.Logger) (*Registry, error) { | |
| 77 | - | if epoch == 0 || probe == nil || clock == nil { | |
| 88 | + | // The store owns durable membership as well as the advisory snapshot, so | |
| 89 | + | // BrokerHostStore is required explicitly: a store that cannot answer | |
| 90 | + | // LoadHosts is not a broker host store, and an optional assertion would let a | |
| 91 | + | // caller silently run the registry without authoritative membership. A nil | |
| 92 | + | // store is refused by the dependency guard, including a typed nil hidden | |
| 93 | + | // behind the interface. Until Run returns, callers must not mutate the store | |
| 94 | + | // outside Registry.ReplaceHosts. The caller closes the store after the registry | |
| 95 | + | // has stopped and flushed its advisory writer. | |
| 96 | + | func NewRegistry(epoch ports.BrokerEpoch, store ports.BrokerHostStore, probe ports.BrokerHostProbe, clock ports.Clock, log *slog.Logger) (*Registry, error) { | |
| 97 | + | if epoch == 0 || nilDependency(store) || nilDependency(probe) || nilDependency(clock) { | |
| 78 | 98 | return nil, errors.New("broker: invalid registry dependencies") | |
| 79 | 99 | } | |
| 80 | 100 | if log == nil { | |
| @@ -88,20 +108,68 @@ func NewRegistry(epoch ports.BrokerEpoch, store ports.BrokerSnapshotStore, probe | |||
| 88 | 108 | freshFor: defaultFreshFor, retryBase: defaultRetryBase, retryMax: defaultRetryLimit, | |
| 89 | 109 | jitter: jittered, | |
| 90 | 110 | } | |
| 91 | - | if store != nil { | |
| 92 | - | r.store = newSnapshotWriter(store, log) | |
| 93 | - | snapshot, err := store.Load() | |
| 94 | - | if err != nil { | |
| 95 | - | return nil, err | |
| 96 | - | } | |
| 97 | - | if err := r.restore(snapshot); err != nil { | |
| 98 | - | return nil, err | |
| 99 | - | } | |
| 111 | + | r.store = newSnapshotWriter(store, log) | |
| 112 | + | snapshot, err := store.Load() | |
| 113 | + | if err != nil { | |
| 114 | + | return nil, err | |
| 115 | + | } | |
| 116 | + | if err := r.restore(snapshot); err != nil { | |
| 117 | + | return nil, err | |
| 100 | 118 | } | |
| 119 | + | hosts, err := store.LoadHosts() | |
| 120 | + | if err != nil { | |
| 121 | + | return nil, err | |
| 122 | + | } | |
| 123 | + | if err := r.projectMembership(hosts); err != nil { | |
| 124 | + | return nil, err | |
| 125 | + | } | |
| 126 | + | r.hostStore = store | |
| 127 | + | r.authority = ports.BrokerHosts{Revision: hosts.Revision, Hosts: append([]ports.BrokerHostRecord(nil), hosts.Hosts...)} | |
| 101 | 128 | r.publishLocked(false) | |
| 102 | 129 | return r, nil | |
| 103 | 130 | } | |
| 104 | 131 | ||
| 132 | + | // nilDependency reports whether a required dependency is absent, including a | |
| 133 | + | // typed nil pointer stored behind a non-nil interface. Comparing the interface | |
| 134 | + | // to nil alone would admit a typed nil and defer the failure to the first | |
| 135 | + | // method call, which panics; the reflection check keeps the dependency guard | |
| 136 | + | // total for every nil shape. | |
| 137 | + | func nilDependency(dependency any) bool { | |
| 138 | + | if dependency == nil { | |
| 139 | + | return true | |
| 140 | + | } | |
| 141 | + | value := reflect.ValueOf(dependency) | |
| 142 | + | switch value.Kind() { | |
| 143 | + | case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice: | |
| 144 | + | return value.IsNil() | |
| 145 | + | default: | |
| 146 | + | return false | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // projectMembership intersects durable membership with the observations a | |
| 151 | + | // previous process left behind. Durable membership is authoritative: | |
| 152 | + | // snapshots cannot resurrect removed hosts or omit never-observed | |
| 153 | + | // registrations. A restored observation is reused only when it belongs to the | |
| 154 | + | // exact same registration identity; any other registration starts unknown and | |
| 155 | + | // carries no inventory. | |
| 156 | + | func (r *Registry) projectMembership(hosts ports.BrokerHosts) error { | |
| 157 | + | if err := hosts.Validate(); err != nil { | |
| 158 | + | return err | |
| 159 | + | } | |
| 160 | + | next := make(map[string]ports.RemoteHostSnapshot, len(hosts.Hosts)) | |
| 161 | + | for _, record := range hosts.Hosts { | |
| 162 | + | registration := record.Registration | |
| 163 | + | restored, present := r.hosts[registration.Endpoint] | |
| 164 | + | if !present || !restored.Registration.Equal(registration) { | |
| 165 | + | restored = ports.RemoteHostSnapshot{Endpoint: registration.Endpoint, Registration: registration, Availability: domain.RemoteAvailabilityUnknown} | |
| 166 | + | } | |
| 167 | + | next[registration.Endpoint] = restored | |
| 168 | + | } | |
| 169 | + | r.hosts = next | |
| 170 | + | return nil | |
| 171 | + | } | |
| 172 | + | ||
| 105 | 173 | // restore adopts a validated durable snapshot. A zero-epoch snapshot is only | |
| 106 | 174 | // the empty placeholder a store returns before its first write: any record | |
| 107 | 175 | // without an epoch is rejected instead of silently dropped. A persisted | |
| @@ -151,12 +219,53 @@ func (r *Registry) Subscribe() ports.BrokerSubscription { | |||
| 151 | 219 | return s | |
| 152 | 220 | } | |
| 153 | 221 | ||
| 154 | - | // SetHosts atomically replaces configured membership. An unchanged membership | |
| 155 | - | // is a no-op: it neither republishes nor restarts an in-flight observation. | |
| 222 | + | // ReplaceHosts durably replaces membership and explicit policy using the loaded | |
| 223 | + | // authority revision. Success means membership is committed, not that advisory | |
| 224 | + | // observations have flushed. Store errors (including stale authority) are | |
| 225 | + | // returned without changing the projection, probe attempts, or local token. | |
| 226 | + | // A settled run reports ports.ErrBrokerRegistryClosed and an exhausted revision | |
| 227 | + | // series reports ports.ErrBrokerRevisionExhausted; both refuse the mutation | |
| 228 | + | // before the store CAS, so a refusal never changes durable authority either. | |
| 229 | + | // Conflicts are not retried or refreshed implicitly: reconcile by reopening the | |
| 230 | + | // owner. Callers must supply fresh incarnations when removing and re-adding. | |
| 231 | + | // Even identical records perform CAS so stale authority cannot report success. | |
| 232 | + | // The synchronous write holds mu; Snapshot remains lock-free, but scheduling | |
| 233 | + | // waits for membership durability. Observation writes remain asynchronous. | |
| 234 | + | func (r *Registry) ReplaceHosts(records []ports.BrokerHostRecord) error { | |
| 235 | + | records = append([]ports.BrokerHostRecord(nil), records...) | |
| 236 | + | if err := ports.ValidateBrokerHostRecords(records); err != nil { | |
| 237 | + | return err | |
| 238 | + | } | |
| 239 | + | next := make(map[string]ports.RemoteHostSnapshot, len(records)) | |
| 240 | + | for _, record := range records { | |
| 241 | + | reg := record.Registration | |
| 242 | + | next[reg.Endpoint] = ports.RemoteHostSnapshot{Endpoint: reg.Endpoint, Registration: reg, Availability: domain.RemoteAvailabilityUnknown} | |
| 243 | + | } | |
| 244 | + | r.mu.Lock() | |
| 245 | + | defer r.mu.Unlock() | |
| 246 | + | if r.running.Load() == runStopped { | |
| 247 | + | return fmt.Errorf("broker: membership replacement: %w", ports.ErrBrokerRegistryClosed) | |
| 248 | + | } | |
| 249 | + | if r.revisionExhaustedLocked() { | |
| 250 | + | return fmt.Errorf("broker: membership replacement: %w", ports.ErrBrokerRevisionExhausted) | |
| 251 | + | } | |
| 252 | + | if err := r.hostStore.ReplaceHosts(r.authority.Revision, records); err != nil { | |
| 253 | + | return fmt.Errorf("broker: replace hosts: %w", err) | |
| 254 | + | } | |
| 255 | + | r.authority = ports.BrokerHosts{Revision: r.authority.Revision + 1, Hosts: records} | |
| 256 | + | r.setHostsLocked(next) | |
| 257 | + | return nil | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | // setHosts is projection-only; it is private so callers cannot bypass durable | |
| 261 | + | // membership and policy. Observation tests use it without claiming durability. | |
| 262 | + | // An unchanged membership is a no-op: it neither republishes nor restarts an | |
| 263 | + | // in-flight observation. An exhausted revision series is refused with | |
| 264 | + | // ports.ErrBrokerRevisionExhausted before the projection changes. | |
| 156 | 265 | // Removed registrations retire bounded tombstones so a stale completion can | |
| 157 | 266 | // never revive them; replaced or re-added endpoints are fenced by the | |
| 158 | 267 | // registry attempt token instead. | |
| 159 | - | func (r *Registry) SetHosts(registrations []domain.RemoteRegistration) error { | |
| 268 | + | func (r *Registry) setHosts(registrations []domain.RemoteRegistration) error { | |
| 160 | 269 | if len(registrations) > ports.BrokerMaxHosts { | |
| 161 | 270 | return fmt.Errorf("broker: %d host registrations exceed the limit of %d", len(registrations), ports.BrokerMaxHosts) | |
| 162 | 271 | } | |
| @@ -172,9 +281,19 @@ func (r *Registry) SetHosts(registrations []domain.RemoteRegistration) error { | |||
| 172 | 281 | } | |
| 173 | 282 | ||
| 174 | 283 | r.mu.Lock() | |
| 284 | + | defer r.mu.Unlock() | |
| 285 | + | if r.revisionExhaustedLocked() { | |
| 286 | + | return fmt.Errorf("broker: set hosts: %w", ports.ErrBrokerRevisionExhausted) | |
| 287 | + | } | |
| 288 | + | r.setHostsLocked(next) | |
| 289 | + | return nil | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | // setHostsLocked applies only a projection; the public path commits authority | |
| 293 | + | // first. The caller holds mu through both steps, fencing probe completions. | |
| 294 | + | func (r *Registry) setHostsLocked(next map[string]ports.RemoteHostSnapshot) { | |
| 175 | 295 | if registrationsUnchanged(r.hosts, next) { | |
| 176 | - | r.mu.Unlock() | |
| 177 | - | return nil | |
| 296 | + | return | |
| 178 | 297 | } | |
| 179 | 298 | for endpoint, current := range r.hosts { | |
| 180 | 299 | candidate, present := next[endpoint] | |
| @@ -207,9 +326,7 @@ func (r *Registry) SetHosts(registrations []domain.RemoteRegistration) error { | |||
| 207 | 326 | } | |
| 208 | 327 | r.hosts = next | |
| 209 | 328 | r.publishLocked(true) | |
| 210 | - | r.mu.Unlock() | |
| 211 | 329 | r.hint() | |
| 212 | - | return nil | |
| 213 | 330 | } | |
| 214 | 331 | ||
| 215 | 332 | // cancelInflightLocked cancels the in-flight attempt for one endpoint and | |
| @@ -301,11 +418,9 @@ func (r *Registry) settle() { | |||
| 301 | 418 | if cleared { | |
| 302 | 419 | r.publishLocked(true) | |
| 303 | 420 | } | |
| 304 | - | r.mu.Unlock() | |
| 305 | - | if r.store != nil { | |
| 306 | - | r.store.close() | |
| 307 | - | } | |
| 308 | 421 | r.running.Store(runStopped) | |
| 422 | + | r.mu.Unlock() | |
| 423 | + | r.store.close() | |
| 309 | 424 | } | |
| 310 | 425 | ||
| 311 | 426 | // probeAttempt is one admitted observation: the registry-wide token that | |
| @@ -392,9 +507,6 @@ func (r *Registry) apply(result probeResult) { | |||
| 392 | 507 | ||
| 393 | 508 | kind, availability, failed := observationOutcome(result) | |
| 394 | 509 | if !failed { | |
| 395 | - | // Reachable: adopt the observed inventory, but registry-owned | |
| 396 | - | // accounting (success time, attempt counters, failure episode) stays | |
| 397 | - | // local. | |
| 398 | 510 | observed := result.snapshot.Clone() | |
| 399 | 511 | observed.Endpoint = result.endpoint | |
| 400 | 512 | observed.Registration = result.registration | |
| @@ -405,9 +517,17 @@ func (r *Registry) apply(result probeResult) { | |||
| 405 | 517 | observed.ConsecutiveFailures = 0 | |
| 406 | 518 | observed.LastFailure = domain.RemoteFailure{} | |
| 407 | 519 | observed.FailureEpisode = current.FailureEpisode | |
| 408 | - | r.hosts[result.endpoint] = observed | |
| 409 | - | r.publishLocked(true) | |
| 410 | - | return | |
| 520 | + | // A projection the durable store would reject is an invalid response, | |
| 521 | + | // not an observation: publishing it would leave the registry valid but | |
| 522 | + | // permanently unpersisted. The store applies the same rule. | |
| 523 | + | if err := ports.ValidateDurableHostProjection(observed); err != nil { | |
| 524 | + | r.log.Debug("broker: observation is not persistable", "endpoint", result.endpoint, "err", err) | |
| 525 | + | kind, availability, failed = domain.RemoteFailureInvalidResponse, domain.RemoteAvailabilityInvalidResponse, true | |
| 526 | + | } else { | |
| 527 | + | r.hosts[result.endpoint] = observed | |
| 528 | + | r.publishLocked(true) | |
| 529 | + | return | |
| 530 | + | } | |
| 411 | 531 | } | |
| 412 | 532 | // Every non-reachable outcome is a typed failure on the capped retry | |
| 413 | 533 | // cadence: the last success, last-known inventory, and failure episode | |
| @@ -425,7 +545,10 @@ func (r *Registry) apply(result probeResult) { | |||
| 425 | 545 | ||
| 426 | 546 | // observationOutcome classifies a completed observation. It reports | |
| 427 | 547 | // failed=false only for a confirmed reachable result; every other outcome | |
| 428 | - | // carries the typed failure kind and the availability to publish. | |
| 548 | + | // carries the typed failure kind and the availability to publish. The caller | |
| 549 | + | // additionally checks the adopted projection against the durable rules | |
| 550 | + | // (ports.ValidateDurableHostProjection), which cover the session bound and | |
| 551 | + | // catalogue validity for the exact incarnation and success time. | |
| 429 | 552 | func observationOutcome(result probeResult) (domain.RemoteFailureKind, domain.RemoteAvailability, bool) { | |
| 430 | 553 | if result.err != nil { | |
| 431 | 554 | kind := domain.RemoteFailureTransport | |
| @@ -434,13 +557,6 @@ func observationOutcome(result probeResult) (domain.RemoteFailureKind, domain.Re | |||
| 434 | 557 | } | |
| 435 | 558 | return kind, availabilityFor(kind), true | |
| 436 | 559 | } | |
| 437 | - | // A projection carrying more sessions than the publication bound is a | |
| 438 | - | // malformed response: publishing it would produce a snapshot that fails | |
| 439 | - | // BrokerSnapshot.Validate. Classify the whole projection as an invalid | |
| 440 | - | // response so the last-known inventory stays authoritative. | |
| 441 | - | if len(result.snapshot.Sessions) > ports.BrokerMaxSessionsPerHost { | |
| 442 | - | return domain.RemoteFailureInvalidResponse, domain.RemoteAvailabilityInvalidResponse, true | |
| 443 | - | } | |
| 444 | 560 | switch result.snapshot.Availability { | |
| 445 | 561 | case domain.RemoteAvailabilityReachable: | |
| 446 | 562 | return domain.RemoteFailureNone, domain.RemoteAvailabilityReachable, false | |
| @@ -548,12 +664,11 @@ func registrationsUnchanged(current, next map[string]ports.RemoteHostSnapshot) b | |||
| 548 | 664 | } | |
| 549 | 665 | ||
| 550 | 666 | // retireLocked records the tombstone for one removed registration at the | |
| 551 | - | // revision of the publication that carries it. | |
| 667 | + | // revision of the publication that carries it. Mutation paths refuse an | |
| 668 | + | // exhausted series before retiring, so the retirement revision can never wrap | |
| 669 | + | // to zero or fall below the last published one. | |
| 552 | 670 | func (r *Registry) retireLocked(registration domain.RemoteRegistration) { | |
| 553 | 671 | retired := r.revision + 1 | |
| 554 | - | if retired == 0 { | |
| 555 | - | retired = 1 | |
| 556 | - | } | |
| 557 | 672 | r.tombstones[registration.Endpoint] = ports.BrokerHostTombstone{ | |
| 558 | 673 | Endpoint: registration.Endpoint, | |
| 559 | 674 | Registration: registration, | |
| @@ -562,6 +677,21 @@ func (r *Registry) retireLocked(registration domain.RemoteRegistration) { | |||
| 562 | 677 | r.pruneTombstonesLocked() | |
| 563 | 678 | } | |
| 564 | 679 | ||
| 680 | + | // revisionExhaustedLocked reports whether this epoch has no revision left to | |
| 681 | + | // publish, latching the exhausted state on first detection. Callers must hold | |
| 682 | + | // r.mu. | |
| 683 | + | func (r *Registry) revisionExhaustedLocked() bool { | |
| 684 | + | if r.revisionExhausted { | |
| 685 | + | return true | |
| 686 | + | } | |
| 687 | + | if r.revision != ^ports.BrokerRevision(0) { | |
| 688 | + | return false | |
| 689 | + | } | |
| 690 | + | r.revisionExhausted = true | |
| 691 | + | r.log.Error("broker: revision series exhausted; refusing further publication", "epoch", r.epoch) | |
| 692 | + | return true | |
| 693 | + | } | |
| 694 | + | ||
| 565 | 695 | // pruneTombstonesLocked keeps only the newest BrokerMaxTombstones retirements | |
| 566 | 696 | // so an endpoint that churns can never grow the published set without bound. | |
| 567 | 697 | func (r *Registry) pruneTombstonesLocked() { | |
| @@ -604,6 +734,14 @@ func (r *Registry) tombstonesLocked() []ports.BrokerHostTombstone { | |||
| 604 | 734 | } | |
| 605 | 735 | ||
| 606 | 736 | func (r *Registry) publishLocked(persist bool) { | |
| 737 | + | // Fail closed on an exhausted series: a wrapped revision would be zero, and | |
| 738 | + | // a wrapped series would publish revisions below the newest one, either of | |
| 739 | + | // which silently freezes durable persistence. The last valid snapshot stays | |
| 740 | + | // published, nothing is stored, and no subscriber is woken for a publication | |
| 741 | + | // that did not happen. | |
| 742 | + | if r.revisionExhaustedLocked() { | |
| 743 | + | return | |
| 744 | + | } | |
| 607 | 745 | r.revision++ | |
| 608 | 746 | hosts := make([]ports.RemoteHostSnapshot, 0, len(r.hosts)) | |
| 609 | 747 | for _, host := range r.hosts { | |
| @@ -612,7 +750,7 @@ func (r *Registry) publishLocked(persist bool) { | |||
| 612 | 750 | sort.Slice(hosts, func(i, j int) bool { return hosts[i].Endpoint < hosts[j].Endpoint }) | |
| 613 | 751 | snapshot := ports.BrokerSnapshot{Epoch: r.epoch, Revision: r.revision, Hosts: hosts, Removed: r.tombstonesLocked()} | |
| 614 | 752 | r.current.Store(&snapshot) | |
| 615 | - | if persist && r.store != nil { | |
| 753 | + | if persist { | |
| 616 | 754 | // enqueue only stages an immutable publication: rendering the durable | |
| 617 | 755 | // copy and every store call happen on the writer goroutine, never | |
| 618 | 756 | // under the registry lock and never on the probe schedule. | |
| @@ -625,9 +763,13 @@ func (r *Registry) publishLocked(persist bool) { | |||
| 625 | 763 | ||
| 626 | 764 | // durable returns the persistable copy of a publication, rendered by the | |
| 627 | 765 | // writer goroutine. Checking is transient in-flight state and never reaches | |
| 628 | - | // the durable snapshot. | |
| 766 | + | // the durable snapshot. Retirement tombstones are process-local fencing state: | |
| 767 | + | // they retire registrations only for this process lifetime and a restart never | |
| 768 | + | // adopts them, so persisting them would leak stale authority into durable | |
| 769 | + | // state. Removed is therefore cleared before persistence. | |
| 629 | 770 | func durable(snapshot ports.BrokerSnapshot) ports.BrokerSnapshot { | |
| 630 | 771 | out := snapshot.Clone() | |
| 772 | + | out.Removed = nil | |
| 631 | 773 | for i := range out.Hosts { | |
| 632 | 774 | out.Hosts[i].Checking = false | |
| 633 | 775 | } | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "hash/fnv" | 8 | "hash/fnv" | ||
| 9 | "log/slog" | 9 | "log/slog" | ||
| 10 | + | "reflect" | |||
| 10 | "sort" | 11 | "sort" | ||
| 11 | "sync" | 12 | "sync" | ||
| 12 | "sync/atomic" | 13 | "sync/atomic" | ||
| @@ -44,9 +45,20 @@ type Registry struct { | |||||
| 44 | log *slog.Logger | 45 | log *slog.Logger | ||
| 45 | store *snapshotWriter | 46 | store *snapshotWriter | ||
| 46 | 47 | ||||
| 48 | + | // hostStore and authority are the synchronous membership owner. Only this | |||
| 49 | + | // registry may mutate the store while it is live; the caller owns Close. | |||
| 50 | + | hostStore ports.BrokerHostStore | |||
| 51 | + | authority ports.BrokerHosts // guarded by mu; independent of publication revision | |||
| 52 | + | ||||
| 47 | current atomic.Pointer[ports.BrokerSnapshot] | 53 | current atomic.Pointer[ports.BrokerSnapshot] | ||
| 48 | mu sync.Mutex | 54 | mu sync.Mutex | ||
| 49 | revision ports.BrokerRevision | 55 | revision ports.BrokerRevision | ||
| 56 | + | // revisionExhausted records that this epoch consumed its entire revision | |||
| 57 | + | // series. The registry then fails closed: the last valid snapshot stays | |||
| 58 | + | // published, nothing is written to the store, and every mutation path | |||
| 59 | + | // reports ports.ErrBrokerRevisionExhausted instead of wrapping the series to | |||
| 60 | + | // zero or below the newest revision. | |||
| 61 | + | revisionExhausted bool | |||
| 50 | // attempts is the registry-wide monotonic attempt token source. Every | 62 | // attempts is the registry-wide monotonic attempt token source. Every | ||
| 51 | // started observation claims one token, and only the token currently | 63 | // started observation claims one token, and only the token currently | ||
| 52 | // recorded as in flight may complete. Tokens are never reused, so a stale | 64 | // recorded as in flight may complete. Tokens are never reused, so a stale | ||
| @@ -73,8 +85,16 @@ type Registry struct { | |||||
| 73 | } | 85 | } | ||
| 74 | 86 | ||||
| 75 | // NewRegistry restores a validated durable snapshot under a fresh broker epoch. | 87 | // NewRegistry restores a validated durable snapshot under a fresh broker epoch. | ||
| 76 | - | func NewRegistry(epoch ports.BrokerEpoch, store ports.BrokerSnapshotStore, probe ports.BrokerHostProbe, clock ports.Clock, log *slog.Logger) (*Registry, error) { | 88 | + | // The store owns durable membership as well as the advisory snapshot, so |
| 77 | - | if epoch == 0 || probe == nil || clock == nil { | 89 | + | // BrokerHostStore is required explicitly: a store that cannot answer |
| 90 | + | // LoadHosts is not a broker host store, and an optional assertion would let a | |||
| 91 | + | // caller silently run the registry without authoritative membership. A nil | |||
| 92 | + | // store is refused by the dependency guard, including a typed nil hidden | |||
| 93 | + | // behind the interface. Until Run returns, callers must not mutate the store | |||
| 94 | + | // outside Registry.ReplaceHosts. The caller closes the store after the registry | |||
| 95 | + | // has stopped and flushed its advisory writer. | |||
| 96 | + | func NewRegistry(epoch ports.BrokerEpoch, store ports.BrokerHostStore, probe ports.BrokerHostProbe, clock ports.Clock, log *slog.Logger) (*Registry, error) { | |||
| 97 | + | if epoch == 0 || nilDependency(store) || nilDependency(probe) || nilDependency(clock) { | |||
| 78 | return nil, errors.New("broker: invalid registry dependencies") | 98 | return nil, errors.New("broker: invalid registry dependencies") | ||
| 79 | } | 99 | } | ||
| 80 | if log == nil { | 100 | if log == nil { | ||
| @@ -88,20 +108,68 @@ func NewRegistry(epoch ports.BrokerEpoch, store ports.BrokerSnapshotStore, probe | |||||
| 88 | freshFor: defaultFreshFor, retryBase: defaultRetryBase, retryMax: defaultRetryLimit, | 108 | freshFor: defaultFreshFor, retryBase: defaultRetryBase, retryMax: defaultRetryLimit, | ||
| 89 | jitter: jittered, | 109 | jitter: jittered, | ||
| 90 | } | 110 | } | ||
| 91 | - | if store != nil { | 111 | + | r.store = newSnapshotWriter(store, log) |
| 92 | - | r.store = newSnapshotWriter(store, log) | 112 | + | snapshot, err := store.Load() |
| 93 | - | snapshot, err := store.Load() | 113 | + | if err != nil { |
| 94 | - | if err != nil { | 114 | + | return nil, err |
| 95 | - | return nil, err | 115 | + | } |
| 96 | - | } | 116 | + | if err := r.restore(snapshot); err != nil { |
| 97 | - | if err := r.restore(snapshot); err != nil { | 117 | + | return nil, err |
| 98 | - | return nil, err | |||
| 99 | - | } | |||
| 100 | } | 118 | } | ||
| 119 | + | hosts, err := store.LoadHosts() | |||
| 120 | + | if err != nil { | |||
| 121 | + | return nil, err | |||
| 122 | + | } | |||
| 123 | + | if err := r.projectMembership(hosts); err != nil { | |||
| 124 | + | return nil, err | |||
| 125 | + | } | |||
| 126 | + | r.hostStore = store | |||
| 127 | + | r.authority = ports.BrokerHosts{Revision: hosts.Revision, Hosts: append([]ports.BrokerHostRecord(nil), hosts.Hosts...)} | |||
| 101 | r.publishLocked(false) | 128 | r.publishLocked(false) | ||
| 102 | return r, nil | 129 | return r, nil | ||
| 103 | } | 130 | } | ||
| 104 | 131 | ||||
| 132 | + | // nilDependency reports whether a required dependency is absent, including a | |||
| 133 | + | // typed nil pointer stored behind a non-nil interface. Comparing the interface | |||
| 134 | + | // to nil alone would admit a typed nil and defer the failure to the first | |||
| 135 | + | // method call, which panics; the reflection check keeps the dependency guard | |||
| 136 | + | // total for every nil shape. | |||
| 137 | + | func nilDependency(dependency any) bool { | |||
| 138 | + | if dependency == nil { | |||
| 139 | + | return true | |||
| 140 | + | } | |||
| 141 | + | value := reflect.ValueOf(dependency) | |||
| 142 | + | switch value.Kind() { | |||
| 143 | + | case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice: | |||
| 144 | + | return value.IsNil() | |||
| 145 | + | default: | |||
| 146 | + | return false | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | // projectMembership intersects durable membership with the observations a | |||
| 151 | + | // previous process left behind. Durable membership is authoritative: | |||
| 152 | + | // snapshots cannot resurrect removed hosts or omit never-observed | |||
| 153 | + | // registrations. A restored observation is reused only when it belongs to the | |||
| 154 | + | // exact same registration identity; any other registration starts unknown and | |||
| 155 | + | // carries no inventory. | |||
| 156 | + | func (r *Registry) projectMembership(hosts ports.BrokerHosts) error { | |||
| 157 | + | if err := hosts.Validate(); err != nil { | |||
| 158 | + | return err | |||
| 159 | + | } | |||
| 160 | + | next := make(map[string]ports.RemoteHostSnapshot, len(hosts.Hosts)) | |||
| 161 | + | for _, record := range hosts.Hosts { | |||
| 162 | + | registration := record.Registration | |||
| 163 | + | restored, present := r.hosts[registration.Endpoint] | |||
| 164 | + | if !present || !restored.Registration.Equal(registration) { | |||
| 165 | + | restored = ports.RemoteHostSnapshot{Endpoint: registration.Endpoint, Registration: registration, Availability: domain.RemoteAvailabilityUnknown} | |||
| 166 | + | } | |||
| 167 | + | next[registration.Endpoint] = restored | |||
| 168 | + | } | |||
| 169 | + | r.hosts = next | |||
| 170 | + | return nil | |||
| 171 | + | } | |||
| 172 | + | ||||
| 105 | // restore adopts a validated durable snapshot. A zero-epoch snapshot is only | 173 | // restore adopts a validated durable snapshot. A zero-epoch snapshot is only | ||
| 106 | // the empty placeholder a store returns before its first write: any record | 174 | // the empty placeholder a store returns before its first write: any record | ||
| 107 | // without an epoch is rejected instead of silently dropped. A persisted | 175 | // without an epoch is rejected instead of silently dropped. A persisted | ||
| @@ -151,12 +219,53 @@ func (r *Registry) Subscribe() ports.BrokerSubscription { | |||||
| 151 | return s | 219 | return s | ||
| 152 | } | 220 | } | ||
| 153 | 221 | ||||
| 154 | - | // SetHosts atomically replaces configured membership. An unchanged membership | 222 | + | // ReplaceHosts durably replaces membership and explicit policy using the loaded |
| 155 | - | // is a no-op: it neither republishes nor restarts an in-flight observation. | 223 | + | // authority revision. Success means membership is committed, not that advisory |
| 224 | + | // observations have flushed. Store errors (including stale authority) are | |||
| 225 | + | // returned without changing the projection, probe attempts, or local token. | |||
| 226 | + | // A settled run reports ports.ErrBrokerRegistryClosed and an exhausted revision | |||
| 227 | + | // series reports ports.ErrBrokerRevisionExhausted; both refuse the mutation | |||
| 228 | + | // before the store CAS, so a refusal never changes durable authority either. | |||
| 229 | + | // Conflicts are not retried or refreshed implicitly: reconcile by reopening the | |||
| 230 | + | // owner. Callers must supply fresh incarnations when removing and re-adding. | |||
| 231 | + | // Even identical records perform CAS so stale authority cannot report success. | |||
| 232 | + | // The synchronous write holds mu; Snapshot remains lock-free, but scheduling | |||
| 233 | + | // waits for membership durability. Observation writes remain asynchronous. | |||
| 234 | + | func (r *Registry) ReplaceHosts(records []ports.BrokerHostRecord) error { | |||
| 235 | + | records = append([]ports.BrokerHostRecord(nil), records...) | |||
| 236 | + | if err := ports.ValidateBrokerHostRecords(records); err != nil { | |||
| 237 | + | return err | |||
| 238 | + | } | |||
| 239 | + | next := make(map[string]ports.RemoteHostSnapshot, len(records)) | |||
| 240 | + | for _, record := range records { | |||
| 241 | + | reg := record.Registration | |||
| 242 | + | next[reg.Endpoint] = ports.RemoteHostSnapshot{Endpoint: reg.Endpoint, Registration: reg, Availability: domain.RemoteAvailabilityUnknown} | |||
| 243 | + | } | |||
| 244 | + | r.mu.Lock() | |||
| 245 | + | defer r.mu.Unlock() | |||
| 246 | + | if r.running.Load() == runStopped { | |||
| 247 | + | return fmt.Errorf("broker: membership replacement: %w", ports.ErrBrokerRegistryClosed) | |||
| 248 | + | } | |||
| 249 | + | if r.revisionExhaustedLocked() { | |||
| 250 | + | return fmt.Errorf("broker: membership replacement: %w", ports.ErrBrokerRevisionExhausted) | |||
| 251 | + | } | |||
| 252 | + | if err := r.hostStore.ReplaceHosts(r.authority.Revision, records); err != nil { | |||
| 253 | + | return fmt.Errorf("broker: replace hosts: %w", err) | |||
| 254 | + | } | |||
| 255 | + | r.authority = ports.BrokerHosts{Revision: r.authority.Revision + 1, Hosts: records} | |||
| 256 | + | r.setHostsLocked(next) | |||
| 257 | + | return nil | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | // setHosts is projection-only; it is private so callers cannot bypass durable | |||
| 261 | + | // membership and policy. Observation tests use it without claiming durability. | |||
| 262 | + | // An unchanged membership is a no-op: it neither republishes nor restarts an | |||
| 263 | + | // in-flight observation. An exhausted revision series is refused with | |||
| 264 | + | // ports.ErrBrokerRevisionExhausted before the projection changes. | |||
| 156 | // Removed registrations retire bounded tombstones so a stale completion can | 265 | // Removed registrations retire bounded tombstones so a stale completion can | ||
| 157 | // never revive them; replaced or re-added endpoints are fenced by the | 266 | // never revive them; replaced or re-added endpoints are fenced by the | ||
| 158 | // registry attempt token instead. | 267 | // registry attempt token instead. | ||
| 159 | - | func (r *Registry) SetHosts(registrations []domain.RemoteRegistration) error { | 268 | + | func (r *Registry) setHosts(registrations []domain.RemoteRegistration) error { |
| 160 | if len(registrations) > ports.BrokerMaxHosts { | 269 | if len(registrations) > ports.BrokerMaxHosts { | ||
| 161 | return fmt.Errorf("broker: %d host registrations exceed the limit of %d", len(registrations), ports.BrokerMaxHosts) | 270 | return fmt.Errorf("broker: %d host registrations exceed the limit of %d", len(registrations), ports.BrokerMaxHosts) | ||
| 162 | } | 271 | } | ||
| @@ -172,9 +281,19 @@ func (r *Registry) SetHosts(registrations []domain.RemoteRegistration) error { | |||||
| 172 | } | 281 | } | ||
| 173 | 282 | ||||
| 174 | r.mu.Lock() | 283 | r.mu.Lock() | ||
| 284 | + | defer r.mu.Unlock() | |||
| 285 | + | if r.revisionExhaustedLocked() { | |||
| 286 | + | return fmt.Errorf("broker: set hosts: %w", ports.ErrBrokerRevisionExhausted) | |||
| 287 | + | } | |||
| 288 | + | r.setHostsLocked(next) | |||
| 289 | + | return nil | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | // setHostsLocked applies only a projection; the public path commits authority | |||
| 293 | + | // first. The caller holds mu through both steps, fencing probe completions. | |||
| 294 | + | func (r *Registry) setHostsLocked(next map[string]ports.RemoteHostSnapshot) { | |||
| 175 | if registrationsUnchanged(r.hosts, next) { | 295 | if registrationsUnchanged(r.hosts, next) { | ||
| 176 | - | r.mu.Unlock() | 296 | + | return |
| 177 | - | return nil | |||
| 178 | } | 297 | } | ||
| 179 | for endpoint, current := range r.hosts { | 298 | for endpoint, current := range r.hosts { | ||
| 180 | candidate, present := next[endpoint] | 299 | candidate, present := next[endpoint] | ||
| @@ -207,9 +326,7 @@ func (r *Registry) SetHosts(registrations []domain.RemoteRegistration) error { | |||||
| 207 | } | 326 | } | ||
| 208 | r.hosts = next | 327 | r.hosts = next | ||
| 209 | r.publishLocked(true) | 328 | r.publishLocked(true) | ||
| 210 | - | r.mu.Unlock() | |||
| 211 | r.hint() | 329 | r.hint() | ||
| 212 | - | return nil | |||
| 213 | } | 330 | } | ||
| 214 | 331 | ||||
| 215 | // cancelInflightLocked cancels the in-flight attempt for one endpoint and | 332 | // cancelInflightLocked cancels the in-flight attempt for one endpoint and | ||
| @@ -301,11 +418,9 @@ func (r *Registry) settle() { | |||||
| 301 | if cleared { | 418 | if cleared { | ||
| 302 | r.publishLocked(true) | 419 | r.publishLocked(true) | ||
| 303 | } | 420 | } | ||
| 304 | - | r.mu.Unlock() | |||
| 305 | - | if r.store != nil { | |||
| 306 | - | r.store.close() | |||
| 307 | - | } | |||
| 308 | r.running.Store(runStopped) | 421 | r.running.Store(runStopped) | ||
| 422 | + | r.mu.Unlock() | |||
| 423 | + | r.store.close() | |||
| 309 | } | 424 | } | ||
| 310 | 425 | ||||
| 311 | // probeAttempt is one admitted observation: the registry-wide token that | 426 | // probeAttempt is one admitted observation: the registry-wide token that | ||
| @@ -392,9 +507,6 @@ func (r *Registry) apply(result probeResult) { | |||||
| 392 | 507 | ||||
| 393 | kind, availability, failed := observationOutcome(result) | 508 | kind, availability, failed := observationOutcome(result) | ||
| 394 | if !failed { | 509 | if !failed { | ||
| 395 | - | // Reachable: adopt the observed inventory, but registry-owned | |||
| 396 | - | // accounting (success time, attempt counters, failure episode) stays | |||
| 397 | - | // local. | |||
| 398 | observed := result.snapshot.Clone() | 510 | observed := result.snapshot.Clone() | ||
| 399 | observed.Endpoint = result.endpoint | 511 | observed.Endpoint = result.endpoint | ||
| 400 | observed.Registration = result.registration | 512 | observed.Registration = result.registration | ||
| @@ -405,9 +517,17 @@ func (r *Registry) apply(result probeResult) { | |||||
| 405 | observed.ConsecutiveFailures = 0 | 517 | observed.ConsecutiveFailures = 0 | ||
| 406 | observed.LastFailure = domain.RemoteFailure{} | 518 | observed.LastFailure = domain.RemoteFailure{} | ||
| 407 | observed.FailureEpisode = current.FailureEpisode | 519 | observed.FailureEpisode = current.FailureEpisode | ||
| 408 | - | r.hosts[result.endpoint] = observed | 520 | + | // A projection the durable store would reject is an invalid response, |
| 409 | - | r.publishLocked(true) | 521 | + | // not an observation: publishing it would leave the registry valid but |
| 410 | - | return | 522 | + | // permanently unpersisted. The store applies the same rule. |
| 523 | + | if err := ports.ValidateDurableHostProjection(observed); err != nil { | |||
| 524 | + | r.log.Debug("broker: observation is not persistable", "endpoint", result.endpoint, "err", err) | |||
| 525 | + | kind, availability, failed = domain.RemoteFailureInvalidResponse, domain.RemoteAvailabilityInvalidResponse, true | |||
| 526 | + | } else { | |||
| 527 | + | r.hosts[result.endpoint] = observed | |||
| 528 | + | r.publishLocked(true) | |||
| 529 | + | return | |||
| 530 | + | } | |||
| 411 | } | 531 | } | ||
| 412 | // Every non-reachable outcome is a typed failure on the capped retry | 532 | // Every non-reachable outcome is a typed failure on the capped retry | ||
| 413 | // cadence: the last success, last-known inventory, and failure episode | 533 | // cadence: the last success, last-known inventory, and failure episode | ||
| @@ -425,7 +545,10 @@ func (r *Registry) apply(result probeResult) { | |||||
| 425 | 545 | ||||
| 426 | // observationOutcome classifies a completed observation. It reports | 546 | // observationOutcome classifies a completed observation. It reports | ||
| 427 | // failed=false only for a confirmed reachable result; every other outcome | 547 | // failed=false only for a confirmed reachable result; every other outcome | ||
| 428 | - | // carries the typed failure kind and the availability to publish. | 548 | + | // carries the typed failure kind and the availability to publish. The caller |
| 549 | + | // additionally checks the adopted projection against the durable rules | |||
| 550 | + | // (ports.ValidateDurableHostProjection), which cover the session bound and | |||
| 551 | + | // catalogue validity for the exact incarnation and success time. | |||
| 429 | func observationOutcome(result probeResult) (domain.RemoteFailureKind, domain.RemoteAvailability, bool) { | 552 | func observationOutcome(result probeResult) (domain.RemoteFailureKind, domain.RemoteAvailability, bool) { | ||
| 430 | if result.err != nil { | 553 | if result.err != nil { | ||
| 431 | kind := domain.RemoteFailureTransport | 554 | kind := domain.RemoteFailureTransport | ||
| @@ -434,13 +557,6 @@ func observationOutcome(result probeResult) (domain.RemoteFailureKind, domain.Re | |||||
| 434 | } | 557 | } | ||
| 435 | return kind, availabilityFor(kind), true | 558 | return kind, availabilityFor(kind), true | ||
| 436 | } | 559 | } | ||
| 437 | - | // A projection carrying more sessions than the publication bound is a | |||
| 438 | - | // malformed response: publishing it would produce a snapshot that fails | |||
| 439 | - | // BrokerSnapshot.Validate. Classify the whole projection as an invalid | |||
| 440 | - | // response so the last-known inventory stays authoritative. | |||
| 441 | - | if len(result.snapshot.Sessions) > ports.BrokerMaxSessionsPerHost { | |||
| 442 | - | return domain.RemoteFailureInvalidResponse, domain.RemoteAvailabilityInvalidResponse, true | |||
| 443 | - | } | |||
| 444 | switch result.snapshot.Availability { | 560 | switch result.snapshot.Availability { | ||
| 445 | case domain.RemoteAvailabilityReachable: | 561 | case domain.RemoteAvailabilityReachable: | ||
| 446 | return domain.RemoteFailureNone, domain.RemoteAvailabilityReachable, false | 562 | return domain.RemoteFailureNone, domain.RemoteAvailabilityReachable, false | ||
| @@ -548,12 +664,11 @@ func registrationsUnchanged(current, next map[string]ports.RemoteHostSnapshot) b | |||||
| 548 | } | 664 | } | ||
| 549 | 665 | ||||
| 550 | // retireLocked records the tombstone for one removed registration at the | 666 | // retireLocked records the tombstone for one removed registration at the | ||
| 551 | - | // revision of the publication that carries it. | 667 | + | // revision of the publication that carries it. Mutation paths refuse an |
| 668 | + | // exhausted series before retiring, so the retirement revision can never wrap | |||
| 669 | + | // to zero or fall below the last published one. | |||
| 552 | func (r *Registry) retireLocked(registration domain.RemoteRegistration) { | 670 | func (r *Registry) retireLocked(registration domain.RemoteRegistration) { | ||
| 553 | retired := r.revision + 1 | 671 | retired := r.revision + 1 | ||
| 554 | - | if retired == 0 { | |||
| 555 | - | retired = 1 | |||
| 556 | - | } | |||
| 557 | r.tombstones[registration.Endpoint] = ports.BrokerHostTombstone{ | 672 | r.tombstones[registration.Endpoint] = ports.BrokerHostTombstone{ | ||
| 558 | Endpoint: registration.Endpoint, | 673 | Endpoint: registration.Endpoint, | ||
| 559 | Registration: registration, | 674 | Registration: registration, | ||
| @@ -562,6 +677,21 @@ func (r *Registry) retireLocked(registration domain.RemoteRegistration) { | |||||
| 562 | r.pruneTombstonesLocked() | 677 | r.pruneTombstonesLocked() | ||
| 563 | } | 678 | } | ||
| 564 | 679 | ||||
| 680 | + | // revisionExhaustedLocked reports whether this epoch has no revision left to | |||
| 681 | + | // publish, latching the exhausted state on first detection. Callers must hold | |||
| 682 | + | // r.mu. | |||
| 683 | + | func (r *Registry) revisionExhaustedLocked() bool { | |||
| 684 | + | if r.revisionExhausted { | |||
| 685 | + | return true | |||
| 686 | + | } | |||
| 687 | + | if r.revision != ^ports.BrokerRevision(0) { | |||
| 688 | + | return false | |||
| 689 | + | } | |||
| 690 | + | r.revisionExhausted = true | |||
| 691 | + | r.log.Error("broker: revision series exhausted; refusing further publication", "epoch", r.epoch) | |||
| 692 | + | return true | |||
| 693 | + | } | |||
| 694 | + | ||||
| 565 | // pruneTombstonesLocked keeps only the newest BrokerMaxTombstones retirements | 695 | // pruneTombstonesLocked keeps only the newest BrokerMaxTombstones retirements | ||
| 566 | // so an endpoint that churns can never grow the published set without bound. | 696 | // so an endpoint that churns can never grow the published set without bound. | ||
| 567 | func (r *Registry) pruneTombstonesLocked() { | 697 | func (r *Registry) pruneTombstonesLocked() { | ||
| @@ -604,6 +734,14 @@ func (r *Registry) tombstonesLocked() []ports.BrokerHostTombstone { | |||||
| 604 | } | 734 | } | ||
| 605 | 735 | ||||
| 606 | func (r *Registry) publishLocked(persist bool) { | 736 | func (r *Registry) publishLocked(persist bool) { | ||
| 737 | + | // Fail closed on an exhausted series: a wrapped revision would be zero, and | |||
| 738 | + | // a wrapped series would publish revisions below the newest one, either of | |||
| 739 | + | // which silently freezes durable persistence. The last valid snapshot stays | |||
| 740 | + | // published, nothing is stored, and no subscriber is woken for a publication | |||
| 741 | + | // that did not happen. | |||
| 742 | + | if r.revisionExhaustedLocked() { | |||
| 743 | + | return | |||
| 744 | + | } | |||
| 607 | r.revision++ | 745 | r.revision++ | ||
| 608 | hosts := make([]ports.RemoteHostSnapshot, 0, len(r.hosts)) | 746 | hosts := make([]ports.RemoteHostSnapshot, 0, len(r.hosts)) | ||
| 609 | for _, host := range r.hosts { | 747 | for _, host := range r.hosts { | ||
| @@ -612,7 +750,7 @@ func (r *Registry) publishLocked(persist bool) { | |||||
| 612 | sort.Slice(hosts, func(i, j int) bool { return hosts[i].Endpoint < hosts[j].Endpoint }) | 750 | sort.Slice(hosts, func(i, j int) bool { return hosts[i].Endpoint < hosts[j].Endpoint }) | ||
| 613 | snapshot := ports.BrokerSnapshot{Epoch: r.epoch, Revision: r.revision, Hosts: hosts, Removed: r.tombstonesLocked()} | 751 | snapshot := ports.BrokerSnapshot{Epoch: r.epoch, Revision: r.revision, Hosts: hosts, Removed: r.tombstonesLocked()} | ||
| 614 | r.current.Store(&snapshot) | 752 | r.current.Store(&snapshot) | ||
| 615 | - | if persist && r.store != nil { | 753 | + | if persist { |
| 616 | // enqueue only stages an immutable publication: rendering the durable | 754 | // enqueue only stages an immutable publication: rendering the durable | ||
| 617 | // copy and every store call happen on the writer goroutine, never | 755 | // copy and every store call happen on the writer goroutine, never | ||
| 618 | // under the registry lock and never on the probe schedule. | 756 | // under the registry lock and never on the probe schedule. | ||
| @@ -625,9 +763,13 @@ func (r *Registry) publishLocked(persist bool) { | |||||
| 625 | 763 | ||||
| 626 | // durable returns the persistable copy of a publication, rendered by the | 764 | // durable returns the persistable copy of a publication, rendered by the | ||
| 627 | // writer goroutine. Checking is transient in-flight state and never reaches | 765 | // writer goroutine. Checking is transient in-flight state and never reaches | ||
| 628 | - | // the durable snapshot. | 766 | + | // the durable snapshot. Retirement tombstones are process-local fencing state: |
| 767 | + | // they retire registrations only for this process lifetime and a restart never | |||
| 768 | + | // adopts them, so persisting them would leak stale authority into durable | |||
| 769 | + | // state. Removed is therefore cleared before persistence. | |||
| 629 | func durable(snapshot ports.BrokerSnapshot) ports.BrokerSnapshot { | 770 | func durable(snapshot ports.BrokerSnapshot) ports.BrokerSnapshot { | ||
| 630 | out := snapshot.Clone() | 771 | out := snapshot.Clone() | ||
| 772 | + | out.Removed = nil | |||
| 631 | for i := range out.Hosts { | 773 | for i := range out.Hosts { | ||
| 632 | out.Hosts[i].Checking = false | 774 | out.Hosts[i].Checking = false | ||
| 633 | } | 775 | } | ||
A internal/usecase/broker/registry_durable_test.go +165 -0
| @@ -0,0 +1,165 @@ | |||
| 1 | + | package broker | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/adapters/brokerstore" | |
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 12 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // Exercise the real CAS and snapshot validator, not the observation-only fake. | |
| 17 | + | // Each stage closes and reopens both owners, so membership and observations | |
| 18 | + | // must come from disk, including the empty set and a fresh re-added identity. | |
| 19 | + | func TestRegistryDurableReplaceHostsRestart(t *testing.T) { | |
| 20 | + | dir := filepath.Join(t.TempDir(), "broker") | |
| 21 | + | first := ports.BrokerHostRecord{Registration: registration(t, "host", 1), Pinned: true, Policy: poolPolicy()} | |
| 22 | + | readded := first | |
| 23 | + | readded.Registration = registration(t, "host", 2) | |
| 24 | + | readded.Pinned, readded.Learned = false, true | |
| 25 | + | readded.Policy.Trust = "new-trust" | |
| 26 | + | var previous []ports.BrokerHostRecord | |
| 27 | + | for i, stage := range []struct { | |
| 28 | + | name string | |
| 29 | + | records []ports.BrokerHostRecord | |
| 30 | + | }{ | |
| 31 | + | {"add", []ports.BrokerHostRecord{first}}, {"remove", nil}, {"re-add", []ports.BrokerHostRecord{readded}}, {"restart", []ports.BrokerHostRecord{readded}}, | |
| 32 | + | } { | |
| 33 | + | t.Run(stage.name, func(t *testing.T) { | |
| 34 | + | store, err := brokerstore.OpenOffline(brokerstore.Options{Dir: dir}) | |
| 35 | + | require.NoError(t, err) | |
| 36 | + | defer store.Close() | |
| 37 | + | r, err := NewRegistry(ports.BrokerEpoch(i+1), store, newTestProbe(1), newManualClock(time.Unix(100, 0)), nil) | |
| 38 | + | require.NoError(t, err) | |
| 39 | + | defer r.settle() | |
| 40 | + | require.Equal(t, previous, r.authority.Hosts) | |
| 41 | + | require.Len(t, r.Snapshot().Hosts, len(previous)) | |
| 42 | + | if len(previous) > 0 { | |
| 43 | + | require.True(t, r.Snapshot().Hosts[0].InventoryKnown, "advisory observation must also survive restart") | |
| 44 | + | } | |
| 45 | + | require.NoError(t, r.ReplaceHosts(stage.records)) | |
| 46 | + | authority, err := store.LoadHosts() | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | require.Equal(t, r.authority, authority) | |
| 49 | + | require.Equal(t, stage.records, authority.Hosts) | |
| 50 | + | require.Len(t, r.Snapshot().Hosts, len(stage.records)) | |
| 51 | + | if len(stage.records) > 0 { | |
| 52 | + | reg := stage.records[0].Registration | |
| 53 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 54 | + | defer cancel() | |
| 55 | + | r.inflight[reg.Endpoint] = &probeAttempt{token: 1, cancel: cancel} | |
| 56 | + | r.apply(probeResult{endpoint: reg.Endpoint, registration: reg, attempt: 1, at: r.clock.Now(), snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable, InventoryKnown: true}}) | |
| 57 | + | require.ErrorIs(t, ctx.Err(), context.Canceled) | |
| 58 | + | } | |
| 59 | + | r.settle() // flush before inspecting persisted projection | |
| 60 | + | snapshot, err := store.Load() | |
| 61 | + | require.NoError(t, err) | |
| 62 | + | require.Equal(t, durable(r.Snapshot()), snapshot) | |
| 63 | + | require.ErrorIs(t, r.ReplaceHosts(stage.records), ports.ErrBrokerRegistryClosed, "shutdown must not acknowledge mutations") | |
| 64 | + | previous = stage.records | |
| 65 | + | }) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func TestRegistryDurableConflictDoesNotMutateProjection(t *testing.T) { | |
| 70 | + | for _, mode := range []string{"stale revision", "policy without new generation"} { | |
| 71 | + | t.Run(mode, func(t *testing.T) { | |
| 72 | + | store, err := brokerstore.OpenOffline(brokerstore.Options{Dir: filepath.Join(t.TempDir(), "broker")}) | |
| 73 | + | require.NoError(t, err) | |
| 74 | + | defer store.Close() | |
| 75 | + | record := ports.BrokerHostRecord{Registration: registration(t, "host", 1), Pinned: true, Policy: poolPolicy()} | |
| 76 | + | require.NoError(t, store.ReplaceHosts(1, []ports.BrokerHostRecord{record})) | |
| 77 | + | r, err := NewRegistry(1, store, newTestProbe(1), newManualClock(time.Unix(100, 0)), nil) | |
| 78 | + | require.NoError(t, err) | |
| 79 | + | defer r.settle() | |
| 80 | + | before := r.Snapshot() | |
| 81 | + | authority := r.authority | |
| 82 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 83 | + | defer cancel() | |
| 84 | + | r.inflight[record.Registration.Endpoint] = &probeAttempt{token: 1, cancel: cancel} | |
| 85 | + | if mode == "stale revision" { | |
| 86 | + | // Simulate an illicit second mutator. Even identical local records must | |
| 87 | + | // CAS rather than returning success from a projection-only no-op. | |
| 88 | + | require.NoError(t, store.ReplaceHosts(authority.Revision, nil)) | |
| 89 | + | } else { | |
| 90 | + | record.Policy.Trust = "changed-trust" | |
| 91 | + | } | |
| 92 | + | require.ErrorIs(t, r.ReplaceHosts([]ports.BrokerHostRecord{record}), ports.ErrBrokerHostConflict) | |
| 93 | + | require.Equal(t, before, r.Snapshot()) | |
| 94 | + | require.Equal(t, authority, r.authority) | |
| 95 | + | require.NoError(t, ctx.Err(), "failed CAS must not cancel the current observation") | |
| 96 | + | }) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestRegistryReplaceHostsStoreFailureIsSynchronous(t *testing.T) { | |
| 101 | + | store := portsmocks.NewMockBrokerHostStore(t) | |
| 102 | + | store.EXPECT().Load().Return(ports.BrokerSnapshot{}, nil).Once() | |
| 103 | + | store.EXPECT().LoadHosts().Return(ports.BrokerHosts{Revision: 7}, nil).Once() | |
| 104 | + | record := ports.BrokerHostRecord{Registration: registration(t, "host", 1), Pinned: true, Policy: poolPolicy()} | |
| 105 | + | records := []ports.BrokerHostRecord{record} | |
| 106 | + | store.EXPECT().ReplaceHosts(uint64(7), records).Return(context.DeadlineExceeded).Once() | |
| 107 | + | r, err := NewRegistry(1, store, newTestProbe(1), newManualClock(time.Unix(100, 0)), nil) | |
| 108 | + | require.NoError(t, err) | |
| 109 | + | before := r.Snapshot() | |
| 110 | + | require.ErrorIs(t, r.ReplaceHosts(records), context.DeadlineExceeded) | |
| 111 | + | require.Equal(t, before, r.Snapshot()) | |
| 112 | + | require.Equal(t, uint64(7), r.authority.Revision) | |
| 113 | + | require.Error(t, r.ReplaceHosts([]ports.BrokerHostRecord{{Registration: record.Registration}})) | |
| 114 | + | require.Equal(t, before, r.Snapshot()) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // TestRegistryReplaceHostsAfterShutdownIsTyped pins the closed sentinel: once | |
| 118 | + | // the run has settled, a membership mutation is refused with a typed error, so | |
| 119 | + | // a caller can tell shutdown apart from a store failure without matching text. | |
| 120 | + | func TestRegistryReplaceHostsAfterShutdownIsTyped(t *testing.T) { | |
| 121 | + | store := newTestStore() | |
| 122 | + | r := newTestRegistry(t, 1, store, newTestProbe(1), newManualClock(time.Unix(100, 0))) | |
| 123 | + | record := ports.BrokerHostRecord{Registration: registration(t, "host", 1), Pinned: true, Policy: poolPolicy()} | |
| 124 | + | require.NoError(t, r.setHosts([]domain.RemoteRegistration{record.Registration})) | |
| 125 | + | r.settle() | |
| 126 | + | ||
| 127 | + | before := r.Snapshot() | |
| 128 | + | authority := r.authority | |
| 129 | + | err := r.ReplaceHosts([]ports.BrokerHostRecord{record}) | |
| 130 | + | require.ErrorIs(t, err, ports.ErrBrokerRegistryClosed) | |
| 131 | + | require.NotErrorIs(t, err, ports.ErrBrokerRevisionExhausted) | |
| 132 | + | require.Equal(t, before, r.Snapshot()) | |
| 133 | + | require.Equal(t, authority, r.authority) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // Queued pre-CAS observations may be rejected as stale by the real store, but | |
| 137 | + | // the newest post-CAS publication must always flush; churn cannot leave the | |
| 138 | + | // registry permanently publishing membership that the store will reject. | |
| 139 | + | func TestRegistryDurableChurnFlushesLatestMembership(t *testing.T) { | |
| 140 | + | store, err := brokerstore.OpenOffline(brokerstore.Options{Dir: filepath.Join(t.TempDir(), "broker")}) | |
| 141 | + | require.NoError(t, err) | |
| 142 | + | defer store.Close() | |
| 143 | + | r, err := NewRegistry(1, store, newTestProbe(1), newManualClock(time.Unix(100, 0)), nil) | |
| 144 | + | require.NoError(t, err) | |
| 145 | + | defer r.settle() | |
| 146 | + | for i := byte(1); i <= 20; i++ { | |
| 147 | + | record := ports.BrokerHostRecord{Registration: registration(t, "host", i), Pinned: true, Policy: poolPolicy()} | |
| 148 | + | require.NoError(t, r.ReplaceHosts([]ports.BrokerHostRecord{record})) | |
| 149 | + | // A policy change is admitted only with a new registration generation. | |
| 150 | + | record.Registration.Generation++ | |
| 151 | + | record.Policy.Trust = "replacement-trust" | |
| 152 | + | require.NoError(t, r.ReplaceHosts([]ports.BrokerHostRecord{record})) | |
| 153 | + | require.Equal(t, record.Registration, r.Snapshot().Hosts[0].Registration) | |
| 154 | + | if i < 20 { | |
| 155 | + | require.NoError(t, r.ReplaceHosts(nil)) | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | r.settle() | |
| 159 | + | snapshot, err := store.Load() | |
| 160 | + | require.NoError(t, err) | |
| 161 | + | require.Equal(t, durable(r.Snapshot()), snapshot) | |
| 162 | + | authority, err := store.LoadHosts() | |
| 163 | + | require.NoError(t, err) | |
| 164 | + | require.Equal(t, r.authority, authority) | |
| 165 | + | } | |
| @@ -0,0 +1,165 @@ | |||||
| 1 | + | package broker | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/adapters/brokerstore" | |||
| 10 | + | "github.com/bnema/vev/internal/domain" | |||
| 11 | + | "github.com/bnema/vev/internal/ports" | |||
| 12 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // Exercise the real CAS and snapshot validator, not the observation-only fake. | |||
| 17 | + | // Each stage closes and reopens both owners, so membership and observations | |||
| 18 | + | // must come from disk, including the empty set and a fresh re-added identity. | |||
| 19 | + | func TestRegistryDurableReplaceHostsRestart(t *testing.T) { | |||
| 20 | + | dir := filepath.Join(t.TempDir(), "broker") | |||
| 21 | + | first := ports.BrokerHostRecord{Registration: registration(t, "host", 1), Pinned: true, Policy: poolPolicy()} | |||
| 22 | + | readded := first | |||
| 23 | + | readded.Registration = registration(t, "host", 2) | |||
| 24 | + | readded.Pinned, readded.Learned = false, true | |||
| 25 | + | readded.Policy.Trust = "new-trust" | |||
| 26 | + | var previous []ports.BrokerHostRecord | |||
| 27 | + | for i, stage := range []struct { | |||
| 28 | + | name string | |||
| 29 | + | records []ports.BrokerHostRecord | |||
| 30 | + | }{ | |||
| 31 | + | {"add", []ports.BrokerHostRecord{first}}, {"remove", nil}, {"re-add", []ports.BrokerHostRecord{readded}}, {"restart", []ports.BrokerHostRecord{readded}}, | |||
| 32 | + | } { | |||
| 33 | + | t.Run(stage.name, func(t *testing.T) { | |||
| 34 | + | store, err := brokerstore.OpenOffline(brokerstore.Options{Dir: dir}) | |||
| 35 | + | require.NoError(t, err) | |||
| 36 | + | defer store.Close() | |||
| 37 | + | r, err := NewRegistry(ports.BrokerEpoch(i+1), store, newTestProbe(1), newManualClock(time.Unix(100, 0)), nil) | |||
| 38 | + | require.NoError(t, err) | |||
| 39 | + | defer r.settle() | |||
| 40 | + | require.Equal(t, previous, r.authority.Hosts) | |||
| 41 | + | require.Len(t, r.Snapshot().Hosts, len(previous)) | |||
| 42 | + | if len(previous) > 0 { | |||
| 43 | + | require.True(t, r.Snapshot().Hosts[0].InventoryKnown, "advisory observation must also survive restart") | |||
| 44 | + | } | |||
| 45 | + | require.NoError(t, r.ReplaceHosts(stage.records)) | |||
| 46 | + | authority, err := store.LoadHosts() | |||
| 47 | + | require.NoError(t, err) | |||
| 48 | + | require.Equal(t, r.authority, authority) | |||
| 49 | + | require.Equal(t, stage.records, authority.Hosts) | |||
| 50 | + | require.Len(t, r.Snapshot().Hosts, len(stage.records)) | |||
| 51 | + | if len(stage.records) > 0 { | |||
| 52 | + | reg := stage.records[0].Registration | |||
| 53 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 54 | + | defer cancel() | |||
| 55 | + | r.inflight[reg.Endpoint] = &probeAttempt{token: 1, cancel: cancel} | |||
| 56 | + | r.apply(probeResult{endpoint: reg.Endpoint, registration: reg, attempt: 1, at: r.clock.Now(), snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable, InventoryKnown: true}}) | |||
| 57 | + | require.ErrorIs(t, ctx.Err(), context.Canceled) | |||
| 58 | + | } | |||
| 59 | + | r.settle() // flush before inspecting persisted projection | |||
| 60 | + | snapshot, err := store.Load() | |||
| 61 | + | require.NoError(t, err) | |||
| 62 | + | require.Equal(t, durable(r.Snapshot()), snapshot) | |||
| 63 | + | require.ErrorIs(t, r.ReplaceHosts(stage.records), ports.ErrBrokerRegistryClosed, "shutdown must not acknowledge mutations") | |||
| 64 | + | previous = stage.records | |||
| 65 | + | }) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func TestRegistryDurableConflictDoesNotMutateProjection(t *testing.T) { | |||
| 70 | + | for _, mode := range []string{"stale revision", "policy without new generation"} { | |||
| 71 | + | t.Run(mode, func(t *testing.T) { | |||
| 72 | + | store, err := brokerstore.OpenOffline(brokerstore.Options{Dir: filepath.Join(t.TempDir(), "broker")}) | |||
| 73 | + | require.NoError(t, err) | |||
| 74 | + | defer store.Close() | |||
| 75 | + | record := ports.BrokerHostRecord{Registration: registration(t, "host", 1), Pinned: true, Policy: poolPolicy()} | |||
| 76 | + | require.NoError(t, store.ReplaceHosts(1, []ports.BrokerHostRecord{record})) | |||
| 77 | + | r, err := NewRegistry(1, store, newTestProbe(1), newManualClock(time.Unix(100, 0)), nil) | |||
| 78 | + | require.NoError(t, err) | |||
| 79 | + | defer r.settle() | |||
| 80 | + | before := r.Snapshot() | |||
| 81 | + | authority := r.authority | |||
| 82 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 83 | + | defer cancel() | |||
| 84 | + | r.inflight[record.Registration.Endpoint] = &probeAttempt{token: 1, cancel: cancel} | |||
| 85 | + | if mode == "stale revision" { | |||
| 86 | + | // Simulate an illicit second mutator. Even identical local records must | |||
| 87 | + | // CAS rather than returning success from a projection-only no-op. | |||
| 88 | + | require.NoError(t, store.ReplaceHosts(authority.Revision, nil)) | |||
| 89 | + | } else { | |||
| 90 | + | record.Policy.Trust = "changed-trust" | |||
| 91 | + | } | |||
| 92 | + | require.ErrorIs(t, r.ReplaceHosts([]ports.BrokerHostRecord{record}), ports.ErrBrokerHostConflict) | |||
| 93 | + | require.Equal(t, before, r.Snapshot()) | |||
| 94 | + | require.Equal(t, authority, r.authority) | |||
| 95 | + | require.NoError(t, ctx.Err(), "failed CAS must not cancel the current observation") | |||
| 96 | + | }) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func TestRegistryReplaceHostsStoreFailureIsSynchronous(t *testing.T) { | |||
| 101 | + | store := portsmocks.NewMockBrokerHostStore(t) | |||
| 102 | + | store.EXPECT().Load().Return(ports.BrokerSnapshot{}, nil).Once() | |||
| 103 | + | store.EXPECT().LoadHosts().Return(ports.BrokerHosts{Revision: 7}, nil).Once() | |||
| 104 | + | record := ports.BrokerHostRecord{Registration: registration(t, "host", 1), Pinned: true, Policy: poolPolicy()} | |||
| 105 | + | records := []ports.BrokerHostRecord{record} | |||
| 106 | + | store.EXPECT().ReplaceHosts(uint64(7), records).Return(context.DeadlineExceeded).Once() | |||
| 107 | + | r, err := NewRegistry(1, store, newTestProbe(1), newManualClock(time.Unix(100, 0)), nil) | |||
| 108 | + | require.NoError(t, err) | |||
| 109 | + | before := r.Snapshot() | |||
| 110 | + | require.ErrorIs(t, r.ReplaceHosts(records), context.DeadlineExceeded) | |||
| 111 | + | require.Equal(t, before, r.Snapshot()) | |||
| 112 | + | require.Equal(t, uint64(7), r.authority.Revision) | |||
| 113 | + | require.Error(t, r.ReplaceHosts([]ports.BrokerHostRecord{{Registration: record.Registration}})) | |||
| 114 | + | require.Equal(t, before, r.Snapshot()) | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // TestRegistryReplaceHostsAfterShutdownIsTyped pins the closed sentinel: once | |||
| 118 | + | // the run has settled, a membership mutation is refused with a typed error, so | |||
| 119 | + | // a caller can tell shutdown apart from a store failure without matching text. | |||
| 120 | + | func TestRegistryReplaceHostsAfterShutdownIsTyped(t *testing.T) { | |||
| 121 | + | store := newTestStore() | |||
| 122 | + | r := newTestRegistry(t, 1, store, newTestProbe(1), newManualClock(time.Unix(100, 0))) | |||
| 123 | + | record := ports.BrokerHostRecord{Registration: registration(t, "host", 1), Pinned: true, Policy: poolPolicy()} | |||
| 124 | + | require.NoError(t, r.setHosts([]domain.RemoteRegistration{record.Registration})) | |||
| 125 | + | r.settle() | |||
| 126 | + | ||||
| 127 | + | before := r.Snapshot() | |||
| 128 | + | authority := r.authority | |||
| 129 | + | err := r.ReplaceHosts([]ports.BrokerHostRecord{record}) | |||
| 130 | + | require.ErrorIs(t, err, ports.ErrBrokerRegistryClosed) | |||
| 131 | + | require.NotErrorIs(t, err, ports.ErrBrokerRevisionExhausted) | |||
| 132 | + | require.Equal(t, before, r.Snapshot()) | |||
| 133 | + | require.Equal(t, authority, r.authority) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | // Queued pre-CAS observations may be rejected as stale by the real store, but | |||
| 137 | + | // the newest post-CAS publication must always flush; churn cannot leave the | |||
| 138 | + | // registry permanently publishing membership that the store will reject. | |||
| 139 | + | func TestRegistryDurableChurnFlushesLatestMembership(t *testing.T) { | |||
| 140 | + | store, err := brokerstore.OpenOffline(brokerstore.Options{Dir: filepath.Join(t.TempDir(), "broker")}) | |||
| 141 | + | require.NoError(t, err) | |||
| 142 | + | defer store.Close() | |||
| 143 | + | r, err := NewRegistry(1, store, newTestProbe(1), newManualClock(time.Unix(100, 0)), nil) | |||
| 144 | + | require.NoError(t, err) | |||
| 145 | + | defer r.settle() | |||
| 146 | + | for i := byte(1); i <= 20; i++ { | |||
| 147 | + | record := ports.BrokerHostRecord{Registration: registration(t, "host", i), Pinned: true, Policy: poolPolicy()} | |||
| 148 | + | require.NoError(t, r.ReplaceHosts([]ports.BrokerHostRecord{record})) | |||
| 149 | + | // A policy change is admitted only with a new registration generation. | |||
| 150 | + | record.Registration.Generation++ | |||
| 151 | + | record.Policy.Trust = "replacement-trust" | |||
| 152 | + | require.NoError(t, r.ReplaceHosts([]ports.BrokerHostRecord{record})) | |||
| 153 | + | require.Equal(t, record.Registration, r.Snapshot().Hosts[0].Registration) | |||
| 154 | + | if i < 20 { | |||
| 155 | + | require.NoError(t, r.ReplaceHosts(nil)) | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | r.settle() | |||
| 159 | + | snapshot, err := store.Load() | |||
| 160 | + | require.NoError(t, err) | |||
| 161 | + | require.Equal(t, durable(r.Snapshot()), snapshot) | |||
| 162 | + | authority, err := store.LoadHosts() | |||
| 163 | + | require.NoError(t, err) | |||
| 164 | + | require.Equal(t, r.authority, authority) | |||
| 165 | + | } | |||
A internal/usecase/broker/registry_store_test.go +116 -0
| @@ -0,0 +1,116 @@ | |||
| 1 | + | package broker | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestRegistryRestoresAuthoritativeMembership(t *testing.T) { | |
| 15 | + | for _, mode := range []string{"removed", "replaced", "never observed", "matching"} { | |
| 16 | + | t.Run(mode, func(t *testing.T) { | |
| 17 | + | old := registration(t, "host", 1) | |
| 18 | + | current := old | |
| 19 | + | store := portsmocks.NewMockBrokerHostStore(t) | |
| 20 | + | snapshot := ports.BrokerSnapshot{Epoch: 1, Revision: 9, Hosts: []ports.RemoteHostSnapshot{{Endpoint: "host", Registration: old, Availability: domain.RemoteAvailabilityReachable, InventoryKnown: true}}} | |
| 21 | + | hosts := ports.BrokerHosts{Revision: 4} | |
| 22 | + | if mode == "replaced" { | |
| 23 | + | current.Generation++ | |
| 24 | + | } | |
| 25 | + | if mode != "removed" { | |
| 26 | + | hosts.Hosts = []ports.BrokerHostRecord{{Registration: current, Pinned: true, Policy: poolPolicy()}} | |
| 27 | + | } | |
| 28 | + | if mode == "never observed" { | |
| 29 | + | snapshot = ports.BrokerSnapshot{} | |
| 30 | + | } | |
| 31 | + | store.EXPECT().Load().Return(snapshot, nil).Once() | |
| 32 | + | store.EXPECT().LoadHosts().Return(hosts, nil).Once() | |
| 33 | + | r, err := NewRegistry(2, store, newTestProbe(1), newManualClock(time.Unix(100, 0)), nil) | |
| 34 | + | require.NoError(t, err) | |
| 35 | + | got := r.Snapshot() | |
| 36 | + | require.Equal(t, ports.BrokerEpoch(2), got.Epoch) | |
| 37 | + | require.Equal(t, ports.BrokerRevision(1), got.Revision) | |
| 38 | + | if mode == "removed" { | |
| 39 | + | require.Empty(t, got.Hosts) | |
| 40 | + | return | |
| 41 | + | } | |
| 42 | + | require.Len(t, got.Hosts, 1) | |
| 43 | + | require.Equal(t, current, got.Hosts[0].Registration) | |
| 44 | + | require.Equal(t, mode == "matching", got.Hosts[0].InventoryKnown) | |
| 45 | + | }) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // TestRegistryRejectsAuthorityAndLoadFailures covers the negative store seam: | |
| 50 | + | // NewRegistry requires BrokerHostStore explicitly, so a load error, an | |
| 51 | + | // authority error, or authority that fails validation is fatal instead of | |
| 52 | + | // being skipped as an optional capability. | |
| 53 | + | func TestRegistryRejectsAuthorityAndLoadFailures(t *testing.T) { | |
| 54 | + | clock := newManualClock(time.Unix(100, 0)) | |
| 55 | + | reg := registration(t, "host", 1) | |
| 56 | + | loadErr := errors.New("snapshot load failed") | |
| 57 | + | authorityErr := errors.New("authority load failed") | |
| 58 | + | invalidAuthority := map[string]ports.BrokerHosts{ | |
| 59 | + | "missing revision": {Hosts: []ports.BrokerHostRecord{{Registration: reg, Pinned: true, Policy: poolPolicy()}}}, | |
| 60 | + | "duplicate endpoint": {Revision: 1, Hosts: []ports.BrokerHostRecord{ | |
| 61 | + | {Registration: reg, Pinned: true, Policy: poolPolicy()}, | |
| 62 | + | {Registration: reg, Learned: true, Policy: poolPolicy()}, | |
| 63 | + | }}, | |
| 64 | + | "unanchored record": {Revision: 1, Hosts: []ports.BrokerHostRecord{{Registration: reg, Policy: poolPolicy()}}}, | |
| 65 | + | "zero policy": {Revision: 1, Hosts: []ports.BrokerHostRecord{{Registration: reg, Pinned: true}}}, | |
| 66 | + | } | |
| 67 | + | for name, hosts := range invalidAuthority { | |
| 68 | + | t.Run(name, func(t *testing.T) { | |
| 69 | + | store := portsmocks.NewMockBrokerHostStore(t) | |
| 70 | + | store.EXPECT().Load().Return(ports.BrokerSnapshot{}, nil).Once() | |
| 71 | + | store.EXPECT().LoadHosts().Return(hosts, nil).Once() | |
| 72 | + | registry, err := NewRegistry(1, store, newTestProbe(1), clock, nil) | |
| 73 | + | require.Error(t, err) | |
| 74 | + | require.Nil(t, registry) | |
| 75 | + | }) | |
| 76 | + | } | |
| 77 | + | t.Run("snapshot load error", func(t *testing.T) { | |
| 78 | + | store := portsmocks.NewMockBrokerHostStore(t) | |
| 79 | + | store.EXPECT().Load().Return(ports.BrokerSnapshot{}, loadErr).Once() | |
| 80 | + | registry, err := NewRegistry(1, store, newTestProbe(1), clock, nil) | |
| 81 | + | require.ErrorIs(t, err, loadErr) | |
| 82 | + | require.Nil(t, registry) | |
| 83 | + | }) | |
| 84 | + | t.Run("authority load error", func(t *testing.T) { | |
| 85 | + | store := portsmocks.NewMockBrokerHostStore(t) | |
| 86 | + | store.EXPECT().Load().Return(ports.BrokerSnapshot{}, nil).Once() | |
| 87 | + | store.EXPECT().LoadHosts().Return(ports.BrokerHosts{}, authorityErr).Once() | |
| 88 | + | registry, err := NewRegistry(1, store, newTestProbe(1), clock, nil) | |
| 89 | + | require.ErrorIs(t, err, authorityErr) | |
| 90 | + | require.Nil(t, registry) | |
| 91 | + | }) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // TestRegistryRestoreIsReadOnlyAndPublicationLocal pins the revision series: the | |
| 95 | + | // durable authority revision is store-local and never seeds the publication | |
| 96 | + | // series, and construction writes nothing back. | |
| 97 | + | func TestRegistryRestoreIsReadOnlyAndPublicationLocal(t *testing.T) { | |
| 98 | + | clock := newManualClock(time.Unix(100, 0)) | |
| 99 | + | reg := registration(t, "host", 1) | |
| 100 | + | store := portsmocks.NewMockBrokerHostStore(t) | |
| 101 | + | store.EXPECT().Load().Return(ports.BrokerSnapshot{Epoch: 5, Revision: 4096, Hosts: []ports.RemoteHostSnapshot{{ | |
| 102 | + | Endpoint: reg.Endpoint, Registration: reg, Availability: domain.RemoteAvailabilityReachable, InventoryKnown: true, | |
| 103 | + | }}}, nil).Once() | |
| 104 | + | store.EXPECT().LoadHosts().Return(ports.BrokerHosts{Revision: 99, Hosts: []ports.BrokerHostRecord{{Registration: reg, Pinned: true, Policy: poolPolicy()}}}, nil).Once() | |
| 105 | + | ||
| 106 | + | r, err := NewRegistry(6, store, newTestProbe(1), clock, nil) | |
| 107 | + | require.NoError(t, err) | |
| 108 | + | snapshot := r.Snapshot() | |
| 109 | + | require.Equal(t, ports.BrokerRevision(1), snapshot.Revision) | |
| 110 | + | require.Equal(t, ports.BrokerEpoch(6), snapshot.Epoch) | |
| 111 | + | // Restoring reuses the matching observation but never republishes it: a | |
| 112 | + | // fresh process adopts authority and rewrites only when it observes. | |
| 113 | + | require.Len(t, snapshot.Hosts, 1) | |
| 114 | + | require.True(t, snapshot.Hosts[0].InventoryKnown) | |
| 115 | + | require.Equal(t, domain.RemoteAvailabilityReachable, snapshot.Hosts[0].Availability) | |
| 116 | + | } | |
| @@ -0,0 +1,116 @@ | |||||
| 1 | + | package broker | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | "github.com/bnema/vev/internal/ports" | |||
| 10 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestRegistryRestoresAuthoritativeMembership(t *testing.T) { | |||
| 15 | + | for _, mode := range []string{"removed", "replaced", "never observed", "matching"} { | |||
| 16 | + | t.Run(mode, func(t *testing.T) { | |||
| 17 | + | old := registration(t, "host", 1) | |||
| 18 | + | current := old | |||
| 19 | + | store := portsmocks.NewMockBrokerHostStore(t) | |||
| 20 | + | snapshot := ports.BrokerSnapshot{Epoch: 1, Revision: 9, Hosts: []ports.RemoteHostSnapshot{{Endpoint: "host", Registration: old, Availability: domain.RemoteAvailabilityReachable, InventoryKnown: true}}} | |||
| 21 | + | hosts := ports.BrokerHosts{Revision: 4} | |||
| 22 | + | if mode == "replaced" { | |||
| 23 | + | current.Generation++ | |||
| 24 | + | } | |||
| 25 | + | if mode != "removed" { | |||
| 26 | + | hosts.Hosts = []ports.BrokerHostRecord{{Registration: current, Pinned: true, Policy: poolPolicy()}} | |||
| 27 | + | } | |||
| 28 | + | if mode == "never observed" { | |||
| 29 | + | snapshot = ports.BrokerSnapshot{} | |||
| 30 | + | } | |||
| 31 | + | store.EXPECT().Load().Return(snapshot, nil).Once() | |||
| 32 | + | store.EXPECT().LoadHosts().Return(hosts, nil).Once() | |||
| 33 | + | r, err := NewRegistry(2, store, newTestProbe(1), newManualClock(time.Unix(100, 0)), nil) | |||
| 34 | + | require.NoError(t, err) | |||
| 35 | + | got := r.Snapshot() | |||
| 36 | + | require.Equal(t, ports.BrokerEpoch(2), got.Epoch) | |||
| 37 | + | require.Equal(t, ports.BrokerRevision(1), got.Revision) | |||
| 38 | + | if mode == "removed" { | |||
| 39 | + | require.Empty(t, got.Hosts) | |||
| 40 | + | return | |||
| 41 | + | } | |||
| 42 | + | require.Len(t, got.Hosts, 1) | |||
| 43 | + | require.Equal(t, current, got.Hosts[0].Registration) | |||
| 44 | + | require.Equal(t, mode == "matching", got.Hosts[0].InventoryKnown) | |||
| 45 | + | }) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // TestRegistryRejectsAuthorityAndLoadFailures covers the negative store seam: | |||
| 50 | + | // NewRegistry requires BrokerHostStore explicitly, so a load error, an | |||
| 51 | + | // authority error, or authority that fails validation is fatal instead of | |||
| 52 | + | // being skipped as an optional capability. | |||
| 53 | + | func TestRegistryRejectsAuthorityAndLoadFailures(t *testing.T) { | |||
| 54 | + | clock := newManualClock(time.Unix(100, 0)) | |||
| 55 | + | reg := registration(t, "host", 1) | |||
| 56 | + | loadErr := errors.New("snapshot load failed") | |||
| 57 | + | authorityErr := errors.New("authority load failed") | |||
| 58 | + | invalidAuthority := map[string]ports.BrokerHosts{ | |||
| 59 | + | "missing revision": {Hosts: []ports.BrokerHostRecord{{Registration: reg, Pinned: true, Policy: poolPolicy()}}}, | |||
| 60 | + | "duplicate endpoint": {Revision: 1, Hosts: []ports.BrokerHostRecord{ | |||
| 61 | + | {Registration: reg, Pinned: true, Policy: poolPolicy()}, | |||
| 62 | + | {Registration: reg, Learned: true, Policy: poolPolicy()}, | |||
| 63 | + | }}, | |||
| 64 | + | "unanchored record": {Revision: 1, Hosts: []ports.BrokerHostRecord{{Registration: reg, Policy: poolPolicy()}}}, | |||
| 65 | + | "zero policy": {Revision: 1, Hosts: []ports.BrokerHostRecord{{Registration: reg, Pinned: true}}}, | |||
| 66 | + | } | |||
| 67 | + | for name, hosts := range invalidAuthority { | |||
| 68 | + | t.Run(name, func(t *testing.T) { | |||
| 69 | + | store := portsmocks.NewMockBrokerHostStore(t) | |||
| 70 | + | store.EXPECT().Load().Return(ports.BrokerSnapshot{}, nil).Once() | |||
| 71 | + | store.EXPECT().LoadHosts().Return(hosts, nil).Once() | |||
| 72 | + | registry, err := NewRegistry(1, store, newTestProbe(1), clock, nil) | |||
| 73 | + | require.Error(t, err) | |||
| 74 | + | require.Nil(t, registry) | |||
| 75 | + | }) | |||
| 76 | + | } | |||
| 77 | + | t.Run("snapshot load error", func(t *testing.T) { | |||
| 78 | + | store := portsmocks.NewMockBrokerHostStore(t) | |||
| 79 | + | store.EXPECT().Load().Return(ports.BrokerSnapshot{}, loadErr).Once() | |||
| 80 | + | registry, err := NewRegistry(1, store, newTestProbe(1), clock, nil) | |||
| 81 | + | require.ErrorIs(t, err, loadErr) | |||
| 82 | + | require.Nil(t, registry) | |||
| 83 | + | }) | |||
| 84 | + | t.Run("authority load error", func(t *testing.T) { | |||
| 85 | + | store := portsmocks.NewMockBrokerHostStore(t) | |||
| 86 | + | store.EXPECT().Load().Return(ports.BrokerSnapshot{}, nil).Once() | |||
| 87 | + | store.EXPECT().LoadHosts().Return(ports.BrokerHosts{}, authorityErr).Once() | |||
| 88 | + | registry, err := NewRegistry(1, store, newTestProbe(1), clock, nil) | |||
| 89 | + | require.ErrorIs(t, err, authorityErr) | |||
| 90 | + | require.Nil(t, registry) | |||
| 91 | + | }) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // TestRegistryRestoreIsReadOnlyAndPublicationLocal pins the revision series: the | |||
| 95 | + | // durable authority revision is store-local and never seeds the publication | |||
| 96 | + | // series, and construction writes nothing back. | |||
| 97 | + | func TestRegistryRestoreIsReadOnlyAndPublicationLocal(t *testing.T) { | |||
| 98 | + | clock := newManualClock(time.Unix(100, 0)) | |||
| 99 | + | reg := registration(t, "host", 1) | |||
| 100 | + | store := portsmocks.NewMockBrokerHostStore(t) | |||
| 101 | + | store.EXPECT().Load().Return(ports.BrokerSnapshot{Epoch: 5, Revision: 4096, Hosts: []ports.RemoteHostSnapshot{{ | |||
| 102 | + | Endpoint: reg.Endpoint, Registration: reg, Availability: domain.RemoteAvailabilityReachable, InventoryKnown: true, | |||
| 103 | + | }}}, nil).Once() | |||
| 104 | + | store.EXPECT().LoadHosts().Return(ports.BrokerHosts{Revision: 99, Hosts: []ports.BrokerHostRecord{{Registration: reg, Pinned: true, Policy: poolPolicy()}}}, nil).Once() | |||
| 105 | + | ||||
| 106 | + | r, err := NewRegistry(6, store, newTestProbe(1), clock, nil) | |||
| 107 | + | require.NoError(t, err) | |||
| 108 | + | snapshot := r.Snapshot() | |||
| 109 | + | require.Equal(t, ports.BrokerRevision(1), snapshot.Revision) | |||
| 110 | + | require.Equal(t, ports.BrokerEpoch(6), snapshot.Epoch) | |||
| 111 | + | // Restoring reuses the matching observation but never republishes it: a | |||
| 112 | + | // fresh process adopts authority and rewrites only when it observes. | |||
| 113 | + | require.Len(t, snapshot.Hosts, 1) | |||
| 114 | + | require.True(t, snapshot.Hosts[0].InventoryKnown) | |||
| 115 | + | require.Equal(t, domain.RemoteAvailabilityReachable, snapshot.Hosts[0].Availability) | |||
| 116 | + | } | |||
M internal/usecase/broker/registry_test.go +388 -87
| @@ -3,6 +3,7 @@ package broker | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | + | "crypto/sha256" | |
| 6 | 7 | "errors" | |
| 7 | 8 | "fmt" | |
| 8 | 9 | "log/slog" | |
| @@ -165,11 +166,17 @@ func testLogger() (*slog.Logger, *syncBuffer) { | |||
| 165 | 166 | } | |
| 166 | 167 | ||
| 167 | 168 | // testStore is a race-safe in-memory durable store whose writes signal a | |
| 168 | - | // capacity-one channel so tests wait on a write instead of on wall time. | |
| 169 | + | // capacity-one channel so tests wait on a write instead of on wall time. It | |
| 170 | + | // implements the whole BrokerHostStore seam: authority defaults to the hosts of | |
| 171 | + | // the restored snapshot, so a fixture that only names a durable snapshot still | |
| 172 | + | // keeps exercising the rule that observations intersect authoritative | |
| 173 | + | // membership. | |
| 169 | 174 | type testStore struct { | |
| 170 | 175 | mu sync.Mutex | |
| 171 | 176 | loaded ports.BrokerSnapshot | |
| 172 | 177 | loadErr error | |
| 178 | + | hosts ports.BrokerHosts | |
| 179 | + | hostsErr error | |
| 173 | 180 | stored ports.BrokerSnapshot | |
| 174 | 181 | storeErr error | |
| 175 | 182 | writes int | |
| @@ -185,6 +192,41 @@ func (s *testStore) Load() (ports.BrokerSnapshot, error) { | |||
| 185 | 192 | return s.loaded.Clone(), nil | |
| 186 | 193 | } | |
| 187 | 194 | ||
| 195 | + | func (s *testStore) LoadHosts() (ports.BrokerHosts, error) { | |
| 196 | + | s.mu.Lock() | |
| 197 | + | defer s.mu.Unlock() | |
| 198 | + | if s.hostsErr != nil { | |
| 199 | + | return ports.BrokerHosts{}, s.hostsErr | |
| 200 | + | } | |
| 201 | + | if s.hosts.Revision != 0 || len(s.hosts.Hosts) > 0 { | |
| 202 | + | return s.hosts, nil | |
| 203 | + | } | |
| 204 | + | authority := ports.BrokerHosts{Revision: 1} | |
| 205 | + | for _, host := range s.loaded.Hosts { | |
| 206 | + | authority.Hosts = append(authority.Hosts, ports.BrokerHostRecord{Registration: host.Registration, Pinned: true, Policy: poolPolicy()}) | |
| 207 | + | } | |
| 208 | + | return authority, nil | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func (s *testStore) ReplaceHosts(expected uint64, hosts []ports.BrokerHostRecord) error { | |
| 212 | + | s.mu.Lock() | |
| 213 | + | defer s.mu.Unlock() | |
| 214 | + | if s.hostsErr != nil { | |
| 215 | + | return s.hostsErr | |
| 216 | + | } | |
| 217 | + | if s.hosts.Revision == 0 { | |
| 218 | + | s.hosts.Revision = 1 | |
| 219 | + | } | |
| 220 | + | if expected != s.hosts.Revision { | |
| 221 | + | return ports.ErrBrokerHostConflict | |
| 222 | + | } | |
| 223 | + | s.hosts.Hosts = append([]ports.BrokerHostRecord(nil), hosts...) | |
| 224 | + | s.hosts.Revision++ | |
| 225 | + | return nil | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | func (s *testStore) Close() error { return nil } | |
| 229 | + | ||
| 188 | 230 | func (s *testStore) Store(snapshot ports.BrokerSnapshot) error { | |
| 189 | 231 | s.mu.Lock() | |
| 190 | 232 | s.stored = snapshot.Clone() | |
| @@ -251,6 +293,14 @@ func newBlockingStore() *blockingStore { | |||
| 251 | 293 | ||
| 252 | 294 | func (s *blockingStore) Load() (ports.BrokerSnapshot, error) { return ports.BrokerSnapshot{}, nil } | |
| 253 | 295 | ||
| 296 | + | func (s *blockingStore) LoadHosts() (ports.BrokerHosts, error) { | |
| 297 | + | return ports.BrokerHosts{Revision: 1}, nil | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | func (s *blockingStore) ReplaceHosts(uint64, []ports.BrokerHostRecord) error { return nil } | |
| 301 | + | ||
| 302 | + | func (s *blockingStore) Close() error { return nil } | |
| 303 | + | ||
| 254 | 304 | func (s *blockingStore) Store(snapshot ports.BrokerSnapshot) error { | |
| 255 | 305 | s.mu.Lock() | |
| 256 | 306 | s.revisions = append(s.revisions, snapshot.Revision) | |
| @@ -273,6 +323,34 @@ func (s *blockingStore) writtenRevisions() []ports.BrokerRevision { | |||
| 273 | 323 | return append([]ports.BrokerRevision(nil), s.revisions...) | |
| 274 | 324 | } | |
| 275 | 325 | ||
| 326 | + | // blockingCASStore parks the first durable membership CAS until it is released, | |
| 327 | + | // so a test can prove publication and Snapshot never wait behind membership | |
| 328 | + | // durability. Load, LoadHosts, and Store keep the race-safe testStore behavior, | |
| 329 | + | // and every later CAS is admitted without blocking. | |
| 330 | + | type blockingCASStore struct { | |
| 331 | + | testStore | |
| 332 | + | entered chan struct{} | |
| 333 | + | released chan struct{} | |
| 334 | + | block sync.Once | |
| 335 | + | open sync.Once | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | func newBlockingCASStore() *blockingCASStore { | |
| 339 | + | return &blockingCASStore{entered: make(chan struct{}, 1), released: make(chan struct{})} | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | func (s *blockingCASStore) ReplaceHosts(expected uint64, hosts []ports.BrokerHostRecord) error { | |
| 343 | + | s.block.Do(func() { | |
| 344 | + | s.entered <- struct{}{} | |
| 345 | + | <-s.released | |
| 346 | + | }) | |
| 347 | + | return s.testStore.ReplaceHosts(expected, hosts) | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | // releaseCAS lets the parked CAS commit. It is idempotent so a failing test can | |
| 351 | + | // unblock the parked mutator from cleanup. | |
| 352 | + | func (s *blockingCASStore) releaseCAS() { s.open.Do(func() { close(s.released) }) } | |
| 353 | + | ||
| 276 | 354 | // testProbe scripts one observation per received call: the test owns the | |
| 277 | 355 | // completion, so a probe never depends on wall time. | |
| 278 | 356 | type testProbe struct { | |
| @@ -421,22 +499,41 @@ func registration(t *testing.T, endpoint string, marker byte) domain.RemoteRegis | |||
| 421 | 499 | return result | |
| 422 | 500 | } | |
| 423 | 501 | ||
| 424 | - | // hostSessions builds count minimal catalogue sessions for the projection | |
| 502 | + | // hostSession builds one catalogue-valid session. The registry applies the | |
| 503 | + | // same durable projection rules as the store before adopting an observation, so | |
| 504 | + | // a fixture session must carry a non-zero lifecycle identity and a non-nil tab | |
| 505 | + | // list to count as durable. | |
| 506 | + | func hostSession(name string) catalogue.RemoteCatalogSession { | |
| 507 | + | sum := sha256.Sum256([]byte("lifecycle:" + name)) | |
| 508 | + | lifecycle := domain.SessionLifecycleID{} | |
| 509 | + | copy(lifecycle[:], sum[:]) | |
| 510 | + | return catalogue.RemoteCatalogSession{ | |
| 511 | + | LifecycleID: lifecycle, | |
| 512 | + | Name: name, | |
| 513 | + | State: catalogue.RemoteCatalogSessionUp, | |
| 514 | + | Tabs: []catalogue.RemoteCatalogTab{}, | |
| 515 | + | } | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | // hostSessions builds count minimal catalogue-valid sessions for the projection | |
| 425 | 519 | // session bound tests. | |
| 426 | 520 | func hostSessions(count int) []catalogue.RemoteCatalogSession { | |
| 427 | 521 | sessions := make([]catalogue.RemoteCatalogSession, 0, count) | |
| 428 | 522 | for index := 0; index < count; index++ { | |
| 429 | - | sessions = append(sessions, catalogue.RemoteCatalogSession{ | |
| 430 | - | Name: fmt.Sprintf("session-%d", index), | |
| 431 | - | State: catalogue.RemoteCatalogSessionUp, | |
| 432 | - | }) | |
| 523 | + | sessions = append(sessions, hostSession(fmt.Sprintf("session-%d", index))) | |
| 433 | 524 | } | |
| 434 | 525 | return sessions | |
| 435 | 526 | } | |
| 436 | 527 | ||
| 528 | + | // newTestStore returns the minimal authoritative durable store: a valid | |
| 529 | + | // revision series over empty membership. NewRegistry requires a | |
| 530 | + | // BrokerHostStore, so every registry fixture names this store explicitly | |
| 531 | + | // instead of relying on an optional seam. | |
| 532 | + | func newTestStore() *testStore { return &testStore{} } | |
| 533 | + | ||
| 437 | 534 | // newTestRegistry builds a registry with exact scheduling and no logger so a | |
| 438 | 535 | // failure never depends on jitter. | |
| 439 | - | func newTestRegistry(t *testing.T, epoch ports.BrokerEpoch, store ports.BrokerSnapshotStore, probe ports.BrokerHostProbe, clock *manualClock) *Registry { | |
| 536 | + | func newTestRegistry(t *testing.T, epoch ports.BrokerEpoch, store ports.BrokerHostStore, probe ports.BrokerHostProbe, clock *manualClock) *Registry { | |
| 440 | 537 | t.Helper() | |
| 441 | 538 | registry, err := NewRegistry(epoch, store, probe, clock, nil) | |
| 442 | 539 | require.NoError(t, err) | |
| @@ -633,11 +730,19 @@ func TestRegistryRestoreRejectsInvalidInputs(t *testing.T) { | |||
| 633 | 730 | clock := newManualClock(time.Unix(100, 0)) | |
| 634 | 731 | reg := registration(t, "example.test", 1) | |
| 635 | 732 | ||
| 636 | - | _, err := NewRegistry(0, nil, newTestProbe(1), clock, nil) | |
| 733 | + | _, err := NewRegistry(0, newTestStore(), newTestProbe(1), clock, nil) | |
| 734 | + | require.Error(t, err) | |
| 735 | + | _, err = NewRegistry(1, newTestStore(), nil, clock, nil) | |
| 736 | + | require.Error(t, err) | |
| 737 | + | _, err = NewRegistry(1, newTestStore(), newTestProbe(1), nil, nil) | |
| 637 | 738 | require.Error(t, err) | |
| 638 | - | _, err = NewRegistry(1, nil, nil, clock, nil) | |
| 739 | + | ||
| 740 | + | // The store is a required dependency: a literal nil store is rejected, and | |
| 741 | + | // a typed nil behind the interface is rejected without panicking. | |
| 742 | + | _, err = NewRegistry(1, nil, newTestProbe(1), clock, nil) | |
| 639 | 743 | require.Error(t, err) | |
| 640 | - | _, err = NewRegistry(1, nil, newTestProbe(1), nil, nil) | |
| 744 | + | var typedNilStore *testStore | |
| 745 | + | _, err = NewRegistry(1, typedNilStore, newTestProbe(1), clock, nil) | |
| 641 | 746 | require.Error(t, err) | |
| 642 | 747 | ||
| 643 | 748 | loadErr := errors.New("load failed") | |
| @@ -698,40 +803,40 @@ func TestRegistryRestoreRejectsPersistedSnapshotFromItsOwnEpoch(t *testing.T) { | |||
| 698 | 803 | } | |
| 699 | 804 | ||
| 700 | 805 | func TestRegistrySetHostsRejectsInvalidRegistrations(t *testing.T) { | |
| 701 | - | registry := newTestRegistry(t, 1, nil, newTestProbe(1), newManualClock(time.Unix(100, 0))) | |
| 806 | + | registry := newTestRegistry(t, 1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0))) | |
| 702 | 807 | ||
| 703 | 808 | reg := registration(t, "example.test", 1) | |
| 704 | - | require.Error(t, registry.SetHosts([]domain.RemoteRegistration{reg, reg})) | |
| 705 | - | require.Error(t, registry.SetHosts([]domain.RemoteRegistration{{Endpoint: reg.Endpoint}})) | |
| 809 | + | require.Error(t, registry.setHosts([]domain.RemoteRegistration{reg, reg})) | |
| 810 | + | require.Error(t, registry.setHosts([]domain.RemoteRegistration{{Endpoint: reg.Endpoint}})) | |
| 706 | 811 | require.Empty(t, registry.Snapshot().Hosts) | |
| 707 | 812 | } | |
| 708 | 813 | ||
| 709 | 814 | func TestRegistrySetHostsRejectsHostLimitWithoutMutation(t *testing.T) { | |
| 710 | - | registry := newTestRegistry(t, 1, nil, newTestProbe(1), newManualClock(time.Unix(100, 0))) | |
| 815 | + | registry := newTestRegistry(t, 1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0))) | |
| 711 | 816 | reg := registration(t, "example.test", 1) | |
| 712 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | |
| 817 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 713 | 818 | ||
| 714 | 819 | oversized := make([]domain.RemoteRegistration, 0, ports.BrokerMaxHosts+1) | |
| 715 | 820 | for index := 0; index <= ports.BrokerMaxHosts; index++ { | |
| 716 | 821 | oversized = append(oversized, registration(t, fmt.Sprintf("host-%d.test", index), byte(index+1))) | |
| 717 | 822 | } | |
| 718 | 823 | before := registry.Snapshot() | |
| 719 | - | require.Error(t, registry.SetHosts(oversized)) | |
| 824 | + | require.Error(t, registry.setHosts(oversized)) | |
| 720 | 825 | // The rejected call mutates nothing: same membership, same revision. | |
| 721 | 826 | require.Equal(t, before, registry.Snapshot()) | |
| 722 | 827 | ||
| 723 | 828 | // The boundary itself is accepted. | |
| 724 | - | require.NoError(t, registry.SetHosts(oversized[:ports.BrokerMaxHosts])) | |
| 829 | + | require.NoError(t, registry.setHosts(oversized[:ports.BrokerMaxHosts])) | |
| 725 | 830 | require.Len(t, registry.Snapshot().Hosts, ports.BrokerMaxHosts) | |
| 726 | 831 | } | |
| 727 | 832 | ||
| 728 | 833 | func TestRegistryUnchangedSetHostsIsNoOp(t *testing.T) { | |
| 729 | 834 | clock := newManualClock(time.Unix(100, 0)) | |
| 730 | 835 | probe := newTestProbe(2) | |
| 731 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | |
| 836 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 732 | 837 | first := registration(t, "example.test", 1) | |
| 733 | 838 | second := registration(t, "other.test", 2) | |
| 734 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{first, second})) | |
| 839 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first, second})) | |
| 735 | 840 | startRegistry(t, registry) | |
| 736 | 841 | ||
| 737 | 842 | // Every configured host is observed exactly once. | |
| @@ -748,8 +853,8 @@ func TestRegistryUnchangedSetHostsIsNoOp(t *testing.T) { | |||
| 748 | 853 | ||
| 749 | 854 | // Identical membership, including in a different order, never republishes | |
| 750 | 855 | // and never restarts an in-flight observation. | |
| 751 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{second, first})) | |
| 752 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{first, second})) | |
| 856 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{second, first})) | |
| 857 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first, second})) | |
| 753 | 858 | require.Equal(t, before, registry.Snapshot()) | |
| 754 | 859 | requireNoProbeWithin(t, probe, probeGrace) | |
| 755 | 860 | ||
| @@ -765,18 +870,19 @@ func TestRegistryUnchangedSetHostsIsNoOp(t *testing.T) { | |||
| 765 | 870 | func TestRegistrySnapshotIsImmutable(t *testing.T) { | |
| 766 | 871 | clock := newManualClock(time.Unix(100, 0)) | |
| 767 | 872 | probe := newTestProbe(1) | |
| 768 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | |
| 873 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 769 | 874 | reg := registration(t, "example.test", 1) | |
| 770 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | |
| 875 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 771 | 876 | startRegistry(t, registry) | |
| 772 | 877 | ||
| 773 | 878 | call := receiveCall(t, probe) | |
| 774 | 879 | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | |
| 775 | 880 | Availability: domain.RemoteAvailabilityReachable, | |
| 776 | 881 | Sessions: []catalogue.RemoteCatalogSession{{ | |
| 777 | - | Name: "work", | |
| 778 | - | State: catalogue.RemoteCatalogSessionUp, | |
| 779 | - | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Name: "shell"}}, | |
| 882 | + | LifecycleID: domain.SessionLifecycleID{1}, | |
| 883 | + | Name: "work", | |
| 884 | + | State: catalogue.RemoteCatalogSessionUp, | |
| 885 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Name: "shell"}}, | |
| 780 | 886 | }}, | |
| 781 | 887 | }} | |
| 782 | 888 | observed := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| @@ -799,7 +905,7 @@ func TestRegistrySnapshotIsImmutable(t *testing.T) { | |||
| 799 | 905 | next := receiveCall(t, probe) | |
| 800 | 906 | next.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | |
| 801 | 907 | Availability: domain.RemoteAvailabilityReachable, | |
| 802 | - | Sessions: []catalogue.RemoteCatalogSession{{Name: "other", State: catalogue.RemoteCatalogSessionUp}}, | |
| 908 | + | Sessions: []catalogue.RemoteCatalogSession{hostSession("other")}, | |
| 803 | 909 | }} | |
| 804 | 910 | waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 805 | 911 | return !host.Checking && len(host.Sessions) == 1 && host.Sessions[0].Name == "other" | |
| @@ -811,9 +917,9 @@ func TestRegistrySchedulesFreshObservationAfterSuccess(t *testing.T) { | |||
| 811 | 917 | start := time.Unix(100, 0) | |
| 812 | 918 | clock := newManualClock(start) | |
| 813 | 919 | probe := newTestProbe(1) | |
| 814 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | |
| 920 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 815 | 921 | reg := registration(t, "example.test", 1) | |
| 816 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | |
| 922 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 817 | 923 | startRegistry(t, registry) | |
| 818 | 924 | ||
| 819 | 925 | call := receiveCall(t, probe) | |
| @@ -840,9 +946,9 @@ func TestRegistryDemandOverridesFreshSchedule(t *testing.T) { | |||
| 840 | 946 | start := time.Unix(100, 0) | |
| 841 | 947 | clock := newManualClock(start) | |
| 842 | 948 | probe := newTestProbe(1) | |
| 843 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | |
| 949 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 844 | 950 | reg := registration(t, "example.test", 1) | |
| 845 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | |
| 951 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 846 | 952 | startRegistry(t, registry) | |
| 847 | 953 | ||
| 848 | 954 | call := receiveCall(t, probe) | |
| @@ -862,9 +968,9 @@ func TestRegistryCoalescesConcurrentDemand(t *testing.T) { | |||
| 862 | 968 | start := time.Unix(100, 0) | |
| 863 | 969 | clock := newManualClock(start) | |
| 864 | 970 | probe := newTestProbe(2) | |
| 865 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | |
| 971 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 866 | 972 | reg := registration(t, "example.test", 1) | |
| 867 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | |
| 973 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 868 | 974 | startRegistry(t, registry) | |
| 869 | 975 | ||
| 870 | 976 | call := receiveCall(t, probe) | |
| @@ -894,9 +1000,9 @@ func TestRegistryCoalescesConcurrentDemand(t *testing.T) { | |||
| 894 | 1000 | func TestRegistryIgnoresDemandForUnknownEndpoint(t *testing.T) { | |
| 895 | 1001 | clock := newManualClock(time.Unix(100, 0)) | |
| 896 | 1002 | probe := newTestProbe(1) | |
| 897 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | |
| 1003 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 898 | 1004 | reg := registration(t, "example.test", 1) | |
| 899 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | |
| 1005 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 900 | 1006 | registry.RequestProbe("unknown.test") | |
| 901 | 1007 | startRegistry(t, registry) | |
| 902 | 1008 | ||
| @@ -913,14 +1019,14 @@ func TestRegistryPublishesReachabilityAndPersists(t *testing.T) { | |||
| 913 | 1019 | store := &testStore{} | |
| 914 | 1020 | registry := newTestRegistry(t, 3, store, probe, clock) | |
| 915 | 1021 | reg := registration(t, "example.test", 1) | |
| 916 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | |
| 1022 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 917 | 1023 | startRegistry(t, registry) | |
| 918 | 1024 | ||
| 919 | 1025 | call := receiveCall(t, probe) | |
| 920 | 1026 | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | |
| 921 | 1027 | Availability: domain.RemoteAvailabilityReachable, | |
| 922 | 1028 | InventoryKnown: true, | |
| 923 | - | Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: catalogue.RemoteCatalogSessionUp}}, | |
| 1029 | + | Sessions: []catalogue.RemoteCatalogSession{hostSession("work")}, | |
| 924 | 1030 | }} | |
| 925 | 1031 | snapshot := waitSnapshot(t, registry, func(snapshot ports.BrokerSnapshot) bool { | |
| 926 | 1032 | host, ok := snapshot.Find(reg.Endpoint) | |
| @@ -996,9 +1102,9 @@ func TestRegistryNonReachableOutcomesUseTypedFailureBackoff(t *testing.T) { | |||
| 996 | 1102 | start := time.Unix(100, 0) | |
| 997 | 1103 | clock := newManualClock(start) | |
| 998 | 1104 | probe := newTestProbe(1) | |
| 999 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | |
| 1105 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 1000 | 1106 | reg := registration(t, "example.test", 1) | |
| 1001 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | |
| 1107 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1002 | 1108 | startRegistry(t, registry) | |
| 1003 | 1109 | ||
| 1004 | 1110 | call := receiveCall(t, probe) | |
| @@ -1027,9 +1133,9 @@ func TestRegistryClassifiesOversizedProbeProjectionAsInvalidResponse(t *testing. | |||
| 1027 | 1133 | start := time.Unix(100, 0) | |
| 1028 | 1134 | clock := newManualClock(start) | |
| 1029 | 1135 | probe := newTestProbe(1) | |
| 1030 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | |
| 1136 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 1031 | 1137 | reg := registration(t, "example.test", 1) | |
| 1032 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | |
| 1138 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1033 | 1139 | startRegistry(t, registry) | |
| 1034 | 1140 | sub := registry.Subscribe() | |
| 1035 | 1141 | defer sub.Close() | |
| @@ -1038,17 +1144,17 @@ func TestRegistryClassifiesOversizedProbeProjectionAsInvalidResponse(t *testing. | |||
| 1038 | 1144 | call := receiveCall(t, probe) | |
| 1039 | 1145 | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | |
| 1040 | 1146 | Availability: domain.RemoteAvailabilityReachable, | |
| 1041 | - | Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: catalogue.RemoteCatalogSessionUp}}, | |
| 1147 | + | Sessions: []catalogue.RemoteCatalogSession{hostSession("work")}, | |
| 1042 | 1148 | }} | |
| 1043 | 1149 | waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 1044 | 1150 | return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable | |
| 1045 | 1151 | }) | |
| 1046 | 1152 | ||
| 1047 | - | // A projection carrying one session more than the publication bound is a | |
| 1048 | - | // malformed response: it is classified as an invalid response and never | |
| 1049 | - | // reaches publication, because such a snapshot fails Validate. The loop | |
| 1050 | - | // checks every observed publication, so an invalid snapshot can never pass | |
| 1051 | - | // unnoticed. | |
| 1153 | + | // A projection carrying one session more than the durable session bound is | |
| 1154 | + | // a malformed response: it is classified as an invalid response and never | |
| 1155 | + | // reaches publication, because the store enforces the same catalogue rule. | |
| 1156 | + | // The loop checks every observed publication, so an invalid snapshot can | |
| 1157 | + | // never pass unnoticed. | |
| 1052 | 1158 | clock.Advance(defaultFreshFor) | |
| 1053 | 1159 | call = receiveCall(t, probe) | |
| 1054 | 1160 | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | |
| @@ -1084,9 +1190,9 @@ func TestRegistryAcceptsProbeProjectionAtSessionBound(t *testing.T) { | |||
| 1084 | 1190 | start := time.Unix(100, 0) | |
| 1085 | 1191 | clock := newManualClock(start) | |
| 1086 | 1192 | probe := newTestProbe(1) | |
| 1087 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | |
| 1193 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 1088 | 1194 | reg := registration(t, "example.test", 1) | |
| 1089 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | |
| 1195 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1090 | 1196 | startRegistry(t, registry) | |
| 1091 | 1197 | ||
| 1092 | 1198 | // The bound itself is valid: exactly BrokerMaxSessionsPerHost sessions are | |
| @@ -1106,20 +1212,106 @@ func TestRegistryAcceptsProbeProjectionAtSessionBound(t *testing.T) { | |||
| 1106 | 1212 | require.Len(t, snapshot.Hosts[0].Sessions, ports.BrokerMaxSessionsPerHost) | |
| 1107 | 1213 | } | |
| 1108 | 1214 | ||
| 1215 | + | // TestRegistryRejectsNonDurableObservation proves the registry and the durable | |
| 1216 | + | // store agree on what may be persisted: a reachable projection the store would | |
| 1217 | + | // reject is classified as an invalid response, so the last-known inventory | |
| 1218 | + | // stays authoritative instead of a snapshot that can never be written. | |
| 1219 | + | func TestRegistryRejectsNonDurableObservation(t *testing.T) { | |
| 1220 | + | start := time.Unix(100, 0) | |
| 1221 | + | clock := newManualClock(start) | |
| 1222 | + | probe := newTestProbe(1) | |
| 1223 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 1224 | + | reg := registration(t, "example.test", 1) | |
| 1225 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1226 | + | startRegistry(t, registry) | |
| 1227 | + | ||
| 1228 | + | call := receiveCall(t, probe) | |
| 1229 | + | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | |
| 1230 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 1231 | + | Sessions: []catalogue.RemoteCatalogSession{hostSession("work")}, | |
| 1232 | + | }} | |
| 1233 | + | waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 1234 | + | return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable | |
| 1235 | + | }) | |
| 1236 | + | ||
| 1237 | + | // A probe answer that violates the catalogue rules the store enforces (here | |
| 1238 | + | // a zero lifecycle identity and an absent tab list) is a malformed response. | |
| 1239 | + | clock.Advance(defaultFreshFor) | |
| 1240 | + | call = receiveCall(t, probe) | |
| 1241 | + | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | |
| 1242 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 1243 | + | Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: catalogue.RemoteCatalogSessionUp}}, | |
| 1244 | + | }} | |
| 1245 | + | host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 1246 | + | return !host.Checking && host.ConsecutiveFailures == 1 | |
| 1247 | + | }) | |
| 1248 | + | require.Equal(t, domain.RemoteAvailabilityInvalidResponse, host.Availability) | |
| 1249 | + | require.Equal(t, domain.RemoteFailureInvalidResponse, host.LastFailure.Kind) | |
| 1250 | + | require.Len(t, host.Sessions, 1) | |
| 1251 | + | require.Equal(t, "work", host.Sessions[0].Name) | |
| 1252 | + | require.Equal(t, start, host.LastSuccess) | |
| 1253 | + | snapshot := registry.Snapshot() | |
| 1254 | + | require.NoError(t, snapshot.Validate()) | |
| 1255 | + | require.NoError(t, ports.ValidateDurableHostProjection(snapshot.Hosts[0])) | |
| 1256 | + | } | |
| 1257 | + | ||
| 1258 | + | // TestRegistryRevisionOverflowFailsClosed pins the revision series: zero is not | |
| 1259 | + | // a valid revision and revisions never regress, so an exhausted counter fails | |
| 1260 | + | // closed instead of wrapping. The last valid snapshot stays published, the | |
| 1261 | + | // registry is poisoned, and every mutation path reports a typed error rather | |
| 1262 | + | // than emitting revision zero or a revision below the newest one, either of | |
| 1263 | + | // which would silently freeze durable persistence. | |
| 1264 | + | func TestRegistryRevisionOverflowFailsClosed(t *testing.T) { | |
| 1265 | + | logger, logs := testLogger() | |
| 1266 | + | registry, err := NewRegistry(1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0)), logger) | |
| 1267 | + | require.NoError(t, err) | |
| 1268 | + | first := registration(t, "example.test", 1) | |
| 1269 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first})) | |
| 1270 | + | before := registry.Snapshot() | |
| 1271 | + | require.NoError(t, before.Validate()) | |
| 1272 | + | require.Equal(t, ports.BrokerRevision(2), before.Revision) | |
| 1273 | + | authority := registry.authority | |
| 1274 | + | ||
| 1275 | + | // The next publication would overflow the series to zero. | |
| 1276 | + | registry.revision = ^ports.BrokerRevision(0) | |
| 1277 | + | ||
| 1278 | + | // Both mutation paths refuse the change before touching the store or the | |
| 1279 | + | // projection, so neither durable authority nor the publication series moves. | |
| 1280 | + | require.ErrorIs(t, registry.setHosts([]domain.RemoteRegistration{registration(t, "other.test", 2)}), ports.ErrBrokerRevisionExhausted) | |
| 1281 | + | require.ErrorIs(t, registry.ReplaceHosts([]ports.BrokerHostRecord{{Registration: first, Pinned: true, Policy: poolPolicy()}}), ports.ErrBrokerRevisionExhausted) | |
| 1282 | + | require.Equal(t, authority, registry.authority) | |
| 1283 | + | ||
| 1284 | + | // The publication path itself fails closed too: a direct attempt neither | |
| 1285 | + | // wraps the counter nor replaces the last valid snapshot. | |
| 1286 | + | registry.mu.Lock() | |
| 1287 | + | registry.publishLocked(false) | |
| 1288 | + | registry.mu.Unlock() | |
| 1289 | + | ||
| 1290 | + | after := registry.Snapshot() | |
| 1291 | + | require.Equal(t, before, after) | |
| 1292 | + | require.NoError(t, after.Validate()) | |
| 1293 | + | require.Greater(t, after.Revision, ports.BrokerRevision(0)) | |
| 1294 | + | require.Equal(t, ^ports.BrokerRevision(0), registry.revision) | |
| 1295 | + | require.Equal(t, first, after.Hosts[0].Registration) | |
| 1296 | + | // The refusal is observable: an exhausted series logs instead of failing | |
| 1297 | + | // silently behind a frozen snapshot. | |
| 1298 | + | require.Contains(t, logs.String(), "revision series exhausted") | |
| 1299 | + | } | |
| 1300 | + | ||
| 1109 | 1301 | func TestRegistryPreservesLastSuccessAndOwnsFailureEpisode(t *testing.T) { | |
| 1110 | 1302 | start := time.Unix(100, 0) | |
| 1111 | 1303 | clock := newManualClock(start) | |
| 1112 | 1304 | probe := newTestProbe(1) | |
| 1113 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | |
| 1305 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 1114 | 1306 | reg := registration(t, "example.test", 1) | |
| 1115 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | |
| 1307 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1116 | 1308 | startRegistry(t, registry) | |
| 1117 | 1309 | ||
| 1118 | 1310 | // A successful observation owns the success time and opens no episode. | |
| 1119 | 1311 | call := receiveCall(t, probe) | |
| 1120 | 1312 | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | |
| 1121 | 1313 | Availability: domain.RemoteAvailabilityReachable, | |
| 1122 | - | Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: catalogue.RemoteCatalogSessionUp}}, | |
| 1314 | + | Sessions: []catalogue.RemoteCatalogSession{hostSession("work")}, | |
| 1123 | 1315 | }} | |
| 1124 | 1316 | host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 1125 | 1317 | return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable | |
| @@ -1172,9 +1364,9 @@ func TestRegistryBackoffIsExponentialAndCapped(t *testing.T) { | |||
| 1172 | 1364 | start := time.Unix(100, 0) | |
| 1173 | 1365 | clock := newManualClock(start) | |
| 1174 | 1366 | probe := newTestProbe(1) | |
| 1175 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | |
| 1367 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 1176 | 1368 | reg := registration(t, "example.test", 1) | |
| 1177 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | |
| 1369 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1178 | 1370 | startRegistry(t, registry) | |
| 1179 | 1371 | ||
| 1180 | 1372 | probeErr := errors.New("offline") | |
| @@ -1215,10 +1407,10 @@ func TestRegistryJitterAppliesToRetryAndHealthyRefresh(t *testing.T) { | |||
| 1215 | 1407 | start := time.Unix(100, 0) | |
| 1216 | 1408 | clock := newManualClock(start) | |
| 1217 | 1409 | probe := newTestProbe(1) | |
| 1218 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | |
| 1410 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 1219 | 1411 | registry.jitter = func(base time.Duration, _ string, _ uint64) time.Duration { return base / 2 } | |
| 1220 | 1412 | reg := registration(t, "example.test", 1) | |
| 1221 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | |
| 1413 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1222 | 1414 | startRegistry(t, registry) | |
| 1223 | 1415 | ||
| 1224 | 1416 | call := receiveCall(t, probe) | |
| @@ -1267,20 +1459,20 @@ func TestRegistryFencesStaleCompletionAcrossRegistrationChanges(t *testing.T) { | |||
| 1267 | 1459 | replacementAnswer := func(name string) probeAnswer { | |
| 1268 | 1460 | return probeAnswer{snapshot: ports.RemoteHostSnapshot{ | |
| 1269 | 1461 | Availability: domain.RemoteAvailabilityReachable, | |
| 1270 | - | Sessions: []catalogue.RemoteCatalogSession{{Name: name, State: catalogue.RemoteCatalogSessionUp}}, | |
| 1462 | + | Sessions: []catalogue.RemoteCatalogSession{hostSession(name)}, | |
| 1271 | 1463 | }} | |
| 1272 | 1464 | } | |
| 1273 | 1465 | assertFenced := func(t *testing.T, current, replacement domain.RemoteRegistration) { | |
| 1274 | 1466 | t.Helper() | |
| 1275 | 1467 | clock := newManualClock(time.Unix(100, 0)) | |
| 1276 | 1468 | probe := newTestProbe(2) | |
| 1277 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | |
| 1278 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{current})) | |
| 1469 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 1470 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{current})) | |
| 1279 | 1471 | startRegistry(t, registry) | |
| 1280 | 1472 | currentCall := receiveCall(t, probe) | |
| 1281 | 1473 | require.True(t, currentCall.registration.Equal(current)) | |
| 1282 | 1474 | ||
| 1283 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{replacement})) | |
| 1475 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{replacement})) | |
| 1284 | 1476 | replacementCall := receiveCall(t, probe) | |
| 1285 | 1477 | require.True(t, replacementCall.registration.Equal(replacement)) | |
| 1286 | 1478 | ||
| @@ -1322,9 +1514,9 @@ func TestRegistryFencesStaleCompletionAcrossRegistrationChanges(t *testing.T) { | |||
| 1322 | 1514 | func TestRegistryFencesRetiredAttemptsAcrossRemovalAndIdenticalReAdd(t *testing.T) { | |
| 1323 | 1515 | clock := newManualClock(time.Unix(100, 0)) | |
| 1324 | 1516 | probe := newTestProbe(2) | |
| 1325 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | |
| 1517 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 1326 | 1518 | reg := registration(t, "example.test", 1) | |
| 1327 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | |
| 1519 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1328 | 1520 | ||
| 1329 | 1521 | ctx, cancel := context.WithCancel(context.Background()) | |
| 1330 | 1522 | defer cancel() | |
| @@ -1338,7 +1530,7 @@ func TestRegistryFencesRetiredAttemptsAcrossRemovalAndIdenticalReAdd(t *testing. | |||
| 1338 | 1530 | require.NotZero(t, retired) | |
| 1339 | 1531 | ||
| 1340 | 1532 | // Removal retires a bounded tombstone and drops the in-flight token. | |
| 1341 | - | require.NoError(t, registry.SetHosts(nil)) | |
| 1533 | + | require.NoError(t, registry.setHosts(nil)) | |
| 1342 | 1534 | removed := registry.Snapshot() | |
| 1343 | 1535 | require.Empty(t, removed.Hosts) | |
| 1344 | 1536 | require.Len(t, removed.Removed, 1) | |
| @@ -1353,7 +1545,7 @@ func TestRegistryFencesRetiredAttemptsAcrossRemovalAndIdenticalReAdd(t *testing. | |||
| 1353 | 1545 | ||
| 1354 | 1546 | // Re-adding the identical registration supersedes the tombstone, so only | |
| 1355 | 1547 | // the attempt token can fence the retired completion. | |
| 1356 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | |
| 1548 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1357 | 1549 | require.Empty(t, registry.Snapshot().Removed) | |
| 1358 | 1550 | dispatch() | |
| 1359 | 1551 | freshCall := receiveCall(t, probe) | |
| @@ -1388,8 +1580,8 @@ func TestRegistryRetiresInflightAttemptsOnReplacementAndRemoval(t *testing.T) { | |||
| 1388 | 1580 | t.Run(tt.name, func(t *testing.T) { | |
| 1389 | 1581 | clock := newManualClock(time.Unix(100, 0)) | |
| 1390 | 1582 | probe := newTestProbe(2) | |
| 1391 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | |
| 1392 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{current})) | |
| 1583 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 1584 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{current})) | |
| 1393 | 1585 | startRegistry(t, registry) | |
| 1394 | 1586 | ||
| 1395 | 1587 | call := receiveCall(t, probe) | |
| @@ -1399,7 +1591,7 @@ func TestRegistryRetiresInflightAttemptsOnReplacementAndRemoval(t *testing.T) { | |||
| 1399 | 1591 | // attempt: the probe's derived context is cancelled before the | |
| 1400 | 1592 | // replacement can start, so a ctx-honoring probe stops instead of | |
| 1401 | 1593 | // overlapping its successor. | |
| 1402 | - | require.NoError(t, registry.SetHosts(tt.next)) | |
| 1594 | + | require.NoError(t, registry.setHosts(tt.next)) | |
| 1403 | 1595 | select { | |
| 1404 | 1596 | case <-probe.canceled: | |
| 1405 | 1597 | case <-time.After(time.Second): | |
| @@ -1413,11 +1605,11 @@ func TestRegistryChurnKeepsOneActiveProbePerEndpoint(t *testing.T) { | |||
| 1413 | 1605 | clock := newManualClock(time.Unix(100, 0)) | |
| 1414 | 1606 | probe := newChurnProbe() | |
| 1415 | 1607 | defer probe.openRelease() | |
| 1416 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | |
| 1608 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 1417 | 1609 | ||
| 1418 | 1610 | first := registration(t, "example.test", 1) | |
| 1419 | 1611 | second := registration(t, "example.test", 2) | |
| 1420 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{first})) | |
| 1612 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first})) | |
| 1421 | 1613 | ||
| 1422 | 1614 | ctx, cancel := context.WithCancel(context.Background()) | |
| 1423 | 1615 | defer cancel() | |
| @@ -1443,7 +1635,7 @@ func TestRegistryChurnKeepsOneActiveProbePerEndpoint(t *testing.T) { | |||
| 1443 | 1635 | if round%2 == 0 { | |
| 1444 | 1636 | next = second | |
| 1445 | 1637 | } | |
| 1446 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{next})) | |
| 1638 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{next})) | |
| 1447 | 1639 | select { | |
| 1448 | 1640 | case <-probe.startedSig: | |
| 1449 | 1641 | case <-time.After(time.Second): | |
| @@ -1465,7 +1657,7 @@ func TestRegistryChurnKeepsOneActiveProbePerEndpoint(t *testing.T) { | |||
| 1465 | 1657 | if (worker+round)%2 == 0 { | |
| 1466 | 1658 | next = second | |
| 1467 | 1659 | } | |
| 1468 | - | if err := registry.SetHosts([]domain.RemoteRegistration{next}); err != nil { | |
| 1660 | + | if err := registry.setHosts([]domain.RemoteRegistration{next}); err != nil { | |
| 1469 | 1661 | failures[worker] = err | |
| 1470 | 1662 | return | |
| 1471 | 1663 | } | |
| @@ -1503,7 +1695,7 @@ func TestRegistryChurnKeepsOneActiveProbePerEndpoint(t *testing.T) { | |||
| 1503 | 1695 | } | |
| 1504 | 1696 | ||
| 1505 | 1697 | func TestRegistryPublishesBoundedTombstones(t *testing.T) { | |
| 1506 | - | registry := newTestRegistry(t, 1, nil, newTestProbe(1), newManualClock(time.Unix(100, 0))) | |
| 1698 | + | registry := newTestRegistry(t, 1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0))) | |
| 1507 | 1699 | ||
| 1508 | 1700 | // Two full membership rounds with distinct endpoints: without pruning the | |
| 1509 | 1701 | // retired set would double. | |
| @@ -1512,8 +1704,8 @@ func TestRegistryPublishesBoundedTombstones(t *testing.T) { | |||
| 1512 | 1704 | for index := 0; index < ports.BrokerMaxHosts; index++ { | |
| 1513 | 1705 | registrations = append(registrations, registration(t, fmt.Sprintf("round-%d-host-%d.test", round, index), byte(round*ports.BrokerMaxHosts+index+1))) | |
| 1514 | 1706 | } | |
| 1515 | - | require.NoError(t, registry.SetHosts(registrations)) | |
| 1516 | - | require.NoError(t, registry.SetHosts(nil)) | |
| 1707 | + | require.NoError(t, registry.setHosts(registrations)) | |
| 1708 | + | require.NoError(t, registry.setHosts(nil)) | |
| 1517 | 1709 | } | |
| 1518 | 1710 | ||
| 1519 | 1711 | snapshot := registry.Snapshot() | |
| @@ -1538,7 +1730,7 @@ func TestRegistryPersistenceRunsOffLockAndCoalescesWrites(t *testing.T) { | |||
| 1538 | 1730 | defer registry.store.close() | |
| 1539 | 1731 | ||
| 1540 | 1732 | reg := registration(t, "example.test", 1) | |
| 1541 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | |
| 1733 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1542 | 1734 | // The writer is now blocked inside the first durable write. | |
| 1543 | 1735 | first := <-store.entered | |
| 1544 | 1736 | require.Equal(t, ports.BrokerRevision(2), first) | |
| @@ -1551,7 +1743,7 @@ func TestRegistryPersistenceRunsOffLockAndCoalescesWrites(t *testing.T) { | |||
| 1551 | 1743 | defer close(published) | |
| 1552 | 1744 | for generation := domain.RemoteGeneration(2); generation <= 6; generation++ { | |
| 1553 | 1745 | reg.Generation = generation | |
| 1554 | - | if err := registry.SetHosts([]domain.RemoteRegistration{reg}); err != nil { | |
| 1746 | + | if err := registry.setHosts([]domain.RemoteRegistration{reg}); err != nil { | |
| 1555 | 1747 | setErr = err | |
| 1556 | 1748 | return | |
| 1557 | 1749 | } | |
| @@ -1573,6 +1765,83 @@ func TestRegistryPersistenceRunsOffLockAndCoalescesWrites(t *testing.T) { | |||
| 1573 | 1765 | require.Equal(t, []ports.BrokerRevision{2, last}, store.writtenRevisions()) | |
| 1574 | 1766 | } | |
| 1575 | 1767 | ||
| 1768 | + | // TestRegistryReplaceHostsBlockedCASKeepsSnapshotLockFree proves membership | |
| 1769 | + | // durability and publication stay independent: a ReplaceHosts parked inside the | |
| 1770 | + | // durable CAS may hold the registry lock, but Snapshot is served from the atomic | |
| 1771 | + | // publication, no observation may be applied behind it, and the run loop | |
| 1772 | + | // resumes scheduling as soon as the CAS commits. | |
| 1773 | + | func TestRegistryReplaceHostsBlockedCASKeepsSnapshotLockFree(t *testing.T) { | |
| 1774 | + | clock := newManualClock(time.Unix(100, 0)) | |
| 1775 | + | probe := newTestProbe(2) | |
| 1776 | + | store := newBlockingCASStore() | |
| 1777 | + | registry, err := NewRegistry(1, store, probe, clock, nil) | |
| 1778 | + | require.NoError(t, err) | |
| 1779 | + | ||
| 1780 | + | reg := registration(t, "example.test", 1) | |
| 1781 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1782 | + | startRegistry(t, registry) | |
| 1783 | + | call := receiveCall(t, probe) | |
| 1784 | + | require.True(t, waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 1785 | + | return host.Checking | |
| 1786 | + | }).Checking) | |
| 1787 | + | ||
| 1788 | + | // The replacement takes the registry lock and parks inside the durable CAS. | |
| 1789 | + | replaced := make(chan error, 1) | |
| 1790 | + | go func() { | |
| 1791 | + | replaced <- registry.ReplaceHosts([]ports.BrokerHostRecord{{Registration: reg, Pinned: true, Policy: poolPolicy()}}) | |
| 1792 | + | }() | |
| 1793 | + | <-store.entered | |
| 1794 | + | t.Cleanup(store.releaseCAS) | |
| 1795 | + | ||
| 1796 | + | // Snapshot stays lock-free: the atomic publication is readable while the | |
| 1797 | + | // mutator is parked inside the store, and it still carries the pre-CAS | |
| 1798 | + | // projection. | |
| 1799 | + | observed := make(chan ports.BrokerSnapshot, 1) | |
| 1800 | + | go func() { observed <- registry.Snapshot() }() | |
| 1801 | + | select { | |
| 1802 | + | case snapshot := <-observed: | |
| 1803 | + | require.NoError(t, snapshot.Validate()) | |
| 1804 | + | require.Len(t, snapshot.Hosts, 1) | |
| 1805 | + | require.Equal(t, reg, snapshot.Hosts[0].Registration) | |
| 1806 | + | case <-time.After(time.Second): | |
| 1807 | + | t.Fatal("Snapshot blocked behind the durable membership CAS") | |
| 1808 | + | } | |
| 1809 | + | ||
| 1810 | + | // Wake the run loop and complete the observation while the CAS is still | |
| 1811 | + | // parked: applying the result needs the registry lock, so the in-flight | |
| 1812 | + | // projection must stay exactly as published until membership commits. | |
| 1813 | + | registry.hint() | |
| 1814 | + | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}} | |
| 1815 | + | pending, ok := registry.Snapshot().Find(reg.Endpoint) | |
| 1816 | + | require.True(t, ok) | |
| 1817 | + | require.True(t, pending.Checking) | |
| 1818 | + | stable := time.NewTimer(probeGrace) | |
| 1819 | + | defer stable.Stop() | |
| 1820 | + | ||
| 1821 | + | window: | |
| 1822 | + | for { | |
| 1823 | + | current, found := registry.Snapshot().Find(reg.Endpoint) | |
| 1824 | + | require.True(t, found) | |
| 1825 | + | require.Equal(t, pending, current) | |
| 1826 | + | select { | |
| 1827 | + | case <-stable.C: | |
| 1828 | + | break window | |
| 1829 | + | case <-time.After(time.Millisecond): | |
| 1830 | + | } | |
| 1831 | + | } | |
| 1832 | + | ||
| 1833 | + | // Committing membership releases the mutator and unparks the run loop, which | |
| 1834 | + | // applies the observation and resumes scheduling the next one. | |
| 1835 | + | store.releaseCAS() | |
| 1836 | + | require.NoError(t, <-replaced) | |
| 1837 | + | host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |
| 1838 | + | return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable | |
| 1839 | + | }) | |
| 1840 | + | require.Equal(t, clock.Now(), host.LastSuccess) | |
| 1841 | + | clock.Advance(defaultFreshFor) | |
| 1842 | + | receiveCall(t, probe) | |
| 1843 | + | } | |
| 1844 | + | ||
| 1576 | 1845 | func TestRegistryStoreErrorsAreObservableAndDoNotStallPublication(t *testing.T) { | |
| 1577 | 1846 | clock := newManualClock(time.Unix(100, 0)) | |
| 1578 | 1847 | probe := newTestProbe(1) | |
| @@ -1584,14 +1853,14 @@ func TestRegistryStoreErrorsAreObservableAndDoNotStallPublication(t *testing.T) | |||
| 1584 | 1853 | defer registry.store.close() | |
| 1585 | 1854 | ||
| 1586 | 1855 | reg := registration(t, "example.test", 1) | |
| 1587 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | |
| 1856 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1588 | 1857 | waitForLog(t, logs, "durable snapshot write failed") | |
| 1589 | 1858 | require.Contains(t, logs.String(), storeErr.Error()) | |
| 1590 | 1859 | ||
| 1591 | 1860 | // A failed write is not fatal and never blocks the next publication. | |
| 1592 | 1861 | store.setStoreErr(nil) | |
| 1593 | 1862 | reg.Generation++ | |
| 1594 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | |
| 1863 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1595 | 1864 | stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool { | |
| 1596 | 1865 | return snapshot.Revision == registry.Snapshot().Revision | |
| 1597 | 1866 | }) | |
| @@ -1605,7 +1874,7 @@ func TestRegistryPersistedCopyClearsChecking(t *testing.T) { | |||
| 1605 | 1874 | store := &testStore{} | |
| 1606 | 1875 | registry := newTestRegistry(t, 3, store, probe, clock) | |
| 1607 | 1876 | reg := registration(t, "example.test", 1) | |
| 1608 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | |
| 1877 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1609 | 1878 | startRegistry(t, registry) | |
| 1610 | 1879 | ||
| 1611 | 1880 | // The observation is in flight: the published projection carries Checking, | |
| @@ -1627,16 +1896,48 @@ func TestRegistryPersistedCopyClearsChecking(t *testing.T) { | |||
| 1627 | 1896 | require.False(t, settled.Hosts[0].Checking) | |
| 1628 | 1897 | } | |
| 1629 | 1898 | ||
| 1899 | + | // TestRegistryNeverPersistsRetirementTombstones proves retirement tombstones | |
| 1900 | + | // are process-local fencing state: the durable copy carries no Removed, and a | |
| 1901 | + | // fresh registry reopened over that snapshot neither resurrects the retired | |
| 1902 | + | // host nor sees a stale tombstone. | |
| 1903 | + | func TestRegistryNeverPersistsRetirementTombstones(t *testing.T) { | |
| 1904 | + | clock := newManualClock(time.Unix(100, 0)) | |
| 1905 | + | store := &testStore{} | |
| 1906 | + | registry := newTestRegistry(t, 1, store, newTestProbe(1), clock) | |
| 1907 | + | defer registry.store.close() | |
| 1908 | + | ||
| 1909 | + | reg := registration(t, "retired.test", 1) | |
| 1910 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1911 | + | require.NoError(t, registry.setHosts(nil)) | |
| 1912 | + | ||
| 1913 | + | // Removal publishes a bounded tombstone and drops the live host in memory. | |
| 1914 | + | require.Empty(t, registry.Snapshot().Hosts) | |
| 1915 | + | require.Len(t, registry.Snapshot().Removed, 1) | |
| 1916 | + | ||
| 1917 | + | // The durable copy drops the tombstone before the store ever sees it. | |
| 1918 | + | stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool { | |
| 1919 | + | return len(snapshot.Hosts) == 0 && snapshot.Revision == registry.Snapshot().Revision | |
| 1920 | + | }) | |
| 1921 | + | require.Empty(t, stored.Removed) | |
| 1922 | + | ||
| 1923 | + | // Reopening over the durable snapshot adopts empty membership without the | |
| 1924 | + | // stale tombstone. | |
| 1925 | + | reopened, err := NewRegistry(2, &testStore{loaded: stored}, newTestProbe(1), clock, nil) | |
| 1926 | + | require.NoError(t, err) | |
| 1927 | + | require.Empty(t, reopened.Snapshot().Hosts) | |
| 1928 | + | require.Empty(t, reopened.Snapshot().Removed) | |
| 1929 | + | } | |
| 1930 | + | ||
| 1630 | 1931 | func TestRegistryBoundsSlowSubscribersAndStopsOnCancellation(t *testing.T) { | |
| 1631 | 1932 | clock := newManualClock(time.Unix(100, 0)) | |
| 1632 | 1933 | probe := newTestProbe(1) | |
| 1633 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | |
| 1934 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |
| 1634 | 1935 | ||
| 1635 | 1936 | sub := registry.Subscribe() | |
| 1636 | 1937 | reg := registration(t, "example.test", 1) | |
| 1637 | 1938 | for generation := domain.RemoteGeneration(1); generation <= 10; generation++ { | |
| 1638 | 1939 | reg.Generation = generation | |
| 1639 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | |
| 1940 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1640 | 1941 | } | |
| 1641 | 1942 | // A subscriber that never drains is bounded to one pending wake and | |
| 1642 | 1943 | // never blocks publication. | |
| @@ -1648,7 +1949,7 @@ func TestRegistryBoundsSlowSubscribersAndStopsOnCancellation(t *testing.T) { | |||
| 1648 | 1949 | sub.Close() | |
| 1649 | 1950 | sub.Close() | |
| 1650 | 1951 | reg.Generation++ | |
| 1651 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | |
| 1952 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1652 | 1953 | select { | |
| 1653 | 1954 | case <-sub.Changed(): | |
| 1654 | 1955 | t.Fatal("closed subscription received a notification") | |
| @@ -1685,7 +1986,7 @@ func TestRegistryRunIsSingleShotAndCleansUpInFlightState(t *testing.T) { | |||
| 1685 | 1986 | registry, err := NewRegistry(1, store, probe, clock, logger) | |
| 1686 | 1987 | require.NoError(t, err) | |
| 1687 | 1988 | reg := registration(t, "example.test", 1) | |
| 1688 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | |
| 1989 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |
| 1689 | 1990 | ||
| 1690 | 1991 | ctx, cancel := context.WithCancel(context.Background()) | |
| 1691 | 1992 | done := make(chan struct{}) | |
| @@ -1730,7 +2031,7 @@ func TestRegistryRunIsSingleShotAndCleansUpInFlightState(t *testing.T) { | |||
| 1730 | 2031 | require.Equal(t, settled.Revision, stored.Revision) | |
| 1731 | 2032 | require.False(t, stored.Hosts[0].Checking) | |
| 1732 | 2033 | writes := store.writeCount() | |
| 1733 | - | require.NoError(t, registry.SetHosts(nil)) | |
| 2034 | + | require.NoError(t, registry.setHosts(nil)) | |
| 1734 | 2035 | require.Equal(t, writes, store.writeCount()) | |
| 1735 | 2036 | require.Contains(t, logs.String(), "refused after shutdown") | |
| 1736 | 2037 | ||
| @@ -1749,7 +2050,7 @@ func TestRegistryRevisionsIncreaseMonotonicallyWithinEpoch(t *testing.T) { | |||
| 1749 | 2050 | start := time.Unix(100, 0) | |
| 1750 | 2051 | clock := newManualClock(start) | |
| 1751 | 2052 | probe := newTestProbe(2) | |
| 1752 | - | registry := newTestRegistry(t, 42, nil, probe, clock) | |
| 2053 | + | registry := newTestRegistry(t, 42, newTestStore(), probe, clock) | |
| 1753 | 2054 | ||
| 1754 | 2055 | var last ports.BrokerRevision | |
| 1755 | 2056 | observe := func() { | |
| @@ -1763,9 +2064,9 @@ func TestRegistryRevisionsIncreaseMonotonicallyWithinEpoch(t *testing.T) { | |||
| 1763 | 2064 | ||
| 1764 | 2065 | first := registration(t, "a.test", 1) | |
| 1765 | 2066 | second := registration(t, "b.test", 2) | |
| 1766 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{first})) | |
| 2067 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first})) | |
| 1767 | 2068 | observe() | |
| 1768 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{first, second})) | |
| 2069 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first, second})) | |
| 1769 | 2070 | observe() | |
| 1770 | 2071 | ||
| 1771 | 2072 | startRegistry(t, registry) | |
| @@ -3,6 +3,7 @@ package broker | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | + | "crypto/sha256" | |||
| 6 | "errors" | 7 | "errors" | ||
| 7 | "fmt" | 8 | "fmt" | ||
| 8 | "log/slog" | 9 | "log/slog" | ||
| @@ -165,11 +166,17 @@ func testLogger() (*slog.Logger, *syncBuffer) { | |||||
| 165 | } | 166 | } | ||
| 166 | 167 | ||||
| 167 | // testStore is a race-safe in-memory durable store whose writes signal a | 168 | // testStore is a race-safe in-memory durable store whose writes signal a | ||
| 168 | - | // capacity-one channel so tests wait on a write instead of on wall time. | 169 | + | // capacity-one channel so tests wait on a write instead of on wall time. It |
| 170 | + | // implements the whole BrokerHostStore seam: authority defaults to the hosts of | |||
| 171 | + | // the restored snapshot, so a fixture that only names a durable snapshot still | |||
| 172 | + | // keeps exercising the rule that observations intersect authoritative | |||
| 173 | + | // membership. | |||
| 169 | type testStore struct { | 174 | type testStore struct { | ||
| 170 | mu sync.Mutex | 175 | mu sync.Mutex | ||
| 171 | loaded ports.BrokerSnapshot | 176 | loaded ports.BrokerSnapshot | ||
| 172 | loadErr error | 177 | loadErr error | ||
| 178 | + | hosts ports.BrokerHosts | |||
| 179 | + | hostsErr error | |||
| 173 | stored ports.BrokerSnapshot | 180 | stored ports.BrokerSnapshot | ||
| 174 | storeErr error | 181 | storeErr error | ||
| 175 | writes int | 182 | writes int | ||
| @@ -185,6 +192,41 @@ func (s *testStore) Load() (ports.BrokerSnapshot, error) { | |||||
| 185 | return s.loaded.Clone(), nil | 192 | return s.loaded.Clone(), nil | ||
| 186 | } | 193 | } | ||
| 187 | 194 | ||||
| 195 | + | func (s *testStore) LoadHosts() (ports.BrokerHosts, error) { | |||
| 196 | + | s.mu.Lock() | |||
| 197 | + | defer s.mu.Unlock() | |||
| 198 | + | if s.hostsErr != nil { | |||
| 199 | + | return ports.BrokerHosts{}, s.hostsErr | |||
| 200 | + | } | |||
| 201 | + | if s.hosts.Revision != 0 || len(s.hosts.Hosts) > 0 { | |||
| 202 | + | return s.hosts, nil | |||
| 203 | + | } | |||
| 204 | + | authority := ports.BrokerHosts{Revision: 1} | |||
| 205 | + | for _, host := range s.loaded.Hosts { | |||
| 206 | + | authority.Hosts = append(authority.Hosts, ports.BrokerHostRecord{Registration: host.Registration, Pinned: true, Policy: poolPolicy()}) | |||
| 207 | + | } | |||
| 208 | + | return authority, nil | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func (s *testStore) ReplaceHosts(expected uint64, hosts []ports.BrokerHostRecord) error { | |||
| 212 | + | s.mu.Lock() | |||
| 213 | + | defer s.mu.Unlock() | |||
| 214 | + | if s.hostsErr != nil { | |||
| 215 | + | return s.hostsErr | |||
| 216 | + | } | |||
| 217 | + | if s.hosts.Revision == 0 { | |||
| 218 | + | s.hosts.Revision = 1 | |||
| 219 | + | } | |||
| 220 | + | if expected != s.hosts.Revision { | |||
| 221 | + | return ports.ErrBrokerHostConflict | |||
| 222 | + | } | |||
| 223 | + | s.hosts.Hosts = append([]ports.BrokerHostRecord(nil), hosts...) | |||
| 224 | + | s.hosts.Revision++ | |||
| 225 | + | return nil | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | func (s *testStore) Close() error { return nil } | |||
| 229 | + | ||||
| 188 | func (s *testStore) Store(snapshot ports.BrokerSnapshot) error { | 230 | func (s *testStore) Store(snapshot ports.BrokerSnapshot) error { | ||
| 189 | s.mu.Lock() | 231 | s.mu.Lock() | ||
| 190 | s.stored = snapshot.Clone() | 232 | s.stored = snapshot.Clone() | ||
| @@ -251,6 +293,14 @@ func newBlockingStore() *blockingStore { | |||||
| 251 | 293 | ||||
| 252 | func (s *blockingStore) Load() (ports.BrokerSnapshot, error) { return ports.BrokerSnapshot{}, nil } | 294 | func (s *blockingStore) Load() (ports.BrokerSnapshot, error) { return ports.BrokerSnapshot{}, nil } | ||
| 253 | 295 | ||||
| 296 | + | func (s *blockingStore) LoadHosts() (ports.BrokerHosts, error) { | |||
| 297 | + | return ports.BrokerHosts{Revision: 1}, nil | |||
| 298 | + | } | |||
| 299 | + | ||||
| 300 | + | func (s *blockingStore) ReplaceHosts(uint64, []ports.BrokerHostRecord) error { return nil } | |||
| 301 | + | ||||
| 302 | + | func (s *blockingStore) Close() error { return nil } | |||
| 303 | + | ||||
| 254 | func (s *blockingStore) Store(snapshot ports.BrokerSnapshot) error { | 304 | func (s *blockingStore) Store(snapshot ports.BrokerSnapshot) error { | ||
| 255 | s.mu.Lock() | 305 | s.mu.Lock() | ||
| 256 | s.revisions = append(s.revisions, snapshot.Revision) | 306 | s.revisions = append(s.revisions, snapshot.Revision) | ||
| @@ -273,6 +323,34 @@ func (s *blockingStore) writtenRevisions() []ports.BrokerRevision { | |||||
| 273 | return append([]ports.BrokerRevision(nil), s.revisions...) | 323 | return append([]ports.BrokerRevision(nil), s.revisions...) | ||
| 274 | } | 324 | } | ||
| 275 | 325 | ||||
| 326 | + | // blockingCASStore parks the first durable membership CAS until it is released, | |||
| 327 | + | // so a test can prove publication and Snapshot never wait behind membership | |||
| 328 | + | // durability. Load, LoadHosts, and Store keep the race-safe testStore behavior, | |||
| 329 | + | // and every later CAS is admitted without blocking. | |||
| 330 | + | type blockingCASStore struct { | |||
| 331 | + | testStore | |||
| 332 | + | entered chan struct{} | |||
| 333 | + | released chan struct{} | |||
| 334 | + | block sync.Once | |||
| 335 | + | open sync.Once | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | func newBlockingCASStore() *blockingCASStore { | |||
| 339 | + | return &blockingCASStore{entered: make(chan struct{}, 1), released: make(chan struct{})} | |||
| 340 | + | } | |||
| 341 | + | ||||
| 342 | + | func (s *blockingCASStore) ReplaceHosts(expected uint64, hosts []ports.BrokerHostRecord) error { | |||
| 343 | + | s.block.Do(func() { | |||
| 344 | + | s.entered <- struct{}{} | |||
| 345 | + | <-s.released | |||
| 346 | + | }) | |||
| 347 | + | return s.testStore.ReplaceHosts(expected, hosts) | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | // releaseCAS lets the parked CAS commit. It is idempotent so a failing test can | |||
| 351 | + | // unblock the parked mutator from cleanup. | |||
| 352 | + | func (s *blockingCASStore) releaseCAS() { s.open.Do(func() { close(s.released) }) } | |||
| 353 | + | ||||
| 276 | // testProbe scripts one observation per received call: the test owns the | 354 | // testProbe scripts one observation per received call: the test owns the | ||
| 277 | // completion, so a probe never depends on wall time. | 355 | // completion, so a probe never depends on wall time. | ||
| 278 | type testProbe struct { | 356 | type testProbe struct { | ||
| @@ -421,22 +499,41 @@ func registration(t *testing.T, endpoint string, marker byte) domain.RemoteRegis | |||||
| 421 | return result | 499 | return result | ||
| 422 | } | 500 | } | ||
| 423 | 501 | ||||
| 424 | - | // hostSessions builds count minimal catalogue sessions for the projection | 502 | + | // hostSession builds one catalogue-valid session. The registry applies the |
| 503 | + | // same durable projection rules as the store before adopting an observation, so | |||
| 504 | + | // a fixture session must carry a non-zero lifecycle identity and a non-nil tab | |||
| 505 | + | // list to count as durable. | |||
| 506 | + | func hostSession(name string) catalogue.RemoteCatalogSession { | |||
| 507 | + | sum := sha256.Sum256([]byte("lifecycle:" + name)) | |||
| 508 | + | lifecycle := domain.SessionLifecycleID{} | |||
| 509 | + | copy(lifecycle[:], sum[:]) | |||
| 510 | + | return catalogue.RemoteCatalogSession{ | |||
| 511 | + | LifecycleID: lifecycle, | |||
| 512 | + | Name: name, | |||
| 513 | + | State: catalogue.RemoteCatalogSessionUp, | |||
| 514 | + | Tabs: []catalogue.RemoteCatalogTab{}, | |||
| 515 | + | } | |||
| 516 | + | } | |||
| 517 | + | ||||
| 518 | + | // hostSessions builds count minimal catalogue-valid sessions for the projection | |||
| 425 | // session bound tests. | 519 | // session bound tests. | ||
| 426 | func hostSessions(count int) []catalogue.RemoteCatalogSession { | 520 | func hostSessions(count int) []catalogue.RemoteCatalogSession { | ||
| 427 | sessions := make([]catalogue.RemoteCatalogSession, 0, count) | 521 | sessions := make([]catalogue.RemoteCatalogSession, 0, count) | ||
| 428 | for index := 0; index < count; index++ { | 522 | for index := 0; index < count; index++ { | ||
| 429 | - | sessions = append(sessions, catalogue.RemoteCatalogSession{ | 523 | + | sessions = append(sessions, hostSession(fmt.Sprintf("session-%d", index))) |
| 430 | - | Name: fmt.Sprintf("session-%d", index), | |||
| 431 | - | State: catalogue.RemoteCatalogSessionUp, | |||
| 432 | - | }) | |||
| 433 | } | 524 | } | ||
| 434 | return sessions | 525 | return sessions | ||
| 435 | } | 526 | } | ||
| 436 | 527 | ||||
| 528 | + | // newTestStore returns the minimal authoritative durable store: a valid | |||
| 529 | + | // revision series over empty membership. NewRegistry requires a | |||
| 530 | + | // BrokerHostStore, so every registry fixture names this store explicitly | |||
| 531 | + | // instead of relying on an optional seam. | |||
| 532 | + | func newTestStore() *testStore { return &testStore{} } | |||
| 533 | + | ||||
| 437 | // newTestRegistry builds a registry with exact scheduling and no logger so a | 534 | // newTestRegistry builds a registry with exact scheduling and no logger so a | ||
| 438 | // failure never depends on jitter. | 535 | // failure never depends on jitter. | ||
| 439 | - | func newTestRegistry(t *testing.T, epoch ports.BrokerEpoch, store ports.BrokerSnapshotStore, probe ports.BrokerHostProbe, clock *manualClock) *Registry { | 536 | + | func newTestRegistry(t *testing.T, epoch ports.BrokerEpoch, store ports.BrokerHostStore, probe ports.BrokerHostProbe, clock *manualClock) *Registry { |
| 440 | t.Helper() | 537 | t.Helper() | ||
| 441 | registry, err := NewRegistry(epoch, store, probe, clock, nil) | 538 | registry, err := NewRegistry(epoch, store, probe, clock, nil) | ||
| 442 | require.NoError(t, err) | 539 | require.NoError(t, err) | ||
| @@ -633,11 +730,19 @@ func TestRegistryRestoreRejectsInvalidInputs(t *testing.T) { | |||||
| 633 | clock := newManualClock(time.Unix(100, 0)) | 730 | clock := newManualClock(time.Unix(100, 0)) | ||
| 634 | reg := registration(t, "example.test", 1) | 731 | reg := registration(t, "example.test", 1) | ||
| 635 | 732 | ||||
| 636 | - | _, err := NewRegistry(0, nil, newTestProbe(1), clock, nil) | 733 | + | _, err := NewRegistry(0, newTestStore(), newTestProbe(1), clock, nil) |
| 734 | + | require.Error(t, err) | |||
| 735 | + | _, err = NewRegistry(1, newTestStore(), nil, clock, nil) | |||
| 736 | + | require.Error(t, err) | |||
| 737 | + | _, err = NewRegistry(1, newTestStore(), newTestProbe(1), nil, nil) | |||
| 637 | require.Error(t, err) | 738 | require.Error(t, err) | ||
| 638 | - | _, err = NewRegistry(1, nil, nil, clock, nil) | 739 | + | |
| 740 | + | // The store is a required dependency: a literal nil store is rejected, and | |||
| 741 | + | // a typed nil behind the interface is rejected without panicking. | |||
| 742 | + | _, err = NewRegistry(1, nil, newTestProbe(1), clock, nil) | |||
| 639 | require.Error(t, err) | 743 | require.Error(t, err) | ||
| 640 | - | _, err = NewRegistry(1, nil, newTestProbe(1), nil, nil) | 744 | + | var typedNilStore *testStore |
| 745 | + | _, err = NewRegistry(1, typedNilStore, newTestProbe(1), clock, nil) | |||
| 641 | require.Error(t, err) | 746 | require.Error(t, err) | ||
| 642 | 747 | ||||
| 643 | loadErr := errors.New("load failed") | 748 | loadErr := errors.New("load failed") | ||
| @@ -698,40 +803,40 @@ func TestRegistryRestoreRejectsPersistedSnapshotFromItsOwnEpoch(t *testing.T) { | |||||
| 698 | } | 803 | } | ||
| 699 | 804 | ||||
| 700 | func TestRegistrySetHostsRejectsInvalidRegistrations(t *testing.T) { | 805 | func TestRegistrySetHostsRejectsInvalidRegistrations(t *testing.T) { | ||
| 701 | - | registry := newTestRegistry(t, 1, nil, newTestProbe(1), newManualClock(time.Unix(100, 0))) | 806 | + | registry := newTestRegistry(t, 1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0))) |
| 702 | 807 | ||||
| 703 | reg := registration(t, "example.test", 1) | 808 | reg := registration(t, "example.test", 1) | ||
| 704 | - | require.Error(t, registry.SetHosts([]domain.RemoteRegistration{reg, reg})) | 809 | + | require.Error(t, registry.setHosts([]domain.RemoteRegistration{reg, reg})) |
| 705 | - | require.Error(t, registry.SetHosts([]domain.RemoteRegistration{{Endpoint: reg.Endpoint}})) | 810 | + | require.Error(t, registry.setHosts([]domain.RemoteRegistration{{Endpoint: reg.Endpoint}})) |
| 706 | require.Empty(t, registry.Snapshot().Hosts) | 811 | require.Empty(t, registry.Snapshot().Hosts) | ||
| 707 | } | 812 | } | ||
| 708 | 813 | ||||
| 709 | func TestRegistrySetHostsRejectsHostLimitWithoutMutation(t *testing.T) { | 814 | func TestRegistrySetHostsRejectsHostLimitWithoutMutation(t *testing.T) { | ||
| 710 | - | registry := newTestRegistry(t, 1, nil, newTestProbe(1), newManualClock(time.Unix(100, 0))) | 815 | + | registry := newTestRegistry(t, 1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0))) |
| 711 | reg := registration(t, "example.test", 1) | 816 | reg := registration(t, "example.test", 1) | ||
| 712 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | 817 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) |
| 713 | 818 | ||||
| 714 | oversized := make([]domain.RemoteRegistration, 0, ports.BrokerMaxHosts+1) | 819 | oversized := make([]domain.RemoteRegistration, 0, ports.BrokerMaxHosts+1) | ||
| 715 | for index := 0; index <= ports.BrokerMaxHosts; index++ { | 820 | for index := 0; index <= ports.BrokerMaxHosts; index++ { | ||
| 716 | oversized = append(oversized, registration(t, fmt.Sprintf("host-%d.test", index), byte(index+1))) | 821 | oversized = append(oversized, registration(t, fmt.Sprintf("host-%d.test", index), byte(index+1))) | ||
| 717 | } | 822 | } | ||
| 718 | before := registry.Snapshot() | 823 | before := registry.Snapshot() | ||
| 719 | - | require.Error(t, registry.SetHosts(oversized)) | 824 | + | require.Error(t, registry.setHosts(oversized)) |
| 720 | // The rejected call mutates nothing: same membership, same revision. | 825 | // The rejected call mutates nothing: same membership, same revision. | ||
| 721 | require.Equal(t, before, registry.Snapshot()) | 826 | require.Equal(t, before, registry.Snapshot()) | ||
| 722 | 827 | ||||
| 723 | // The boundary itself is accepted. | 828 | // The boundary itself is accepted. | ||
| 724 | - | require.NoError(t, registry.SetHosts(oversized[:ports.BrokerMaxHosts])) | 829 | + | require.NoError(t, registry.setHosts(oversized[:ports.BrokerMaxHosts])) |
| 725 | require.Len(t, registry.Snapshot().Hosts, ports.BrokerMaxHosts) | 830 | require.Len(t, registry.Snapshot().Hosts, ports.BrokerMaxHosts) | ||
| 726 | } | 831 | } | ||
| 727 | 832 | ||||
| 728 | func TestRegistryUnchangedSetHostsIsNoOp(t *testing.T) { | 833 | func TestRegistryUnchangedSetHostsIsNoOp(t *testing.T) { | ||
| 729 | clock := newManualClock(time.Unix(100, 0)) | 834 | clock := newManualClock(time.Unix(100, 0)) | ||
| 730 | probe := newTestProbe(2) | 835 | probe := newTestProbe(2) | ||
| 731 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | 836 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) |
| 732 | first := registration(t, "example.test", 1) | 837 | first := registration(t, "example.test", 1) | ||
| 733 | second := registration(t, "other.test", 2) | 838 | second := registration(t, "other.test", 2) | ||
| 734 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{first, second})) | 839 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first, second})) |
| 735 | startRegistry(t, registry) | 840 | startRegistry(t, registry) | ||
| 736 | 841 | ||||
| 737 | // Every configured host is observed exactly once. | 842 | // Every configured host is observed exactly once. | ||
| @@ -748,8 +853,8 @@ func TestRegistryUnchangedSetHostsIsNoOp(t *testing.T) { | |||||
| 748 | 853 | ||||
| 749 | // Identical membership, including in a different order, never republishes | 854 | // Identical membership, including in a different order, never republishes | ||
| 750 | // and never restarts an in-flight observation. | 855 | // and never restarts an in-flight observation. | ||
| 751 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{second, first})) | 856 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{second, first})) |
| 752 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{first, second})) | 857 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first, second})) |
| 753 | require.Equal(t, before, registry.Snapshot()) | 858 | require.Equal(t, before, registry.Snapshot()) | ||
| 754 | requireNoProbeWithin(t, probe, probeGrace) | 859 | requireNoProbeWithin(t, probe, probeGrace) | ||
| 755 | 860 | ||||
| @@ -765,18 +870,19 @@ func TestRegistryUnchangedSetHostsIsNoOp(t *testing.T) { | |||||
| 765 | func TestRegistrySnapshotIsImmutable(t *testing.T) { | 870 | func TestRegistrySnapshotIsImmutable(t *testing.T) { | ||
| 766 | clock := newManualClock(time.Unix(100, 0)) | 871 | clock := newManualClock(time.Unix(100, 0)) | ||
| 767 | probe := newTestProbe(1) | 872 | probe := newTestProbe(1) | ||
| 768 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | 873 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) |
| 769 | reg := registration(t, "example.test", 1) | 874 | reg := registration(t, "example.test", 1) | ||
| 770 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | 875 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) |
| 771 | startRegistry(t, registry) | 876 | startRegistry(t, registry) | ||
| 772 | 877 | ||||
| 773 | call := receiveCall(t, probe) | 878 | call := receiveCall(t, probe) | ||
| 774 | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | 879 | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | ||
| 775 | Availability: domain.RemoteAvailabilityReachable, | 880 | Availability: domain.RemoteAvailabilityReachable, | ||
| 776 | Sessions: []catalogue.RemoteCatalogSession{{ | 881 | Sessions: []catalogue.RemoteCatalogSession{{ | ||
| 777 | - | Name: "work", | 882 | + | LifecycleID: domain.SessionLifecycleID{1}, |
| 778 | - | State: catalogue.RemoteCatalogSessionUp, | 883 | + | Name: "work", |
| 779 | - | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Name: "shell"}}, | 884 | + | State: catalogue.RemoteCatalogSessionUp, |
| 885 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Name: "shell"}}, | |||
| 780 | }}, | 886 | }}, | ||
| 781 | }} | 887 | }} | ||
| 782 | observed := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | 888 | observed := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | ||
| @@ -799,7 +905,7 @@ func TestRegistrySnapshotIsImmutable(t *testing.T) { | |||||
| 799 | next := receiveCall(t, probe) | 905 | next := receiveCall(t, probe) | ||
| 800 | next.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | 906 | next.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | ||
| 801 | Availability: domain.RemoteAvailabilityReachable, | 907 | Availability: domain.RemoteAvailabilityReachable, | ||
| 802 | - | Sessions: []catalogue.RemoteCatalogSession{{Name: "other", State: catalogue.RemoteCatalogSessionUp}}, | 908 | + | Sessions: []catalogue.RemoteCatalogSession{hostSession("other")}, |
| 803 | }} | 909 | }} | ||
| 804 | waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | 910 | waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | ||
| 805 | return !host.Checking && len(host.Sessions) == 1 && host.Sessions[0].Name == "other" | 911 | return !host.Checking && len(host.Sessions) == 1 && host.Sessions[0].Name == "other" | ||
| @@ -811,9 +917,9 @@ func TestRegistrySchedulesFreshObservationAfterSuccess(t *testing.T) { | |||||
| 811 | start := time.Unix(100, 0) | 917 | start := time.Unix(100, 0) | ||
| 812 | clock := newManualClock(start) | 918 | clock := newManualClock(start) | ||
| 813 | probe := newTestProbe(1) | 919 | probe := newTestProbe(1) | ||
| 814 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | 920 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) |
| 815 | reg := registration(t, "example.test", 1) | 921 | reg := registration(t, "example.test", 1) | ||
| 816 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | 922 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) |
| 817 | startRegistry(t, registry) | 923 | startRegistry(t, registry) | ||
| 818 | 924 | ||||
| 819 | call := receiveCall(t, probe) | 925 | call := receiveCall(t, probe) | ||
| @@ -840,9 +946,9 @@ func TestRegistryDemandOverridesFreshSchedule(t *testing.T) { | |||||
| 840 | start := time.Unix(100, 0) | 946 | start := time.Unix(100, 0) | ||
| 841 | clock := newManualClock(start) | 947 | clock := newManualClock(start) | ||
| 842 | probe := newTestProbe(1) | 948 | probe := newTestProbe(1) | ||
| 843 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | 949 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) |
| 844 | reg := registration(t, "example.test", 1) | 950 | reg := registration(t, "example.test", 1) | ||
| 845 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | 951 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) |
| 846 | startRegistry(t, registry) | 952 | startRegistry(t, registry) | ||
| 847 | 953 | ||||
| 848 | call := receiveCall(t, probe) | 954 | call := receiveCall(t, probe) | ||
| @@ -862,9 +968,9 @@ func TestRegistryCoalescesConcurrentDemand(t *testing.T) { | |||||
| 862 | start := time.Unix(100, 0) | 968 | start := time.Unix(100, 0) | ||
| 863 | clock := newManualClock(start) | 969 | clock := newManualClock(start) | ||
| 864 | probe := newTestProbe(2) | 970 | probe := newTestProbe(2) | ||
| 865 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | 971 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) |
| 866 | reg := registration(t, "example.test", 1) | 972 | reg := registration(t, "example.test", 1) | ||
| 867 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | 973 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) |
| 868 | startRegistry(t, registry) | 974 | startRegistry(t, registry) | ||
| 869 | 975 | ||||
| 870 | call := receiveCall(t, probe) | 976 | call := receiveCall(t, probe) | ||
| @@ -894,9 +1000,9 @@ func TestRegistryCoalescesConcurrentDemand(t *testing.T) { | |||||
| 894 | func TestRegistryIgnoresDemandForUnknownEndpoint(t *testing.T) { | 1000 | func TestRegistryIgnoresDemandForUnknownEndpoint(t *testing.T) { | ||
| 895 | clock := newManualClock(time.Unix(100, 0)) | 1001 | clock := newManualClock(time.Unix(100, 0)) | ||
| 896 | probe := newTestProbe(1) | 1002 | probe := newTestProbe(1) | ||
| 897 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | 1003 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) |
| 898 | reg := registration(t, "example.test", 1) | 1004 | reg := registration(t, "example.test", 1) | ||
| 899 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | 1005 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) |
| 900 | registry.RequestProbe("unknown.test") | 1006 | registry.RequestProbe("unknown.test") | ||
| 901 | startRegistry(t, registry) | 1007 | startRegistry(t, registry) | ||
| 902 | 1008 | ||||
| @@ -913,14 +1019,14 @@ func TestRegistryPublishesReachabilityAndPersists(t *testing.T) { | |||||
| 913 | store := &testStore{} | 1019 | store := &testStore{} | ||
| 914 | registry := newTestRegistry(t, 3, store, probe, clock) | 1020 | registry := newTestRegistry(t, 3, store, probe, clock) | ||
| 915 | reg := registration(t, "example.test", 1) | 1021 | reg := registration(t, "example.test", 1) | ||
| 916 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | 1022 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) |
| 917 | startRegistry(t, registry) | 1023 | startRegistry(t, registry) | ||
| 918 | 1024 | ||||
| 919 | call := receiveCall(t, probe) | 1025 | call := receiveCall(t, probe) | ||
| 920 | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | 1026 | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | ||
| 921 | Availability: domain.RemoteAvailabilityReachable, | 1027 | Availability: domain.RemoteAvailabilityReachable, | ||
| 922 | InventoryKnown: true, | 1028 | InventoryKnown: true, | ||
| 923 | - | Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: catalogue.RemoteCatalogSessionUp}}, | 1029 | + | Sessions: []catalogue.RemoteCatalogSession{hostSession("work")}, |
| 924 | }} | 1030 | }} | ||
| 925 | snapshot := waitSnapshot(t, registry, func(snapshot ports.BrokerSnapshot) bool { | 1031 | snapshot := waitSnapshot(t, registry, func(snapshot ports.BrokerSnapshot) bool { | ||
| 926 | host, ok := snapshot.Find(reg.Endpoint) | 1032 | host, ok := snapshot.Find(reg.Endpoint) | ||
| @@ -996,9 +1102,9 @@ func TestRegistryNonReachableOutcomesUseTypedFailureBackoff(t *testing.T) { | |||||
| 996 | start := time.Unix(100, 0) | 1102 | start := time.Unix(100, 0) | ||
| 997 | clock := newManualClock(start) | 1103 | clock := newManualClock(start) | ||
| 998 | probe := newTestProbe(1) | 1104 | probe := newTestProbe(1) | ||
| 999 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | 1105 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) |
| 1000 | reg := registration(t, "example.test", 1) | 1106 | reg := registration(t, "example.test", 1) | ||
| 1001 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | 1107 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) |
| 1002 | startRegistry(t, registry) | 1108 | startRegistry(t, registry) | ||
| 1003 | 1109 | ||||
| 1004 | call := receiveCall(t, probe) | 1110 | call := receiveCall(t, probe) | ||
| @@ -1027,9 +1133,9 @@ func TestRegistryClassifiesOversizedProbeProjectionAsInvalidResponse(t *testing. | |||||
| 1027 | start := time.Unix(100, 0) | 1133 | start := time.Unix(100, 0) | ||
| 1028 | clock := newManualClock(start) | 1134 | clock := newManualClock(start) | ||
| 1029 | probe := newTestProbe(1) | 1135 | probe := newTestProbe(1) | ||
| 1030 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | 1136 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) |
| 1031 | reg := registration(t, "example.test", 1) | 1137 | reg := registration(t, "example.test", 1) | ||
| 1032 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | 1138 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) |
| 1033 | startRegistry(t, registry) | 1139 | startRegistry(t, registry) | ||
| 1034 | sub := registry.Subscribe() | 1140 | sub := registry.Subscribe() | ||
| 1035 | defer sub.Close() | 1141 | defer sub.Close() | ||
| @@ -1038,17 +1144,17 @@ func TestRegistryClassifiesOversizedProbeProjectionAsInvalidResponse(t *testing. | |||||
| 1038 | call := receiveCall(t, probe) | 1144 | call := receiveCall(t, probe) | ||
| 1039 | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | 1145 | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | ||
| 1040 | Availability: domain.RemoteAvailabilityReachable, | 1146 | Availability: domain.RemoteAvailabilityReachable, | ||
| 1041 | - | Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: catalogue.RemoteCatalogSessionUp}}, | 1147 | + | Sessions: []catalogue.RemoteCatalogSession{hostSession("work")}, |
| 1042 | }} | 1148 | }} | ||
| 1043 | waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | 1149 | waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | ||
| 1044 | return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable | 1150 | return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable | ||
| 1045 | }) | 1151 | }) | ||
| 1046 | 1152 | ||||
| 1047 | - | // A projection carrying one session more than the publication bound is a | 1153 | + | // A projection carrying one session more than the durable session bound is |
| 1048 | - | // malformed response: it is classified as an invalid response and never | 1154 | + | // a malformed response: it is classified as an invalid response and never |
| 1049 | - | // reaches publication, because such a snapshot fails Validate. The loop | 1155 | + | // reaches publication, because the store enforces the same catalogue rule. |
| 1050 | - | // checks every observed publication, so an invalid snapshot can never pass | 1156 | + | // The loop checks every observed publication, so an invalid snapshot can |
| 1051 | - | // unnoticed. | 1157 | + | // never pass unnoticed. |
| 1052 | clock.Advance(defaultFreshFor) | 1158 | clock.Advance(defaultFreshFor) | ||
| 1053 | call = receiveCall(t, probe) | 1159 | call = receiveCall(t, probe) | ||
| 1054 | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | 1160 | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | ||
| @@ -1084,9 +1190,9 @@ func TestRegistryAcceptsProbeProjectionAtSessionBound(t *testing.T) { | |||||
| 1084 | start := time.Unix(100, 0) | 1190 | start := time.Unix(100, 0) | ||
| 1085 | clock := newManualClock(start) | 1191 | clock := newManualClock(start) | ||
| 1086 | probe := newTestProbe(1) | 1192 | probe := newTestProbe(1) | ||
| 1087 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | 1193 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) |
| 1088 | reg := registration(t, "example.test", 1) | 1194 | reg := registration(t, "example.test", 1) | ||
| 1089 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | 1195 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) |
| 1090 | startRegistry(t, registry) | 1196 | startRegistry(t, registry) | ||
| 1091 | 1197 | ||||
| 1092 | // The bound itself is valid: exactly BrokerMaxSessionsPerHost sessions are | 1198 | // The bound itself is valid: exactly BrokerMaxSessionsPerHost sessions are | ||
| @@ -1106,20 +1212,106 @@ func TestRegistryAcceptsProbeProjectionAtSessionBound(t *testing.T) { | |||||
| 1106 | require.Len(t, snapshot.Hosts[0].Sessions, ports.BrokerMaxSessionsPerHost) | 1212 | require.Len(t, snapshot.Hosts[0].Sessions, ports.BrokerMaxSessionsPerHost) | ||
| 1107 | } | 1213 | } | ||
| 1108 | 1214 | ||||
| 1215 | + | // TestRegistryRejectsNonDurableObservation proves the registry and the durable | |||
| 1216 | + | // store agree on what may be persisted: a reachable projection the store would | |||
| 1217 | + | // reject is classified as an invalid response, so the last-known inventory | |||
| 1218 | + | // stays authoritative instead of a snapshot that can never be written. | |||
| 1219 | + | func TestRegistryRejectsNonDurableObservation(t *testing.T) { | |||
| 1220 | + | start := time.Unix(100, 0) | |||
| 1221 | + | clock := newManualClock(start) | |||
| 1222 | + | probe := newTestProbe(1) | |||
| 1223 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) | |||
| 1224 | + | reg := registration(t, "example.test", 1) | |||
| 1225 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |||
| 1226 | + | startRegistry(t, registry) | |||
| 1227 | + | ||||
| 1228 | + | call := receiveCall(t, probe) | |||
| 1229 | + | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | |||
| 1230 | + | Availability: domain.RemoteAvailabilityReachable, | |||
| 1231 | + | Sessions: []catalogue.RemoteCatalogSession{hostSession("work")}, | |||
| 1232 | + | }} | |||
| 1233 | + | waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |||
| 1234 | + | return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable | |||
| 1235 | + | }) | |||
| 1236 | + | ||||
| 1237 | + | // A probe answer that violates the catalogue rules the store enforces (here | |||
| 1238 | + | // a zero lifecycle identity and an absent tab list) is a malformed response. | |||
| 1239 | + | clock.Advance(defaultFreshFor) | |||
| 1240 | + | call = receiveCall(t, probe) | |||
| 1241 | + | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | |||
| 1242 | + | Availability: domain.RemoteAvailabilityReachable, | |||
| 1243 | + | Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: catalogue.RemoteCatalogSessionUp}}, | |||
| 1244 | + | }} | |||
| 1245 | + | host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |||
| 1246 | + | return !host.Checking && host.ConsecutiveFailures == 1 | |||
| 1247 | + | }) | |||
| 1248 | + | require.Equal(t, domain.RemoteAvailabilityInvalidResponse, host.Availability) | |||
| 1249 | + | require.Equal(t, domain.RemoteFailureInvalidResponse, host.LastFailure.Kind) | |||
| 1250 | + | require.Len(t, host.Sessions, 1) | |||
| 1251 | + | require.Equal(t, "work", host.Sessions[0].Name) | |||
| 1252 | + | require.Equal(t, start, host.LastSuccess) | |||
| 1253 | + | snapshot := registry.Snapshot() | |||
| 1254 | + | require.NoError(t, snapshot.Validate()) | |||
| 1255 | + | require.NoError(t, ports.ValidateDurableHostProjection(snapshot.Hosts[0])) | |||
| 1256 | + | } | |||
| 1257 | + | ||||
| 1258 | + | // TestRegistryRevisionOverflowFailsClosed pins the revision series: zero is not | |||
| 1259 | + | // a valid revision and revisions never regress, so an exhausted counter fails | |||
| 1260 | + | // closed instead of wrapping. The last valid snapshot stays published, the | |||
| 1261 | + | // registry is poisoned, and every mutation path reports a typed error rather | |||
| 1262 | + | // than emitting revision zero or a revision below the newest one, either of | |||
| 1263 | + | // which would silently freeze durable persistence. | |||
| 1264 | + | func TestRegistryRevisionOverflowFailsClosed(t *testing.T) { | |||
| 1265 | + | logger, logs := testLogger() | |||
| 1266 | + | registry, err := NewRegistry(1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0)), logger) | |||
| 1267 | + | require.NoError(t, err) | |||
| 1268 | + | first := registration(t, "example.test", 1) | |||
| 1269 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first})) | |||
| 1270 | + | before := registry.Snapshot() | |||
| 1271 | + | require.NoError(t, before.Validate()) | |||
| 1272 | + | require.Equal(t, ports.BrokerRevision(2), before.Revision) | |||
| 1273 | + | authority := registry.authority | |||
| 1274 | + | ||||
| 1275 | + | // The next publication would overflow the series to zero. | |||
| 1276 | + | registry.revision = ^ports.BrokerRevision(0) | |||
| 1277 | + | ||||
| 1278 | + | // Both mutation paths refuse the change before touching the store or the | |||
| 1279 | + | // projection, so neither durable authority nor the publication series moves. | |||
| 1280 | + | require.ErrorIs(t, registry.setHosts([]domain.RemoteRegistration{registration(t, "other.test", 2)}), ports.ErrBrokerRevisionExhausted) | |||
| 1281 | + | require.ErrorIs(t, registry.ReplaceHosts([]ports.BrokerHostRecord{{Registration: first, Pinned: true, Policy: poolPolicy()}}), ports.ErrBrokerRevisionExhausted) | |||
| 1282 | + | require.Equal(t, authority, registry.authority) | |||
| 1283 | + | ||||
| 1284 | + | // The publication path itself fails closed too: a direct attempt neither | |||
| 1285 | + | // wraps the counter nor replaces the last valid snapshot. | |||
| 1286 | + | registry.mu.Lock() | |||
| 1287 | + | registry.publishLocked(false) | |||
| 1288 | + | registry.mu.Unlock() | |||
| 1289 | + | ||||
| 1290 | + | after := registry.Snapshot() | |||
| 1291 | + | require.Equal(t, before, after) | |||
| 1292 | + | require.NoError(t, after.Validate()) | |||
| 1293 | + | require.Greater(t, after.Revision, ports.BrokerRevision(0)) | |||
| 1294 | + | require.Equal(t, ^ports.BrokerRevision(0), registry.revision) | |||
| 1295 | + | require.Equal(t, first, after.Hosts[0].Registration) | |||
| 1296 | + | // The refusal is observable: an exhausted series logs instead of failing | |||
| 1297 | + | // silently behind a frozen snapshot. | |||
| 1298 | + | require.Contains(t, logs.String(), "revision series exhausted") | |||
| 1299 | + | } | |||
| 1300 | + | ||||
| 1109 | func TestRegistryPreservesLastSuccessAndOwnsFailureEpisode(t *testing.T) { | 1301 | func TestRegistryPreservesLastSuccessAndOwnsFailureEpisode(t *testing.T) { | ||
| 1110 | start := time.Unix(100, 0) | 1302 | start := time.Unix(100, 0) | ||
| 1111 | clock := newManualClock(start) | 1303 | clock := newManualClock(start) | ||
| 1112 | probe := newTestProbe(1) | 1304 | probe := newTestProbe(1) | ||
| 1113 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | 1305 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) |
| 1114 | reg := registration(t, "example.test", 1) | 1306 | reg := registration(t, "example.test", 1) | ||
| 1115 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | 1307 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) |
| 1116 | startRegistry(t, registry) | 1308 | startRegistry(t, registry) | ||
| 1117 | 1309 | ||||
| 1118 | // A successful observation owns the success time and opens no episode. | 1310 | // A successful observation owns the success time and opens no episode. | ||
| 1119 | call := receiveCall(t, probe) | 1311 | call := receiveCall(t, probe) | ||
| 1120 | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | 1312 | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{ | ||
| 1121 | Availability: domain.RemoteAvailabilityReachable, | 1313 | Availability: domain.RemoteAvailabilityReachable, | ||
| 1122 | - | Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: catalogue.RemoteCatalogSessionUp}}, | 1314 | + | Sessions: []catalogue.RemoteCatalogSession{hostSession("work")}, |
| 1123 | }} | 1315 | }} | ||
| 1124 | host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | 1316 | host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | ||
| 1125 | return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable | 1317 | return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable | ||
| @@ -1172,9 +1364,9 @@ func TestRegistryBackoffIsExponentialAndCapped(t *testing.T) { | |||||
| 1172 | start := time.Unix(100, 0) | 1364 | start := time.Unix(100, 0) | ||
| 1173 | clock := newManualClock(start) | 1365 | clock := newManualClock(start) | ||
| 1174 | probe := newTestProbe(1) | 1366 | probe := newTestProbe(1) | ||
| 1175 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | 1367 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) |
| 1176 | reg := registration(t, "example.test", 1) | 1368 | reg := registration(t, "example.test", 1) | ||
| 1177 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | 1369 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) |
| 1178 | startRegistry(t, registry) | 1370 | startRegistry(t, registry) | ||
| 1179 | 1371 | ||||
| 1180 | probeErr := errors.New("offline") | 1372 | probeErr := errors.New("offline") | ||
| @@ -1215,10 +1407,10 @@ func TestRegistryJitterAppliesToRetryAndHealthyRefresh(t *testing.T) { | |||||
| 1215 | start := time.Unix(100, 0) | 1407 | start := time.Unix(100, 0) | ||
| 1216 | clock := newManualClock(start) | 1408 | clock := newManualClock(start) | ||
| 1217 | probe := newTestProbe(1) | 1409 | probe := newTestProbe(1) | ||
| 1218 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | 1410 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) |
| 1219 | registry.jitter = func(base time.Duration, _ string, _ uint64) time.Duration { return base / 2 } | 1411 | registry.jitter = func(base time.Duration, _ string, _ uint64) time.Duration { return base / 2 } | ||
| 1220 | reg := registration(t, "example.test", 1) | 1412 | reg := registration(t, "example.test", 1) | ||
| 1221 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | 1413 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) |
| 1222 | startRegistry(t, registry) | 1414 | startRegistry(t, registry) | ||
| 1223 | 1415 | ||||
| 1224 | call := receiveCall(t, probe) | 1416 | call := receiveCall(t, probe) | ||
| @@ -1267,20 +1459,20 @@ func TestRegistryFencesStaleCompletionAcrossRegistrationChanges(t *testing.T) { | |||||
| 1267 | replacementAnswer := func(name string) probeAnswer { | 1459 | replacementAnswer := func(name string) probeAnswer { | ||
| 1268 | return probeAnswer{snapshot: ports.RemoteHostSnapshot{ | 1460 | return probeAnswer{snapshot: ports.RemoteHostSnapshot{ | ||
| 1269 | Availability: domain.RemoteAvailabilityReachable, | 1461 | Availability: domain.RemoteAvailabilityReachable, | ||
| 1270 | - | Sessions: []catalogue.RemoteCatalogSession{{Name: name, State: catalogue.RemoteCatalogSessionUp}}, | 1462 | + | Sessions: []catalogue.RemoteCatalogSession{hostSession(name)}, |
| 1271 | }} | 1463 | }} | ||
| 1272 | } | 1464 | } | ||
| 1273 | assertFenced := func(t *testing.T, current, replacement domain.RemoteRegistration) { | 1465 | assertFenced := func(t *testing.T, current, replacement domain.RemoteRegistration) { | ||
| 1274 | t.Helper() | 1466 | t.Helper() | ||
| 1275 | clock := newManualClock(time.Unix(100, 0)) | 1467 | clock := newManualClock(time.Unix(100, 0)) | ||
| 1276 | probe := newTestProbe(2) | 1468 | probe := newTestProbe(2) | ||
| 1277 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | 1469 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) |
| 1278 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{current})) | 1470 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{current})) |
| 1279 | startRegistry(t, registry) | 1471 | startRegistry(t, registry) | ||
| 1280 | currentCall := receiveCall(t, probe) | 1472 | currentCall := receiveCall(t, probe) | ||
| 1281 | require.True(t, currentCall.registration.Equal(current)) | 1473 | require.True(t, currentCall.registration.Equal(current)) | ||
| 1282 | 1474 | ||||
| 1283 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{replacement})) | 1475 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{replacement})) |
| 1284 | replacementCall := receiveCall(t, probe) | 1476 | replacementCall := receiveCall(t, probe) | ||
| 1285 | require.True(t, replacementCall.registration.Equal(replacement)) | 1477 | require.True(t, replacementCall.registration.Equal(replacement)) | ||
| 1286 | 1478 | ||||
| @@ -1322,9 +1514,9 @@ func TestRegistryFencesStaleCompletionAcrossRegistrationChanges(t *testing.T) { | |||||
| 1322 | func TestRegistryFencesRetiredAttemptsAcrossRemovalAndIdenticalReAdd(t *testing.T) { | 1514 | func TestRegistryFencesRetiredAttemptsAcrossRemovalAndIdenticalReAdd(t *testing.T) { | ||
| 1323 | clock := newManualClock(time.Unix(100, 0)) | 1515 | clock := newManualClock(time.Unix(100, 0)) | ||
| 1324 | probe := newTestProbe(2) | 1516 | probe := newTestProbe(2) | ||
| 1325 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | 1517 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) |
| 1326 | reg := registration(t, "example.test", 1) | 1518 | reg := registration(t, "example.test", 1) | ||
| 1327 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | 1519 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) |
| 1328 | 1520 | ||||
| 1329 | ctx, cancel := context.WithCancel(context.Background()) | 1521 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 1330 | defer cancel() | 1522 | defer cancel() | ||
| @@ -1338,7 +1530,7 @@ func TestRegistryFencesRetiredAttemptsAcrossRemovalAndIdenticalReAdd(t *testing. | |||||
| 1338 | require.NotZero(t, retired) | 1530 | require.NotZero(t, retired) | ||
| 1339 | 1531 | ||||
| 1340 | // Removal retires a bounded tombstone and drops the in-flight token. | 1532 | // Removal retires a bounded tombstone and drops the in-flight token. | ||
| 1341 | - | require.NoError(t, registry.SetHosts(nil)) | 1533 | + | require.NoError(t, registry.setHosts(nil)) |
| 1342 | removed := registry.Snapshot() | 1534 | removed := registry.Snapshot() | ||
| 1343 | require.Empty(t, removed.Hosts) | 1535 | require.Empty(t, removed.Hosts) | ||
| 1344 | require.Len(t, removed.Removed, 1) | 1536 | require.Len(t, removed.Removed, 1) | ||
| @@ -1353,7 +1545,7 @@ func TestRegistryFencesRetiredAttemptsAcrossRemovalAndIdenticalReAdd(t *testing. | |||||
| 1353 | 1545 | ||||
| 1354 | // Re-adding the identical registration supersedes the tombstone, so only | 1546 | // Re-adding the identical registration supersedes the tombstone, so only | ||
| 1355 | // the attempt token can fence the retired completion. | 1547 | // the attempt token can fence the retired completion. | ||
| 1356 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | 1548 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) |
| 1357 | require.Empty(t, registry.Snapshot().Removed) | 1549 | require.Empty(t, registry.Snapshot().Removed) | ||
| 1358 | dispatch() | 1550 | dispatch() | ||
| 1359 | freshCall := receiveCall(t, probe) | 1551 | freshCall := receiveCall(t, probe) | ||
| @@ -1388,8 +1580,8 @@ func TestRegistryRetiresInflightAttemptsOnReplacementAndRemoval(t *testing.T) { | |||||
| 1388 | t.Run(tt.name, func(t *testing.T) { | 1580 | t.Run(tt.name, func(t *testing.T) { | ||
| 1389 | clock := newManualClock(time.Unix(100, 0)) | 1581 | clock := newManualClock(time.Unix(100, 0)) | ||
| 1390 | probe := newTestProbe(2) | 1582 | probe := newTestProbe(2) | ||
| 1391 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | 1583 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) |
| 1392 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{current})) | 1584 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{current})) |
| 1393 | startRegistry(t, registry) | 1585 | startRegistry(t, registry) | ||
| 1394 | 1586 | ||||
| 1395 | call := receiveCall(t, probe) | 1587 | call := receiveCall(t, probe) | ||
| @@ -1399,7 +1591,7 @@ func TestRegistryRetiresInflightAttemptsOnReplacementAndRemoval(t *testing.T) { | |||||
| 1399 | // attempt: the probe's derived context is cancelled before the | 1591 | // attempt: the probe's derived context is cancelled before the | ||
| 1400 | // replacement can start, so a ctx-honoring probe stops instead of | 1592 | // replacement can start, so a ctx-honoring probe stops instead of | ||
| 1401 | // overlapping its successor. | 1593 | // overlapping its successor. | ||
| 1402 | - | require.NoError(t, registry.SetHosts(tt.next)) | 1594 | + | require.NoError(t, registry.setHosts(tt.next)) |
| 1403 | select { | 1595 | select { | ||
| 1404 | case <-probe.canceled: | 1596 | case <-probe.canceled: | ||
| 1405 | case <-time.After(time.Second): | 1597 | case <-time.After(time.Second): | ||
| @@ -1413,11 +1605,11 @@ func TestRegistryChurnKeepsOneActiveProbePerEndpoint(t *testing.T) { | |||||
| 1413 | clock := newManualClock(time.Unix(100, 0)) | 1605 | clock := newManualClock(time.Unix(100, 0)) | ||
| 1414 | probe := newChurnProbe() | 1606 | probe := newChurnProbe() | ||
| 1415 | defer probe.openRelease() | 1607 | defer probe.openRelease() | ||
| 1416 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | 1608 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) |
| 1417 | 1609 | ||||
| 1418 | first := registration(t, "example.test", 1) | 1610 | first := registration(t, "example.test", 1) | ||
| 1419 | second := registration(t, "example.test", 2) | 1611 | second := registration(t, "example.test", 2) | ||
| 1420 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{first})) | 1612 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first})) |
| 1421 | 1613 | ||||
| 1422 | ctx, cancel := context.WithCancel(context.Background()) | 1614 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 1423 | defer cancel() | 1615 | defer cancel() | ||
| @@ -1443,7 +1635,7 @@ func TestRegistryChurnKeepsOneActiveProbePerEndpoint(t *testing.T) { | |||||
| 1443 | if round%2 == 0 { | 1635 | if round%2 == 0 { | ||
| 1444 | next = second | 1636 | next = second | ||
| 1445 | } | 1637 | } | ||
| 1446 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{next})) | 1638 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{next})) |
| 1447 | select { | 1639 | select { | ||
| 1448 | case <-probe.startedSig: | 1640 | case <-probe.startedSig: | ||
| 1449 | case <-time.After(time.Second): | 1641 | case <-time.After(time.Second): | ||
| @@ -1465,7 +1657,7 @@ func TestRegistryChurnKeepsOneActiveProbePerEndpoint(t *testing.T) { | |||||
| 1465 | if (worker+round)%2 == 0 { | 1657 | if (worker+round)%2 == 0 { | ||
| 1466 | next = second | 1658 | next = second | ||
| 1467 | } | 1659 | } | ||
| 1468 | - | if err := registry.SetHosts([]domain.RemoteRegistration{next}); err != nil { | 1660 | + | if err := registry.setHosts([]domain.RemoteRegistration{next}); err != nil { |
| 1469 | failures[worker] = err | 1661 | failures[worker] = err | ||
| 1470 | return | 1662 | return | ||
| 1471 | } | 1663 | } | ||
| @@ -1503,7 +1695,7 @@ func TestRegistryChurnKeepsOneActiveProbePerEndpoint(t *testing.T) { | |||||
| 1503 | } | 1695 | } | ||
| 1504 | 1696 | ||||
| 1505 | func TestRegistryPublishesBoundedTombstones(t *testing.T) { | 1697 | func TestRegistryPublishesBoundedTombstones(t *testing.T) { | ||
| 1506 | - | registry := newTestRegistry(t, 1, nil, newTestProbe(1), newManualClock(time.Unix(100, 0))) | 1698 | + | registry := newTestRegistry(t, 1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0))) |
| 1507 | 1699 | ||||
| 1508 | // Two full membership rounds with distinct endpoints: without pruning the | 1700 | // Two full membership rounds with distinct endpoints: without pruning the | ||
| 1509 | // retired set would double. | 1701 | // retired set would double. | ||
| @@ -1512,8 +1704,8 @@ func TestRegistryPublishesBoundedTombstones(t *testing.T) { | |||||
| 1512 | for index := 0; index < ports.BrokerMaxHosts; index++ { | 1704 | for index := 0; index < ports.BrokerMaxHosts; index++ { | ||
| 1513 | registrations = append(registrations, registration(t, fmt.Sprintf("round-%d-host-%d.test", round, index), byte(round*ports.BrokerMaxHosts+index+1))) | 1705 | registrations = append(registrations, registration(t, fmt.Sprintf("round-%d-host-%d.test", round, index), byte(round*ports.BrokerMaxHosts+index+1))) | ||
| 1514 | } | 1706 | } | ||
| 1515 | - | require.NoError(t, registry.SetHosts(registrations)) | 1707 | + | require.NoError(t, registry.setHosts(registrations)) |
| 1516 | - | require.NoError(t, registry.SetHosts(nil)) | 1708 | + | require.NoError(t, registry.setHosts(nil)) |
| 1517 | } | 1709 | } | ||
| 1518 | 1710 | ||||
| 1519 | snapshot := registry.Snapshot() | 1711 | snapshot := registry.Snapshot() | ||
| @@ -1538,7 +1730,7 @@ func TestRegistryPersistenceRunsOffLockAndCoalescesWrites(t *testing.T) { | |||||
| 1538 | defer registry.store.close() | 1730 | defer registry.store.close() | ||
| 1539 | 1731 | ||||
| 1540 | reg := registration(t, "example.test", 1) | 1732 | reg := registration(t, "example.test", 1) | ||
| 1541 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | 1733 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) |
| 1542 | // The writer is now blocked inside the first durable write. | 1734 | // The writer is now blocked inside the first durable write. | ||
| 1543 | first := <-store.entered | 1735 | first := <-store.entered | ||
| 1544 | require.Equal(t, ports.BrokerRevision(2), first) | 1736 | require.Equal(t, ports.BrokerRevision(2), first) | ||
| @@ -1551,7 +1743,7 @@ func TestRegistryPersistenceRunsOffLockAndCoalescesWrites(t *testing.T) { | |||||
| 1551 | defer close(published) | 1743 | defer close(published) | ||
| 1552 | for generation := domain.RemoteGeneration(2); generation <= 6; generation++ { | 1744 | for generation := domain.RemoteGeneration(2); generation <= 6; generation++ { | ||
| 1553 | reg.Generation = generation | 1745 | reg.Generation = generation | ||
| 1554 | - | if err := registry.SetHosts([]domain.RemoteRegistration{reg}); err != nil { | 1746 | + | if err := registry.setHosts([]domain.RemoteRegistration{reg}); err != nil { |
| 1555 | setErr = err | 1747 | setErr = err | ||
| 1556 | return | 1748 | return | ||
| 1557 | } | 1749 | } | ||
| @@ -1573,6 +1765,83 @@ func TestRegistryPersistenceRunsOffLockAndCoalescesWrites(t *testing.T) { | |||||
| 1573 | require.Equal(t, []ports.BrokerRevision{2, last}, store.writtenRevisions()) | 1765 | require.Equal(t, []ports.BrokerRevision{2, last}, store.writtenRevisions()) | ||
| 1574 | } | 1766 | } | ||
| 1575 | 1767 | ||||
| 1768 | + | // TestRegistryReplaceHostsBlockedCASKeepsSnapshotLockFree proves membership | |||
| 1769 | + | // durability and publication stay independent: a ReplaceHosts parked inside the | |||
| 1770 | + | // durable CAS may hold the registry lock, but Snapshot is served from the atomic | |||
| 1771 | + | // publication, no observation may be applied behind it, and the run loop | |||
| 1772 | + | // resumes scheduling as soon as the CAS commits. | |||
| 1773 | + | func TestRegistryReplaceHostsBlockedCASKeepsSnapshotLockFree(t *testing.T) { | |||
| 1774 | + | clock := newManualClock(time.Unix(100, 0)) | |||
| 1775 | + | probe := newTestProbe(2) | |||
| 1776 | + | store := newBlockingCASStore() | |||
| 1777 | + | registry, err := NewRegistry(1, store, probe, clock, nil) | |||
| 1778 | + | require.NoError(t, err) | |||
| 1779 | + | ||||
| 1780 | + | reg := registration(t, "example.test", 1) | |||
| 1781 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |||
| 1782 | + | startRegistry(t, registry) | |||
| 1783 | + | call := receiveCall(t, probe) | |||
| 1784 | + | require.True(t, waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |||
| 1785 | + | return host.Checking | |||
| 1786 | + | }).Checking) | |||
| 1787 | + | ||||
| 1788 | + | // The replacement takes the registry lock and parks inside the durable CAS. | |||
| 1789 | + | replaced := make(chan error, 1) | |||
| 1790 | + | go func() { | |||
| 1791 | + | replaced <- registry.ReplaceHosts([]ports.BrokerHostRecord{{Registration: reg, Pinned: true, Policy: poolPolicy()}}) | |||
| 1792 | + | }() | |||
| 1793 | + | <-store.entered | |||
| 1794 | + | t.Cleanup(store.releaseCAS) | |||
| 1795 | + | ||||
| 1796 | + | // Snapshot stays lock-free: the atomic publication is readable while the | |||
| 1797 | + | // mutator is parked inside the store, and it still carries the pre-CAS | |||
| 1798 | + | // projection. | |||
| 1799 | + | observed := make(chan ports.BrokerSnapshot, 1) | |||
| 1800 | + | go func() { observed <- registry.Snapshot() }() | |||
| 1801 | + | select { | |||
| 1802 | + | case snapshot := <-observed: | |||
| 1803 | + | require.NoError(t, snapshot.Validate()) | |||
| 1804 | + | require.Len(t, snapshot.Hosts, 1) | |||
| 1805 | + | require.Equal(t, reg, snapshot.Hosts[0].Registration) | |||
| 1806 | + | case <-time.After(time.Second): | |||
| 1807 | + | t.Fatal("Snapshot blocked behind the durable membership CAS") | |||
| 1808 | + | } | |||
| 1809 | + | ||||
| 1810 | + | // Wake the run loop and complete the observation while the CAS is still | |||
| 1811 | + | // parked: applying the result needs the registry lock, so the in-flight | |||
| 1812 | + | // projection must stay exactly as published until membership commits. | |||
| 1813 | + | registry.hint() | |||
| 1814 | + | call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}} | |||
| 1815 | + | pending, ok := registry.Snapshot().Find(reg.Endpoint) | |||
| 1816 | + | require.True(t, ok) | |||
| 1817 | + | require.True(t, pending.Checking) | |||
| 1818 | + | stable := time.NewTimer(probeGrace) | |||
| 1819 | + | defer stable.Stop() | |||
| 1820 | + | ||||
| 1821 | + | window: | |||
| 1822 | + | for { | |||
| 1823 | + | current, found := registry.Snapshot().Find(reg.Endpoint) | |||
| 1824 | + | require.True(t, found) | |||
| 1825 | + | require.Equal(t, pending, current) | |||
| 1826 | + | select { | |||
| 1827 | + | case <-stable.C: | |||
| 1828 | + | break window | |||
| 1829 | + | case <-time.After(time.Millisecond): | |||
| 1830 | + | } | |||
| 1831 | + | } | |||
| 1832 | + | ||||
| 1833 | + | // Committing membership releases the mutator and unparks the run loop, which | |||
| 1834 | + | // applies the observation and resumes scheduling the next one. | |||
| 1835 | + | store.releaseCAS() | |||
| 1836 | + | require.NoError(t, <-replaced) | |||
| 1837 | + | host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool { | |||
| 1838 | + | return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable | |||
| 1839 | + | }) | |||
| 1840 | + | require.Equal(t, clock.Now(), host.LastSuccess) | |||
| 1841 | + | clock.Advance(defaultFreshFor) | |||
| 1842 | + | receiveCall(t, probe) | |||
| 1843 | + | } | |||
| 1844 | + | ||||
| 1576 | func TestRegistryStoreErrorsAreObservableAndDoNotStallPublication(t *testing.T) { | 1845 | func TestRegistryStoreErrorsAreObservableAndDoNotStallPublication(t *testing.T) { | ||
| 1577 | clock := newManualClock(time.Unix(100, 0)) | 1846 | clock := newManualClock(time.Unix(100, 0)) | ||
| 1578 | probe := newTestProbe(1) | 1847 | probe := newTestProbe(1) | ||
| @@ -1584,14 +1853,14 @@ func TestRegistryStoreErrorsAreObservableAndDoNotStallPublication(t *testing.T) | |||||
| 1584 | defer registry.store.close() | 1853 | defer registry.store.close() | ||
| 1585 | 1854 | ||||
| 1586 | reg := registration(t, "example.test", 1) | 1855 | reg := registration(t, "example.test", 1) | ||
| 1587 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | 1856 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) |
| 1588 | waitForLog(t, logs, "durable snapshot write failed") | 1857 | waitForLog(t, logs, "durable snapshot write failed") | ||
| 1589 | require.Contains(t, logs.String(), storeErr.Error()) | 1858 | require.Contains(t, logs.String(), storeErr.Error()) | ||
| 1590 | 1859 | ||||
| 1591 | // A failed write is not fatal and never blocks the next publication. | 1860 | // A failed write is not fatal and never blocks the next publication. | ||
| 1592 | store.setStoreErr(nil) | 1861 | store.setStoreErr(nil) | ||
| 1593 | reg.Generation++ | 1862 | reg.Generation++ | ||
| 1594 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | 1863 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) |
| 1595 | stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool { | 1864 | stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool { | ||
| 1596 | return snapshot.Revision == registry.Snapshot().Revision | 1865 | return snapshot.Revision == registry.Snapshot().Revision | ||
| 1597 | }) | 1866 | }) | ||
| @@ -1605,7 +1874,7 @@ func TestRegistryPersistedCopyClearsChecking(t *testing.T) { | |||||
| 1605 | store := &testStore{} | 1874 | store := &testStore{} | ||
| 1606 | registry := newTestRegistry(t, 3, store, probe, clock) | 1875 | registry := newTestRegistry(t, 3, store, probe, clock) | ||
| 1607 | reg := registration(t, "example.test", 1) | 1876 | reg := registration(t, "example.test", 1) | ||
| 1608 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | 1877 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) |
| 1609 | startRegistry(t, registry) | 1878 | startRegistry(t, registry) | ||
| 1610 | 1879 | ||||
| 1611 | // The observation is in flight: the published projection carries Checking, | 1880 | // The observation is in flight: the published projection carries Checking, | ||
| @@ -1627,16 +1896,48 @@ func TestRegistryPersistedCopyClearsChecking(t *testing.T) { | |||||
| 1627 | require.False(t, settled.Hosts[0].Checking) | 1896 | require.False(t, settled.Hosts[0].Checking) | ||
| 1628 | } | 1897 | } | ||
| 1629 | 1898 | ||||
| 1899 | + | // TestRegistryNeverPersistsRetirementTombstones proves retirement tombstones | |||
| 1900 | + | // are process-local fencing state: the durable copy carries no Removed, and a | |||
| 1901 | + | // fresh registry reopened over that snapshot neither resurrects the retired | |||
| 1902 | + | // host nor sees a stale tombstone. | |||
| 1903 | + | func TestRegistryNeverPersistsRetirementTombstones(t *testing.T) { | |||
| 1904 | + | clock := newManualClock(time.Unix(100, 0)) | |||
| 1905 | + | store := &testStore{} | |||
| 1906 | + | registry := newTestRegistry(t, 1, store, newTestProbe(1), clock) | |||
| 1907 | + | defer registry.store.close() | |||
| 1908 | + | ||||
| 1909 | + | reg := registration(t, "retired.test", 1) | |||
| 1910 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) | |||
| 1911 | + | require.NoError(t, registry.setHosts(nil)) | |||
| 1912 | + | ||||
| 1913 | + | // Removal publishes a bounded tombstone and drops the live host in memory. | |||
| 1914 | + | require.Empty(t, registry.Snapshot().Hosts) | |||
| 1915 | + | require.Len(t, registry.Snapshot().Removed, 1) | |||
| 1916 | + | ||||
| 1917 | + | // The durable copy drops the tombstone before the store ever sees it. | |||
| 1918 | + | stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool { | |||
| 1919 | + | return len(snapshot.Hosts) == 0 && snapshot.Revision == registry.Snapshot().Revision | |||
| 1920 | + | }) | |||
| 1921 | + | require.Empty(t, stored.Removed) | |||
| 1922 | + | ||||
| 1923 | + | // Reopening over the durable snapshot adopts empty membership without the | |||
| 1924 | + | // stale tombstone. | |||
| 1925 | + | reopened, err := NewRegistry(2, &testStore{loaded: stored}, newTestProbe(1), clock, nil) | |||
| 1926 | + | require.NoError(t, err) | |||
| 1927 | + | require.Empty(t, reopened.Snapshot().Hosts) | |||
| 1928 | + | require.Empty(t, reopened.Snapshot().Removed) | |||
| 1929 | + | } | |||
| 1930 | + | ||||
| 1630 | func TestRegistryBoundsSlowSubscribersAndStopsOnCancellation(t *testing.T) { | 1931 | func TestRegistryBoundsSlowSubscribersAndStopsOnCancellation(t *testing.T) { | ||
| 1631 | clock := newManualClock(time.Unix(100, 0)) | 1932 | clock := newManualClock(time.Unix(100, 0)) | ||
| 1632 | probe := newTestProbe(1) | 1933 | probe := newTestProbe(1) | ||
| 1633 | - | registry := newTestRegistry(t, 1, nil, probe, clock) | 1934 | + | registry := newTestRegistry(t, 1, newTestStore(), probe, clock) |
| 1634 | 1935 | ||||
| 1635 | sub := registry.Subscribe() | 1936 | sub := registry.Subscribe() | ||
| 1636 | reg := registration(t, "example.test", 1) | 1937 | reg := registration(t, "example.test", 1) | ||
| 1637 | for generation := domain.RemoteGeneration(1); generation <= 10; generation++ { | 1938 | for generation := domain.RemoteGeneration(1); generation <= 10; generation++ { | ||
| 1638 | reg.Generation = generation | 1939 | reg.Generation = generation | ||
| 1639 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | 1940 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) |
| 1640 | } | 1941 | } | ||
| 1641 | // A subscriber that never drains is bounded to one pending wake and | 1942 | // A subscriber that never drains is bounded to one pending wake and | ||
| 1642 | // never blocks publication. | 1943 | // never blocks publication. | ||
| @@ -1648,7 +1949,7 @@ func TestRegistryBoundsSlowSubscribersAndStopsOnCancellation(t *testing.T) { | |||||
| 1648 | sub.Close() | 1949 | sub.Close() | ||
| 1649 | sub.Close() | 1950 | sub.Close() | ||
| 1650 | reg.Generation++ | 1951 | reg.Generation++ | ||
| 1651 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | 1952 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) |
| 1652 | select { | 1953 | select { | ||
| 1653 | case <-sub.Changed(): | 1954 | case <-sub.Changed(): | ||
| 1654 | t.Fatal("closed subscription received a notification") | 1955 | t.Fatal("closed subscription received a notification") | ||
| @@ -1685,7 +1986,7 @@ func TestRegistryRunIsSingleShotAndCleansUpInFlightState(t *testing.T) { | |||||
| 1685 | registry, err := NewRegistry(1, store, probe, clock, logger) | 1986 | registry, err := NewRegistry(1, store, probe, clock, logger) | ||
| 1686 | require.NoError(t, err) | 1987 | require.NoError(t, err) | ||
| 1687 | reg := registration(t, "example.test", 1) | 1988 | reg := registration(t, "example.test", 1) | ||
| 1688 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{reg})) | 1989 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg})) |
| 1689 | 1990 | ||||
| 1690 | ctx, cancel := context.WithCancel(context.Background()) | 1991 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 1691 | done := make(chan struct{}) | 1992 | done := make(chan struct{}) | ||
| @@ -1730,7 +2031,7 @@ func TestRegistryRunIsSingleShotAndCleansUpInFlightState(t *testing.T) { | |||||
| 1730 | require.Equal(t, settled.Revision, stored.Revision) | 2031 | require.Equal(t, settled.Revision, stored.Revision) | ||
| 1731 | require.False(t, stored.Hosts[0].Checking) | 2032 | require.False(t, stored.Hosts[0].Checking) | ||
| 1732 | writes := store.writeCount() | 2033 | writes := store.writeCount() | ||
| 1733 | - | require.NoError(t, registry.SetHosts(nil)) | 2034 | + | require.NoError(t, registry.setHosts(nil)) |
| 1734 | require.Equal(t, writes, store.writeCount()) | 2035 | require.Equal(t, writes, store.writeCount()) | ||
| 1735 | require.Contains(t, logs.String(), "refused after shutdown") | 2036 | require.Contains(t, logs.String(), "refused after shutdown") | ||
| 1736 | 2037 | ||||
| @@ -1749,7 +2050,7 @@ func TestRegistryRevisionsIncreaseMonotonicallyWithinEpoch(t *testing.T) { | |||||
| 1749 | start := time.Unix(100, 0) | 2050 | start := time.Unix(100, 0) | ||
| 1750 | clock := newManualClock(start) | 2051 | clock := newManualClock(start) | ||
| 1751 | probe := newTestProbe(2) | 2052 | probe := newTestProbe(2) | ||
| 1752 | - | registry := newTestRegistry(t, 42, nil, probe, clock) | 2053 | + | registry := newTestRegistry(t, 42, newTestStore(), probe, clock) |
| 1753 | 2054 | ||||
| 1754 | var last ports.BrokerRevision | 2055 | var last ports.BrokerRevision | ||
| 1755 | observe := func() { | 2056 | observe := func() { | ||
| @@ -1763,9 +2064,9 @@ func TestRegistryRevisionsIncreaseMonotonicallyWithinEpoch(t *testing.T) { | |||||
| 1763 | 2064 | ||||
| 1764 | first := registration(t, "a.test", 1) | 2065 | first := registration(t, "a.test", 1) | ||
| 1765 | second := registration(t, "b.test", 2) | 2066 | second := registration(t, "b.test", 2) | ||
| 1766 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{first})) | 2067 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first})) |
| 1767 | observe() | 2068 | observe() | ||
| 1768 | - | require.NoError(t, registry.SetHosts([]domain.RemoteRegistration{first, second})) | 2069 | + | require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first, second})) |
| 1769 | observe() | 2070 | observe() | ||
| 1770 | 2071 | ||||
| 1771 | startRegistry(t, registry) | 2072 | startRegistry(t, registry) | ||