carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package moves
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "time"
9
10 "github.com/carlosframework/platform/internal/lease"
11 "github.com/carlosframework/platform/internal/provision"
12 "github.com/carlosframework/platform/internal/release"
13)
14
15// The readiness checks of §7.3. The target box writes T1 to T13 (T13 the cut
16// guard's, Paul's ruling of 2026-10-11) into its
17// report; the source box writes S1 and S2 into its own. Ready joins them.
18const (
19 CheckProtocol = "T1"
20 CheckSecrets = "T2"
21 CheckEnv = "T3"
22 CheckStorage = "T4"
23 CheckArchive = "T5"
24 CheckDisk = "T6"
25 CheckRelease = "T7"
26 CheckOneOwner = "T8"
27 CheckClaim = "T9"
28 CheckLane = "T10"
29 CheckBudget = "T11"
30 CheckQuiet = "T12"
31 // CheckArmed is the cut guard's T13 (Paul's ruling, 2026-10-11): the
32 // target box can land the move and roll it back, every hook of both
33 // wired, judged when the report is written. It is the report's arming
34 // statement: the source refuses to start a cut unless the target's
35 // latest report states it passing (TargetReport.Armed).
36 CheckArmed = "T13"
37)
38
39// AllChecks lists the target's checks in id order. Passing walks it, so the
40// first failure it names is the lowest-numbered one.
41var AllChecks = []string{CheckProtocol, CheckSecrets, CheckEnv, CheckStorage, CheckArchive, CheckDisk, CheckRelease, CheckOneOwner, CheckClaim, CheckLane, CheckBudget, CheckQuiet, CheckArmed}
42
43// ReportFreshness bounds how old a report may be and still decide ready
44// (§7.3); the cut re-checks it at `admitted` (§7.4).
45const ReportFreshness = 10 * time.Minute
46
47// ReportSkew is how far ahead of the reader's clock a report may be dated
48// (clock skew between boxes). A report dated further ahead is refused, so a
49// wrong or stuck clock cannot keep a report fresh. It is the same bound as
50// MeasurementSkew.
51const ReportSkew = time.Minute
52
53// MeasurementFreshness bounds how old a rate measurement may be and still
54// price a cut (§7.3 T11). MeasurementSkew is how far ahead of the reader's
55// clock a measurement made on another box may be dated.
56const (
57 MeasurementFreshness = time.Hour
58 MeasurementSkew = ReportSkew
59)
60
61// TargetReport is what the target's box publishes at TargetReportKey, every
62// pass (§7.3). Inputs are what its checks were made against; Ready compares
63// them with what the source publishes now. Notes are advisories that do not
64// fail a check (the `shadowed` names of T3).
65type TargetReport struct {
66 V int `json:"v"`
67 Box string `json:"box"`
68 Region string `json:"region"`
69 MoveID string `json:"move_id"`
70 At time.Time `json:"at"`
71 Checks map[string]Check `json:"checks"`
72 Inputs provision.MoveInputs `json:"inputs"`
73 AttemptsAdmitted int `json:"attempts_admitted"`
74 CutBudgetS int `json:"cut_budget_s"`
75 Rates Rates `json:"rates"`
76 // DBMiB, S3At and DiskAt are the evidence behind Rates, so that Ready
77 // can re-price the cut and re-check freshness when it reads the report,
78 // not only when the report was written. Rates.Cross is the source's rate
79 // as the target used it; Ready prices from the source's current one.
80 DBMiB float64 `json:"db_mib"`
81 S3At time.Time `json:"s3_at"`
82 DiskAt time.Time `json:"disk_at"`
83 // Entries is the archive's entry count the Archive phase was priced
84 // for, and FsyncAt when the per-file sync rate (Rates.FsyncRate) was
85 // measured (ruling T7-budget).
86 Entries int `json:"entries"`
87 FsyncAt time.Time `json:"fsync_at"`
88 Notes []string `json:"notes,omitempty"`
89}
90
91// RecordBudget copies a BudgetCheck result and the measurements, database
92// size and entry count it was priced from into the report, the fields Ready
93// re-checks.
94func (r *TargetReport) RecordBudget(dbMiB float64, entries int, m Measurements, res BudgetResult) {
95 r.DBMiB = dbMiB
96 r.Entries = entries
97 r.S3At = m.S3.At
98 r.DiskAt = m.Disk.At
99 r.FsyncAt = m.Fsync.At
100 r.Rates = res.Rates
101 r.AttemptsAdmitted = res.AttemptsAdmitted
102}
103
104// SourceReport is what the source's box publishes at SourceReportKey.
105// Release is the source's live version (release.LiveVersion), the pinned
106// release T7 stages. CrossRate is the cross-region copy rate in MiB/s from
107// the source's most recent copying round, made through the target bucket's
108// region (the path the cut uses), and CrossRateAt is when that round ended.
109type SourceReport struct {
110 V int `json:"v"`
111 Box string `json:"box"`
112 MoveID string `json:"move_id"`
113 At time.Time `json:"at"`
114 Env EnvReport `json:"env"`
115 S1 Check `json:"s1"`
116 S2 Check `json:"s2"`
117 FilesPlain string `json:"files_plain"`
118 Release string `json:"release"`
119 // DBBytes is the size of the source's local database file when one is
120 // present (a hibernating source may have none). T11 prices the larger
121 // of it and the logical size in the newest staged LTX header.
122 DBBytes int64 `json:"db_bytes,omitempty"`
123 // ReplicaBytes is the database's logical size as the source's replica
124 // states it this round: Commit pages of PageSize in the header of the
125 // newest LTX file under the claim's generation, the one the lane
126 // mirrors. Ready refuses while either size exceeds the size the
127 // target's report priced (TargetReport.DBMiB, final review finding 2).
128 ReplicaBytes int64 `json:"replica_bytes,omitempty"`
129 Archive *ArchiveRef `json:"archive,omitempty"`
130 CrossRate float64 `json:"cross_rate"`
131 CrossRateAt time.Time `json:"cross_rate_at"`
132}
133
134// ArchiveRef names the archive the source published, by exact version, so
135// T5 reads the very objects the source wrote. SealedObjectKey is the sealed
136// archive's object key. It is not archive.Manifest's SealedDataKey, which is
137// the key that seals the data inside.
138type ArchiveRef struct {
139 ManifestKey string `json:"manifest_key"`
140 ManifestVersion string `json:"manifest_version"`
141 SealedObjectKey string `json:"sealed_object_key"`
142 SealedVersion string `json:"sealed_version"`
143 Plain string `json:"plain"`
144}
145
146// Pinned reports whether both objects are named by a version id that pins
147// one immutable version (lease.UsableVersionID): not "", and not the "null"
148// a versioning-suspended bucket answers, which the next write to the key
149// overwrites (final review finding 4). A reference that is not pinned is
150// never verified or trusted from a cache.
151func (r ArchiveRef) Pinned() bool {
152 return lease.UsableVersionID(r.ManifestVersion) && lease.UsableVersionID(r.SealedVersion)
153}
154
155// CrossMeasurement is the source's cross-region rate as a measurement. A
156// nil report, or one that has measured nothing, gives the zero measurement,
157// which Measurements.Rates refuses.
158func (s *SourceReport) CrossMeasurement() MeasuredRate {
159 if s == nil {
160 return MeasuredRate{}
161 }
162 return MeasuredRate{MiBPerS: s.CrossRate, At: s.CrossRateAt}
163}
164
165// Armed reports whether the report states that its box can land the move
166// and roll it back (CheckArmed). It fails closed: no report, a report from
167// an older box that has no T13, and a failing T13 are all unarmed.
168func (r *TargetReport) Armed() bool {
169 if r == nil {
170 return false
171 }
172 c, ok := r.Checks[CheckArmed]
173 return ok && c.OK
174}
175
176// Passing reports whether every check passed, naming the first that did not
177// in id order. A check that is missing from the report fails.
178func (r TargetReport) Passing() (bool, string) {
179 for _, id := range AllChecks {
180 c, ok := r.Checks[id]
181 if !ok {
182 return false, id + " was not reported"
183 }
184 if !c.OK {
185 if c.Reason == "" {
186 return false, id + ": did not pass"
187 }
188 return false, id + ": " + c.Reason
189 }
190 }
191 return true, ""
192}
193
194// Ready is §7.3's promotion rule: a round at most the bar, a passing target
195// report and source readiness, each under ReportFreshness old, and at least
196// one probe attempt admitted by the budget (R3-7). It is a pure reading and
197// only ever gates promotion. A later stale report reads false, and nothing
198// here, or in MovePromoteReady, which is legal from preparing only, takes a
199// move back out of ready (Review Focus 5).
200//
201// The target's checks must also have been made against what the source
202// publishes now (round-5 finding 9), its release included: a fresh passing report for archive A
203// must not promote after the source has published archive B. And the budget
204// must have been priced for a final mirror round as large as the bar allows
205// (F28(e)), so raising CARLOS_MOVE_READY_DELTA_BYTES cannot promote on an
206// estimate made under a smaller one. Nor can a database that has grown past
207// the size the target priced (sizeCovered, final review finding 2).
208func Ready(roundDelta, bar int64, target *TargetReport, source *SourceReport, now time.Time) (bool, string) {
209 if roundDelta > bar {
210 return false, fmt.Sprintf("the last round moved %d bytes and the bar is %d", roundDelta, bar)
211 }
212 if target == nil {
213 return false, "the target has not published a readiness report yet"
214 }
215 if age := now.Sub(target.At); age > ReportFreshness {
216 return false, "the target's readiness report is stale"
217 } else if age < -ReportSkew {
218 return false, "the target's readiness report is dated ahead of this clock"
219 }
220 if ok, why := target.Passing(); !ok {
221 return false, why
222 }
223 if target.AttemptsAdmitted < 1 {
224 return false, "T11: the cut budget admits no probe attempt"
225 }
226 priced := target.Rates.MirrorBar
227 if priced == 0 {
228 priced = MirrorBarMiB
229 }
230 if want := BarMiB(bar); !finite(priced) || !(priced >= want) { // written so NaN fails
231 return false, fmt.Sprintf("T11: the cut budget was priced for a final round of %g MiB and the bar is %g MiB", priced, want)
232 }
233 if source == nil {
234 return false, "the source has not published a readiness report yet"
235 }
236 if age := now.Sub(source.At); age > ReportFreshness {
237 return false, "the source's readiness report is stale"
238 } else if age < -ReportSkew {
239 return false, "the source's readiness report is dated ahead of this clock"
240 }
241 if !source.S1.OK {
242 return false, "S1: " + reasonOr(source.S1, "the source did not answer")
243 }
244 if !source.S2.OK {
245 return false, "S2: " + reasonOr(source.S2, "the source could not confirm its spawn state")
246 }
247 if why := budgetEvidence(target, source, bar, now); why != "" {
248 return false, why
249 }
250 if why := sizeCovered(target, source); why != "" {
251 return false, why
252 }
253 if target.Inputs.FilesPlain != source.FilesPlain {
254 return false, fmt.Sprintf("the target checked files %s and the source now publishes %s", target.Inputs.FilesPlain, source.FilesPlain)
255 }
256 if target.Inputs.EnvReport != source.Env.Digest {
257 return false, fmt.Sprintf("the target checked launch environment %s and the source now reports %s", target.Inputs.EnvReport, source.Env.Digest)
258 }
259 // The release promotion pins is the one T7 established on the target,
260 // never one the source moved to since (Task 14 review finding 3).
261 if target.Inputs.Release == "" {
262 return false, "the target has not checked a release yet"
263 }
264 if target.Inputs.Release != source.Release {
265 return false, fmt.Sprintf("the target checked release %s and the source now runs %s", target.Inputs.Release, source.Release)
266 }
267 return true, ""
268}
269
270// budgetEvidence re-prices the cut from the evidence the reports carry, at
271// `now`, and returns why it cannot be trusted, or "". Every rate must be a
272// positive finite number measured within MeasurementFreshness (the target's
273// S3 and disk rates by their report times, the cross-region rate by the
274// source's CrossRateAt), the cut window and database size must be usable,
275// and the recomputed budget must admit an attempt. The stored
276// AttemptsAdmitted may be lower than the recomputation (the reports are
277// refreshed on their own schedules) but never higher: a report cannot claim
278// more attempts than its own evidence buys.
279func budgetEvidence(target *TargetReport, source *SourceReport, bar int64, now time.Time) string {
280 if !usable(target.Rates.Cross) || !usable(target.Rates.S3) || !usable(target.Rates.Disk) || !usable(target.Rates.FsyncRate) {
281 return "T11: the target's report carries a rate that is not a usable number"
282 }
283 m := Measurements{
284 Cross: source.CrossMeasurement(),
285 S3: MeasuredRate{MiBPerS: target.Rates.S3, At: target.S3At},
286 Disk: MeasuredRate{MiBPerS: target.Rates.Disk, At: target.DiskAt},
287 Fsync: FilesRate{PerS: target.Rates.FsyncRate, At: target.FsyncAt},
288 }
289 res := BudgetCheck(target.DBMiB, target.Entries, m, bar, float64(target.CutBudgetS), now)
290 if !res.Check.OK {
291 return "T11: " + res.Check.Reason
292 }
293 if target.AttemptsAdmitted > res.AttemptsAdmitted {
294 return fmt.Sprintf("T11: the target's report claims %d probe attempts and its measurements admit %d", target.AttemptsAdmitted, res.AttemptsAdmitted)
295 }
296 return ""
297}
298
299// sizeCovered refuses a database that has grown past the size the target's
300// report priced (final review finding 2). TargetReport.DBMiB is the size
301// both T11's budget and T6's disk check were made for, and the source's
302// report carries the size now: its local file (DBBytes) and its replica's
303// logical size (ReplicaBytes). A larger one leaves both checks stale even
304// while the report is fresh, so promotion waits for the target's next
305// report, which prices the new size. Equal or smaller is covered.
306func sizeCovered(target *TargetReport, source *SourceReport) string {
307 size := max(source.DBBytes, source.ReplicaBytes)
308 if now := float64(size) / (1 << 20); now > target.DBMiB {
309 return fmt.Sprintf("T11: the database has grown to %g MiB since the target priced %g MiB. waiting for the target's next report", now, target.DBMiB)
310 }
311 return ""
312}
313
314func reasonOr(c Check, fallback string) string {
315 if c.Reason == "" {
316 return fallback
317 }
318 return c.Reason
319}
320
321// ---- measured rates and T11 ----
322
323// MeasuredRate is one throughput measurement, in MiB/s, with when and over
324// how many bytes it was taken.
325type MeasuredRate struct {
326 MiBPerS float64 `json:"mib_per_s"`
327 At time.Time `json:"at"`
328 Bytes int64 `json:"bytes"`
329}
330
331// RateOf is bytes over d as a measurement made at `at`. No bytes or no
332// elapsed time is no measurement (a zero rate), never an infinite one.
333func RateOf(bytes int64, d time.Duration, at time.Time) MeasuredRate {
334 m := MeasuredRate{At: at, Bytes: bytes}
335 if bytes > 0 && d > 0 {
336 m.MiBPerS = float64(bytes) / (1 << 20) / d.Seconds()
337 }
338 return m
339}
340
341// FilesRate is a per-file sync measurement: files synced per second, with
342// when and over how many files it was taken (ruling T7-budget).
343type FilesRate struct {
344 PerS float64 `json:"per_s"`
345 At time.Time `json:"at"`
346 Files int `json:"files"`
347}
348
349// Measurements are the rates T11 prices a cut from. The target measures
350// regional S3, its disk and its per-file sync rate itself. Cross comes from
351// the source's report (SourceReport.CrossMeasurement), as only the source's
352// copying rounds cross the regions.
353type Measurements struct {
354 Cross, S3, Disk MeasuredRate
355 Fsync FilesRate
356}
357
358// BarMiB is a ready-delta bar in bytes as the MiB that Rates.MirrorBar
359// counts. A bar of zero or less is zero, which Estimate prices at the
360// default, never below it.
361func BarMiB(bytes int64) float64 {
362 if bytes <= 0 {
363 return 0
364 }
365 return float64(bytes) / (1 << 20)
366}
367
368// Rates turns the measurements into the rates Estimate takes, with the
369// operator's ready-delta bar (bytes) as the mirror bar. Every rate must be
370// a positive finite number taken in the last MeasurementFreshness; any other
371// is refused, naming which. The worked example's rates are never a fallback.
372func (m Measurements) Rates(now time.Time, barBytes int64) (Rates, error) {
373 for _, c := range []struct {
374 which string
375 m MeasuredRate
376 }{
377 {"the cross-region copy path", m.Cross},
378 {"regional S3", m.S3},
379 {"the disk", m.Disk},
380 } {
381 if !fresh(c.m.MiBPerS, c.m.At, now) {
382 return Rates{}, &NoFreshRateError{Which: c.which}
383 }
384 }
385 if !fresh(m.Fsync.PerS, m.Fsync.At, now) {
386 return Rates{}, &NoFreshRateError{Which: "the disk's per-file sync"}
387 }
388 return Rates{Cross: m.Cross.MiBPerS, S3: m.S3.MiBPerS, Disk: m.Disk.MiBPerS, MirrorBar: BarMiB(barBytes), FsyncRate: m.Fsync.PerS}, nil
389}
390
391// NoFreshRateError is Measurements.Rates refusing a rate: Which names it.
392type NoFreshRateError struct{ Which string }
393
394func (e *NoFreshRateError) Error() string { return NoFreshRate(e.Which) }
395
396// NoFreshRate is T11's sentence for a missing or stale rate (MV-N68).
397func NoFreshRate(which string) string {
398 return fmt.Sprintf("no fresh rate measurement for %s", which)
399}
400
401// fresh is a usable rate measured within MeasurementFreshness of now and no
402// more than MeasurementSkew ahead of it.
403func fresh(rate float64, at, now time.Time) bool {
404 age := now.Sub(at)
405 return !at.IsZero() && usable(rate) && age <= MeasurementFreshness && age >= -MeasurementSkew
406}
407
408// BudgetResult is T11 decided: the check, the attempts the window admits,
409// the rates and the price, for the report to carry.
410type BudgetResult struct {
411 Check Check
412 AttemptsAdmitted int
413 Rates Rates
414 Seconds float64 // the cut priced with one probe attempt
415}
416
417// BudgetCheck is T11 (§7.3): price a cut of a dbMiB database whose archive
418// holds entries files at the measured rates, with barBytes as the final
419// mirror bar, and admit the probe attempts that fit windowSeconds. It fails
420// closed: a missing, stale or unusable measurement, a negative entry count,
421// a database size or window that is not a usable number, or a window that
422// admits no attempt, fails the check with zero attempts admitted.
423func BudgetCheck(dbMiB float64, entries int, m Measurements, barBytes int64, windowSeconds float64, now time.Time) BudgetResult {
424 fail := func(why string) BudgetResult { return BudgetResult{Check: Check{Reason: why}} }
425 if !finite(dbMiB) || dbMiB < 0 {
426 return fail("the database size could not be measured")
427 }
428 if entries < 0 {
429 return fail("the archive's file count could not be measured")
430 }
431 if !finite(windowSeconds) || windowSeconds <= 0 {
432 return fail("the cut window is not a usable number of seconds")
433 }
434 rates, err := m.Rates(now, barBytes)
435 var nf *NoFreshRateError
436 if errors.As(err, &nf) {
437 return fail(NoFreshRate(nf.Which))
438 }
439 if err != nil {
440 return fail("the cut budget could not be priced")
441 }
442 e := Estimate(dbMiB, entries, rates)
443 n := e.AttemptsAdmitted(windowSeconds)
444 need := e.Total(1)
445 res := BudgetResult{AttemptsAdmitted: n, Rates: rates, Seconds: need}
446 if n < 1 {
447 res.Check.Reason = fmt.Sprintf("the cut needs about %.0f seconds with one probe attempt and the window is %.0f seconds", need, windowSeconds)
448 return res
449 }
450 res.Check.OK = true
451 return res
452}
453
454// ---- writing a recurring object ----
455
456// PutReplace writes key whether or not it exists. release.Store.Put with an
457// empty ifMatch is create-only, so a recurring object (a report, the
458// console record) is written by Get for the current etag (absent is "") and
459// then Put with it, retrying once when another writer got in between. A Get
460// that fails for any reason but absence is returned, never written over.
461// Every mutable object the series writes to the deployment bucket goes
462// through here.
463//
464// It has twins: putCAS in internal/edge/certsync.go (three attempts) and
465// status.WriteBox in internal/status/snapshot.go (one retry, a second
466// conflict is returned loudly). Both are the same read-etag-then-If-Match
467// loop with different policies and error wording. Neither fits here:
468// putCAS is unexported in internal/edge, and WriteBox writes a box's own
469// status snapshot to its fixed key, not an arbitrary key and body.
470// This one retries ONCE on conflict, like WriteBox: a report has a single
471// writer per pass, so two conflicts in a row mean something else is writing
472// the key, and the caller sees ErrCASConflict rather than a silent third try.
473func PutReplace(ctx context.Context, s release.Store, key string, body []byte) error {
474 var err error
475 for attempt := 0; attempt < 2; attempt++ {
476 _, etag, gerr := s.Get(ctx, key)
477 switch {
478 case gerr == nil:
479 case errors.Is(gerr, os.ErrNotExist):
480 etag = ""
481 default:
482 return fmt.Errorf("read %s before replacing it: %w", key, gerr)
483 }
484 if _, err = s.Put(ctx, key, body, etag); err == nil {
485 return nil
486 }
487 if !errors.Is(err, release.ErrCASConflict) {
488 break
489 }
490 }
491 return fmt.Errorf("write %s: %w", key, err)
492}
493