carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package-file doc: control/serial is the deploy signal (carlos-deploy spec
2// §3): an UNSIGNED counter the console bumps after any bucket write a box
3// loop consumes. It carries no authority — it can only trigger verified work
4// sooner — which is why it is the one object in the bucket outside the
5// Ed25519 envelope. Boxes react to any CHANGE, not only increments, so a
6// reset or a lost-increment race can never wedge a box into ignoring bumps.
7package release
8
9import (
10 "context"
11 "encoding/json"
12 "errors"
13 "fmt"
14 "os"
15 "time"
16)
17
18// SerialKey deliberately sits at the bucket top level: nothing that lists or
19// parses app shapes (ChannelsPrefix, appPrefix) ever sees it.
20const SerialKey = "control/serial"
21
22type SerialDoc struct {
23 Schema int `json:"schema"`
24 Serial int64 `json:"serial"`
25 BumpedAt string `json:"bumped_at"`
26}
27
28// ReadSerial returns the current serial, its etag for CAS, and nil for a
29// missing object (zero doc, "" etag) — absence is the ordinary pre-first-bump
30// state, not an error. A present-but-unparseable object also reads as the
31// zero doc WITH its real etag, so the next bump overwrites the garbage.
32func ReadSerial(ctx context.Context, s Store) (SerialDoc, string, error) {
33 body, etag, err := s.Get(ctx, SerialKey)
34 if errors.Is(err, os.ErrNotExist) {
35 return SerialDoc{}, "", nil
36 }
37 if err != nil {
38 return SerialDoc{}, "", err
39 }
40 var doc SerialDoc
41 if json.Unmarshal(body, &doc) != nil {
42 return SerialDoc{}, etag, nil // garbage: recoverable, next bump replaces it
43 }
44 return doc, etag, nil
45}
46
47// BumpSerial increments the serial with a small CAS retry loop. Callers must
48// treat failure as log-only: a lost bump costs latency (the hourly floor
49// still converges everything), never correctness, and must never fail the
50// user-facing write that triggered it.
51func BumpSerial(ctx context.Context, s Store, now time.Time) error {
52 var lastErr error
53 for attempt := 0; attempt < 4; attempt++ {
54 doc, etag, err := ReadSerial(ctx, s)
55 if err != nil {
56 return err
57 }
58 next := SerialDoc{Schema: 1, Serial: doc.Serial + 1, BumpedAt: now.UTC().Format(time.RFC3339)}
59 body, err := json.Marshal(next)
60 if err != nil {
61 return err
62 }
63 // A caller whose context ended stops here, before the write, every
64 // attempt: a store may ignore the context (DirStore does), and a
65 // request that has ended must announce nothing (db region forward,
66 // Astra round 1, P2).
67 if err := ctx.Err(); err != nil {
68 return err
69 }
70 if _, err = s.Put(ctx, SerialKey, body, etag); err == nil {
71 return nil
72 } else if errors.Is(err, ErrCASConflict) {
73 lastErr = err
74 continue // someone else bumped — their change signal serves ours too,
75 // but retry so OUR write's announcement isn't silently coalesced away
76 } else {
77 return err
78 }
79 }
80 return fmt.Errorf("bump %s: lost CAS race %d times: %w", SerialKey, 4, lastErr)
81}
82