carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package ledger
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8
9 "github.com/carlosframework/platform/internal/release"
10)
11
12func ReadManifest(ctx context.Context, st release.Store, sqid, app string) (Manifest, error) {
13 b, _, err := st.Get(ctx, ManifestKey(sqid, app))
14 if err != nil {
15 return Manifest{}, err
16 }
17 var m Manifest
18 if err := json.Unmarshal(b, &m); err != nil {
19 return Manifest{}, err
20 }
21 return m, nil
22}
23
24// WriteManifest replaces the whole manifest: read-etag-then-Put with one
25// retry on a lost race, last-writer-wins (the putOverwrite pattern,
26// internal/status/fleet.go). It is the right call for a caller that HAS the
27// whole intended value — a seeded fixture, a box-side tool writing the set
28// it just computed. A caller changing one ledger's publication wants
29// UpdateManifest instead: replacing the whole value there unpublishes
30// everything the caller didn't happen to name.
31func WriteManifest(ctx context.Context, st release.Store, sqid, app string, m Manifest) error {
32 _, err := UpdateManifest(ctx, st, sqid, app, func(cur *Manifest) error {
33 *cur = m
34 return nil
35 })
36 return err
37}
38
39// manifestAttempts bounds UpdateManifest's CAS retry loop, matching
40// appendAttempts: publish contention is two contributors publishing the same
41// app in the same moment, and four attempts outlast that without spinning.
42const manifestAttempts = 4
43
44// UpdateManifest is the manifest's read-modify-write door: it reads the
45// stored manifest (a missing one is the ZERO value, not an error — the first
46// publish of an app creates it), hands it to apply, and CASes the result
47// back on the etag it read. A lost race re-READS and re-APPLIES rather than
48// re-putting a stale copy, which is the whole point: the manifest is a SET
49// accrued across separate publish calls, so "carbon and prompts published at
50// the same moment" must end with both, not with whichever writer was slower.
51//
52// Contention that outlasts the retries surfaces as release.ErrCASConflict
53// (the console maps it to a retry-worthy 503) — never as a silent no-op.
54//
55// apply may REFUSE: an error from it aborts the attempt BEFORE the Put and is
56// returned untouched, so nothing is written. That is what lets a caller
57// enforce a rule the request alone cannot decide — a cap on the RESULTING set
58// — on whatever manifest each attempt actually read, including the re-read
59// after a lost race, rather than on a snapshot that a concurrent publisher
60// has already moved past.
61func claimRawUpdateManifest(ctx context.Context, st release.Store, sqid, app string,
62 apply func(*Manifest) error) (Manifest, error) {
63 key := ManifestKey(sqid, app)
64 var lastErr error
65 for range manifestAttempts {
66 var m Manifest
67 b, etag, err := st.Get(ctx, key)
68 ifMatch := etag
69 switch {
70 case errors.Is(err, os.ErrNotExist):
71 ifMatch = "" // create-only: no manifest yet
72 case err != nil:
73 return Manifest{}, err
74 default:
75 if err := json.Unmarshal(b, &m); err != nil {
76 return Manifest{}, err
77 }
78 }
79
80 if err := apply(&m); err != nil {
81 return Manifest{}, err
82 }
83 m.Schema = Schema
84 body, err := json.Marshal(m)
85 if err != nil {
86 return Manifest{}, err
87 }
88 if _, err := st.Put(ctx, key, body, ifMatch); err != nil {
89 if errors.Is(err, release.ErrCASConflict) {
90 lastErr = err
91 continue
92 }
93 return Manifest{}, err
94 }
95 return m, nil
96 }
97 return Manifest{}, lastErr
98}
99