package ledger import ( "context" "encoding/json" "errors" "os" "github.com/carlosframework/platform/internal/release" ) func ReadManifest(ctx context.Context, st release.Store, sqid, app string) (Manifest, error) { b, _, err := st.Get(ctx, ManifestKey(sqid, app)) if err != nil { return Manifest{}, err } var m Manifest if err := json.Unmarshal(b, &m); err != nil { return Manifest{}, err } return m, nil } // WriteManifest replaces the whole manifest: read-etag-then-Put with one // retry on a lost race, last-writer-wins (the putOverwrite pattern, // internal/status/fleet.go). It is the right call for a caller that HAS the // whole intended value — a seeded fixture, a box-side tool writing the set // it just computed. A caller changing one ledger's publication wants // UpdateManifest instead: replacing the whole value there unpublishes // everything the caller didn't happen to name. func WriteManifest(ctx context.Context, st release.Store, sqid, app string, m Manifest) error { _, err := UpdateManifest(ctx, st, sqid, app, func(cur *Manifest) error { *cur = m return nil }) return err } // manifestAttempts bounds UpdateManifest's CAS retry loop, matching // appendAttempts: publish contention is two contributors publishing the same // app in the same moment, and four attempts outlast that without spinning. const manifestAttempts = 4 // UpdateManifest is the manifest's read-modify-write door: it reads the // stored manifest (a missing one is the ZERO value, not an error — the first // publish of an app creates it), hands it to apply, and CASes the result // back on the etag it read. A lost race re-READS and re-APPLIES rather than // re-putting a stale copy, which is the whole point: the manifest is a SET // accrued across separate publish calls, so "carbon and prompts published at // the same moment" must end with both, not with whichever writer was slower. // // Contention that outlasts the retries surfaces as release.ErrCASConflict // (the console maps it to a retry-worthy 503) — never as a silent no-op. // // apply may REFUSE: an error from it aborts the attempt BEFORE the Put and is // returned untouched, so nothing is written. That is what lets a caller // enforce a rule the request alone cannot decide — a cap on the RESULTING set // — on whatever manifest each attempt actually read, including the re-read // after a lost race, rather than on a snapshot that a concurrent publisher // has already moved past. func claimRawUpdateManifest(ctx context.Context, st release.Store, sqid, app string, apply func(*Manifest) error) (Manifest, error) { key := ManifestKey(sqid, app) var lastErr error for range manifestAttempts { var m Manifest b, etag, err := st.Get(ctx, key) ifMatch := etag switch { case errors.Is(err, os.ErrNotExist): ifMatch = "" // create-only: no manifest yet case err != nil: return Manifest{}, err default: if err := json.Unmarshal(b, &m); err != nil { return Manifest{}, err } } if err := apply(&m); err != nil { return Manifest{}, err } m.Schema = Schema body, err := json.Marshal(m) if err != nil { return Manifest{}, err } if _, err := st.Put(ctx, key, body, ifMatch); err != nil { if errors.Is(err, release.ErrCASConflict) { lastErr = err continue } return Manifest{}, err } return m, nil } return Manifest{}, lastErr }