perf(snapshot): avoid full-history publication reads
- commit
- 0e3ca7c7f2297e4388a2897f1064a7147dbc4c7f
- author
- brice · · 2026-07-22 02:30 UTC
- parent
- 058bbc8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +280 -31
M internal/adapters/snapshot/publish.go +139 -29
| @@ -21,7 +21,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||
| 21 | 21 | return err | |
| 22 | 22 | } | |
| 23 | 23 | ||
| 24 | - | refs, supplied, err := validatePublication(publication) | |
| 24 | + | refs, err := validatePublicationManifest(publication) | |
| 25 | 25 | if err != nil { | |
| 26 | 26 | return err | |
| 27 | 27 | } | |
| @@ -35,7 +35,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||
| 35 | 35 | return err | |
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | - | current, currentManifest, err := r.currentGeneration(ctx, publication.Name, key) | |
| 38 | + | current, currentManifest, currentRefs, err := r.currentPublication(ctx, publication.Name, key) | |
| 39 | 39 | if err != nil { | |
| 40 | 40 | return err | |
| 41 | 41 | } | |
| @@ -49,22 +49,37 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||
| 49 | 49 | return nil | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | + | // Retained refs from the authoritative generation were verified when their | |
| 53 | + | // immutable files were installed. They are intentionally not re-read when a | |
| 54 | + | // producer supplies them again. Every other referenced object is checked | |
| 55 | + | // from disk once, or validated and installed from the supplied bytes. | |
| 56 | + | supplied, err := suppliedNecessaryObjects(refs, currentRefs, publication.Objects) | |
| 57 | + | if err != nil { | |
| 58 | + | return err | |
| 59 | + | } | |
| 52 | 60 | for digest, ref := range refs { | |
| 61 | + | if retainedObject(currentRefs, digest, ref) { | |
| 62 | + | continue | |
| 63 | + | } | |
| 53 | 64 | if err := ctx.Err(); err != nil { | |
| 54 | 65 | return err | |
| 55 | 66 | } | |
| 56 | 67 | path := r.objectPath(key, digest) | |
| 57 | - | exists, err := verifyObjectFile(path, digest, ref) | |
| 68 | + | exists, err := r.verifyObjectFile(path, digest, ref) | |
| 58 | 69 | if err != nil { | |
| 59 | 70 | return fmt.Errorf("verify object %x: %w", digest, err) | |
| 60 | 71 | } | |
| 61 | 72 | if exists { | |
| 62 | 73 | continue | |
| 63 | 74 | } | |
| 64 | - | object, ok := supplied[digest] | |
| 65 | - | if !ok { | |
| 75 | + | objects := supplied[digest] | |
| 76 | + | if len(objects) == 0 { | |
| 66 | 77 | return fmt.Errorf("missing referenced object %x", digest) | |
| 67 | 78 | } | |
| 79 | + | object, err := r.validateSuppliedObject(objects, digest, ref) | |
| 80 | + | if err != nil { | |
| 81 | + | return err | |
| 82 | + | } | |
| 68 | 83 | if r.hooks.beforeBlobWrite != nil { | |
| 69 | 84 | if err := r.hooks.beforeBlobWrite(path); err != nil { | |
| 70 | 85 | return err | |
| @@ -74,7 +89,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||
| 74 | 89 | return err | |
| 75 | 90 | } | |
| 76 | 91 | if err := r.writeImmutable(path, object.Data, func(existing []byte) error { | |
| 77 | - | if sha256.Sum256(existing) != digest || !validObject(existing, ref) { | |
| 92 | + | if r.objectDigest(existing) != digest || !validObject(existing, ref) { | |
| 78 | 93 | return fmt.Errorf("existing immutable object is invalid") | |
| 79 | 94 | } | |
| 80 | 95 | return nil | |
| @@ -123,40 +138,124 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||
| 123 | 138 | } | |
| 124 | 139 | return nil | |
| 125 | 140 | } | |
| 141 | + | ||
| 142 | + | // currentPublication takes the normal HEAD path without loading every object | |
| 143 | + | // in the current generation. A damaged HEAD or manifest still uses the full | |
| 144 | + | // fallback scan, because only Load's object validation can establish a safe | |
| 145 | + | // replacement authoritative generation in that recovery case. | |
| 146 | + | func (r *Repository) currentPublication(ctx context.Context, name, key string) (uint64, []byte, map[ports.SnapshotDigest]codec.ObjectRef, error) { | |
| 147 | + | generation, digest, err := r.readHead(key) | |
| 148 | + | if err == nil { | |
| 149 | + | data, readErr := readBounded(r.manifestPath(key, generation)) | |
| 150 | + | if readErr == nil && sha256.Sum256(data) == digest { | |
| 151 | + | refs, validateErr := validateManifest(data, name, generation) | |
| 152 | + | if validateErr == nil { | |
| 153 | + | return generation, data, refs, nil | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | generation, data, err := r.currentGeneration(ctx, name, key) | |
| 159 | + | if err != nil || generation == 0 { | |
| 160 | + | return generation, data, nil, err | |
| 161 | + | } | |
| 162 | + | refs, err := validateManifest(data, name, generation) | |
| 163 | + | if err != nil { | |
| 164 | + | return 0, nil, nil, err | |
| 165 | + | } | |
| 166 | + | return generation, data, refs, nil | |
| 167 | + | } | |
| 168 | + | ||
| 126 | 169 | func validatePublication(p ports.SnapshotPublication) (map[ports.SnapshotDigest]codec.ObjectRef, map[ports.SnapshotDigest]ports.SnapshotObject, error) { | |
| 127 | - | manifest, err := codec.UnmarshalManifest(p.Manifest) | |
| 170 | + | refs, err := validatePublicationManifest(p) | |
| 171 | + | if err != nil { | |
| 172 | + | return nil, nil, err | |
| 173 | + | } | |
| 174 | + | supplied, err := suppliedNecessaryObjects(refs, nil, p.Objects) | |
| 128 | 175 | if err != nil { | |
| 129 | - | return nil, nil, fmt.Errorf("invalid manifest: %w", err) | |
| 176 | + | return nil, nil, err | |
| 130 | 177 | } | |
| 131 | - | if manifest.Name != p.Name || manifest.Generation != p.Generation || p.Generation == 0 { | |
| 132 | - | return nil, nil, fmt.Errorf("manifest name or generation does not match publication") | |
| 178 | + | objects := make(map[ports.SnapshotDigest]ports.SnapshotObject, len(supplied)) | |
| 179 | + | for digest, entries := range supplied { | |
| 180 | + | object, err := validateSuppliedObject(entries, digest, refs[digest], nil) | |
| 181 | + | if err != nil { | |
| 182 | + | return nil, nil, err | |
| 183 | + | } | |
| 184 | + | objects[digest] = object | |
| 185 | + | } | |
| 186 | + | return refs, objects, nil | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func validatePublicationManifest(p ports.SnapshotPublication) (map[ports.SnapshotDigest]codec.ObjectRef, error) { | |
| 190 | + | return validateManifest(p.Manifest, p.Name, p.Generation) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func validateManifest(data []byte, name string, generation uint64) (map[ports.SnapshotDigest]codec.ObjectRef, error) { | |
| 194 | + | manifest, err := codec.UnmarshalManifest(data) | |
| 195 | + | if err != nil { | |
| 196 | + | return nil, fmt.Errorf("invalid manifest: %w", err) | |
| 197 | + | } | |
| 198 | + | if manifest.Name != name || manifest.Generation != generation || generation == 0 { | |
| 199 | + | return nil, fmt.Errorf("manifest name or generation does not match publication") | |
| 133 | 200 | } | |
| 134 | 201 | refs := manifestRefs(manifest) | |
| 135 | 202 | if refs == nil { | |
| 136 | - | return nil, nil, fmt.Errorf("conflicting manifest references") | |
| 203 | + | return nil, fmt.Errorf("conflicting manifest references") | |
| 137 | 204 | } | |
| 138 | - | if !withinGenerationBudget(len(p.Manifest), refs) { | |
| 139 | - | return nil, nil, fmt.Errorf("snapshot generation too large") | |
| 205 | + | if !withinGenerationBudget(len(data), refs) { | |
| 206 | + | return nil, fmt.Errorf("snapshot generation too large") | |
| 140 | 207 | } | |
| 141 | - | objects := make(map[ports.SnapshotDigest]ports.SnapshotObject, len(p.Objects)) | |
| 142 | - | for _, object := range p.Objects { | |
| 143 | - | if sha256.Sum256(object.Data) != object.Digest { | |
| 144 | - | return nil, nil, fmt.Errorf("object digest mismatch") | |
| 208 | + | return refs, nil | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // suppliedNecessaryObjects first rejects unreferenced input without inspecting | |
| 212 | + | // its payload. Objects retained from currentRefs cannot affect publication and | |
| 213 | + | // are left out so callers do not pay to hash or copy a full history again. | |
| 214 | + | func suppliedNecessaryObjects(refs, currentRefs map[ports.SnapshotDigest]codec.ObjectRef, supplied []ports.SnapshotObject) (map[ports.SnapshotDigest][]ports.SnapshotObject, error) { | |
| 215 | + | out := make(map[ports.SnapshotDigest][]ports.SnapshotObject) | |
| 216 | + | for _, object := range supplied { | |
| 217 | + | if _, used := refs[object.Digest]; !used { | |
| 218 | + | return nil, fmt.Errorf("unreferenced object") | |
| 219 | + | } | |
| 220 | + | if retainedObject(currentRefs, object.Digest, refs[object.Digest]) { | |
| 221 | + | continue | |
| 145 | 222 | } | |
| 146 | - | ref, used := refs[object.Digest] | |
| 147 | - | if !used { | |
| 148 | - | return nil, nil, fmt.Errorf("unreferenced object") | |
| 223 | + | out[object.Digest] = append(out[object.Digest], object) | |
| 224 | + | } | |
| 225 | + | return out, nil | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | func retainedObject(currentRefs map[ports.SnapshotDigest]codec.ObjectRef, digest ports.SnapshotDigest, ref codec.ObjectRef) bool { | |
| 229 | + | currentRef, ok := currentRefs[digest] | |
| 230 | + | return ok && currentRef == ref | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func (r *Repository) validateSuppliedObject(objects []ports.SnapshotObject, digest ports.SnapshotDigest, ref codec.ObjectRef) (ports.SnapshotObject, error) { | |
| 234 | + | return validateSuppliedObject(objects, digest, ref, r) | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func validateSuppliedObject(objects []ports.SnapshotObject, digest ports.SnapshotDigest, ref codec.ObjectRef, repository *Repository) (ports.SnapshotObject, error) { | |
| 238 | + | object := objects[0] | |
| 239 | + | for _, candidate := range objects { | |
| 240 | + | actual := sha256.Sum256(candidate.Data) | |
| 241 | + | if repository != nil { | |
| 242 | + | actual = repository.objectDigest(candidate.Data) | |
| 149 | 243 | } | |
| 150 | - | kind, payload, err := codec.PreflightObject(object.Data) | |
| 151 | - | if err != nil || kind != ref.Kind || len(object.Data) != int(ref.Size) || len(payload) == 0 { | |
| 152 | - | return nil, nil, fmt.Errorf("invalid object envelope") | |
| 244 | + | if actual != candidate.Digest || candidate.Digest != digest { | |
| 245 | + | return ports.SnapshotObject{}, fmt.Errorf("object digest mismatch") | |
| 153 | 246 | } | |
| 154 | - | if old, duplicate := objects[object.Digest]; duplicate && !equalBytes(old.Data, object.Data) { | |
| 155 | - | return nil, nil, fmt.Errorf("conflicting object") | |
| 247 | + | if !equalBytes(candidate.Data, object.Data) { | |
| 248 | + | return ports.SnapshotObject{}, fmt.Errorf("conflicting object") | |
| 156 | 249 | } | |
| 157 | - | objects[object.Digest] = ports.SnapshotObject{Digest: object.Digest, Data: append([]byte(nil), object.Data...)} | |
| 158 | 250 | } | |
| 159 | - | return refs, objects, nil | |
| 251 | + | kind, payload, err := codec.PreflightObject(object.Data) | |
| 252 | + | if err != nil || kind != ref.Kind || len(object.Data) != int(ref.Size) || len(payload) == 0 { | |
| 253 | + | return ports.SnapshotObject{}, fmt.Errorf("invalid object envelope") | |
| 254 | + | } | |
| 255 | + | if repository != nil && repository.hooks.beforeObjectCopy != nil { | |
| 256 | + | repository.hooks.beforeObjectCopy(object.Data) | |
| 257 | + | } | |
| 258 | + | return ports.SnapshotObject{Digest: digest, Data: append([]byte(nil), object.Data...)}, nil | |
| 160 | 259 | } | |
| 161 | 260 | ||
| 162 | 261 | func withinGenerationBudget(manifestSize int, refs map[ports.SnapshotDigest]codec.ObjectRef) bool { | |
| @@ -196,17 +295,28 @@ func manifestRefs(manifest codec.Manifest) map[ports.SnapshotDigest]codec.Object | |||
| 196 | 295 | } | |
| 197 | 296 | return refs | |
| 198 | 297 | } | |
| 298 | + | ||
| 199 | 299 | func validObject(data []byte, ref codec.ObjectRef) bool { | |
| 200 | 300 | kind, payload, err := codec.PreflightObject(data) | |
| 201 | 301 | return err == nil && kind == ref.Kind && len(payload) > 0 && len(data) == int(ref.Size) | |
| 202 | 302 | } | |
| 203 | 303 | ||
| 204 | - | func verifyObjectFile(path string, digest ports.SnapshotDigest, ref codec.ObjectRef) (bool, error) { | |
| 304 | + | func (r *Repository) objectDigest(data []byte) ports.SnapshotDigest { | |
| 305 | + | if r.hooks.beforeObjectHash != nil { | |
| 306 | + | r.hooks.beforeObjectHash(data) | |
| 307 | + | } | |
| 308 | + | return sha256.Sum256(data) | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | func (r *Repository) verifyObjectFile(path string, digest ports.SnapshotDigest, ref codec.ObjectRef) (bool, error) { | |
| 312 | + | if r.hooks.beforeObjectRead != nil { | |
| 313 | + | r.hooks.beforeObjectRead(path) | |
| 314 | + | } | |
| 205 | 315 | data, exists, err := readOptionalBounded(path) | |
| 206 | 316 | if err != nil || !exists { | |
| 207 | 317 | return exists, err | |
| 208 | 318 | } | |
| 209 | - | if sha256.Sum256(data) != digest || !validObject(data, ref) { | |
| 319 | + | if r.objectDigest(data) != digest || !validObject(data, ref) { | |
| 210 | 320 | return true, fmt.Errorf("existing immutable object is invalid") | |
| 211 | 321 | } | |
| 212 | 322 | return true, nil | |
| @@ -21,7 +21,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||||
| 21 | return err | 21 | return err | ||
| 22 | } | 22 | } | ||
| 23 | 23 | ||||
| 24 | - | refs, supplied, err := validatePublication(publication) | 24 | + | refs, err := validatePublicationManifest(publication) |
| 25 | if err != nil { | 25 | if err != nil { | ||
| 26 | return err | 26 | return err | ||
| 27 | } | 27 | } | ||
| @@ -35,7 +35,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||||
| 35 | return err | 35 | return err | ||
| 36 | } | 36 | } | ||
| 37 | 37 | ||||
| 38 | - | current, currentManifest, err := r.currentGeneration(ctx, publication.Name, key) | 38 | + | current, currentManifest, currentRefs, err := r.currentPublication(ctx, publication.Name, key) |
| 39 | if err != nil { | 39 | if err != nil { | ||
| 40 | return err | 40 | return err | ||
| 41 | } | 41 | } | ||
| @@ -49,22 +49,37 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||||
| 49 | return nil | 49 | return nil | ||
| 50 | } | 50 | } | ||
| 51 | 51 | ||||
| 52 | + | // Retained refs from the authoritative generation were verified when their | |||
| 53 | + | // immutable files were installed. They are intentionally not re-read when a | |||
| 54 | + | // producer supplies them again. Every other referenced object is checked | |||
| 55 | + | // from disk once, or validated and installed from the supplied bytes. | |||
| 56 | + | supplied, err := suppliedNecessaryObjects(refs, currentRefs, publication.Objects) | |||
| 57 | + | if err != nil { | |||
| 58 | + | return err | |||
| 59 | + | } | |||
| 52 | for digest, ref := range refs { | 60 | for digest, ref := range refs { | ||
| 61 | + | if retainedObject(currentRefs, digest, ref) { | |||
| 62 | + | continue | |||
| 63 | + | } | |||
| 53 | if err := ctx.Err(); err != nil { | 64 | if err := ctx.Err(); err != nil { | ||
| 54 | return err | 65 | return err | ||
| 55 | } | 66 | } | ||
| 56 | path := r.objectPath(key, digest) | 67 | path := r.objectPath(key, digest) | ||
| 57 | - | exists, err := verifyObjectFile(path, digest, ref) | 68 | + | exists, err := r.verifyObjectFile(path, digest, ref) |
| 58 | if err != nil { | 69 | if err != nil { | ||
| 59 | return fmt.Errorf("verify object %x: %w", digest, err) | 70 | return fmt.Errorf("verify object %x: %w", digest, err) | ||
| 60 | } | 71 | } | ||
| 61 | if exists { | 72 | if exists { | ||
| 62 | continue | 73 | continue | ||
| 63 | } | 74 | } | ||
| 64 | - | object, ok := supplied[digest] | 75 | + | objects := supplied[digest] |
| 65 | - | if !ok { | 76 | + | if len(objects) == 0 { |
| 66 | return fmt.Errorf("missing referenced object %x", digest) | 77 | return fmt.Errorf("missing referenced object %x", digest) | ||
| 67 | } | 78 | } | ||
| 79 | + | object, err := r.validateSuppliedObject(objects, digest, ref) | |||
| 80 | + | if err != nil { | |||
| 81 | + | return err | |||
| 82 | + | } | |||
| 68 | if r.hooks.beforeBlobWrite != nil { | 83 | if r.hooks.beforeBlobWrite != nil { | ||
| 69 | if err := r.hooks.beforeBlobWrite(path); err != nil { | 84 | if err := r.hooks.beforeBlobWrite(path); err != nil { | ||
| 70 | return err | 85 | return err | ||
| @@ -74,7 +89,7 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||||
| 74 | return err | 89 | return err | ||
| 75 | } | 90 | } | ||
| 76 | if err := r.writeImmutable(path, object.Data, func(existing []byte) error { | 91 | if err := r.writeImmutable(path, object.Data, func(existing []byte) error { | ||
| 77 | - | if sha256.Sum256(existing) != digest || !validObject(existing, ref) { | 92 | + | if r.objectDigest(existing) != digest || !validObject(existing, ref) { |
| 78 | return fmt.Errorf("existing immutable object is invalid") | 93 | return fmt.Errorf("existing immutable object is invalid") | ||
| 79 | } | 94 | } | ||
| 80 | return nil | 95 | return nil | ||
| @@ -123,40 +138,124 @@ func (r *Repository) Publish(ctx context.Context, publication ports.SnapshotPubl | |||||
| 123 | } | 138 | } | ||
| 124 | return nil | 139 | return nil | ||
| 125 | } | 140 | } | ||
| 141 | + | ||||
| 142 | + | // currentPublication takes the normal HEAD path without loading every object | |||
| 143 | + | // in the current generation. A damaged HEAD or manifest still uses the full | |||
| 144 | + | // fallback scan, because only Load's object validation can establish a safe | |||
| 145 | + | // replacement authoritative generation in that recovery case. | |||
| 146 | + | func (r *Repository) currentPublication(ctx context.Context, name, key string) (uint64, []byte, map[ports.SnapshotDigest]codec.ObjectRef, error) { | |||
| 147 | + | generation, digest, err := r.readHead(key) | |||
| 148 | + | if err == nil { | |||
| 149 | + | data, readErr := readBounded(r.manifestPath(key, generation)) | |||
| 150 | + | if readErr == nil && sha256.Sum256(data) == digest { | |||
| 151 | + | refs, validateErr := validateManifest(data, name, generation) | |||
| 152 | + | if validateErr == nil { | |||
| 153 | + | return generation, data, refs, nil | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | generation, data, err := r.currentGeneration(ctx, name, key) | |||
| 159 | + | if err != nil || generation == 0 { | |||
| 160 | + | return generation, data, nil, err | |||
| 161 | + | } | |||
| 162 | + | refs, err := validateManifest(data, name, generation) | |||
| 163 | + | if err != nil { | |||
| 164 | + | return 0, nil, nil, err | |||
| 165 | + | } | |||
| 166 | + | return generation, data, refs, nil | |||
| 167 | + | } | |||
| 168 | + | ||||
| 126 | func validatePublication(p ports.SnapshotPublication) (map[ports.SnapshotDigest]codec.ObjectRef, map[ports.SnapshotDigest]ports.SnapshotObject, error) { | 169 | func validatePublication(p ports.SnapshotPublication) (map[ports.SnapshotDigest]codec.ObjectRef, map[ports.SnapshotDigest]ports.SnapshotObject, error) { | ||
| 127 | - | manifest, err := codec.UnmarshalManifest(p.Manifest) | 170 | + | refs, err := validatePublicationManifest(p) |
| 171 | + | if err != nil { | |||
| 172 | + | return nil, nil, err | |||
| 173 | + | } | |||
| 174 | + | supplied, err := suppliedNecessaryObjects(refs, nil, p.Objects) | |||
| 128 | if err != nil { | 175 | if err != nil { | ||
| 129 | - | return nil, nil, fmt.Errorf("invalid manifest: %w", err) | 176 | + | return nil, nil, err |
| 130 | } | 177 | } | ||
| 131 | - | if manifest.Name != p.Name || manifest.Generation != p.Generation || p.Generation == 0 { | 178 | + | objects := make(map[ports.SnapshotDigest]ports.SnapshotObject, len(supplied)) |
| 132 | - | return nil, nil, fmt.Errorf("manifest name or generation does not match publication") | 179 | + | for digest, entries := range supplied { |
| 180 | + | object, err := validateSuppliedObject(entries, digest, refs[digest], nil) | |||
| 181 | + | if err != nil { | |||
| 182 | + | return nil, nil, err | |||
| 183 | + | } | |||
| 184 | + | objects[digest] = object | |||
| 185 | + | } | |||
| 186 | + | return refs, objects, nil | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func validatePublicationManifest(p ports.SnapshotPublication) (map[ports.SnapshotDigest]codec.ObjectRef, error) { | |||
| 190 | + | return validateManifest(p.Manifest, p.Name, p.Generation) | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | func validateManifest(data []byte, name string, generation uint64) (map[ports.SnapshotDigest]codec.ObjectRef, error) { | |||
| 194 | + | manifest, err := codec.UnmarshalManifest(data) | |||
| 195 | + | if err != nil { | |||
| 196 | + | return nil, fmt.Errorf("invalid manifest: %w", err) | |||
| 197 | + | } | |||
| 198 | + | if manifest.Name != name || manifest.Generation != generation || generation == 0 { | |||
| 199 | + | return nil, fmt.Errorf("manifest name or generation does not match publication") | |||
| 133 | } | 200 | } | ||
| 134 | refs := manifestRefs(manifest) | 201 | refs := manifestRefs(manifest) | ||
| 135 | if refs == nil { | 202 | if refs == nil { | ||
| 136 | - | return nil, nil, fmt.Errorf("conflicting manifest references") | 203 | + | return nil, fmt.Errorf("conflicting manifest references") |
| 137 | } | 204 | } | ||
| 138 | - | if !withinGenerationBudget(len(p.Manifest), refs) { | 205 | + | if !withinGenerationBudget(len(data), refs) { |
| 139 | - | return nil, nil, fmt.Errorf("snapshot generation too large") | 206 | + | return nil, fmt.Errorf("snapshot generation too large") |
| 140 | } | 207 | } | ||
| 141 | - | objects := make(map[ports.SnapshotDigest]ports.SnapshotObject, len(p.Objects)) | 208 | + | return refs, nil |
| 142 | - | for _, object := range p.Objects { | 209 | + | } |
| 143 | - | if sha256.Sum256(object.Data) != object.Digest { | 210 | + | |
| 144 | - | return nil, nil, fmt.Errorf("object digest mismatch") | 211 | + | // suppliedNecessaryObjects first rejects unreferenced input without inspecting |
| 212 | + | // its payload. Objects retained from currentRefs cannot affect publication and | |||
| 213 | + | // are left out so callers do not pay to hash or copy a full history again. | |||
| 214 | + | func suppliedNecessaryObjects(refs, currentRefs map[ports.SnapshotDigest]codec.ObjectRef, supplied []ports.SnapshotObject) (map[ports.SnapshotDigest][]ports.SnapshotObject, error) { | |||
| 215 | + | out := make(map[ports.SnapshotDigest][]ports.SnapshotObject) | |||
| 216 | + | for _, object := range supplied { | |||
| 217 | + | if _, used := refs[object.Digest]; !used { | |||
| 218 | + | return nil, fmt.Errorf("unreferenced object") | |||
| 219 | + | } | |||
| 220 | + | if retainedObject(currentRefs, object.Digest, refs[object.Digest]) { | |||
| 221 | + | continue | |||
| 145 | } | 222 | } | ||
| 146 | - | ref, used := refs[object.Digest] | 223 | + | out[object.Digest] = append(out[object.Digest], object) |
| 147 | - | if !used { | 224 | + | } |
| 148 | - | return nil, nil, fmt.Errorf("unreferenced object") | 225 | + | return out, nil |
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | func retainedObject(currentRefs map[ports.SnapshotDigest]codec.ObjectRef, digest ports.SnapshotDigest, ref codec.ObjectRef) bool { | |||
| 229 | + | currentRef, ok := currentRefs[digest] | |||
| 230 | + | return ok && currentRef == ref | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | func (r *Repository) validateSuppliedObject(objects []ports.SnapshotObject, digest ports.SnapshotDigest, ref codec.ObjectRef) (ports.SnapshotObject, error) { | |||
| 234 | + | return validateSuppliedObject(objects, digest, ref, r) | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | func validateSuppliedObject(objects []ports.SnapshotObject, digest ports.SnapshotDigest, ref codec.ObjectRef, repository *Repository) (ports.SnapshotObject, error) { | |||
| 238 | + | object := objects[0] | |||
| 239 | + | for _, candidate := range objects { | |||
| 240 | + | actual := sha256.Sum256(candidate.Data) | |||
| 241 | + | if repository != nil { | |||
| 242 | + | actual = repository.objectDigest(candidate.Data) | |||
| 149 | } | 243 | } | ||
| 150 | - | kind, payload, err := codec.PreflightObject(object.Data) | 244 | + | if actual != candidate.Digest || candidate.Digest != digest { |
| 151 | - | if err != nil || kind != ref.Kind || len(object.Data) != int(ref.Size) || len(payload) == 0 { | 245 | + | return ports.SnapshotObject{}, fmt.Errorf("object digest mismatch") |
| 152 | - | return nil, nil, fmt.Errorf("invalid object envelope") | |||
| 153 | } | 246 | } | ||
| 154 | - | if old, duplicate := objects[object.Digest]; duplicate && !equalBytes(old.Data, object.Data) { | 247 | + | if !equalBytes(candidate.Data, object.Data) { |
| 155 | - | return nil, nil, fmt.Errorf("conflicting object") | 248 | + | return ports.SnapshotObject{}, fmt.Errorf("conflicting object") |
| 156 | } | 249 | } | ||
| 157 | - | objects[object.Digest] = ports.SnapshotObject{Digest: object.Digest, Data: append([]byte(nil), object.Data...)} | |||
| 158 | } | 250 | } | ||
| 159 | - | return refs, objects, nil | 251 | + | kind, payload, err := codec.PreflightObject(object.Data) |
| 252 | + | if err != nil || kind != ref.Kind || len(object.Data) != int(ref.Size) || len(payload) == 0 { | |||
| 253 | + | return ports.SnapshotObject{}, fmt.Errorf("invalid object envelope") | |||
| 254 | + | } | |||
| 255 | + | if repository != nil && repository.hooks.beforeObjectCopy != nil { | |||
| 256 | + | repository.hooks.beforeObjectCopy(object.Data) | |||
| 257 | + | } | |||
| 258 | + | return ports.SnapshotObject{Digest: digest, Data: append([]byte(nil), object.Data...)}, nil | |||
| 160 | } | 259 | } | ||
| 161 | 260 | ||||
| 162 | func withinGenerationBudget(manifestSize int, refs map[ports.SnapshotDigest]codec.ObjectRef) bool { | 261 | func withinGenerationBudget(manifestSize int, refs map[ports.SnapshotDigest]codec.ObjectRef) bool { | ||
| @@ -196,17 +295,28 @@ func manifestRefs(manifest codec.Manifest) map[ports.SnapshotDigest]codec.Object | |||||
| 196 | } | 295 | } | ||
| 197 | return refs | 296 | return refs | ||
| 198 | } | 297 | } | ||
| 298 | + | ||||
| 199 | func validObject(data []byte, ref codec.ObjectRef) bool { | 299 | func validObject(data []byte, ref codec.ObjectRef) bool { | ||
| 200 | kind, payload, err := codec.PreflightObject(data) | 300 | kind, payload, err := codec.PreflightObject(data) | ||
| 201 | return err == nil && kind == ref.Kind && len(payload) > 0 && len(data) == int(ref.Size) | 301 | return err == nil && kind == ref.Kind && len(payload) > 0 && len(data) == int(ref.Size) | ||
| 202 | } | 302 | } | ||
| 203 | 303 | ||||
| 204 | - | func verifyObjectFile(path string, digest ports.SnapshotDigest, ref codec.ObjectRef) (bool, error) { | 304 | + | func (r *Repository) objectDigest(data []byte) ports.SnapshotDigest { |
| 305 | + | if r.hooks.beforeObjectHash != nil { | |||
| 306 | + | r.hooks.beforeObjectHash(data) | |||
| 307 | + | } | |||
| 308 | + | return sha256.Sum256(data) | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | func (r *Repository) verifyObjectFile(path string, digest ports.SnapshotDigest, ref codec.ObjectRef) (bool, error) { | |||
| 312 | + | if r.hooks.beforeObjectRead != nil { | |||
| 313 | + | r.hooks.beforeObjectRead(path) | |||
| 314 | + | } | |||
| 205 | data, exists, err := readOptionalBounded(path) | 315 | data, exists, err := readOptionalBounded(path) | ||
| 206 | if err != nil || !exists { | 316 | if err != nil || !exists { | ||
| 207 | return exists, err | 317 | return exists, err | ||
| 208 | } | 318 | } | ||
| 209 | - | if sha256.Sum256(data) != digest || !validObject(data, ref) { | 319 | + | if r.objectDigest(data) != digest || !validObject(data, ref) { |
| 210 | return true, fmt.Errorf("existing immutable object is invalid") | 320 | return true, fmt.Errorf("existing immutable object is invalid") | ||
| 211 | } | 321 | } | ||
| 212 | return true, nil | 322 | return true, nil | ||
A internal/adapters/snapshot/publish_efficiency_test.go +133 -0
| @@ -0,0 +1,133 @@ | |||
| 1 | + | package snapshot | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "crypto/sha256" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/ports" | |
| 12 | + | codec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestRepositoryPublishSkipsUnchangedTenThousandObjectHistory(t *testing.T) { | |
| 16 | + | const historyObjects = 10_000 | |
| 17 | + | ||
| 18 | + | repo := NewRepository(privateDir(t)) | |
| 19 | + | first, second := largeIncrementalPublications(t, "named", historyObjects) | |
| 20 | + | seedCompletePublication(t, repo, first) | |
| 21 | + | ||
| 22 | + | var reads, hashes, copies int | |
| 23 | + | repo.hooks.beforeObjectRead = func(string) { reads++ } | |
| 24 | + | repo.hooks.beforeObjectHash = func([]byte) { hashes++ } | |
| 25 | + | repo.hooks.beforeObjectCopy = func([]byte) { copies++ } | |
| 26 | + | ||
| 27 | + | if err := repo.Publish(context.Background(), second); err != nil { | |
| 28 | + | t.Fatal(err) | |
| 29 | + | } | |
| 30 | + | // Only the new mutable tail and visible objects need work. The 10k sealed | |
| 31 | + | // objects are retained history supplied again by the producer, not input | |
| 32 | + | // that Publish needs to read, hash, or copy. | |
| 33 | + | if reads != 2 || hashes != 2 || copies != 2 { | |
| 34 | + | t.Fatalf("unchanged 10k history reads/hashes/copies = %d/%d/%d, want 2/2/2", reads, hashes, copies) | |
| 35 | + | } | |
| 36 | + | got, err := repo.Load(context.Background(), second.Name) | |
| 37 | + | if err != nil { | |
| 38 | + | t.Fatal(err) | |
| 39 | + | } | |
| 40 | + | if got.Generation != second.Generation { | |
| 41 | + | t.Fatalf("published generation = %d, want %d", got.Generation, second.Generation) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestRepositoryPublishVerifiesNecessaryExistingObjectOnce(t *testing.T) { | |
| 46 | + | repo := NewRepository(privateDir(t)) | |
| 47 | + | first := repositoryPublication(t, "named", 1, []byte("first")) | |
| 48 | + | if err := repo.Publish(context.Background(), first); err != nil { | |
| 49 | + | t.Fatal(err) | |
| 50 | + | } | |
| 51 | + | second := repositoryPublication(t, "named", 2, []byte("second")) | |
| 52 | + | newTail := second.Objects[0] | |
| 53 | + | key := sessionKey(second.Name) | |
| 54 | + | if err := os.MkdirAll(filepath.Dir(repo.objectPath(key, newTail.Digest)), 0o700); err != nil { | |
| 55 | + | t.Fatal(err) | |
| 56 | + | } | |
| 57 | + | if err := os.WriteFile(repo.objectPath(key, newTail.Digest), newTail.Data, 0o600); err != nil { | |
| 58 | + | t.Fatal(err) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | var reads, hashes, copies int | |
| 62 | + | repo.hooks.beforeObjectRead = func(string) { reads++ } | |
| 63 | + | repo.hooks.beforeObjectHash = func([]byte) { hashes++ } | |
| 64 | + | repo.hooks.beforeObjectCopy = func([]byte) { copies++ } | |
| 65 | + | if err := repo.Publish(context.Background(), second); err != nil { | |
| 66 | + | t.Fatal(err) | |
| 67 | + | } | |
| 68 | + | // The existing new tail is checked from disk once. The unchanged visible | |
| 69 | + | // object is retained from generation one and needs no further work. | |
| 70 | + | if reads != 1 || hashes != 1 || copies != 0 { | |
| 71 | + | t.Fatalf("necessary object reads/hashes/copies = %d/%d/%d, want 1/1/0", reads, hashes, copies) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func largeIncrementalPublications(t *testing.T, name string, count int) (ports.SnapshotPublication, ports.SnapshotPublication) { | |
| 76 | + | t.Helper() | |
| 77 | + | sealed := make([]codec.ObjectRef, 0, count) | |
| 78 | + | objects := make([]ports.SnapshotObject, 0, count+2) | |
| 79 | + | for i := range count { | |
| 80 | + | object, err := codec.MarshalObject(codec.HistoryChunk, []byte(fmt.Sprintf("history-%d", i))) | |
| 81 | + | if err != nil { | |
| 82 | + | t.Fatal(err) | |
| 83 | + | } | |
| 84 | + | sealed = append(sealed, codec.ObjectRef{Kind: codec.HistoryChunk, Digest: object.Digest, Size: uint32(len(object.Data))}) | |
| 85 | + | objects = append(objects, object) | |
| 86 | + | } | |
| 87 | + | makePublication := func(generation uint64, tailPayload, visiblePayload string) ports.SnapshotPublication { | |
| 88 | + | t.Helper() | |
| 89 | + | tail, err := codec.MarshalObject(codec.HistoryTail, []byte(tailPayload)) | |
| 90 | + | if err != nil { | |
| 91 | + | t.Fatal(err) | |
| 92 | + | } | |
| 93 | + | visible, err := codec.MarshalObject(codec.Visible, []byte(visiblePayload)) | |
| 94 | + | if err != nil { | |
| 95 | + | t.Fatal(err) | |
| 96 | + | } | |
| 97 | + | manifest, err := codec.MarshalManifest(codec.Manifest{Generation: generation, Name: name, Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Sealed: sealed, Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}}}) | |
| 98 | + | if err != nil { | |
| 99 | + | t.Fatal(err) | |
| 100 | + | } | |
| 101 | + | return ports.SnapshotPublication{Name: name, Generation: generation, Manifest: manifest, Objects: append(append([]ports.SnapshotObject(nil), objects...), tail, visible)} | |
| 102 | + | } | |
| 103 | + | return makePublication(1, "tail-1", "visible-1"), makePublication(2, "tail-2", "visible-2") | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func seedCompletePublication(t *testing.T, repo *Repository, publication ports.SnapshotPublication) { | |
| 107 | + | t.Helper() | |
| 108 | + | key := sessionKey(publication.Name) | |
| 109 | + | if err := repo.ensureSession(key); err != nil { | |
| 110 | + | t.Fatal(err) | |
| 111 | + | } | |
| 112 | + | manifest, err := codec.UnmarshalManifest(publication.Manifest) | |
| 113 | + | if err != nil { | |
| 114 | + | t.Fatal(err) | |
| 115 | + | } | |
| 116 | + | for _, object := range publication.Objects { | |
| 117 | + | if err := os.MkdirAll(filepath.Dir(repo.objectPath(key, object.Digest)), 0o700); err != nil { | |
| 118 | + | t.Fatal(err) | |
| 119 | + | } | |
| 120 | + | if err := os.WriteFile(repo.objectPath(key, object.Digest), object.Data, 0o600); err != nil { | |
| 121 | + | t.Fatal(err) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | if err := os.WriteFile(repo.manifestPath(key, publication.Generation), publication.Manifest, 0o600); err != nil { | |
| 125 | + | t.Fatal(err) | |
| 126 | + | } | |
| 127 | + | if err := os.WriteFile(repo.headPath(key), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest)), 0o600); err != nil { | |
| 128 | + | t.Fatal(err) | |
| 129 | + | } | |
| 130 | + | if refs := manifestRefs(manifest); refs == nil { | |
| 131 | + | t.Fatal("invalid seeded manifest") | |
| 132 | + | } | |
| 133 | + | } | |
| @@ -0,0 +1,133 @@ | |||||
| 1 | + | package snapshot | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "crypto/sha256" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "testing" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/vev/internal/ports" | |||
| 12 | + | codec "github.com/bnema/vev/internal/usecase/snapshot" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestRepositoryPublishSkipsUnchangedTenThousandObjectHistory(t *testing.T) { | |||
| 16 | + | const historyObjects = 10_000 | |||
| 17 | + | ||||
| 18 | + | repo := NewRepository(privateDir(t)) | |||
| 19 | + | first, second := largeIncrementalPublications(t, "named", historyObjects) | |||
| 20 | + | seedCompletePublication(t, repo, first) | |||
| 21 | + | ||||
| 22 | + | var reads, hashes, copies int | |||
| 23 | + | repo.hooks.beforeObjectRead = func(string) { reads++ } | |||
| 24 | + | repo.hooks.beforeObjectHash = func([]byte) { hashes++ } | |||
| 25 | + | repo.hooks.beforeObjectCopy = func([]byte) { copies++ } | |||
| 26 | + | ||||
| 27 | + | if err := repo.Publish(context.Background(), second); err != nil { | |||
| 28 | + | t.Fatal(err) | |||
| 29 | + | } | |||
| 30 | + | // Only the new mutable tail and visible objects need work. The 10k sealed | |||
| 31 | + | // objects are retained history supplied again by the producer, not input | |||
| 32 | + | // that Publish needs to read, hash, or copy. | |||
| 33 | + | if reads != 2 || hashes != 2 || copies != 2 { | |||
| 34 | + | t.Fatalf("unchanged 10k history reads/hashes/copies = %d/%d/%d, want 2/2/2", reads, hashes, copies) | |||
| 35 | + | } | |||
| 36 | + | got, err := repo.Load(context.Background(), second.Name) | |||
| 37 | + | if err != nil { | |||
| 38 | + | t.Fatal(err) | |||
| 39 | + | } | |||
| 40 | + | if got.Generation != second.Generation { | |||
| 41 | + | t.Fatalf("published generation = %d, want %d", got.Generation, second.Generation) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestRepositoryPublishVerifiesNecessaryExistingObjectOnce(t *testing.T) { | |||
| 46 | + | repo := NewRepository(privateDir(t)) | |||
| 47 | + | first := repositoryPublication(t, "named", 1, []byte("first")) | |||
| 48 | + | if err := repo.Publish(context.Background(), first); err != nil { | |||
| 49 | + | t.Fatal(err) | |||
| 50 | + | } | |||
| 51 | + | second := repositoryPublication(t, "named", 2, []byte("second")) | |||
| 52 | + | newTail := second.Objects[0] | |||
| 53 | + | key := sessionKey(second.Name) | |||
| 54 | + | if err := os.MkdirAll(filepath.Dir(repo.objectPath(key, newTail.Digest)), 0o700); err != nil { | |||
| 55 | + | t.Fatal(err) | |||
| 56 | + | } | |||
| 57 | + | if err := os.WriteFile(repo.objectPath(key, newTail.Digest), newTail.Data, 0o600); err != nil { | |||
| 58 | + | t.Fatal(err) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | var reads, hashes, copies int | |||
| 62 | + | repo.hooks.beforeObjectRead = func(string) { reads++ } | |||
| 63 | + | repo.hooks.beforeObjectHash = func([]byte) { hashes++ } | |||
| 64 | + | repo.hooks.beforeObjectCopy = func([]byte) { copies++ } | |||
| 65 | + | if err := repo.Publish(context.Background(), second); err != nil { | |||
| 66 | + | t.Fatal(err) | |||
| 67 | + | } | |||
| 68 | + | // The existing new tail is checked from disk once. The unchanged visible | |||
| 69 | + | // object is retained from generation one and needs no further work. | |||
| 70 | + | if reads != 1 || hashes != 1 || copies != 0 { | |||
| 71 | + | t.Fatalf("necessary object reads/hashes/copies = %d/%d/%d, want 1/1/0", reads, hashes, copies) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func largeIncrementalPublications(t *testing.T, name string, count int) (ports.SnapshotPublication, ports.SnapshotPublication) { | |||
| 76 | + | t.Helper() | |||
| 77 | + | sealed := make([]codec.ObjectRef, 0, count) | |||
| 78 | + | objects := make([]ports.SnapshotObject, 0, count+2) | |||
| 79 | + | for i := range count { | |||
| 80 | + | object, err := codec.MarshalObject(codec.HistoryChunk, []byte(fmt.Sprintf("history-%d", i))) | |||
| 81 | + | if err != nil { | |||
| 82 | + | t.Fatal(err) | |||
| 83 | + | } | |||
| 84 | + | sealed = append(sealed, codec.ObjectRef{Kind: codec.HistoryChunk, Digest: object.Digest, Size: uint32(len(object.Data))}) | |||
| 85 | + | objects = append(objects, object) | |||
| 86 | + | } | |||
| 87 | + | makePublication := func(generation uint64, tailPayload, visiblePayload string) ports.SnapshotPublication { | |||
| 88 | + | t.Helper() | |||
| 89 | + | tail, err := codec.MarshalObject(codec.HistoryTail, []byte(tailPayload)) | |||
| 90 | + | if err != nil { | |||
| 91 | + | t.Fatal(err) | |||
| 92 | + | } | |||
| 93 | + | visible, err := codec.MarshalObject(codec.Visible, []byte(visiblePayload)) | |||
| 94 | + | if err != nil { | |||
| 95 | + | t.Fatal(err) | |||
| 96 | + | } | |||
| 97 | + | manifest, err := codec.MarshalManifest(codec.Manifest{Generation: generation, Name: name, Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Sealed: sealed, Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}}}) | |||
| 98 | + | if err != nil { | |||
| 99 | + | t.Fatal(err) | |||
| 100 | + | } | |||
| 101 | + | return ports.SnapshotPublication{Name: name, Generation: generation, Manifest: manifest, Objects: append(append([]ports.SnapshotObject(nil), objects...), tail, visible)} | |||
| 102 | + | } | |||
| 103 | + | return makePublication(1, "tail-1", "visible-1"), makePublication(2, "tail-2", "visible-2") | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func seedCompletePublication(t *testing.T, repo *Repository, publication ports.SnapshotPublication) { | |||
| 107 | + | t.Helper() | |||
| 108 | + | key := sessionKey(publication.Name) | |||
| 109 | + | if err := repo.ensureSession(key); err != nil { | |||
| 110 | + | t.Fatal(err) | |||
| 111 | + | } | |||
| 112 | + | manifest, err := codec.UnmarshalManifest(publication.Manifest) | |||
| 113 | + | if err != nil { | |||
| 114 | + | t.Fatal(err) | |||
| 115 | + | } | |||
| 116 | + | for _, object := range publication.Objects { | |||
| 117 | + | if err := os.MkdirAll(filepath.Dir(repo.objectPath(key, object.Digest)), 0o700); err != nil { | |||
| 118 | + | t.Fatal(err) | |||
| 119 | + | } | |||
| 120 | + | if err := os.WriteFile(repo.objectPath(key, object.Digest), object.Data, 0o600); err != nil { | |||
| 121 | + | t.Fatal(err) | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | if err := os.WriteFile(repo.manifestPath(key, publication.Generation), publication.Manifest, 0o600); err != nil { | |||
| 125 | + | t.Fatal(err) | |||
| 126 | + | } | |||
| 127 | + | if err := os.WriteFile(repo.headPath(key), marshalHead(publication.Generation, sha256.Sum256(publication.Manifest)), 0o600); err != nil { | |||
| 128 | + | t.Fatal(err) | |||
| 129 | + | } | |||
| 130 | + | if refs := manifestRefs(manifest); refs == nil { | |||
| 131 | + | t.Fatal("invalid seeded manifest") | |||
| 132 | + | } | |||
| 133 | + | } | |||
M internal/adapters/snapshot/repository.go +8 -2
| @@ -48,8 +48,14 @@ type Repository struct { | |||
| 48 | 48 | // immediately before their respective syscall and are intentionally package | |
| 49 | 49 | // private so production callers cannot weaken repository guarantees. | |
| 50 | 50 | type repositoryHooks struct { | |
| 51 | - | beforeBlobWrite func(string) error | |
| 52 | - | beforeManifestWrite func(string) error | |
| 51 | + | beforeBlobWrite func(string) error | |
| 52 | + | beforeManifestWrite func(string) error | |
| 53 | + | // Object hooks instrument the publication path in package tests. They keep | |
| 54 | + | // the steady-state cost of retained history observable without exposing a | |
| 55 | + | // production metrics surface. | |
| 56 | + | beforeObjectRead func(string) | |
| 57 | + | beforeObjectHash func([]byte) | |
| 58 | + | beforeObjectCopy func([]byte) | |
| 53 | 59 | beforeHeadWrite func(string) error | |
| 54 | 60 | createTemp func(string) error | |
| 55 | 61 | writeTemp func(string) error | |
| @@ -48,8 +48,14 @@ type Repository struct { | |||||
| 48 | // immediately before their respective syscall and are intentionally package | 48 | // immediately before their respective syscall and are intentionally package | ||
| 49 | // private so production callers cannot weaken repository guarantees. | 49 | // private so production callers cannot weaken repository guarantees. | ||
| 50 | type repositoryHooks struct { | 50 | type repositoryHooks struct { | ||
| 51 | - | beforeBlobWrite func(string) error | 51 | + | beforeBlobWrite func(string) error |
| 52 | - | beforeManifestWrite func(string) error | 52 | + | beforeManifestWrite func(string) error |
| 53 | + | // Object hooks instrument the publication path in package tests. They keep | |||
| 54 | + | // the steady-state cost of retained history observable without exposing a | |||
| 55 | + | // production metrics surface. | |||
| 56 | + | beforeObjectRead func(string) | |||
| 57 | + | beforeObjectHash func([]byte) | |||
| 58 | + | beforeObjectCopy func([]byte) | |||
| 53 | beforeHeadWrite func(string) error | 59 | beforeHeadWrite func(string) error | ||
| 54 | createTemp func(string) error | 60 | createTemp func(string) error | ||
| 55 | writeTemp func(string) error | 61 | writeTemp func(string) error | ||