carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "log"
9 "os"
10 "strings"
11 "sync"
12 "time"
13
14 "github.com/carlosframework/platform/internal/activator"
15 "github.com/carlosframework/platform/internal/lease"
16 "github.com/carlosframework/platform/internal/moves"
17 "github.com/carlosframework/platform/internal/provision"
18 "github.com/carlosframework/platform/internal/release"
19)
20
21// The pre-copy of an offline move (offline-staging): while the instance
22// keeps serving, one source box (the stager) runs mirror rounds of its
23// replica into the target's staging prefix, so the seal's own round, with
24// the instance offline, copies only what changed since.
25//
26// A stage round is the seal's round with nothing else: the same source
27// (instances/<host>/ in this box's bucket), the same destination
28// (moves/<host>/ in the target's data bucket), the same receipts
29// (move-receipts/<host>.json in this box's bucket) and the same mirror pair.
30// The staging prefix and the receipts are keyed by host, not by move, so
31// the seal finds what the stage copied with no hand-over: lease.MirrorPrefix
32// skips an object only when its ETag equals the source's or a receipt for
33// this exact pair still matches both sides, deletes every staged key the
34// source no longer has (litestream's retention between stage and seal),
35// and copies everything else. Nothing a stage round left behind is trusted
36// beyond that, so a stale prefix is corrected, never sealed as it is.
37//
38// The round holds nothing, fences nothing and takes no lease: it reads the
39// replica (its listing and the copies' reads) and writes only the target's
40// staging prefix and this box's receipts object. The lease, lock.json
41// included, is never touched (MirrorPrefix skips lock.json on both sides).
42//
43// One round runs per stage request: the record's staged_at, which the stage
44// door sets and every later stage moves forward (provision.OfflineRestage).
45// The stager report names the staged_at its round answered
46// (StageRequest), and a pass whose stager report already answers the
47// record's runs nothing, so an idle staging costs one report read a pass and
48// no S3 request; a failed round is retried by running stage again. Rounds
49// stop when the record leaves staging: the pass that runs a round holds the
50// worker's pass lock, so this box's stopped report, which the seal door
51// waits for, is written only after the last round it ran has ended.
52
53// capOfflineStage1 is the edges-file capability a box publishes beside
54// offline-move-1 when it can stage: run the pre-copy as the stager, and
55// clean it up on abort. The console's stage door refuses a region holding a
56// box that does not publish it.
57const capOfflineStage1 = "offline-stage-1"
58
59// offlineStageStep is the stager's step in offline-staging: one round, then
60// the stager report. The stager is the box the stage door pinned on the
61// record (MoveState.Stager); no other box ever writes this move's staging
62// prefix, whatever the fleet looks like now (Astra round 1, finding 2).
63func (w *movesWorker) offlineStageStep(ctx context.Context, s release.Store, rec provision.Record) error {
64 cfg := w.cfg
65 m, host := rec.Move, rec.Host
66 if cfg.Box == "" {
67 return errors.New("offline move: this box has no label to report under")
68 }
69 if m.Stager == "" || m.Stager != cfg.Box {
70 return nil
71 }
72 // The record again, now, under this box's pass lock: the pass's listing
73 // may predate the start, and once the move has left staging no round
74 // ever starts (Astra round 2, finding 1).
75 cur, err := provision.GetRecord(ctx, s, rec.Account, rec.App, rec.Host)
76 if err != nil {
77 return fmt.Errorf("offline move %s: read the record before a stage round: %w", m.ID, err)
78 }
79 if cur.Move == nil || cur.Move.ID != m.ID || cur.Move.State != provision.OfflineStaging {
80 return nil
81 }
82 rec, m = *cur, cur.Move
83 var request time.Time
84 if m.StagedAt != nil {
85 request = m.StagedAt.UTC()
86 }
87 prev, err := w.ownStagerReport(ctx, s, rec)
88 if err != nil {
89 return fmt.Errorf("offline move %s: read the stager report: %w", m.ID, err)
90 }
91 if prev != nil && prev.StageRequest.Equal(request) && (prev.Phase == moves.OfflinePhaseStaged || prev.Phase == moves.OfflinePhaseFailed) {
92 return nil // this request has had its round
93 }
94 r := moves.OfflineReport{Op: m.ID, Host: host, Box: cfg.Box, Role: moves.OfflineRoleStager, Phase: moves.OfflinePhaseStaged, StageRequest: request}
95 p, err := w.offlineStageRound(ctx, s, rec)
96 r.At = cfg.now().UTC()
97 if err != nil {
98 // The progress stays what the last good round found; the error is a
99 // box diagnostic the status view shows as it is.
100 if prev != nil {
101 p = stageProgress{replicaObjects: prev.ReplicaObjects, replicaBytes: prev.ReplicaBytes, stagedObjects: prev.StagedObjects, stagedBytes: prev.StagedBytes}
102 }
103 p.fill(&r)
104 r.Phase, r.StageError = moves.OfflinePhaseFailed, err.Error()
105 return errors.Join(fmt.Errorf("offline move %s: staging %s: %w", m.ID, host, err), writeOfflineReport(ctx, s, r))
106 }
107 p.fill(&r)
108 if p.roundObjects > 0 {
109 log.Printf("moves: %s: offline move %s: staged round: %d object(s) changed, %d bytes copied; %d of %d objects staged", host, m.ID, p.roundObjects, p.roundBytes, p.stagedObjects, p.replicaObjects)
110 }
111 return writeOfflineReport(ctx, s, r)
112}
113
114// stageProgress is what one stage round found.
115type stageProgress struct {
116 replicaObjects, stagedObjects, roundObjects int
117 replicaBytes, stagedBytes, roundBytes int64
118}
119
120func (p stageProgress) fill(r *moves.OfflineReport) {
121 r.ReplicaObjects, r.ReplicaBytes = p.replicaObjects, p.replicaBytes
122 r.StagedObjects, r.StagedBytes = p.stagedObjects, p.stagedBytes
123 r.RoundObjects, r.RoundBytes = p.roundObjects, p.roundBytes
124}
125
126// offlineStageRound is one pre-copy round and the listing that measures it.
127// It writes only to the staging destination the stage fixed on the record
128// (Astra round 2, finding 2), and only while the target still publishes
129// exactly that bucket and region; a target that publishes anything else is
130// refused, never followed. The round's delete phase reads the record again
131// first and stops if the move has left staging (finding 1).
132func (w *movesWorker) offlineStageRound(ctx context.Context, s release.Store, rec provision.Record) (stageProgress, error) {
133 cfg := w.cfg
134 host, m := rec.Host, rec.Move
135 if m.StageBucket == "" {
136 return stageProgress{}, errors.New("the record names no staging bucket")
137 }
138 tgt, err := w.offlineTarget(ctx, s, m)
139 if err != nil {
140 return stageProgress{}, err
141 }
142 if tgt.DataBucket != m.StageBucket || tgt.DataBucketRegion != m.StageRegion {
143 return stageProgress{}, fmt.Errorf("the target now publishes %s (%s), not the staging bucket %s (%s) the stage fixed", tgt.DataBucket, tgt.DataBucketRegion, m.StageBucket, m.StageRegion)
144 }
145 bucket, loc, _, err := cfg.tombstoneAt(host)
146 if err != nil {
147 return stageProgress{}, err
148 }
149 // The activator promotes a host's moves/<host>/ in its OWN bucket over
150 // its replica on every restore (promoteStagedReplica: staging wins). A
151 // staging bucket that is this box's own would have the serving
152 // instance restore from a copy behind its replica, so the round refuses
153 // (the stage door refuses it first).
154 if m.StageBucket == bucket {
155 return stageProgress{}, fmt.Errorf("the staging bucket is this box's own bucket %s; a staged copy there would replace the serving replica on its next wake", bucket)
156 }
157 mirror, err := w.offlineMirror(ctx, m.StageRegion)
158 if err != nil {
159 return stageProgress{}, err
160 }
161 gated := &stageDeleteGate{MirrorStore: mirror, check: func(ctx context.Context) error {
162 cur, err := provision.GetRecord(ctx, s, rec.Account, rec.App, rec.Host)
163 if err != nil {
164 return fmt.Errorf("read the record before the delete phase: %w", err)
165 }
166 if cur.Move == nil || cur.Move.ID != m.ID || cur.Move.State != provision.OfflineStaging {
167 return errors.New("the move left staging during the round; its deletes are not made")
168 }
169 return nil
170 }}
171 srcPrefix := loc + "/"
172 dstPrefix := activator.MovesPrefix(host)
173 receipts := lease.Receipts{Bucket: bucket, Key: activator.MoveReceiptsKey(host)}
174 round, err := lease.MirrorPrefix(ctx, gated, bucket, srcPrefix, m.StageBucket, dstPrefix, receipts)
175 if err != nil {
176 return stageProgress{}, err
177 }
178 source, err := mirror.List(ctx, bucket, srcPrefix)
179 if err != nil {
180 return stageProgress{}, fmt.Errorf("list the replica: %w", err)
181 }
182 staged, err := mirror.List(ctx, m.StageBucket, dstPrefix)
183 if err != nil {
184 return stageProgress{}, fmt.Errorf("list the staged copy: %w", err)
185 }
186 p := stageProgress{roundObjects: round.Changed, roundBytes: round.BytesCopied}
187 for k, o := range source {
188 rel := strings.TrimPrefix(k, srcPrefix)
189 if rel == "lock.json" {
190 continue
191 }
192 p.replicaObjects++
193 p.replicaBytes += o.Size
194 if d, ok := staged[dstPrefix+rel]; ok && d.Size == o.Size {
195 p.stagedObjects++
196 p.stagedBytes += o.Size
197 }
198 }
199 return p, nil
200}
201
202// stageDeleteGate is a stage round's mirror with one check before the
203// delete phase: lease.MirrorPrefix deletes only after every copy has
204// landed, so the first Delete is that phase's start, and check runs there
205// once for all of the round's deletes. Its error fails every delete, so
206// the round deletes nothing.
207type stageDeleteGate struct {
208 lease.MirrorStore
209 check func(ctx context.Context) error
210 once sync.Once
211 err error
212}
213
214func (g *stageDeleteGate) Delete(ctx context.Context, bucket, key string) error {
215 g.once.Do(func() { g.err = g.check(ctx) })
216 if g.err != nil {
217 return g.err
218 }
219 return g.MirrorStore.Delete(ctx, bucket, key)
220}
221
222// offlineStagerStopped is the pinned stager's stop acknowledgement, written
223// in stopping and every later state up to landed, on every pass and every
224// reassert tick, always under this box's pass lock (Astra round 2, finding
225// 1). The record it was handed has left staging, which never comes back,
226// so no stage round starts from here on (offlineStageStep reads the record
227// again before one), and the round that holds the pass lock has ended
228// before this runs. The seal door waits for it, fresh. The progress of the
229// last round is kept.
230func (w *movesWorker) offlineStagerStopped(ctx context.Context, s release.Store, rec provision.Record) error {
231 m := rec.Move
232 if m.StagedAt == nil || m.Stager == "" || m.Stager != w.cfg.Box {
233 return nil
234 }
235 prev, err := w.ownStagerReport(ctx, s, rec)
236 if err != nil {
237 return fmt.Errorf("offline move %s: read the stager report: %w", m.ID, err)
238 }
239 r := moves.OfflineReport{Op: m.ID, Host: rec.Host, Box: w.cfg.Box, Role: moves.OfflineRoleStager, Phase: moves.OfflinePhaseStopped,
240 At: w.cfg.now().UTC(), StageRequest: m.StagedAt.UTC()}
241 if prev != nil {
242 stageProgress{replicaObjects: prev.ReplicaObjects, replicaBytes: prev.ReplicaBytes, stagedObjects: prev.StagedObjects,
243 stagedBytes: prev.StagedBytes, roundObjects: prev.RoundObjects, roundBytes: prev.RoundBytes}.fill(&r)
244 }
245 return writeOfflineReport(ctx, s, r)
246}
247
248// ownStagerReport is this box's stager report of the move, nil when it has
249// written none. One that does not read as a report is an error.
250func (w *movesWorker) ownStagerReport(ctx context.Context, s release.Store, rec provision.Record) (*moves.OfflineReport, error) {
251 key := moves.OfflineReportKey(rec.Host, rec.Move.ID, moves.OfflineRoleStager, w.cfg.Box)
252 if key == "" {
253 return nil, fmt.Errorf("offline move %s: %s has no stager report key", rec.Move.ID, rec.Host)
254 }
255 raw, _, err := s.Get(ctx, key)
256 if errors.Is(err, os.ErrNotExist) {
257 return nil, nil
258 }
259 if err != nil {
260 return nil, err
261 }
262 var r moves.OfflineReport
263 if err := json.Unmarshal(raw, &r); err != nil {
264 return nil, fmt.Errorf("%s: %w", key, err)
265 }
266 return &r, nil
267}
268
269// offlineUnstage is the stager's part of an abort of a staged move. A move
270// that carries staged_at may have staged copies whatever its reports say: a
271// round can land copies and die before its report (Astra round 1, finding
272// 1). So the pinned stager, and only it, deletes the target's staging
273// prefix and the copy receipts and says so in its stager report (phase
274// reverted), once, before its source step reports reverted. Its own last
275// round has ended by then: rounds run only in staging, and both run under
276// this box's pass lock. The console's finish of a staged move waits for
277// that report (finding 2). A move that was never staged does nothing here,
278// so an abort from stopping is what it was. The prefix deleted is this
279// host's in the target's bucket, never this box's own bucket, where a
280// moves/<host>/ prefix is not this move's (the stage round refuses to write
281// there for the same reason).
282func (w *movesWorker) offlineUnstage(ctx context.Context, s release.Store, rec provision.Record) error {
283 cfg := w.cfg
284 m, host := rec.Move, rec.Host
285 if m.StagedAt == nil || m.Stager == "" || m.Stager != cfg.Box {
286 return nil
287 }
288 own, err := w.ownStagerReport(ctx, s, rec)
289 if err != nil {
290 return fmt.Errorf("offline move %s: read the stager report: %w", m.ID, err)
291 }
292 if own != nil && own.Phase == moves.OfflinePhaseReverted {
293 return nil
294 }
295 // The destination the stage fixed on the record, never what the
296 // target publishes now (Astra round 2, finding 2): reverted is written
297 // only after that prefix is deleted. A stored bucket that is this box's
298 // own was refused at the stage, so it is never deleted from here, and
299 // nothing is acknowledged.
300 if m.StageBucket == "" {
301 return fmt.Errorf("offline move %s: the record names no staging bucket to clean", m.ID)
302 }
303 if m.StageBucket == cfg.OwnBucket {
304 return fmt.Errorf("offline move %s: the stored staging bucket %s is this box's own; nothing is deleted or acknowledged", m.ID, m.StageBucket)
305 }
306 mirror, err := w.offlineMirror(ctx, m.StageRegion)
307 if err != nil {
308 return err
309 }
310 prefix := activator.MovesPrefix(host)
311 if prefix == "moves/" || !strings.HasPrefix(prefix, "moves/") {
312 return fmt.Errorf("offline move %s: no staging prefix for %s", m.ID, host)
313 }
314 if err := lease.DeletePrefix(ctx, mirror, m.StageBucket, prefix); err != nil {
315 return fmt.Errorf("offline move %s: delete %s/%s: %w", m.ID, m.StageBucket, prefix, err)
316 }
317 log.Printf("moves: %s: offline move %s: the staged copy in %s/%s is deleted", host, m.ID, m.StageBucket, prefix)
318 clearOfflineReceipts(ctx, mirror, cfg.OwnBucket, host)
319 r := moves.OfflineReport{Op: m.ID, Host: host, Box: cfg.Box, Role: moves.OfflineRoleStager, Phase: moves.OfflinePhaseReverted, At: cfg.now().UTC()}
320 return writeOfflineReport(ctx, s, r)
321}
322