package ledger import ( "context" "encoding/json" "errors" "fmt" "os" "time" "github.com/carlosframework/platform/internal/release" ) type Appended struct { Key string `json:"key"` SHA256 string `json:"sha256"` Count int `json:"count"` } // appendAttempts bounds the CAS retry loop. Conflicts are rare (two // writers accruing the same app's ledger in the same moment); four // attempts outlasts any plausible burst without ever spinning. const appendAttempts = 4 // ErrBusy marks the one Append failure that is neither the caller's fault // nor a broken store: too many writers accruing the same chain at once, so // every CAS attempt lost. A caller that retries later succeeds — which is // exactly what a caller cannot work out from an error STRING, hence a // sentinel to match with errors.Is. var ErrBusy = errors.New("ledger: append contention") // Append validates, chains, and writes one entry, advancing HEAD with CAS. // A lost HEAD race re-reads and re-chains (up to 4 attempts). An entry // object orphaned by a lost race is harmless: nothing references it, and // Verify walks only the chain. func claimRawAppend(ctx context.Context, st release.Store, sqid, app, name, writer string, payload json.RawMessage, now func() time.Time) (Appended, error) { if !ValidName(name) { return Appended{}, fmt.Errorf("ledger: invalid ledger name %q", name) } if len(payload) > MaxPayload { return Appended{}, fmt.Errorf("ledger: payload %d bytes exceeds cap %d", len(payload), MaxPayload) } if !json.Valid(payload) { return Appended{}, fmt.Errorf("ledger: payload is not valid JSON") } var lastErr error for range appendAttempts { headKey := HeadKey(sqid, app, name) hb, etag, err := st.Get(ctx, headKey) var head Head exists := err == nil switch { case errors.Is(err, os.ErrNotExist): // First entry: prev fields stay empty, HEAD is create-only. case err != nil: return Appended{}, err default: if err := json.Unmarshal(hb, &head); err != nil { return Appended{}, fmt.Errorf("ledger: %s corrupt: %w", headKey, err) } } ts := now().UTC().Format(time.RFC3339) e := Entry{ Schema: Schema, Ledger: name, Writer: writer, TS: ts, Payload: payload, PrevKey: head.HeadKey, PrevSHA256: head.HeadSHA256, } eb, err := json.Marshal(e) if err != nil { return Appended{}, err } sha := SHA256Hex(eb) key := EntryKey(sqid, app, name, ts, sha) if _, err := st.Put(ctx, key, eb, ""); err != nil { if errors.Is(err, release.ErrCASConflict) { // Same second, same bytes-shape collision: retry re-reads // HEAD and produces a differently-chained entry. lastErr = err continue } return Appended{}, err } nh := Head{Schema: Schema, UpdatedAt: ts, HeadKey: key, HeadSHA256: sha, Count: head.Count + 1} nhb, err := json.Marshal(nh) if err != nil { return Appended{}, err } ifMatch := "" if exists { ifMatch = etag } if _, err := st.Put(ctx, headKey, nhb, ifMatch); err != nil { if errors.Is(err, release.ErrCASConflict) { // Lost the race: another writer advanced HEAD. Our entry // object is now an unreferenced orphan — harmless in a // versioned bucket; Verify walks only the chain. Re-read // and re-chain. lastErr = err continue } return Appended{}, err } return Appended{Key: key, SHA256: sha, Count: nh.Count}, nil } return Appended{}, fmt.Errorf("ledger: append lost %d CAS races: %w: %w", appendAttempts, ErrBusy, lastErr) }