| new file mode 100644 |
| index 000000000..7c315b788 |
| --- /dev/null |
| +++ b/internal/release/serial.go |
| @@ -0,0 +1,74 @@ |
| +// Package-file doc: control/serial is the deploy signal (carlos-deploy spec |
| +// §3): an UNSIGNED counter the console bumps after any bucket write a box |
| +// loop consumes. It carries no authority — it can only trigger verified work |
| +// sooner — which is why it is the one object in the bucket outside the |
| +// Ed25519 envelope. Boxes react to any CHANGE, not only increments, so a |
| +// reset or a lost-increment race can never wedge a box into ignoring bumps. |
| +package release |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "time" |
| +) |
| + |
| +// SerialKey deliberately sits at the bucket top level: nothing that lists or |
| +// parses app shapes (ChannelsPrefix, appPrefix) ever sees it. |
| +const SerialKey = "control/serial" |
| + |
| +type SerialDoc struct { |
| + Schema int `json:"schema"` |
| + Serial int64 `json:"serial"` |
| + BumpedAt string `json:"bumped_at"` |
| +} |
| + |
| +// ReadSerial returns the current serial, its etag for CAS, and nil for a |
| +// missing object (zero doc, "" etag) — absence is the ordinary pre-first-bump |
| +// state, not an error. A present-but-unparseable object also reads as the |
| +// zero doc WITH its real etag, so the next bump overwrites the garbage. |
| +func ReadSerial(ctx context.Context, s Store) (SerialDoc, string, error) { |
| + body, etag, err := s.Get(ctx, SerialKey) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return SerialDoc{}, "", nil |
| + } |
| + if err != nil { |
| + return SerialDoc{}, "", err |
| + } |
| + var doc SerialDoc |
| + if json.Unmarshal(body, &doc) != nil { |
| + return SerialDoc{}, etag, nil // garbage: recoverable, next bump replaces it |
| + } |
| + return doc, etag, nil |
| +} |
| + |
| +// BumpSerial increments the serial with a small CAS retry loop. Callers must |
| +// treat failure as log-only: a lost bump costs latency (the hourly floor |
| +// still converges everything), never correctness, and must never fail the |
| +// user-facing write that triggered it. |
| +func BumpSerial(ctx context.Context, s Store, now time.Time) error { |
| + var lastErr error |
| + for attempt := 0; attempt < 4; attempt++ { |
| + doc, etag, err := ReadSerial(ctx, s) |
| + if err != nil { |
| + return err |
| + } |
| + next := SerialDoc{Schema: 1, Serial: doc.Serial + 1, BumpedAt: now.UTC().Format(time.RFC3339)} |
| + body, err := json.Marshal(next) |
| + if err != nil { |
| + return err |
| + } |
| + if _, err = s.Put(ctx, SerialKey, body, etag); err == nil { |
| + return nil |
| + } else if errors.Is(err, ErrCASConflict) { |
| + lastErr = err |
| + continue // someone else bumped — their change signal serves ours too, |
| + // but retry so OUR write's announcement isn't silently coalesced away |
| + } else { |
| + return err |
| + } |
| + } |
| + return fmt.Errorf("bump %s: lost CAS race %d times: %w", SerialKey, 4, lastErr) |
| +} |