| index ec7d5d65e..e048cef86 100644 |
| --- a/internal/status/snapshot.go |
| +++ b/internal/status/snapshot.go |
| @@ -10,6 +10,8 @@ import ( |
| "context" |
| "crypto/sha256" |
| "encoding/json" |
| + "errors" |
| + "os" |
| "time" |
| |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -208,20 +210,56 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box { |
| } |
| } |
| |
| -// WriteBox marshals a Box indented (human-diffable in the bucket) and Puts it |
| -// at its label-derived key with ifMatch "", per this task's contract. Note |
| -// for whoever wires the recurring publish loop (Task 3+): release.Store's |
| -// ifMatch "" is create-only (If-None-Match: *) per store.go, so a second |
| -// WriteBox for the same label — expected every 5 minutes per the plan — will |
| -// return ErrCASConflict once the object exists. Overwriting on a schedule |
| -// needs either a Get-then-Put-with-etag retry loop or a Delete-then-Put |
| -// around this call; WriteBox itself stays a thin, single-attempt Put so its |
| -// behavior is exactly what Task 2's contract specifies. |
| +// WriteBox marshals a Box indented (human-diffable in the bucket) and writes |
| +// it to its label-derived key, last-writer-wins. Each box republishes its own |
| +// snapshot every 5 minutes to the SAME key, so a bare create-only Put (ifMatch |
| +// "") would fail every tick after the first with ErrCASConflict — that's the |
| +// bug this retry loop exists to avoid. The read-etag-then-Put dance is the |
| +// only way to express "replace whatever is there, or create it" on top of |
| +// release.Store's CAS-only Put: Get the current etag (a not-found just means |
| +// "absent", so ifMatch stays "" for a create), Put with that etag, and if the |
| +// Put itself loses the race (something else wrote in between), re-read once |
| +// and retry with the fresh etag. A second conflict is NOT retried again and |
| +// is returned to the caller: this box is the only writer that should ever be |
| +// targeting its own label's key, so two conflicts in a row means something |
| +// else collided on this label (clock-skewed duplicate machine id, a bug |
| +// elsewhere) — that deserves a loud failure, not a silent third attempt. |
| func WriteBox(ctx context.Context, s release.Store, b Box) error { |
| body, err := json.MarshalIndent(b, "", " ") |
| if err != nil { |
| return err |
| } |
| - _, err = s.Put(ctx, "status/boxes/"+b.Label+".json", body, "") |
| - return err |
| + key := "status/boxes/" + b.Label + ".json" |
| + |
| + etag, err := currentEtag(ctx, s, key) |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := s.Put(ctx, key, body, etag); err != nil { |
| + if !errors.Is(err, release.ErrCASConflict) { |
| + return err |
| + } |
| + etag, err = currentEtag(ctx, s, key) |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := s.Put(ctx, key, body, etag); err != nil { |
| + return err |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// currentEtag reads a key's etag for a replace-only Put, folding "doesn't |
| +// exist yet" into "" (create-only) — the same absent-means-empty-ifMatch |
| +// convention release.Store's own CAS contract uses. |
| +func currentEtag(ctx context.Context, s release.Store, key string) (string, error) { |
| + _, etag, err := s.Get(ctx, key) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + return "", nil |
| + } |
| + return "", err |
| + } |
| + return etag, nil |
| } |